322 lines
14 KiB
Python
322 lines
14 KiB
Python
#!/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()
|