myircbot/bot.py
2025-05-29 22:58:53 +02:00

658 lines
28 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <comando> 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 <plugin>` → Cargar un plugin",
"- `unload <plugin>` → Descargar un plugin",
"- `reload_plugins` → Recargar todos los plugins",
"- `quit` → Apagar el bot",
"- `restart` → Reiniciar el bot",
"- `reconnect` → Reconectar al servidor IRC",
"- `nick <nuevo_nick>` → Cambiar el apodo del bot",
"- `msg <#canal> <mensaje>` → Enviar mensaje a un canal",
"- `raw <comando>` → Enviar un comando IRC crudo",
"- `kick <#canal> <usuario>` → Expulsar usuario",
"- `ban <#canal> <usuario>` → Banear usuario",
"- `unban <#canal> <usuario>` → Desbanear usuario",
"- `op <#canal> <usuario>` → Dar OP a un usuario",
"- `deop <#canal> <usuario>` → Quitar OP a un usuario",
"- `logout` → Cerrar sesión de admin",
"🆘 Usa `.help <comando>` 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 <contraseña>' 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="."
)