05-sep: [proyecto1] menu de botones inline para el bot IDD (/menu) sin tocar la logica

This commit is contained in:
juanjo
2026-09-05 19:59:01 +02:00
parent 1c56bbb753
commit 6c4b0b2252
2 changed files with 565 additions and 1 deletions

524
vigilancia/menu_idd.py Normal file
View File

@@ -0,0 +1,524 @@
# -*- coding: utf-8 -*-
"""menu_idd.py - Menu de botones inline para el bot IDD (Telegram).
Capa nueva sobre pi_bot.py: los botones NO duplican logica, disparan los
mismos comandos de texto llamando a pi_bot.procesar(). Asi las integraciones
(NAS, router, camaras, TV, Tapo, parental, AiMesh, QoS, limites) siguen
exactamente igual. Se importa pi_bot de forma perezosa para evitar ciclos.
"""
import json
import time
API = "https://api.telegram.org/bot"
# Prefijo de los callback_data del menu: "m:<ruta>"
PREFIJO = "m:"
def _bot_token():
import pi_bot
return pi_bot.TOKEN
def _api(method):
return f"{API}{_bot_token()}/{method}"
def _log(linea):
import pi_bot
try:
pi_bot.log(f"menu: {linea}")
except Exception:
pass
def _txt(codigo, chat_id):
"""Recupera el estado de la ultima busqueda de pi_bot para este chat."""
import pi_bot
try:
if codigo == "ultimos_titulos":
estado = pi_bot.ultima_titulos.get(chat_id) or {}
return estado.get("results") or []
if codigo == "ultimos_torrents":
return pi_bot.ultimos_torrents.get(chat_id) or {}
except Exception:
return {} if codigo == "ultimos_torrents" else []
return [] if codigo == "ultimos_torrents" else {}
def _btn(texto, data):
return {"text": texto, "callback_data": data}
def _mk(filas):
return {"inline_keyboard": filas}
# ---------------------------------------------------------------- teclados --
FILA_VOLVER = [_btn("⬅️ Menú", PREFIJO + "ini")]
def teclado_principal():
return _mk([
[
_btn("🎬 Videoclub", PREFIJO + "cat:videoclub"),
_btn("📥 NAS/Descargas", PREFIJO + "cat:nas"),
],
[
_btn("📺 TV", PREFIJO + "cat:tv"),
_btn("👪 Control parental", PREFIJO + "cat:parental"),
_btn("🛠️ Sistema", PREFIJO + "cat:sistema"),
],
[_btn("📚 Ayuda (texto)", PREFIJO + "acc:ayuda")],
])
def teclado_videoclub(chat_id):
filas = [
[
_btn("🔎 Buscar", PREFIJO + "txt:busca"),
_btn("🎬 Película", PREFIJO + "txt:pelicula"),
_btn("📺 Serie", PREFIJO + "txt:serie"),
],
[
_btn("🎭 Por género", PREFIJO + "acc:genero"),
_btn("🆕 Novedades", PREFIJO + "acc:novedades"),
],
]
titulos = _txt("ultimos_titulos", chat_id)
if titulos:
filas.append([
_btn("🧲 Torrents…", PREFIJO + "sub:torrents"),
_btn("⬇️ Bajar…", PREFIJO + "sub:bajar"),
])
filas.append(FILA_VOLVER)
return _mk(filas)
def teclado_nas():
return _mk([
[
_btn("📥 Panel descargas", PREFIJO + "acc:descargas"),
_btn("➕ Añadir enlace", PREFIJO + "txt:descargar"),
],
[
_btn("🔌 Termo ON", PREFIJO + "acc:termo:on"),
_btn("🔌 Termo OFF", PREFIJO + "acc:termo:off"),
_btn("🔌 Termo estado", PREFIJO + "acc:termo:estado"),
],
FILA_VOLVER,
])
def teclado_tv():
return _mk([
[
_btn("📺 Mensaje", PREFIJO + "txt:tv"),
_btn("🚨 Alarma", PREFIJO + "txt:alarma"),
],
[_btn("▶️ YouTube", PREFIJO + "txt:youtube")],
FILA_VOLVER,
])
def teclado_parental():
return _mk([
[_btn("📋 Listar estado", PREFIJO + "acc:parental_listar")],
[
_btn("🔒 Desactivar…", PREFIJO + "sub:desactivar"),
_btn("🔓 Activar…", PREFIJO + "sub:activar"),
],
[_btn("⏸️ Pausa…", PREFIJO + "sub:pausa")],
FILA_VOLVER,
])
def teclado_sistema():
return _mk([
[
_btn("📊 Estado", PREFIJO + "acc:estado"),
_btn("🌡️ Temperatura", PREFIJO + "acc:temperatura"),
],
[
_btn("📡 Router…", PREFIJO + "sub:router"),
_btn("🌐 AiMesh", PREFIJO + "acc:aimesh"),
],
[
_btn("⚙️ QoS", PREFIJO + "acc:qos"),
_btn("🚦 Límites", PREFIJO + "acc:limite"),
_btn("🛡️ Seguridad", PREFIJO + "acc:seguridad"),
],
[
_btn("📸 Panorama", PREFIJO + "acc:panorama"),
_btn("🧹 Limpieza", PREFIJO + "acc:limpieza"),
],
FILA_VOLVER,
])
def teclado_router():
return _mk([
[
_btn("📊 Estado", PREFIJO + "acc:router:estado"),
_btn("📶 Clientes", PREFIJO + "acc:router:clientes"),
_btn("📡 Wi-Fi", PREFIJO + "acc:router:wifi"),
],
[
_btn("📋 Inventario", PREFIJO + "acc:router:inventario"),
_btn("👪 Parental", PREFIJO + "acc:router:parental"),
],
[
_btn("🔄 Reiniciar", PREFIJO + "acc:router:reiniciar"),
_btn("🔁 Reservar IPs", PREFIJO + "acc:router:reservar"),
],
[_btn("⬅️ Sistema", PREFIJO + "cat:sistema")],
])
def teclado_nombres(accion):
return _mk([
[_btn("👧 Inés", PREFIJO + f"sub:{accion}:Inés"),
_btn("👩 Carolina", PREFIJO + f"sub:{accion}:Carolina")],
[_btn("✏️ Otro…", PREFIJO + f"txt:{accion}")],
[_btn("⬅️ Control parental", PREFIJO + "cat:parental")],
])
def teclado_minutos_pausa(nombre):
return _mk([
[
_btn("15 min", PREFIJO + "pausa_min:15"),
_btn("30 min", PREFIJO + "pausa_min:30"),
_btn("60 min", PREFIJO + "pausa_min:60"),
],
[_btn("✏️ Otro…", PREFIJO + "pausa_min:otro")],
[_btn("⬅️ Nombres", PREFIJO + "cat:parental")],
])
def teclado_resultados(lista, prefijo):
filas = []
for i, item in enumerate(lista[:12], start=1):
nombre = item.get("name") or item.get("filename") or item.get("title") or item.get("clean") or f"Opción {i}"
filas.append([_btn(f"{i}. {nombre[:40]}", PREFIJO + f"acc:{prefijo}:{i}")])
filas.append([_btn("⬅️ Videoclub", PREFIJO + "cat:videoclub")])
return filas
def teclado_cancelar():
return _mk([[_btn("❌ Cancelar", PREFIJO + "cancelar")]])
# --------------------------------------------------------------- helpers ---
def _enviar_edit(chat_id, message_id, texto, teclado):
import requests
try:
requests.post(_api("editMessageText"), data={
"chat_id": chat_id,
"message_id": message_id,
"text": texto,
"parse_mode": "HTML",
"reply_markup": json.dumps(teclado),
}, timeout=10)
except Exception as e:
_log(f"edit error: {e}")
def _enviar_nuevo(chat_id, texto, teclado=None):
import requests
data = {
"chat_id": chat_id,
"text": texto,
"parse_mode": "HTML",
}
if teclado:
data["reply_markup"] = json.dumps(teclado)
try:
requests.post(_api("sendMessage"), data=data, timeout=10)
except Exception as e:
_log(f"send error: {e}")
# ----------------------------------------------------------------- menu ----
def enviar_menu(chat_id):
_enviar_nuevo(
chat_id,
"🧭 <b>Menú IDD</b>\nElige una categoría:",
teclado_principal(),
)
def _header(categoria):
nombres = {
"videoclub": "🎬 <b>Videoclub</b>",
"nas": "📥 <b>NAS / Descargas</b>",
"tv": "📺 <b>TV</b>",
"parental": "👪 <b>Control parental</b>",
"sistema": "🛠️ <b>Sistema</b>",
"router": "📡 <b>Router ASUS</b>",
}
return nombres.get(categoria, "🧭 <b>Menú IDD</b>")
def _abrir_categoria(chat_id, message_id, categoria):
teclado = {
"videoclub": lambda: teclado_videoclub(chat_id),
"nas": teclado_nas,
"tv": teclado_tv,
"parental": teclado_parental,
"sistema": teclado_sistema,
"router": teclado_router,
}
fn = teclado.get(categoria)
if not fn:
return False
_enviar_edit(chat_id, message_id, _header(categoria), fn())
return True
def procesar_callback(data, chat_id, message_id):
"""data llega sin el prefijo 'm:'. Gestiona navegacion y acciones."""
partes = data.split(":")
ruta = partes[0]
_log(f"callback {data} chat={chat_id}")
if ruta == "ini":
_enviar_edit(chat_id, message_id, "🧭 <b>Menú IDD</b>\nElige una categoría:", teclado_principal())
return
if ruta == "cat":
return _abrir_categoria(chat_id, message_id, partes[1])
if ruta == "sub":
# sub:torrents / sub:bajar / sub:router / sub:parental:<acc>
sub = partes[1]
if sub in ("router",):
_enviar_edit(chat_id, message_id, _header("router"), teclado_router())
return
if sub in ("torrents", "bajar"):
return _mostrar_resultados(chat_id, message_id, sub)
if len(partes) == 2 and sub in ("desactivar", "activar", "pausa"):
_enviar_edit(chat_id, message_id,
f"¿Sobre qué dispositivo? ({sub})",
teclado_nombres(sub))
return
# sub:<accion>:<nombre> -> accion parental con nombre directo
if len(partes) >= 3 and sub in ("desactivar", "activar", "pausa"):
return _ejecutar_parental(chat_id, message_id, sub, partes[2])
_log(f"sub desconocida: {data}")
return
if ruta == "txt":
# pide un dato por teclado: lo captura el siguiente mensaje
accion = partes[1]
_guardar_pendiente(chat_id, accion)
preguntas = {
"busca": "🔎 ¿Qué busco? Escribe el nombre.",
"pelicula": "🎬 ¿Qué película busco?",
"serie": "📺 ¿Qué serie busco?",
"tv": "📺 ¿Qué mensaje envío a la tele?",
"alarma": "🚨 ¿Qué mensaje de alarma lanzo?",
"youtube": "▶️ Pega la URL de YouTube.",
"descargar": "🧲 Pega el enlace magnet o URL a descargar.",
"desactivar": "🔒 ¿A quién bloqueo? Escribe el nombre.",
"activar": "🔓 ¿A quién desbloqueo? Escribe el nombre.",
}
_enviar_nuevo(chat_id, preguntas.get(accion, "Escribe el dato:"),
teclado_cancelar())
return
if ruta == "pausa_min":
nombre = _get_pausa_nombre(chat_id)
if not nombre:
_enviar_nuevo(chat_id, "Empieza de nuevo: /menu", teclado_cancelar())
return
if partes[1] == "otro":
_guardar_pendiente(chat_id, "pausa_minutos")
_enviar_nuevo(chat_id, "⏱️ ¿Cuántos minutos? (1-1440)", teclado_cancelar())
return
minutos = partes[1]
import pi_bot
_enviar_nuevo(chat_id, f"⏸️ Pausa {nombre} {minutos} minutos…", None)
pi_bot.procesar(f"parental pausa {nombre} {minutos}", chat_id)
return
if ruta == "acc":
return _ejecutar_accion(chat_id, message_id, partes[1:])
if ruta == "cancelar":
_limpiar_pendiente(chat_id)
_borrar(chat_id, message_id)
return
def _mostrar_resultados(chat_id, message_id, tipo):
titulos = _txt("ultimos_titulos", chat_id)
if tipo == "torrents":
if titulos:
_enviar_edit(chat_id, message_id,
"🧲 Elige el título para ver sus torrents:",
teclado_resultados(titulos, "torrents"))
else:
_enviar_nuevo(chat_id, "Primero haz una <b>/busca</b> o usa <code>/torrents &lt;n&gt;</code>.", None)
return
torrents = _txt("ultimos_torrents", chat_id)
if torrents:
_enviar_edit(chat_id, message_id,
f"⬇️ Elige torrent de «{torrents.get('titulo')}»:",
teclado_resultados(torrents.get("torrents", []), "bajar"))
else:
_enviar_nuevo(chat_id, "Primero consulta <code>/torrents &lt;n&gt;</code> de un título.", None)
def _ejecutar_parental(chat_id, message_id, accion, nombre):
if accion == "pausa":
_set_pausa_nombre(chat_id, nombre)
_enviar_edit(chat_id, message_id,
f"⏸️ Pausa para <b>{nombre}</b>. ¿Cuántos minutos?",
teclado_minutos_pausa(nombre))
return
comando = f"parental {'desactivar' if accion == 'desactivar' else 'activar'} {nombre}"
import pi_bot
pi_bot.procesar(comando, chat_id)
def _ejecutar_accion(chat_id, message_id, partes):
import pi_bot
accion = partes[0]
sub = partes[1] if len(partes) > 1 else ""
directos = {
"ayuda": "ayuda",
"genero": "genero",
"novedades": "novedades",
"descargas": "descargas",
"estado": "estado",
"temperatura": "temperatura",
"panorama": "panorama",
"limpieza": "limpieza",
"aimesh": "aiMesh estado",
"qos": "qos estado",
"limite": "limite listar",
"seguridad": "seguridad estado",
"parental_listar": "parental listar",
}
if accion in directos:
pi_bot.procesar(directos[accion], chat_id)
return
if accion == "termo":
pi_bot.procesar(f"termo {sub}", chat_id)
return
if accion == "router":
mapeo = {
"estado": "router estado",
"clientes": "router clientes",
"wifi": "router wifi",
"inventario": "router inventario",
"parental": "router parental",
"reiniciar": "router reiniciar",
"reservar": "router reservar",
}
pi_bot.procesar(mapeo.get(sub, "router ayuda"), chat_id)
return
if accion in ("torrents", "bajar"):
numero = int(sub) if sub.isdigit() else 0
if numero:
pi_bot.procesar(f"{accion} {numero}", chat_id)
return
# --------------------------------------------- pendiente de texto libre ----
def _pend(chat_id):
import pi_bot
return pi_bot.menu_pendiente
def _guardar_pendiente(chat_id, accion):
_pend(chat_id)[chat_id] = accion
def _limpiar_pendiente(chat_id):
_pend(chat_id).pop(chat_id, None)
def pendiente_activo(chat_id):
return chat_id in _pend(chat_id)
def cancelar_pendiente(chat_id):
_limpiar_pendiente(chat_id)
def construir_comando(chat_id, texto):
"""Devuelve el texto del comando para el texto libre recibido, o None."""
accion = _pend(chat_id).get(chat_id)
if not accion:
return None
texto = texto.strip()
if accion in ("pausa_minutos",):
if not texto.isdigit():
return "INVALIDO"
return None # manejado aparte
prefijos = {
"busca": "busca",
"pelicula": "pelicula",
"serie": "serie",
"tv": "tv",
"alarma": "alarma",
"youtube": "youtube",
"descargar": "descargar",
"desactivar": "parental desactivar",
"activar": "parental activar",
}
prefijo = prefijos.get(accion)
if prefijo is None:
return None
return f"{prefijo} {texto}"
def resolver_pausa_minutos(chat_id, texto):
"""Si el pendiente es pausa_minutos y el texto es valido, lanza la pausa."""
accion = _pend(chat_id).get(chat_id)
if accion != "pausa_minutos":
return False
if not texto.strip().isdigit():
return False
minutos = int(texto.strip())
if minutos < 1 or minutos > 1440:
return False
nombre = _get_pausa_nombre(chat_id)
_limpiar_pendiente(chat_id)
if not nombre:
return True
import pi_bot
pi_bot.procesar(f"parental pausa {nombre} {minutos}", chat_id)
return True
def pausa_estado(chat_id):
import pi_bot
return pi_bot.menu_pausa_nombre.get(chat_id)
def _set_pausa_nombre(chat_id, nombre):
import pi_bot
pi_bot.menu_pausa_nombre[chat_id] = nombre
def _get_pausa_nombre(chat_id):
import pi_bot
return pi_bot.menu_pausa_nombre.get(chat_id)
def _borrar(chat_id, message_id):
import requests
try:
requests.post(_api("deleteMessage"),
data={"chat_id": chat_id, "message_id": message_id},
timeout=10)
except Exception:
pass

