add chat system

This commit is contained in:
Levi Planelles 2026-01-19 19:39:44 +01:00
parent 073733b887
commit 1ac67ba9bd
9 changed files with 1749 additions and 12 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -66,6 +66,12 @@ Características principales
- Juego de carreras (canvas): [`proyecto.open_game_race`](proyecto.py) - Juego de carreras (canvas): [`proyecto.open_game_race`](proyecto.py)
- Abrir aplicaciones macOS con `open`: [`proyecto.launch_app`](proyecto.py) - Abrir aplicaciones macOS con `open`: [`proyecto.launch_app`](proyecto.py)
- Monitor de red en la barra de estado: [`proyecto.network_monitor`](proyecto.py) - Monitor de red en la barra de estado: [`proyecto.network_monitor`](proyecto.py)
- **Chat TCP (Multiusuario):**
- **Acceso:** Desde pestaña "Enlaces" -> Botón "Chat TCP".
- **Servidor:** Al lanzarlo, se integra en el **panel derecho de la ventana principal**. Desde ahí se visualizan logs de conexión y mensajería en tiempo real, y permite enviar mensajes como Administrador a todos los conectados. [Código servidor](chat_server.py).
- **Cliente:**
- *Desde la App:* Abre una terminal externa independiente para chatear.
- *Desde otro PC:* Solo requiere el archivo `chat_client.py` (sin dependencias extra). Ejecutar: `python chat_client.py --host IP_SERVIDOR --name TuNombre`. [Código cliente](chat_client.py).
Configuración y datos sensibles Configuración y datos sensibles
------------------------------- -------------------------------

Binary file not shown.

Binary file not shown.

88
chat_client.py Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
import threading
DEFAULT_PORT = 5050
ENCODING = "utf-8"
def _receiver_loop(rfile) -> None:
while True:
try:
line = rfile.readline()
except Exception:
break
if not line:
break
# Imprime mensajes entrantes en tiempo real
sys.stdout.write(line)
sys.stdout.flush()
def main() -> int:
parser = argparse.ArgumentParser(description="Cliente de chat TCP")
parser.add_argument("--host", help="IP/host del servidor (ej: 192.168.1.10)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Puerto (por defecto {DEFAULT_PORT})")
parser.add_argument("--name", help="Tu nombre en el chat")
args = parser.parse_args()
host = args.host or input("IP del servidor: ").strip()
if not host:
print("Host vacío. Abortando.")
return 2
name = args.name or input("Tu nombre: ").strip()
if not name:
name = "Anon"
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, args.port))
rfile = sock.makefile("r", encoding=ENCODING, newline="\n")
wfile = sock.makefile("w", encoding=ENCODING, newline="\n")
# Handshake simple con el servidor
first = rfile.readline()
if first and first.strip() == "NAME?":
wfile.write(f"NAME {name}\n")
wfile.flush()
else:
# Si el servidor no pide nombre, lo mandamos igualmente.
wfile.write(f"NAME {name}\n")
wfile.flush()
if first:
sys.stdout.write(first)
sys.stdout.flush()
t = threading.Thread(target=_receiver_loop, args=(rfile,), daemon=True)
t.start()
print("Conectado. Escribe mensajes y pulsa Enter. /quit para salir.")
try:
for line in sys.stdin:
msg = line.rstrip("\n")
wfile.write(msg + "\n")
wfile.flush()
if msg.lower() in {"/quit", "/exit"}:
break
except KeyboardInterrupt:
try:
wfile.write("/quit\n")
wfile.flush()
except Exception:
pass
finally:
try:
sock.close()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())

