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: