Compare commits
30 Commits
2269a81d32
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0adad1e45e | ||
|
|
23d794f5a7 | ||
|
|
f484eaab38 | ||
|
|
cba0e1f931 | ||
|
|
dde5d97989 | ||
|
|
ce457261fb | ||
|
|
7f67560a0a | ||
|
|
0e784be152 | ||
|
|
cdeaec64ec | ||
|
|
a8cb3852b1 | ||
|
|
d5df0e3f05 | ||
|
|
80bd93f821 | ||
|
|
ec4dcb7a4c | ||
|
|
f470bb68f6 | ||
|
|
f243ce8f1d | ||
|
|
29157d4f08 | ||
|
|
0b4fe1cf7f | ||
|
|
c830c2ad47 | ||
|
|
52f1a0ed25 | ||
|
|
10b0bb357d | ||
|
|
a75211cd05 | ||
|
|
3f20313f6d | ||
|
|
4fa9c2ae5b | ||
|
|
34806538d5 | ||
|
|
5446db6a2b | ||
|
|
1b385cca7c | ||
|
|
fdd4ea877a | ||
|
|
f10ce5bef4 | ||
|
|
0579892b9e | ||
|
|
5fad171a97 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
84
docs/ACCESO_REMOTO_SOBREMESA.md
Normal file
84
docs/ACCESO_REMOTO_SOBREMESA.md
Normal 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.
|
||||||
69
docs/COMPARATIVA_EQUIPOS.md
Normal file
69
docs/COMPARATIVA_EQUIPOS.md
Normal 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).
|
||||||
55
docs/PROTOCOLO_ENCENDIDO_REMOTO_SSH.md
Normal file
55
docs/PROTOCOLO_ENCENDIDO_REMOTO_SSH.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
24
omniroute/clipboard_watch.ps1
Normal file
24
omniroute/clipboard_watch.ps1
Normal 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
|
||||||
|
}
|
||||||
@@ -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
90
omniroute/oauth_helper.py
Normal 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")
|
||||||
67
opencode/monitor-estado-bootstrap.ts
Normal file
67
opencode/monitor-estado-bootstrap.ts
Normal 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 {}
|
||||||
|
}
|
||||||
116
opencode/omniroute-autostart.ts
Normal file
116
opencode/omniroute-autostart.ts
Normal 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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
107
scripts/acceso_remoto/setup_ssh_server.ps1
Normal file
107
scripts/acceso_remoto/setup_ssh_server.ps1
Normal 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>"
|
||||||
60
scripts/acceso_remoto/setup_tailscale.ps1
Normal file
60
scripts/acceso_remoto/setup_tailscale.ps1
Normal 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)"
|
||||||
59
scripts/bootstrap_autologon_juanjo.ps1
Normal file
59
scripts/bootstrap_autologon_juanjo.ps1
Normal 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
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
190
scripts/encender_sobremesa.py
Normal file
190
scripts/encender_sobremesa.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
28
scripts/instalar_dependencias.ps1
Normal file
28
scripts/instalar_dependencias.ps1
Normal 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
|
||||||
@@ -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) -----------------
|
||||||
|
|||||||
@@ -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
215
scripts/monitor_estado.py
Normal 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())
|
||||||
58
scripts/monitor_estado/README.md
Normal file
58
scripts/monitor_estado/README.md
Normal 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).
|
||||||
11
scripts/monitor_estado/__init__.py
Normal file
11
scripts/monitor_estado/__init__.py
Normal 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"
|
||||||
122
scripts/monitor_estado/archivo.py
Normal file
122
scripts/monitor_estado/archivo.py
Normal 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 {}
|
||||||
53
scripts/monitor_estado/aviso.py
Normal file
53
scripts/monitor_estado/aviso.py
Normal 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
|
||||||
133
scripts/monitor_estado/ciclo.py
Normal file
133
scripts/monitor_estado/ciclo.py
Normal 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)
|
||||||
19
scripts/monitor_estado/config.example.json
Normal file
19
scripts/monitor_estado/config.example.json
Normal 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 }
|
||||||
|
}
|
||||||
117
scripts/monitor_estado/config.py
Normal file
117
scripts/monitor_estado/config.py
Normal 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())
|
||||||
2
scripts/monitor_estado/fuentes/__init__.py
Normal file
2
scripts/monitor_estado/fuentes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Fuentes del monitor de estado (Telegram, correo, Jira)."""
|
||||||
73
scripts/monitor_estado/fuentes/correo.py
Normal file
73
scripts/monitor_estado/fuentes/correo.py
Normal 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
|
||||||
94
scripts/monitor_estado/fuentes/jira.py
Normal file
94
scripts/monitor_estado/fuentes/jira.py
Normal 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}"
|
||||||
263
scripts/monitor_estado/fuentes/telegram.py
Normal file
263
scripts/monitor_estado/fuentes/telegram.py
Normal 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)
|
||||||
91
scripts/monitor_estado/lock.py
Normal file
91
scripts/monitor_estado/lock.py
Normal 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))
|
||||||
134
scripts/monitor_estado/resumen.py
Normal file
134
scripts/monitor_estado/resumen.py
Normal 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
|
||||||
94
scripts/monitor_estado/vistos.py
Normal file
94
scripts/monitor_estado/vistos.py
Normal 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
|
||||||
59
scripts/monitor_estado_watchdog.ps1
Normal file
59
scripts/monitor_estado_watchdog.ps1
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
try:
|
||||||
if not token:
|
from ha_alexa import command
|
||||||
print(" -> [ERROR] Falta VOICEMONKEY_TOKEN en .env")
|
except Exception as e:
|
||||||
|
print(f" -> [ERROR] No se pudo cargar ha_alexa: {e}", flush=True)
|
||||||
return
|
return
|
||||||
|
for nombre in ("barras", "cuartillo"):
|
||||||
|
ok = command(f"apaga {nombre}")
|
||||||
|
print(f" -> {'OK' if ok else 'ERROR'} al apagar '{nombre}'", flush=True)
|
||||||
|
|
||||||
# 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:
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
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:
|
|
||||||
print(f" -> Error al apagar '{nombre}': {e}")
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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}\"'"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}\"'"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
try:
|
||||||
if not token:
|
from ha_alexa import command
|
||||||
print(" ✗ [ERROR] Falta VOICEMONKEY_TOKEN en .env")
|
except Exception as e:
|
||||||
|
print(f" ✗ [ERROR] No se pudo cargar ha_alexa: {e}")
|
||||||
return
|
return
|
||||||
|
for nombre in ("barras", "cuartillo"):
|
||||||
# VoiceMonkey: crear dos "monkeys" (triggers virtuales) llamados 'apagar-barras' y 'apagar-cuartillo'
|
ok = command(f"apaga {nombre}")
|
||||||
# que en su rutina de Alexa ejecutan: "Alexa, apaga barras" y "Alexa, apaga cuartillo"
|
print(f" {'✓' if ok else '✗'} apagar '{nombre}'")
|
||||||
|
|
||||||
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:
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
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:
|
|
||||||
print(f" ✗ Error al apagar '{nombre}': {e}")
|
|
||||||
|
|
||||||
print(" ℹ️ Si las luces no se apagaron, verifica que existan los monkeys:")
|
|
||||||
print(" - 'apagar-barras' → rutina Alexa: 'Alexa, apaga barras'")
|
|
||||||
print(" - 'apagar-cuartillo' → rutina Alexa: 'Alexa, apaga cuartillo'")
|
|
||||||
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}\"'"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
21
scripts/ssh_sobremesa.ps1
Normal 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"
|
||||||
40
scripts/superwhisper_enter.ahk
Normal file
40
scripts/superwhisper_enter.ahk
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
110
scripts/voz_mensaje.py
Normal 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
13
tests/conftest.py
Normal 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
73
tests/test_archivo.py
Normal 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
32
tests/test_aviso.py
Normal 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
81
tests/test_ciclo.py
Normal 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
74
tests/test_correo.py
Normal 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
58
tests/test_jira.py
Normal 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
40
tests/test_lock.py
Normal 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
45
tests/test_resumen.py
Normal 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
91
tests/test_telegram.py
Normal 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
48
tests/test_vistos.py
Normal 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]
|
||||||
Reference in New Issue
Block a user