#!/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': , '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()