192
chat_server.py Normal file
View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
import socket
import threading
from dataclasses import dataclass
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 5050
ENCODING = "utf-8"
@dataclass
class ClientConn:
sock: socket.socket
addr: tuple
name: str
wfile: any
class ChatServer:
def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT, on_log=None):
self.host = host
self.port = port
self.on_log = on_log if on_log else print
self.clients = {}
self.clients_lock = threading.Lock()
self.server_sock = None
self.running = False
self.thread = None
def log(self, message):
"""Envía el mensaje al callback configurado (GUI o print)"""
self.on_log(message)
def broadcast(self, message: str, exclude: socket.socket | None = None) -> None:
"""Envía mensaje a todos los clientes conectados"""
line = message.rstrip("\n") + "\n"
with self.clients_lock:
items = list(self.clients.items())
# Log local si no está excluido el propio servidor (opcional, pero útil ver lo que se envía)
# self.log(f"BRD: {message}")
for sock, client in items:
if exclude is not None and sock is exclude:
continue
try:
client.wfile.write(line)
client.wfile.flush()
except Exception:
pass
def send_server_message(self, text: str):
"""Mensaje desde el servidor (admin)"""
msg = f"[ADMIN] {text}"
self.log(msg)
self.broadcast(msg)
def _handle_client(self, conn: socket.socket, addr: tuple) -> None:
try:
rfile = conn.makefile("r", encoding=ENCODING, newline="\n")
wfile = conn.makefile("w", encoding=ENCODING, newline="\n")
name = None
# Protocolo simple: pedir nombre
try:
wfile.write("NAME?\n")
wfile.flush()
except Exception:
return
try:
raw_name = rfile.readline()
except Exception:
raw_name = None
if not raw_name:
return
raw_name = raw_name.strip()
# Compatibilidad si el cliente manda "NAME Pepe"
if raw_name.upper().startswith("NAME "):
raw_name = raw_name[5:].strip()
name = raw_name or f"{addr[0]}:{addr[1]}"
with self.clients_lock:
self.clients[conn] = ClientConn(sock=conn, addr=addr, name=name, wfile=wfile)
msg_join = f"* {name} se ha unido al chat *"
self.log(msg_join)
self.broadcast(msg_join)
while self.running:
try:
line = rfile.readline()
except Exception:
break
if not line:
break
msg = line.strip()
if not msg:
continue
if msg.lower() in {"/quit", "/exit"}:
break
# Mostrar en servidor y reenviar a otros
full_msg = f"[{name}] {msg}"
self.log(full_msg)
self.broadcast(full_msg)
except Exception as e:
self.log(f"Error gestionando cliente {addr}: {e}")
finally:
with self.clients_lock:
self.clients.pop(conn, None)
try:
conn.close()
except Exception:
pass
if name:
msg_left = f"* {name} ha salido del chat *"
self.log(msg_left)
self.broadcast(msg_left)
def start_background(self):
"""Inicia el servidor en un hilo secundario"""
if self.running:
return
self.running = True
self.thread = threading.Thread(target=self._run_server_loop, daemon=True)
self.thread.start()
def stop(self):
"""Detiene el servidor"""
self.running = False
if self.server_sock:
try:
self.server_sock.close()
except Exception:
pass
self.log("Servidor detenido.")
def _run_server_loop(self):
self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
self.server_sock.bind((self.host, self.port))
self.server_sock.listen(100)
self.server_sock.settimeout(1.0) # Timeout para permitir verificar self.running
self.log(f"Servidor escuchando en {self.host}:{self.port}")
while self.running:
try:
conn, addr = self.server_sock.accept()
t = threading.Thread(target=self._handle_client, args=(conn, addr), daemon=True)
t.start()
except TimeoutError:
continue
except OSError:
break
except Exception as e:
self.log(f"Error aceptando conexión: {e}")
except Exception as e:
self.log(f"Error fatal en servidor: {e}")
finally:
self.running = False
try:
self.server_sock.close()
except Exception:
pass
if __name__ == "__main__":
# Modo standalone para pruebas
import sys
try:
srv = ChatServer(port=5050)
srv.start_background()
print("Presiona Ctrl+C para salir.")
while True:
cmd = sys.stdin.readline()
if not cmd: break
if cmd.strip():
srv.send_server_message(cmd.strip())
except KeyboardInterrupt:
pass
finally:
srv.stop()

88
para_enviar.txt Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
import argparse
import socket
import sys
import threading
DEFAULT_PORT = 5050
ENCODING = "utf-8"
def _receiver_loop(rfile) -> None:
while True:
try:
line = rfile.readline()
except Exception:
break
if not line:
break
# Imprime mensajes entrantes en tiempo real
sys.stdout.write(line)
sys.stdout.flush()
def main() -> int:
parser = argparse.ArgumentParser(description="Cliente de chat TCP")
parser.add_argument("--host", help="IP/host del servidor (ej: 192.168.1.10)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Puerto (por defecto {DEFAULT_PORT})")
parser.add_argument("--name", help="Tu nombre en el chat")
args = parser.parse_args()
host = args.host or input("IP del servidor: ").strip()
if not host:
print("Host vacío. Abortando.")
return 2
name = args.name or input("Tu nombre: ").strip()
if not name:
name = "Anon"
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((host, args.port))
rfile = sock.makefile("r", encoding=ENCODING, newline="\n")
wfile = sock.makefile("w", encoding=ENCODING, newline="\n")
# Handshake simple con el servidor
first = rfile.readline()
if first and first.strip() == "NAME?":
wfile.write(f"NAME {name}\n")
wfile.flush()
else:
# Si el servidor no pide nombre, lo mandamos igualmente.
wfile.write(f"NAME {name}\n")
wfile.flush()
if first:
sys.stdout.write(first)
sys.stdout.flush()
t = threading.Thread(target=_receiver_loop, args=(rfile,), daemon=True)
t.start()
print("Conectado. Escribe mensajes y pulsa Enter. /quit para salir.")
try:
for line in sys.stdin:
msg = line.rstrip("\n")
wfile.write(msg + "\n")
wfile.flush()
if msg.lower() in {"/quit", "/exit"}:
break
except KeyboardInterrupt:
try:
wfile.write("/quit\n")
wfile.flush()
except Exception:
pass
finally:
try:
sock.close()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,7 +1,7 @@
import tkinter as tk import tkinter as tk
from tkinter import Menu # Importar el widget Menu from tkinter import Menu, ttk, scrolledtext # Importar el widget Menu, ttk y scrolledtext
from tkinter import ttk # Importar el widget ttk
import threading import threading
import sys
import time import time
import datetime import datetime
import webbrowser import webbrowser
@ -52,6 +52,10 @@ try:
except Exception: except Exception:
HAS_PYGAME = False HAS_PYGAME = False
# Import chat TCP (servidor y cliente)
import chat_server
import chat_client
def update_time(label_widget): def update_time(label_widget):
"""Función que actualiza la hora y el día de la semana en un label. """Función que actualiza la hora y el día de la semana en un label.
@ -168,6 +172,128 @@ def backup_ui():
pass pass
def open_chat_window():
"""Abre ventana de configuración para lanzar servidor o cliente de chat"""
win = tk.Toplevel(root)
win.title("Chat TCP - Configuración")
win.geometry("450x400")
win.minsize(400, 350)
# Estilo personalizado para títulos
header_font = ("Helvetica", 16, "bold")
label_font = ("Helvetica", 11)
# Contenedor principal
content = tk.Frame(win, bg="#f5f5f5", padx=20, pady=20)
content.pack(fill="both", expand=True)
# Título
tk.Label(content, text="Iniciar Chat", font=header_font, bg="#f5f5f5", fg="#333").pack(pady=(0, 20))
# --- Sección ROL ---
rol_frame = tk.Frame(content, bg="white", bd=1, relief="solid", padx=15, pady=15)
rol_frame.pack(fill="x", pady=(0, 15))
tk.Label(rol_frame, text="Selecciona tu rol:", font=("Helvetica", 12, "bold"), bg="white").pack(anchor="w", pady=(0, 10))
mode_var = tk.StringVar(value="server")
# Sub-frame para radios
radios_f = tk.Frame(rol_frame, bg="white")
radios_f.pack(fill="x")
r1 = tk.Radiobutton(radios_f, text="Servidor (Anfitrión)", variable=mode_var, value="server", bg="white", font=label_font)
r1.pack(side="left", padx=(0, 20))
r2 = tk.Radiobutton(radios_f, text="Cliente (Invitado)", variable=mode_var, value="client", bg="white", font=label_font)
r2.pack(side="left")
# --- Sección DATOS ---
data_frame = tk.Frame(content, bg="white", bd=1, relief="solid", padx=15, pady=15)
data_frame.pack(fill="x", pady=(0, 20))
# Grid para formulario
tk.Label(data_frame, text="Puerto:", bg="white", font=label_font).grid(row=0, column=0, sticky="e", padx=5, pady=5)
port_var = tk.StringVar(value="5050")
tk.Entry(data_frame, textvariable=port_var, width=10, font=label_font).grid(row=0, column=1, sticky="w", padx=5, pady=5)
tk.Label(data_frame, text="IP Servidor:", bg="white", font=label_font).grid(row=1, column=0, sticky="e", padx=5, pady=5)
host_var = tk.StringVar(value="127.0.0.1")
ip_entry = tk.Entry(data_frame, textvariable=host_var, width=20, font=label_font)
ip_entry.grid(row=1, column=1, sticky="w", padx=5, pady=5)
tk.Label(data_frame, text="(Solo Cliente)", bg="white", fg="gray", font=("Arial", 9)).grid(row=1, column=2, sticky="w", padx=5)
def toggle_ip(*args):
if mode_var.get() == "server":
ip_entry.config(state="disabled", bg="#eee")
else:
ip_entry.config(state="normal", bg="white")
mode_var.trace_add("write", toggle_ip)
toggle_ip()
# --- Botón Acción ---
def run_action():
mode = mode_var.get()
try:
port = int(port_var.get())
except ValueError:
mb.showerror("Error", "El puerto debe ser numérico")
return
if mode == "server":
win.destroy()
start_server_in_ui(port)
else:
host = host_var.get()
win.destroy()
open_client_terminal(host, port)
# Botón usando ttk para asegurar renderizado nativo correcto sobre frame
# A veces tk.Button da problemas de z-order. Usamos un botón grande.
btn_frame = tk.Frame(content, bg="#f5f5f5", pady=10)
btn_frame.pack(fill="x", side="bottom")
btn = tk.Button(btn_frame, text="LANZAR APLICACIÓN", command=run_action,
bg="#007aff", fg="black", font=("Helvetica", 14, "bold"), height=2)
# En macOS 'fg' a veces no va, pero el botón debería verse.
# Usamos pack con fill x
btn.pack(fill="x")
def open_client_terminal(host, port):
"""Abre terminal externa para el cliente (interactivo)"""
if not host:
host = "127.0.0.1"
# Intentar detectar terminal según OS
# macOS
cmd_script = f'tell application "Terminal" to do script "cd \\"{os.getcwd()}\\" && \\"{sys.executable}\\" chat_client.py --host {host} --port {port} --name UsuarioGUI"'
try:
subprocess.run(["osascript", "-e", cmd_script])
except Exception as e:
mb.showerror("Error", f"No se pudo lanzar terminal:\n{e}")
def start_server_in_ui(port):
"""Inicia el servidor y conecta los logs al sidebar derecho"""
global global_chat_server
if global_chat_server and global_chat_server.running:
mb.showinfo("Servidor", "El servidor ya está corriendo. Detenlo primero si quieres reiniciar.")
return
try:
global_chat_server = chat_server.ChatServer(port=port, on_log=append_chat_log)
global_chat_server.start_background()
append_chat_log(f">>> Servidor iniciado en puerto {port}")
mb.showinfo("Servidor", f"Servidor iniciado correctamente en puerto {port}.\nVer logs en panel derecho.")
except Exception as e:
mb.showerror("Error", f"Error al iniciar servidor:\n{e}")
append_chat_log(f"Error inicio: {e}")
def open_resource_window(): def open_resource_window():
if not HAS_MATPLOTLIB: if not HAS_MATPLOTLIB:
mb.showwarning("Dependencia", "matplotlib no está disponible. Instálalo con pip install matplotlib") mb.showwarning("Dependencia", "matplotlib no está disponible. Instálalo con pip install matplotlib")
@ -918,18 +1044,38 @@ sec_batch.pack(fill="x", padx=8, pady=(12,6))
btn_backup = ttk.Button(frame_izquierdo, text="Copias de seguridad", width=18, style="Secondary.TButton") btn_backup = ttk.Button(frame_izquierdo, text="Copias de seguridad", width=18, style="Secondary.TButton")
btn_backup.pack(pady=6, padx=8, fill='x') btn_backup.pack(pady=6, padx=8, fill='x')
# --- Contenido del sidebar derecho (chat y lista de alumnos) --- # --- Contenido del sidebar derecho (chat y lista de alumnos) ---
chat_title = tk.Label(frame_derecho, text="Chat", font=("Helvetica", 14, "bold"), bg=PALETTE["sidebar"]) chat_title = tk.Label(frame_derecho, text="Chat Servidor", font=("Helvetica", 14, "bold"), bg=PALETTE["sidebar"])
chat_title.pack(pady=(8,8)) chat_title.pack(pady=(8,8))
msg_label = tk.Label(frame_derecho, text="Mensaje", bg=PALETTE["sidebar"], font=FONT_NORMAL) # Área de logs del chat (solo lectura)
chat_log = scrolledtext.ScrolledText(frame_derecho, height=10, width=26, bd=0, relief="flat", state="disabled", font=("Helvetica", 9))
chat_log.pack(padx=8, pady=(0, 6), fill="x", expand=False)
msg_label = tk.Label(frame_derecho, text="Mensaje Admin", bg=PALETTE["sidebar"], font=FONT_NORMAL)
msg_label.pack(padx=8, anchor="w") msg_label.pack(padx=8, anchor="w")
msg_text = tk.Text(frame_derecho, height=6, width=26, bd=0, relief="flat") msg_text = tk.Text(frame_derecho, height=3, width=26, bd=0, relief="flat")
msg_text.pack(padx=8, pady=(6,8), fill="x") msg_text.pack(padx=8, pady=(2,8), fill="x")
send_btn = ttk.Button(frame_derecho, text="Enviar", style="Accent.TButton") send_btn = ttk.Button(frame_derecho, text="Enviar", style="Accent.TButton")
send_btn.pack(padx=8, pady=(0,12)) send_btn.pack(padx=8, pady=(0,12))
# Variable global para la instancia del servidor
global_chat_server = None
def append_chat_log(msg):
"""Callback para añadir logs al área de chat del sidebar"""
def _u():
chat_log.config(state="normal")
chat_log.insert("end", str(msg) + "\n")
chat_log.see("end")
chat_log.config(state="disabled")
# Asegurar ejecución en hilo principal
try:
chat_log.after(0, _u)
except Exception:
pass
alumnos_label = tk.Label(frame_derecho, text="Alumnos", bg=PALETTE["sidebar"], font=FONT_TITLE) alumnos_label = tk.Label(frame_derecho, text="Alumnos", bg=PALETTE["sidebar"], font=FONT_TITLE)
alumnos_label.pack(padx=8, anchor="w") alumnos_label.pack(padx=8, anchor="w")
@ -972,16 +1118,22 @@ btn_buscar.config(command=fetch_weather_xabia)
# Los controles de alarma y reproducción de música están disponibles en la pestaña "Enlaces" # Los controles de alarma y reproducción de música están disponibles en la pestaña "Enlaces"
# Enviar mensaje (simulado) # Enviar mensaje (Chat Servidor)
def send_message(): def send_message(event=None):
# Si viene de evento KeyRelease/Return, evitar salto de línea extra si es Text
text = msg_text.get("1.0", "end-1c").strip() text = msg_text.get("1.0", "end-1c").strip()
if not text: if not text:
mb.showwarning("Mensaje", "El mensaje está vacío") return "break"
return
mb.showinfo("Mensaje", "Mensaje enviado (simulado)") if global_chat_server and global_chat_server.running:
msg_text.delete("1.0", "end") global_chat_server.send_server_message(text)
msg_text.delete("1.0", "end")
else:
mb.showwarning("Chat Servidor", "El servidor no está iniciado.\nVe a 'Enlaces > Chat TCP' e inicia el servidor.")
return "break"
send_btn.config(command=send_message) send_btn.config(command=send_message)
msg_text.bind("<Return>", send_message)
# Dividir el frame central en dos partes (superior variable e inferior fija) # Dividir el frame central en dos partes (superior variable e inferior fija)
frame_central.rowconfigure(0, weight=1) # Parte superior, tamaño variable frame_central.rowconfigure(0, weight=1) # Parte superior, tamaño variable
@ -1155,6 +1307,7 @@ links_frame = tk.Frame(tab_enlaces)
links_frame.pack(fill="both", expand=True, padx=8, pady=8) links_frame.pack(fill="both", expand=True, padx=8, pady=8)
ttk.Button(links_frame, text="Abrir Visual Studio Code", command=lambda: launch_app("/Applications/Visual Studio Code.app"), style="Accent.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Abrir Visual Studio Code", command=lambda: launch_app("/Applications/Visual Studio Code.app"), style="Accent.TButton").pack(fill="x", pady=4)
ttk.Button(links_frame, text="Abrir Spotify", command=lambda: launch_app("/Applications/Spotify.app"), style="Secondary.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Abrir Spotify", command=lambda: launch_app("/Applications/Spotify.app"), style="Secondary.TButton").pack(fill="x", pady=4)
ttk.Button(links_frame, text="Chat TCP (Servidor/Cliente)", command=open_chat_window, style="Accent.TButton").pack(fill="x", pady=4)
ttk.Button(links_frame, text="Mostrar recursos (matplotlib)", command=open_resource_window, style="Secondary.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Mostrar recursos (matplotlib)", command=open_resource_window, style="Secondary.TButton").pack(fill="x", pady=4)
ttk.Button(links_frame, text="Reproducir música (archivo)", command=play_music_file, style="Secondary.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Reproducir música (archivo)", command=play_music_file, style="Secondary.TButton").pack(fill="x", pady=4)
ttk.Button(links_frame, text="Detener música", command=stop_music, style="Secondary.TButton").pack(fill="x", pady=4) ttk.Button(links_frame, text="Detener música", command=stop_music, style="Secondary.TButton").pack(fill="x", pady=4)

1210
proyecto.py.bak Normal file

File diff suppressed because it is too large Load Diff