first
This commit is contained in:
parent
21922a11df
commit
a1cd5613e4
Binary file not shown.
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
flask>=3.0.0
|
||||
flask-socketio>=5.0.0
|
||||
eventlet>=0.35.0
|
||||
|
|
@ -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': <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()
|
||||
|
|
@ -0,0 +1,747 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>PSP Chat</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg: #17212b;
|
||||
--sidebar: #232e3c;
|
||||
--panel: #17212b;
|
||||
--bubble-me:#2b5278;
|
||||
--bubble-other:#182533;
|
||||
--accent: #5288c1;
|
||||
--accent2: #64b5f6;
|
||||
--text: #e8f1f9;
|
||||
--text2: #7d9db7;
|
||||
--text3: #4a6580;
|
||||
--border: #2a3b4c;
|
||||
--hover: #2a3a4a;
|
||||
--green: #4caf88;
|
||||
--red: #e05060;
|
||||
--header-h: 56px;
|
||||
--sidebar-w:300px;
|
||||
}
|
||||
|
||||
html,body{height:100%;overflow:hidden;font-family:'Manrope',sans-serif;background:var(--bg);color:var(--text)}
|
||||
|
||||
/* ── LOGIN SCREEN ─────────────────────────────────────────────────────────── */
|
||||
#login-screen{
|
||||
position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--bg);z-index:50;
|
||||
animation:fadeIn .4s ease;
|
||||
}
|
||||
.login-box{
|
||||
background:var(--sidebar);border-radius:16px;padding:40px 48px;width:360px;
|
||||
box-shadow:0 24px 64px rgba(0,0,0,.5);
|
||||
animation:slideUp .4s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.login-logo{text-align:center;margin-bottom:28px}
|
||||
.login-logo .plane{font-size:56px;filter:drop-shadow(0 4px 12px rgba(82,136,193,.5));animation:float 3s ease-in-out infinite}
|
||||
.login-logo h1{font-size:26px;font-weight:700;letter-spacing:-.5px;margin-top:8px}
|
||||
.login-logo span{color:var(--accent2)}
|
||||
.login-box p{color:var(--text2);font-size:14px;text-align:center;margin-bottom:28px}
|
||||
.input-group{position:relative;margin-bottom:16px}
|
||||
.input-group label{display:block;font-size:11px;font-weight:600;color:var(--text2);letter-spacing:.5px;text-transform:uppercase;margin-bottom:6px}
|
||||
.input-group input{
|
||||
width:100%;padding:12px 16px;background:var(--bg);border:1.5px solid var(--border);
|
||||
border-radius:10px;color:var(--text);font-size:15px;font-family:'Manrope',sans-serif;outline:none;transition:.2s
|
||||
}
|
||||
.input-group input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(82,136,193,.15)}
|
||||
.input-group input::placeholder{color:var(--text3)}
|
||||
.btn-connect{
|
||||
width:100%;padding:13px;background:var(--accent);color:#fff;border:none;border-radius:10px;
|
||||
font-size:15px;font-weight:600;font-family:'Manrope',sans-serif;cursor:pointer;
|
||||
transition:.2s;margin-top:4px;
|
||||
}
|
||||
.btn-connect:hover{background:var(--accent2);transform:translateY(-1px);box-shadow:0 6px 20px rgba(82,136,193,.35)}
|
||||
.btn-connect:active{transform:translateY(0)}
|
||||
.login-status{text-align:center;font-size:13px;color:var(--red);margin-top:12px;min-height:18px}
|
||||
|
||||
/* ── MAIN LAYOUT ──────────────────────────────────────────────────────────── */
|
||||
#app{display:none;height:100%;flex-direction:row}
|
||||
#app.visible{display:flex;animation:fadeIn .3s ease}
|
||||
|
||||
/* ── SIDEBAR ──────────────────────────────────────────────────────────────── */
|
||||
.sidebar{
|
||||
width:var(--sidebar-w);flex-shrink:0;background:var(--sidebar);
|
||||
display:flex;flex-direction:column;border-right:1px solid var(--border);
|
||||
}
|
||||
.sidebar-header{
|
||||
height:var(--header-h);display:flex;align-items:center;padding:0 16px;gap:10px;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.sidebar-header .logo{font-size:22px;font-weight:800;letter-spacing:-1px;flex:1}
|
||||
.sidebar-header .logo span{color:var(--accent2)}
|
||||
.sidebar-header .user-chip{
|
||||
display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);
|
||||
border-radius:20px;font-size:13px;font-weight:600;
|
||||
}
|
||||
.avatar{
|
||||
width:28px;height:28px;border-radius:50%;background:var(--accent);
|
||||
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.avatar.sm{width:36px;height:36px;font-size:14px}
|
||||
|
||||
.sidebar-section{padding:12px 16px 4px;font-size:10px;font-weight:700;letter-spacing:1px;color:var(--text3);text-transform:uppercase;display:flex;align-items:center;gap:8px}
|
||||
.sidebar-section button{margin-left:auto;background:none;border:none;cursor:pointer;color:var(--accent2);font-size:16px;padding:0;line-height:1;transition:.15s}
|
||||
.sidebar-section button:hover{color:var(--text)}
|
||||
|
||||
.room-item{
|
||||
display:flex;align-items:center;padding:10px 16px;cursor:pointer;
|
||||
gap:10px;border-radius:0;transition:.15s;position:relative;
|
||||
}
|
||||
.room-item:hover{background:var(--hover)}
|
||||
.room-item.active{background:var(--hover)}
|
||||
.room-item.active::before{content:'';position:absolute;left:0;top:4px;bottom:4px;width:3px;background:var(--accent2);border-radius:0 2px 2px 0}
|
||||
.room-icon{width:36px;height:36px;border-radius:50%;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
|
||||
.room-info{flex:1;min-width:0}
|
||||
.room-name{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.room-preview{font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.room-badge{
|
||||
background:var(--accent);color:#fff;border-radius:10px;
|
||||
font-size:11px;font-weight:700;padding:2px 7px;flex-shrink:0;
|
||||
}
|
||||
|
||||
.user-item{display:flex;align-items:center;padding:8px 16px;gap:10px;cursor:pointer;transition:.15s}
|
||||
.user-item:hover{background:var(--hover)}
|
||||
.user-item .uname{font-size:14px;font-weight:500}
|
||||
.online-dot{width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0}
|
||||
|
||||
.sidebar-footer{padding:12px 16px;border-top:1px solid var(--border)}
|
||||
.sidebar-footer button{
|
||||
width:100%;padding:10px;background:var(--red);color:#fff;border:none;border-radius:8px;
|
||||
font-size:13px;font-weight:600;font-family:'Manrope',sans-serif;cursor:pointer;transition:.2s
|
||||
}
|
||||
.sidebar-footer button:hover{filter:brightness(1.1)}
|
||||
|
||||
/* ── CHAT PANEL ───────────────────────────────────────────────────────────── */
|
||||
.chat-panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
|
||||
.chat-header{
|
||||
height:var(--header-h);display:flex;align-items:center;padding:0 20px;gap:12px;
|
||||
background:var(--sidebar);border-bottom:1px solid var(--border);
|
||||
}
|
||||
.chat-header .room-title{font-size:16px;font-weight:700;flex:1}
|
||||
.chat-header .room-meta{font-size:12px;color:var(--text2)}
|
||||
.chat-header .actions button{background:none;border:none;cursor:pointer;color:var(--text2);font-size:18px;padding:6px;border-radius:6px;transition:.15s}
|
||||
.chat-header .actions button:hover{background:var(--hover);color:var(--text)}
|
||||
|
||||
/* Messages area */
|
||||
.messages{
|
||||
flex:1;overflow-y:auto;padding:16px 20px;display:flex;flex-direction:column;gap:2px;
|
||||
background:var(--panel);
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 80%, rgba(82,136,193,.04) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(82,136,193,.04) 0%, transparent 50%);
|
||||
}
|
||||
.messages::-webkit-scrollbar{width:4px}
|
||||
.messages::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
||||
|
||||
/* Message bubbles */
|
||||
.msg-wrap{display:flex;align-items:flex-end;gap:8px;margin-bottom:2px;animation:msgIn .2s cubic-bezier(.22,1,.36,1)}
|
||||
.msg-wrap.mine{flex-direction:row-reverse}
|
||||
.msg-wrap .av{flex-shrink:0;margin-bottom:4px}
|
||||
.bubble{
|
||||
max-width:520px;padding:8px 12px;border-radius:16px;position:relative;
|
||||
font-size:14px;line-height:1.5;word-break:break-word;
|
||||
}
|
||||
.msg-wrap:not(.mine) .bubble{
|
||||
background:var(--bubble-other);border-bottom-left-radius:4px;
|
||||
}
|
||||
.msg-wrap.mine .bubble{
|
||||
background:var(--bubble-me);border-bottom-right-radius:4px;
|
||||
}
|
||||
.bubble .sender{font-size:12px;font-weight:700;color:var(--accent2);margin-bottom:2px}
|
||||
.bubble .text{color:var(--text)}
|
||||
.bubble .meta{display:flex;align-items:center;justify-content:flex-end;gap:4px;margin-top:4px}
|
||||
.bubble .ts{font-size:10px;color:var(--text2)}
|
||||
.bubble .ticks{font-size:11px;color:var(--accent2)}
|
||||
|
||||
/* Event messages */
|
||||
.event-msg{
|
||||
text-align:center;font-size:12px;color:var(--text3);
|
||||
padding:4px 12px;background:rgba(0,0,0,.2);border-radius:12px;
|
||||
margin:6px auto;max-width:400px;animation:msgIn .2s ease;
|
||||
}
|
||||
|
||||
/* PM badge */
|
||||
.pm-bubble{
|
||||
border:1px solid var(--accent);background:rgba(82,136,193,.1) !important;
|
||||
}
|
||||
.pm-label{font-size:10px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
||||
|
||||
/* Input area */
|
||||
.input-area{
|
||||
display:flex;align-items:flex-end;padding:12px 16px;gap:10px;
|
||||
background:var(--sidebar);border-top:1px solid var(--border);
|
||||
}
|
||||
.input-area textarea{
|
||||
flex:1;padding:12px 16px;background:var(--bg);border:none;border-radius:12px;
|
||||
color:var(--text);font-size:14px;font-family:'Manrope',sans-serif;outline:none;
|
||||
resize:none;max-height:120px;line-height:1.5;transition:.2s;
|
||||
}
|
||||
.input-area textarea::placeholder{color:var(--text3)}
|
||||
.send-btn{
|
||||
width:44px;height:44px;border-radius:50%;background:var(--accent);border:none;
|
||||
cursor:pointer;display:flex;align-items:center;justify-content:center;
|
||||
transition:.2s;flex-shrink:0;font-size:18px;
|
||||
}
|
||||
.send-btn:hover{background:var(--accent2);transform:scale(1.05)}
|
||||
.send-btn:active{transform:scale(.96)}
|
||||
|
||||
/* Welcome / empty */
|
||||
.welcome-screen{
|
||||
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
color:var(--text2);gap:16px;background:var(--panel);
|
||||
}
|
||||
.welcome-screen .big-icon{font-size:72px;opacity:.3;animation:float 4s ease-in-out infinite}
|
||||
.welcome-screen h2{font-size:22px;font-weight:600;color:var(--text)}
|
||||
.welcome-screen p{font-size:14px;max-width:320px;text-align:center;line-height:1.6}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{
|
||||
position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;
|
||||
align-items:center;justify-content:center;z-index:100;
|
||||
animation:fadeIn .2s ease;
|
||||
}
|
||||
.modal{
|
||||
background:var(--sidebar);border-radius:14px;padding:28px;width:320px;
|
||||
box-shadow:0 16px 48px rgba(0,0,0,.5);
|
||||
animation:slideUp .3s cubic-bezier(.22,1,.36,1);
|
||||
}
|
||||
.modal h3{font-size:18px;font-weight:700;margin-bottom:16px}
|
||||
.modal input{
|
||||
width:100%;padding:10px 14px;background:var(--bg);border:1.5px solid var(--border);
|
||||
border-radius:8px;color:var(--text);font-size:14px;font-family:'Manrope',sans-serif;outline:none;margin-bottom:16px;
|
||||
}
|
||||
.modal input:focus{border-color:var(--accent)}
|
||||
.modal-btns{display:flex;gap:8px;justify-content:flex-end}
|
||||
.modal-btns button{padding:8px 20px;border-radius:8px;font-family:'Manrope',sans-serif;font-size:14px;font-weight:600;cursor:pointer;border:none;transition:.15s}
|
||||
.modal-btns .ok{background:var(--accent);color:#fff}
|
||||
.modal-btns .ok:hover{background:var(--accent2)}
|
||||
.modal-btns .cancel{background:var(--bg);color:var(--text2)}
|
||||
|
||||
/* PM input area */
|
||||
.pm-banner{
|
||||
display:flex;align-items:center;padding:8px 16px;background:rgba(82,136,193,.1);
|
||||
border-top:1px solid var(--accent);font-size:13px;gap:8px;
|
||||
}
|
||||
.pm-banner span{flex:1;color:var(--accent2)}
|
||||
.pm-banner button{background:none;border:none;cursor:pointer;color:var(--text2);font-size:16px;padding:2px}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||
@keyframes slideUp{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes msgIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
|
||||
|
||||
/* Notifications badge */
|
||||
.notif{position:relative}
|
||||
.notif::after{
|
||||
content:attr(data-count);position:absolute;top:-4px;right:-8px;
|
||||
background:var(--red);color:#fff;border-radius:10px;font-size:10px;
|
||||
font-weight:700;padding:1px 5px;display:none;
|
||||
}
|
||||
.notif[data-count]:not([data-count="0"])::after{display:block}
|
||||
|
||||
/* Typing indicator */
|
||||
.typing{font-size:12px;color:var(--text2);padding:4px 20px;min-height:20px;font-style:italic}
|
||||
|
||||
@media(max-width:700px){
|
||||
.sidebar{position:absolute;z-index:10;height:100%;transform:translateX(-100%);transition:.3s}
|
||||
.sidebar.open{transform:translateX(0)}
|
||||
:root{--sidebar-w:280px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── LOGIN ─────────────────────────────────────────────────────────────── -->
|
||||
<div id="login-screen">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<div class="plane">✈️</div>
|
||||
<h1>PSP <span>Chat</span></h1>
|
||||
</div>
|
||||
<p>Servidor TCP {{ server_host }}:{{ server_port }}</p>
|
||||
<div class="input-group">
|
||||
<label>Tu nickname</label>
|
||||
<input id="nick-input" type="text" placeholder="Ej: javier" maxlength="20"
|
||||
onkeydown="if(event.key==='Enter')connect()"/>
|
||||
</div>
|
||||
<button class="btn-connect" onclick="connect()">Conectar al servidor →</button>
|
||||
<div id="login-status" class="login-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── APP ───────────────────────────────────────────────────────────────── -->
|
||||
<div id="app">
|
||||
<!-- SIDEBAR -->
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">PSP <span>Chat</span></div>
|
||||
<div class="user-chip">
|
||||
<div class="avatar" id="my-avatar">?</div>
|
||||
<span id="my-nick">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rooms -->
|
||||
<div class="sidebar-section">
|
||||
Salas
|
||||
<button onclick="showCreateRoom()" title="Crear sala">+</button>
|
||||
</div>
|
||||
<div id="room-list"></div>
|
||||
|
||||
<!-- Users -->
|
||||
<div class="sidebar-section">
|
||||
En línea
|
||||
<span id="user-count" style="color:var(--green);font-size:11px;margin-left:auto;font-weight:700"></span>
|
||||
</div>
|
||||
<div id="user-list" style="flex:1;overflow-y:auto"></div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button onclick="disconnect()">Desconectar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CHAT PANEL -->
|
||||
<div class="chat-panel" id="chat-panel">
|
||||
<div class="welcome-screen" id="welcome-screen">
|
||||
<div class="big-icon">💬</div>
|
||||
<h2>¡Bienvenido al chat!</h2>
|
||||
<p>Selecciona una sala o haz clic en un usuario para enviarle un mensaje privado.</p>
|
||||
</div>
|
||||
|
||||
<div id="active-chat" style="display:none;flex:1;flex-direction:column;overflow:hidden">
|
||||
<div class="chat-header">
|
||||
<div class="room-icon" id="chat-icon">💬</div>
|
||||
<div style="flex:1">
|
||||
<div class="room-title" id="chat-title">—</div>
|
||||
<div class="room-meta" id="chat-meta"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="leaveCurrentRoom()" id="btn-leave" title="Salir de la sala" style="display:none">🚪</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages" id="messages"></div>
|
||||
<div class="typing" id="typing-indicator"></div>
|
||||
<div id="pm-banner" class="pm-banner" style="display:none">
|
||||
<span>📩 Mensaje privado a <strong id="pm-target-label"></strong></span>
|
||||
<button onclick="clearPM()" title="Cancelar">✖</button>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<textarea id="msg-input" rows="1" placeholder="Escribe un mensaje..."
|
||||
onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||||
<button class="send-btn" onclick="sendMessage()" title="Enviar">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CREATE ROOM MODAL -->
|
||||
<div class="modal-overlay" id="create-modal" style="display:none" onclick="if(event.target===this)hideCreateRoom()">
|
||||
<div class="modal">
|
||||
<h3>Nueva sala</h3>
|
||||
<input id="new-room-input" type="text" placeholder="nombre_sala" maxlength="30"
|
||||
onkeydown="if(event.key==='Enter')createRoom()"/>
|
||||
<div class="modal-btns">
|
||||
<button class="cancel" onclick="hideCreateRoom()">Cancelar</button>
|
||||
<button class="ok" onclick="createRoom()">Crear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
const socket = io();
|
||||
let myNick = '';
|
||||
let currentRoom = null; // room name or null
|
||||
let pmTarget = null; // nick for private message
|
||||
let myRooms = new Set(['general']);
|
||||
let unread = {}; // room/pm → count
|
||||
let msgHistory = {}; // room → [{...}]
|
||||
let onlineUsers = [];
|
||||
|
||||
// ── Connect / Disconnect ──────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const nick = document.getElementById('nick-input').value.trim();
|
||||
if (!nick) { setLoginStatus('Escribe tu nickname'); return; }
|
||||
if (!/^[a-zA-Z0-9_]{1,20}$/.test(nick)) {
|
||||
setLoginStatus('Solo letras, números y _ (máx 20)'); return;
|
||||
}
|
||||
setLoginStatus('Conectando...');
|
||||
socket.emit('join', {nick});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function setLoginStatus(msg, isError=true) {
|
||||
const el = document.getElementById('login-status');
|
||||
el.textContent = msg;
|
||||
el.style.color = isError ? 'var(--red)' : 'var(--green)';
|
||||
}
|
||||
|
||||
// ── Socket events ─────────────────────────────────────────────────────────
|
||||
socket.on('srv_msg', msg => {
|
||||
console.log('←', msg);
|
||||
switch(msg.type) {
|
||||
case 'welcome': handleWelcome(msg); break;
|
||||
case 'msg': handleMsg(msg); break;
|
||||
case 'pm': handlePM(msg); break;
|
||||
case 'event': handleEvent(msg); break;
|
||||
case 'users': handleUsers(msg); break;
|
||||
case 'rooms': handleRooms(msg); break;
|
||||
case 'joined_room':handleJoinedRoom(msg); break;
|
||||
case 'error': handleError(msg); break;
|
||||
}
|
||||
});
|
||||
|
||||
function handleWelcome(msg) {
|
||||
myNick = msg.nick;
|
||||
document.getElementById('my-nick').textContent = myNick;
|
||||
document.getElementById('my-avatar').textContent = myNick[0].toUpperCase();
|
||||
|
||||
// Show app
|
||||
document.getElementById('login-screen').style.display = 'none';
|
||||
document.getElementById('app').classList.add('visible');
|
||||
|
||||
// Init rooms
|
||||
(msg.rooms || ['general']).forEach(r => myRooms.add(r));
|
||||
refreshRoomList();
|
||||
|
||||
// Auto-join general
|
||||
switchToRoom('general');
|
||||
}
|
||||
|
||||
function handleMsg(msg) {
|
||||
if (!msgHistory[msg.room]) msgHistory[msg.room] = [];
|
||||
msgHistory[msg.room].push(msg);
|
||||
|
||||
if (currentRoom === msg.room) {
|
||||
appendBubble(msg);
|
||||
scrollBottom();
|
||||
} else {
|
||||
unread[msg.room] = (unread[msg.room] || 0) + 1;
|
||||
refreshRoomList();
|
||||
notifyTitle();
|
||||
}
|
||||
|
||||
// Update preview
|
||||
updateRoomPreview(msg.room, msg.from + ': ' + msg.text);
|
||||
}
|
||||
|
||||
function handlePM(msg) {
|
||||
const key = msg.from === myNick ? 'pm:' + msg.to : 'pm:' + msg.from;
|
||||
if (!msgHistory[key]) msgHistory[key] = [];
|
||||
msgHistory[key].push(msg);
|
||||
|
||||
if (pmTarget && (msg.from === pmTarget || (msg.from === myNick && msg.to === pmTarget))) {
|
||||
appendPMBubble(msg);
|
||||
scrollBottom();
|
||||
} else if (msg.from !== myNick) {
|
||||
unread[key] = (unread[key] || 0) + 1;
|
||||
// Add/refresh DM in user list
|
||||
refreshUserList();
|
||||
notifyTitle();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(msg) {
|
||||
if (!msgHistory[msg.room]) msgHistory[msg.room] = [];
|
||||
const ev = {...msg, isEvent: true};
|
||||
msgHistory[msg.room].push(ev);
|
||||
if (currentRoom === msg.room) {
|
||||
appendEvent(msg.text);
|
||||
scrollBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUsers(msg) {
|
||||
onlineUsers = msg.users || [];
|
||||
refreshUserList();
|
||||
document.getElementById('user-count').textContent = onlineUsers.length;
|
||||
}
|
||||
|
||||
function handleRooms(msg) {
|
||||
// rooms is an object room→members
|
||||
refreshRoomList();
|
||||
}
|
||||
|
||||
function handleJoinedRoom(msg) {
|
||||
myRooms.add(msg.room);
|
||||
refreshRoomList();
|
||||
switchToRoom(msg.room);
|
||||
}
|
||||
|
||||
function handleError(msg) {
|
||||
if (!document.getElementById('app').classList.contains('visible')) {
|
||||
setLoginStatus(msg.text);
|
||||
} else {
|
||||
showToast('❌ ' + msg.text, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Room logic ────────────────────────────────────────────────────────────
|
||||
function switchToRoom(room) {
|
||||
currentRoom = room;
|
||||
pmTarget = null;
|
||||
clearPMBanner();
|
||||
|
||||
document.getElementById('welcome-screen').style.display = 'none';
|
||||
const ac = document.getElementById('active-chat');
|
||||
ac.style.display = 'flex';
|
||||
|
||||
document.getElementById('chat-icon').textContent = room === 'general' ? '🌐' : '💬';
|
||||
document.getElementById('chat-title').textContent = '#' + room;
|
||||
document.getElementById('chat-meta').textContent = 'Sala pública';
|
||||
document.getElementById('btn-leave').style.display = room === 'general' ? 'none' : '';
|
||||
|
||||
unread[room] = 0;
|
||||
refreshRoomList();
|
||||
renderHistory(room, false);
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
function switchToPM(nick) {
|
||||
pmTarget = nick;
|
||||
currentRoom = null;
|
||||
clearPMBanner();
|
||||
|
||||
document.getElementById('welcome-screen').style.display = 'none';
|
||||
const ac = document.getElementById('active-chat');
|
||||
ac.style.display = 'flex';
|
||||
|
||||
document.getElementById('chat-icon').textContent = '👤';
|
||||
document.getElementById('chat-title').textContent = nick;
|
||||
document.getElementById('chat-meta').textContent = 'Mensaje privado';
|
||||
document.getElementById('btn-leave').style.display = 'none';
|
||||
|
||||
// PM banner
|
||||
document.getElementById('pm-banner').style.display = 'flex';
|
||||
document.getElementById('pm-target-label').textContent = nick;
|
||||
|
||||
const key = 'pm:' + nick;
|
||||
unread[key] = 0;
|
||||
refreshUserList();
|
||||
renderHistory(key, true);
|
||||
scrollBottom();
|
||||
}
|
||||
|
||||
function clearPM() {
|
||||
pmTarget = null;
|
||||
clearPMBanner();
|
||||
if (myRooms.has('general')) switchToRoom('general');
|
||||
}
|
||||
|
||||
function clearPMBanner() {
|
||||
document.getElementById('pm-banner').style.display = 'none';
|
||||
}
|
||||
|
||||
function leaveCurrentRoom() {
|
||||
if (!currentRoom || currentRoom === 'general') return;
|
||||
socket.emit('cmd', {type:'leave_room', room:currentRoom});
|
||||
myRooms.delete(currentRoom);
|
||||
switchToRoom('general');
|
||||
refreshRoomList();
|
||||
}
|
||||
|
||||
function updateRoomPreview(room, text) {
|
||||
const el = document.querySelector(`.room-item[data-room="${room}"] .room-preview`);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
// ── Send ──────────────────────────────────────────────────────────────────
|
||||
function sendMessage() {
|
||||
const ta = document.getElementById('msg-input');
|
||||
const text = ta.value.trim();
|
||||
if (!text) return;
|
||||
ta.value = '';
|
||||
autoResize(ta);
|
||||
|
||||
if (pmTarget) {
|
||||
socket.emit('send_pm', {type:'pm', to:pmTarget, text});
|
||||
} else if (currentRoom) {
|
||||
socket.emit('send_msg', {type:'msg', room:currentRoom, text});
|
||||
}
|
||||
}
|
||||
|
||||
function handleKey(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
function renderHistory(key, isPM) {
|
||||
const msgs = msgHistory[key] || [];
|
||||
const container = document.getElementById('messages');
|
||||
container.innerHTML = '';
|
||||
msgs.forEach(m => {
|
||||
if (m.isEvent) appendEvent(m.text);
|
||||
else if (isPM) appendPMBubble(m);
|
||||
else appendBubble(m);
|
||||
});
|
||||
}
|
||||
|
||||
function appendBubble(msg) {
|
||||
const container = document.getElementById('messages');
|
||||
const isMe = msg.from === myNick;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg-wrap' + (isMe ? ' mine' : '');
|
||||
wrap.innerHTML = `
|
||||
${!isMe ? `<div class="av"><div class="avatar sm" style="background:${nickColor(msg.from)}">${msg.from[0].toUpperCase()}</div></div>` : ''}
|
||||
<div class="bubble">
|
||||
${!isMe ? `<div class="sender">${esc(msg.from)}</div>` : ''}
|
||||
<div class="text">${esc(msg.text)}</div>
|
||||
<div class="meta"><span class="ts">${msg.ts}</span>${isMe ? '<span class="ticks">✓✓</span>' : ''}</div>
|
||||
</div>
|
||||
${isMe ? `<div class="av"><div class="avatar sm" style="background:${nickColor(msg.from)}">${msg.from[0].toUpperCase()}</div></div>` : ''}
|
||||
`;
|
||||
container.appendChild(wrap);
|
||||
}
|
||||
|
||||
function appendPMBubble(msg) {
|
||||
const container = document.getElementById('messages');
|
||||
const isMe = msg.from === myNick;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg-wrap' + (isMe ? ' mine' : '');
|
||||
wrap.innerHTML = `
|
||||
${!isMe ? `<div class="av"><div class="avatar sm" style="background:${nickColor(msg.from)}">${msg.from[0].toUpperCase()}</div></div>` : ''}
|
||||
<div class="bubble pm-bubble">
|
||||
<div class="pm-label">🔒 Privado</div>
|
||||
<div class="text">${esc(msg.text)}</div>
|
||||
<div class="meta"><span class="ts">${msg.ts}</span>${isMe ? '<span class="ticks">✓✓</span>' : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(wrap);
|
||||
}
|
||||
|
||||
function appendEvent(text) {
|
||||
const container = document.getElementById('messages');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'event-msg';
|
||||
el.textContent = text;
|
||||
container.appendChild(el);
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
const c = document.getElementById('messages');
|
||||
requestAnimationFrame(() => c.scrollTop = c.scrollHeight);
|
||||
}
|
||||
|
||||
// ── Sidebar renders ───────────────────────────────────────────────────────
|
||||
function refreshRoomList() {
|
||||
const container = document.getElementById('room-list');
|
||||
const rooms = [...myRooms].sort((a,b) => a === 'general' ? -1 : 1);
|
||||
container.innerHTML = rooms.map(r => {
|
||||
const u = unread[r] || 0;
|
||||
const isActive = currentRoom === r && !pmTarget;
|
||||
const icon = r === 'general' ? '🌐' : r === 'offtopic' ? '💬' : '📁';
|
||||
const preview = (msgHistory[r] || []).filter(m => !m.isEvent).slice(-1)[0];
|
||||
return `<div class="room-item${isActive?' active':''}" data-room="${r}" onclick="switchToRoom('${r}')">
|
||||
<div class="room-icon">${icon}</div>
|
||||
<div class="room-info">
|
||||
<div class="room-name">#${r}</div>
|
||||
<div class="room-preview">${preview ? esc(preview.from+': '+preview.text.slice(0,40)) : 'Sin mensajes'}</div>
|
||||
</div>
|
||||
${u > 0 ? `<div class="room-badge">${u}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refreshUserList() {
|
||||
const container = document.getElementById('user-list');
|
||||
container.innerHTML = onlineUsers.filter(n => n !== myNick).map(n => {
|
||||
const key = 'pm:' + n;
|
||||
const u = unread[key] || 0;
|
||||
const isActive = pmTarget === n;
|
||||
return `<div class="user-item${isActive?' active':''}" onclick="switchToPM('${n}')">
|
||||
<div class="avatar" style="background:${nickColor(n)}">${n[0].toUpperCase()}</div>
|
||||
<span class="uname">${esc(n)}</span>
|
||||
<div class="online-dot" style="margin-left:auto"></div>
|
||||
${u > 0 ? `<div class="room-badge">${u}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── Create room ───────────────────────────────────────────────────────────
|
||||
function showCreateRoom() {
|
||||
document.getElementById('create-modal').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('new-room-input').focus(), 50);
|
||||
}
|
||||
function hideCreateRoom() {
|
||||
document.getElementById('create-modal').style.display = 'none';
|
||||
document.getElementById('new-room-input').value = '';
|
||||
}
|
||||
function createRoom() {
|
||||
const name = document.getElementById('new-room-input').value.trim();
|
||||
if (!name) return;
|
||||
socket.emit('cmd', {type:'create', room:name});
|
||||
hideCreateRoom();
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"')
|
||||
.replace(/\n/g,'<br/>');
|
||||
}
|
||||
|
||||
function nickColor(nick) {
|
||||
const colors = ['#e07b39','#5288c1','#4caf88','#9c64d4','#d44f7a','#3fa8b0','#c7a020'];
|
||||
let h = 0;
|
||||
for (let i=0; i<nick.length; i++) h = (h*31 + nick.charCodeAt(i)) & 0xffffffff;
|
||||
return colors[Math.abs(h) % colors.length];
|
||||
}
|
||||
|
||||
let titleTimer;
|
||||
function notifyTitle() {
|
||||
clearInterval(titleTimer);
|
||||
let toggle = true;
|
||||
titleTimer = setInterval(() => {
|
||||
document.title = toggle ? '💬 Nuevo mensaje!' : 'PSP Chat';
|
||||
toggle = !toggle;
|
||||
}, 1000);
|
||||
setTimeout(() => { clearInterval(titleTimer); document.title = 'PSP Chat'; }, 8000);
|
||||
}
|
||||
|
||||
function showToast(msg, isError=false) {
|
||||
let t = document.getElementById('toast-el');
|
||||
if (!t) {
|
||||
t = document.createElement('div');
|
||||
t.id = 'toast-el';
|
||||
t.style.cssText = 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#323232;color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;z-index:200;opacity:0;transition:.3s;pointer-events:none';
|
||||
document.body.appendChild(t);
|
||||
}
|
||||
t.textContent = msg;
|
||||
if (isError) t.style.background = 'var(--red)';
|
||||
else t.style.background = '#323232';
|
||||
t.style.opacity = '1';
|
||||
setTimeout(() => t.style.opacity = '0', 3000);
|
||||
}
|
||||
|
||||
// Focus input when clicking in panel
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target === document.body && e.key.length === 1) {
|
||||
document.getElementById('msg-input').focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue