From a1cd5613e437b3c4ad41361d6ea8e7cdf8969492 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 23 Feb 2026 20:16:19 +0100 Subject: [PATCH] first --- __pycache__/server.cpython-313.pyc | Bin 0 -> 12455 bytes client_web.py | 265 ++++++++++ requirements.txt | 3 + server.py | 321 +++++++++++++ templates/index.html | 747 +++++++++++++++++++++++++++++ 5 files changed, 1336 insertions(+) create mode 100644 __pycache__/server.cpython-313.pyc create mode 100644 client_web.py create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 templates/index.html diff --git a/__pycache__/server.cpython-313.pyc b/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a962277efab8d3c60c0cfb1d4344f87400c4d99b GIT binary patch literal 12455 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�HuCYSp@><(xHrX$?1j?Zj z0p7C-_ruhK{yG#MSRCd~|FTsdQD6>}aMtgwI|c+pOP;6Vo)NhE%pUkPwp%mOy$9^P z==SH&V2I3~r!XQyw+u9vL3b~(@w8%3x~8kRW6XqMmA!}ovwwh}if+$L$iq{4z5*B5 zmuu%d4dYfC^7t)RGP!=*|0DMg%VtX-dxv{m4`isw90V|o5OlQ#+~(T?78sPK;{H1* zJTP(J5%$W3V=*Xx*l-ttFT0OFBILO~BXFf3NWLT3lo2iZT{5C&GrMfu3M(RHDsl@O zrOYO&v|!uVpG+vIjz2(1)P;M=+cNH%I5Bk+4eM<2!|!m96iOVz+$LMVV@8bGl$CfB zP?9h4U=}1-@Z|-cSGsBu&VsIV1wnVG$mnXraSKvjZ#>}?q`bG;karFQ-0>($NYs%B z;gG@<#@iJ%g|in7x8#LvfF?YM6{P%-SQrmWGzGH`B)x?YA@2kI2*`X_)V*;Ub5ui! z$&{lUYUa&G-9-m3t$X9K3})!o^v*@d3=C;Eln zU}xX}J>w%lVi@k72|fG!@g)E`gr5FjXGrJ_baVtYZXhN{HEwT@(5rC=dxE{138g8? z%5I=i*#nto5+itBNwX%8DH7adl#gnrURtDQT0FPI4NR@%3F+iO0{jDAco|Vi4JVZ@ z=niffR#irFhi#2XsvNw)8OW>w+>jWPm1m&sP57x53|jp~o9lG**P73pE)<!ITt=V_jYl*WO zJ#a2hS9vc~pR0b^durc!$D*Sc579Ff&2-;iwq@P{`~Ip07yq`4f8BYp_u~GUPlsk* zhsFbGhx7EFQ+vMNbp_AKAHwd{u?qLZ^@|oe9*<^9*1l_5hqtxrZ^7{e&P>)**5~aX zji7e72H?^94Uh_@bGkX@9xp8ppE+h2Bws% z#OJZA0-46`$D3>Prpa1ttiudV8(sI616ZoT480y1PZ372snTRNR=6zFc}HsTQYFxb z>lnQjn9&W(!$XN^DlR>$d<9;BW)M|_3}nPGSGY2UyVgROqF)i$uZaCur0BoNs`rU` zfwpm=CLMF8bz|)NX4e>(HrvNIkhi7e z*)M+ii*x1*XtFuR%$LinUXWgMf2VBL_Ng)BRgPhrmMk3Ooanty;Bm!aWqgzFB?6DB zj{n8Sm2zz2XnCo!pJB=-O&6T!oYx7KukL4ACcs>^vCN}a9d5?6)JQnT_~;y4b(yn# Nb@viWxN6G#{{sdJ-U|Q# literal 0 HcmV?d00001 diff --git a/client_web.py b/client_web.py new file mode 100644 index 0000000..50adab0 --- /dev/null +++ b/client_web.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +PSP Chat – Cliente Web +Flask + Socket.IO que hace de PUENTE entre el navegador y el servidor TCP. + +Arquitectura: + Navegador ⇄ Socket.IO (WebSocket) ⇄ client_web.py ⇄ TCP ⇄ server.py + +Uso: + python client_web.py [--server 127.0.0.1] [--port 9000] [--web-port 5001] +""" + +# ── Importaciones ──────────────────────────────────────────────────────────── +import argparse # Para leer argumentos de la línea de comandos (--server, --port, etc.) +import json # Para convertir diccionarios Python a texto JSON y viceversa +import socket # Para crear conexiones TCP hacia el servidor de chat +import threading # Para leer del servidor TCP en paralelo sin bloquear el servidor web +import logging # Para mostrar mensajes informativos en la consola + +from flask import Flask, render_template, request # Framework web para servir la página HTML +from flask_socketio import SocketIO, emit # WebSockets: comunicación en tiempo real con el navegador + +# ── Configuración del sistema de logs ────────────────────────────────────────── +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + datefmt='%H:%M:%S' +) +log = logging.getLogger('ChatClient') + +# ── Aplicación Flask y Socket.IO ──────────────────────────────────────────── +# Flask sirve la página web (HTML/CSS/JS) +app = Flask(__name__) +app.config['SECRET_KEY'] = 'psp_chat_secret' # Clave para firmar cookies de sesión + +# SocketIO añade soporte WebSocket sobre Flask +# cors_allowed_origins='*' permite conexiones desde cualquier dirección IP +# async_mode='threading' usa hilos de Python (compatible con nuestro código TCP) +socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading') + +# ── Configuración del servidor TCP destino (se puede cambiar por argumentos CLI) ── +SERVER_HOST = '127.0.0.1' # IP del servidor TCP de chat +SERVER_PORT = 9000 # Puerto del servidor TCP de chat + +# ── Sesiones activas ──────────────────────────────────────────────────────────── +# Cada navegador que se conecta tiene su propia sesión con su propio socket TCP. +# Clave: sid (Socket.IO session ID) del navegador +# Valor: {'sock': socket TCP, 'nick': nombre del usuario} +sessions = {} +sess_lock = threading.Lock() # Candado para acceder a 'sessions' de forma segura + +def tcp_reader(sid, sock): + """ + Hilo que escucha continuamente el socket TCP del servidor de chat + y reenvía cada mensaje al navegador mediante Socket.IO. + + Esto es necesario porque TCP y WebSocket son protocolos distintos; + este puente los une de forma transparente para el usuario. + """ + buf = '' + try: + while True: + # Leemos hasta 4096 bytes del servidor TCP + chunk = sock.recv(4096).decode('utf-8', errors='replace') + if not chunk: + break # El servidor cerró la conexión + + buf += chunk + + # Procesamos cada línea completa del buffer (cada mensaje JSON) + while '\n' in buf: + line, buf = buf.split('\n', 1) + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) # Convertir el texto JSON a diccionario + except json.JSONDecodeError: + continue # Línea no válida, la ignoramos + + log.debug(f'TCP→Browser [{sid[:6]}]: {msg}') + + # Enviar el mensaje al navegador por WebSocket + socketio.emit('srv_msg', msg, to=sid) + + except Exception as e: + log.info(f'TCP reader ended for {sid[:6]}: {e}') + finally: + # Si se pierde la conexión TCP, notificamos al navegador + socketio.emit('srv_msg', { + 'type': 'error', + 'text': 'Conexión con el servidor perdida' + }, to=sid) + with sess_lock: + sessions.pop(sid, None) # Limpiar la sesión de la memoria + + +def tcp_send(sid, data: dict): + """ + Envía un mensaje JSON al servidor TCP desde la sesión indicada. + Se usa solo para el JOIN inicial, que sí requiere formato JSON. + Devuelve True si se envió correctamente, False si hubo error. + """ + with sess_lock: + s = sessions.get(sid) + if not s: + return False # Sesión no encontrada (el usuario no está conectado al TCP) + try: + s['sock'].sendall((json.dumps(data, ensure_ascii=False) + '\n').encode('utf-8')) + return True + except Exception as e: + log.error(f'TCP send error: {e}') + return False + + +def tcp_send_text(sid, text: str): + """ + Envía una línea de texto plano al servidor TCP. + El servidor, tras el JOIN, trata cada línea como un mensaje de chat, + así que debemos enviar solo el texto, no JSON. + Esto garantiza que los mensajes del cliente web sean idénticos + a los que envía un usuario de netcat o terminal. + """ + with sess_lock: + s = sessions.get(sid) + if not s: + return False + try: + s['sock'].sendall((text + '\n').encode('utf-8')) + return True + except Exception as e: + log.error(f'TCP send error: {e}') + return False + +# ── Rutas Flask (HTTP) ────────────────────────────────────────────────── + +@app.route('/') +def index(): + """ + Sirve la página principal del chat (templates/index.html). + Pasa al HTML la IP y puerto del servidor TCP para mostrarlos en el login. + """ + return render_template('index.html', + server_host=SERVER_HOST, + server_port=SERVER_PORT) + +# ── Eventos Socket.IO (WebSocket entre navegador y este cliente web) ───────── + +@socketio.on('connect') +def on_connect(): + """Se dispara cuando un navegador abre la página. Solo lo registramos en el log.""" + log.info(f'Browser conectado: {request.sid[:8]}') + +@socketio.on('disconnect') +def on_disconnect(): + """ + Se dispara cuando el navegador cierra la pestaña o pierde la conexión. + Cerramos el socket TCP asociado para no dejar conexiones huérfanas abiertas. + """ + sid = request.sid + log.info(f'Browser desconectado: {sid[:8]}') + with sess_lock: + s = sessions.pop(sid, None) # Eliminar la sesión de la memoria + if s: + try: + s['sock'].close() # Cerrar el socket TCP liberando el recurso + except Exception: + pass + +@socketio.on('join') +def on_join(data): + """ + El navegador quiere unirse al chat. Recibe: {nick: 'Alice'} + + Pasos: + 1. Crea una nueva conexión TCP al servidor de chat. + 2. Guarda el socket en la tabla de sesiones. + 3. Lanza el hilo tcp_reader para escuchar respuestas del servidor. + 4. Envía el mensaje JOIN al servidor TCP. + """ + sid = request.sid + nick = str(data.get('nick', '')).strip() + + # Evitar que el mismo navegador se conecte dos veces + with sess_lock: + if sid in sessions: + emit('srv_msg', {'type': 'error', 'text': 'Ya estás conectado'}) + return + + # Intentar conectar al servidor TCP + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) # Timeout de 10 s para la conexión inicial + sock.connect((SERVER_HOST, SERVER_PORT)) + sock.settimeout(None) # Sin timeout una vez conectado + except Exception as e: + emit('srv_msg', {'type': 'error', 'text': f'No se puede conectar al servidor TCP: {e}'}) + return + + # Guardar la sesión: asociar el sid del navegador con su socket TCP + with sess_lock: + sessions[sid] = {'sock': sock, 'nick': nick} + + # Lanzar el hilo lector: escuchará el TCP y retransmitirá mensajes al navegador + t = threading.Thread(target=tcp_reader, args=(sid, sock), daemon=True) + t.start() + + # Enviar el mensaje de join al servidor TCP + tcp_send(sid, {'type': 'join', 'nick': nick}) + log.info(f'{nick} ({sid[:8]}) conectado al servidor TCP') + +@socketio.on('send_msg') +def on_send_msg(data): + """ + El navegador envía un mensaje a la sala. Recibe: {type:'msg', room:'general', text:'...'} + Enviamos Solo el texto plano al servidor TCP (no el JSON completo), + porque el servidor tras el JOIN trata cada línea como texto de chat. + Así los mensajes del cliente web son idénticos a los de netcat. + """ + text = str(data.get('text', '')).strip() + if text: + tcp_send_text(request.sid, text) + + +@socketio.on('send_pm') +def on_send_pm(data): + """ + El navegador envía un mensaje privado. Recibe: {type:'pm', to:'Bob', text:'...'} + Lo reenvía al servidor TCP. + """ + tcp_send(request.sid, data) + + +@socketio.on('cmd') +def on_cmd(data): + """ + Cualquier otro comando del navegador (crear sala, unirse a sala, etc.). + Se reenvía directamente al servidor TCP sin modificaciones. + """ + tcp_send(request.sid, data) + +# ── Punto de entrada principal ──────────────────────────────────────────────── +if __name__ == '__main__': + # Configurar argumentos de línea de comandos para poder personalizar + # la IP/puerto del servidor TCP y el puerto web sin editar el código + parser = argparse.ArgumentParser(description='PSP Chat – Web Client') + parser.add_argument('--server', default='127.0.0.1', help='IP del servidor TCP') + parser.add_argument('--port', type=int, default=9000, help='Puerto del servidor TCP') + parser.add_argument('--web-port', type=int, default=5001, help='Puerto del servidor web') + args = parser.parse_args() + + # Actualizamos las variables globales con los valores recibidos + SERVER_HOST = args.server + SERVER_PORT = args.port + + log.info(f'╔══════════════════════════════════════╗') + log.info(f'║ PSP Chat – Cliente Web ║') + log.info(f'║ Servidor TCP : {SERVER_HOST}:{SERVER_PORT} ║') + log.info(f'║ Interfaz web : http://0.0.0.0:{args.web_port} ║') + log.info(f'╚══════════════════════════════════════╝') + + # Arrancar el servidor web Flask+SocketIO en todas las interfaces + # debug=False para no mostrar información sensible en producción + socketio.run(app, host='0.0.0.0', port=args.web_port, debug=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ee641c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0.0 +flask-socketio>=5.0.0 +eventlet>=0.35.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..77af8b8 --- /dev/null +++ b/server.py @@ -0,0 +1,321 @@ +#!/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() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3e8d70a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,747 @@ + + + + + +PSP Chat + + + + + + + +
+ +
+ + +
+ + + + +
+
+
💬
+

¡Bienvenido al chat!

+

Selecciona una sala o haz clic en un usuario para enviarle un mensaje privado.

+
+ + +
+
+ + + + + + +