Compare commits

...

30 Commits

Author SHA1 Message Date
minguezsanzjuanjose
0adad1e45e 08-oct: superguardado automático 2026-10-08 23:31:02 +02:00
minguezsanzjuanjose
23d794f5a7 06-oct: superguardado automático 2026-10-06 16:53:35 +02:00
minguezsanzjuanjose
f484eaab38 06-oct: [omniroute] re-auth provider Claude + helper OAuth y vigilante portapapeles 2026-10-06 14:44:40 +02:00
minguezsanzjuanjose
cba0e1f931 05-oct: superguardado automático 2026-10-05 23:43:17 +02:00
minguezsanzjuanjose
dde5d97989 05-oct: superguardado automático 2026-10-05 18:04:38 +02:00
minguezsanzjuanjose
ce457261fb 05-oct: superguardado automático 2026-10-05 15:39:54 +02:00
minguezsanzjuanjose
7f67560a0a 04-oct: encender_sobremesa omite WoL por defecto (--wol opcional) 2026-10-04 01:39:52 +02:00
minguezsanzjuanjose
0e784be152 04-oct: changelog fix encender_sobremesa .env 2026-10-04 01:38:01 +02:00
minguezsanzjuanjose
cdeaec64ec 04-oct: encender_sobremesa.py carga .env (fix HOMEASSISTANT_URL/TOKEN) 2026-10-04 01:37:48 +02:00
minguezsanzjuanjose
a8cb3852b1 04-oct: RDP sobremesa por Tailscale (changelog) 2026-10-04 00:52:54 +02:00
minguezsanzjuanjose
d5df0e3f05 04-oct: encender_sobremesa escalera WoL->regleta->ciclo (Power On Loss validado) + doc 2026-10-04 00:48:29 +02:00
minguezsanzjuanjose
80bd93f821 04-oct: correccion changelog (sobremesa SI tiene RTX 4070) 2026-10-04 00:30:20 +02:00
minguezsanzjuanjose
ec4dcb7a4c 04-oct: changelog - VoiceStudio+AutoHotkey+tarea SuperWhisper en sobremesa 2026-10-04 00:28:38 +02:00
minguezsanzjuanjose
f470bb68f6 04-oct: changelog fix USERDOMAIN->COMPUTERNAME 2026-10-04 00:22:15 +02:00
minguezsanzjuanjose
f243ce8f1d 04-oct: fix USERDOMAIN->COMPUTERNAME en tareas (mapeo cuenta fallaba en workgroup/SSH) 2026-10-04 00:21:52 +02:00
minguezsanzjuanjose
29157d4f08 04-oct: instalar_dependencias.ps1 + omniroute-autostart.ts al repo + comparativa equipos 2026-10-04 00:15:07 +02:00
minguezsanzjuanjose
0b4fe1cf7f 04-oct: superguardado automático 2026-10-04 00:06:43 +02:00
Juanjo Minguez
c830c2ad47 03-oct: fix protocolo_apagado.py (ruta clave VPS dinamica + base64 + skip rapido) + changelog 2026-10-03 23:36:23 +02:00
Juanjo Minguez
52f1a0ed25 03-oct: superguardado automático 2026-10-03 23:30:44 +02:00
Juanjo Minguez
10b0bb357d 03-oct: changelog - config SuperWhisper/Obsidian/M575 sobremesa juanjo 2026-10-03 23:06:54 +02:00
Juanjo Minguez
a75211cd05 03-oct: migracion juanjo - rutas dinamicas (.py/.ps1/.ts) + changelog (apps, OmniRoute 3.8.51, UFW 5001) 2026-10-03 22:24:12 +02:00
juanminsanz
3f20313f6d 03-oct: superguardado automático 2026-10-03 21:03:27 +02:00
juanminsanz
4fa9c2ae5b 03-oct: superguardado automático 2026-10-03 20:45:54 +02:00
minguezsanzjuanjose
34806538d5 03-oct: superguardado automático 2026-10-03 19:56:53 +02:00
minguezsanzjuanjose
5446db6a2b 03-oct: superguardado automático 2026-10-03 19:24:21 +02:00
minguezsanzjuanjose
1b385cca7c 03-oct: superguardado automático 2026-10-03 17:18:41 +02:00
minguezsanzjuanjose
fdd4ea877a 03-oct: superguardado automático 2026-10-03 17:13:39 +02:00
minguezsanzjuanjose
f10ce5bef4 03-oct: superguardado automático 2026-10-03 13:29:51 +02:00
minguezsanzjuanjose
0579892b9e 03-oct: superguardado automático 2026-10-03 13:21:34 +02:00
minguezsanzjuanjose
5fad171a97 02-oct: superguardado automático 2026-10-02 21:41:35 +02:00
64 changed files with 3902 additions and 111 deletions

2
.gitignore vendored
View File

@@ -4,6 +4,7 @@ __pycache__/
*.pyo *.pyo
.venv/ .venv/
venv/ venv/
.pytest_cache/
# Entorno / secretos # Entorno / secretos
.env .env
@@ -15,6 +16,7 @@ estado/*.log
estado/.watcher_last_id estado/.watcher_last_id
estado/.netatmo_last_alert.json estado/.netatmo_last_alert.json
estado/.netatmo_token.json estado/.netatmo_token.json
estado/monitor/
# IDE / OS # IDE / OS
.vscode/ .vscode/

View File

@@ -5,39 +5,211 @@
## Pendientes de replicar ## Pendientes de replicar
<<<<<<< HEAD - **2026-10-03-superwhisper-enter-ahk-f13-boton-auxiliar-logitech-tarea-pro** [scripts] SuperWhisper Enter: AHK F13 boton auxiliar Logitech + tarea programada (aplicado en: portatil)
- **2026-09-30-fix-telegram-watcher-telethon-1-44-encoding-utf-8** [watchers] Fix telegram_watcher (telethon 1.44 + encoding UTF-8) (aplicado en: portatil) - **2026-10-03-enrutado-de-voz-de-avisos-carolina-juanjo-abuelo-voicestudio** [scripts] Enrutado de voz de avisos: Carolina/Juanjo/Abuelo (VoiceStudio) (aplicado en: portatil)
- **2026-10-03-voces-de-aviso-cambiadas-a-nativas-en-castellano-carolina-f4** [scripts] Voces de aviso cambiadas a nativas en castellano (Carolina f40515ca / Abuelo e0ecda0a) (aplicado en: portatil)
- **2026-10-04-protocolo-de-encendido-remoto-del-sobremesa-powershell-por-s** [servicios] Protocolo de encendido remoto del sobremesa + PowerShell por SSH (aplicado en: portatil)
- **2026-10-04-instalador-de-dependencias-python-plugin-omniroute-autostart** [scripts] Instalador de dependencias Python + plugin omniroute-autostart + comparativa equipos (aplicado en: portatil)
- **2026-10-04-fix-tareas-programadas-userdomain-computername-workgroup-ssh** [scripts] Fix tareas programadas: USERDOMAIN->COMPUTERNAME (workgroup/SSH) (aplicado en: portatil)
- **2026-10-04-sobremesa-instalados-voicestudio-autohotkey-v2-tarea-superwh** [scripts] Sobremesa: instalados VoiceStudio + AutoHotkey v2 + tarea SuperWhisper Enter (aplicado en: portatil)
- **2026-10-04-protocolo-encendido-sobremesa-validado-wol-falla-ciclo-regle** [servicios] Protocolo encendido sobremesa validado: WoL falla, ciclo regleta funciona (Power On Loss) (aplicado en: portatil)
- **2026-10-04-rdp-activado-en-sobremesa-por-tailscale-cuenta-sin-password** [servicios] RDP activado en sobremesa por Tailscale (cuenta sin password) (aplicado en: portatil)
- **2026-10-04-fix-encender-sobremesa-py-cargar-env-home-assistant** [scripts] Fix encender_sobremesa.py: cargar .env (Home Assistant) (aplicado en: portatil)
- **2026-10-06-re-auth-provider-claude-omniroute-helper-oauth-vigilante-por** [omniroute] Re-auth provider Claude OmniRoute (helper OAuth + vigilante portapapeles) (aplicado en: portatil)
## Historial ## Historial
### 2026-10-06 · Re-auth provider Claude OmniRoute (helper OAuth + vigilante portapapeles)
- **id**: `2026-10-06-re-auth-provider-claude-omniroute-helper-oauth-vigilante-por`
- **Equipo**: portatil
- **Categoria**: omniroute
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: Scripts reutilizables en INFRAESTRUCTURA/omniroute: oauth_helper.py (mantiene vivo el proceso OAuth y le inyecta code#state por stdin, sin timeout) y clipboard_watch.ps1 (lee el codigo del portapapeles en modo -STA). Uso: lanzar helper, abrir URL con --no-browser, autorizar, copiar codigo -> auto-inyectado. Necesario cuando caduca el token OAuth del provider claude.
### 2026-10-04 · Protocolo de encendido remoto del sobremesa + PowerShell por SSH
- **id**: `2026-10-04-protocolo-de-encendido-remoto-del-sobremesa-powershell-por-s`
- **Equipo**: portatil
- **Categoria**: servicios
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: docs/PROTOCOLO_ENCENDIDO_REMOTO_SSH.md + scripts/encender_sobremesa.py (check Tailscale -> enciende regleta via Alexa/HA -> espera 3min -> check -> WoL best-effort -> espera 3min -> informe con voz Carolina) + scripts/ssh_sobremesa.ps1 (abre PowerShell en juanjo@100.75.115.29). Validado: regleta encendio el PC y SSH funciona. Red doble NAT Zyxel(.1)->Asus(.50); se llega por Tailscale.
### 2026-10-04 · Instalador de dependencias Python + plugin omniroute-autostart + comparativa equipos
- **id**: `2026-10-04-instalador-de-dependencias-python-plugin-omniroute-autostart`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: scripts/instalar_dependencias.ps1 (union de paquetes pip), opencode/omniroute-autostart.ts anadido al repo, instalar_global.ps1 ahora copia tambien ese plugin, docs/COMPARATIVA_EQUIPOS.md. Objetivo: igualar componentes comunes entre portatil y sobremesa.
### 2026-10-04 · Fix tareas programadas: USERDOMAIN->COMPUTERNAME (workgroup/SSH)
- **id**: `2026-10-04-fix-tareas-programadas-userdomain-computername-workgroup-ssh`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: Register-ScheduledTask fallaba con 'No mapping account SIDs' porque en cuentas locales USERDOMAIN=WORKGROUP. Corregido en instalar_global.ps1, instalar_watchers.ps1, install_omniroute_autostart.ps1, bootstrap_autologon_juanjo.ps1. Validado re-ejecutando instalar_global.ps1 sobre el sobremesa (por SSH): tareas OmniRoute Watchdog, Monitor Estado, Telegram Watcher, Recordatorios Msgbox, Shutdown Listener registradas OK.
### 2026-10-04 · Sobremesa: instalados VoiceStudio + AutoHotkey v2 + tarea SuperWhisper Enter
- **id**: `2026-10-04-sobremesa-instalados-voicestudio-autohotkey-v2-tarea-superwh`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: Via SSH desde el portatil. VoiceStudio 0.5.6 (per-user en %LOCALAPPDATA%\Programs\VoiceStudio, instalador GitHub debpalash/VoiceStudio /S). AutoHotkey v2 (winget AutoHotkey.AutoHotkey). Tarea programada 'SuperWhisper Enter' (AtLogOn, hidden) -> AutoHotkey64.exe + scripts/superwhisper_enter.ahk. Nota: el sobremesa tiene RTX 4070 (12GB) -> VoiceStudio usara GPU; requiere abrir la app para levantar backend 3900 y crear perfiles de voz.
### 2026-10-04 · Protocolo encendido sobremesa validado: WoL falla, ciclo regleta funciona (Power On Loss)
- **id**: `2026-10-04-protocolo-encendido-sobremesa-validado-wol-falla-ciclo-regle`
- **Equipo**: portatil
- **Categoria**: servicios
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: Prueba 04-oct: apagado simple OK; WoL desde portatil NO responde (doble NAT .50->.1); ciclo regleta (apagar 1min + encender) arranca el PC solo (BIOS Restore on AC Power Loss = Power On). encender_sobremesa.py actualizado con la escalera WoL->regleta->ciclo-regleta. Doc PROTOCOLO_ENCENDIDO_REMOTO_SSH.md actualizado.
### 2026-10-04 · RDP activado en sobremesa por Tailscale (cuenta sin password)
- **id**: `2026-10-04-rdp-activado-en-sobremesa-por-tailscale-cuenta-sin-password`
- **Equipo**: portatil
- **Categoria**: servicios
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: RDP habilitado: fDenyTSConnections=0, TermService Automatic, firewall 'RDP (Tailscale)' (solo 100.64.0.0/10). Cuenta juanjo SIN password -> LimitBlankPasswordUse=0 para permitir logon remoto. Conexion: mstsc /v:100.75.115.29 (usuario juanjo, password vacia). Win11 Pro = 1 sesion (RDP desconecta la consola/autologon). Seguridad: RDP solo accesible por Tailscale.
### 2026-10-04 · Fix encender_sobremesa.py: cargar .env (Home Assistant)
- **id**: `2026-10-04-fix-encender-sobremesa-py-cargar-env-home-assistant`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: encender_sobremesa.py no cargaba el .env -> ha_alexa sin HOMEASSISTANT_URL/TOKEN, la regleta no respondia. Anadido load_dotenv(biblioteca/.env). Protocolo validado end-to-end: apagado por SSH -> verificado offline -> encendido por ciclo de regleta (Power On Loss).
### 2026-10-03 · SuperWhisper Enter: AHK F13 boton auxiliar Logitech + tarea programada
- **id**: `2026-10-03-superwhisper-enter-ahk-f13-boton-auxiliar-logitech-tarea-pro`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: Script superwhisper_enter.ahk: F13 (boton auxiliar Logitech asignado en Logi Options+) espera cambio portapapeles (Super Whisper pega) y manda Enter + devuelve foco a OpenCode. Tarea programada SuperWhisper Enter al inicio de sesion. Rueda central = toggle Super Whisper (inicio/parar). F13 = Enter automatico.
### 2026-10-03 · Monitor de estado (Telegram + correo + Jira) en segundo plano
- **id**: `2026-10-03-monitor-de-estado-telegram-correo-jira-en-segundo-plano`
- **Equipo**: portatil
- **Categoria**: servicios
- **Transferible**: si
- **Aplicado en**: portatil, sobremesa
- **Detalle**: Proceso Python (scripts/monitor_estado.py) que cada 30 min recoge Telegram (5 fuentes), correo jminguez@prolongo.es y Jira DS (nuevos + sin asignar), archiva por fuente, resume con IA (OmniRoute) y avisa por voz local. Arranque por tarea programada 'Monitor Estado' + plugin global monitor-estado-bootstrap.ts; watchdog idempotente; sesion Telegram dedicada (sessions/monitor, login por QR); tests pytest en tests/. Requiere en el otro equipo: pip install pytest qrcode, login Telegram QR y re-ejecutar instalar_global.ps1.
### 2026-10-03 · Acceso remoto al sobremesa: OpenSSH Server + Tailscale (ssh -> PowerShell)
- **id**: `2026-10-03-acceso-remoto-al-sobremesa-openssh-server-tailscale-ssh-powe`
- **Equipo**: portatil
- **Categoria**: servicios
- **Transferible**: si
- **Aplicado en**: portatil, sobremesa
- **Detalle**: Scripts en scripts/acceso_remoto/: setup_tailscale.ps1 (malla privada) y setup_ssh_server.ps1 (OpenSSH Server, shell PowerShell, firewall solo Tailscale+LAN, instala clave publica). Clave SSH del portatil: ~/.ssh/id_ed25519. Doc: docs/ACCESO_REMOTO_SOBREMESA.md. Permite ssh juanm@sobremesa desde cualquier red sin abrir puertos. Requiere en cada equipo: ejecutar setup_tailscale; en el sobremesa ademas setup_ssh_server (admin).
### 2026-10-03 · VoiceMonkey sustituido por Home Assistant (Alexa)
- **id**: `2026-10-03-voicemonkey-sustituido-por-home-assistant-alexa`
- **Equipo**: portatil
- **Categoria**: servicios
- **Transferible**: si
- **Aplicado en**: portatil, sobremesa
- **Detalle**: Validado con efecto real (anuncio por voz + comando 'apaga/enciende barras'). Nuevo helper biblioteca/scripts/ha_alexa.py (announce/command via HA REST). Migrados: netatmo_tool.notificar_alexa, telegram_read_tracker.notificar_alexa, protocolo_apagado.py y protocolo_apagado_test.py (apagar_luces_alexa). .env: +HOMEASSISTANT_URL/TOKEN (long-lived), -VOICEMONKEY_TOKEN/DEVICE. Obsoletos: alexa_notify.js, configurar_voicemonkey.py. Requiere en el otro equipo: git pull + tener HOMEASSISTANT_TOKEN en .env.
### 2026-10-03 · OmniRoute 3.8.48 -> 3.8.51: fix catalogo live antigravity
- **id**: `2026-10-03-omniroute-3-8-48-3-8-51-fix-catalogo-live-antigravity`
- **Equipo**: portatil
- **Categoria**: omniroute
- **Transferible**: si
- **Aplicado en**: portatil, sobremesa
- **Detalle**: Actualizado omniroute a 3.8.51 (npm i -g omniroute@latest). Ahora valida contra el 'active live catalog' de Antigravity: los modelos deprecados (gemini-3.5-flash-*, gemini-2.5-*, claude-* en antigravity) devuelven HTTP 400 en vez de texto de deprecacion. Funcionan: gemini-3.1-pro-low/high, gemini-3.1-flash-lite, gemini-pro-agent, gpt-oss-120b-medium. Servidor relanzado via omniroute_watchdog.ps1 -Once.
### 2026-10-03 · Fix ACL setup_ssh_server.ps1 (SIDs, no nombres en ingles)
- **id**: `2026-10-03-fix-acl-setup-ssh-server-ps1-sids-no-nombres-en-ingles`
- **Equipo**: sobremesa
- **Categoria**: scripts
- **Transferible**: no
- **Aplicado en**: sobremesa
- **Detalle**: icacls fallaba en Windows es-ES al usar BUILTIN\Administrators; ahora usa *S-1-5-18 y *S-1-5-32-544. Aplicado y verificado en sobremesa (sshd Automatic/Running, key portatil autorizada).
### 2026-10-03 · Perfil juanjo: instaladas dependencias Python (21 paquetes) en C:\Python314
- **id**: `2026-10-03-perfil-juanjo-instaladas-dependencias-python-21-paquetes-en-`
- **Equipo**: sobremesa
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: sobremesa, portatil
- **Detalle**: Tras migrar a la cuenta juanjo, Python solo tenia pip. Instalados: pycryptodome PyP100 caldav python-dotenv edge-tts feedparser flask ftfy google-auth-oauthlib google-api-python-client openpyxl psutil qrcode requests telethon todoist-api-python urllib3 wakeonlan watchdog win10toast + setuptools<81 + pywin32. Necesario para watchers/tablero/Jira.
### 2026-10-03 · Sobremesa juanjo: instaladas 17 apps de usuario que estaban en juanm
- **id**: `2026-10-03-sobremesa-juanjo-instaladas-17-apps-de-usuario-que-estaban-e`
- **Equipo**: sobremesa
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: sobremesa, portatil
- **Detalle**: VS Code, GitHub Desktop, Postman, HeidiSQL, Inno Setup, FFmpeg, Rclone, WinDbg(pend), Obsidian, Ollama, Antigravity IDE, Claude, Discord, Telegram Desktop, Stremio, superwhisper, Greenshot. Via winget (ids del inventario docs/formateo/01-INVENTARIO_PROGRAMAS.md). Requiere winget elevado.
### 2026-10-03 · Migracion cuenta juanjo: rutas juanm -> dinamicas (os.path.expanduser / \C:\Users\juanjo / USERPROFILE)
- **id**: `2026-10-03-migracion-cuenta-juanjo-rutas-juanm-dinamicas-os-path-expand`
- **Equipo**: sobremesa
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: sobremesa, portatil
- **Detalle**: 50 ficheros .py + 8 .ps1 + 2 .ts: rutas C:\Users\juanm -> dinamicas para que funcionen en juanm (portatil) y juanjo (sobremesa). Incluye .env de INFRA scripts, gestion_jira, gestion_jira, contabo key, plugins opencode. Tambien: clave SSH contabo copiada, OmniRoute 3.8.51, UFW 5001 solo IP sobremesa, Google OAuth reautorizado.
### 2026-10-03 · Sobremesa juanjo: SuperWhisper config replicada + Obsidian vault registrado + boton central M575 -> Ctrl+Space
- **id**: `2026-10-03-sobremesa-juanjo-superwhisper-config-replicada-obsidian-vaul`
- **Equipo**: sobremesa
- **Categoria**: scripts
- **Transferible**: no
- **Aplicado en**: sobremesa
- **Detalle**: Perfil nuevo juanjo tras migracion. (1) SuperWhisper modes/default.json=s1-mini+es+prompt, settings.json=31 terminos vocab, preferences launchAtLogin=true (pendiente login PRO + descarga modelo s1-mini). (2) Obsidian: creado %APPDATA%\obsidian\obsidian.json registrando vault biblioteca_negocio_prolongo\vault (open true) -> API REST 27124 OK. (3) LogiOptionsPlus settings.db: slot m575-6b027_c82 (boton central) MB3 -> KEYSTROKE Ctrl+Space (toggle SuperWhisper). appbroker corre elevado y no se puede matar; editar DB con agent parado y relanzar logioptionsplus.exe persiste.
### 2026-10-03 · Fix protocolo_apagado.py: ruta clave VPS dinamica + comando RPi en base64 + skip rapido
- **id**: `2026-10-03-fix-protocolo-apagado-py-ruta-clave-vps-dinamica-comando-rpi`
- **Equipo**: sobremesa
- **Categoria**: scripts
- **Transferible**: no
- **Aplicado en**: sobremesa
- **Detalle**: Colgaba 15s por ruta C:\Users\juanm hardcodeada tras migrar a juanjo; ahora usa ~/Documents/Github/contabo, ConnectTimeout 8s y sigue aunque el VPS no responda.
### 2026-10-03 · Enrutado de voz de avisos: Carolina/Juanjo/Abuelo (VoiceStudio)
- **id**: `2026-10-03-enrutado-de-voz-de-avisos-carolina-juanjo-abuelo-voicestudio`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: Nuevo INFRAESTRUCTURA\scripts\voz_mensaje.py: genera+reproduce avisos con VoiceStudio (Carolina 10466680 por defecto, Juanjo 4f18d2df biblioteca, Abuelo 9f83c527 critico), en castellano. biblioteca\scripts\aviso_sonoro.py reescrito como wrapper. Requiere VoiceStudio local.
### 2026-10-03 · Voces de aviso cambiadas a nativas en castellano (Carolina f40515ca / Abuelo e0ecda0a)
- **id**: `2026-10-03-voces-de-aviso-cambiadas-a-nativas-en-castellano-carolina-f4`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil
- **Detalle**: voz_mensaje.py actualizado: Carolina=f40515ca (ml_spanish_companion) por defecto, Abuelo=e0ecda0a (ml_spanish_explainer) critico, Juanjo=4f18d2df biblioteca. Antes usaba arquetipos ingleses (leian castellano con acento ingles). AGENTS Regla 35 actualizada.
### 2026-10-02 · superguardado.py: protocolo guardado total automatico portatil+sobremesa
- **id**: `2026-10-02-superguardado-py-protocolo-guardado-total-automatico-portati`
- **Equipo**: portatil
- **Categoria**: scripts
- **Transferible**: si
- **Aplicado en**: portatil, sobremesa
- **Detalle**: Script scripts/superguardado.py en biblioteca_negocio_prolongo. 7 pasos: ESTADO_DIARIO + git biblioteca + engram cloud + sync_continuo + obsidian + git INFRAESTRUCTURA + aviso sonoro. Palabra clave: superguardado/guarda/memoria. Descripcion auto si no se indica.
### 2026-09-30 · Fix telegram_watcher (telethon 1.44 + encoding UTF-8) ### 2026-09-30 · Fix telegram_watcher (telethon 1.44 + encoding UTF-8)
- **id**: `2026-09-30-fix-telegram-watcher-telethon-1-44-encoding-utf-8` - **id**: `2026-09-30-fix-telegram-watcher-telethon-1-44-encoding-utf-8`
- **Equipo**: portatil - **Equipo**: portatil
- **Categoria**: watchers - **Categoria**: watchers
- **Transferible**: si - **Transferible**: si
- **Aplicado en**: portatil - **Aplicado en**: portatil, sobremesa
- **Detalle**: MessageRead(outbox=True) rompe en telethon 1.44 (kwarg eliminado) -> usar MessageRead(). Anadido reconfigure UTF-8 en stdout/stderr para evitar UnicodeEncodeError cp1252 al lanzar desde tarea programada sin PYTHONUTF8. - **Detalle**: MessageRead(outbox=True) rompe en telethon 1.44 (kwarg eliminado) -> usar MessageRead(). Anadido reconfigure UTF-8 en stdout/stderr para evitar UnicodeEncodeError cp1252 al lanzar desde tarea programada sin PYTHONUTF8.
=======
- **2026-09-20-omniroute-arranque-fiable-tarea-programada-vigilante** [omniroute] OmniRoute arranque fiable (tarea programada + vigilante) (aplicado en: sobremesa)
- **2026-09-20-omniroute-505-modelos-expuestos-en-opencode** [omniroute] OmniRoute: 505 modelos expuestos en OpenCode (aplicado en: sobremesa)
- **2026-09-20-plugin-global-opencode-relanza-omniroute** [opencode] Plugin global OpenCode relanza OmniRoute (aplicado en: sobremesa)
- **2026-09-20-migracion-de-watchers-telegram-recordatorios-a-infraestructu** [watchers] Migracion de watchers (Telegram + recordatorios) a INFRAESTRUCTURA (aplicado en: sobremesa)
- **2026-09-20-primera-version-de-morning-routine-wol-tapo-autologon-y-arra** [scripts] Primera version de Morning Routine (WoL, Tapo, AutoLogon y Arranque Apps) (aplicado en: sobremesa)
- **2026-09-20-fix-bot-telegram-control-pc-confirmacion-watchdog** [scripts] Fix bot Telegram control PC: confirmacion + watchdog (aplicado en: sobremesa)
- **2026-09-20-shutdown-listener-instalado-como-tarea-programada** [watchers] Shutdown listener instalado como tarea programada (aplicado en: sobremesa)
- **2026-09-20-timeout-home-assistant-aumentado-5s-15s-para-proxy-shutdown** [servicios] Timeout Home Assistant aumentado 5s→15s para proxy shutdown (aplicado en: sobremesa)
- **2026-09-29-migraci-n-de-watchers-al-vps** [servicios] Migración de watchers al VPS (aplicado en: sobremesa)
## Historial
### 2026-09-29 · Migración de watchers al VPS ### 2026-09-29 · Migración de watchers al VPS
- **id**: `2026-09-29-migraci-n-de-watchers-al-vps` - **id**: `2026-09-29-migraci-n-de-watchers-al-vps`
- **Equipo**: sobremesa - **Equipo**: sobremesa
- **Categoria**: servicios - **Categoria**: servicios
- **Transferible**: si - **Transferible**: si
- **Aplicado en**: sobremesa - **Aplicado en**: sobremesa, portatil
- **Detalle**: Telegram Watcher y Shutdown Listener migrados al VPS. Recordatorios Msgbox eliminado. Ver INFRAESTRUCTURA/vps/MIGRACION_SERVICIOS_VPS.md para instrucciones. - **Detalle**: Telegram Watcher y Shutdown Listener migrados al VPS. Recordatorios Msgbox eliminado. Ver INFRAESTRUCTURA/vps/MIGRACION_SERVICIOS_VPS.md para instrucciones.
>>>>>>> bb50d468beeef53c679c3f01bb99fddd14f2afa3
### 2026-09-20 · OmniRoute arranque fiable (tarea programada + vigilante) ### 2026-09-20 · OmniRoute arranque fiable (tarea programada + vigilante)
- **id**: `2026-09-20-omniroute-arranque-fiable-tarea-programada-vigilante` - **id**: `2026-09-20-omniroute-arranque-fiable-tarea-programada-vigilante`

View File

@@ -185,7 +185,8 @@
"titulo": "Migración de watchers al VPS", "titulo": "Migración de watchers al VPS",
"transferible": true, "transferible": true,
"aplicado_en": [ "aplicado_en": [
"sobremesa" "sobremesa",
"portatil"
], ],
"detalle": "Telegram Watcher y Shutdown Listener migrados al VPS. Recordatorios Msgbox eliminado. Ver INFRAESTRUCTURA/vps/MIGRACION_SERVICIOS_VPS.md para instrucciones." "detalle": "Telegram Watcher y Shutdown Listener migrados al VPS. Recordatorios Msgbox eliminado. Ver INFRAESTRUCTURA/vps/MIGRACION_SERVICIOS_VPS.md para instrucciones."
}, },
@@ -197,9 +198,282 @@
"titulo": "Fix telegram_watcher (telethon 1.44 + encoding UTF-8)", "titulo": "Fix telegram_watcher (telethon 1.44 + encoding UTF-8)",
"transferible": true, "transferible": true,
"aplicado_en": [ "aplicado_en": [
"portatil" "portatil",
"sobremesa"
], ],
"detalle": "MessageRead(outbox=True) rompe en telethon 1.44 (kwarg eliminado) -> usar MessageRead(). Anadido reconfigure UTF-8 en stdout/stderr para evitar UnicodeEncodeError cp1252 al lanzar desde tarea programada sin PYTHONUTF8." "detalle": "MessageRead(outbox=True) rompe en telethon 1.44 (kwarg eliminado) -> usar MessageRead(). Anadido reconfigure UTF-8 en stdout/stderr para evitar UnicodeEncodeError cp1252 al lanzar desde tarea programada sin PYTHONUTF8."
},
{
"id": "2026-10-02-superguardado-py-protocolo-guardado-total-automatico-portati",
"fecha": "2026-10-02",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "superguardado.py: protocolo guardado total automatico portatil+sobremesa",
"transferible": true,
"aplicado_en": [
"portatil",
"sobremesa"
],
"detalle": "Script scripts/superguardado.py en biblioteca_negocio_prolongo. 7 pasos: ESTADO_DIARIO + git biblioteca + engram cloud + sync_continuo + obsidian + git INFRAESTRUCTURA + aviso sonoro. Palabra clave: superguardado/guarda/memoria. Descripcion auto si no se indica."
},
{
"id": "2026-10-03-superwhisper-enter-ahk-f13-boton-auxiliar-logitech-tarea-pro",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "SuperWhisper Enter: AHK F13 boton auxiliar Logitech + tarea programada",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "Script superwhisper_enter.ahk: F13 (boton auxiliar Logitech asignado en Logi Options+) espera cambio portapapeles (Super Whisper pega) y manda Enter + devuelve foco a OpenCode. Tarea programada SuperWhisper Enter al inicio de sesion. Rueda central = toggle Super Whisper (inicio/parar). F13 = Enter automatico."
},
{
"id": "2026-10-03-monitor-de-estado-telegram-correo-jira-en-segundo-plano",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "servicios",
"titulo": "Monitor de estado (Telegram + correo + Jira) en segundo plano",
"transferible": true,
"aplicado_en": [
"portatil",
"sobremesa"
],
"detalle": "Proceso Python (scripts/monitor_estado.py) que cada 30 min recoge Telegram (5 fuentes), correo jminguez@prolongo.es y Jira DS (nuevos + sin asignar), archiva por fuente, resume con IA (OmniRoute) y avisa por voz local. Arranque por tarea programada 'Monitor Estado' + plugin global monitor-estado-bootstrap.ts; watchdog idempotente; sesion Telegram dedicada (sessions/monitor, login por QR); tests pytest en tests/. Requiere en el otro equipo: pip install pytest qrcode, login Telegram QR y re-ejecutar instalar_global.ps1."
},
{
"id": "2026-10-03-acceso-remoto-al-sobremesa-openssh-server-tailscale-ssh-powe",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "servicios",
"titulo": "Acceso remoto al sobremesa: OpenSSH Server + Tailscale (ssh -> PowerShell)",
"transferible": true,
"aplicado_en": [
"portatil",
"sobremesa"
],
"detalle": "Scripts en scripts/acceso_remoto/: setup_tailscale.ps1 (malla privada) y setup_ssh_server.ps1 (OpenSSH Server, shell PowerShell, firewall solo Tailscale+LAN, instala clave publica). Clave SSH del portatil: ~/.ssh/id_ed25519. Doc: docs/ACCESO_REMOTO_SOBREMESA.md. Permite ssh juanm@sobremesa desde cualquier red sin abrir puertos. Requiere en cada equipo: ejecutar setup_tailscale; en el sobremesa ademas setup_ssh_server (admin)."
},
{
"id": "2026-10-03-voicemonkey-sustituido-por-home-assistant-alexa",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "servicios",
"titulo": "VoiceMonkey sustituido por Home Assistant (Alexa)",
"transferible": true,
"aplicado_en": [
"portatil",
"sobremesa"
],
"detalle": "Validado con efecto real (anuncio por voz + comando 'apaga/enciende barras'). Nuevo helper biblioteca/scripts/ha_alexa.py (announce/command via HA REST). Migrados: netatmo_tool.notificar_alexa, telegram_read_tracker.notificar_alexa, protocolo_apagado.py y protocolo_apagado_test.py (apagar_luces_alexa). .env: +HOMEASSISTANT_URL/TOKEN (long-lived), -VOICEMONKEY_TOKEN/DEVICE. Obsoletos: alexa_notify.js, configurar_voicemonkey.py. Requiere en el otro equipo: git pull + tener HOMEASSISTANT_TOKEN en .env."
},
{
"id": "2026-10-03-omniroute-3-8-48-3-8-51-fix-catalogo-live-antigravity",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "omniroute",
"titulo": "OmniRoute 3.8.48 -> 3.8.51: fix catalogo live antigravity",
"transferible": true,
"aplicado_en": [
"portatil",
"sobremesa"
],
"detalle": "Actualizado omniroute a 3.8.51 (npm i -g omniroute@latest). Ahora valida contra el 'active live catalog' de Antigravity: los modelos deprecados (gemini-3.5-flash-*, gemini-2.5-*, claude-* en antigravity) devuelven HTTP 400 en vez de texto de deprecacion. Funcionan: gemini-3.1-pro-low/high, gemini-3.1-flash-lite, gemini-pro-agent, gpt-oss-120b-medium. Servidor relanzado via omniroute_watchdog.ps1 -Once."
},
{
"id": "2026-10-03-fix-acl-setup-ssh-server-ps1-sids-no-nombres-en-ingles",
"fecha": "2026-10-03",
"equipo": "sobremesa",
"categoria": "scripts",
"titulo": "Fix ACL setup_ssh_server.ps1 (SIDs, no nombres en ingles)",
"transferible": false,
"aplicado_en": [
"sobremesa"
],
"detalle": "icacls fallaba en Windows es-ES al usar BUILTIN\\Administrators; ahora usa *S-1-5-18 y *S-1-5-32-544. Aplicado y verificado en sobremesa (sshd Automatic/Running, key portatil autorizada)."
},
{
"id": "2026-10-03-perfil-juanjo-instaladas-dependencias-python-21-paquetes-en-",
"fecha": "2026-10-03",
"equipo": "sobremesa",
"categoria": "scripts",
"titulo": "Perfil juanjo: instaladas dependencias Python (21 paquetes) en C:\\Python314",
"transferible": true,
"aplicado_en": [
"sobremesa",
"portatil"
],
"detalle": "Tras migrar a la cuenta juanjo, Python solo tenia pip. Instalados: pycryptodome PyP100 caldav python-dotenv edge-tts feedparser flask ftfy google-auth-oauthlib google-api-python-client openpyxl psutil qrcode requests telethon todoist-api-python urllib3 wakeonlan watchdog win10toast + setuptools<81 + pywin32. Necesario para watchers/tablero/Jira."
},
{
"id": "2026-10-03-sobremesa-juanjo-instaladas-17-apps-de-usuario-que-estaban-e",
"fecha": "2026-10-03",
"equipo": "sobremesa",
"categoria": "scripts",
"titulo": "Sobremesa juanjo: instaladas 17 apps de usuario que estaban en juanm",
"transferible": true,
"aplicado_en": [
"sobremesa",
"portatil"
],
"detalle": "VS Code, GitHub Desktop, Postman, HeidiSQL, Inno Setup, FFmpeg, Rclone, WinDbg(pend), Obsidian, Ollama, Antigravity IDE, Claude, Discord, Telegram Desktop, Stremio, superwhisper, Greenshot. Via winget (ids del inventario docs/formateo/01-INVENTARIO_PROGRAMAS.md). Requiere winget elevado."
},
{
"id": "2026-10-03-migracion-cuenta-juanjo-rutas-juanm-dinamicas-os-path-expand",
"fecha": "2026-10-03",
"equipo": "sobremesa",
"categoria": "scripts",
"titulo": "Migracion cuenta juanjo: rutas juanm -> dinamicas (os.path.expanduser / \\C:\\Users\\juanjo / USERPROFILE)",
"transferible": true,
"aplicado_en": [
"sobremesa",
"portatil"
],
"detalle": "50 ficheros .py + 8 .ps1 + 2 .ts: rutas C:\\Users\\juanm -> dinamicas para que funcionen en juanm (portatil) y juanjo (sobremesa). Incluye .env de INFRA scripts, gestion_jira, gestion_jira, contabo key, plugins opencode. Tambien: clave SSH contabo copiada, OmniRoute 3.8.51, UFW 5001 solo IP sobremesa, Google OAuth reautorizado."
},
{
"id": "2026-10-03-sobremesa-juanjo-superwhisper-config-replicada-obsidian-vaul",
"fecha": "2026-10-03",
"equipo": "sobremesa",
"categoria": "scripts",
"titulo": "Sobremesa juanjo: SuperWhisper config replicada + Obsidian vault registrado + boton central M575 -> Ctrl+Space",
"transferible": false,
"aplicado_en": [
"sobremesa"
],
"detalle": "Perfil nuevo juanjo tras migracion. (1) SuperWhisper modes/default.json=s1-mini+es+prompt, settings.json=31 terminos vocab, preferences launchAtLogin=true (pendiente login PRO + descarga modelo s1-mini). (2) Obsidian: creado %APPDATA%\\obsidian\\obsidian.json registrando vault biblioteca_negocio_prolongo\\vault (open true) -> API REST 27124 OK. (3) LogiOptionsPlus settings.db: slot m575-6b027_c82 (boton central) MB3 -> KEYSTROKE Ctrl+Space (toggle SuperWhisper). appbroker corre elevado y no se puede matar; editar DB con agent parado y relanzar logioptionsplus.exe persiste."
},
{
"id": "2026-10-03-fix-protocolo-apagado-py-ruta-clave-vps-dinamica-comando-rpi",
"fecha": "2026-10-03",
"equipo": "sobremesa",
"categoria": "scripts",
"titulo": "Fix protocolo_apagado.py: ruta clave VPS dinamica + comando RPi en base64 + skip rapido",
"transferible": false,
"aplicado_en": [
"sobremesa"
],
"detalle": "Colgaba 15s por ruta C:\\Users\\juanm hardcodeada tras migrar a juanjo; ahora usa ~/Documents/Github/contabo, ConnectTimeout 8s y sigue aunque el VPS no responda."
},
{
"id": "2026-10-03-enrutado-de-voz-de-avisos-carolina-juanjo-abuelo-voicestudio",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "Enrutado de voz de avisos: Carolina/Juanjo/Abuelo (VoiceStudio)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "Nuevo INFRAESTRUCTURA\\scripts\\voz_mensaje.py: genera+reproduce avisos con VoiceStudio (Carolina 10466680 por defecto, Juanjo 4f18d2df biblioteca, Abuelo 9f83c527 critico), en castellano. biblioteca\\scripts\\aviso_sonoro.py reescrito como wrapper. Requiere VoiceStudio local."
},
{
"id": "2026-10-03-voces-de-aviso-cambiadas-a-nativas-en-castellano-carolina-f4",
"fecha": "2026-10-03",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "Voces de aviso cambiadas a nativas en castellano (Carolina f40515ca / Abuelo e0ecda0a)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "voz_mensaje.py actualizado: Carolina=f40515ca (ml_spanish_companion) por defecto, Abuelo=e0ecda0a (ml_spanish_explainer) critico, Juanjo=4f18d2df biblioteca. Antes usaba arquetipos ingleses (leian castellano con acento ingles). AGENTS Regla 35 actualizada."
},
{
"id": "2026-10-04-protocolo-de-encendido-remoto-del-sobremesa-powershell-por-s",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "servicios",
"titulo": "Protocolo de encendido remoto del sobremesa + PowerShell por SSH",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "docs/PROTOCOLO_ENCENDIDO_REMOTO_SSH.md + scripts/encender_sobremesa.py (check Tailscale -> enciende regleta via Alexa/HA -> espera 3min -> check -> WoL best-effort -> espera 3min -> informe con voz Carolina) + scripts/ssh_sobremesa.ps1 (abre PowerShell en juanjo@100.75.115.29). Validado: regleta encendio el PC y SSH funciona. Red doble NAT Zyxel(.1)->Asus(.50); se llega por Tailscale."
},
{
"id": "2026-10-04-instalador-de-dependencias-python-plugin-omniroute-autostart",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "Instalador de dependencias Python + plugin omniroute-autostart + comparativa equipos",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "scripts/instalar_dependencias.ps1 (union de paquetes pip), opencode/omniroute-autostart.ts anadido al repo, instalar_global.ps1 ahora copia tambien ese plugin, docs/COMPARATIVA_EQUIPOS.md. Objetivo: igualar componentes comunes entre portatil y sobremesa."
},
{
"id": "2026-10-04-fix-tareas-programadas-userdomain-computername-workgroup-ssh",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "Fix tareas programadas: USERDOMAIN->COMPUTERNAME (workgroup/SSH)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "Register-ScheduledTask fallaba con 'No mapping account SIDs' porque en cuentas locales USERDOMAIN=WORKGROUP. Corregido en instalar_global.ps1, instalar_watchers.ps1, install_omniroute_autostart.ps1, bootstrap_autologon_juanjo.ps1. Validado re-ejecutando instalar_global.ps1 sobre el sobremesa (por SSH): tareas OmniRoute Watchdog, Monitor Estado, Telegram Watcher, Recordatorios Msgbox, Shutdown Listener registradas OK."
},
{
"id": "2026-10-04-sobremesa-instalados-voicestudio-autohotkey-v2-tarea-superwh",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "Sobremesa: instalados VoiceStudio + AutoHotkey v2 + tarea SuperWhisper Enter",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "Via SSH desde el portatil. VoiceStudio 0.5.6 (per-user en %LOCALAPPDATA%\\Programs\\VoiceStudio, instalador GitHub debpalash/VoiceStudio /S). AutoHotkey v2 (winget AutoHotkey.AutoHotkey). Tarea programada 'SuperWhisper Enter' (AtLogOn, hidden) -> AutoHotkey64.exe + scripts/superwhisper_enter.ahk. Nota: el sobremesa tiene RTX 4070 (12GB) -> VoiceStudio usara GPU; requiere abrir la app para levantar backend 3900 y crear perfiles de voz."
},
{
"id": "2026-10-04-protocolo-encendido-sobremesa-validado-wol-falla-ciclo-regle",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "servicios",
"titulo": "Protocolo encendido sobremesa validado: WoL falla, ciclo regleta funciona (Power On Loss)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "Prueba 04-oct: apagado simple OK; WoL desde portatil NO responde (doble NAT .50->.1); ciclo regleta (apagar 1min + encender) arranca el PC solo (BIOS Restore on AC Power Loss = Power On). encender_sobremesa.py actualizado con la escalera WoL->regleta->ciclo-regleta. Doc PROTOCOLO_ENCENDIDO_REMOTO_SSH.md actualizado."
},
{
"id": "2026-10-04-rdp-activado-en-sobremesa-por-tailscale-cuenta-sin-password",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "servicios",
"titulo": "RDP activado en sobremesa por Tailscale (cuenta sin password)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "RDP habilitado: fDenyTSConnections=0, TermService Automatic, firewall 'RDP (Tailscale)' (solo 100.64.0.0/10). Cuenta juanjo SIN password -> LimitBlankPasswordUse=0 para permitir logon remoto. Conexion: mstsc /v:100.75.115.29 (usuario juanjo, password vacia). Win11 Pro = 1 sesion (RDP desconecta la consola/autologon). Seguridad: RDP solo accesible por Tailscale."
},
{
"id": "2026-10-04-fix-encender-sobremesa-py-cargar-env-home-assistant",
"fecha": "2026-10-04",
"equipo": "portatil",
"categoria": "scripts",
"titulo": "Fix encender_sobremesa.py: cargar .env (Home Assistant)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "encender_sobremesa.py no cargaba el .env -> ha_alexa sin HOMEASSISTANT_URL/TOKEN, la regleta no respondia. Anadido load_dotenv(biblioteca/.env). Protocolo validado end-to-end: apagado por SSH -> verificado offline -> encendido por ciclo de regleta (Power On Loss)."
},
{
"id": "2026-10-06-re-auth-provider-claude-omniroute-helper-oauth-vigilante-por",
"fecha": "2026-10-06",
"equipo": "portatil",
"categoria": "omniroute",
"titulo": "Re-auth provider Claude OmniRoute (helper OAuth + vigilante portapapeles)",
"transferible": true,
"aplicado_en": [
"portatil"
],
"detalle": "Scripts reutilizables en INFRAESTRUCTURA/omniroute: oauth_helper.py (mantiene vivo el proceso OAuth y le inyecta code#state por stdin, sin timeout) y clipboard_watch.ps1 (lee el codigo del portapapeles en modo -STA). Uso: lanzar helper, abrir URL con --no-browser, autorizar, copiar codigo -> auto-inyectado. Necesario cuando caduca el token OAuth del provider claude."
} }
] ]
} }

View File

@@ -0,0 +1,84 @@
# Acceso remoto al sobremesa (SSH → PowerShell) + Tailscale
Fecha: 03-oct-2026. Complementa `SISTEMA_APAGADO_ENCENDIDO_REMOTO.md` (ese cubre *encender/apagar*;
esto cubre *entrar una vez encendido*).
## Objetivo
Poder trabajar en el sobremesa desde el portátil (o el iPad) **esté donde esté el PC**, con acceso
directo a su **PowerShell** por SSH, sin abrir puertos en el router.
## Arquitectura
```
Portátil / iPad ──(Tailscale, red privada cifrada)──> SOBREMESA
└─ sshd (OpenSSH) → PowerShell
```
- **Tailscale** pone ambos equipos en una malla privada (rango `100.64.0.0/10`), con nombres estables.
- **OpenSSH Server** en el sobremesa expone `sshd` (puerto 22), shell por defecto = PowerShell.
- El firewall del sobremesa solo acepta SSH desde Tailscale y LAN.
> **ESTADO ACTUAL (03-oct-2026)** — en el sobremesa ya está TODO hecho:
> `sshd`+`ssh-agent` Automatic/Running, shell = PowerShell, firewall :22 solo Tailscale+LAN,
> Tailscale up (**100.75.115.29**, hostname real `desktop-m8se61g`), clave del portátil en
> `administrators_authorized_keys`, Fast Startup OFF, BIOS Restore on AC = Power On.
> **Usuario SSH = `juanjo`** (cuenta local admin). **Único pendiente: unir el portátil a Tailscale.**
El encendido es por la **regleta inteligente** (comando Alexa `regletta`): se apaga el PC en limpio,
la regleta corta la corriente, y al restaurarla la BIOS ("Restore on AC Power Loss = Power On") lo
enciente y hace **autologon** en `juanjo`. No se usa Wake-on-LAN (descartado).
`sshd` arranca como **servicio** con Windows, así que en cuanto el PC tiene corriente, SSH ya está
disponible (no depende del autologon).
## Puesta en marcha (una vez por equipo)
### 1) Portátil (cliente)
- Instalar Tailscale y unirse: `scripts\acceso_remoto\setup_tailscale.ps1 -Hostname portatil`
(abre el navegador para autenticar; requiere admin para instalar).
- Clave SSH (ya creada en este portátil): `~/.ssh/id_ed25519`.
Pública:
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK7tk2+Vznv3zs+vjks64fwV+mznOQ7D+7Ct+vIBFJcz juanm@portatil
```
### 2) Sobremesa (servidor) — ejecutar **como administrador**
```powershell
# 1. Tailscale
powershell -ExecutionPolicy Bypass -File scripts\acceso_remoto\setup_tailscale.ps1 -Hostname sobremesa
# 2. OpenSSH Server + clave del portátil (auto-eleva)
powershell -ExecutionPolicy Bypass -File scripts\acceso_remoto\setup_ssh_server.ps1 `
-PublicKey "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK7tk2+Vznv3zs+vjks64fwV+mznOQ7D+7Ct+vIBFJcz juanm@portatil"
```
### 3) Probar desde el portátil
```powershell
ssh juanjo@desktop-m8se61g # por nombre Tailscale (MagicDNS)
ssh juanjo@100.75.115.29 # o por IP del sobremesa en la tailnet
```
> El usuario del sobremesa es `juanjo` (no `juanm`). El hostname en la tailnet es
> `desktop-m8se61g` (nombre Windows), no `sobremesa`; confirmar siempre con `tailscale status`.
## Uso diario
- `ssh juanjo@desktop-m8se61g` → consola PowerShell remota.
- Comandos sueltos: `ssh juanjo@desktop-m8se61g "Get-Process | Sort CPU -Desc | Select -First 5"`.
- Copiar ficheros: `scp fichero juanjo@desktop-m8se61g:C:/Users/juanjo/`.
- Desde el iPad: app **Tailscale** + cliente SSH (p. ej. Termius/Blink) con la misma clave.
## Seguridad
- Contraseñas SSH deshabilitadas → **solo clave pública** (recomendado: `PasswordAuthentication no`).
- Firewall SSH restringido a `100.64.0.0/10` (Tailscale) y LAN; sin exposición a Internet.
- La tailnet se puede restringir por ACLs en el panel de Tailscale.
- La clave `id_ed25519` del portátil está **sin passphrase** por comodidad (los equipos ya tienen
autologon). Si se quiere endurecer: `ssh-keygen -p -f ~/.ssh/id_ed25519`.
## Troubleshooting
- `ssh` no conecta: en el sobremesa `Get-Service sshd` (debe estar *Running*) y `Get-NetFirewallRule -DisplayName 'OpenSSH Server (sshd) - acceso remoto'`.
- No resuelve el nombre Tailscale: `tailscale status` en ambos; usar la IP `100.x.y.z`.
- ¿Autenticación rechazada? Revisar que la clave pública está en
`C:\ProgramData\ssh\administrators_authorized_keys` (si el usuario es admin) con ACLs correctas
(`SYSTEM` y `Administrators` a Full).
- Ver logs: `Get-Service sshd | Get-Content` o `C:\ProgramData\ssh\logs\sshd.log` (activando `LogLevel DEBUG3` si hace falta).
## Relación con el arranque remoto
1. Comando Alexa `regletta` → la regleta corta/restaura corriente → BIOS (Restore on AC) arranca el sobremesa.
2. `sshd` y Tailscale arrancan solos con Windows; el autologon entra en `juanjo`.
3. Portátil: `ssh juanjo@desktop-m8se61g` → PowerShell remoto.

View File

@@ -0,0 +1,69 @@
# Comparativa portátil ↔ sobremesa (INFRAESTRUCTURA)
Fecha: 04-oct-2026. Generado comparando ambos equipos en vivo (scheduled tasks, plugins, pip, winget, puertos).
## Leyenda
- **SOBREMESA falta** = está en el portátil y **no** en el sobremesa.
- **PORTÁTIL falta** = está en el sobremesa y **no** en el portátil.
## Apps (winget)
| App | Portátil | Sobremesa |
|-----|:--:|:--:|
| VoiceStudio 0.5.6 | ✅ | ❌ → **falta en sobremesa** (port 3900 off) |
| AutoHotkey v2 | ✅ | ❌ → **falta en sobremesa** |
| Tailscale | ✅ | ✅ |
| Obsidian | ✅ | ✅ |
| Ollama | ✅ | ✅ |
| superwhisper | ✅ | ✅ |
| Logi Options+/G HUB | ✅ | ✅ |
| Node | 24.19 | 24.21 |
| Python | 3.13.11 | **3.14.7** (distinta versión) |
## Tareas programadas
| Tarea | Portátil | Sobremesa |
|-------|:--:|:--:|
| Monitor Estado | ✅ | ✅ |
| OmniRoute Watchdog | ✅ | ✅ |
| Recordatorios Msgbox | ✅ | ✅ |
| Shutdown Listener | ✅ | ✅ |
| Telegram Watcher | ✅ | ✅ |
| **SuperWhisper Enter** | ✅ | ❌ → **falta en sobremesa** |
| **Recordatorio_12PM** | ✅ | ❌ → **falta en sobremesa** |
| **Recordatorio_13PM** | ✅ | ❌ → **falta en sobremesa** |
| MorningRoutine Prolongo | ❌ | ✅ (solo sobremesa) |
| OmniRoute / OmnirouteFixCheck | ❌ | ✅ (solo sobremesa) |
## Plugins globales OpenCode (`~/.config/opencode/plugins`)
| Plugin | Portátil | Sobremesa |
|--------|:--:|:--:|
| engram.ts | ✅ | ✅ |
| monitor-estado-bootstrap.ts | ✅ | ✅ |
| omniroute-bootstrap.ts | ✅ | ✅ |
| **omniroute-autostart.ts** | ✅ | ❌ → **falta en sobremesa** |
## Dependencias Python (pip)
| Paquete | Portátil | Sobremesa |
|---------|:--:|:--:|
| Pillow | ✅ | ❌ → **falta en sobremesa** |
| caldav | ❌ | ✅ → **falta en portátil** |
| edge-tts | ❌ | ✅ → **falta en portátil** |
| wakeonlan | ❌ | ✅ → **falta en portátil** |
## Servicios/carpetas
| Elemento | Portátil | Sobremesa |
|----------|:--:|:--:|
| OmniRoute 20128 | ✅ | ✅ |
| VoiceStudio 3900 | ✅ | ❌ |
| `Documents\voz_mensajes` | ✅ | ❌ (lo crea VoiceStudio) |
---
## Cómo instalar lo que falta en el SOBREMESA (fácil)
1. **Dependencias Python** (incluye Pillow) — `scripts\instalar_dependencias.ps1` (ver abajo).
2. **VoiceStudio** (puerto 3900 + voz Carolina/Abuelo): pendiente #196 → `docs/INFRA_PENDIENTE_SOBREMESA.md`.
3. **AutoHotkey v2** + `superwhisper_enter.ahk` + tarea **SuperWhisper Enter**: pendiente #201.
4. **Plugin `omniroute-autostart.ts`**: copiar de `~/.config/opencode/plugins` del portátil (o desde `INFRAESTRUCTURA/opencode/` si está versionado).
5. **Tareas Recordatorio_12PM/13PM**: exportar/importar XML de tareas programadas.
## Cómo instalar lo que falta en el PORTÁTIL
- `pip install caldav edge-tts wakeonlan` (una línea).

View File

@@ -0,0 +1,55 @@
# Protocolo — Encendido remoto del sobremesa + PowerShell por SSH
Fecha: 03-oct-2026. Objetivo: **encender el sobremesa estando apagado y conectarse a su PowerShell** desde el portátil (o iPad).
## Datos
| Elemento | Valor |
|----------|-------|
| Nombre Tailscale | `desktop-m8se61g` |
| IP Tailscale | `100.75.115.29` |
| IP LAN | `192.168.1.239` (Realtek 2.5GbE, MAC `74:56:3C:CB:0E:CE`) |
| Usuario SSH | `juanjo` (administrador, cuenta local) |
| Regleta (Tapo) | `192.168.50.224` · comando Alexa: **"enciende regleta"** |
| Red | Doble NAT: Zyxel (`.1`) → Asus (`.50`). El portátil está en `.50` y llega al sobremesa por **Tailscale** |
## Protocolo (paso a paso)
**0. Estado de la regleta** (opcional, si hay acceso al Tapo): `device_on` vía PyP100 (`getDeviceInfo`).
**1. Comprobar si el PC está apagado**
```powershell
& "$env:ProgramFiles\Tailscale\tailscale.exe" status | findstr desktop-m8se61g
```
`offline` = apagado. (Desde la red `.1` también vale `ping 192.168.1.239`.)
**2. Escalera de intentos** (en orden; comprobar el estado tras cada uno):
1. **Encender la regleta** (comando Alexa vía Home Assistant):
```powershell
python C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\scripts\ha_alexa.py command "enciende regleta"
```
2. **Ciclo de regleta** (el **fiable**): apagar la regleta, esperar **1 min**, y volver a encenderla. Con `Restore on AC Power Loss = Power On` en BIOS, el PC **arranca solo**.
> **Wake-on-LAN: OMITIDO por defecto** (no cruza el doble NAT `.50`→`.1`). Se puede probar con `--wol`.
**3. Informar** (aviso voz Carolina) tras cada comprobación.
> **Resultado de la prueba (04-oct-2026):** apagado simple OK; **WoL NO funcionó**; **ciclo de regleta SÍ** (arrancó solo, confirma `Power On Loss`). Es la vía buena para el sobremesa.
**7. Conectar a PowerShell:**
```powershell
ssh juanjo@100.75.115.29
```
(o `scripts\ssh_sobremesa.ps1`).
## Scripts
- `scripts\encender_sobremesa.py` — automatiza el protocolo (check → regleta → 3 min → check → WoL → 3 min → informe + aviso de voz Carolina).
- `scripts\ssh_sobremesa.ps1` — abre una sesión **PowerShell** interactiva en el sobremesa por SSH.
- `scripts\ha_alexa.py` (repo de negocio) — `announce` / `command` a Alexa vía Home Assistant.
## Notas
- **Fast Startup** activado → WoL desde apagado poco fiable.
- `sshd` y Tailscale arrancan como **servicios** (no requieren que haya usuario logueado).
- Los avisos de fin de comprobación se hacen con la **voz Carolina** (`voz_mensaje.py --tipo default`).
- Si el SSH al VPS (`185.187.169.109`) deja de responder desde un equipo, puede ser **fail2ban** por reintentos; esperar o desbloquear la IP en el VPS.

View File

@@ -1,5 +1,9 @@
# Configuración VoiceMonkey para luces Alexa # Configuración VoiceMonkey para luces Alexa
> **OBSOLETO desde 03-oct-2026.** Sustituido por **Home Assistant + Alexa Media Player**
> (gratis e ilimitado). Ver `docs/home-assistant-setup-alexa.md` y `scripts/ha_alexa.py`.
> Este documento se conserva solo como referencia histórica.
## Resumen ## Resumen
VoiceMonkey permite disparar rutinas de Alexa desde scripts Python mediante HTTP triggers. VoiceMonkey permite disparar rutinas de Alexa desde scripts Python mediante HTTP triggers.

View File

@@ -4,6 +4,25 @@
Home Assistant instalado en el VPS Contabo para controlar luces de Alexa ("barras" y "cuartillo") de forma gratuita e ilimitada, reemplazando VoiceMonkey. Home Assistant instalado en el VPS Contabo para controlar luces de Alexa ("barras" y "cuartillo") de forma gratuita e ilimitada, reemplazando VoiceMonkey.
## Estado de la migración (03-oct-2026) — COMPLETADA
**Validado** (con efecto real): anuncio por voz (`notify.alexa_media_echo_dot_de_nahid`) y comando a Alexa ("apaga/enciende barras" vía `media_player.play_media` custom).
**Helper común:** `biblioteca_negocio_prolongo/scripts/ha_alexa.py`
- `announce(mensaje)` — anuncio TTS a un Echo.
- `command(texto)` — envía un comando de voz a Alexa (dispara rutinas/dispositivos).
**Sustituido VoiceMonkey por HA en:**
- `scripts/netatmo_tool.py::notificar_alexa`
- `scripts/telegram_read_tracker.py::notificar_alexa`
- `scripts/protocolo_apagado.py::apagar_luces_alexa` (y `protocolo_apagado_test.py`)
**Credenciales:** `HOMEASSISTANT_URL` + `HOMEASSISTANT_TOKEN` en `.env` (token de larga duración).
Se han eliminado `VOICEMONKEY_TOKEN`/`VOICEMONKEY_DEVICE` del `.env`.
Ficheros VoiceMonkey ahora obsoletos: `scripts/alexa_notify.js`, `scripts/configurar_voicemonkey.py`.
Nota: Alexa Media Player pide re-login en Amazon cada ~2 semanas.
--- ---
## 1. Instalación (YA HECHA) ## 1. Instalación (YA HECHA)

View File

@@ -101,6 +101,27 @@
"fecha": "diario", "fecha": "diario",
"hora": "09:00", "hora": "09:00",
"done": true "done": true
},
{
"id": 15,
"texto": "Revisar tareas de Fernandez esta semana: albaranes NC, facturacion de Abueno, jamones a China",
"fecha": "2026-10-07",
"hora": "09:00",
"done": true
},
{
"id": 16,
"texto": "Facturacion electronica B2B (Orden HAC/1028/2026): preparar analisis de impacto para KPMG",
"fecha": "2026-10-09",
"hora": "09:00",
"done": false
},
{
"id": 17,
"texto": "Revisar pendientes Jira (IAD-251/252/256/257, IFE-41, DS-3566) y decidir que hacemos",
"fecha": "2026-10-06",
"hora": "09:00",
"done": true
} }
] ]
} }

View File

@@ -0,0 +1,24 @@
Add-Type -AssemblyName System.Windows.Forms
$codeFile = "C:\Users\juanm\Documents\Github\INFRAESTRUCTURA\omniroute\oauth_code.txt"
$exitFile = "C:\Users\juanm\Documents\Github\INFRAESTRUCTURA\omniroute\oauth_helper.exit"
$last = ""
try { $last = [System.Windows.Forms.Clipboard]::GetText() } catch {}
$deadline = (Get-Date).AddMinutes(20)
while ((Get-Date) -lt $deadline) {
if (Test-Path $exitFile) { break }
$c = ""
try { $c = [System.Windows.Forms.Clipboard]::GetText() } catch {}
if ($c -and $c -ne $last -and $c.Length -ge 20 -and ($c -match "#" -or $c -match "code=")) {
# Limpiar espacios/saltos por si copia con formato
$clean = $c.Trim()
if ($clean -match "https?://") {
$m = [regex]::Match($clean, "code=([^&#\s]+)")
$s = [regex]::Match($clean, "state=([^&#\s]+)")
if ($m.Success -and $s.Success) { $clean = $m.Groups[1].Value + "#" + $s.Groups[1].Value }
}
Set-Content -Path $codeFile -Value $clean -NoNewline
break
}
$last = $c
Start-Sleep -Milliseconds 700
}

View File

@@ -30,7 +30,7 @@ $ScriptPath = Join-Path $PSScriptRoot 'omniroute_watchdog.ps1'
$PwshExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $PwshExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$NodeExe = 'C:\Program Files\nodejs\node.exe' $NodeExe = 'C:\Program Files\nodejs\node.exe'
$OmniRouteMjs = Join-Path $env:APPDATA 'npm\node_modules\omniroute\bin\omniroute.mjs' $OmniRouteMjs = Join-Path $env:APPDATA 'npm\node_modules\omniroute\bin\omniroute.mjs'
$UserId = "$env:USERDOMAIN\$env:USERNAME" $UserId = "$env:COMPUTERNAME\$env:USERNAME"
Write-Host '== Instalando arranque fiable de OmniRoute ==' -ForegroundColor Cyan Write-Host '== Instalando arranque fiable de OmniRoute ==' -ForegroundColor Cyan

90
omniroute/oauth_helper.py Normal file
View File

@@ -0,0 +1,90 @@
import subprocess, time, os, threading
base = r"C:\Users\juanm\Documents\Github\INFRAESTRUCTURA\omniroute"
url_file = os.path.join(base, "oauth_url.txt")
code_file = os.path.join(base, "oauth_code.txt")
log_file = os.path.join(base, "oauth_helper.log")
exit_file = os.path.join(base, "oauth_helper.exit")
hb_file = os.path.join(base, "oauth_helper.hb")
for f in (url_file, code_file, log_file, exit_file, hb_file):
try:
os.remove(f)
except FileNotFoundError:
pass
proc = subprocess.Popen(
["omniroute.cmd", "oauth", "start", "--provider", "claude-code", "--no-browser"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1
)
log = open(log_file, "w", encoding="utf-8", buffering=1)
buf = ""
def reader():
global buf
while True:
ch = proc.stdout.read(1)
if ch == "":
if proc.poll() is not None:
break
time.sleep(0.05)
continue
log.write(ch)
buf += ch
if "https://claude.ai/oauth/authorize" in buf and not os.path.exists(url_file):
start = buf.find("https://claude.ai/oauth/authorize")
nl = buf.find("\n", start)
if nl != -1:
with open(url_file, "w", encoding="utf-8") as uf:
uf.write(buf[start:nl].strip())
if len(buf) > 200000:
buf = buf[-50000:]
threading.Thread(target=reader, daemon=True).start()
# Esperar la URL (max 2 min, es rapido)
wait_url = time.time() + 120
while time.time() < wait_url and not os.path.exists(url_file):
time.sleep(0.5)
# Sondear el codigo SIN limite de tiempo; hasta que el proceso termine
sent = False
while proc.poll() is None:
with open(hb_file, "w") as hb:
hb.write(str(time.time()))
if not sent and os.path.exists(code_file):
try:
with open(code_file, "r", encoding="utf-8") as cf:
code = cf.read().strip()
except Exception:
code = ""
if code:
try:
os.remove(code_file)
except FileNotFoundError:
pass
log.write("\n[helper] enviando codigo al proceso...\n")
try:
proc.stdin.write(code + "\n")
proc.stdin.flush()
except Exception as e:
log.write("[helper] fallo al escribir stdin: %r\n" % e)
sent = True
time.sleep(1)
try:
rest = proc.stdout.read()
if rest:
log.write(rest)
except Exception:
pass
log.write("\n=== PROCESO TERMINADO rc=%s ===\n" % proc.poll())
log.close()
with open(exit_file, "w") as ef:
ef.write("done")

View File

@@ -0,0 +1,67 @@
/**
* Monitor Estado Bootstrap — plugin GLOBAL de OpenCode
*
* Objetivo: que al arrancar OpenCode (en CUALQUIER repositorio) se asegure de que el
* Monitor de estado esta corriendo, lanzando su vigilante en modo `-Once` (idempotente:
* el propio monitor tiene un lock, asi que nunca habra dos instancias).
*
* No depende de ningun repositorio: vive en la config global de OpenCode
* (~/.config/opencode/plugins/) y el vigilante canonico esta en el repo INFRAESTRUCTURA.
*
* Instalacion: `scripts/instalar_global.ps1`.
*
* Nota: el vigilante ya corre como tarea programada al iniciar sesion; este plugin es
* la red de seguridad para cuando OpenCode arranca sin que el monitor este vivo.
*/
import type { Plugin } from "@opencode-ai/plugin"
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
// --- Configuracion ---------------------------------------------------------
// Vigilante canonico (repo INFRAESTRUCTURA). Se puede sobreescribir con MONITOR_ESTADO_WATCHDOG.
const WATCHDOG =
process.env.MONITOR_ESTADO_WATCHDOG ??
`${process.env.USERPROFILE}\\Documents\\Github\\INFRAESTRUCTURA\\scripts\\monitor_estado_watchdog.ps1`
const POWERSHELL =
process.env.SystemRoot
? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
// Evita lanzar el vigilante mas de una vez por proceso de OpenCode.
let launched = false
function ensureMonitor(): void {
if (launched) return
launched = true
try {
if (!existsSync(WATCHDOG)) return
const child = spawn(
POWERSHELL,
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-File",
WATCHDOG,
"-Once",
],
{ detached: true, stdio: "ignore", windowsHide: true },
)
child.unref()
} catch {
// Silencioso: el arranque de OpenCode no debe romperse por el monitor.
}
}
// --- Plugin ----------------------------------------------------------------
export const MonitorEstadoBootstrap: Plugin = async () => {
ensureMonitor()
return {}
}

