diff --git a/vigilancia/menu_idd.py b/vigilancia/menu_idd.py index d3b986f..96ec421 100644 --- a/vigilancia/menu_idd.py +++ b/vigilancia/menu_idd.py @@ -243,6 +243,24 @@ def _enviar_nuevo(chat_id, texto, teclado=None): _log(f"send error: {e}") +def _cerrar_menu(chat_id, message_id): + """Quita los botones del menu (lo deja cerrado como texto estatico).""" + import requests + try: + requests.post(_api("editMessageReplyMarkup"), + data={"chat_id": chat_id, "message_id": message_id}, + timeout=10) + except Exception as e: + _log(f"cerrar menu error: {e}") + + +def _disp(chat_id, message_id, comando): + """Ejecuta un comando del bot y cierra el menu (opcion elegida).""" + import pi_bot + pi_bot.procesar(comando, chat_id) + _cerrar_menu(chat_id, message_id) + + # ----------------------------------------------------------------- menu ---- def enviar_menu(chat_id): @@ -330,21 +348,23 @@ def procesar_callback(data, chat_id, message_id): } _enviar_nuevo(chat_id, preguntas.get(accion, "Escribe el dato:"), teclado_cancelar()) + _cerrar_menu(chat_id, message_id) return if ruta == "pausa_min": nombre = _get_pausa_nombre(chat_id) if not nombre: _enviar_nuevo(chat_id, "Empieza de nuevo: /menu", teclado_cancelar()) + _cerrar_menu(chat_id, message_id) return if partes[1] == "otro": _guardar_pendiente(chat_id, "pausa_minutos") _enviar_nuevo(chat_id, "⏱️ ¿Cuántos minutos? (1-1440)", teclado_cancelar()) + _cerrar_menu(chat_id, message_id) 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) + _disp(chat_id, message_id, + f"parental pausa {nombre} {minutos}") return if ruta == "acc": @@ -383,8 +403,7 @@ def _ejecutar_parental(chat_id, message_id, accion, nombre): teclado_minutos_pausa(nombre)) return comando = f"parental {'desactivar' if accion == 'desactivar' else 'activar'} {nombre}" - import pi_bot - pi_bot.procesar(comando, chat_id) + _disp(chat_id, message_id, comando) def _ejecutar_accion(chat_id, message_id, partes): @@ -408,10 +427,10 @@ def _ejecutar_accion(chat_id, message_id, partes): "parental_listar": "parental listar", } if accion in directos: - pi_bot.procesar(directos[accion], chat_id) + _disp(chat_id, message_id, directos[accion]) return if accion == "termo": - pi_bot.procesar(f"termo {sub}", chat_id) + _disp(chat_id, message_id, f"termo {sub}") return if accion == "router": mapeo = { @@ -423,12 +442,12 @@ def _ejecutar_accion(chat_id, message_id, partes): "reiniciar": "router reiniciar", "reservar": "router reservar", } - pi_bot.procesar(mapeo.get(sub, "router ayuda"), chat_id) + _disp(chat_id, message_id, mapeo.get(sub, "router ayuda")) return if accion in ("torrents", "bajar"): numero = int(sub) if sub.isdigit() else 0 if numero: - pi_bot.procesar(f"{accion} {numero}", chat_id) + _disp(chat_id, message_id, f"{accion} {numero}") return diff --git a/vigilancia/nas_dl.py b/vigilancia/nas_dl.py index d213f90..0183936 100644 --- a/vigilancia/nas_dl.py +++ b/vigilancia/nas_dl.py @@ -210,6 +210,64 @@ def listar_descargas(hablar_solo_activas=True, session=None): return lineas, activas +def listar_tareas(session=None): + """Devuelve todas las tareas de Download Station con id y estado, + para poder borrar una concreta desde Telegram.""" + if not session: + session = login() + if not session: + return [] + try: + r = _api("/webapi/DownloadStation/task.cgi", { + "api": "SYNO.DownloadStation.Task", + "version": "3", + "method": "list", + "additional": "detail,transfer", + }, session=session) + if not r.get("success"): + return [] + except Exception as e: + print(f"[NAS] Error listar_tareas: {e}") + return [] + salida = [] + for t in (r.get("data", {}).get("tasks", []) or []): + detail = (t.get("additional") or {}).get("detail") or {} + transfer = (t.get("additional") or {}).get("transfer") or {} + total = detail.get("total_pieces") or 0 + done = transfer.get("downloaded_pieces") or 0 + pct = (done * 100.0 / total) if total else 0.0 + salida.append({ + "id": t.get("id"), + "title": t.get("title") or "", + "status": t.get("status") or "?", + "pct": pct, + "size": transfer.get("size_downloaded") or 0, + }) + return salida + + +def borrar_tarea(tid, session=None): + """Borra una tarea de Download Station por su id. + Devuelve (ok, detalle).""" + if not session: + session = login() + if not session: + return False, "No se pudo autenticar con Download Station." + try: + import json as _json + r = _api("/webapi/DownloadStation/task.cgi", { + "api": "SYNO.DownloadStation.Task", + "version": "3", + "method": "delete", + "id": _json.dumps([tid]), + }, session=session) + except Exception as e: + return False, str(e) + if r.get("success"): + return True, "ok" + return False, str(r.get("error")) + + if __name__ == "__main__": import sys if len(sys.argv) < 2: diff --git a/vigilancia/pi_bot.py b/vigilancia/pi_bot.py index f80429a..93fceac 100644 --- a/vigilancia/pi_bot.py +++ b/vigilancia/pi_bot.py @@ -19,6 +19,7 @@ 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) +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") @@ -119,6 +120,7 @@ def teclado_descargas(): ], [ {"text": "📁 Todas", "callback_data": "dl:all"}, + {"text": "🗑️ Borrar…", "callback_data": "dl:del"}, {"text": "✖️ Cerrar", "callback_data": "dl:close"}, ], ] @@ -245,6 +247,9 @@ def procesar_callback(callback): 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": @@ -292,6 +297,57 @@ def limpiar_pantalla(chat_id): 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 = ["🗑️ Responde el número de la tarea a BORRAR " + "(o escribe cancelar):", ""] + 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: {titulo[:80]}", chat_id) + else: + enviar(f"❌ No se pudo borrar {titulo[:60]}: {detalle}", chat_id) + return True + + def mandar_tv(mensaje, simple): try: url = f"{TV_API}?texto={urllib.parse.quote(mensaje)}" @@ -340,6 +396,10 @@ def procesar(texto, chat_id): return if cmd: return procesar(cmd, chat_id) + # 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] @@ -418,6 +478,10 @@ def procesar(texto, 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": @@ -716,7 +780,8 @@ def procesar(texto, chat_id): " ✏️ Ej: /novedades\n\n" "━━━ 🖥️ NAS / DESCARGAS ━━━\n" "8. descargas — panel con progreso y botones\n" - " ✏️ Ej: /descargas o /descargar\n" + " ▶️ En el panel: botón 🗑️ Borrar… para eliminar una tarea\n" + " ✏️ Ej: /descargas o /borrar\n" "9. descargar <enlace> — añade a Download Station\n" " ✏️ Ej: /descargar magnet:?xt=urn:btih:...\n" "10. termo on|off|estado — controla el enchufe Tapo\n" @@ -1091,6 +1156,23 @@ def guardar_offset(offset): 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 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") @@ -1112,16 +1194,8 @@ def main(): offset = upd["update_id"] + 1 guardar_offset(offset) log(f"UPDATE {upd.get('update_id')}") - if upd.get("callback_query"): - procesar_callback(upd["callback_query"]) - continue - msg = upd.get("message") or upd.get("channel_post") - if not msg: - continue - texto = msg.get("text") - chat_id = msg["chat"]["id"] - if texto: - procesar(texto, chat_id) + threading.Thread(target=_atender_update, args=(upd,), + daemon=True).start() except Exception as e: log(f"ERROR {e}") print("Error:", e) diff --git a/vps_opencode_bot/opencode_bot.py b/vps_opencode_bot/opencode_bot.py index c3db909..4c8ec80 100644 --- a/vps_opencode_bot/opencode_bot.py +++ b/vps_opencode_bot/opencode_bot.py @@ -53,6 +53,7 @@ bloqueos = {} # chat_id -> True (procesando mensaje) sesiones = {} # chat_id -> {'repo': slug, 'session': id, 'model': obj|None} repos_cache = [] # lista de {'slug','dir'} sentidos = {} # chat_id -> [message_id...] enviados por el bot (para /limpiar) +menu_msg = {} # chat_id -> message_id del menu abierto def log(linea): @@ -140,10 +141,14 @@ def teclado(filas): def tg_con_teclado(chat_id, texto, filas): - _registrar(chat_id, tg("sendMessage", - {"chat_id": chat_id, "text": texto, - "parse_mode": "HTML", - "reply_markup": json.dumps(teclado(filas))})) + resp = tg("sendMessage", + {"chat_id": chat_id, "text": texto, "parse_mode": "HTML", + "reply_markup": json.dumps(teclado(filas))}) + _registrar(chat_id, resp) + try: + return (resp.get("result") or {}).get("message_id") + except Exception: + return None def editar(chat_id, mid, texto, filas=None): @@ -443,6 +448,7 @@ def cmd_ayuda(chat): "🤖 OpenCode por Telegram\n" "Escribe cualquier cosa y lo hará el agente en el repo activo.\n\n" "Comandos:\n" + "/menu — menú interactivo (modelo, proyecto, cerrar)\n" "/nueva [texto] — sesión nueva (olvida el contexto)\n" "/salir — cerrar la sesión actual (la próxima arranca limpia)\n" "/proyecto / /defecto — elegir repo (queda por defecto)\n" @@ -565,6 +571,154 @@ def cmd_modelo(chat): tg_con_teclado(chat, "🧠 Elige modelo (se aplica a partir del próximo mensaje):", filas) +# ------------------------------------------------------------ menu wizard -- + +def _wiz_resumen(chat): + slug, _ = repo_activo() + st = sesiones.get(str(chat)) or {} + modelo = (st.get("model") or {}).get("name") or "por defecto" + return (f"🧭 Menú OpenCode\n" + f"🧠 Modelo: {modelo}\n" + f"📂 Proyecto: {slug}\n\n" + "Configura y pulsa Cerrar.") + + +def _wiz_raiz_filas(): + return [ + [{"text": "🧠 Elegir modelo", "callback_data": "w:modelo"}, + {"text": "📂 Elegir proyecto", "callback_data": "w:proyecto"}], + [{"text": "✅ Cerrar menú", "callback_data": "w:cerrar"}, + {"text": "🚪 Salir", "callback_data": "w:salir"}], + ] + + +def _quitar_teclado(chat, mid): + try: + requests.post(f"{API}/editMessageReplyMarkup", + data={"chat_id": chat, "message_id": mid}, timeout=10) + except Exception: + pass + + +def cmd_menu(chat): + mid = tg_con_teclado(chat, _wiz_resumen(chat), _wiz_raiz_filas()) + if mid: + menu_msg[str(chat)] = mid + + +def _wiz_mostrar(chat, mid, texto, filas): + try: + requests.post(f"{API}/editMessageText", + data={"chat_id": chat, "message_id": mid, "text": texto, + "parse_mode": "HTML", + "reply_markup": json.dumps(teclado(filas))}, + timeout=15) + except Exception as e: + log(f"wiz edit error: {e}") + + +def _cambiar_repo_silencioso(chat, slug): + ruta = os.path.join(GH, slug) + if not os.path.isdir(os.path.join(ruta, ".git")): + return "Repo no válido" + try: + with open(REPO_ENV, "w") as f: + f.write(f"REPO_DIR={ruta}\n") + subprocess.run(["systemctl", "restart", "opencode-serve"], + timeout=60, capture_output=True) + except Exception as e: + log(f"cambiar_repo_silencioso error: {e}") + return str(e) + for _ in range(25): + if health_ok(): + break + time.sleep(1) + st = sesiones.get(str(chat)) or {} + sid, err = crear_sesion() + if sid: + sesiones[str(chat)] = {"repo": slug, "session": sid, + "model": st.get("model")} + guardar_estado() + return None + return str(err) + + +def manejar_wizard(data, chat, mid): + if data == "w:cerrar": + _quitar_teclado(chat, mid) + return True + if data == "w:salir": + _quitar_teclado(chat, mid) + return True + if data == "w:modelo": + modelos = listar_modelos() + if not modelos: + _wiz_mostrar(chat, mid, "No hay modelos disponibles.", _wiz_raiz_filas()) + return True + st = sesiones.get(str(chat)) or {} + actual = (st.get("model") or {}).get("id") + filas = [] + for m in modelos[:24]: + marca = " ✅" if m["id"] == actual else "" + filas.append([{"text": (m["name"] + marca)[:50], + "callback_data": f"wm:{m['providerID']}:{m['id']}"}]) + filas.append([{"text": "↩️ Por defecto", "callback_data": "wm:def"}, + {"text": "⬅️ Atrás", "callback_data": "w:root"}]) + _wiz_mostrar(chat, mid, "🧠 Elige modelo:", filas) + return True + if data == "w:proyecto": + lista = listar_repos() + if not lista: + _wiz_mostrar(chat, mid, "No hay repos.", _wiz_raiz_filas()) + return True + slug_act, _ = repo_activo() + filas = [] + for i, r in enumerate(lista[:20]): + marca = " ✅" if r["slug"] == slug_act else "" + filas.append([{"text": r["slug"] + marca, + "callback_data": f"wr:{i}"}]) + filas.append([{"text": "⬅️ Atrás", "callback_data": "w:root"}]) + _wiz_mostrar(chat, mid, "📂 Elige proyecto (reinicia el serve):", filas) + return True + if data == "w:root": + _wiz_mostrar(chat, mid, _wiz_resumen(chat), _wiz_raiz_filas()) + return True + if data.startswith("wm:"): + if data == "wm:def": + st = sesiones.get(str(chat)) or {} + st["model"] = None + sesiones[str(chat)] = st + guardar_estado() + else: + resto = data[3:] + provider, mid_modelo = resto.split(":", 1) + elegido = next((m for m in listar_modelos() + if m["providerID"] == provider + and m["id"] == mid_modelo), None) + if elegido: + st = sesiones.get(str(chat)) or {} + st["model"] = elegido + sesiones[str(chat)] = st + guardar_estado() + _wiz_mostrar(chat, mid, _wiz_resumen(chat), _wiz_raiz_filas()) + return True + if data.startswith("wr:"): + try: + idx = int(data[3:]) + r = repos_cache[idx] + except Exception: + return True + _wiz_mostrar(chat, mid, f"Cambiando a {r['slug']}…", + [[{"text": "⏳", "callback_data": "w:none"}]]) + err = _cambiar_repo_silencioso(chat, r["slug"]) + if err: + _wiz_mostrar(chat, mid, f"⚠️ {err}", _wiz_raiz_filas()) + else: + _wiz_mostrar(chat, mid, _wiz_resumen(chat), _wiz_raiz_filas()) + return True + return False + + def responder_callback(callback): cid = callback.get("id") data = callback.get("data") or "" @@ -572,6 +726,14 @@ def responder_callback(callback): chat = (msg.get("chat") or {}).get("id") mid = msg.get("message_id") tg("answerCallbackQuery", {"callback_query_id": cid}) + if ALLOWED and str(chat) not in ALLOWED.split(","): + return + if data.startswith(("w:", "wm:", "wr:")): + try: + manejar_wizard(data, chat, mid) + except Exception as e: + log(f"wizard error: {e}") + return if not data.startswith("cb:"): return partes = data.split(":") @@ -625,6 +787,8 @@ def procesar(texto, chat): resto = partes[1].strip() if len(partes) > 1 else "" if comando in ("/start", "/ayuda", "/help"): cmd_ayuda(chat) + elif comando == "/menu": + cmd_menu(chat) elif comando in ("/nueva", "/nuevo"): cmd_nueva(chat, resto) elif comando in ("/salir", "/fin", "/exit"): @@ -660,7 +824,9 @@ def main(): offset = upd["update_id"] + 1 guardar_offset(offset) if upd.get("callback_query"): - responder_callback(upd["callback_query"]) + threading.Thread(target=responder_callback, + args=(upd["callback_query"],), + daemon=True).start() continue msg = upd.get("message") if not msg: