diff --git a/AGENTS.md b/AGENTS.md index f32376e..7d7def3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ Start-Process powershell -WindowStyle Hidden -ArgumentList '-Command', 'python C | `consumo` | Abrir https://opencode.ai/auth, esperar captura del panel Go y analizarla con image-vision | | `memoria` | Protocolo completo: git pull, actualizar ficheros de memoria (MEMORIA_ABSOLUTA, ESTADO_DIARIO, AGENTS, DOCUMENTACION, README), Engram local + cloud, Obsidian sync, **trello-sync**, git push | | `recordatorios` | Consultar API recordatorios en VPS: GET /todo (calendarios + recordatorios webhook) | +| `raspberry` | Diagnosticar y mantener las Raspberry Pi del laboratorio | | `idd-procesar` o `procesar IDD` | Fetch manual de mensajes pendientes del canal IDD Telegram via Bot API, guardarlos en Google Drive y clasificarlos localmente | | `trello-sync` | Sincronizar pendientes con Trello: local→Trello (crea/actualiza cards) + Trello→local (elimina tareas archivadas en Trello de la base de conocimiento) | | `pelicula ` | Buscar pelicula en el videoclub y descargar a NAS | diff --git a/docs/raspberry_pi.md b/docs/raspberry_pi.md new file mode 100644 index 0000000..85d03e5 --- /dev/null +++ b/docs/raspberry_pi.md @@ -0,0 +1,80 @@ +# Raspberry Pi del laboratorio + +## Objetivo + +Centralizar la configuracion, el acceso y el mantenimiento de una flota de Raspberry Pi. La primera unidad es `minipc`; el mismo procedimiento debe servir para las cinco unidades previstas. + +## Inventario actual + +| Campo | Valor | +|---|---| +| Hostname | `minipc` | +| Sistema | Raspbian GNU/Linux 13 (trixie) | +| Ethernet | `192.168.50.187` | +| Wi-Fi | `192.168.50.251` | +| Usuario operativo | `pi` | +| Codigo | `/home/pi/minipc/` | +| Servicio bot | `pi-bot.service` | +| Servicio tunel | `autossh-tunel.service` | +| Zona horaria | `Europe/Madrid` | +| Teclado | Espanol (`es`) | + +No se documentan contrasenas, tokens ni claves privadas en este fichero. + +## Comando unico de diagnostico + +En la Raspberry: + +```bash +bash /home/pi/minipc/vigilancia/raspberry_info.sh +``` + +El comando muestra identidad, red, teclado, locale, hora, servicios y recursos sin revelar secretos. + +Desde el PC, pasando por el VPS: + +```powershell +ssh -i C:\Users\juanm\Documents\GitHub\contabo root@185.187.169.109 "ssh -i /root/.ssh/pi_deploy -p 2222 pi@127.0.0.1 'bash /home/pi/minipc/vigilancia/raspberry_info.sh'" +``` + +## Configuracion aplicada en minipc + +```bash +sudo raspi-config nonint do_configure_keyboard es +sudo localectl set-x11-keymap es pc105 +sudo locale-gen es_ES.UTF-8 +sudo localectl set-locale LANG=es_ES.UTF-8 +sudo timedatectl set-timezone Europe/Madrid +sudo timedatectl set-ntp true +``` + +El cambio de idioma puede requerir cerrar la sesion y volver a entrar. El teclado de consola queda definido en `/etc/default/keyboard` con `XKBLAYOUT="es"`. + +## Conectividad + +- Ethernet es la ruta preferida y usa la IP `192.168.50.187`. +- Wi-Fi queda disponible como respaldo en `192.168.50.251`. +- El tunel inverso publica SSH de la Pi en el VPS como `127.0.0.1:2222`. +- El acceso remoto usa la clave privada del VPS configurada para el tunel; nunca se copia ni documenta aqui. +- Si la IP cambia, consultar `hostname -I` en la Pi y actualizar el inventario. + +## Despliegue + +La Pi no tiene Git operativo para este flujo. El despliegue se hace por el VPS y el tunel inverso: + +1. Copiar el fichero al VPS. +2. Copiarlo por SSH a `pi@127.0.0.1:2222`. +3. Validar con `python3 -m py_compile` si es Python. +4. Reiniciar el servicio correspondiente. +5. Verificar `systemctl is-active` y los logs. + +## Escalado a cinco Raspberry + +Cada unidad debe tener un registro con hostname, MAC, IP Ethernet, IP Wi-Fi, servicios, funcion y ubicacion. Se recomienda: + +- Reservas DHCP por MAC para evitar cambios de IP. +- Ethernet como red principal. +- Un hostname unico por unidad: `minipc-01`, `minipc-02`, etc. +- Un servicio `autossh-tunel` por unidad con un puerto de tunel distinto. +- Una clave SSH de despliegue independiente por unidad. +- Un script de diagnostico identico en todas las unidades. diff --git a/documentacion/bitacora-dashboard-telegram-2026-09-05.md b/documentacion/bitacora-dashboard-telegram-2026-09-05.md new file mode 100644 index 0000000..2487a38 --- /dev/null +++ b/documentacion/bitacora-dashboard-telegram-2026-09-05.md @@ -0,0 +1,35 @@ +# Bitacora: Dashboard de descargas en Telegram + +**Fecha:** 2026-09-05 · **Equipo:** sobremesa · **Agentes:** OpenCode + +--- + +## Registro en tiempo real + +### [14:35] Diseño inicial +- **Que:** Crear una vista de descargas dentro de Telegram. +- **Como:** Reutilizar la consulta existente a Download Station y editar un unico mensaje con botones inline. +- **Resultado:** OK +- **Decisiones:** No se crea una web externa ni una Mini App en esta primera version. +- **Gotchas/Lecciones:** El bot usa directamente la Bot API mediante `requests` y actualmente solo procesa mensajes normales. + +### [14:42] Implementacion +- **Que:** Añadir panel editable, botones inline, callbacks y refresco automatico. +- **Como:** `pi_bot.py` mantiene un mensaje por chat y lo actualiza cada 20 segundos; `nas_dl.py` genera la barra Unicode usando el porcentaje de piezas descargadas. +- **Resultado:** OK +- **Decisiones:** `descargas` y `descargar` sin argumento abren el panel; `descargar ` mantiene la funcion de añadir tareas. +- **Gotchas/Lecciones:** Telegram necesita recibir y responder `callback_query` para que los botones funcionen correctamente. + +### [14:47] Verificacion local +- **Que:** Comprobar sintaxis, barra de progreso y callbacks sin llamar a Telegram. +- **Como:** `py_compile`, prueba de extremos de `progress_bar` y callbacks simulados. +- **Resultado:** OK +- **Decisiones:** No se enviaron mensajes reales ni se modificaron tareas del NAS durante la prueba. +- **Gotchas/Lecciones:** La Pi no acepta conexiones SSH desde este equipo en `192.168.50.187:22`; el despliegue queda pendiente. + +### [15:43] Configuracion Raspberry Pi y flota +- **Que:** Recuperar el acceso por clave, configurar teclado/locale espanoles, zona horaria y NTP, y centralizar el mantenimiento de Raspberry Pi. +- **Como:** Se uso el salto PC -> VPS -> tunel inverso -> Pi; se aplico `raspi-config` para teclado, `localectl` para locale/X11 y `timedatectl` para hora. +- **Resultado:** OK +- **Decisiones:** Ethernet queda como ruta preferida, Wi-Fi como respaldo; se crea `raspberry_info.sh` como comando unico de diagnostico sin secretos. +- **Gotchas/Lecciones:** El servicio correcto es `autossh-tunel.service`; la Pi tiene Raspbian 13 y dos IPs activas (`192.168.50.187` y `192.168.50.251`). diff --git a/vigilancia/nas_dl.py b/vigilancia/nas_dl.py index 1a3863f..d213f90 100644 --- a/vigilancia/nas_dl.py +++ b/vigilancia/nas_dl.py @@ -149,6 +149,13 @@ def human_size(n): return f"{n:.1f}PB" +def progress_bar(percent, width=12): + """Genera una barra de progreso compacta para mensajes de Telegram.""" + percent = max(0.0, min(100.0, float(percent or 0))) + filled = int(round(percent * width / 100)) + return "█" * filled + "░" * (width - filled) + + def listar_descargas(hablar_solo_activas=True, session=None): """Devuelve (lineas_texto, activas). Lista las tareas de Download Station con estado, porcentaje de progreso, tamaño descargado y velocidad. @@ -181,7 +188,7 @@ def listar_descargas(hablar_solo_activas=True, session=None): size = transfer.get("size_downloaded") or 0 speed = transfer.get("speed_download") or 0 if estado == "downloading": - activas.append(f"▶️ {pct:.1f}% {human_size(size)} ⬇️{speed // 1024} KB/s\n" + activas.append(f"▶️ {progress_bar(pct)} {pct:.1f}% {human_size(size)} ⬇️{speed // 1024} KB/s\n" f" {titulo}") else: resto.append((estado, pct, size, titulo)) diff --git a/vigilancia/pi_bot.py b/vigilancia/pi_bot.py index b3f921a..c4b998d 100644 --- a/vigilancia/pi_bot.py +++ b/vigilancia/pi_bot.py @@ -5,6 +5,7 @@ import sys import threading import time import urllib.parse +import json import requests @@ -13,6 +14,7 @@ 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 BASE = os.path.dirname(os.path.abspath(__file__)) LOG = os.path.join(BASE, "bot.log") @@ -100,6 +102,148 @@ def enviar(texto, chat_id): 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": "✖️ 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 = "📥 DESCARGAS DEL NAS" + if modo == "active": + encabezado += " · ACTIVAS" + texto = encabezado + "\n\n" + "\n".join(lineas) + texto += f"\n\n🕒 Actualizado: {time.strftime('%H:%M:%S')}" + 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") + accion = (callback.get("data") or "").split(":", 1)[-1] + responder_callback(callback_id) + estado_panel = panel_descargas.get(chat_id) + 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( @@ -213,6 +357,8 @@ 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 t in ("descargas", "/descargas") or (comando == "descargar" and not argumento): + abrir_panel_descargas(chat_id) elif comando == "descargar": try: import nas_dl @@ -220,11 +366,6 @@ def procesar(texto, chat_id): 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 bot.log.", chat_id) - else: - # Incluye también pendientes, pausadas y terminadas para que - # el usuario pueda ver el estado real de cada torrent. - lineas, _act = nas_dl.listar_descargas(hablar_solo_activas=False) - enviar("\n".join(lineas), chat_id) except Exception as e: log(f"descargar error: {e}") enviar(f"❌ Error en Download Station: {e}", chat_id) @@ -269,8 +410,8 @@ def procesar(texto, chat_id): "7. novedades — últimas novedades del catálogo\n" " ✏️ Ej: /novedades\n\n" "━━━ 🖥️ NAS / DESCARGAS ━━━\n" - "8. descargar — torrents descargándose y su progreso\n" - " ✏️ Ej: /descargar\n" + "8. descargas — panel con progreso y botones\n" + " ✏️ Ej: /descargas o /descargar\n" "9. descargar <enlace> — añade a Download Station\n" " ✏️ Ej: /descargar magnet:?xt=urn:btih:...\n\n" "━━━ 📺 TV ━━━\n" @@ -646,6 +787,9 @@ 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 diff --git a/vigilancia/raspberry_info.sh b/vigilancia/raspberry_info.sh new file mode 100644 index 0000000..4ae25ce --- /dev/null +++ b/vigilancia/raspberry_info.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Diagnostico seguro de una Raspberry Pi del laboratorio. +# No muestra credenciales, tokens ni contenido de ficheros .env. +set -u + +echo "== IDENTIDAD ==" +hostname +cat /etc/os-release | sed -n 's/^\(PRETTY_NAME\)=/\1=/p' + +echo "== RED ==" +hostname -I +ip -br addr +ip route + +echo "== TECLADO Y LOCALE ==" +localectl status 2>&1 +if [ -f /etc/default/keyboard ]; then + sed -n '/^XKBMODEL=/p;/^XKBLAYOUT=/p;/^XKBVARIANT=/p;/^XKBOPTIONS=/p' /etc/default/keyboard +fi + +echo "== HORA ==" +timedatectl status 2>&1 + +echo "== SERVICIOS ==" +for service in ssh pi-bot autossh-tunel tv-api; do + printf '%-16s' "$service" + systemctl is-active "$service" 2>/dev/null || true +done + +echo "== RECURSOS ==" +uptime +free -h +df -h / | tail -n 1 +if command -v vcgencmd >/dev/null 2>&1; then + vcgencmd measure_temp +fi