diff --git a/vigilancia/menu_idd.py b/vigilancia/menu_idd.py new file mode 100644 index 0000000..39a2b5d --- /dev/null +++ b/vigilancia/menu_idd.py @@ -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:" +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, + "🧭 Menú IDD\nElige una categoría:", + teclado_principal(), + ) + + +def _header(categoria): + nombres = { + "videoclub": "🎬 Videoclub", + "nas": "📥 NAS / Descargas", + "tv": "📺 TV", + "parental": "👪 Control parental", + "sistema": "🛠️ Sistema", + "router": "📡 Router ASUS", + } + return nombres.get(categoria, "🧭 Menú IDD") + + +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, "🧭 Menú IDD\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: + 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 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 /busca o usa /torrents <n>.", 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 /torrents <n> 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 {nombre}. ¿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 diff --git a/vigilancia/pi_bot.py b/vigilancia/pi_bot.py index 1f0f710..75418c9 100644 --- a/vigilancia/pi_bot.py +++ b/vigilancia/pi_bot.py @@ -16,6 +16,8 @@ flujo_genero = {} # chat_id -> 'esperando_tipo' | 'esperando_genero' | No 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 BASE = os.path.dirname(os.path.abspath(__file__)) LOG = os.path.join(BASE, "bot.log") @@ -226,8 +228,17 @@ def procesar_callback(callback): callback_id = callback.get("id") mensaje = callback.get("message") or {} 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) + # 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 not estado_panel: return @@ -283,6 +294,27 @@ def procesar(texto, chat_id): 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 + 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) if flujo_genero.get(chat_id) and t.isdigit(): paso = flujo_genero[chat_id] @@ -627,9 +659,17 @@ def procesar(texto, chat_id): elif t.startswith("bajar ") or t.startswith("/bajar "): n = _int_arg(texto) 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"): enviar( "📚 ÍNDICE DE COMANDOS\n" + "🧭 Escribe /menu para navegar con botones.\n" "Escribe /ayuda cuando lo necesites.\n\n" "━━━ 🎬 VIDEOCLUB ━━━\n" "1. busca <nombre> — buscar en TODO internet\n"