import socket import ssl import base64 import os import threading import importlib import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry import queue import time from plugins import news from plugins import radio from plugins.database import store_message import traceback from plugins.database import init_db as init_db_messages from plugins.auth import init_db as init_db_auth # Inicializar ambas bases de datos init_db_messages() # Base de datos de mensajes init_db_auth() ADMIN_PASSWORD = os.getenv("IRC_ADMIN_PASSWORD") password = os.getenv("IRC_BOT_PASSWORD") if not password: raise ValueError("Error: La variable de entorno IRC_BOT_PASSWORD no está definida.") class IRCBot: def __init__(self, server, port, nickname, username, password, channels, command_prefix="!", control_socket="/tmp/ircbot.sock"): self.server = server self.port = port self.nickname = nickname self.username = username self.password = password self.channels = channels # Lista de canales self.command_prefix = command_prefix self.control_socket = control_socket self.authenticated = False self.joined = False self.plugins = {} self.admins = set() # Lista de administradores autenticados self.msg_queue = queue.Queue() self.running = True # Inicia el hilo que envía los mensajes en cola (para throttling). threading.Thread(target=self._message_worker, daemon=True).start() # Carga plugins self.load_plugins() # Monitoreo en un hilo separado (ejemplo: radio, news) threading.Thread(target=radio.monitor_liquidsoap, daemon=True).start() news_plugin = news.NewsPlugin() # Instancia de la clase threading.Thread(target=news_plugin.announce_news, args=(self.send_raw, self), daemon=True).start() # Conexión TLS context = ssl.create_default_context() self.sock = socket.create_connection((self.server, self.port)) self.sock = context.wrap_socket(self.sock, server_hostname=self.server) print(f" Conectado a {self.server}:{self.port}") # SASL self.send_raw("CAP REQ :sasl") self.send_raw(f"NICK {self.nickname}") self.send_raw(f"USER {self.username} 0 * :{self.nickname}") # Iniciar socket UNIX (control externo) threading.Thread(target=self.control_socket_server, daemon=True).start() # Iniciar escucha del servidor IRC self.listen() def load_plugins(self, plugin_dir="plugins"): """Carga o recarga dinámicamente los plugins desde el directorio especificado.""" if not os.path.exists(plugin_dir): os.makedirs(plugin_dir) self.plugins = {} # Reinicia la lista de plugins for file in os.listdir(plugin_dir): if file.endswith(".py") and file != "__init__.py": name = file[:-3] # Nombre sin `.py` try: module = importlib.import_module(f"{plugin_dir}.{name}") # Plugins especiales con lógica personalizada if name == "grab" and hasattr(module, "GrabPlugin"): self.plugins["grab"] = module.GrabPlugin() print(f" Plugin especial '{name}' instanciado.") continue if name == "alias" and hasattr(module, "AliasPlugin"): alias_plugin = module.AliasPlugin(self.send_raw, self) self.plugins["alias"] = alias_plugin # Registrar alias como comandos dynamic_cmds = alias_plugin.get_dynamic_commands() for alias_name, alias_function in dynamic_cmds.items(): self.plugins[alias_name] = alias_function print(f" Alias dinámico registrado: {alias_name}") continue if name == "uptime" and hasattr(module, "UptimePlugin"): self.plugins["uptime"] = module.UptimePlugin(self) print(f" Plugin especial '{name}' instanciado con `bot`.") continue if name == "give" and hasattr(module, "GivePlugin"): self.plugins["give"] = module.GivePlugin(self.plugins) print(f" Plugin especial '{name}' instanciado con referencia a otros plugins.") continue # Instanciar clases convencionales como XyzPlugin class_name = f"{name.capitalize()}Plugin" if hasattr(module, class_name): self.plugins[name] = getattr(module, class_name)() print(f" Plugin '{name}' instanciado como clase ({class_name}).") continue # Si tiene run() directamente, cargar como módulo plano if hasattr(module, "run"): self.plugins[name] = module print(f" Plugin '{name}' cargado como módulo.") continue print(f" ⚠️ El plugin '{name}' no tiene 'run()' ni clase '{class_name}', se omitirá.") except Exception as e: error_trace = traceback.format_exc() print(f" ❌ Error al cargar el plugin '{name}': {e}\n{error_trace}") print(" ✅ Todos los plugins han sido cargados correctamente.") def send_raw(self, message): """Envía un mensaje al servidor IRC inmediatamente (para conexiones, etc.).""" try: if self.sock and not self.sock._closed: print(f">> {message}") self.sock.send((message + "\r\n").encode("utf-8")) else: print("️ Socket no disponible, intentando reconectar...") self.connect() except Exception as e: print(f" Error al enviar mensaje: {e}") self.handle_reconnect() def send_queued_message(self, target, message): """Coloca un mensaje en cola para enviarlo con retraso (throttling).""" max_length = 400 # Máx. caracteres para evitar flood if len(message) > max_length: parts = [message[i:i + max_length] for i in range(0, len(message), max_length)] for part in parts: self.msg_queue.put(f"PRIVMSG {target} :{part}") else: self.msg_queue.put(f"PRIVMSG {target} :{message}") def _message_worker(self): """Hilo que gestiona la salida de mensajes con throttling.""" while self.running: try: message = self.msg_queue.get() self.send_raw(message) time.sleep(1) # Ajusta el delay si el servidor lo requiere except Exception as e: print(f" Error al enviar mensaje de la cola: {e}") def join_channels(self): """Unirse a todos los canales configurados.""" for channel in self.channels: self.send_raw(f"JOIN {channel}") print(f" Unido a {channel}") self.joined = True def listen(self): """Escucha mensajes del servidor y maneja comandos.""" print(" Escuchando mensajes del servidor...") while True: try: data = self.sock.recv(4096).decode("utf-8", errors="ignore").strip() if not data: print(" El servidor cerró la conexión.") break for line in data.split("\r\n"): if not line: continue print(f"<< {line}") # Debug if line.startswith("PING"): self.send_raw(f"PONG {line.split()[1]}") if "CAP" in line and "ACK :sasl" in line: self.send_raw("AUTHENTICATE PLAIN") if "AUTHENTICATE +" in line: auth_string = base64.b64encode( f"{self.username}\0{self.username}\0{self.password}".encode() ).decode() self.send_raw(f"AUTHENTICATE {auth_string}") if " 900 " in line or " 903 " in line: print(" SASL autenticado correctamente.") self.authenticated = True self.send_raw("CAP END") if ((" 376 " in line) or (" 422 " in line)) and self.authenticated and not self.joined: self.join_channels() # Mensajes PRIVMSG if "PRIVMSG" in line: self.handle_message(line) except Exception as e: print(f" Error en la conexión: {e}") break def handle_message(self, raw_line): """ Procesa los PRIVMSG del IRC y maneja autenticación para plugins restringidos. """ parts = raw_line.split() if len(parts) < 4: return sender_info = parts[0] command_type = parts[1] target = parts[2] msg_raw = " ".join(parts[3:])[1:] # Quitar el ':' inicial sender_nick = sender_info.split("!")[0][1:] # **Verificar si el mensaje es un privado (PM)** if target.lower() == self.nickname.lower(): print(f"DEBUG: Recibido PM de {sender_nick}: {msg_raw}") # Log de depuración self.handle_private_message(sender_nick, msg_raw) return # ️ Evitar almacenar comandos sensibles como `.auth login usuario contraseña` SENSITIVE_COMMANDS = ["auth", "login", "register"] if msg_raw.startswith(self.command_prefix): command_line = msg_raw[len(self.command_prefix):].strip() tokens = command_line.split() if tokens and tokens[0] in SENSITIVE_COMMANDS: print(f"️ Mensaje de {sender_nick} omitido (comando sensible): {msg_raw}") else: store_message(sender_nick, target, msg_raw) # Guardar en SQLite si NO es un comando sensible else: store_message(sender_nick, target, msg_raw) # Guardar en SQLite # **Guardar última frase del usuario para `.grab`** if "grab" in self.plugins: try: self.plugins["grab"].store_last_message(sender_nick, msg_raw) except Exception as e: print(f"️ Error al almacenar mensaje en grab: {e}") # **Procesar comandos si empiezan con el prefijo** if command_type == "PRIVMSG" and msg_raw.startswith(self.command_prefix): command_line = msg_raw[len(self.command_prefix):].strip() tokens = command_line.split() if not tokens: return cmd = tokens[0] # Comando (ej: "give", "alias", "grab", "ping") args = tokens[1:] # **Verificar si el usuario es admin y saltar autenticación** if sender_nick in self.admins: print(f" {sender_nick} es admin, ejecutando '.{cmd}' sin restricciones.") else: # **Verificar si el comando requiere autenticación** restricted_plugins = ["grab"] if cmd in restricted_plugins: if "auth" in self.plugins and not self.plugins["auth"].is_authenticated(sender_nick): self.send_queued_message(target, f" {sender_nick}, debes autenticarte para usar '.{cmd}'") return # **Si el comando existe en `self.plugins`, ejecutarlo** if cmd in self.plugins: plugin_or_func = self.plugins[cmd] try: if callable(plugin_or_func): response = plugin_or_func(sender_nick, *args) else: response = plugin_or_func.run(sender_nick, *args) if isinstance(response, str) and response: for line in response.split("\n"): self.send_queued_message(target, line) except Exception as e: error_trace = traceback.format_exc() print(f" Error en '{cmd}': {e}\n{error_trace}") self.send_queued_message(target, f"️ Error en '{cmd}': {str(e)}") elif cmd == "help": if args: cmd_help = args[0] if cmd_help in self.plugins and hasattr(self.plugins[cmd_help], "help"): desc = self.plugins[cmd_help].help() self.send_queued_message(target, desc) else: self.send_queued_message(target, f" No hay ayuda disponible para '.{cmd_help}'") else: # Listar comandos disponibles disponibles = " ".join([f"`.{p}`" for p in self.plugins.keys()]) lines = [ " **Comandos disponibles:**", disponibles, " ℹ️ Usa .help para más detalles." ] for l in lines: self.send_queued_message(target, l) else: # Comando no encontrado self.send_queued_message(target, f" No existe el comando '.{cmd}'") def handle_private_message(self, sender, msg): """Maneja mensajes privados, autenticación de administradores y comandos de plugins.""" print(f" Mensaje privado recibido de {sender}: {msg}") # DEBUG # **Manejar autenticación de administradores con `/msg bot op password`** if msg.startswith("op "): password_provided = msg.split(" ", 1)[1] print(f" Intento de autenticación de {sender} con contraseña: {password_provided}") # DEBUG if password_provided == ADMIN_PASSWORD: self.admins.add(sender) self.send_raw(f"PRIVMSG {sender} : Has sido autenticado como administrador.") print(f" {sender} ahora es administrador.") # DEBUG else: self.send_raw(f"PRIVMSG {sender} : Contraseña incorrecta.") print(f" {sender} intentó autenticarse con una contraseña incorrecta.") # DEBUG return # **Si el mensaje comienza con el prefijo de comandos, procesarlo** if msg.startswith(self.command_prefix): command_line = msg[len(self.command_prefix):].strip() tokens = command_line.split() if not tokens: return cmd = tokens[0] # Extraer el comando (`auth`, `grab`, etc.) args = tokens[1:] # Extraer los argumentos if cmd in self.plugins: try: print(f" Ejecutando {cmd} en PM con args={args}") # Debug response = self.plugins[cmd].run(sender, *args) # FIX: Solo un `sender` if response: self.send_raw(f"PRIVMSG {sender} :{response}") return except Exception as e: self.send_raw(f"PRIVMSG {sender} : Error en '{cmd}': {str(e)}") print(f" Error en '{cmd}': {e}") return # **Si el usuario es administrador, manejar comandos especiales** if sender in self.admins: parts = msg.split() if len(parts) == 0: return # Si el mensaje está vacío, salir command = parts[0] # Extraer el comando principal print(f"️ {sender} ejecutando comando admin: {command}") if command == "status": self.send_raw(f"PRIVMSG {sender} :Bot activo | Plugins cargados: {len(self.plugins)}") elif command == "join" and len(parts) > 1: self.send_raw(f"JOIN {parts[1]}") self.send_raw(f"PRIVMSG {sender} :Unido a {parts[1]}") elif command == "part" and len(parts) > 1: self.send_raw(f"PART {parts[1]}") self.send_raw(f"PRIVMSG {sender} :Salió de {parts[1]}") elif command == "list_plugins": plist = ", ".join(self.plugins.keys()) if self.plugins else "No hay plugins cargados." self.send_raw(f"PRIVMSG {sender} :Plugins cargados: {plist}") elif command == "load" and len(parts) > 1: plugin_name = parts[1] try: module = importlib.import_module(f"plugins.{plugin_name}") self.plugins[plugin_name] = module self.send_raw(f"PRIVMSG {sender} :Plugin {plugin_name} cargado.") print(f" {sender} cargó el plugin {plugin_name}") except Exception as e: self.send_raw(f"PRIVMSG {sender} :Error al cargar plugin {plugin_name}: {e}") elif command == "unload" and len(parts) > 1: plugin_name = parts[1] if plugin_name in self.plugins: del self.plugins[plugin_name] self.send_raw(f"PRIVMSG {sender} :Plugin {plugin_name} descargado.") print(f" {sender} descargó el plugin {plugin_name}") else: self.send_raw(f"PRIVMSG {sender} :Plugin {plugin_name} no encontrado.") elif command == "reload_plugins": self.load_plugins() self.send_raw(f"PRIVMSG {sender} :Plugins recargados correctamente.") elif command == "quit": self.send_raw("QUIT :Apagando bot...") self.send_raw(f"PRIVMSG {sender} :Bot desconectado.") os._exit(0) elif command == "restart": self.send_raw("QUIT :Reiniciando bot...") self.send_raw(f"PRIVMSG {sender} :Bot reiniciándose.") os.execv(__file__, []) elif command == "reconnect": self.send_raw("QUIT :Reconectando...") self.send_raw(f"PRIVMSG {sender} :Reconectando al servidor IRC.") self.sock.close() self.__init__(self.server, self.port, self.nickname, self.username, self.password, self.channels) elif command == "nick" and len(parts) > 1: self.send_raw(f"NICK {parts[1]}") self.send_raw(f"PRIVMSG {sender} :🆔 Nick cambiado a {parts[1]}") elif command == "msg" and len(parts) > 2: channel = parts[1] message = " ".join(parts[2:]) self.send_raw(f"PRIVMSG {channel} :{message}") self.send_raw(f"PRIVMSG {sender} :Mensaje enviado a {channel}") elif command == "raw" and len(parts) > 1: raw_cmd = " ".join(parts[1:]) self.send_raw(raw_cmd) self.send_raw(f"PRIVMSG {sender} :Comando IRC enviado: {raw_cmd}") elif command == "kick" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"KICK {channel} {user} :Expulsado por {sender}") self.send_raw(f"PRIVMSG {sender} :{user} ha sido expulsado de {channel}") elif command == "ban" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} +b {user}") self.send_raw(f"PRIVMSG {sender} :{user} ha sido baneado en {channel}") elif command == "unban" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} -b {user}") self.send_raw(f"PRIVMSG {sender} :{user} ha sido desbaneado en {channel}") elif command == "op" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} +o {user}") self.send_raw(f"PRIVMSG {sender} :{user} ahora tiene OP en {channel}") elif command == "deop" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} -o {user}") self.send_raw(f"PRIVMSG {sender} :{user} ya no tiene OP en {channel}") elif command == "help": comandos = [ " **Comandos disponibles:**", "- `status` → Estado del bot y plugins", "- `join <#canal>` → Unirse a un canal", "- `part <#canal>` → Salir de un canal", "- `list_plugins` → Listar plugins activos", "- `load ` → Cargar un plugin", "- `unload ` → Descargar un plugin", "- `reload_plugins` → Recargar todos los plugins", "- `quit` → Apagar el bot", "- `restart` → Reiniciar el bot", "- `reconnect` → Reconectar al servidor IRC", "- `nick ` → Cambiar el apodo del bot", "- `msg <#canal> ` → Enviar mensaje a un canal", "- `raw ` → Enviar un comando IRC crudo", "- `kick <#canal> ` → Expulsar usuario", "- `ban <#canal> ` → Banear usuario", "- `unban <#canal> ` → Desbanear usuario", "- `op <#canal> ` → Dar OP a un usuario", "- `deop <#canal> ` → Quitar OP a un usuario", "- `logout` → Cerrar sesión de admin", "🆘 Usa `.help ` para obtener ayuda detallada.", ] for line in comandos: self.send_queued_message(sender, line) elif command == "logout": self.admins.discard(sender) self.send_raw(f"PRIVMSG {sender} :Has cerrado sesión como administrador.") print(f" {sender} ha cerrado sesión.") else: self.send_raw(f"PRIVMSG {sender} :Comando no reconocido. Usa 'help' para ver opciones.") else: self.send_raw(f"PRIVMSG {sender} :No tienes permisos. Usa 'op ' para autenticarte.") def control_socket_server(self): """Crea un socket UNIX para recibir comandos desde consola o socat.""" if os.path.exists(self.control_socket): os.remove(self.control_socket) server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) server.bind(self.control_socket) server.listen(1) print(f" Control socket activo en {self.control_socket}") while True: conn, _ = server.accept() with conn: data = conn.recv(1024).decode().strip() if not data: continue print(f" Comando recibido desde consola: {data}") parts = data.split() if not parts: continue cmd = parts[0] if cmd == "join" and len(parts) > 1: self.send_raw(f"JOIN {parts[1]}") print(f" Unido a {parts[1]}") elif cmd == "part" and len(parts) > 1: self.send_raw(f"PART {parts[1]}") print(f" Salió de {parts[1]}") elif cmd == "list_channels": ch_list = ", ".join(self.channels) conn.sendall(f"Canales actuales: {ch_list}\n".encode()) elif cmd == "load" and len(parts) > 1: plug = parts[1] try: module = importlib.import_module(f"plugins.{plug}") self.plugins[plug] = module print(f" Plugin {plug} cargado") conn.sendall(f"Plugin {plug} cargado\n".encode()) except Exception as e: print(f" Error al cargar plugin {plug}: {e}") conn.sendall(f"Error al cargar plugin {plug}: {e}\n".encode()) elif cmd == "unload" and len(parts) > 1: plug = parts[1] if plug in self.plugins: del self.plugins[plug] print(f"️ Plugin {plug} descargado") conn.sendall(f"Plugin {plug} descargado\n".encode()) else: conn.sendall(f"Plugin {plug} no encontrado\n".encode()) elif cmd == "list_plugins": plist = ", ".join(self.plugins.keys()) if self.plugins else "No hay plugins cargados." conn.sendall(f"Plugins cargados: {plist}\n".encode()) elif cmd == "quit": self.send_raw("QUIT :Apagando bot...") conn.sendall(" Bot desconectado.\n".encode()) os._exit(0) elif cmd == "msg" and len(parts) > 2: channel = parts[1] message = " ".join(parts[2:]) self.send_raw(f"PRIVMSG {channel} :{message}") conn.sendall(f" Mensaje enviado a {channel}\n".encode()) elif cmd == "raw" and len(parts) > 1: raw_command = " ".join(parts[1:]) self.send_raw(raw_command) conn.sendall(f"️ Comando IRC enviado: {raw_command}\n".encode()) elif cmd == "kick" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"KICK {channel} {user} :Expulsado") conn.sendall(f" {user} ha sido expulsado de {channel}\n".encode()) elif cmd == "ban" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} +b {user}") conn.sendall(f" {user} ha sido baneado de {channel}\n".encode()) elif cmd == "unban" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} -b {user}") conn.sendall(f" {user} ha sido desbaneado de {channel}\n".encode()) elif cmd == "op" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} +o {user}") conn.sendall(f"⭐ {user} ahora tiene OP en {channel}\n".encode()) elif cmd == "deop" and len(parts) > 2: channel = parts[1] user = parts[2] self.send_raw(f"MODE {channel} -o {user}") conn.sendall(f"️ {user} ya no tiene OP en {channel}\n".encode()) else: # Envía el texto tal cual self.send_raw(data) def handle_reconnect(self): """Reintenta la conexión al servidor IRC tras una desconexión.""" print(" 🔁 Intentando reconectar...") try: context = ssl.create_default_context() self.sock = socket.create_connection((self.server, self.port)) self.sock = context.wrap_socket(self.sock, server_hostname=self.server) # Reautenticación y rejoin self.authenticated = False self.joined = False self.send_raw("CAP REQ :sasl") self.send_raw(f"NICK {self.nickname}") self.send_raw(f"USER {self.username} 0 * :{self.nickname}") print(" ✅ Reconexión iniciada correctamente.") except Exception as e: print(f" ❌ Error durante la reconexión: {e}") time.sleep(5) self.handle_reconnect() # Reintento recursivo (con cuidado) if __name__ == "__main__": bot = IRCBot( server="irc.libera.chat", port=6697, nickname="terryflaps", username="terryflaps", password=password, channels=["#testtest", "#terryflaps"], command_prefix="." )