chat_javi/server.py

322 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
╔══════════════════════════════════════╗
║ PSP Chat Server TCP puro ║
║ Puerto por defecto: 9000 ║
╚══════════════════════════════════════╝
Protocolo simplificado (todo el tráfico es texto plano):
- El primer mensaje que envía el cliente se considera el nick con el que
se va a unir al chat. No hace falta JSON; basta con escribir el nombre
y pulsar Enter.
- A partir de entonces cada línea que llegue se considera un mensaje
público que se retransmite a **la sala general**.
- No existen mensajes privados ni salas adicionales; todo el mundo ve
todo.
Salidas del servidor (conservadas por compatibilidad interna):
{"type":"welcome","nick":"Alice","rooms":["general"],"ts":"..."}
{"type":"msg","room":"general","from":"Bob","text":"Hola","ts":"..."}
{"type":"event","text":"Alice se ha unido al chat","room":"general","ts":"..."}
{"type":"users","users":["Alice","Bob"],"ts":"..."}
{"type":"error","text":"..."}
"""
# ── Importaciones ────────────────────────────────────────────────────────────
import socket # Para crear el servidor TCP y aceptar conexiones de red
import threading # Para atender varios clientes a la vez (un hilo por cliente)
import json # Para convertir diccionarios Python a texto JSON y viceversa
import logging # Para mostrar mensajes informativos en la consola del servidor
from datetime import datetime # Para añadir la hora actual a cada mensaje
# ── Configuración del sistema de logs (mensajes en consola) ───────────────────
# Cada línea mostrará: hora [NIVEL] mensaje
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%H:%M:%S'
)
log = logging.getLogger('ChatServer')
# ── Configuración de red ──────────────────────────────────────────────────────
HOST = '0.0.0.0' # Escuchar en todas las interfaces de red del equipo
PORT = 9000 # Puerto TCP en el que esperamos conexiones
# ── Estado compartido entre todos los hilos ───────────────────────────────────
# Un Lock es un "candado" que evita que dos hilos modifiquen los datos
# al mismo tiempo y provoquen errores (race conditions).
lock = threading.Lock()
# Diccionario de clientes conectados: clave=nick, valor=socket y metadatos
# Ejemplo: {'Alice': {'sock': <socket>, 'addr': ('192.168.1.5', 52341), 'rooms': {'general'}}}
clients = {}
# Salas disponibles: clave=nombre_sala, valor=conjunto de nicks dentro de esa sala
rooms = {'general': set(), 'offtopic': set()}
def now():
"""Devuelve la hora actual en formato HH:MM para añadirla a los mensajes."""
return datetime.now().strftime('%H:%M')
# ── Funciones de envío ───────────────────────────────────────────────────────
def send(sock, data: dict):
"""
Envía UN mensaje JSON a un socket concreto.
Convierte el diccionario a texto JSON, añade '\n' como separador
(el receptor sabrá que el mensaje terminó al ver el salto de línea)
y lo manda por la red en UTF-8. Ignora errores si el cliente se desconectó.
"""
try:
# json.dumps → convierte dict a string | + '\n' → separador de mensajes
# .encode('utf-8') → convierte el texto a bytes para enviarlo por la red
sock.sendall((json.dumps(data, ensure_ascii=False) + '\n').encode('utf-8'))
except Exception:
pass # Si el cliente se cortó, ignoramos el error
def broadcast_room(room: str, data: dict, exclude=None):
"""
Envía un mensaje a TODOS los usuarios dentro de una sala.
Parámetros:
- room: nombre de la sala destino (ej: 'general')
- data: el mensaje como diccionario Python
- exclude: nick que NO recibirá el mensaje (normalmente el propio emisor)
"""
# Copiamos la lista de miembros dentro del candado para evitar cambios mientras iteramos
with lock:
members = list(rooms.get(room, set()))
for nick in members:
if nick == exclude:
continue # No enviamos el mensaje al propio emisor
with lock:
c = clients.get(nick)
if c:
send(c['sock'], data)
def broadcast_all(data: dict, exclude=None):
"""
Envía un mensaje a TODOS los clientes conectados al servidor,
independientemente de la sala. Se usa para actualizar la lista de usuarios.
"""
with lock:
nicks = list(clients.keys())
for nick in nicks:
if nick == exclude:
continue
with lock:
c = clients.get(nick)
if c:
send(c['sock'], data)
# ── Manejador de cada cliente ─────────────────────────────────────────────────
def handle_client(sock: socket.socket, addr):
"""
Se ejecuta en un hilo separado para cada cliente que se conecta.
Flujo:
1. Espera el primer mensaje (el nick).
2. Valida el nick y comprueba que no esté repetido.
3. Registra al cliente, manda bienvenida y avisa al resto.
4. Bucle infinito: lee mensajes y los retransmite a la sala general.
5. Al desconectarse, limpia todo y notifica al resto del chat.
"""
nick = None # Nombre del usuario (se asigna tras validar el primer mensaje)
buf = '' # Buffer: acumula bytes hasta tener una línea completa ('\n')
try:
# ── PASO 1: Recibir el primer mensaje (el nick) ───────────────────────
# Timeout de 30 s: si no llega el nick en ese tiempo, cerramos la conexión.
sock.settimeout(30)
raw = ''
# TCP puede partir los datos; seguimos leyendo hasta encontrar '\n'
while '\n' not in raw:
chunk = sock.recv(1024).decode('utf-8', errors='replace')
if not chunk:
return # El cliente cerró antes de enviar nada
raw += chunk
# Separamos la primera línea del resto del buffer
line, buf = raw.split('\n', 1)
# ── PASO 2: Interpretar el primer mensaje ─────────────────────────────
# Admitimos dos formatos:
# - JSON: {"type":"join","nick":"Alice"} (cliente web)
# - Texto plano: "Alice" (cliente de terminal)
try:
msg = json.loads(line) # Intentamos parsear como JSON
except json.JSONDecodeError:
# No es JSON → asumimos que es el nick directamente en texto plano
requested = line.strip()[:20]
msg = {'type': 'join', 'nick': requested}
else:
# Es JSON, pero verificamos que sea un mensaje de tipo 'join'
if not isinstance(msg, dict) or msg.get('type') != 'join':
requested = line.strip()[:20]
msg = {'type': 'join', 'nick': requested}
else:
requested = str(msg.get('nick', '')).strip()[:20]
# ── PASO 3: Validar el nick ───────────────────────────────────────────
# Solo letras, números y guion bajo; máximo 20 caracteres
if not requested or not requested.replace('_', '').isalnum():
send(sock, {'type': 'error', 'text': 'Nick inválido (solo letras, números, _)'})
return
# El Lock evita que dos clientes elijan el mismo nick al mismo tiempo
with lock:
if requested in clients:
send(sock, {'type': 'error', 'text': 'Nick ya en uso'})
return
# Registrar al cliente en el estado global
nick = requested
clients[nick] = {'sock': sock, 'addr': addr, 'rooms': {'general'}}
rooms['general'].add(nick) # Añadir a la sala general
log.info(f'{nick} conectado desde {addr}')
sock.settimeout(None) # Sin límite de tiempo a partir de aquí
# ── PASO 4: Enviar bienvenida y notificar al resto ────────────────────
with lock:
room_list = list(rooms.keys())
# Mensaje solo para el recién llegado: confirma su nick y le da las salas
send(sock, {'type': 'welcome', 'nick': nick, 'rooms': room_list, 'ts': now()})
# A todos los demás: evento informativo de que alguien entró
broadcast_room('general', {
'type': 'event', 'room': 'general',
'text': f'{nick} se ha unido al chat', 'ts': now()
}, exclude=nick)
# Actualizar la lista de usuarios online para todos
broadcast_users()
# ── PASO 5: Bucle principal leer y retransmitir mensajes ────────────
# A partir de aquí, cada línea recibida es un mensaje de chat.
while True:
chunk = sock.recv(4096).decode('utf-8', errors='replace')
if not chunk:
break # El cliente cerró la conexión → salimos
buf += chunk # Acumulamos en el buffer
# Procesamos todas las líneas completas del buffer
while '\n' in buf:
line, buf = buf.split('\n', 1)
text = line.strip()
if not text:
continue # Ignoramos líneas vacías
log.info(f'[general] {nick}: {text}')
# Reenviar el mensaje a todos en la sala general
broadcast_room('general', {
'type': 'msg',
'room': 'general',
'from': nick,
'text': text,
'ts': now()
})
except (ConnectionResetError, BrokenPipeError, OSError):
# Desconexión abrupta (cerró la app, se cortó la red, etc.)
pass
finally:
# ── LIMPIEZA: se ejecuta SIEMPRE, haya error o no ────────────────────
if nick:
with lock:
clients.pop(nick, None) # Quitar del diccionario de clientes
for r in rooms.values():
r.discard(nick) # Quitar de todas las salas
log.info(f'{nick} desconectado')
# Avisar a todos de que este usuario se fue
broadcast_room('general', {
'type': 'event', 'room': 'general',
'text': f'{nick} ha abandonado el chat', 'ts': now()
})
# Enviar lista de usuarios actualizada (sin este nick)
broadcast_users()
sock.close() # Liberar el socket y recursos del sistema operativo
# ── Funciones auxiliares de usuarios ─────────────────────────────────────────
def broadcast_users():
"""
Recoge la lista de todos los nicks conectados y la envía a TODOS los clientes.
Se llama cada vez que alguien entra o sale para que el sidebar se actualice.
"""
with lock:
user_list = list(clients.keys())
broadcast_all({'type': 'users', 'users': user_list, 'ts': now()})
def broadcast_users_to(nick, sock):
"""
Envía la lista de usuarios únicamente a un cliente concreto.
(Función auxiliar reservada para uso futuro.)
"""
with lock:
user_list = list(clients.keys())
send(sock, {'type': 'users', 'users': user_list, 'ts': now()})
# ── Punto de entrada principal ────────────────────────────────────────────────
def main():
"""
Arranca el servidor TCP:
1. Crea el socket del servidor.
2. Lo asocia a HOST:PORT.
3. Se pone a escuchar conexiones entrantes.
4. Por cada cliente, lanza un hilo independiente con handle_client().
"""
# AF_INET = IPv4 | SOCK_STREAM = TCP (orientado a conexión, fiable, ordenado)
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_REUSEADDR permite reutilizar el puerto inmediatamente al reiniciar
# (sin esto habría que esperar ~1 minuto entre reinicios)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((HOST, PORT)) # Asociar el socket a la IP y puerto configurados
srv.listen(100) # Cola de hasta 100 conexiones pendientes de aceptar
log.info(f'╔══════════════════════════════════╗')
log.info(f'║ PSP Chat Server listo ║')
log.info(f'║ Escuchando en {HOST}:{PORT}')
log.info(f'╚══════════════════════════════════╝')
try:
# Bucle infinito: el servidor siempre está listo para nuevos clientes
while True:
# .accept() se bloquea aquí hasta que alguien se conecta
# Devuelve el socket del cliente y su dirección (IP, puerto)
sock, addr = srv.accept()
log.info(f'Nueva conexión desde {addr}')
# Creamos un hilo separado para este cliente.
# daemon=True → el hilo muere automáticamente si el programa principal termina.
t = threading.Thread(target=handle_client, args=(sock, addr), daemon=True)
t.start()
except KeyboardInterrupt:
# Ctrl+C → apagar el servidor limpiamente
log.info('Servidor detenido')
finally:
srv.close() # Liberar el puerto del sistema operativo
# Solo arrancamos el servidor si ejecutamos este archivo directamente
# (no si se importa como módulo desde otro script)
if __name__ == '__main__':
main()