View File

@@ -0,0 +1,116 @@
/**
* OmniRoute autostart — OpenCode plugin
*
* Arranca el servidor local OmniRoute (http://127.0.0.1:20128) cada vez que
* OpenCode se inicia, si no está ya escuchando. También lo relanza si se cae
* durante la sesión. Esto garantiza que los modelos del proveedor `omniroute`
* estén siempre disponibles (modelos gratuitos de la suscripción Claude Code).
*
* Se carga automáticamente desde ~/.config/opencode/plugins/*.ts
*/
import type { Plugin } from "@opencode-ai/plugin"
import { spawn } from "node:child_process"
import { appendFileSync, mkdirSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
// ─── Configuración ───────────────────────────────────────────────────────────
const OR_URL = "http://127.0.0.1:20128"
const OR_PORT = 20128
const NODE_BIN = process.env.OMNIROUTE_NODE ?? "C:\\Program Files\\nodejs\\node.exe"
const OR_ENTRY =
process.env.OMNIROUTE_ENTRY ??
join(
process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
"npm",
"node_modules",
"omniroute",
"bin",
"omniroute.mjs",
)
const LOG_FILE = join(homedir(), ".omniroute", "autostart-plugin.log")
// ─── Utilidades ──────────────────────────────────────────────────────────────
function log(msg: string): void {
try {
mkdirSync(join(homedir(), ".omniroute"), { recursive: true })
appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`)
} catch {
// el log nunca debe romper el arranque de OpenCode
}
}
/** ¿Responde OmniRoute? El dashboard en "/" devuelve 200 sin autenticación. */
async function isUp(): Promise<boolean> {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 3000)
const res = await fetch(`${OR_URL}/`, { signal: ctrl.signal })
clearTimeout(timer)
return res.status > 0 && res.status < 500
} catch {
return false
}
}
/** Lanza OmniRoute desacoplado para que sobreviva al cierre de OpenCode. */
function startServer(): void {
const child = spawn(NODE_BIN, [OR_ENTRY, "serve"], {
detached: true,
stdio: "ignore",
windowsHide: true,
env: process.env,
})
child.unref()
log(`lanzado OmniRoute (pid ${child.pid})`)
}
// Evita arranques concurrentes si coinciden el inicio y varios session.created
let starting: Promise<void> | null = null
/** Arranca OmniRoute si no está levantado y espera a que responda. */
async function ensureServer(reason: string): Promise<void> {
if (await isUp()) return
if (starting) return starting
starting = (async () => {
log(`OmniRoute caído (${reason}) — arrancando...`)
startServer()
// El arranque puede tardar ~60s; sondeamos hasta 2 minutos
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 3000))
if (await isUp()) {
log(`OmniRoute listo en puerto ${OR_PORT}`)
return
}
}
log("OmniRoute no respondió dentro del tiempo de espera")
})()
try {
await starting
} finally {
starting = null
}
}
// ─── Plugin ──────────────────────────────────────────────────────────────────
export const OmniRouteAutostart: Plugin = async () => {
// Al cargar el plugin (= arranque de OpenCode) nos aseguramos de que esté vivo
void ensureServer("opencode-start")
return {
event: async ({ event }) => {
// Re-verificación barata al crear sesión (cubre una caída a mitad de sesión)
if (event.type === "session.created") {
void ensureServer("session-created")
}
},
}
}

View File

@@ -23,7 +23,7 @@ import { existsSync } from "node:fs"
// Vigilante canonico (repo INFRAESTRUCTURA). Se puede sobreescribir con OMNIROUTE_WATCHDOG. // Vigilante canonico (repo INFRAESTRUCTURA). Se puede sobreescribir con OMNIROUTE_WATCHDOG.
const WATCHDOG = const WATCHDOG =
process.env.OMNIROUTE_WATCHDOG ?? process.env.OMNIROUTE_WATCHDOG ??
"C:\\Users\\juanm\\Documents\\Github\\INFRAESTRUCTURA\\omniroute\\omniroute_watchdog.ps1" `${process.env.USERPROFILE}\\Documents\\Github\\INFRAESTRUCTURA\\omniroute\\omniroute_watchdog.ps1`
const POWERSHELL = const POWERSHELL =
process.env.SystemRoot process.env.SystemRoot

View File

@@ -0,0 +1,107 @@
<#
.SYNOPSIS
Configura el acceso remoto por SSH (-> PowerShell) en un PC Windows.
.DESCRIPTION
Idempotente. Deja listo el equipo para conectarse desde otro (p. ej. el portatil)
con `ssh usuario@equipo` y caer directamente en PowerShell.
Hace:
1. Instala la capacidad OpenSSH.Server (si falta).
2. Pone y arranca los servicios sshd y ssh-agent (Automatico).
3. Deja PowerShell como shell por defecto de SSH.
4. Crea una regla de firewall para el puerto 22, restringida a Tailscale y LAN.
5. Instala la clave publica recibida en authorized_keys (o administrators_authorized_keys).
Requiere PowerShell ELEVADO (se auto-eleva si hace falta).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File setup_ssh_server.ps1 -PublicKey "ssh-ed25519 AAAA... juanm@portatil"
#>
[CmdletBinding()]
param(
[string]$PublicKey = '',
[string[]]$AllowedNetworks = @('100.64.0.0/10', '192.168.1.0/24', '192.168.50.0/24')
)
$ErrorActionPreference = 'Stop'
# --- Auto-elevacion -------------------------------------------------------
$esAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $esAdmin) {
Write-Host 'Requiere administrador. Reabriendo elevado...' -ForegroundColor Yellow
$argsList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"")
if ($PublicKey) { $argsList += @('-PublicKey', "`"$PublicKey`"") }
Start-Process powershell -Verb RunAs -ArgumentList $argsList
exit
}
Write-Host '== Configurando OpenSSH Server (acceso remoto -> PowerShell) ==' -ForegroundColor Cyan
# 1) Capacidad OpenSSH.Server ----------------------------------------------
$cap = Get-WindowsCapability -Online -Name 'OpenSSH.Server*' | Select-Object -First 1
if ($cap.State -ne 'Installed') {
Write-Host ' - Instalando OpenSSH.Server...' -ForegroundColor Yellow
Add-WindowsCapability -Online -Name $cap.Name | Out-Null
}
Write-Host ' - OpenSSH.Server instalado.' -ForegroundColor Green
# 2) Servicios --------------------------------------------------------------
foreach ($svc in @('sshd', 'ssh-agent')) {
if (Get-Service $svc -ErrorAction SilentlyContinue) {
Set-Service -Name $svc -StartupType Automatic
if ((Get-Service $svc).Status -ne 'Running') { Start-Service $svc }
Write-Host " - Servicio '$svc' -> Automatico y en marcha." -ForegroundColor Green
}
}
# 3) Shell por defecto = PowerShell ----------------------------------------
if (-not (Test-Path 'HKLM:\SOFTWARE\OpenSSH')) { New-Item -Path 'HKLM:\SOFTWARE\OpenSSH' -Force | Out-Null }
$pwsh = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
New-ItemProperty -Path 'HKLM:\SOFTWARE\OpenSSH' -Name 'DefaultShell' -Value $pwsh -PropertyType String -Force | Out-Null
Write-Host " - Shell por defecto: $pwsh" -ForegroundColor Green
# 4) Firewall (solo Tailscale + LAN) ---------------------------------------
$ruleName = 'OpenSSH Server (sshd) - acceso remoto'
Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue | Remove-NetFirewallRule
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -Action Allow `
-LocalPort 22 -RemoteAddress $AllowedNetworks -Profile Any | Out-Null
Write-Host " - Firewall: puerto 22 permitido desde $($AllowedNetworks -join ', ')." -ForegroundColor Green
# 5) Clave publica ----------------------------------------------------------
if ($PublicKey) {
$usuarioEsAdmin = $null -ne (Get-LocalGroupMember -Group (Get-LocalGroup -SID 'S-1-5-32-544').Name |
Where-Object { $_.Name -like "*\$env:USERNAME" })
if ($usuarioEsAdmin) {
$keyFile = Join-Path $env:ProgramData 'ssh\administrators_authorized_keys'
$dir = Split-Path $keyFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
} else {
$keyFile = Join-Path $env:USERPROFILE '.ssh\authorized_keys'
$dir = Split-Path $keyFile
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
}
$contenido = if (Test-Path $keyFile) { Get-Content -LiteralPath $keyFile -ErrorAction SilentlyContinue } else { @() }
if ($contenido -notcontains $PublicKey.Trim()) {
Add-Content -LiteralPath $keyFile -Value $PublicKey.Trim() -Encoding ascii
Write-Host " - Clave publica anadida a $keyFile" -ForegroundColor Green
} else {
Write-Host " - La clave publica ya estaba en $keyFile" -ForegroundColor Green
}
if ($usuarioEsAdmin) {
# ACLs estrictas exigidas por sshd para administrators_authorized_keys.
# Se usan SIDs (*S-1-5-18 = SYSTEM, *S-1-5-32-544 = Administrators) para
# no depender del idioma de Windows (BUILTIN\Administrators no resuelve en es-ES).
icacls $keyFile /inheritance:r 2>$null | Out-Null
icacls $keyFile /grant:r '*S-1-5-18:F' '*S-1-5-32-544:F' 2>$null | Out-Null
}
Restart-Service sshd
Write-Host ' - sshd reiniciado para aplicar la clave.' -ForegroundColor Green
}
$ips = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' }).IPAddress -join ', '
Write-Host ''
Write-Host 'Listo. Datos de conexion:' -ForegroundColor Cyan
Write-Host " Usuario : $env:USERNAME"
Write-Host " IPs : $ips"
Write-Host " Prueba : ssh $env:USERNAME@<IP-o-nombre-tailscale>"

View File

@@ -0,0 +1,60 @@
<#
.SYNOPSIS
Instala y conecta Tailscale en un PC Windows (malla privada para acceso remoto).
.DESCRIPTION
Idempotente. Tailscale une los equipos (sobremesa, portatil...) en una red privada
cifrada, sin abrir puertos en el router ni depender de la red local.
Hace:
1. Instala Tailscale via winget (si falta).
2. Ejecuta `tailscale up --hostname <nombre>` (autenticacion en el navegador, una vez).
3. Muestra estado y la IP de la tailnet.
Ejecutar en CADA equipo que quiera usar la malla (sobremesa Y portatil).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File setup_tailscale.ps1 -Hostname sobremesa
#>
[CmdletBinding()]
param(
[string]$Hostname = $env:COMPUTERNAME.ToLower()
)
$ErrorActionPreference = 'Continue'
function Get-TailscaleExe {
$cmd = Get-Command tailscale -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
foreach ($p in @("$env:ProgramFiles\Tailscale\tailscale.exe", "${env:ProgramFiles(x86)}\Tailscale\tailscale.exe")) {
if (Test-Path $p) { return $p }
}
return $null
}
Write-Host '== Tailscale (malla privada para acceso remoto) ==' -ForegroundColor Cyan
$ts = Get-TailscaleExe
if (-not $ts) {
Write-Host ' - Instalando Tailscale via winget...' -ForegroundColor Yellow
$wg = Get-Command winget -ErrorAction SilentlyContinue
if (-not $wg) { Write-Error 'winget no disponible. Instala Tailscale manualmente desde https://tailscale.com/download/windows'; exit 1 }
winget install --id Tailscale.Tailscale -e --accept-source-agreements --accept-package-agreements
Start-Sleep -Seconds 5
$ts = Get-TailscaleExe
if (-not $ts) { Write-Error 'No se encuentra tailscale.exe tras la instalacion.'; exit 1 }
}
Write-Host " - tailscale: $ts" -ForegroundColor Green
$svc = Get-Service -Name Tailscale -ErrorAction SilentlyContinue
if ($svc) { Set-Service -Name Tailscale -StartupType Automatic; if ($svc.Status -ne 'Running') { Start-Service Tailscale } }
Write-Host " - Conectando a la tailnet como '$Hostname' (se abrira el navegador para autenticar)..." -ForegroundColor Yellow
& $ts up --hostname $Hostname
Write-Host ''
Write-Host 'Estado:' -ForegroundColor Cyan
& $ts status
$ip = (& $ts ip -4 2>$null) -join ', '
Write-Host "IP Tailscale : $ip"
Write-Host "Conexion : ssh $env:USERNAME@$Hostname (o a la IP Tailscale)"

View File

@@ -0,0 +1,59 @@
# bootstrap_autologon_juanjo.ps1
# Migra la infraestructura a la cuenta local de autologon (juanjo).
# Se ejecuta UNA sola vez, en el primer inicio de sesion de juanjo (via RunOnce HKLM).
# No requiere administrador (registra tareas del usuario actual + config git de usuario).
$ErrorActionPreference = 'Continue'
$log = Join-Path $env:USERPROFILE 'bootstrap_autologon.log'
Start-Transcript -Path $log -Force | Out-Null
Write-Output "=== BOOTSTRAP AUTOLOGON $(Get-Date -Format s) ==="
Write-Output "Usuario: $env:USERDOMAIN\$env:USERNAME"
$infra = Join-Path $env:USERPROFILE 'Documents\Github\INFRAESTRUCTURA'
$bib = Join-Path $env:USERPROFILE 'Documents\Github\biblioteca_negocio_prolongo'
$pwsh = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
# 1) Git: identidad + safe.directory (los repos son de otro usuario) + helper
git config --global user.name "Juanjo Minguez"
git config --global user.email "desarrollo@faccsa.es"
git config --global --add safe.directory '*'
git config --global credential.helper manager
# 2) Credenciales de Gitea reutilizando el token del remote de INFRAESTRUCTURA (sin mostrarlo)
try {
$remote = git -C $infra remote get-url origin 2>$null
if ($remote -match 'https://([^:]+):([^@]+)@([^/]+)/') {
$u = $matches[1]; $tok = $matches[2]; $hostname = $matches[3]
$credPath = Join-Path $env:USERPROFILE '.git-credentials'
"https://${u}:${tok}@${hostname}" | Set-Content -Path $credPath -Encoding ascii
git config --global credential.helper store
Write-Output "[OK] Credenciales git (store) configuradas."
} else {
Write-Output "[WARN] No se pudo extraer token del remote; se usara Git Credential Manager."
}
} catch { Write-Output "[WARN] credenciales git: $_" }
# 3) Instalar/registrar OmniRoute Watchdog + Monitor Estado + watchers para este usuario
try {
& (Join-Path $infra 'scripts\instalar_global.ps1')
Write-Output "[OK] instalar_global.ps1 ejecutado."
} catch { Write-Output "[ERROR] instalar_global.ps1: $_" }
# 4) MorningRoutine Prolongo para este usuario (abre apps al iniciar sesion)
try {
if (Get-ScheduledTask -TaskName 'MorningRoutine Prolongo' -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName 'MorningRoutine Prolongo' -Confirm:$false
}
$action = New-ScheduledTaskAction -Execute $pwsh -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$infra\scripts\morning_routine\arranque_apps.ps1`""
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
$principal = New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\$env:USERNAME" -LogonType Interactive -RunLevel Limited
Register-ScheduledTask -TaskName 'MorningRoutine Prolongo' -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description 'Abre apps al iniciar sesion (migrado a cuenta autologon).' -Force | Out-Null
Write-Output "[OK] MorningRoutine Prolongo registrada."
} catch { Write-Output "[ERROR] MorningRoutine: $_" }
Write-Output "--- TAREAS REGISTRADAS ---"
Get-ScheduledTask | Where-Object { $_.TaskName -match 'OmniRoute|Monitor Estado|Telegram Watcher|Recordatorios|Shutdown Listener|MorningRoutine' } | Select-Object TaskName,State | Format-Table -AutoSize
Write-Output "=== FIN BOOTSTRAP $(Get-Date -Format s) ==="
Stop-Transcript | Out-Null

View File

@@ -2,7 +2,7 @@ import requests
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv(r'C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env') load_dotenv(os.path.expanduser('~/Documents/GitHub/biblioteca_negocio_prolongo/.env'))
url = os.getenv('HOMEASSISTANT_URL') url = os.getenv('HOMEASSISTANT_URL')
token = os.getenv('HOMEASSISTANT_TOKEN') token = os.getenv('HOMEASSISTANT_TOKEN')

View File

@@ -15,7 +15,7 @@ if sys.platform == 'win32':
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# Cargar .env # Cargar .env
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def main(): def main():

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""encender_sobremesa.py — Protocolo de encendido remoto del sobremesa.
Escalera de intentos (acordada):
0. Estado de la regleta (best-effort).
1. Encender la regleta (comando Alexa via Home Assistant).
2. Si no responde -> apagar la regleta 1 min y volver a encenderla
(BIOS 'Restore on AC Power Loss = Power On' -> arranca solo).
3. Informar por voz (Carolina) tras cada comprobacion.
Nota: el Wake-on-LAN esta OMITIDO por defecto (no cruza el doble NAT);
se puede probar con --wol.
Uso:
python scripts/encender_sobremesa.py [--espera 120] [--wol] [--no-voz]
"""
from __future__ import annotations
import argparse
import glob
import os
import re
import subprocess
import sys
import time
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import infra_paths # noqa: E402
# Cargar variables de entorno (HOMEASSISTANT_URL/TOKEN) del repo de negocio.
try:
from dotenv import load_dotenv
load_dotenv(infra_paths.biblioteca_repo() / ".env")
except Exception as _e: # pragma: no cover
print(f"[encender_sobremesa] Aviso: no se pudo cargar .env: {_e}", file=sys.stderr)
TS_EXE = os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Tailscale", "tailscale.exe")
NODO = "desktop-m8se61g"
COS_ENCENDER = "enciende regleta"
COS_APAGAR = "apaga regleta"
MAC = "74:56:3C:CB:0E:CE"
def pc_encendido() -> bool | None:
"""True/False segun Tailscale. None si no se puede determinar."""
try:
out = subprocess.run([TS_EXE, "status"], capture_output=True, text=True, timeout=20).stdout
except Exception:
return None
for linea in out.splitlines():
if NODO in linea:
return "offline" not in linea
return None
def estado_regleta() -> bool | None:
"""Best-effort: True/False si se puede consultar el Tapo; None si no."""
try:
from PyP100 import PyP100 as PP
except Exception:
return None
repo = infra_paths.biblioteca_repo()
creds = None
for f in glob.glob(str(repo / "scripts" / "protocolo_apagado*.py")) + glob.glob(str(_SCRIPTS_DIR / "protocolo_apagado*.py")):
try:
s = open(f, encoding="utf-8", errors="replace").read()
except Exception:
continue
m = re.search(r"P100\(\s*'([^']+)'\s*,\s*'([^']+)'\s*,\s*'([^']+)'\s*\)", s)
if m:
creds = m.groups()
break
if not creds:
return None
try:
d = PP.P100(*creds)
d.handshake()
d.login()
return bool(d.getDeviceInfo().get("result", {}).get("device_on"))
except Exception:
return None
def _comando_alexa(texto: str) -> bool:
infra_paths.bootstrap_biblioteca_paths()
try:
from ha_alexa import command
return command(texto)
except Exception as e:
print(f"[encender_sobremesa] Error llamando a HA: {e}", file=sys.stderr)
return False
def encender_regleta() -> bool:
return _comando_alexa(COS_ENCENDER)
def apagar_regleta() -> bool:
return _comando_alexa(COS_APAGAR)
def wol() -> None:
"""Magic packet (best-effort). Desde .50 no cruza a .1; se intenta igualmente."""
import socket
paquete = b"\xff" * 6 + bytes.fromhex(MAC.replace(":", "")) * 16
for destino in ("192.168.1.255", "192.168.1.239", "255.255.255.255", "192.168.50.255"):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.sendto(paquete, (destino, 9))
s.close()
print(f" magic packet -> {destino}:9")
except Exception as e:
print(f" magic packet -> {destino}:9 ERROR {e}")
def esperar_encendido(segundos: int) -> bool:
t = 0
while t < segundos:
if pc_encendido():
return True
time.sleep(10)
t += 10
return False
def aviso(texto: str, voz: bool = True) -> None:
print(f"[aviso] {texto}")
if not voz:
return
try:
subprocess.run([sys.executable, str(_SCRIPTS_DIR / "voz_mensaje.py"), texto, "--tipo", "default"],
check=False, timeout=120)
except Exception:
pass
def main() -> int:
ap = argparse.ArgumentParser(description="Encendido remoto del sobremesa.")
ap.add_argument("--espera", type=int, default=120, help="Segundos por intento (defecto 120).")
ap.add_argument("--wol", action="store_true", help="Intentar Wake-on-LAN primero (por defecto omitido).")
ap.add_argument("--no-voz", action="store_true", help="Sin avisos de voz.")
args = ap.parse_args()
voz = not args.no_voz
reg = estado_regleta()
print(f"Regleta: {'ENCENDIDA' if reg else 'APAGADA' if reg is False else 'desconocido'}")
st = pc_encendido()
print(f"PC: {'encendido' if st else 'apagado' if st is False else 'desconocido'}")
if st:
aviso("El sobremesa ya estaba encendido.", voz)
return 0
# Wake-on-LAN (opcional; por defecto OMITIDO para acelerar)
if args.wol:
print("-> Wake-on-LAN (opcional)...")
wol()
if esperar_encendido(args.espera):
aviso("El sobremesa se ha encendido con Wake on LAN.", voz)
return 0
# Encender regleta
print("-> Encender regleta (Alexa)...")
encender_regleta()
if esperar_encendido(args.espera):
aviso("El sobremesa se ha encendido al dar corriente la regleta.", voz)
return 0
# Ciclo de regleta (Power On Loss)
print("-> Apagar regleta 1 min y volver a encender (Power On Loss)...")
apagar_regleta()
time.sleep(60)
encender_regleta()
if esperar_encendido(args.espera):
aviso("El sobremesa se ha encendido tras el ciclo de regleta. Power On Loss funciona.", voz)
return 0
aviso("No se ha podido encender el sobremesa ni con la regleta ni por ciclo.", voz)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -31,6 +31,30 @@ if (-not $Password) {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR) [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
} }
# Verificar la contraseña ANTES de escribir (evita guardar una credencial incorrecta,
# que es lo que dejaba el arranque colgado en la pantalla de login)
Write-Host "`nVerificando credenciales..." -ForegroundColor Cyan
$credencialOk = $false
$tmpVerify = Join-Path $env:TEMP "autologon_verify_$PID.txt"
Remove-Item $tmpVerify -ErrorAction SilentlyContinue
try {
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential("$computername\$username", $sec)
Start-Process cmd -ArgumentList "/c whoami > `"$tmpVerify`"" -Credential $cred -Wait -WindowStyle Hidden -ErrorAction Stop
Start-Sleep -Milliseconds 500
$credencialOk = (Test-Path $tmpVerify)
} catch {
$credencialOk = $false
} finally {
Remove-Item $tmpVerify -ErrorAction SilentlyContinue
}
if (-not $credencialOk) {
Write-Host "[ERROR] La contraseña NO es correcta para $computername\$username." -ForegroundColor Red
Write-Host " No se ha modificado el registro. Comprueba la contraseña e inténtalo de nuevo." -ForegroundColor Red
exit 1
}
Write-Host "[OK] Contraseña verificada." -ForegroundColor Green
# Mostrar lo que se va a configurar (SIN mostrar la contraseña) # Mostrar lo que se va a configurar (SIN mostrar la contraseña)
Write-Host "`nSe configurará autologon con:" -ForegroundColor Cyan Write-Host "`nSe configurará autologon con:" -ForegroundColor Cyan
Write-Host " AutoAdminLogon: 1" -ForegroundColor White Write-Host " AutoAdminLogon: 1" -ForegroundColor White

View File

@@ -0,0 +1,28 @@
<#
.SYNOPSIS
Instala/actualiza las dependencias Python comunes (union portatil + sobremesa).
.DESCRIPTION
Deja cualquier equipo con el mismo juego de paquetes Python que usan los
watchers, la biblioteca, el monitor, tapo/netatmo, etc. Idempotente.
Uso: powershell -ExecutionPolicy Bypass -File scripts\instalar_dependencias.ps1
#>
$ErrorActionPreference = "Continue"
$pkgs = @(
"pycryptodome", "PyP100", "caldav", "python-dotenv", "edge-tts", "feedparser",
"flask", "ftfy", "google-auth-oauthlib", "google-api-python-client", "openpyxl",
"psutil", "qrcode", "requests", "requests-oauthlib", "google-auth",
"google-auth-httplib2", "google-api-core", "telethon", "todoist-api-python",
"urllib3", "wakeonlan", "watchdog", "win10toast", "pywin32", "pillow", "setuptools<82"
)
Write-Host "== Instalando dependencias Python (union) ==" -ForegroundColor Cyan
Write-Host ("Version: " + (python --version 2>&1)) -ForegroundColor DarkGray
python -m pip install --upgrade --disable-pip-version-check @pkgs
Write-Host ""
Write-Host "Listo. Verifica con: python -m pip freeze" -ForegroundColor Green
Write-Host "Si algun paquete avisa de incompatibilidad con tu version de Python, ignorarlo." -ForegroundColor DarkGray

View File

@@ -29,7 +29,15 @@ $Watchdog = Join-Path $RepoRoot 'omniroute\omniroute_watchdog.ps1'
$PluginSrc = Join-Path $RepoRoot 'opencode\omniroute-bootstrap.ts' $PluginSrc = Join-Path $RepoRoot 'opencode\omniroute-bootstrap.ts'
$PluginDir = Join-Path $env:USERPROFILE '.config\opencode\plugins' $PluginDir = Join-Path $env:USERPROFILE '.config\opencode\plugins'
$PluginDst = Join-Path $PluginDir 'omniroute-bootstrap.ts' $PluginDst = Join-Path $PluginDir 'omniroute-bootstrap.ts'
$UserId = "$env:USERDOMAIN\$env:USERNAME" $UserId = "$env:COMPUTERNAME\$env:USERNAME"
# Monitor de estado (Telegram + correo + Jira)
$TaskMonitor = 'Monitor Estado'
$WatchdogMonitor = Join-Path $RepoRoot 'scripts\monitor_estado_watchdog.ps1'
$PluginSrcMon = Join-Path $RepoRoot 'opencode\monitor-estado-bootstrap.ts'
$PluginDstMon = Join-Path $PluginDir 'monitor-estado-bootstrap.ts'
$PluginSrcAuto = Join-Path $RepoRoot 'opencode\omniroute-autostart.ts'
$PluginDstAuto = Join-Path $PluginDir 'omniroute-autostart.ts'
Write-Host '== Instalando infraestructura global (OmniRoute + OpenCode) ==' -ForegroundColor Cyan Write-Host '== Instalando infraestructura global (OmniRoute + OpenCode) ==' -ForegroundColor Cyan
@@ -38,6 +46,14 @@ if (-not (Test-Path $PluginDir)) { New-Item -ItemType Directory -Force -Path $Pl
Copy-Item -LiteralPath $PluginSrc -Destination $PluginDst -Force Copy-Item -LiteralPath $PluginSrc -Destination $PluginDst -Force
Write-Host " - Plugin global instalado en $PluginDst" -ForegroundColor Green Write-Host " - Plugin global instalado en $PluginDst" -ForegroundColor Green
Copy-Item -LiteralPath $PluginSrcMon -Destination $PluginDstMon -Force
Write-Host " - Plugin global instalado en $PluginDstMon" -ForegroundColor Green
if (Test-Path $PluginSrcAuto) {
Copy-Item -LiteralPath $PluginSrcAuto -Destination $PluginDstAuto -Force
Write-Host " - Plugin global instalado en $PluginDstAuto" -ForegroundColor Green
}
# 2) Tarea programada ------------------------------------------------------ # 2) Tarea programada ------------------------------------------------------
if (-not (Test-Path $Watchdog)) { throw "No se encuentra el vigilante: $Watchdog" } if (-not (Test-Path $Watchdog)) { throw "No se encuentra el vigilante: $Watchdog" }
@@ -76,10 +92,43 @@ Register-ScheduledTask `
Write-Host " - Tarea '$TaskName' registrada -> $Watchdog" -ForegroundColor Green Write-Host " - Tarea '$TaskName' registrada -> $Watchdog" -ForegroundColor Green
# 2b) Tarea programada del Monitor de estado --------------------------------
if (-not (Test-Path $WatchdogMonitor)) { throw "No se encuentra el vigilante: $WatchdogMonitor" }
if (Get-ScheduledTask -TaskName $TaskMonitor -ErrorAction SilentlyContinue) {
Write-Host ' - Eliminando tarea anterior del monitor...' -ForegroundColor Yellow
Unregister-ScheduledTask -TaskName $TaskMonitor -Confirm:$false
}
$ActionMon = New-ScheduledTaskAction `
-Execute $PwshExe `
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$WatchdogMonitor`" -Once" `
-WorkingDirectory $env:USERPROFILE
$SettingsMon = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-MultipleInstances IgnoreNew
$SettingsMon.ExecutionTimeLimit = 'PT0S'
$SettingsMon.Hidden = $true
Register-ScheduledTask `
-TaskName $TaskMonitor `
-Action $ActionMon `
-Trigger $Trigger `
-Settings $SettingsMon `
-Principal $Principal `
-Description 'Arranca el Monitor de estado (Telegram + correo + Jira) al iniciar sesion.' `
-Force | Out-Null
Write-Host " - Tarea '$TaskMonitor' registrada -> $WatchdogMonitor" -ForegroundColor Green
# 3) Arrancar -------------------------------------------------------------- # 3) Arrancar --------------------------------------------------------------
if (-not $NoStart) { if (-not $NoStart) {
Start-ScheduledTask -TaskName $TaskName Start-ScheduledTask -TaskName $TaskName
Write-Host ' - Vigilante arrancado.' -ForegroundColor Yellow Start-ScheduledTask -TaskName $TaskMonitor
Write-Host ' - Vigilantes arrancados.' -ForegroundColor Yellow
} }
# 4) Watchers de infraestructura (Telegram + recordatorios) ----------------- # 4) Watchers de infraestructura (Telegram + recordatorios) -----------------

View File

@@ -25,7 +25,7 @@ $ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot $RepoRoot = Split-Path -Parent $PSScriptRoot
$PwshExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $PwshExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$UserId = "$env:USERDOMAIN\$env:USERNAME" $UserId = "$env:COMPUTERNAME\$env:USERNAME"
# Resolver el interprete de Python # Resolver el interprete de Python
$Python = (Get-Command python -ErrorAction SilentlyContinue).Source $Python = (Get-Command python -ErrorAction SilentlyContinue).Source

215
scripts/monitor_estado.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""monitor_estado.py — Punto de entrada del Monitor de estado.
Modos:
ciclo Ejecuta un ciclo completo y termina (a demanda).
daemon Bucle en segundo plano cada `intervalo_minutos` (defecto 30).
estado Muestra el ultimo resumen y las rutas de los ficheros.
login-telegram Autoriza la sesion dedicada de Telegram (una vez).
El nucleo vive en el paquete `monitor_estado/`.
"""
from __future__ import annotations
import argparse
import sys
import time
from datetime import datetime
from pathlib import Path
# Permite ejecutar el script desde cualquier directorio.
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import monitor_estado.config as config # noqa: E402
from monitor_estado import __version__, ciclo, lock # noqa: E402
# Salida UTF-8 en consola Windows (acentos y simbolos).
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
class _NoJira:
"""Cliente Jira vacio (para el modo --solo que desactiva esta fuente)."""
def search(self, jql, fields=None, max_results=50):
return {"issues": []}
def _deps_solo(solo: str | None) -> dict:
"""Dependencias que desactivan las fuentes no seleccionadas con --solo."""
deps: dict = {}
if solo and solo != "telegram":
deps["telegram_recoger"] = lambda cfg, hoy: ({}, [])
if solo and solo != "correo":
deps["correo_recoger"] = lambda horas=24, limite=50: []
if solo and solo != "jira":
deps["jira_cliente"] = _NoJira()
return deps
def cmd_ciclo(args: argparse.Namespace) -> int:
"""Ejecuta un ciclo completo (o de una sola fuente con --solo)."""
cfg = config.cargar()
if not lock.adquirir():
print("[monitor_estado] Ya hay una instancia en marcha; no lanzo otra.")
return 0
try:
ciclo.ejecutar(cfg, deps=_deps_solo(getattr(args, "solo", None)))
except Exception as e:
print(f"[monitor_estado] Error en el ciclo: {e}", file=sys.stderr)
return 1
finally:
lock.liberar()
print(ciclo.texto_estado())
return 0
def cmd_daemon(args: argparse.Namespace) -> int:
"""Bucle en segundo plano cada intervalo_minutos (RF-19)."""
if not lock.adquirir():
print("[monitor_estado] Ya hay una instancia en marcha; salgo.")
return 0
cfg = config.cargar()
intervalo = max(1, int(cfg.get("intervalo_minutos", 30))) * 60
print(f"[monitor_estado] daemon iniciado (cada {intervalo // 60} min). Ctrl+C para salir.", flush=True)
try:
while True:
try:
res = ciclo.ejecutar(cfg)
atencion = len(res.get("requiere_atencion", []))
print(
f"[{datetime.now():%Y-%m-%d %H:%M:%S}] ciclo ok | fuentes={res.get('fuentes_con_novedad')} "
f"| atencion={atencion} | aviso={res.get('aviso_emitido')}",
flush=True,
)
except Exception as e: # un ciclo fallido no debe matar el daemon
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Error en ciclo: {e}", flush=True, file=sys.stderr)
time.sleep(intervalo)
except KeyboardInterrupt:
pass
finally:
lock.liberar()
return 0
def cmd_estado(args: argparse.Namespace) -> int:
"""Muestra el ultimo resumen y la configuracion (RF-18)."""
print(f"[monitor_estado] v{__version__}")
print(ciclo.texto_estado())
print("\n--- Configuracion ---")
print(config.resumen_config(config.cargar()))
return 0
def cmd_simular(args: argparse.Namespace) -> int:
"""Simula entradas ficticias (correo/Telegram) SIN enviar ni crear nada real.
Ejecuta el pipeline completo en un directorio aislado (`estado/monitor/simulacion/`):
no toca los datos reales, no crea correos y NUNCA envia mensajes a Telegram.
"""
from datetime import date
import monitor_estado.archivo as archivo
import monitor_estado.vistos as vistos
base = config.MONITOR_DIR / "simulacion"
archivo.MONITOR_DIR = base
vistos.VISTO_DIR = base / "visto"
hoy = date.today()
texto = getattr(args, "texto", None) or "Confirmado el desarrollo"
asunto = getattr(args, "asunto", None) or texto
def tg_fake(cfg, h):
return ({"simulacion_telegram": [{
"id": 9900001, "fecha": datetime.now().isoformat(),
"quien": getattr(args, "de", None) or "DAX", "texto": texto,
}]}, [])
def correo_fake(horas=24, limite=50):
remitente = getattr(args, "de", None) or "DAX"
return [{
"id": "sim-mail-1", "fecha": datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0200"),
"de": f"{remitente} <{remitente.lower()}@prolongo.es>",
"asunto": asunto, "snippet": texto,
}]
res = ciclo.ejecutar(
cfg=config.cargar(), hoy=hoy,
deps={"telegram_recoger": tg_fake, "correo_recoger": correo_fake, "jira_cliente": _NoJira()},
)
print("[SIMULACION] No se ha enviado ni creado nada real (solo simulado, aislado en 'simulacion/').")
print(f"Elementos de atencion: {len(res.get('requiere_atencion', []))} | aviso={res.get('aviso_emitido')}")
print(ciclo.texto_estado())
return 0
def cmd_login_telegram(args: argparse.Namespace) -> int:
"""Autoriza la sesion dedicada de Telegram (interactivo, una sola vez)."""
from monitor_estado.fuentes import telegram as tg
if getattr(args, "qr", False):
tg.login_qr(config.cargar())
else:
tg.login(config.cargar(), force_sms=getattr(args, "sms", False))
return 0
def build_parser() -> argparse.ArgumentParser:
"""Construye el parser de argumentos del CLI."""
parser = argparse.ArgumentParser(
prog="monitor_estado",
description="Monitor de estado (Telegram + correo + Jira) con resumen IA y aviso local.",
)
parser.add_argument("--version", action="version", version=f"monitor_estado {__version__}")
subs = parser.add_subparsers(dest="comando")
p_ciclo = subs.add_parser("ciclo", help="Ejecuta un ciclo completo y termina.")
p_ciclo.add_argument("--solo", choices=["telegram", "correo", "jira"], help="Recoge solo esa fuente.")
subs.add_parser("daemon", help="Bucle en segundo plano cada intervalo_minutos.")
subs.add_parser("estado", help="Muestra el ultimo resumen y las rutas.")
p_sim = subs.add_parser("simular", help="Simula entradas ficticias sin enviar ni crear nada real.")
p_sim.add_argument("--texto", help="Texto simulado (defecto: 'Confirmado el desarrollo').")
p_sim.add_argument("--asunto", help="Asunto del correo simulado.")
p_sim.add_argument("--de", help="Remitente simulado (defecto: DAX).")
p_login = subs.add_parser("login-telegram", help="Autoriza la sesion dedicada de Telegram (una vez).")
p_login.add_argument("--sms", action="store_true", help="Forzar que el codigo llegue por SMS.")
p_login.add_argument("--qr", action="store_true", help="Autorizar escaneando un codigo QR.")
return parser
def main(argv: list[str] | None = None) -> int:
"""Punto de entrada: despacha el subcomando o muestra ayuda + config."""
parser = build_parser()
args = parser.parse_args(argv)
if args.comando == "ciclo":
return cmd_ciclo(args)
if args.comando == "daemon":
return cmd_daemon(args)
if args.comando == "estado":
return cmd_estado(args)
if args.comando == "simular":
return cmd_simular(args)
if args.comando == "login-telegram":
return cmd_login_telegram(args)
# Sin subcomando: ayuda + resumen de configuracion.
parser.print_help()
print("\n--- Configuracion actual ---")
print(config.resumen_config(config.cargar()))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,58 @@
# Monitor de estado — Telegram + correo + Jira
Proceso en segundo plano que **cada 30 minutos** recoge lo del día en curso, lo archiva
por fuente, genera un **resumen IA** y emite un **aviso por voz local** cuando algo
requiere atención. Nunca envía mensajes a ningún medio (solo lee y avisa localmente).
Spec y plan: `biblioteca_negocio_prolongo/specs/005-monitor-estado-conversaciones/`.
## Qué recoge
- **Telegram** (sesión dedicada `~/.telegram-agent/sessions/monitor`): Oscar Rodríguez, Isa,
Daniel García Jiménez, Antonio Romero y el canal "Desarrollo software".
- **Correo** `jminguez@prolongo.es`: últimos 24 h (no marca leído).
- **Jira** proyecto **DS**: incidencias nuevas (desde el ciclo anterior) y tickets sin asignar
no cerrados. Solo lectura.
## Uso
```powershell
python scripts/monitor_estado.py ciclo # un ciclo y salir
python scripts/monitor_estado.py ciclo --solo correo
python scripts/monitor_estado.py daemon # bucle cada 30 min (segundo plano)
python scripts/monitor_estado.py estado # último resumen + rutas
python scripts/monitor_estado.py login-telegram --qr # autorizar sesión Telegram (una vez)
python scripts/monitor_estado.py simular --de DAX --texto "Confirmado el desarrollo"
```
- `simular` inyecta entradas ficticias en un directorio aislado (`estado/monitor/simulacion/`):
no crea correos reales, no toca datos reales y **nunca** envía a Telegram.
## Arranque y vigilancia
- **Tarea programada `Monitor Estado`** (al iniciar sesión) → `scripts/monitor_estado_watchdog.ps1 -Once`.
- **Plugin global de OpenCode** `~/.config/opencode/plugins/monitor-estado-bootstrap.ts` → relanza el
watchdog al arrancar OpenCode.
- El watchdog lanza `daemon` solo si no corre; el propio monitor tiene un lock (instancia única).
- Instalación: `scripts/instalar_global.ps1`.
```powershell
powershell -ExecutionPolicy Bypass -File scripts/monitor_estado_watchdog.ps1 -Once
powershell -ExecutionPolicy Bypass -File scripts/monitor_estado_watchdog.ps1 -Stop
```
## Configuración
`estado/monitor/config.json` (se autogenera desde `config.example.json`):
intervalo, retención (30 días), fuentes de Telegram, correo, Jira, IA (OmniRoute) y aviso de voz.
## Datos y logs
```
estado/monitor/
conversaciones/ correo/ jira/ # ficheros del día por fuente (<YYYY-MM-DD>.md)
visto/ # estado "ya visto" (dedup)
resumen_actual.md/.json # último resumen
ultimo_ciclo.json
monitor_estado.out.log / .err.log # salida del daemon
```
## Tests
```powershell
python -m pytest tests/ -q
```
Requiere: `pip install pytest qrcode` (Telethon/gspread ya usados por otros watchers).

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""monitor_estado — Monitor de estado en segundo plano.
Paquete que recoge cada 30 minutos lo del dia en curso de Telegram (5 fuentes),
correo de empresa y Jira DS, lo archiva por fuente, genera un resumen IA y emite
un aviso por voz local cuando algo requiere atencion.
Ver spec `specs/005-monitor-estado-conversaciones/` en el repo de negocio.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""archivo.py — Archivado diario por fuente y resumen (RF-5, RF-7, RF-9, RF-10, RF-18).
Escribe un fichero Markdown por fuente y dia (`estado/monitor/<ruta>/<YYYY-MM-DD>.md`).
En cada ciclo el fichero del dia se **reescribe** con el estado actual completo, de modo
que siempre contiene todo lo de hoy y nada duplicado. Los ficheros de mas de
`retencion_dias` se eliminan. Tambien persiste el ultimo resumen.
El formateo de cada elemento se delega en `render_fn`, para no acoplar este modulo a
la forma concreta de cada fuente.
"""
from __future__ import annotations
import json
import re
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Callable, Iterable
from . import config
MONITOR_DIR: Path = config.MONITOR_DIR
_PATRON_FECHA = re.compile(r"^(\d{4}-\d{2}-\d{2})\.md$")
def _fecha_str(fecha) -> str:
"""Normaliza una fecha (date o texto ISO) a 'YYYY-MM-DD'."""
if isinstance(fecha, datetime):
fecha = fecha.date()
if isinstance(fecha, date):
return fecha.isoformat()
return str(fecha)
def escribe_dia(
ruta_logica: str,
fecha,
elementos: Iterable,
id_fn: Callable = lambda e: e,
render_fn: Callable = str,
titulo: str | None = None,
) -> Path:
"""Reescribe el fichero del dia de una fuente, sin duplicados por id.
`ruta_logica` es la ruta relativa bajo el directorio del monitor (p. ej.
`telegram/oscar_rodriguez`, `correo`, `jira`).
"""
fecha_str = _fecha_str(fecha)
vistos: set[str] = set()
bloques: list[str] = []
for e in elementos:
clave = str(id_fn(e))
if clave in vistos:
continue
vistos.add(clave)
bloques.append(render_fn(e))
titulo = titulo or f"{ruta_logica} — {fecha_str}"
cuerpo = "\n\n".join(bloques)
contenido = f"# {titulo}\n\n{cuerpo}\n" if cuerpo else f"# {titulo}\n"
destino = MONITOR_DIR / ruta_logica / f"{fecha_str}.md"
destino.parent.mkdir(parents=True, exist_ok=True)
destino.write_text(contenido, encoding="utf-8")
return destino
def poda(retencion_dias: int = 30, hoy: date | None = None) -> list[Path]:
"""Elimina los ficheros diarios con fecha anterior a `hoy - retencion_dias`.
Devuelve la lista de ficheros borrados.
"""
hoy = hoy or date.today()
limite = hoy - timedelta(days=retencion_dias)
borrados: list[Path] = []
if not MONITOR_DIR.exists():
return borrados
for f in MONITOR_DIR.rglob("*.md"):
m = _PATRON_FECHA.match(f.name)
if not m:
continue
try:
fd = date.fromisoformat(m.group(1))
except ValueError:
continue
if fd < limite:
try:
f.unlink()
borrados.append(f)
except OSError:
pass
return borrados
def escribe_resumen(resumen: dict) -> None:
"""Persiste el ultimo resumen en `resumen_actual.md` y `resumen_actual.json`."""
MONITOR_DIR.mkdir(parents=True, exist_ok=True)
md = resumen.get("resumen_md", "") or ""
(MONITOR_DIR / "resumen_actual.md").write_text(md, encoding="utf-8")
with open(MONITOR_DIR / "resumen_actual.json", "w", encoding="utf-8") as f:
json.dump(resumen, f, indent=2, ensure_ascii=False)
def escribe_ultimo_ciclo(data: dict) -> None:
"""Persiste la marca del ultimo ciclo en `ultimo_ciclo.json`."""
MONITOR_DIR.mkdir(parents=True, exist_ok=True)
with open(MONITOR_DIR / "ultimo_ciclo.json", "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def leer_ultimo_ciclo() -> dict:
"""Lee `ultimo_ciclo.json` (o {} si no existe)."""
ruta = MONITOR_DIR / "ultimo_ciclo.json"
if ruta.exists():
try:
with open(ruta, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""aviso.py — Aviso por voz local (RF-14, RF-15).
Emite un aviso sonoro local (reutiliza `scripts/aviso_sonoro.py` del repo de negocio)
solo cuando el resumen del ciclo contiene elementos que requieren atencion, o cuando
la IA fallo (RF-13). Nunca envia nada fuera del equipo (Regla 5).
El runner es inyectable para poder testear sin reproducir audio.
"""
from __future__ import annotations
import subprocess
import sys
from typing import Callable
from . import config
import infra_paths
def _script_aviso():
"""Ruta del script de aviso sonoro (repo de negocio)."""
return infra_paths.biblioteca_repo() / "scripts" / "aviso_sonoro.py"
def _runner_default(tipo: str) -> None:
"""Reproduce el aviso sonoro lanzando el script en un subproceso."""
subprocess.run([sys.executable, str(_script_aviso()), tipo], check=False)
def avisar(resumen: dict, cfg: dict | None = None, _runner: Callable[[str], None] | None = None) -> bool:
"""Emite el aviso por voz si procede. Devuelve True si aviso.
- Si `aviso.voz` esta desactivado en config -> no hace nada.
- Avisa si `fallo_ia` o si hay elementos en `requiere_atencion`.
"""
cfg = cfg or config.cargar()
if not cfg.get("aviso", {}).get("voz", True):
return False
fallo_ia = bool(resumen.get("fallo_ia"))
atencion = bool(resumen.get("requiere_atencion"))
if not (fallo_ia or atencion):
return False
tipo = "fin" if atencion else "error"
runner = _runner or _runner_default
try:
runner(tipo)
except Exception:
pass
return True

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""ciclo.py — Orquestador de un ciclo del monitor (RF-1..RF-18, RF-23, RF-24).
Ejecuta la recogida de todas las fuentes, el archivado por fuente, el resumen IA,
el aviso por voz y el mantenimiento (poda, marca de ultimo ciclo). Aisla los errores
por fuente: una fuente caida no aborta el ciclo (RF-6).
Las dependencias externas (fuentes, IA, aviso) son inyectables para poder testear.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from . import archivo, aviso, config, resumen, vistos
from .fuentes import correo as correo_mod
from .fuentes import jira as jira_mod
from .fuentes import telegram as telegram_mod
def _desde_jira(ahora: datetime) -> datetime:
"""Instante desde el que buscar incidencias nuevas: ultimo ciclo o, si no, 24 h."""
ultimo = archivo.leer_ultimo_ciclo()
inicio = ultimo.get("inicio")
if inicio:
try:
return datetime.fromisoformat(inicio)
except Exception:
pass
return ahora - timedelta(hours=24)
def ejecutar(cfg: dict | None = None, hoy: date | None = None, deps: dict | None = None) -> dict:
"""Ejecuta un ciclo completo. Devuelve el resumen estructurado del ciclo."""
cfg = cfg or config.cargar()
ahora = datetime.now()
hoy = hoy or ahora.date()
deps = deps or {}
telegram_recoger = deps.get("telegram_recoger", telegram_mod.recoger_todas)
correo_recoger = deps.get("correo_recoger", correo_mod.recoger_recientes)
jira_cliente = deps.get("jira_cliente")
ia_fn = deps.get("ia")
avisar_fn = deps.get("avisar", aviso.avisar)
novedades: dict[str, list] = {}
errores: list[tuple[str, str]] = []
# --- Telegram ---
try:
resultado, errs = telegram_recoger(cfg, hoy)
for nombre, msgs in resultado.items():
archivo.escribe_dia(
f"telegram/{nombre}", hoy, msgs,
id_fn=lambda m: m["id"], render_fn=telegram_mod.render_mensaje,
titulo=f"{nombre} — {hoy.isoformat()}",
)
nuevos = vistos.filtrar_nuevos(f"telegram_{nombre}", msgs, id_fn=lambda m: m["id"])
if nuevos:
novedades[f"telegram/{nombre}"] = nuevos
errores += [(f"telegram/{n}", e) for n, e in errs]
except Exception as e:
errores.append(("telegram", str(e)))
# --- Correo ---
try:
correos = correo_recoger(horas=cfg.get("correo", {}).get("horas", 24))
archivo.escribe_dia("correo", hoy, correos, id_fn=lambda m: m["id"], render_fn=correo_mod.render_correo)
nuevos = vistos.filtrar_nuevos("correo", correos, id_fn=lambda m: m["id"])
if nuevos:
novedades["correo"] = nuevos
except Exception as e:
errores.append(("correo", str(e)))
# --- Jira (solo lectura) ---
try:
cliente = jira_cliente or jira_mod.construir_cliente(cfg)
ds_nuevos = jira_mod.ds_nuevos(cliente, _desde_jira(ahora))
ds_sin_asignar = jira_mod.ds_sin_asignar(cliente)
todos = ds_nuevos + ds_sin_asignar
archivo.escribe_dia("jira", hoy, todos, id_fn=lambda m: m["key"], render_fn=jira_mod.render_jira)
nuevos = vistos.filtrar_nuevos("jira", todos, id_fn=lambda m: m["key"])
if nuevos:
novedades["jira"] = nuevos
except Exception as e:
errores.append(("jira", str(e)))
# --- Resumen IA + clasificacion ---
res = resumen.generar(novedades, errores, cfg=cfg, _ia=ia_fn)
archivo.escribe_resumen(res)
# --- Aviso por voz ---
emitido = avisar_fn(res, cfg=cfg, _runner=deps.get("aviso_runner"))
res["aviso_emitido"] = emitido
# --- Mantenimiento ---
archivo.poda(cfg.get("retencion_dias", 30), hoy)
archivo.escribe_ultimo_ciclo({
"inicio": ahora.isoformat(),
"fin": datetime.now().isoformat(),
"ok": not errores,
"fuentes_con_novedad": res.get("fuentes_con_novedad", []),
"errores": res.get("errores", []),
})
return res
def texto_estado() -> str:
"""Texto legible del ultimo resumen y estado, para el comando `estado` (RF-18)."""
r = archivo.leer_ultimo_ciclo()
resumen_json = {}
ruta = archivo.MONITOR_DIR / "resumen_actual.json"
if ruta.exists():
import json
try:
resumen_json = json.loads(ruta.read_text(encoding="utf-8"))
except Exception:
resumen_json = {}
lineas = [
"── MONITOR DE ESTADO ──",
f"Datos en : {archivo.MONITOR_DIR}",
f"Ultimo ciclo : {r.get('inicio', 'nunca')} (ok={r.get('ok', '-')})",
]
if r.get("errores"):
lineas.append(f"Errores : {len(r['errores'])}")
lineas.append("── Ultimo resumen ──")
lineas.append(resumen_json.get("resumen_md", "(sin resumen)") or "(sin resumen)")
atencion = resumen_json.get("requiere_atencion", [])
if atencion:
lineas.append("Requiere atencion:")
for a in atencion:
lineas.append(f" - [{a.get('fuente', '?')}] {a.get('texto', '')} ({a.get('motivo', '')})")
return "\n".join(lineas)

View File

@@ -0,0 +1,19 @@
{
"intervalo_minutos": 30,
"retencion_dias": 30,
"telegram": {
"sesion": "~/.telegram-agent/sessions/monitor",
"telefono": "+34657866417",
"fuentes": [
{ "nombre": "oscar_rodriguez", "id": 129020043 },
{ "nombre": "isa", "id": 16182959 },
{ "nombre": "daniel_garcia", "id": 8249301783 },
{ "nombre": "antonio_romero", "id": 880864397 },
{ "nombre": "desarrollo_software", "id": -1002693936231 }
]
},
"correo": { "cuenta": "jminguez@prolongo.es", "horas": 24 },
"jira": { "proyecto": "DS" },
"ia": { "base_url": "http://localhost:20128/v1", "modelo": "antigravity/gemini-2.5-flash-lite" },
"aviso": { "voz": true }
}

View File

@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""config.py — Carga y valida la configuracion del monitor de estado.
La configuracion vive en `INFRAESTRUCTURA/estado/monitor/config.json` (runtime, no
versionada). Si no existe, se crea a partir de la plantilla `config.example.json`
que va junto a este modulo, de modo que el monitor arranca siempre con valores validos.
Las 5 fuentes de Telegram y sus identificadores se resolvieron desde la sesion real
de Telegram (ver plan 005).
"""
from __future__ import annotations
import copy
import json
import sys
from pathlib import Path
# El modulo vive en INFRAESTRUCTURA/scripts/monitor_estado/; su padre es scripts/,
# donde esta infra_paths.py.
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import infra_paths # noqa: E402
# Directorio raiz de datos runtime del monitor (gitignored).
MONITOR_DIR = infra_paths.ESTADO_DIR / "monitor"
CONFIG_PATH = MONITOR_DIR / "config.json"
EXAMPLE_PATH = Path(__file__).resolve().parent / "config.example.json"
def default_config() -> dict:
"""Configuracion por defecto (equivale a la plantilla de ejemplo)."""
return {
"intervalo_minutos": 30,
"retencion_dias": 30,
"telegram": {
# Sesion Telethon DEDICADA (no reutiliza la del watcher, que esta bloqueada).
"sesion": "~/.telegram-agent/sessions/monitor",
"telefono": "+34657866417",
"fuentes": [
{"nombre": "oscar_rodriguez", "id": 129020043},
{"nombre": "isa", "id": 16182959},
{"nombre": "daniel_garcia", "id": 8249301783},
{"nombre": "antonio_romero", "id": 880864397},
{"nombre": "desarrollo_software", "id": -1002693936231},
],
},
"correo": {"cuenta": "jminguez@prolongo.es", "horas": 24},
"jira": {"proyecto": "DS"},
"ia": {"base_url": "http://localhost:20128/v1", "modelo": "antigravity/gemini-2.5-flash-lite"},
"aviso": {"voz": True},
}
def _merge(base: dict, override: dict) -> dict:
"""Mezcla recursiva de diccionarios (override gana; no borra claves base)."""
result = copy.deepcopy(base)
for clave, valor in override.items():
if isinstance(valor, dict) and isinstance(result.get(clave), dict):
result[clave] = _merge(result[clave], valor)
else:
result[clave] = valor
return result
def _escribir_plantilla(destino: Path) -> None:
"""Escribe la config de ejemplo en `destino` (creando el directorio)."""
destino.parent.mkdir(parents=True, exist_ok=True)
with open(destino, "w", encoding="utf-8") as f:
json.dump(default_config(), f, indent=2, ensure_ascii=False)
def cargar(crear_si_falta: bool = True) -> dict:
"""Carga la configuracion, creando `config.json` desde la plantilla si no existe.
Devuelve siempre un dict completo (las claves que falten se toman por defecto).
"""
cfg = default_config()
if not CONFIG_PATH.exists():
if not crear_si_falta:
return cfg
_escribir_plantilla(CONFIG_PATH)
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
usuario = json.load(f)
if isinstance(usuario, dict):
cfg = _merge(cfg, usuario)
except Exception as e: # config corrupta -> seguimos con los valores por defecto
print(f"[monitor_estado] Aviso: no se pudo leer {CONFIG_PATH}: {e}", file=sys.stderr)
return cfg
def fuentes_telegram(cfg: dict | None = None) -> list[dict]:
"""Devuelve la lista de fuentes de Telegram configuradas."""
cfg = cfg or cargar()
return list(cfg.get("telegram", {}).get("fuentes", []))
def resumen_config(cfg: dict | None = None) -> str:
"""Texto legible con las fuentes configuradas (para el CLI/estado)."""
cfg = cfg or cargar()
lineas = [
f"Intervalo : {cfg.get('intervalo_minutos')} min",
f"Retencion : {cfg.get('retencion_dias')} dias",
f"Correo : {cfg.get('correo', {}).get('cuenta')} (ultimas {cfg.get('correo', {}).get('horas')} h)",
f"Jira : proyecto {cfg.get('jira', {}).get('proyecto')}",
"Telegram:",
]
for f in fuentes_telegram(cfg):
lineas.append(f" - {f.get('nombre')} (id {f.get('id')})")
return "\n".join(lineas)
if __name__ == "__main__":
print(resumen_config())

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Fuentes del monitor de estado (Telegram, correo, Jira)."""

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""correo.py — Fuente de correo de empresa del monitor (RF-2, RF-6, RF-25).
Recoge los correos de las ultimas 24 h (o los de hoy) de `jminguez@prolongo.es`
usando la API de Gmail con el OAuth ya existente (repositorio de negocio).
SOLO LECTURA: usa `messages().list()` y `messages().get()`; nunca marca correos
como leidos ni modifica nada (RF-25).
"""
from __future__ import annotations
import sys
from .. import config # noqa: F401 (asegura sys.path con scripts/)
import infra_paths # ya disponible en sys.path (lo anade config.py)
def _bootstrap_google() -> None:
"""Deja importable el paquete `common` del repo de negocio (google/)."""
repo = infra_paths.bootstrap_biblioteca_paths()
google_dir = repo / "google"
if google_dir.exists() and str(google_dir) not in sys.path:
sys.path.insert(0, str(google_dir))
def construir_servicio():
"""Devuelve el servicio de Gmail autenticado (OAuth existente)."""
_bootstrap_google()
import common # google/common.py
return common.gmail_service()
def _consulta(horas: int) -> str:
"""Consulta Gmail para la ventana pedida (granularidad en dias)."""
dias = max(1, int(horas) // 24)
return f"in:inbox newer_than:{dias}d"
def recoger_recientes(gmail=None, horas: int = 24, limite: int = 50) -> list[dict]:
"""Devuelve los correos de la ventana, con {id, fecha, de, asunto, snippet}.
No modifica el estado de los correos (no marca leido).
"""
gmail = gmail or construir_servicio()
res = gmail.users().messages().list(userId="me", q=_consulta(horas), maxResults=limite).execute()
salida: list[dict] = []
for m in res.get("messages", []):
full = gmail.users().messages().get(
userId="me", id=m["id"], format="metadata",
metadataHeaders=["Subject", "From", "Date"],
).execute()
headers = {h["name"].lower(): h["value"] for h in full.get("payload", {}).get("headers", [])}
salida.append({
"id": m["id"],
"fecha": headers.get("date", ""),
"de": headers.get("from", "?"),
"asunto": headers.get("subject", "(sin asunto)"),
"snippet": full.get("snippet", ""),
})
return salida
def render_correo(m: dict) -> str:
"""Render Markdown de un correo para el fichero del dia."""
asunto = m.get("asunto", "(sin asunto)")
de = m.get("de", "?")
fecha = m.get("fecha", "")
snippet = (m.get("snippet") or "").strip()
linea = f"- **{fecha}** — **{de}:** {asunto}"
return f"{linea}\n {snippet}" if snippet else linea

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""jira.py — Fuente Jira del monitor (RF-3, RF-4, RF-16).
Recoge del proyecto DS (Service Desk):
- incidencias NUEVAS creadas desde el ciclo anterior,
- tickets SIN ASIGNAR no cerrados ni resueltos.
SOLO LECTURA (Regla 15): reutiliza el `JiraClient` de gestion_jira, que únicamente
expone consultas GET/POST de busqueda; nunca crea, modifica ni comenta.
"""
from __future__ import annotations
import sys
from datetime import datetime
from .. import config # noqa: F401 (asegura sys.path con scripts/)
import infra_paths
# Estados que NO cuentan como "sin asignar pendiente de atencion".
_ESTADOS_EXCLUIDOS = (
"'En Curso', 'In Progress', 'En desarrollo', 'Pasado a Programación Área', "
"'Cerrado', 'Resuelto', 'Cancelado', 'Closed', 'Resolved', 'Canceled'"
)
def _gestion_jira_dir(cfg: dict | None = None):
"""Directorio del repo gestion_jira (donde vive jira_api.py)."""
cfg = cfg or config.cargar()
override = cfg.get("jira", {}).get("gestion_jira_dir")
if override:
return override
return str(infra_paths.INFRA_ROOT.parent / "gestion_jira")
def construir_cliente(cfg: dict | None = None):
"""Devuelve un JiraClient (solo lectura)."""
directorio = _gestion_jira_dir(cfg)
if directorio not in sys.path:
sys.path.insert(0, directorio)
from jira_api import JiraClient
return JiraClient()
def _iso_para_jql(desde) -> str:
"""Normaliza una fecha a 'YYYY-MM-DD HH:MM' para el JQL."""
if isinstance(desde, datetime):
return desde.strftime("%Y-%m-%d %H:%M")
return str(desde)
def _normalizar(issue: dict) -> dict:
"""Convierte una issue de Jira en un registro legible."""
key = issue.get("key", "")
f = issue.get("fields", {}) or {}
return {
"id": key,
"key": key,
"resumen": f.get("summary", ""),
"estado": (f.get("status") or {}).get("name", ""),
"creado": (f.get("created", "") or "")[:10],
"prioridad": (f.get("priority") or {}).get("name", ""),
"url": f"https://prolongo.atlassian.net/browse/{key}",
}
def ds_nuevos(cliente, desde) -> list[dict]:
"""Incidencias DS creadas desde `desde` (inclusive)."""
jql = f'project = DS AND created >= "{_iso_para_jql(desde)}" ORDER BY created DESC'
res = cliente.search(jql, max_results=100)
return [_normalizar(i) for i in res.get("issues", [])]
def ds_sin_asignar(cliente) -> list[dict]:
"""Tickets DS sin asignar, no cerrados ni resueltos."""
jql = (
"project = DS AND assignee = EMPTY "
f"AND status not in ({_ESTADOS_EXCLUIDOS}) "
"AND statusCategory != Done "
"ORDER BY created DESC"
)
res = cliente.search(jql, max_results=100)
return [_normalizar(i) for i in res.get("issues", [])]
def render_jira(m: dict) -> str:
"""Render Markdown de un ticket para el fichero del dia."""
key = m.get("key", "?")
url = m.get("url", "")
estado = m.get("estado", "")
resumen = m.get("resumen", "")
return f"- **[{key}]({url})** ({estado}) — {resumen}"

View File

@@ -0,0 +1,263 @@
# -*- coding: utf-8 -*-
"""telegram.py — Fuente Telegram del monitor (RF-1, RF-6).
Recoge los mensajes del **dia en curso** de las 5 fuentes configuradas usando
Telethon con una sesion DEDICADA (`~/.telegram-agent/sessions/monitor`), distinta
de la del watcher (que mantiene la suya bloqueada).
La primera vez hay que autorizar esa sesion manualmente:
python scripts/monitor_estado.py login-telegram
El nucleo (`normalizar_mensajes`) es puro y testeable sin conexion.
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import sys
import tempfile
from datetime import date, datetime
from pathlib import Path
from .. import config as config_mod
# ------------------------------------------------------------------ credenciales
def _credenciales() -> tuple[int, str]:
"""Devuelve (api_id, api_hash) desde el entorno de usuario (igual que el watcher)."""
api_id = int(os.environ.get("TELEGRAM_API_ID", "0") or 0)
api_hash = os.environ.get("TELEGRAM_API_HASH", "")
if not api_id or not api_hash:
try:
r1 = subprocess.run(
["powershell", "-Command", '[Environment]::GetEnvironmentVariable("TELEGRAM_API_ID","User")'],
capture_output=True, text=True,
)
r2 = subprocess.run(
["powershell", "-Command", '[Environment]::GetEnvironmentVariable("TELEGRAM_API_HASH","User")'],
capture_output=True, text=True,
)
api_id = int(r1.stdout.strip()) if r1.stdout.strip() else 0
api_hash = r2.stdout.strip()
except Exception:
pass
return api_id, api_hash
def _sesion_path(cfg: dict) -> str:
"""Ruta de la sesion Telethon (sin extension) expandiendo `~`."""
ruta = cfg.get("telegram", {}).get("sesion", "~/.telegram-agent/sessions/monitor")
return str(Path(ruta).expanduser())
def construir_cliente(cfg: dict):
"""Crea un TelegramClient con la sesion dedicada del monitor."""
from telethon import TelegramClient
api_id, api_hash = _credenciales()
if not api_id or not api_hash:
raise RuntimeError("Faltan TELEGRAM_API_ID / TELEGRAM_API_HASH en el entorno del usuario")
return TelegramClient(_sesion_path(cfg), api_id, api_hash)
# ------------------------------------------------------------------ nucleo puro
def _dia_local(fecha: datetime) -> date:
"""Fecha local de un datetime (Telethon entrega fechas con zona)."""
return fecha.astimezone().date() if fecha.tzinfo else fecha.date()
def normalizar_mensajes(raw_mensajes, dia: date, nombre_fuente: str) -> list[dict]:
"""Convierte mensajes crudos en registros del dia, ordenados cronologicamente.
Cada registro: {id, fecha (ISO local), quien, texto, fuente}.
Solo incluye mensajes con texto y del dia indicado. No hace llamadas de red.
"""
salida: list[dict] = []
for m in raw_mensajes:
fecha = getattr(m, "date", None)
texto = getattr(m, "message", None)
if not fecha or not texto:
continue
if _dia_local(fecha) != dia:
continue
quien = getattr(m, "_sender_name", None) or str(getattr(m, "sender_id", "") or "?")
salida.append({
"id": getattr(m, "id", None),
"fecha": fecha.astimezone().isoformat() if fecha.tzinfo else fecha.isoformat(),
"quien": quien,
"texto": texto,
"fuente": nombre_fuente,
})
salida.sort(key=lambda r: r["fecha"])
return salida
def render_mensaje(m: dict) -> str:
"""Render Markdown de un mensaje para el fichero del dia."""
try:
hora = datetime.fromisoformat(m["fecha"]).strftime("%H:%M")
except Exception:
hora = "--:--"
texto = (m.get("texto") or "").replace("\r", "").rstrip()
lineas = texto.split("\n")
cuerpo = "\n".join(f" {ln}" for ln in lineas[1:])
cabecera = f"- **[{hora}] {m.get('quien', '?')}:** {lineas[0] if lineas else ''}"
return f"{cabecera}\n{cuerpo}" if cuerpo else cabecera
# ------------------------------------------------------------------ red (Telethon)
async def _nombre_sender(mensaje) -> str:
"""Nombre legible del emisor de un mensaje."""
try:
s = await mensaje.get_sender()
if s is not None:
return getattr(s, "first_name", None) or getattr(s, "title", None) or getattr(s, "username", None) or str(mensaje.sender_id)
except Exception:
pass
return str(getattr(mensaje, "sender_id", "") or "?")
async def recoger_fuente(client, nombre: str, chat_id, dia: date) -> list[dict]:
"""Recoge los mensajes de hoy de una fuente concreta (una llamada de red)."""
entidad = await client.get_entity(chat_id)
crudos = []
async for m in client.iter_messages(entidad, limit=None):
fecha = getattr(m, "date", None)
if not fecha:
continue
if _dia_local(fecha) < dia:
break # ya salimos del dia en curso (vienen en orden descendente)
m._sender_name = await _nombre_sender(m)
crudos.append(m)
return normalizar_mensajes(crudos, dia, nombre)
async def _recoger_todas_async(cfg: dict, dia: date, client_factory=None) -> tuple[dict, list]:
"""Conecta una vez y recoge todas las fuentes, aislando errores por fuente (RF-6)."""
client = (client_factory or construir_cliente)(cfg)
await client.connect()
try:
if not await client.is_user_authorized():
raise RuntimeError("Sesion de Telegram no autorizada: ejecuta 'login-telegram'")
# Cachea las entidades de los dialogos para poder resolver los IDs de las
# fuentes por numero (Telethon necesita el access_hash de cada peer).
await client.get_dialogs()
resultado: dict[str, list] = {}
errores: list[tuple[str, str]] = []
for f in config_mod.fuentes_telegram(cfg):
nombre = f.get("nombre")
try:
resultado[nombre] = await recoger_fuente(client, nombre, f.get("id"), dia)
except Exception as e: # una fuente caida no aborta el ciclo
errores.append((nombre, str(e)))
return resultado, errores
finally:
await client.disconnect()
def recoger_todas(cfg: dict | None = None, dia: date | None = None, client_factory=None) -> tuple[dict, list]:
"""Version sincrona: {fuente: [mensajes de hoy]} y lista de errores por fuente."""
cfg = cfg or config_mod.cargar()
dia = dia or date.today()
return asyncio.run(_recoger_todas_async(cfg, dia, client_factory))
def login(cfg: dict | None = None, force_sms: bool = False) -> None:
"""Autoriza la sesion dedicada de forma interactiva (una sola vez).
`force_sms=True` obliga a que el codigo llegue por SMS (util si no aparece en
la app de Telegram).
"""
import getpass
from telethon.errors import SessionPasswordNeededError
cfg = cfg or config_mod.cargar()
client = construir_cliente(cfg)
async def _run():
await client.connect()
if await client.is_user_authorized():
me = await client.get_me()
print(f"[monitor_estado] Sesion ya autorizada: {me.first_name} (@{me.username or 'sin usuario'})")
await client.disconnect()
return
telefono = cfg.get("telegram", {}).get("telefono")
sent = await client.send_code_request(telefono, force_sms=force_sms)
print(f"[login] Codigo solicitado (tipo: {type(sent.type).__name__}, force_sms={force_sms}).")
print("[login] Introduce el codigo (llega a la app de Telegram o por SMS).")
codigo = input("Codigo: ").strip()
try:
await client.sign_in(phone=telefono, code=codigo, phone_code_hash=sent.phone_code_hash)
except SessionPasswordNeededError:
password = getpass.getpass("Contrasena 2FA: ")
await client.sign_in(password=password)
me = await client.get_me()
print(f"[monitor_estado] Sesion autorizada: {me.first_name} (@{me.username or 'sin usuario'})")
await client.disconnect()
asyncio.run(_run())
def login_qr(cfg: dict | None = None, timeout: int = 180) -> None:
"""Autoriza la sesion dedicada via codigo QR (escaneo desde la app de Telegram).
Abre una imagen QR en pantalla; se escanea desde Telegram >
Ajustes > Dispositivos > Vincular dispositivo.
"""
cfg = cfg or config_mod.cargar()
client = construir_cliente(cfg)
async def _run():
await client.connect()
if await client.is_user_authorized():
me = await client.get_me()
print(f"[monitor_estado] Sesion ya autorizada: {me.first_name} (@{me.username or 'sin usuario'})")
await client.disconnect()
return
qr = await client.qr_login()
try:
import qrcode
img = qrcode.make(qr.url)
ruta = Path(tempfile.gettempdir()) / "monitor_tg_qr.png"
img.save(ruta)
print(f"[login-qr] QR generado en: {ruta}")
print("[login-qr] Telegram > Ajustes > Dispositivos > Vincular dispositivo.")
try:
os.startfile(str(ruta)) # abre la imagen en el visor por defecto
except Exception:
pass
except Exception as e:
print(f"[login-qr] No se pudo generar la imagen ({e}). Usa la URL:\n{qr.url}")
try:
await qr.wait(timeout=timeout)
except Exception as e:
print(f"[login-qr] QR expirado o cancelado: {e}")
await client.disconnect()
return
me = await client.get_me()
print(f"[monitor_estado] Sesion autorizada: {me.first_name} (@{me.username or 'sin usuario'})")
await client.disconnect()
asyncio.run(_run())
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "login":
login()
elif len(sys.argv) > 1 and sys.argv[1] == "login-qr":
login_qr()
else:
mensajes, errs = recoger_todas()
for nombre, msgs in mensajes.items():
print(f"{nombre}: {len(msgs)} mensajes hoy")
for nombre, err in errs:
print(f"ERROR {nombre}: {err}", file=sys.stderr)

View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""lock.py — Instancia unica del monitor (RF-22).
Usa un fichero `.lock` en el directorio de datos del monitor con el PID y la hora de
arranque de la instancia viva. Antes de arrancar, si hay un PID vivo, se aborta; si el
lock es de un proceso muerto (apagado/crash), se recupera.
Motivo: la tarea programada y el plugin de OpenCode pueden coincidir; este lock evita
que convivan dos monitores recogiendo a la vez.
"""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from . import config
LOCK_PATH: Path = config.MONITOR_DIR / ".lock"
try:
import psutil # type: ignore
except Exception: # pragma: no cover - psutil esta disponible en el entorno
psutil = None
def pid_vivo(pid: int) -> bool:
"""Devuelve True si el PID corresponde a un proceso vivo (no zombie)."""
if pid <= 0:
return False
if psutil is not None:
try:
return psutil.pid_exists(pid) and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
except Exception:
return False
# Fallback sin psutil.
try:
os.kill(pid, 0)
return True
except OSError:
return False
def _leer_pid() -> int | None:
"""Lee el PID del lock actual, o None si no se puede."""
try:
with open(LOCK_PATH, "r", encoding="utf-8") as f:
return int(json.load(f).get("pid", 0)) or None
except Exception:
return None
def _escribir_lock() -> None:
"""Escribe el lock con el PID y la hora de arranque actuales."""
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK_PATH, "w", encoding="utf-8") as f:
json.dump({"pid": os.getpid(), "arranque": datetime.now().isoformat()}, f, indent=2)
def adquirir() -> bool:
"""Intenta adquirir el lock. Devuelve False si ya hay una instancia viva."""
propio = os.getpid()
if LOCK_PATH.exists():
pid = _leer_pid()
if pid and pid_vivo(pid):
# Viva (propia o ajena) -> no arrancar otra instancia.
return False
# Lock caducado (proceso muerto): se elimina y se reintenta.
try:
LOCK_PATH.unlink()
except OSError:
pass
_escribir_lock()
return True
def liberar() -> None:
"""Elimina el lock solo si pertenece a nuestro propio PID."""
try:
if LOCK_PATH.exists() and _leer_pid() == os.getpid():
LOCK_PATH.unlink()
except OSError:
pass
def esta_bloqueado() -> bool:
"""True si hay una instancia viva (distinta o igual) ocupando el lock."""
pid = _leer_pid() if LOCK_PATH.exists() else None
return bool(pid and pid_vivo(pid))

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""resumen.py — Resumen IA del estado y clasificacion "requiere atencion" (RF-11/12/13).
Construye un prompt con las novedades del ciclo y llama al gateway IA ya configurado
en el entorno (OmniRoute, endpoint OpenAI-compatible en localhost). La IA devuelve un
JSON con el resumen en espanol y la lista de elementos que requieren atencion.
Si la IA no esta disponible, no se pierde nada: se archiva igualmente lo recogido y se
deja constancia del fallo (`fallo_ia=True`) para que el orquestador avise (RF-13).
El nucleo es testeable inyectando `_ia` (funcion prompt->texto) sin tocar la red.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Callable
import requests
from . import config
_SYSTEM = (
"Eres el asistente de estado de un jefe de desarrollo. Recibes las novedades del dia "
"(mensajes de Telegram, correos y tickets de Jira DS). Devuelve SOLO un JSON valido, sin "
"texto adicional, con esta forma: "
'{"resumen_md": "<resumen en espanol, breve, indicando fuente y autor>", '
'"requiere_atencion": [{"fuente": "...", "quien": "...", "texto": "...", "motivo": "..."}]}. '
"Marca en requiere_atencion solo incidencias, bloqueos, errores, paros, solicitudes urgentes, "
"tickets sin asignar o cambios de estado relevantes. Si no hay nada relevante, deja la lista vacia."
)
def _texto_novedad(fuente: str, item) -> str:
"""Convierte un elemento de una fuente en una linea legible para el prompt."""
if not isinstance(item, dict):
return f"[{fuente}] {item}"
if fuente.startswith("telegram"):
return f"[{fuente}] {item.get('quien', '?')}: {item.get('texto', '')}"
if fuente == "correo":
return f"[correo] {item.get('de', '?')}: {item.get('asunto', '')} — {item.get('snippet', '')}"
if fuente == "jira":
return f"[jira] {item.get('key', '?')} ({item.get('estado', '')}): {item.get('resumen', '')}"
return f"[{fuente}] {item}"
def construir_prompt(novedades: dict, errores: list) -> str:
"""Texto del prompt a partir de las novedades y errores del ciclo."""
lineas: list[str] = []
for fuente, items in novedades.items():
if not items:
continue
lineas.append(f"## {fuente}")
for it in items:
lineas.append("- " + _texto_novedad(fuente, it))
if errores:
lineas.append("## errores de recogida")
for fuente, err in errores:
lineas.append(f"- [{fuente}] {err}")
return "\n".join(lineas)
def _extraer_json(texto: str) -> dict:
"""Extrae el primer objeto JSON del texto (tolerando fences de markdown)."""
limpio = re.sub(r"^```(?:json)?|```$", "", texto.strip(), flags=re.MULTILINE).strip()
inicio = limpio.find("{")
fin = limpio.rfind("}")
if inicio == -1 or fin == -1:
raise ValueError("la IA no devolvio JSON")
return json.loads(limpio[inicio:fin + 1])
def _llamar_ia(prompt: str, cfg: dict) -> str:
"""Llama al gateway IA (OmniRoute, OpenAI-compatible) y devuelve el texto."""
ia = cfg.get("ia", {})
base_url = ia.get("base_url", "http://localhost:20128/v1").rstrip("/")
modelo = ia.get("modelo", "auto/fast")
headers = {"Content-Type": "application/json"}
api_key = ia.get("api_key")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": modelo,
"messages": [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
"stream": False,
}
r = requests.post(f"{base_url}/chat/completions", json=body, headers=headers, timeout=90)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
def generar(
novedades: dict,
errores: list,
cfg: dict | None = None,
_ia: Callable[[str], str] | None = None,
) -> dict:
"""Genera el resumen del ciclo.
Devuelve un dict con: generado, fuentes_con_novedad, errores, fallo_ia,
requiere_atencion[] y resumen_md.
"""
cfg = cfg or config.cargar()
fuentes_con_novedad = [k for k, v in novedades.items() if v]
resultado = {
"generado": datetime.now().isoformat(),
"fuentes_con_novedad": fuentes_con_novedad,
"errores": [{"fuente": f, "error": e} for f, e in errores],
"fallo_ia": False,
"requiere_atencion": [],
"resumen_md": "",
}
if not fuentes_con_novedad and not errores:
resultado["resumen_md"] = "Sin novedades."
return resultado
prompt = construir_prompt(novedades, errores)
try:
texto = _ia(prompt) if _ia is not None else _llamar_ia(prompt, cfg)
datos = _extraer_json(texto)
resultado["resumen_md"] = datos.get("resumen_md", "") or ""
atencion = datos.get("requiere_atencion", []) or []
resultado["requiere_atencion"] = atencion if isinstance(atencion, list) else []
except Exception as e: # RF-13: no perder lo recogido y dejar constancia
resultado["fallo_ia"] = True
resultado["resumen_md"] = f"No se pudo generar el resumen IA: {e}"
return resultado

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""vistos.py — Estado persistente de "ya visto" por fuente (RF-8, RF-23).
Guarda, por cada fuente, el conjunto de identificadores ya procesados para no
volver a contarlos ni a avisar por ellos en el siguiente ciclo. El estado se
persiste en `estado/monitor/visto/<clave>.json` y sobrevive a reinicios.
Los identificadores pueden ser enteros (Telegram: id de mensaje) o cadenas
(correo: id de mensaje de Gmail; Jira: clave del ticket). Internamente se
normalizan a texto.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Callable, Iterable
from . import config
VISTO_DIR: Path = config.MONITOR_DIR / "visto"
# Limite de ids guardados por fuente (ventana movil; evita ficheros infinitos).
MAX_IDS = 2000
def _nombre_fichero(clave: str) -> str:
"""Nombre de fichero seguro para la clave de la fuente."""
seguro = re.sub(r"[^A-Za-z0-9_.-]", "_", clave)
return f"{seguro}.json"
def _ruta(clave: str) -> Path:
return VISTO_DIR / _nombre_fichero(clave)
def _cargar(clave: str) -> dict:
"""Carga el estado de la fuente (lista de ids vistos)."""
ruta = _ruta(clave)
if ruta.exists():
try:
with open(ruta, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception:
pass
return {"ids_vistos": []}
def _guardar(clave: str, data: dict) -> None:
"""Persiste el estado de la fuente (recortando a MAX_IDS)."""
VISTO_DIR.mkdir(parents=True, exist_ok=True)
ids = data.get("ids_vistos", [])
data["ids_vistos"] = ids[-MAX_IDS:]
with open(_ruta(clave), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _norm(x) -> str:
return str(x)
def marcar(clave: str, ids: Iterable) -> None:
"""Añade ids al estado de la fuente y persiste."""
data = _cargar(clave)
vistos = set(data.get("ids_vistos", []))
nuevos = {_norm(i) for i in ids} - vistos
if not nuevos:
return
data["ids_vistos"] = list(vistos | nuevos)
_guardar(clave, data)
def filtrar_nuevos(
clave: str,
elementos: Iterable,
id_fn: Callable = lambda e: e,
marcar_automatico: bool = True,
) -> list:
"""Devuelve los elementos cuyo id no estaba visto.
Si `marcar_automatico` es True (por defecto), los ids nuevos quedan marcados
en el mismo paso, de modo que el siguiente ciclo no los repita.
"""
elementos = list(elementos)
vistos = set(_cargar(clave).get("ids_vistos", []))
nuevos = [e for e in elementos if _norm(id_fn(e)) not in vistos]
if marcar_automatico and nuevos:
data = _cargar(clave)
data["ids_vistos"] = list(vistos | {_norm(id_fn(e)) for e in nuevos})
_guardar(clave, data)
return nuevos

View File

@@ -0,0 +1,59 @@
# monitor_estado_watchdog.ps1 — Asegura que el Monitor de estado esta corriendo (una instancia).
#
# Uso:
# powershell -ExecutionPolicy Bypass -File monitor_estado_watchdog.ps1 -Once # lanza si no corre
# powershell -ExecutionPolicy Bypass -File monitor_estado_watchdog.ps1 -Stop # detiene el monitor
#
# Lo llaman: la tarea programada al iniciar sesion y el plugin global de OpenCode
# (monitor-estado-bootstrap.ts). El propio monitor tiene ademas un lock (lock.py),
# asi que aunque ambos coincidan no habra dos instancias.
param(
[switch]$Once,
[switch]$Stop
)
$ErrorActionPreference = 'SilentlyContinue'
$ScriptsDir = $PSScriptRoot
$Repo = Split-Path -Parent $ScriptsDir
$Script = Join-Path $ScriptsDir 'monitor_estado.py'
$LogDir = Join-Path $Repo 'estado\monitor'
$OutLog = Join-Path $LogDir 'monitor_estado.out.log'
$ErrLog = Join-Path $LogDir 'monitor_estado.err.log'
$LockFile = Join-Path $LogDir '.lock'
if (-not (Test-Path $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null }
function Get-MonitorProcesos {
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -match 'monitor_estado\.py\s+daemon' }
}
if ($Stop) {
$procs = Get-MonitorProcesos
foreach ($p in $procs) { Stop-Process -Id $p.ProcessId -Force }
Remove-Item -LiteralPath $LockFile -Force -ErrorAction SilentlyContinue
Write-Output "[monitor_estado] Detenido ($($procs.Count) proceso(s))."
exit 0
}
$procs = Get-MonitorProcesos
if ($procs) {
Write-Output "[monitor_estado] Ya en marcha ($($procs.Count) proceso). Nada que hacer."
exit 0
}
# Lanzar el daemon en segundo plano, oculto y con log.
$python = (Get-Command python).Source
if (-not $python) { Write-Output "[monitor_estado] Python no encontrado en PATH."; exit 1 }
Start-Process -FilePath $python `
-ArgumentList @('-u', $Script, 'daemon') `
-WorkingDirectory $Repo `
-WindowStyle Hidden `
-RedirectStandardOutput $OutLog `
-RedirectStandardError $ErrLog
Write-Output "[monitor_estado] Daemon lanzado (log: $OutLog)."
exit 0

View File

@@ -20,7 +20,7 @@ def check_homeassistant():
"""Verifica si Home Assistant esta corriendo en el VPS""" """Verifica si Home Assistant esta corriendo en el VPS"""
try: try:
result = subprocess.run( result = subprocess.run(
["ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo", ["ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
"root@185.187.169.109", "root@185.187.169.109",
"docker ps | grep homeassistant | grep -q Up && echo OK || echo NOK"], "docker ps | grep homeassistant | grep -q Up && echo OK || echo NOK"],
capture_output=True, capture_output=True,
@@ -49,7 +49,7 @@ Add-Type -AssemblyName System.Windows.Forms
try: try:
subprocess.run([ subprocess.run([
"python", "python",
r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\scripts\aviso_sonoro.py", os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/scripts/aviso_sonoro.py"),
"ok" "ok"
]) ])
except Exception: except Exception:

View File

@@ -1,16 +1,26 @@
import os import os
import sys import sys
import time import base64
import subprocess import subprocess
import urllib.request
from dotenv import load_dotenv from dotenv import load_dotenv
# Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo) # Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo)
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
# Permitir importar modulos del repo de negocio (ha_alexa).
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
try:
import infra_paths
infra_paths.bootstrap_biblioteca_paths()
except Exception:
pass
def cerrar_ventanas(): def cerrar_ventanas():
print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...") print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...", flush=True)
ps_command = """ ps_command = """
$procesos = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.ProcessName -notmatch 'OpenCode|WindowsTerminal|cmd|powershell|Code|conhost' } $procesos = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.ProcessName -notmatch 'OpenCode|WindowsTerminal|cmd|powershell|Code|conhost' }
foreach ($p in $procesos) { foreach ($p in $procesos) {
@@ -22,54 +32,68 @@ def cerrar_ventanas():
} }
""" """
subprocess.run(["powershell", "-Command", ps_command], capture_output=True) subprocess.run(["powershell", "-Command", ps_command], capture_output=True)
print(" -> Ventanas cerradas.") print(" -> Ventanas cerradas.", flush=True)
def apagar_luces_alexa(): def apagar_luces_alexa():
"""Apaga las luces 'barras' y 'cuartillo' via VoiceMonkey""" """Apaga las luces 'barras' y 'cuartillo' via HOME ASSISTANT (sustituye a VoiceMonkey)."""
print("2. Apagando luces de Alexa (barras + cuartillo)...") print("2. Apagando luces de Alexa via Home Assistant (barras + cuartillo)...", flush=True)
token = os.getenv("VOICEMONKEY_TOKEN")
if not token:
print(" -> [ERROR] Falta VOICEMONKEY_TOKEN en .env")
return
# VoiceMonkey: crear dos "monkeys" (triggers virtuales) llamados 'apagar-barras' y 'apagar-cuartillo'
# que en su rutina de Alexa ejecutan: "Alexa, apaga barras" y "Alexa, apaga cuartillo"
luces = [
("barras", "apagar-barras"),
("cuartillo", "apagar-cuartillo")
]
for nombre, monkey in luces:
url = f"https://api-v2.voicemonkey.io/trigger?token={token}&device={monkey}"
try: try:
req = urllib.request.Request(url) from ha_alexa import command
with urllib.request.urlopen(req, timeout=10) as response:
if response.status == 200:
print(f" -> Señal enviada para apagar '{nombre}'")
else:
print(f" -> Error HTTP {response.status} al apagar '{nombre}'")
except Exception as e: except Exception as e:
print(f" -> Error al apagar '{nombre}': {e}") print(f" -> [ERROR] No se pudo cargar ha_alexa: {e}", flush=True)
return
for nombre in ("barras", "cuartillo"):
ok = command(f"apaga {nombre}")
print(f" -> {'OK' if ok else 'ERROR'} al apagar '{nombre}'", flush=True)
def apagar_pantalla_tapo(): def apagar_pantalla_tapo():
print("3. Apagando pantalla (Tapo) via túnel RPi...") """Apaga el enchufe Tapo de la pantalla via cadena local -> VPS -> RPi.
# Ejecutamos el apagado del enchufe enviando el comando a través del VPS a la RPi
cmd = '''ssh -i C:\\Users\\juanm\\Documents\\GitHub\\contabo root@185.187.169.109 "ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \\\"from PyP100 import PyP100; p100 = PyP100.P100(\\'192.168.50.224\\', \\'hermes211@gmail.com\\', \\'Nelo@36666\\'); p100.handshake(); p100.login(); p100.turnOff()\\\"'"''' FIX 03-oct-2026: la clave SSH del VPS estaba hardcodeada a C:\\Users\\juanm (perfil
antiguo); ahora es dinamica (~/Documents/Github/contabo). Si no existe o el VPS no
responde, se omite rapido en vez de colgarse 15 s. El codigo del RPi va en base64
para evitar problemas de anidado de comillas.
"""
print("3. Apagando pantalla (Tapo) via tunel RPi...", flush=True)
key = os.path.expanduser("~/Documents/Github/contabo")
if not os.path.exists(key):
print(f" -> [SKIP] No existe la clave SSH del VPS: {key}", flush=True)
return
pycode = (
"from PyP100 import PyP100\n"
"p100 = PyP100.P100('192.168.50.224', 'hermes211@gmail.com', 'Nelo@36666')\n"
"p100.handshake()\n"
"p100.login()\n"
"p100.turnOff()\n"
)
b64 = base64.b64encode(pycode.encode("utf-8")).decode("ascii")
cmd = (
f'ssh -i "{key}" -o BatchMode=yes -o ConnectTimeout=8 root@185.187.169.109 '
f'"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no -o ConnectTimeout=8 '
f"pi@127.0.0.1 'echo {b64} | base64 -d | python3'\""
)
try: try:
subprocess.run(cmd, shell=True, capture_output=True, timeout=15) r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=25)
print(" -> Pantalla apagada.") if r.returncode == 0:
print(" -> Pantalla apagada.", flush=True)
else:
detalle = (r.stderr or r.stdout or "").strip().replace("\n", " ")[:180]
print(f" -> [SKIP] No se pudo apagar la pantalla (rc={r.returncode}): {detalle}", flush=True)
except Exception as e: except Exception as e:
print(f" -> Error al apagar pantalla: {e}") print(f" -> [SKIP] Error al apagar pantalla: {e}", flush=True)
def shutdown_pc(): def shutdown_pc():
print("4. Apagando el PC en 10 segundos...") print("4. Apagando el PC en 10 segundos...", flush=True)
os.system("shutdown /s /t 10") os.system("shutdown /s /t 10")
if __name__ == "__main__": if __name__ == "__main__":
print("=== INICIANDO PROTOCOLO DE APAGADO ===") print("=== INICIANDO PROTOCOLO DE APAGADO ===", flush=True)
cerrar_ventanas() cerrar_ventanas()
apagar_luces_alexa() apagar_luces_alexa()
apagar_pantalla_tapo() apagar_pantalla_tapo()
shutdown_pc() shutdown_pc()
print("=== PROTOCOLO COMPLETADO ===") print("=== PROTOCOLO COMPLETADO ===", flush=True)

View File

@@ -23,7 +23,7 @@ if sys.platform == 'win32':
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo) # Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo)
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def cerrar_ventanas(): def cerrar_ventanas():
@@ -101,7 +101,7 @@ p100.turnOff()"""
# Comando SSH en dos saltos: local -> VPS -> RPi # Comando SSH en dos saltos: local -> VPS -> RPi
cmd = [ cmd = [
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo", "ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
"root@185.187.169.109", "root@185.187.169.109",
f"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \"{tapo_script}\"'" f"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \"{tapo_script}\"'"
] ]

View File

@@ -23,7 +23,7 @@ if sys.platform == 'win32':
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo) # Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo)
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def cerrar_ventanas(): def cerrar_ventanas():
@@ -101,14 +101,14 @@ p100.turnOff()"""
# Paso 1: Crear script en VPS # Paso 1: Crear script en VPS
cmd1 = [ cmd1 = [
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo", "ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
"root@185.187.169.109", "root@185.187.169.109",
f"echo '{script_content}' > /tmp/tapo_off.py" f"echo '{script_content}' > /tmp/tapo_off.py"
] ]
# Paso 2: Copiar a RPi y ejecutar # Paso 2: Copiar a RPi y ejecutar
cmd2 = [ cmd2 = [
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo", "ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
"root@185.187.169.109", "root@185.187.169.109",
"scp -i /root/.ssh/pi_deploy -P 2222 -o StrictHostKeyChecking=no /tmp/tapo_off.py pi@127.0.0.1:/tmp/ && ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 /tmp/tapo_off.py'" "scp -i /root/.ssh/pi_deploy -P 2222 -o StrictHostKeyChecking=no /tmp/tapo_off.py pi@127.0.0.1:/tmp/ && ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 /tmp/tapo_off.py'"
] ]

View File

@@ -23,7 +23,7 @@ if sys.platform == 'win32':
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo) # Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo)
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def cerrar_ventanas(): def cerrar_ventanas():
@@ -90,7 +90,7 @@ p100.turnOff()"""
# Comando SSH en dos saltos: local -> VPS -> RPi # Comando SSH en dos saltos: local -> VPS -> RPi
cmd = [ cmd = [
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo", "ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
"root@185.187.169.109", "root@185.187.169.109",
f"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \"{tapo_script}\"'" f"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \"{tapo_script}\"'"
] ]

View File

@@ -3,7 +3,7 @@ Protocolo de apagado completo del PC (VERSION DE PRUEBA - SIN SHUTDOWN)
Pasos: Pasos:
1. Cerrar ventanas abiertas 1. Cerrar ventanas abiertas
2. Apagar luces Alexa: "barras" y "cuartillo" (via VoiceMonkey) 2. Apagar luces Alexa: "barras" y "cuartillo" (via Home Assistant)
3. Apagar pantalla (Tapo via tunel RPi) 3. Apagar pantalla (Tapo via tunel RPi)
4. [COMENTADO] Shutdown del PC 4. [COMENTADO] Shutdown del PC
@@ -24,9 +24,19 @@ if sys.platform == 'win32':
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo) # Cargar variables de entorno locales (.env de biblioteca_negocio_prolongo)
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
# Permitir importar modulos del repo de negocio (ha_alexa).
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
sys.path.insert(0, _SCRIPT_DIR)
try:
import infra_paths
infra_paths.bootstrap_biblioteca_paths()
except Exception:
pass
def cerrar_ventanas(): def cerrar_ventanas():
"""Cierra todas las ventanas excepto terminal/OpenCode""" """Cierra todas las ventanas excepto terminal/OpenCode"""
print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...") print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...")
@@ -44,37 +54,16 @@ def cerrar_ventanas():
print(" ✓ Ventanas cerradas.") print(" ✓ Ventanas cerradas.")
def apagar_luces_alexa(): def apagar_luces_alexa():
"""Apaga las luces 'barras' y 'cuartillo' via VoiceMonkey""" """Apaga las luces 'barras' y 'cuartillo' via HOME ASSISTANT (sustituye a VoiceMonkey)."""
print("2. Apagando luces de Alexa (barras + cuartillo)...") print("2. Apagando luces de Alexa via Home Assistant (barras + cuartillo)...")
token = os.getenv("VOICEMONKEY_TOKEN")
if not token:
print(" ✗ [ERROR] Falta VOICEMONKEY_TOKEN en .env")
return
# VoiceMonkey: crear dos "monkeys" (triggers virtuales) llamados 'apagar-barras' y 'apagar-cuartillo'
# que en su rutina de Alexa ejecutan: "Alexa, apaga barras" y "Alexa, apaga cuartillo"
luces = [
("barras", "apagar-barras"),
("cuartillo", "apagar-cuartillo")
]
for nombre, monkey in luces:
url = f"https://api-v2.voicemonkey.io/trigger?token={token}&device={monkey}"
try: try:
req = urllib.request.Request(url) from ha_alexa import command
with urllib.request.urlopen(req, timeout=10) as response:
if response.status == 200:
print(f" ✓ Señal enviada para apagar '{nombre}'")
else:
print(f" ✗ Error HTTP {response.status} al apagar '{nombre}'")
except Exception as e: except Exception as e:
print(f" ✗ Error al apagar '{nombre}': {e}") print(f" ✗ [ERROR] No se pudo cargar ha_alexa: {e}")
return
print(" ℹ️ Si las luces no se apagaron, verifica que existan los monkeys:") for nombre in ("barras", "cuartillo"):
print(" - 'apagar-barras' → rutina Alexa: 'Alexa, apaga barras'") ok = command(f"apaga {nombre}")
print(" - 'apagar-cuartillo' → rutina Alexa: 'Alexa, apaga cuartillo'") print(f" {'✓' if ok else '✗'} apagar '{nombre}'")
print(" en https://voicemonkey.io/")
def apagar_pantalla_tapo(): def apagar_pantalla_tapo():
"""Apaga el enchufe Tapo de la pantalla via tunel RPi""" """Apaga el enchufe Tapo de la pantalla via tunel RPi"""
@@ -89,7 +78,7 @@ p100.turnOff()"""
# Comando SSH en dos saltos: local -> VPS -> RPi # Comando SSH en dos saltos: local -> VPS -> RPi
cmd = [ cmd = [
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo", "ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
"root@185.187.169.109", "root@185.187.169.109",
f"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \"{tapo_script}\"'" f"ssh -i /root/.ssh/pi_deploy -p 2222 -o StrictHostKeyChecking=no pi@127.0.0.1 'python3 -c \"{tapo_script}\"'"
] ]

View File

@@ -12,7 +12,7 @@ from flask import Flask, request, jsonify
from dotenv import load_dotenv from dotenv import load_dotenv
# Cargar .env # Cargar .env
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
app = Flask(__name__) app = Flask(__name__)

21
scripts/ssh_sobremesa.ps1 Normal file
View File

@@ -0,0 +1,21 @@
<#
.SYNOPSIS
Abre una sesion PowerShell remota en el sobremesa por SSH (sobre Tailscale).
.DESCRIPTION
Se conecta a `juanjo@100.75.115.29` (sobremesa, DESKTOP-M8SE61G) y cae en su
PowerShell. Requiere que el sobremesa este encendido (ver
`scripts/encender_sobremesa.py`) y Tailscale activo en ambos equipos.
.EXAMPLE
powershell -ExecutionPolicy Bypass -File scripts\ssh_sobremesa.ps1
#>
param(
[string]$Maquina = "100.75.115.29",
[string]$Usuario = "juanjo"
)
$ErrorActionPreference = 'Stop'
Write-Host "Conectando a PowerShell del sobremesa ($Usuario@$Maquina)..." -ForegroundColor Cyan
Write-Host "Para salir: escribe 'exit'." -ForegroundColor DarkGray
ssh "$Usuario@$Maquina"

View File

@@ -0,0 +1,40 @@
; superwhisper_enter.ahk
; F13 (botón auxiliar ratón Logitech) → espera a que Super Whisper
; termine de pegar, pulsa Enter y devuelve el foco a OpenCode.
;
; Flujo:
; 1. Rueda central → inicia/para transcripción Super Whisper
; 2. F13 (botón auxiliar) → Enter automático + vuelve a OpenCode
#Requires AutoHotkey v2.0
F13::
{
; Guardar ventana activa actual (OpenCode u otra)
ventanaActual := WinExist("A")
; Guardar portapapeles actual
clipAntes := A_Clipboard
; Esperar hasta 8 segundos a que Super Whisper pegue
limite := 80
i := 0
loop {
Sleep 100
i++
if (A_Clipboard != clipAntes)
break
if (i >= limite)
return ; timeout, no pasó nada
}
; Pausa mínima para que el Ctrl+V termine
Sleep 150
; Pulsar Enter
Send "{Enter}"
; Volver a OpenCode si estaba activo antes
if (ventanaActual)
WinActivate("ahk_id " ventanaActual)
}

View File

@@ -20,7 +20,7 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes
# Cargar .env # Cargar .env
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
# Configuración # Configuración

View File

@@ -17,7 +17,7 @@ if sys.platform == 'win32':
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def enviar_comando_voz(echo_entity, comando): def enviar_comando_voz(echo_entity, comando):

View File

@@ -20,7 +20,7 @@ if sys.platform == 'win32':
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
# Cargar .env # Cargar .env
dotenv_path = r"C:\Users\juanm\Documents\GitHub\biblioteca_negocio_prolongo\.env" dotenv_path = os.path.expanduser("~/Documents/GitHub/biblioteca_negocio_prolongo/.env")
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
def main(): def main():

110
scripts/voz_mensaje.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""voz_mensaje.py — Enrutador de voz para los mensajes del asistente (OpenCode).
Genera y reproduce un mensaje hablado con VoiceStudio (http://127.0.0.1:3900)
eligiendo el perfil de voz segun el TIPO de mensaje:
default -> Carolina (f40515ca) Mensajes trascendentes de OpenCode (por defecto)
biblioteca -> Juanjo (4f18d2df) Mensajes de la biblioteca Prolongo
critico -> Abuelo (e0ecda0a) Mensajes importantes / criticos
Todas las voces son NATIVAS EN CASTELLANO (language=es). Perfiles en VoiceStudio.
Uso:
python voz_mensaje.py "texto a decir" [--tipo default|biblioteca|critico]
[--idioma es] [--no-play]
Proyecto: INFRAESTRUCTURA. Perfiles de voz locales de VoiceStudio.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
BASE = os.environ.get("VOICESTUDIO_BASE", "http://127.0.0.1:3900")
# tipo -> (profile_id, nombre)
PROFILES = {
"default": ("f40515ca", "Carolina"),
"biblioteca": ("4f18d2df", "Juanjo"),
"critico": ("e0ecda0a", "Abuelo"),
}
def _get_profile(pid: str) -> dict:
"""Metadatos del perfil (para reutilizar su ref_text al generar)."""
try:
with urllib.request.urlopen(f"{BASE}/profiles/{pid}", timeout=30) as r:
return json.loads(r.read())
except Exception:
return {}
def generar(texto: str, tipo: str = "default", idioma: str = "es") -> str:
"""Sintetiza `texto` con el perfil del `tipo` y devuelve la ruta del WAV."""
pid, nombre = PROFILES.get(tipo, PROFILES["default"])
prof = _get_profile(pid)
campos = {
"text": texto,
"language": idioma,
"profile_id": pid,
"stream": "false",
}
# Los perfiles clone necesitan su transcripcion de referencia para generar.
if prof.get("ref_text"):
campos["ref_text"] = prof["ref_text"]
data = urllib.parse.urlencode(campos).encode()
req = urllib.request.Request(
f"{BASE}/generate", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=300) as r:
wav = r.read()
out_dir = os.path.join(os.path.expanduser("~"), "Documents", "voz_mensajes")
os.makedirs(out_dir, exist_ok=True)
out = os.path.join(out_dir, f"msg_{tipo}.wav")
with open(out, "wb") as f:
f.write(wav)
print(f"[voz_mensaje] {nombre} ({tipo}) -> {out}")
return out
def reproducir(path: str) -> None:
"""Reproduce el WAV de forma sincrona (Windows)."""
import winsound
winsound.PlaySound(path, winsound.SND_FILENAME)
def main() -> int:
ap = argparse.ArgumentParser(description="Enrutador de voz para mensajes del asistente.")
ap.add_argument("texto", help="Texto a decir")
ap.add_argument("--tipo", choices=list(PROFILES), default="default")
ap.add_argument("--idioma", default="es")
ap.add_argument("--no-play", action="store_true", help="Solo generar, no reproducir")
args = ap.parse_args()
try:
path = generar(args.texto, args.tipo, args.idioma)
except Exception as e: # noqa: BLE001
print(f"[voz_mensaje] ERROR: {e}", file=sys.stderr)
return 1
if not args.no_play:
try:
reproducir(path)
except Exception as e: # noqa: BLE001
print(f"[voz_mensaje] ERROR reproduciendo: {e}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

13
tests/conftest.py Normal file
View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
"""conftest.py — Deja importable el paquete `monitor_estado` en los tests.
Anade INFRAESTRUCTURA/scripts al sys.path para poder hacer
`import monitor_estado.<modulo>` sin instalar el paquete.
"""
import sys
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))

73
tests/test_archivo.py Normal file
View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.archivo` — ficheros del dia, reescritura y poda (RF-5,7,9,10,18)."""
from datetime import date, timedelta
import monitor_estado.archivo as archivo
def _render(m):
return f"- **{m['id']}**: {m['t']}"
def test_archivo_reescribe_sin_duplicados(tmp_path, monkeypatch):
"""El fichero del dia contiene todo lo de hoy y nada duplicado (RF-9)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
hoy = date(2026, 10, 3)
p = archivo.escribe_dia(
"telegram/test", hoy,
[{"id": 1, "t": "a"}, {"id": 2, "t": "b"}, {"id": 2, "t": "b"}],
id_fn=lambda m: m["id"], render_fn=_render,
)
contenido = p.read_text(encoding="utf-8")
assert contenido.count("- **1**") == 1
assert contenido.count("- **2**") == 1
# Nuevo ciclo con lo del dia completo: el fichero se reescribe con el estado actual.
archivo.escribe_dia(
"telegram/test", hoy,
[{"id": 1, "t": "a"}, {"id": 2, "t": "b"}, {"id": 3, "t": "c"}],
id_fn=lambda m: m["id"], render_fn=_render,
)
contenido = p.read_text(encoding="utf-8")
assert "- **3**" in contenido
assert contenido.count("- **1**") == 1
def test_archivo_poda_30_dias_y_conserva_recientes(tmp_path, monkeypatch):
"""Se borran los ficheros de mas de 30 dias y se conservan los recientes (RF-10)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
hoy = date(2026, 10, 3)
reciente = tmp_path / "telegram" / "test"
reciente.mkdir(parents=True)
f_hoy = reciente / f"{hoy.isoformat()}.md"
f_10 = reciente / f"{(hoy - timedelta(days=10)).isoformat()}.md"
f_40 = reciente / f"{(hoy - timedelta(days=40)).isoformat()}.md"
for f in (f_hoy, f_10, f_40):
f.write_text("x", encoding="utf-8")
borrados = archivo.poda(retencion_dias=30, hoy=hoy)
assert f_hoy.exists() and f_10.exists()
assert not f_40.exists()
assert f_40 in borrados
def test_escribe_resumen(tmp_path, monkeypatch):
"""El resumen se guarda en md y json consultables (RF-18)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
resumen = {"resumen_md": "Todo ok", "requiere_atencion": [], "errores": []}
archivo.escribe_resumen(resumen)
assert (tmp_path / "resumen_actual.md").read_text(encoding="utf-8") == "Todo ok"
assert '"resumen_md": "Todo ok"' in (tmp_path / "resumen_actual.json").read_text(encoding="utf-8")
def test_escribe_dia_de_fuente_sin_novedad(tmp_path, monkeypatch):
"""Una fuente sin elementos genera un fichero vacio (no falla) (RF-5)."""
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
p = archivo.escribe_dia(
"correo", date(2026, 10, 3), [], id_fn=lambda m: m, render_fn=lambda m: str(m)
)
assert p.exists()

32
tests/test_aviso.py Normal file
View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.aviso` — voz solo si requiere atencion (RF-14, RF-15)."""
import monitor_estado.aviso as aviso
def test_aviso_dispara_con_atencion():
llamadas = []
r = {"fallo_ia": False, "requiere_atencion": [{"fuente": "jira", "texto": "caido"}]}
assert aviso.avisar(r, cfg={"aviso": {"voz": True}}, _runner=lambda t: llamadas.append(t)) is True
assert llamadas == ["fin"]
def test_ciclo_sin_novedad_no_avisa():
llamadas = []
r = {"fallo_ia": False, "requiere_atencion": [], "resumen_md": "Sin novedades."}
assert aviso.avisar(r, cfg={"aviso": {"voz": True}}, _runner=lambda t: llamadas.append(t)) is False
assert llamadas == []
def test_aviso_dispara_con_fallo_ia():
llamadas = []
r = {"fallo_ia": True, "requiere_atencion": []}
assert aviso.avisar(r, cfg={"aviso": {"voz": True}}, _runner=lambda t: llamadas.append(t)) is True
assert llamadas == ["error"]
def test_aviso_desactivado_por_config():
llamadas = []
r = {"fallo_ia": False, "requiere_atencion": [{"texto": "x"}]}
assert aviso.avisar(r, cfg={"aviso": {"voz": False}}, _runner=lambda t: llamadas.append(t)) is False
assert llamadas == []

81
tests/test_ciclo.py Normal file
View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.ciclo` — aislamiento de fuentes y ciclo completo (RF-6, RF-9)."""
from datetime import date
import monitor_estado.archivo as archivo
import monitor_estado.ciclo as ciclo
import monitor_estado.vistos as vistos
class _EmptyJira:
def search(self, jql, fields=None, max_results=50):
return {"issues": []}
def _preparar_tmp(tmp_path, monkeypatch):
monkeypatch.setattr(archivo, "MONITOR_DIR", tmp_path)
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path / "visto")
def test_aislamiento_fuente_fallida_continua(tmp_path, monkeypatch):
"""Si todas las fuentes fallan, el ciclo termina y registra los errores (RF-6)."""
_preparar_tmp(tmp_path, monkeypatch)
def bad_tg(cfg, hoy):
raise RuntimeError("telegram caido")
def bad_correo(horas=24, limite=50):
raise RuntimeError("gmail caido")
class BadJira:
def search(self, *a, **k):
raise RuntimeError("jira caido")
res = ciclo.ejecutar(
cfg={"retencion_dias": 30, "correo": {"horas": 24}},
deps={
"telegram_recoger": bad_tg,
"correo_recoger": bad_correo,
"jira_cliente": BadJira(),
"ia": lambda p: '{"resumen_md": "x", "requiere_atencion": []}',
"avisar": lambda r, cfg=None, _runner=None: False,
},
)
assert len(res["errores"]) == 3
assert res["fallo_ia"] is False
assert (tmp_path / "ultimo_ciclo.json").exists()
def test_ciclo_archiva_y_avisa(tmp_path, monkeypatch):
"""Un ciclo con novedad archiva el dia y avisa si hay atencion (RF-9, RF-14)."""
_preparar_tmp(tmp_path, monkeypatch)
emitidos = []
def tg(cfg, hoy):
return ({"oscar_rodriguez": [{"id": 1, "fecha": "2026-10-03T09:00:00", "quien": "Oscar", "texto": "incidencia"}]}, [])
def correo(horas=24, limite=50):
return []
def ia(prompt):
return '{"resumen_md": "hay incidencia", "requiere_atencion": [{"fuente": "telegram", "texto": "incidencia"}]}'
def avisar(res, cfg=None, _runner=None):
emitidos.append(res.get("requiere_atencion"))
return True
res = ciclo.ejecutar(
cfg={"retencion_dias": 30, "correo": {"horas": 24}},
hoy=date(2026, 10, 3),
deps={
"telegram_recoger": tg,
"correo_recoger": correo,
"jira_cliente": _EmptyJira(),
"ia": ia,
"avisar": avisar,
},
)
assert (tmp_path / "telegram" / "oscar_rodriguez" / "2026-10-03.md").exists()
assert res["aviso_emitido"] is True
assert emitidos and emitidos[0]

74
tests/test_correo.py Normal file
View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.fuentes.correo` — ventana 24h y solo lectura (RF-2, RF-25)."""
import monitor_estado.fuentes.correo as correo
class _Req:
def __init__(self, resp):
self._resp = resp
def execute(self):
return self._resp
class _Messages:
def __init__(self):
self.queries = []
self.gets = []
self.modificados = []
def list(self, userId, q, maxResults):
self.queries.append(q)
return _Req({"messages": [{"id": "m1"}, {"id": "m2"}]})
def get(self, userId, id, format, metadataHeaders):
self.gets.append(id)
return _Req({
"snippet": f"resumen de {id}",
"payload": {"headers": [
{"name": "Subject", "value": f"Asunto {id}"},
{"name": "From", "value": "Remitente <r@b.com>"},
{"name": "Date", "value": "Sat, 3 Oct 2026 09:00:00 +0200"},
]},
})
def modify(self, **kwargs): # no debe llamarse nunca (RF-25)
self.modificados.append(kwargs)
return _Req({})
class _Users:
def __init__(self):
self._m = _Messages()
def messages(self):
return self._m
class FakeGmail:
def __init__(self):
self._u = _Users()
def users(self):
return self._u
def test_correo_ventana_24h():
gmail = FakeGmail()
correos = correo.recoger_recientes(gmail, horas=24)
assert len(correos) == 2
assert correos[0]["id"] == "m1"
assert correos[0]["asunto"] == "Asunto m1"
assert "newer_than:1d" in gmail.users().messages().queries[0]
def test_correo_no_modifica_estado():
gmail = FakeGmail()
correo.recoger_recientes(gmail, horas=24)
assert gmail.users().messages().modificados == [] # no marca leido (RF-25)
def test_render_correo():
txt = correo.render_correo({"fecha": "Sat, 3 Oct 2026 09:00:00 +0200", "de": "a@b.com", "asunto": "Hola", "snippet": "cuerpo"})
assert "a@b.com" in txt and "Hola" in txt

58
tests/test_jira.py Normal file
View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.fuentes.jira` — DS nuevos y sin asignar, solo GET (RF-3, RF-4, RF-16)."""
import monitor_estado.fuentes.jira as jira
def _issue(key, summary, status="Pendiente", created="2026-10-03T08:00:00.000+0200"):
return {"key": key, "fields": {
"summary": summary,
"status": {"name": status},
"created": created,
"priority": {"name": "Media"},
}}
class FakeJira:
"""Solo implementa `search` (lectura). Si el codigo intentara escribir, fallaria."""
def __init__(self):
self.calls = []
def search(self, jql, fields=None, max_results=50):
self.calls.append(jql)
if "assignee = EMPTY" in jql:
issues = [_issue("DS-100", "Ticket sin asignar")]
elif "created >=" in jql:
issues = [_issue("DS-200", "Ticket nuevo")]
else:
issues = []
return {"issues": issues}
def test_jira_jql_ds_sin_asignar():
fake = FakeJira()
out = jira.ds_sin_asignar(fake)
assert any("assignee = EMPTY" in c and "project = DS" in c for c in fake.calls)
assert out[0]["key"] == "DS-100"
assert out[0]["estado"] == "Pendiente"
def test_jira_ds_nuevos_ventana():
fake = FakeJira()
out = jira.ds_nuevos(fake, "2026-10-03 00:00")
assert "created >=" in fake.calls[-1]
assert out[0]["key"] == "DS-200"
def test_jira_solo_lectura():
"""El cliente usado solo expone `search` (no hay escritura) — RF-16."""
fake = FakeJira()
jira.ds_sin_asignar(fake)
jira.ds_nuevos(fake, "2026-10-03 00:00")
assert not hasattr(fake, "create_issue") and not hasattr(fake, "post")
def test_render_jira():
txt = jira.render_jira({"key": "DS-1", "resumen": "Algo", "estado": "Pendiente", "url": "u"})
assert "DS-1" in txt and "Algo" in txt and "Pendiente" in txt

40
tests/test_lock.py Normal file
View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.lock` — instancia unica (RF-22)."""
import json
import monitor_estado.lock as lock
def test_lock_impide_segunda_instancia(tmp_path, monkeypatch):
"""Una segunda adquisicion con la primera viva debe fallar (RF-22)."""
monkeypatch.setattr(lock, "LOCK_PATH", tmp_path / ".lock")
assert lock.adquirir() is True
# Misma PID viva -> la segunda instancia no arranca.
assert lock.adquirir() is False
lock.liberar()
assert not (tmp_path / ".lock").exists()
assert lock.adquirir() is True
lock.liberar()
def test_lock_recupera_lock_caducado(tmp_path, monkeypatch):
"""Un lock de un PID muerto no debe bloquear (se recupera)."""
ruta = tmp_path / ".lock"
monkeypatch.setattr(lock, "LOCK_PATH", ruta)
ruta.write_text(json.dumps({"pid": 999999, "arranque": "2026-01-01T00:00:00"}), encoding="utf-8")
assert lock.adquirir() is True
lock.liberar()
def test_lock_liberar_no_borra_lock_ajeno(tmp_path, monkeypatch):
"""`liberar` solo elimina el lock si es de nuestro propio PID."""
ruta = tmp_path / ".lock"
monkeypatch.setattr(lock, "LOCK_PATH", ruta)
ruta.write_text(json.dumps({"pid": 999999, "arranque": "x"}), encoding="utf-8")
lock.liberar()
assert ruta.exists() # no era nuestro -> se respeta

45
tests/test_resumen.py Normal file
View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.resumen` — resumen IA y clasificacion (RF-11, RF-12, RF-13)."""
import monitor_estado.resumen as resumen
def test_resumen_clasifica_atencion():
def fake_ia(prompt):
return ('{"resumen_md": "Hay una incidencia de SQL.", '
'"requiere_atencion": [{"fuente": "jira", "quien": "DS-3648", '
'"texto": "PC526 sin conexion", "motivo": "ticket sin asignar"}]}')
r = resumen.generar({"jira": [{"key": "DS-3648", "resumen": "PC526 sin conexion"}]}, [], _ia=fake_ia)
assert r["fallo_ia"] is False
assert len(r["requiere_atencion"]) == 1
assert r["requiere_atencion"][0]["quien"] == "DS-3648"
assert "incidencia" in r["resumen_md"].lower()
def test_resumen_fallo_ia_deja_constancia():
def bad_ia(prompt):
raise RuntimeError("OmniRoute caido")
r = resumen.generar({"jira": [{"key": "DS-1", "resumen": "x"}]}, [("correo", "token caducado")], _ia=bad_ia)
assert r["fallo_ia"] is True
assert r["requiere_atencion"] == []
assert r["resumen_md"] # deja constancia del fallo
assert r["errores"] # conserva los errores del ciclo
def test_resumen_parsea_json_con_fences():
def fake_ia(prompt):
return '```json\n{"resumen_md": "ok", "requiere_atencion": []}\n```'
r = resumen.generar({"correo": [{"asunto": "hola"}]}, [], _ia=fake_ia)
assert r["fallo_ia"] is False and r["resumen_md"] == "ok"
def test_resumen_sin_novedades_no_llama_ia():
def no_llamar(prompt):
raise AssertionError("no deberia llamarse a la IA sin novedades")
r = resumen.generar({}, [], _ia=no_llamar)
assert r["fallo_ia"] is False
assert r["requiere_atencion"] == []

91
tests/test_telegram.py Normal file
View File

@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.fuentes.telegram` — nucleo y aislamiento (RF-1, RF-6)."""
from datetime import date, datetime, timezone
from types import SimpleNamespace
import monitor_estado.fuentes.telegram as tg
class FakeMsg:
"""Mensaje crudo minimo compatible con lo que espera normalizar/recoger."""
def __init__(self, id, dt, text, sender_id=1, sender=None):
self.id = id
self.date = dt
self.message = text
self.sender_id = sender_id
self._sender_name = sender
self._sender = sender or "Anon"
async def get_sender(self):
return SimpleNamespace(first_name=self._sender)
class FakeClient:
"""Cliente Telethon falso (sin red)."""
def __init__(self, por_id, malos=()):
self.por_id = por_id
self.malos = set(malos)
self.conectado = False
async def connect(self):
self.conectado = True
async def is_user_authorized(self):
return True
async def disconnect(self):
self.conectado = False
async def get_dialogs(self, limit=None):
return []
async def get_entity(self, cid):
if cid in self.malos:
raise RuntimeError("sin acceso a la fuente")
return cid
def iter_messages(self, entidad, limit=None):
async def gen():
for m in self.por_id.get(entidad, []):
yield m
return gen()
def test_normalizar_filtra_dia_y_ordena():
dia = date(2026, 10, 3)
m1 = FakeMsg(2, datetime(2026, 10, 3, 15, 0, tzinfo=timezone.utc), "segundo", sender="Ana")
m2 = FakeMsg(1, datetime(2026, 10, 3, 9, 0, tzinfo=timezone.utc), "primero", sender="Ana")
m3 = FakeMsg(3, datetime(2026, 10, 2, 9, 0, tzinfo=timezone.utc), "ayer", sender="Ana")
out = tg.normalizar_mensajes([m1, m2, m3], dia, "test")
assert [r["id"] for r in out] == [1, 2] # solo hoy y ordenado
assert out[0]["quien"] == "Ana" and out[0]["fuente"] == "test"
def test_recoger_fuente_para_en_el_dia():
dia = date(2026, 10, 3)
hoy = FakeMsg(5, datetime(2026, 10, 3, 12, 0, tzinfo=timezone.utc), "hoy", sender="Ana")
ayer = FakeMsg(4, datetime(2026, 10, 2, 12, 0, tzinfo=timezone.utc), "ayer", sender="Ana")
client = FakeClient({7: [hoy, ayer]})
import asyncio
out = asyncio.run(tg.recoger_fuente(client, "oscar", 7, dia))
assert [r["id"] for r in out] == [5]
def test_recoger_todas_aisla_fuente_caida():
dia = date(2026, 10, 3)
ok = FakeMsg(1, datetime(2026, 10, 3, 12, 0, tzinfo=timezone.utc), "hola", sender="Ana")
cfg = {"telegram": {"fuentes": [{"nombre": "buena", "id": 10}, {"nombre": "mala", "id": 20}]}}
client = FakeClient({10: [ok]}, malos={20})
resultado, errores = tg.recoger_todas(cfg, dia, client_factory=lambda c: client)
assert "buena" in resultado and len(resultado["buena"]) == 1
assert len(errores) == 1 and errores[0][0] == "mala"
def test_render_mensaje():
m = {"fecha": "2026-10-03T09:12:00+02:00", "quien": "Oscar", "texto": "linea1\nlinea2"}
txt = tg.render_mensaje(m)
assert "[09:12] Oscar" in txt and "linea1" in txt and "linea2" in txt

48
tests/test_vistos.py Normal file
View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""Tests de `monitor_estado.vistos` — dedup persistente (RF-8, RF-23)."""
import monitor_estado.vistos as vistos
def test_vistos_filtra_nuevos(tmp_path, monkeypatch):
"""Lo ya visto no se vuelve a contar; lo nuevo si (RF-8)."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
clave = "telegram_test"
msgs = [{"id": 1, "t": "a"}, {"id": 2, "t": "b"}]
nuevos = vistos.filtrar_nuevos(clave, msgs, id_fn=lambda m: m["id"])
assert [m["id"] for m in nuevos] == [1, 2]
# Segundo ciclo con los mismos mensajes -> nada nuevo.
assert vistos.filtrar_nuevos(clave, msgs, id_fn=lambda m: m["id"]) == []
# Llega uno nuevo -> solo ese.
mas = msgs + [{"id": 3, "t": "c"}]
assert [m["id"] for m in vistos.filtrar_nuevos(clave, mas, id_fn=lambda m: m["id"])] == [3]
def test_vistos_persiste_entre_llamadas(tmp_path, monkeypatch):
"""El estado se guarda en disco y sobrevive a una nueva lectura (RF-23)."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
clave = "correo"
vistos.filtrar_nuevos(clave, ["m1", "m2"], id_fn=lambda x: x)
assert (tmp_path / f"{clave}.json").exists()
# Nueva lectura del fichero: m1/m2 ya no son nuevos.
assert vistos.filtrar_nuevos(clave, ["m1", "m2"], id_fn=lambda x: x) == []
def test_vistos_marcar_explicito(tmp_path, monkeypatch):
"""`marcar` añade ids sin devolver nada."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
vistos.marcar("jira", ["DS-1", "DS-2"])
assert vistos.filtrar_nuevos("jira", ["DS-1"], id_fn=lambda x: x) == []
assert vistos.filtrar_nuevos("jira", ["DS-3"], id_fn=lambda x: x) == ["DS-3"]
def test_vistos_idempotente_sin_marcar(tmp_path, monkeypatch):
"""Con marcar_automatico=False, filtrar no altera el estado."""
monkeypatch.setattr(vistos, "VISTO_DIR", tmp_path)
a = vistos.filtrar_nuevos("k", [1, 2], id_fn=lambda x: x, marcar_automatico=False)
b = vistos.filtrar_nuevos("k", [1, 2], id_fn=lambda x: x, marcar_automatico=False)
assert a == [1, 2] and b == [1, 2]