Files
biblioteca_conocimiento_lab…/vigilancia/pi_bot.py

1546 lines
64 KiB
Python

#!/usr/bin/env python3
import os
import re
import subprocess
import sys
import threading
import time
import urllib.parse
import json
import requests
# Estado por chat para la busqueda global interactiva (numero -> descarga)
ultima_titulos = {} # chat_id -> {'query':..., 'results':[...]}
ultimos_torrents = {} # chat_id -> {'titulo':..., 'torrents':[...]}
flujo_genero = {} # chat_id -> 'esperando_tipo' | 'esperando_genero' | None
flujo_genero_tipo = {} # chat_id -> 'movie' | 'series' (tipo ya elegido)
panel_descargas = {} # chat_id -> estado del panel editable
temporizadores_parental = {} # alias normalizado -> Timer de desbloqueo
menu_pendiente = {} # chat_id -> accion de texto libre esperada (menu botones)
menu_pausa_nombre = {} # chat_id -> nombre elegido en flujo de pausa
historial_chat = {} # chat_id -> [message_id...] enviados por el bot (para /limpiar)
entrantes_chat = {} # chat_id -> [message_id...] enviados por el usuario (para /limpiar)
borrar_pend = {} # chat_id -> lista de tareas NAS esperando numero a borrar
BASE = os.path.dirname(os.path.abspath(__file__))
LOG = os.path.join(BASE, "bot.log")
OFFSET_FILE = os.path.join(BASE, "bot.offset")
TV_API = "http://127.0.0.1:8600/tv/mensaje"
def log(linea):
try:
with open(LOG, "a") as f:
f.write(f"{time.strftime('%H:%M:%S')} {linea}\n")
except Exception:
pass
def load_env():
env = {}
try:
for line in open(os.path.join(BASE, ".env")):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
except Exception:
pass
return env
ENV = load_env()
TOKEN = ENV.get("TELEGRAM_TOKEN", "")
CHAT_ID = ENV.get("TELEGRAM_CHAT_ID", "")
ALERTA_TEMP = float(ENV.get("ALERTA_TEMP", "80"))
def sh(cmd):
try:
return subprocess.run(cmd, shell=True, capture_output=True,
text=True, timeout=10).stdout.strip()
except Exception:
return "n/a"
def estado():
temp = sh("vcgencmd measure_temp")
upt = sh("uptime -p")
load = open("/proc/loadavg").read().split()[:3]
free = sh("free -h | awk 'NR==2{print $2\" total, \"$7\" disponible\"}'")
disk = sh("df -h / | awk 'NR==2{print $3\" usados de \"$2\" (\"$5\")\"}'")
ips = sh("hostname -I")
temp_c = None
try:
temp_c = float(temp.replace("'C", "").replace("temp=", ""))
except Exception:
pass
msg = (
"📊 Estado de <b>minipc</b>:\n"
f"🌡️ Temperatura: <b>{temp}</b>\n"
f"🔌 Uptime: {upt}\n"
f"⚙️ Carga: {', '.join(load)}\n"
f"🧠 RAM: {free}\n"
f"💾 Disco /: {disk}\n"
f"🌐 IPs: {ips}"
)
return msg, temp_c
def enviar(texto, chat_id):
# Trocea mensajes largos (Telegram limita a ~4096 chars) para no fallar en silencio
ultimo_id = None
for i in range(0, len(texto), 4000):
bloque = texto[i:i + 4000]
try:
r = requests.post(
f"https://api.telegram.org/bot{TOKEN}/sendMessage",
data={"chat_id": chat_id, "text": bloque, "parse_mode": "HTML"},
timeout=10,
)
d = r.json()
if d.get("ok"):
ultimo_id = d["result"].get("message_id")
lista = historial_chat.setdefault(chat_id, [])
lista.append(ultimo_id)
if len(lista) > 300:
historial_chat[chat_id] = lista[-300:]
else:
log(f"Telegram sendMessage error: {d.get('description', d)}")
except Exception as e:
log(f"Telegram sendMessage exception: {e}")
return ultimo_id
def teclado_descargas():
return {
"inline_keyboard": [
[
{"text": "🔄 Actualizar", "callback_data": "dl:refresh"},
{"text": "▶️ Activas", "callback_data": "dl:active"},
],
[
{"text": "📁 Todas", "callback_data": "dl:all"},
{"text": "🗑️ Borrar…", "callback_data": "dl:del"},
{"text": "✖️ Cerrar", "callback_data": "dl:close"},
],
]
}
def enviar_panel_descargas(texto, chat_id):
try:
r = requests.post(
f"https://api.telegram.org/bot{TOKEN}/sendMessage",
data={
"chat_id": chat_id,
"text": texto,
"parse_mode": "HTML",
"reply_markup": json.dumps(teclado_descargas()),
},
timeout=10,
)
d = r.json()
if d.get("ok"):
return d["result"].get("message_id")
log(f"Telegram panel send error: {d.get('description', d)}")
except Exception as e:
log(f"Telegram panel send exception: {e}")
return None
def editar_panel_descargas(chat_id, message_id, texto):
try:
r = requests.post(
f"https://api.telegram.org/bot{TOKEN}/editMessageText",
data={
"chat_id": chat_id,
"message_id": message_id,
"text": texto,
"parse_mode": "HTML",
"reply_markup": json.dumps(teclado_descargas()),
},
timeout=10,
)
d = r.json()
if not d.get("ok") and "not modified" not in d.get("description", "").lower():
log(f"Telegram panel edit error: {d.get('description', d)}")
return d.get("ok", False)
except Exception as e:
log(f"Telegram panel edit exception: {e}")
return False
def responder_callback(callback_id):
try:
requests.post(
f"https://api.telegram.org/bot{TOKEN}/answerCallbackQuery",
data={"callback_query_id": callback_id},
timeout=10,
)
except Exception as e:
log(f"Telegram callback error: {e}")
def texto_panel_descargas(modo="active"):
import nas_dl
lineas, activas = nas_dl.listar_descargas(
hablar_solo_activas=(modo == "active")
)
encabezado = "📥 <b>DESCARGAS DEL NAS</b>"
if modo == "active":
encabezado += " · ACTIVAS"
texto = encabezado + "\n\n" + "\n".join(lineas)
texto += f"\n\n🕒 Actualizado: <code>{time.strftime('%H:%M:%S')}</code>"
return texto, activas
def abrir_panel_descargas(chat_id):
anterior = panel_descargas.pop(chat_id, None)
if anterior:
anterior["stop"].set()
try:
texto, _activas = texto_panel_descargas()
message_id = enviar_panel_descargas(texto, chat_id)
if not message_id:
enviar("❌ No se pudo abrir el panel de descargas.", chat_id)
return
estado_panel = {
"message_id": message_id,
"modo": "active",
"stop": threading.Event(),
}
panel_descargas[chat_id] = estado_panel
threading.Thread(
target=monitor_panel_descargas,
args=(chat_id, estado_panel),
daemon=True,
).start()
except Exception as e:
log(f"panel descargas error: {e}")
enviar(f"❌ Error en el panel de descargas: {e}", chat_id)
def monitor_panel_descargas(chat_id, estado_panel):
while not estado_panel["stop"].wait(20):
try:
texto, activas = texto_panel_descargas(estado_panel["modo"])
editar_panel_descargas(chat_id, estado_panel["message_id"], texto)
if not activas:
break
except Exception as e:
log(f"monitor panel descargas error: {e}")
def procesar_callback(callback):
callback_id = callback.get("id")
mensaje = callback.get("message") or {}
chat_id = (mensaje.get("chat") or {}).get("id")
data = callback.get("data") or ""
responder_callback(callback_id)
# Menu de botones (menu_idd)
if data.startswith("m:"):
try:
from menu_idd import procesar_callback as menu_cb
menu_cb(data[2:], chat_id, mensaje.get("message_id"))
except Exception as e:
log(f"menu callback error: {e}")
return
accion = data.split(":", 1)[-1]
estado_panel = panel_descargas.get(chat_id)
if accion == "del":
pedir_borrar(chat_id)
return
if not estado_panel:
return
if accion == "close":
estado_panel["stop"].set()
panel_descargas.pop(chat_id, None)
borrar(chat_id, estado_panel["message_id"])
return
if accion in ("active", "all"):
estado_panel["modo"] = "active" if accion == "active" else "all"
try:
texto, _activas = texto_panel_descargas(estado_panel["modo"])
editar_panel_descargas(chat_id, estado_panel["message_id"], texto)
except Exception as e:
log(f"callback panel descargas error: {e}")
def borrar(chat_id, message_id):
try:
requests.post(
f"https://api.telegram.org/bot{TOKEN}/deleteMessage",
data={"chat_id": chat_id, "message_id": message_id},
timeout=10,
)
except Exception as e:
print("Error borrando:", e)
def limpiar_pantalla(chat_id):
"""Borra TODO el chat visible: mensajes del bot y del usuario (/limpiar).
Limite de Telegram: solo mensajes de menos de 48h se pueden borrar."""
lista = historial_chat.pop(chat_id, None) or []
lista += entrantes_chat.pop(chat_id, None) or []
borrados = 0
for mid in reversed(lista):
try:
r = requests.post(
f"https://api.telegram.org/bot{TOKEN}/deleteMessage",
data={"chat_id": chat_id, "message_id": mid},
timeout=8,
)
if r.json().get("ok"):
borrados += 1
except Exception:
pass
aviso = enviar(f"🧹 Chat limpio ({borrados} mensajes borrados).", chat_id)
if aviso:
threading.Timer(6.0, borrar, args=(chat_id, aviso)).start()
def pedir_borrar(chat_id):
"""Pide que se elija el numero de la tarea NAS a borrar."""
try:
import nas_dl
except Exception as e:
log(f"pedir_borrar import error: {e}")
enviar(f"❌ Error cargando el módulo del NAS: {e}", chat_id)
return
try:
tareas = nas_dl.listar_tareas()
except Exception as e:
log(f"listar tareas error: {e}")
enviar(f"❌ Error listando descargas: {e}", chat_id)
return
if not tareas:
enviar("No hay tareas en Download Station.", chat_id)
return
borrar_pend[chat_id] = tareas
iconos = {"downloading": "▶️", "waiting": "⏳", "error": "❌",
"finished": "✅", "paused": "⏸️", "seeding": "🌱"}
lineas = ["🗑️ <b>Responde el número</b> de la tarea a BORRAR "
"(o escribe <code>cancelar</code>):", ""]
for i, t in enumerate(tareas, start=1):
ic = iconos.get(t["status"], "•")
titulo = (t["title"] or "?").strip()
lineas.append(f"{i}. {ic} [{t['status']}] ({t['pct']:.0f}%) {titulo[:70]}")
enviar("\n".join(lineas), chat_id)
def _ejecutar_borrado(chat_id, numero):
tareas = borrar_pend.pop(chat_id, None)
if not tareas:
return False
if numero < 1 or numero > len(tareas):
enviar("❌ Número no válido.", chat_id)
return True
tarea = tareas[numero - 1]
try:
import nas_dl
except Exception as e:
enviar(f"❌ Error cargando el módulo del NAS: {e}", chat_id)
return True
ok, detalle = nas_dl.borrar_tarea(tarea["id"])
titulo = (tarea["title"] or "?").strip()
if ok:
enviar(f"🗑️ Tarea borrada: <b>{titulo[:80]}</b>", chat_id)
else:
enviar(f"❌ No se pudo borrar <b>{titulo[:60]}</b>: {detalle}", chat_id)
# Cierra el panel de descargas, limpia toda la pantalla y deja
# el mensaje para volver al menu.
try:
estado_panel = panel_descargas.pop(chat_id, None)
if estado_panel and estado_panel.get("message_id"):
borrar(chat_id, estado_panel["message_id"])
except Exception:
pass
time.sleep(4)
try:
limpiar_pantalla(chat_id)
except Exception:
pass
enviar("✅ Hecho. Pulsa /menu para volver al menú.", chat_id)
return True
def mandar_tv(mensaje, simple):
try:
url = f"{TV_API}?texto={urllib.parse.quote(mensaje)}"
if simple:
url += "&simple=1"
r = requests.get(url, timeout=8)
return r.status_code == 200
except Exception as e:
log(f"tv error: {e}")
return False
def mandar_youtube(url):
try:
r = requests.get(f"http://127.0.0.1:8600/tv/youtube?url={url}", timeout=10)
return r.status_code == 200
except Exception as e:
log(f"youtube error: {e}")
return False
def procesar(texto, chat_id):
t = texto.lower().strip()
log(f"RECIBIDO chat={chat_id} texto={texto!r}")
partes = t.split(None, 1)
comando = partes[0].split("@", 1)[0].lstrip("/") if partes else ""
argumento = partes[1].strip() if len(partes) > 1 else ""
# Texto libre pendiente pedido desde el menu de botones
try:
import menu_idd
except Exception:
menu_idd = None
if menu_idd is not None:
if t.startswith("/") or t in ("cancelar", "cancel", "salir"):
if menu_idd.pendiente_activo(chat_id):
menu_idd.cancelar_pendiente(chat_id)
if t in ("cancelar", "cancel", "salir"):
return
# Venia del menu: procesar y devolver al menu.
procesar(texto, chat_id)
try:
menu_idd.volver_al_menu(chat_id,
menu_idd.categoria_de(comando))
except Exception:
pass
return
elif menu_idd.pendiente_activo(chat_id):
if menu_idd.resolver_pausa_minutos(chat_id, texto):
return
cmd = menu_idd.construir_comando(chat_id, texto)
menu_idd.cancelar_pendiente(chat_id)
if cmd == "INVALIDO":
enviar("⏱️ Escribe un número de minutos entre 1 y 1440.", chat_id)
return
if cmd:
procesar(cmd, chat_id)
try:
menu_idd.volver_al_menu(
chat_id,
menu_idd.categoria_de(cmd.split(None, 1)[0]))
except Exception:
pass
return
# Borrado de descargas pendiente (numero de tarea NAS)
if t.isdigit() and chat_id in borrar_pend:
_ejecutar_borrado(chat_id, int(t))
return
# Flujo interactivo por genero (espera numeros en pasos)
if flujo_genero.get(chat_id) and t.isdigit():
paso = flujo_genero[chat_id]
if paso == "esperando_tipo":
if t == "1":
flujo_genero_tipo[chat_id] = "movie"
flujo_genero[chat_id] = "esperando_genero"
_pedir_genero(chat_id, "🎬")
elif t == "2":
flujo_genero_tipo[chat_id] = "series"
flujo_genero[chat_id] = "esperando_genero"
_pedir_genero(chat_id, "📺")
else:
enviar("Opción inválida. Responde <b>1</b> para Películas o <b>2</b> para Series.", chat_id)
elif paso == "esperando_genero":
n = int(t)
gol = _import_global_buscar()
if 1 <= n <= len(gol.GENEROS):
genero = gol.GENEROS[n - 1]
tipo = flujo_genero_tipo.get(chat_id, "movie")
flujo_genero.pop(chat_id, None)
flujo_genero_tipo.pop(chat_id, None)
_mostrar_catalogo_genero(chat_id, tipo, genero, "top")
else:
enviar("Número de género inválido.", chat_id)
return
if t in ("genero", "/genero"):
procesar_genero(chat_id)
elif t in ("estado", "/estado", "status", "/status"):
msg, _ = estado()
enviar(msg, chat_id)
elif "temperatura" in t or "temp" in t:
temp = sh("vcgencmd measure_temp")
enviar(f"🌡️ Temperatura de la Pi: <b>{temp}</b>", chat_id)
elif t.startswith("tv ") or t.startswith("/tv "):
msj = texto.split(" ", 1)[1].strip()
if not msj:
enviar("Uso: <b>tv &lt;mensaje&gt;</b> (mensaje rapido a la tele)", chat_id)
else:
ok = mandar_tv(msj, simple=True)
enviar("📺 Mensaje enviado a la tele ✅" if ok
else "❌ No se pudo enviar a la tele", chat_id)
elif t.startswith("alarma ") or t.startswith("/alarma "):
msj = texto.split(" ", 1)[1].strip()
if not msj:
enviar("Uso: <b>alarma &lt;mensaje&gt;</b> (secuencia completa: sirena x3)", chat_id)
else:
ok = mandar_tv(msj, simple=False)
enviar("🚨 Alarma lanzada en la tele ✅" if ok
else "❌ No se pudo lanzar la alarma", chat_id)
elif t.startswith("panorama") or t.startswith("/panorama"):
enviar("📷 Capturando snapshots de las cámaras...", chat_id)
try:
import subprocess as sp
r = sp.run(["python3", "/home/pi/minipc/vigilancia/panorama.py"],
capture_output=True, text=True, timeout=60, cwd="/home/pi/minipc/vigilancia")
log(f"panorama: {r.stdout.strip()[-200:]}")
except Exception as e:
log(f"panorama error: {e}")
elif t in ("limpieza", "/limpieza", "limpiar", "/limpiar", "clear", "/clear"):
if t in ("limpieza", "/limpieza"):
enviar("🧹 Ejecutando limpieza de procesos...", chat_id)
try:
from limpiar import limpiar
resultado = limpiar()
enviar(f"<pre>{resultado}</pre>", chat_id)
except Exception as e:
log(f"limpieza error: {e}")
enviar(f"❌ Error: {e}", chat_id)
else:
limpiar_pantalla(chat_id)
elif t.startswith("youtube ") or t.startswith("/youtube "):
url = texto.split(" ", 1)[1].strip()
if not url:
enviar("Uso: <b>youtube &lt;URL&gt;</b>", chat_id)
else:
ok = mandar_youtube(url)
enviar("🎬 YouTube lanzado en la TV ✅" if ok else "❌ No se pudo lanzar YouTube", chat_id)
elif comando == "borrar" and not argumento:
pedir_borrar(chat_id)
elif comando == "descargas" and argumento.lower() == "borrar":
pedir_borrar(chat_id)
elif t in ("descargas", "/descargas") or (comando == "descargar" and not argumento):
abrir_panel_descargas(chat_id)
elif comando == "descargar":
try:
import nas_dl
if argumento:
ok = nas_dl.add_torrent(argumento)
enviar("📥 Añadido a Download Station del NAS ✅" if ok
else "❌ No se pudo añadir al NAS. Revisa <code>bot.log</code>.", chat_id)
except Exception as e:
log(f"descargar error: {e}")
enviar(f"❌ Error en Download Station: {e}", chat_id)
elif comando in ("termo", "pantalla"):
accion = argumento.lower().strip()
acciones = {"on": "on", "encender": "on", "off": "off",
"apagar": "off", "estado": "status", "status": "status"}
if accion not in acciones:
enviar(f"Uso: <code>/{comando} on</code>, <code>/{comando} off</code> o <code>/{comando} estado</code>", chat_id)
else:
try:
from tapo_control import controlar
resultado = controlar(comando, acciones[accion])
enviar(f"🔌 <b>{resultado}</b>", chat_id)
except Exception as e:
log(f"{comando} error: {e}")
enviar(f"❌ Error controlando {comando}: {e}", chat_id)
elif comando == "meross":
try:
from meross_control import controlar as meross_controlar
tok = argumento.lower().strip().split()
if not tok:
nombre, accion = "", "estado"
elif tok[0] in ("estado", "on", "off") and len(tok) == 1:
nombre, accion = "", tok[0]
else:
nombre = tok[0]
accion = tok[1] if len(tok) > 1 else "estado"
if accion not in ("estado", "on", "off"):
enviar("Uso: <code>/meross [juanjo|nahid] estado|on|off</code>.", chat_id)
else:
for linea in meross_controlar(nombre, accion):
enviar(linea, chat_id)
except Exception as e:
log(f"meross error: {e}")
enviar(f"❌ Error Meross: {e}", chat_id)
elif comando == "router":
partes_router = argumento.lower().split()
accion_router = partes_router[0] if partes_router else "ayuda"
if accion_router in ("ayuda", "help"):
enviar(
"<b>ROUTER ASUS</b>\n"
"<code>/router estado</code> — modelo, firmware, IP y uptime\n"
"<code>/router clientes</code> — tabla ARP de clientes\n"
"<code>/router wifi</code> — radios Wi-Fi\n"
"<code>/router inventario</code> — leases DHCP con IP y MAC\n"
"<code>/router parental</code> — configuración parental actual\n"
"<code>/router reservar confirmar</code> — fija las IP actuales\n"
"<code>/router reiniciar confirmar</code> — reinicio protegido",
chat_id,
)
elif accion_router in ("estado", "status"):
try:
from router_control import estado as router_estado
enviar("📡 <b>ASUS</b>\n" + router_estado(), chat_id)
except Exception as e:
log(f"router estado error: {e}")
enviar(f"❌ Error consultando ASUS: {e}", chat_id)
elif accion_router in ("clientes", "clients"):
try:
from router_control import clientes
enviar("📶 <b>CLIENTES ASUS</b>\n" + clientes(), chat_id)
except Exception as e:
log(f"router clientes error: {e}")
enviar(f"❌ Error consultando clientes: {e}", chat_id)
elif accion_router == "wifi":
try:
from router_control import wifi
enviar("📡 <b>WIFI ASUS</b>\n" + wifi(), chat_id)
except Exception as e:
log(f"router wifi error: {e}")
enviar(f"❌ Error consultando Wi-Fi: {e}", chat_id)
elif accion_router == "inventario":
try:
from router_control import inventario
enviar("📋 <b>INVENTARIO DHCP ASUS</b>\n" + inventario(), chat_id)
except Exception as e:
log(f"router inventario error: {e}")
enviar(f"❌ Error consultando inventario: {e}", chat_id)
elif accion_router in ("parental", "control-parental"):
try:
from router_control import control_parental
enviar("👪 <b>CONTROL PARENTAL ASUS</b>\n" + control_parental(), chat_id)
except Exception as e:
log(f"router parental error: {e}")
enviar(f"❌ Error consultando control parental: {e}", chat_id)
elif accion_router == "reservar":
if len(partes_router) < 2 or partes_router[1] != "confirmar":
enviar("Para reservar las IP actuales responde exactamente: <code>/router reservar confirmar</code>", chat_id)
else:
try:
from router_control import reservar_dhcp
enviar("⏳ Aplicando reservas DHCP...", chat_id)
enviar(f"✅ {reservar_dhcp()}", chat_id)
except Exception as e:
log(f"router reservar error: {e}")
enviar(f"❌ Error aplicando reservas: {e}", chat_id)
elif accion_router in ("reiniciar", "reboot"):
if len(partes_router) < 2 or partes_router[1] != "confirmar":
enviar("Para reiniciar responde exactamente: <code>/router reiniciar confirmar</code>", chat_id)
else:
try:
from router_control import reiniciar
enviar("⚠️ Reiniciando el ASUS...", chat_id)
reiniciar()
except Exception as e:
log(f"router reinicio error: {e}")
enviar(f"❌ Error reiniciando ASUS: {e}", chat_id)
else:
enviar("Acción de router no reconocida. Usa <code>/router ayuda</code>.", chat_id)
elif comando == "parental":
partes_parental = argumento.split()
accion_parental = partes_parental[0].lower() if partes_parental else "listar"
try:
from asus_api_control import cambiar_bloqueo, normalizar_nombre
if accion_parental == "listar":
from router_control import control_parental
enviar("👪 <b>CONTROL PARENTAL ASUS</b>\n" + control_parental(), chat_id)
elif accion_parental in ("desactivar", "bloquear") and len(partes_parental) >= 2:
alias = " ".join(partes_parental[1:])
enviar(f"🔒 {cambiar_bloqueo(alias, True)}", chat_id)
elif accion_parental in ("activar", "desbloquear") and len(partes_parental) >= 2:
alias = " ".join(partes_parental[1:])
timer = temporizadores_parental.pop(normalizar_nombre(alias), None)
if timer:
timer.cancel()
enviar(f"🔓 {cambiar_bloqueo(alias, False)}", chat_id)
elif accion_parental == "pausa" and len(partes_parental) >= 3:
alias = " ".join(partes_parental[1:-1])
minutos = int(partes_parental[-1])
if minutos < 1 or minutos > 1440:
raise ValueError("Los minutos deben estar entre 1 y 1440")
cambiar_bloqueo(alias, True)
clave = normalizar_nombre(alias)
anterior = temporizadores_parental.pop(clave, None)
if anterior:
anterior.cancel()
timer = threading.Timer(
minutos * 60,
lambda: cambiar_bloqueo(alias, False),
)
timer.daemon = True
temporizadores_parental[clave] = timer
timer.start()
enviar(f"⏸️ Bloqueado {alias} durante {minutos} minutos.", chat_id)
else:
enviar(
"Uso: <code>/parental pausa Inés 60</code>, "
"<code>/parental desactivar Carolina</code> o "
"<code>/parental activar Inés</code>.", chat_id)
except Exception as e:
log(f"parental error: {e}")
enviar(f"❌ Error en control parental: {e}", chat_id)
elif comando in ("aimesh", "aiMesh", "mesh"):
partes_mesh = argumento.split()
accion_mesh = partes_mesh[0].lower() if partes_mesh else "estado"
try:
from asus_api_control import (
aimesh_estado,
aimesh_reconstruir,
aimesh_reiniciar_nodo,
)
if accion_mesh in ("estado", "nodos", "listar"):
enviar("🌐 <b>AIMESH ASUS</b>\n" + aimesh_estado(), chat_id)
elif accion_mesh == "reiniciar" and len(partes_mesh) >= 2:
if partes_mesh[-1] != "confirmar":
enviar("Para reiniciar un nodo responde: <code>/aiMesh reiniciar &lt;nodo&gt; confirmar</code>", chat_id)
else:
nodo = " ".join(partes_mesh[1:-1])
enviar(f"🔄 Reiniciando el nodo <b>{nodo}</b>...", chat_id)
enviar(aimesh_reiniciar_nodo(nodo), chat_id)
elif accion_mesh == "reconstruir":
if len(partes_mesh) < 2 or partes_mesh[-1] != "confirmar":
enviar("Para reconstruir la malla responde: <code>/aiMesh reconstruir confirmar</code>", chat_id)
else:
enviar("⚠️ Reconstruyendo la malla AiMesh...", chat_id)
enviar(aimesh_reconstruir(), chat_id)
else:
enviar(
"Uso:\n<code>/aiMesh estado</code> — nodos y señal\n"
"<code>/aiMesh reiniciar &lt;nodo&gt; confirmar</code>\n"
"<code>/aiMesh reconstruir confirmar</code>", chat_id)
except Exception as e:
log(f"aiMesh error: {e}")
enviar(f"❌ Error en AiMesh: {e}", chat_id)
elif comando == "qos":
partes_qos = argumento.lower().split()
accion_qos = partes_qos[0] if partes_qos else "estado"
try:
from router_control import qos_apagar, qos_estado, qos_set
if accion_qos == "estado":
enviar("⚙️ <b>QOS ASUS</b>\n" + qos_estado(), chat_id)
elif accion_qos in ("adaptive", "tradicional") and len(partes_qos) >= 2 and partes_qos[-1] == "confirmar":
enviar("⏳ Aplicando QoS...", chat_id)
enviar(qos_set(accion_qos), chat_id)
elif accion_qos == "apagar" and len(partes_qos) >= 2 and partes_qos[-1] == "confirmar":
enviar("⏳ Apagando QoS...", chat_id)
enviar(qos_apagar(), chat_id)
else:
enviar(
"Uso:\n<code>/qos estado</code>\n"
"<code>/qos adaptive confirmar</code> — auto gaming/streaming\n"
"<code>/qos tradicional confirmar</code> — prioridades manuales\n"
"<code>/qos apagar confirmar</code>", chat_id)
except Exception as e:
log(f"qos error: {e}")
enviar(f"❌ Error en QoS: {e}", chat_id)
elif comando == "limite":
partes_lim = argumento.split()
accion_lim = partes_lim[0].lower() if partes_lim else "listar"
try:
from router_control import limite_listar, limite_poner, limite_quitar
if accion_lim == "listar":
enviar("🚦 <b>LÍMITES DE VELOCIDAD</b>\n" + limite_listar(), chat_id)
elif accion_lim in ("poner", "set") and len(partes_lim) >= 4:
try:
dl = float(partes_lim[-2])
ul = float(partes_lim[-1])
except ValueError:
enviar("Las velocidades deben ser números en Mbps.", chat_id)
else:
dispositivo = " ".join(partes_lim[1:-2]) or partes_lim[1]
enviar(limite_poner(dispositivo, dl, ul), chat_id)
elif accion_lim in ("quitar", "del", "remove") and len(partes_lim) >= 2:
dispositivo = " ".join(partes_lim[1:])
enviar(limite_quitar(dispositivo), chat_id)
else:
enviar(
"Uso:\n<code>/limite listar</code>\n"
"<code>/limite poner &lt;dispositivo&gt; &lt;bajada&gt; &lt;subida&gt;</code> (Mbps)\n"
" ✏️ Ej: <code>/limite poner NAS 50 20</code>\n"
"<code>/limite quitar &lt;dispositivo&gt;</code>", chat_id)
except Exception as e:
log(f"limite error: {e}")
enviar(f"❌ Error en límites: {e}", chat_id)
elif comando == "seguridad":
partes_seg = argumento.lower().split()
accion_seg = partes_seg[0] if partes_seg else "estado"
try:
from router_control import (
seguridad_estado,
seguridad_keyword,
)
if accion_seg == "estado":
enviar("🛡️ <b>SEGURIDAD ASUS</b>\n" + seguridad_estado(), chat_id)
elif accion_seg in ("bloquear", "desbloquear") and len(partes_seg) >= 2:
palabra = " ".join(partes_seg[1:])
activar = accion_seg == "bloquear"
enviar(seguridad_keyword(palabra, activar), chat_id)
else:
enviar(
"Uso (filtro de contenidos AiProtection):\n"
"<code>/seguridad estado</code>\n"
"<code>/seguridad bloquear &lt;palabra&gt;</code> — p. ej. tiktok\n"
"<code>/seguridad desbloquear &lt;palabra&gt;</code>", chat_id)
except Exception as e:
log(f"seguridad error: {e}")
enviar(f"❌ Error en seguridad: {e}", chat_id)
elif t in ("pelis", "/pelis"):
procesar_pelis(chat_id)
elif t in ("series", "/series"):
procesar_series(chat_id)
elif t in ("genero",):
flujo_genero[chat_id] = "esperando_tipo"
flujo_genero_tipo.pop(chat_id, None)
enviar("🎭 Catálogo por <b>género</b> (30 más descargadas). Primero elige el tipo:\n"
"<b>1.</b> 🎬 Películas\n"
"<b>2.</b> 📺 Series\n\nResponde el <b>1</b> o el <b>2</b>.", chat_id)
elif t.startswith("pelicula ") or t.startswith("/pelicula "):
nombre = texto.split(" ", 1)[1].strip()
procesar_buscador_pelicula(nombre, chat_id)
elif t.startswith("serie ") or t.startswith("/serie "):
nombre = texto.split(" ", 1)[1].strip()
procesar_buscador_serie(nombre, chat_id)
elif t in ("novedades", "/novedades"):
procesar_novedades(chat_id)
elif t.startswith("busca ") or t.startswith("/busca "):
nombre = texto.split(" ", 1)[1].strip()
procesar_busca(nombre, chat_id)
elif t.startswith("torrents ") or t.startswith("/torrents "):
n = _int_arg(texto)
procesar_torrents(n, chat_id)
elif t.startswith("bajar ") or t.startswith("/bajar "):
spec = texto.split(" ", 1)[1].strip() if " " in texto else ""
procesar_bajar(spec, chat_id)
elif t.startswith("temporada ") or t.startswith("/temporada "):
n = _int_arg(texto)
procesar_temporada(n, chat_id)
elif comando == "aspiradora":
procesar_aspiradora(argumento, chat_id)
elif comando in ("menu", "menú", "inicio", "start") and not argumento:
try:
from menu_idd import enviar_menu
enviar_menu(chat_id)
except Exception as e:
log(f"menu error: {e}")
enviar(f"❌ Error abriendo el menú: {e}", chat_id)
elif t in ("ayuda", "/ayuda", "help", "/help"):
enviar(
"📚 <b>ÍNDICE DE COMANDOS</b>\n"
"🧭 Escribe <b>/menu</b> para navegar con botones.\n"
"Escribe <b>/ayuda</b> cuando lo necesites.\n\n"
"━━━ 🎬 VIDEOCLUB ━━━\n"
"<b>1.</b> <code>busca &lt;nombre&gt;</code> — buscar en TODO internet\n"
" ✏️ Ej: <code>/busca duna</code>\n"
"<b>2.</b> <code>genero</code> — catálogo por género (30 más descargadas)\n"
" ✏️ Ej: <code>/genero</code> → 1 (pelis) → 12 (Horror)\n"
"<b>3.</b> <code>torrents &lt;n&gt;</code> — torrents del resultado n\n"
" ✏️ Ej: <code>/torrents 1</code>\n"
"<b>4.</b> <code>bajar &lt;n&gt;</code> — descargar al NAS el torrent n\n"
" ✏️ Ej: <code>/bajar 1</code>, <code>/bajar 2-5</code>, <code>/temporada 1</code>\n"
"<b>5.</b> <code>pelicula &lt;nombre&gt;</code> — buscar película (catálogo)\n"
" ✏️ Ej: <code>/pelicula ant-man</code>\n"
"<b>6.</b> <code>serie &lt;nombre&gt;</code> — buscar serie (catálogo)\n"
" ✏️ Ej: <code>/serie breaking bad</code>\n"
"<b>7.</b> <code>novedades</code> — últimas novedades del catálogo\n"
" ✏️ Ej: <code>/novedades</code>\n\n"
"━━━ 🖥️ NAS / DESCARGAS ━━━\n"
"<b>8.</b> <code>descargas</code> — panel con progreso y botones\n"
" ▶️ En el panel: botón <b>🗑️ Borrar…</b> para eliminar una tarea\n"
" ✏️ Ej: <code>/descargas</code> o <code>/borrar</code>\n"
"<b>9.</b> <code>descargar &lt;enlace&gt;</code> — añade a Download Station\n"
" ✏️ Ej: <code>/descargar magnet:?xt=urn:btih:...</code>\n"
"<b>10.</b> <code>termo on|off|estado</code> — controla el enchufe Tapo\n"
" ✏️ Ej: <code>/termo estado</code>\n"
"<b>10c.</b> <code>pantalla on|off|estado</code> — enchufe de la pantalla\n"
" ✏️ Ej: <code>/pantalla estado</code>\n"
"<b>10d.</b> <code>meross [juanjo|nahid] estado|on|off</code> — enchufes mesillas\n"
" ✏️ Ej: <code>/meross estado</code>\n\n"
"━━━ 🏠 DOMÓTICA ━━━\n"
"<b>10b.</b> <code>aspiradora ...</code> — Dreame D10 Plus\n"
" ✏️ Ej: <code>/aspiradora estado</code>, <code>/aspiradora vaciar</code>, <code>/aspiradora stats</code>, <code>/aspiradora potencia 3</code>, <code>/aspiradora agua 2</code>, <code>/aspiradora voz</code>\n\n"
"━━━ 📺 TV ━━━\n"
"<b>11.</b> <code>tv &lt;mensaje&gt;</code> — mensaje rápido a la tele\n"
" ✏️ Ej: <code>/tv la cena está lista</code>\n"
"<b>12.</b> <code>alarma &lt;mensaje&gt;</code> — secuencia completa (sirena x3)\n"
" ✏️ Ej: <code>/alarma vienen a por vosotros</code>\n"
"<b>13.</b> <code>youtube &lt;URL&gt;</code> — reproduce YouTube en la TV\n"
" ✏️ Ej: <code>/youtube https://youtu.be/xxxx</code>\n\n"
"━━━ 👪 CONTROL PARENTAL ━━━\n"
"<b>14.</b> <code>parental listar</code> — estado y dispositivos\n"
" ✏️ Ej: <code>/parental listar</code>\n"
"<b>15.</b> <code>parental desactivar|activar</code> — bloquea o desbloquea\n"
" ✏️ Ej: <code>/parental desactivar Inés</code>\n"
"<b>16.</b> <code>parental pausa</code> — bloquea X minutos\n"
" ✏️ Ej: <code>/parental pausa Carolina 60</code>\n\n"
"━━━ 🛠️ SISTEMA ━━━\n"
"<b>17.</b> <code>estado</code> — estado de la Pi\n"
" ✏️ Ej: <code>/estado</code>\n"
"<b>18.</b> <code>temperatura</code> — temperatura de la Pi\n"
" ✏️ Ej: <code>/temperatura</code>\n"
"<b>19.</b> <code>router estado|clientes|wifi|inventario|parental</code> — controla el ASUS\n"
" ✏️ Ej: <code>/router estado</code>\n"
"<b>20.</b> <code>panorama</code> — fotos de las cámaras\n"
" ✏️ Ej: <code>/panorama</code>\n"
"<b>21.</b> <code>limpieza</code> — limpia procesos atascados\n"
" ✏️ Ej: <code>/limpieza</code>\n"
"<b>22.</b> <code>servicios</code> — estado de los servicios\n"
" ✏️ Ej: <code>/servicios</code>\n"
"<b>23.</b> <code>reboot</code> — reinicia la Pi\n"
" ✏️ Ej: <code>/reboot</code> → confirmar con <code>/confirmar-reboot</code>\n"
"<b>24.</b> <code>ayuda</code> — este índice\n"
" ✏️ Ej: <code>/ayuda</code>\n\n"
"━━━ 🌐 ROUTER ASUS — MESH / QoS / SEGURIDAD ━━━\n"
"<b>25.</b> <code>qos estado</code> — ancho de banda con prioridades\n"
" ✏️ Ej: <code>/qos adaptive confirmar</code> — auto gaming/streaming\n"
"<b>26.</b> <code>aiMesh estado</code> — nodos, señal y roaming unificado\n"
" ✏️ Ej: <code>/aiMesh reiniciar nodo confirmar</code>\n"
"<b>27.</b> <code>seguridad estado</code> — AiProtection: filtro de webs\n"
" ✏️ Ej: <code>/seguridad bloquear tiktok</code>\n"
"<b>28.</b> <code>limite listar</code> — límite de velocidad por dispositivo\n"
" ✏️ Ej: <code>/limite poner NAS 50 20</code> — 50/20 Mbps",
chat_id)
elif t in ("servicios", "/servicios"):
estado_svc = sh("systemctl is-active pi-bot tv-api autossh-tunel")
nombres = "pi-bot\ntv-api\nautossh-tunel"
activos = estado_svc.strip().splitlines()
lineas = []
for n, e in zip(["pi-bot", "tv-api", "autossh-tunel"], activos):
icono = "✅" if e == "active" else "❌"
lineas.append(f"{icono} {n}: {e}")
envio = "\n".join(lineas)
enviar(f"📦 Servicios:\n{envio}\n\n🔌 Túnel VPS: {'OK' if activos and activos[2]=='active' else 'caído'}", chat_id)
elif t == "reboot" or t == "/reboot":
enviar("⚠️ Para reiniciar la Pi responde <b>/confirmar-reboot</b> en 30 segundos.", chat_id)
elif t == "confirmar-reboot" or t == "/confirmar-reboot":
enviar("🔄 Reiniciando la Pi... volveré en un momento.", chat_id)
log("REBOOT solicitado")
sh("echo 'raspberry' | sudo -S reboot")
def procesar_series(chat_id):
"""Comando 'series': series con buena calidad en castellano del catalogo,
con el comando 'descargar <url>' listo. Adultos efimeros (5 min)."""
try:
import pelis
except Exception as e:
log(f"series import error: {e}")
enviar("❌ No se pudo cargar el modulo pelis", chat_id)
return
try:
enviar("📺 Consultando series del catálogo...", chat_id)
mensajes, adultos = pelis.generar_series()
except Exception as e:
log(f"series generar error: {e}")
enviar(f"❌ Error consultando el catálogo: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if adultos:
lineas = [f"🔞 Contenido adulto ({len(adultos)} novedades) — se borrará en 5 min:"]
for it in adultos[:30]:
y = f" ({it['year']})" if it["year"] else ""
enlace = it.get("enlace") or ""
linea = f"{it['clean']}{y}"
if enlace:
linea += f"\n<code>descargar {enlace}</code>"
lineas.append(linea)
if len(adultos) > 30:
lineas.append(f"... y {len(adultos) - 30} más")
texto = "\n".join(lineas)
for parte in pelis.trocear_efimero(texto, adultos):
mid = enviar(parte, chat_id)
if mid:
threading.Timer(300, borrar, args=(chat_id, mid)).start()
def procesar_pelis(chat_id):
"""Comando 'pelis': novedades con buena calidad en castellano, con el
comando 'descargar <url>' listo para copiar. Los adultos se envian en
un mensaje efimero que se borra a los 5 minutos y NO se loguea."""
try:
import pelis
except Exception as e:
log(f"pelis import error: {e}")
enviar("❌ No se pudo cargar el modulo pelis", chat_id)
return
try:
enviar("🎬 Consultando novedades del catálogo...", chat_id)
mensajes, adultos = pelis.generar()
except Exception as e:
log(f"pelis generar error: {e}")
enviar(f"❌ Error consultando el catálogo: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
# Adultos: efimeros (5 min), sin loguear, nunca persisten
if adultos:
lineas = [f"🔞 Contenido adulto ({len(adultos)} novedades) — se borrará en 5 min:"]
for it in adultos[:30]:
y = f" ({it['year']})" if it["year"] else ""
enlace = it.get("enlace") or ""
linea = f"{it['clean']}{y}"
if enlace:
linea += f"\n<code>descargar {enlace}</code>"
lineas.append(linea)
if len(adultos) > 30:
lineas.append(f"... y {len(adultos) - 30} más")
texto = "\n".join(lineas)
for parte in pelis.trocear_efimero(texto, adultos):
mid = enviar(parte, chat_id)
if mid:
threading.Timer(300, borrar, args=(chat_id, mid)).start()
def procesar_buscador_pelicula(nombre, chat_id):
"""Busca peliculas por nombre y muestra resultados numerados."""
try:
sys.path.insert(0, os.path.join(BASE, "..", "videoclub"))
import videoclub_buscador as vcb
except Exception as e:
log(f"videoclub_buscador import error: {e}")
enviar("❌ No se pudo cargar el modulo videoclub", chat_id)
return
enviar(f"🔎 Buscando <b>{nombre}</b> en el catálogo...", chat_id)
try:
resultados = vcb.buscar_peliculas(nombre)
mensajes = vcb.formatear_peliculas(resultados)
except Exception as e:
log(f"buscador pelicula error: {e}")
enviar(f"❌ Error consultando el catálogo: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if resultados:
ultima_titulos[chat_id] = {"query": nombre, "results": resultados,
"origen": "catalogo"}
log(f"BUSCADOR pelis '{nombre}': {len(resultados)} resultados")
return
# Fallback global: no esta en tu RealDebrid, se busca online.
enviar("📡 No está en tu catálogo, buscando online...", chat_id)
try:
gol = _import_global_buscar()
globales = [r for r in gol.buscar_titulos(nombre)
if r["type"] == "movie"]
mensajes = gol.formatear_titulos(globales)
except Exception as e:
log(f"buscador pelicula global error: {e}")
enviar(f"❌ Error en la busqueda global: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if globales:
ultima_titulos[chat_id] = {"query": nombre, "results": globales,
"origen": "global"}
log(f"BUSCADOR pelis global '{nombre}': {len(globales)} titulos")
def procesar_buscador_serie(nombre, chat_id):
"""Busca series por nombre y muestra temporadas/capitulos numerados."""
try:
sys.path.insert(0, os.path.join(BASE, "..", "videoclub"))
import videoclub_buscador as vcb
except Exception as e:
log(f"videoclub_buscador import error: {e}")
enviar("❌ No se pudo cargar el modulo videoclub", chat_id)
return
enviar(f"🔎 Buscando serie <b>{nombre}</b> en el catálogo...", chat_id)
try:
grupos = vcb.buscar_series(nombre)
except Exception as e:
log(f"buscador serie error: {e}")
enviar(f"❌ Error consultando el catálogo: {e}", chat_id)
return
total_eps = sum(len(v) for v in grupos.values())
if grupos and total_eps >= 3:
mensajes = vcb.formatear_series(grupos)
for m in mensajes:
enviar(m, chat_id)
ultima_titulos[chat_id] = {"query": nombre,
"results": vcb.aplanar_series(grupos),
"origen": "catalogo"}
log(f"BUSCADOR series '{nombre}': {len(grupos)} series, {total_eps} capitulos")
return
# Poco o nada en tu RealDebrid: se busca online.
enviar(f"📡 En tu catálogo hay {total_eps} capítulos, buscando online...",
chat_id)
try:
gol = _import_global_buscar()
globales = [r for r in gol.buscar_titulos(nombre)
if r["type"] == "series"]
mensajes = gol.formatear_titulos(globales)
except Exception as e:
log(f"buscador serie global error: {e}")
enviar(f"❌ Error en la busqueda global: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if globales:
ultima_titulos[chat_id] = {"query": nombre, "results": globales,
"origen": "global"}
log(f"BUSCADOR series global '{nombre}': {len(globales)} titulos")
def procesar_novedades(chat_id):
"""Muestra las ultimas novedades del catalogo."""
try:
sys.path.insert(0, os.path.join(BASE, "..", "videoclub"))
import videoclub_buscador as vcb
except Exception as e:
log(f"videoclub_buscador import error: {e}")
enviar("❌ No se pudo cargar el modulo videoclub", chat_id)
return
enviar("🎬 Consultando ultimas novedades...", chat_id)
try:
items = vcb.generar_novedades()
mensajes = vcb.formatear_novedades(items)
except Exception as e:
log(f"novedades error: {e}")
enviar(f"❌ Error consultando el catálogo: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if items:
ultima_titulos[chat_id] = {"query": "novedades", "results": items,
"origen": "catalogo"}
log(f"NOVEDADES: {len(items)} items")
def procesar_aspiradora(argumento, chat_id):
"""Dreame D10 Plus: estado|start|stop|home|pause|locate|vaciar|
auto on|off|stats|consumibles|potencia 0-3|agua 1-3|voz [id]."""
partes = (argumento or "estado").lower().strip().split(None, 1)
accion = partes[0]
arg = partes[1].strip() if len(partes) > 1 else ""
validas = ("estado", "start", "stop", "home", "pause", "locate",
"vaciar", "auto", "stats", "consumibles", "potencia",
"agua", "voz")
if accion not in validas:
enviar("Uso: <code>/aspiradora estado|start|stop|home|pause|locate|"
"vaciar|auto on|off|stats|consumibles|potencia 0-3|agua 1-3|"
"voz [id]</code>.", chat_id)
return
try:
import aspiradora
except Exception as e:
log(f"aspiradora import error: {e}")
enviar("❌ No se pudo cargar el modulo aspiradora", chat_id)
return
try:
if accion == "estado":
enviar(aspiradora.estado(), chat_id)
elif accion == "stats":
enviar(aspiradora.stats(), chat_id)
elif accion == "consumibles":
enviar(aspiradora.consumibles(), chat_id)
elif accion == "auto" and not arg:
enviar(aspiradora.auto_estado(), chat_id)
elif accion == "voz" and not arg:
enviar(aspiradora.voz_estado(), chat_id)
else:
vac = aspiradora.conectar()
if accion == "start":
vac.start()
enviar("🧹 Limpieza iniciada.", chat_id)
elif accion == "stop":
vac.stop()
enviar("⏹️ Limpieza detenida.", chat_id)
elif accion == "home":
vac.home()
enviar("🏠 Volviendo a la base.", chat_id)
elif accion == "pause":
vac.call_action("pause")
enviar("⏸️ Pausada.", chat_id)
elif accion == "locate":
vac.call_action("play_sound")
enviar("🔊 Emitiendo sonido de localización.", chat_id)
elif accion == "vaciar":
vac.call_action("start_collect")
enviar("🗑️ Vaciando en la base.", chat_id)
elif accion == "auto":
if arg in ("on", "1"):
vac.set_property_by(15, 1, 1)
enviar("🗑️ Vaciado automático <b>activado</b>.", chat_id)
else:
vac.set_property_by(15, 1, 0)
enviar("🗑️ Vaciado automático <b>desactivado</b>.",
chat_id)
elif accion == "potencia":
if arg.isdigit() and int(arg) in aspiradora.MODOS:
vac.set_property_by(4, 4, int(arg))
enviar(f"⚡ Potencia: "
f"<b>{aspiradora.MODOS[int(arg)]}</b>.", chat_id)
else:
enviar("Uso: <code>/aspiradora potencia 0-3</code> "
"(0 silencioso, 1 estándar, 2 medio, 3 fuerte).",
chat_id)
elif accion == "agua":
if arg.isdigit() and int(arg) in aspiradora.AGUAS:
vac.set_property_by(4, 5, int(arg))
enviar(f"💧 Agua: <b>{aspiradora.AGUAS[int(arg)]}</b>.",
chat_id)
else:
enviar("Uso: <code>/aspiradora agua 1-3</code> "
"(1 bajo, 2 medio, 3 alto).", chat_id)
elif accion == "voz":
vac.set_voice(arg)
enviar(f"🔊 Voz cambiada a <b>{arg}</b> "
f"(si el pack existe).", chat_id)
log(f"ASPIRADORA {accion} {arg} ok")
except Exception as e:
log(f"aspiradora error: {e}")
enviar(f"❌ Error con la aspiradora: {e}", chat_id)
def _int_arg(texto):
"""Extrae el numero de un comando tipo '/bajar 3'."""
partes = texto.split()
for p in partes[1:]:
if p.isdigit():
return int(p)
return None
def _import_global_buscar():
sys.path.insert(0, os.path.join(BASE, "..", "videoclub"))
import global_buscar as gol
return gol
def procesar_busca(nombre, chat_id):
"""Busqueda global (Cinemeta): texto -> titulos con id IMDb numerados."""
try:
gol = _import_global_buscar()
except Exception as e:
log(f"global_buscar import error: {e}")
enviar("❌ No se pudo cargar el modulo de busqueda global", chat_id)
return
enviar(f"🔎 Buscando <b>{nombre}</b> en todo el catalogo online...", chat_id)
try:
resultados = gol.buscar_titulos(nombre)
mensajes = gol.formatear_titulos(resultados)
except Exception as e:
log(f"busca error: {e}")
enviar(f"❌ Error en la busqueda: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if resultados:
ultima_titulos[chat_id] = {"query": nombre, "results": resultados}
log(f"BUSCA global '{nombre}': {len(resultados)} titulos")
def procesar_genero(chat_id):
"""Comando '/genero': inicia el flujo por genero. Pide el tipo."""
flujo_genero[chat_id] = "esperando_tipo"
flujo_genero_tipo.pop(chat_id, None)
enviar("🎭 Catalogo por <b>genero</b> (30 mas descargadas). Primero elige el tipo:\n"
"<b>1.</b> 🎬 Películas\n"
"<b>2.</b> 📺 Series\n\nResponde el <b>1</b> o el <b>2</b>.", chat_id)
def _pedir_genero(chat_id, icono):
try:
gol = _import_global_buscar()
except Exception as e:
log(f"global_buscar import error: {e}")
enviar("❌ No se pudo cargar el modulo", chat_id)
return
for m in gol.formatear_generos():
# anadimos el icono de tipo en la cabecera
if m.startswith("🎭"):
m = f"{icono} {m[1:]}"
enviar(m, chat_id)
enviar("Responde el <b>numero</b> del genero.", chat_id)
def _mostrar_catalogo_genero(chat_id, tipo, genero, categoria):
enviar(f"🔎 Consultando {genero} ({'peliculas' if tipo=='movie' else 'series'})...", chat_id)
try:
gol = _import_global_buscar()
resultados = gol.catalogo_por_genero(tipo, genero, categoria, limite=30)
mensajes = gol.formatear_catalogo(resultados, tipo, genero, categoria)
except Exception as e:
log(f"catalogo genero error: {e}")
enviar(f"❌ Error consultando el catalogo: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if resultados:
ultima_titulos[chat_id] = {"query": f"{genero} ({tipo})", "results": resultados}
log(f"GENERO {tipo} '{genero}': {len(resultados)} titulos")
def procesar_torrents(n, chat_id):
"""Muestra las opciones de descarga del numero n de la ultima busqueda
(/busca, /pelicula, /serie, /novedades o /genero)."""
estado = ultima_titulos.get(chat_id)
if not estado or n is None or n < 1 or n > len(estado["results"]):
enviar("Primero usa <b>/busca</b>, <b>/pelicula</b>, <b>/serie</b>, "
"<b>/novedades</b> o <b>/genero</b> y luego "
"<b>/torrents &lt;numero&gt;</b>.", chat_id)
return
r = estado["results"][n - 1]
if not r.get("imdb_id") and r.get("enlace"):
# Item de catalogo: ya trae enlace directo, sin paso por Torrentio.
nombre = r.get("clean") or r.get("name") or "titulo"
ultimos_torrents[chat_id] = {
"titulo": nombre,
"torrents": [{"filename": nombre, "calidad": "directo",
"size": None, "enlace": r["enlace"]}],
}
enviar(f"🧲 <b>{nombre}</b> — enlace directo disponible:\n"
"<b>1.</b> Descarga directa al NAS\n\n"
"Responde <b>/bajar 1</b> para descargarlo.", chat_id)
log(f"TORRENTS catalogo '{nombre}': enlace directo")
return
if not r.get("imdb_id"):
enviar("❌ Ese resultado no tiene enlace de descarga. "
"Prueba con <b>/busca &lt;nombre&gt;</b>.", chat_id)
return
try:
gol = _import_global_buscar()
except Exception as e:
log(f"global_buscar import error: {e}")
enviar("❌ No se pudo cargar el modulo", chat_id)
return
enviar(f"🧲 Buscando torrents de <b>{r['name']}</b>...", chat_id)
try:
torrents = gol.torrents_de(r["imdb_id"], r["type"])
torrents = gol.filtrar_torrents(torrents)
mensajes = gol.formatear_torrents(torrents, r["name"])
except Exception as e:
log(f"torrents error: {e}")
enviar(f"❌ Error obteniendo torrents: {e}", chat_id)
return
for m in mensajes:
enviar(m, chat_id)
if torrents:
ultimos_torrents[chat_id] = {"titulo": r["name"], "torrents": torrents}
log(f"TORRENTS '{r['name']}': {len(torrents)} torrents")
def procesar_bajar(spec, chat_id):
"""Descarga al NAS: numero, rango (2-5) o episodios directos."""
estado = ultimos_torrents.get(chat_id)
titulos = ultima_titulos.get(chat_id) or {}
if estado and estado.get("torrents"):
idx = _parse_indices(spec, len(estado["torrents"]))
if not idx:
enviar("Uso: <b>/bajar &lt;numero&gt;</b> o <b>/bajar X-Y</b>.",
chat_id)
return
buenas, malas = [], []
for i in idx:
uri, etiqueta = _bajar_torrent(estado["torrents"][i - 1],
estado.get("titulo") or "")
if not uri:
malas.append(etiqueta)
continue
ok, _ = _bajar_uri(uri, etiqueta)
(buenas if ok else malas).append(etiqueta)
log(f"BAJAR '{estado.get('titulo')}' torrent {i}: ok={ok}")
partes = []
if buenas:
partes.append(f"📥 Añadidos al NAS ({len(buenas)}):\n" +
"\n".join(f"• {b}" for b in buenas))
if malas:
partes.append("❌ Fallaron:\n" +
"\n".join(f"• {m}" for m in malas))
enviar("\n".join(partes) or "❌ Nada que descargar.", chat_id)
return
if titulos.get("origen") == "catalogo" and titulos.get("results"):
resultados = titulos["results"]
idx = _parse_indices(spec, len(resultados))
if not idx:
enviar("Uso: <b>/bajar &lt;numero&gt;</b>, <b>/bajar X-Y</b> o "
"<b>/temporada N</b>.", chat_id)
return
buenas, malas = [], []
for i in idx:
it = resultados[i - 1]
nombre = it.get("clean") or it.get("name") or f"episodio {i}"
if not it.get("enlace"):
malas.append(f"{nombre} (sin enlace)")
continue
ok, _ = _bajar_uri(it["enlace"], nombre)
(buenas if ok else malas).append(nombre)
log(f"BAJAR catalogo '{nombre}': ok={ok}")
partes = []
if buenas:
partes.append(f"📥 Añadidos al NAS ({len(buenas)}):\n" +
"\n".join(f"• {b}" for b in buenas))
if malas:
partes.append("❌ Fallaron:\n" +
"\n".join(f"• {m}" for m in malas))
enviar("\n".join(partes) or "❌ Nada que descargar.", chat_id)
return
enviar("Primero usa <b>/busca</b>, <b>/pelicula</b>, <b>/serie</b>, "
"<b>/novedades</b> o <b>/genero</b>, luego "
"<b>/torrents &lt;numero&gt;</b>.", chat_id)
def _parse_indices(spec, total, maximo=10):
"""'3' -> [3]; '2-5' o '2:5' -> [2..5]. Recorta al total (max 10)."""
spec = str(spec or "").strip()
m = re.match(r"(\d+)\s*[-:]\s*(\d+)$", spec)
if m:
a, b = int(m.group(1)), int(m.group(2))
if a > b:
a, b = b, a
elif spec.isdigit():
a = b = int(spec)
else:
return []
a = max(a, 1)
b = min(b, total)
if a > b:
return []
return list(range(a, min(b, a + maximo - 1) + 1))
def _bajar_uri(uri, etiqueta):
"""Anade un magnet/enlace al NAS. Devuelve (ok, etiqueta)."""
try:
import nas_dl
ok = nas_dl.add_torrent(uri)
except Exception as e:
log(f"bajar error: {e}")
return False, f"{etiqueta} (error: {e})"
return bool(ok), etiqueta
def _bajar_torrent(t, titulo):
"""Resuelve un item de ultimos_torrents a (uri, etiqueta)."""
if t.get("enlace") and not t.get("info_hash"):
return t["enlace"], t.get("filename") or titulo
try:
gol = _import_global_buscar()
except Exception as e:
log(f"global_buscar import error: {e}")
return None, "sin modulo"
tam = f"{t['size']:.1f}GB" if t.get("size") else "?"
return gol.magnet_de(t), f"{t.get('filename') or titulo} ({t.get('calidad')}, {tam})"
def procesar_temporada(n, chat_id):
"""Descarga la temporada N completa de la ultima serie del catalogo."""
titulos = ultima_titulos.get(chat_id) or {}
if titulos.get("origen") != "catalogo" or not titulos.get("results"):
enviar("La temporada completa solo vale para series de tu catálogo. "
"Online usa <b>/torrents N</b> y <b>/bajar X-Y</b>.", chat_id)
return
if n is None or n < 1:
enviar("Uso: <b>/temporada &lt;N&gt;</b>.", chat_id)
return
try:
sys.path.insert(0, os.path.join(BASE, "..", "videoclub"))
import videoclub_buscador as vcb
except Exception as e:
log(f"videoclub_buscador import error: {e}")
enviar("❌ No se pudo cargar el modulo videoclub", chat_id)
return
capitulos = []
for it in titulos["results"]:
try:
temp, _cap = vcb.parsear_episodio(it.get("name") or "")
except Exception:
temp = 0
if temp == n and it.get("enlace"):
capitulos.append(it)
if not capitulos:
enviar(f"❌ No hay capitulos con enlace en la temporada {n}.",
chat_id)
return
buenas, malas = [], []
for it in capitulos[:25]:
nombre = it.get("clean") or it.get("name") or "capitulo"
ok, _ = _bajar_uri(it["enlace"], nombre)
(buenas if ok else malas).append(nombre)
partes = [f"📥 Temporada {n} al NAS ({len(buenas)} capitulos)."]
if malas:
partes.append("❌ Fallaron: " + ", ".join(malas))
enviar("\n".join(partes), chat_id)
log(f"TEMPORADA {n}: {len(buenas)} ok, {len(malas)} fallo")
def monitor_temperatura():
while True:
time.sleep(300)
msg, temp_c = estado()
if temp_c is not None and temp_c > ALERTA_TEMP:
enviar(f"🚨 ALERTA: la Pi está a <b>{temp_c:.1f}°C</b> "
f"(límite {ALERTA_TEMP}°C)\n{msg}", CHAT_ID)
def cargar_offset():
try:
with open(OFFSET_FILE) as f:
return int(f.read().strip())
except Exception:
return None
def guardar_offset(offset):
try:
with open(OFFSET_FILE, "w") as f:
f.write(str(offset))
except Exception:
pass
def _atender_update(upd):
"""Procesa una update en su propio hilo (no bloquea el polling)."""
try:
if upd.get("callback_query"):
procesar_callback(upd["callback_query"])
return
msg = upd.get("message") or upd.get("channel_post")
if not msg:
return
texto = msg.get("text")
chat_id = msg["chat"]["id"]
if msg.get("message_id"):
lista = entrantes_chat.setdefault(chat_id, [])
lista.append(msg["message_id"])
if len(lista) > 300:
entrantes_chat[chat_id] = lista[-300:]
if texto:
procesar(texto, chat_id)
except Exception as e:
log(f"ERROR update: {e}")
def main():
if not TOKEN:
print("Falta TELEGRAM_TOKEN en .env")
return
threading.Thread(target=monitor_temperatura, daemon=True).start()
offset = cargar_offset()
log("Bot iniciado" + (f" (offset {offset})" if offset else ""))
print("Bot iniciado. Esperando mensajes...")
while True:
try:
params = {"timeout": 25}
if offset:
params["offset"] = offset
r = requests.get(
f"https://api.telegram.org/bot{TOKEN}/getUpdates",
params=params, timeout=35,
).json()
for upd in r.get("result", []):
offset = upd["update_id"] + 1
guardar_offset(offset)
log(f"UPDATE {upd.get('update_id')}")
threading.Thread(target=_atender_update, args=(upd,),
daemon=True).start()
except Exception as e:
log(f"ERROR {e}")
print("Error:", e)
time.sleep(3)
if __name__ == "__main__":
main()