View File

@@ -16,6 +16,8 @@ flujo_genero = {} # chat_id -> 'esperando_tipo' | 'esperando_genero' | No
flujo_genero_tipo = {} # chat_id -> 'movie' | 'series' (tipo ya elegido) flujo_genero_tipo = {} # chat_id -> 'movie' | 'series' (tipo ya elegido)
panel_descargas = {} # chat_id -> estado del panel editable panel_descargas = {} # chat_id -> estado del panel editable
temporizadores_parental = {} # alias normalizado -> Timer de desbloqueo 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
BASE = os.path.dirname(os.path.abspath(__file__)) BASE = os.path.dirname(os.path.abspath(__file__))
LOG = os.path.join(BASE, "bot.log") LOG = os.path.join(BASE, "bot.log")
@@ -226,8 +228,17 @@ def procesar_callback(callback):
callback_id = callback.get("id") callback_id = callback.get("id")
mensaje = callback.get("message") or {} mensaje = callback.get("message") or {}
chat_id = (mensaje.get("chat") or {}).get("id") chat_id = (mensaje.get("chat") or {}).get("id")
accion = (callback.get("data") or "").split(":", 1)[-1] data = callback.get("data") or ""
responder_callback(callback_id) 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) estado_panel = panel_descargas.get(chat_id)
if not estado_panel: if not estado_panel:
return return
@@ -283,6 +294,27 @@ def procesar(texto, chat_id):
partes = t.split(None, 1) partes = t.split(None, 1)
comando = partes[0].split("@", 1)[0].lstrip("/") if partes else "" comando = partes[0].split("@", 1)[0].lstrip("/") if partes else ""
argumento = partes[1].strip() if len(partes) > 1 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
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:
return procesar(cmd, chat_id)
# Flujo interactivo por genero (espera numeros en pasos) # Flujo interactivo por genero (espera numeros en pasos)
if flujo_genero.get(chat_id) and t.isdigit(): if flujo_genero.get(chat_id) and t.isdigit():
paso = flujo_genero[chat_id] paso = flujo_genero[chat_id]
@@ -627,9 +659,17 @@ def procesar(texto, chat_id):
elif t.startswith("bajar ") or t.startswith("/bajar "): elif t.startswith("bajar ") or t.startswith("/bajar "):
n = _int_arg(texto) n = _int_arg(texto)
procesar_bajar(n, chat_id) procesar_bajar(n, 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"): elif t in ("ayuda", "/ayuda", "help", "/help"):
enviar( enviar(
"📚 <b>ÍNDICE DE COMANDOS</b>\n" "📚 <b>ÍNDICE DE COMANDOS</b>\n"
"🧭 Escribe <b>/menu</b> para navegar con botones.\n"
"Escribe <b>/ayuda</b> cuando lo necesites.\n\n" "Escribe <b>/ayuda</b> cuando lo necesites.\n\n"
"━━━ 🎬 VIDEOCLUB ━━━\n" "━━━ 🎬 VIDEOCLUB ━━━\n"
"<b>1.</b> <code>busca &lt;nombre&gt;</code> — buscar en TODO internet\n" "<b>1.</b> <code>busca &lt;nombre&gt;</code> — buscar en TODO internet\n"