Compare commits
35 Commits
b589ed3467
...
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 | ||
|
|
2269a81d32 | ||
|
|
dd810c4e13 | ||
|
|
bb50d468be | ||
|
|
b9800b6f25 | ||
|
|
7b8d7f3521 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,7 @@ __pycache__/
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
|
||||
# Entorno / secretos
|
||||
.env
|
||||
@@ -15,6 +16,7 @@ estado/*.log
|
||||
estado/.watcher_last_id
|
||||
estado/.netatmo_last_alert.json
|
||||
estado/.netatmo_token.json
|
||||
estado/monitor/
|
||||
|
||||
# IDE / OS
|
||||
.vscode/
|
||||
|
||||
@@ -5,21 +5,218 @@
|
||||
|
||||
## Pendientes de replicar
|
||||
|
||||
- **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-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-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
|
||||
|
||||
### 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)
|
||||
- **id**: `2026-09-30-fix-telegram-watcher-telethon-1-44-encoding-utf-8`
|
||||
- **Equipo**: portatil
|
||||
- **Categoria**: watchers
|
||||
- **Transferible**: si
|
||||
- **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.
|
||||
|
||||
### 2026-09-29 · Migración de watchers al VPS
|
||||
- **id**: `2026-09-29-migraci-n-de-watchers-al-vps`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: servicios
|
||||
- **Transferible**: si
|
||||
- **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.
|
||||
|
||||
### 2026-09-20 · OmniRoute arranque fiable (tarea programada + vigilante)
|
||||
- **id**: `2026-09-20-omniroute-arranque-fiable-tarea-programada-vigilante`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: omniroute
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: Tarea 'OmniRoute Watchdog' al iniciar sesion + omniroute_watchdog.ps1 (puerto 20128 cada 120s, autocuracion). Sustituye el .vbs fragil.
|
||||
|
||||
### 2026-09-20 · OmniRoute: 505 modelos expuestos en OpenCode
|
||||
@@ -27,7 +224,7 @@
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: omniroute
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: setup-opencode --only de 5 conexiones probadas (antigravity, claude, opencode-go, opencode, aihorde) + auto/*. Quitar BOM de opencode.jsonc.
|
||||
|
||||
### 2026-09-20 · Plugin global OpenCode relanza OmniRoute
|
||||
@@ -35,7 +232,7 @@
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: opencode
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: omniroute-bootstrap.ts en ~/.config/opencode/plugins/ lanza el vigilante -Once al arrancar OpenCode en cualquier repo.
|
||||
|
||||
### 2026-09-20 · Gitea caido (502) - contenedor en bucle de reinicio
|
||||
@@ -59,7 +256,7 @@
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: watchers
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: telegram_watcher.py, recordatorios_msgbox.py y recordatorio.cmd movidos de biblioteca_negocio_prolongo/scripts a INFRAESTRUCTURA/scripts. Rutas resueltas con scripts/infra_paths.py: estado en INFRAESTRUCTURA/estado; pendientes.json y modulos compartidos (idd_channel, netatmo_tool, alarma_twilio) desde el repo hermano (override BIBLIOTECA_REPO). Tareas programadas nuevas (Telegram Watcher, Recordatorios Msgbox) via scripts/instalar_watchers.ps1 (integrado en instalar_global.ps1). En el portatil: git clone INFRAESTRUCTURA, ejecutar scripts/instalar_global.ps1, copiar ~/.telegram-agent y ~/.omniroute, y eliminar el autostart .lnk antiguo si existe. Estado migrado a INFRAESTRUCTURA/estado/.
|
||||
|
||||
### 2026-09-20 · Primera version de Morning Routine (WoL, Tapo, AutoLogon y Arranque Apps)
|
||||
@@ -67,7 +264,7 @@
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: scripts
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: Scripts para encender PC via RPi+Tapo, hacer autologon en Windows y lanzar Chrome, Edge, WhatsApp y Telegram al arrancar
|
||||
|
||||
### 2026-09-20 · Fix bot Telegram control PC: confirmacion + watchdog
|
||||
@@ -75,5 +272,53 @@
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: scripts
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: Corregidos 4 problemas del bot telegram_bot_pc.py: (1) confirmacion obligatoria antes de apagar, (2) watchdog automatico de estado tras apagar (30s + ping), (3) watchdog de encendido con 3 reintentos (90s + 3x15s ping), (4) documentado problema autologon usuario nuevo. Archivos: telegram_bot_pc.py (corregido), docs/TELEGRAM_BOT_PC_FIXES.md, docs/FIX_PROBLEMA_AUTOLOGON_USUARIO_NUEVO.md
|
||||
|
||||
### 2026-09-20 · Bot IDD desplegado en VPS con control PC
|
||||
- **id**: `2026-09-20-bot-idd-desplegado-en-vps-con-control-pc`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: servicios
|
||||
- **Transferible**: no
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Detalle**: Bot Telegram IDD en /opt/telegram_bot_idd (VPS 185.187.169.109), servicio telegram-idd-bot.service, control remoto PC (apagar/encender/estado WoL). Panel IDD actualizado con botones de control PC.
|
||||
|
||||
### 2026-09-20 · Shutdown listener instalado como tarea programada
|
||||
- **id**: `2026-09-20-shutdown-listener-instalado-como-tarea-programada`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: watchers
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: shutdown_listener.py añadido a instalar_watchers.ps1. Escucha puerto 5555, arranca al login, reinicio automático. Recibe comandos de Home Assistant para apagar el PC.
|
||||
|
||||
### 2026-09-20 · PC Status Proxy en VPS via túnel RPi
|
||||
- **id**: `2026-09-20-pc-status-proxy-en-vps-via-t-nel-rpi`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: servicios
|
||||
- **Transferible**: no
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Detalle**: Servicio pc-status-proxy.service en VPS verifica estado PC via SSH a RPi (puerto 2222) → curl a 192.168.1.239:5555. Bot IDD usa localhost:15555 para consultar estado real del PC.
|
||||
|
||||
### 2026-09-20 · Timeout Home Assistant aumentado 5s→15s para proxy shutdown
|
||||
- **id**: `2026-09-20-timeout-home-assistant-aumentado-5s-15s-para-proxy-shutdown`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: servicios
|
||||
- **Transferible**: si
|
||||
- **Aplicado en**: sobremesa, portatil
|
||||
- **Detalle**: rest_command.shutdown_pc_local timeout aumentado de 5s a 15s en /opt/homeassistant/config/configuration.yaml. El proxy VPS tarda ~5s por túnel SSH→RPi→PC. Evita timeout que abortaba script completo de apagado.
|
||||
|
||||
### 2026-09-20 · Scripts test apagado luces sin regleta (continue_on_error)
|
||||
- **id**: `2026-09-20-scripts-test-apagado-luces-sin-regleta-continue-on-error`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: servicios
|
||||
- **Transferible**: no
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Detalle**: Creados scripts V2 en Home Assistant para probar apagado de luces sin apagar regleta: apagar_pc_test_luces_v2 con continue_on_error: true. Webhook: test-luces-v2-juanjo. Problema detectado: comandos Alexa custom no se ejecutan (requiere investigación).
|
||||
|
||||
### 2026-09-20 · Usuario temporal Juan M en login - script fix_autologon.ps1 listo
|
||||
- **id**: `2026-09-20-usuario-temporal-juan-m-en-login-script-fix-autologon-ps1-li`
|
||||
- **Equipo**: sobremesa
|
||||
- **Categoria**: scripts
|
||||
- **Transferible**: no
|
||||
- **Aplicado en**: sobremesa
|
||||
- **Detalle**: Documentado problema usuario temporal Juan M en pantalla login. No es usuario real, es perfil corrupto de Windows. Solución: ejecutar scripts\fix_autologon.ps1 como Admin y reiniciar. Autologon correctamente configurado pero perfil persiste en caché.
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"titulo": "OmniRoute arranque fiable (tarea programada + vigilante)",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "Tarea 'OmniRoute Watchdog' al iniciar sesion + omniroute_watchdog.ps1 (puerto 20128 cada 120s, autocuracion). Sustituye el .vbs fragil."
|
||||
},
|
||||
@@ -21,7 +22,8 @@
|
||||
"titulo": "OmniRoute: 505 modelos expuestos en OpenCode",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "setup-opencode --only de 5 conexiones probadas (antigravity, claude, opencode-go, opencode, aihorde) + auto/*. Quitar BOM de opencode.jsonc."
|
||||
},
|
||||
@@ -33,7 +35,8 @@
|
||||
"titulo": "Plugin global OpenCode relanza OmniRoute",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "omniroute-bootstrap.ts en ~/.config/opencode/plugins/ lanza el vigilante -Once al arrancar OpenCode en cualquier repo."
|
||||
},
|
||||
@@ -69,7 +72,8 @@
|
||||
"titulo": "Migracion de watchers (Telegram + recordatorios) a INFRAESTRUCTURA",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "telegram_watcher.py, recordatorios_msgbox.py y recordatorio.cmd movidos de biblioteca_negocio_prolongo/scripts a INFRAESTRUCTURA/scripts. Rutas resueltas con scripts/infra_paths.py: estado en INFRAESTRUCTURA/estado; pendientes.json y modulos compartidos (idd_channel, netatmo_tool, alarma_twilio) desde el repo hermano (override BIBLIOTECA_REPO). Tareas programadas nuevas (Telegram Watcher, Recordatorios Msgbox) via scripts/instalar_watchers.ps1 (integrado en instalar_global.ps1). En el portatil: git clone INFRAESTRUCTURA, ejecutar scripts/instalar_global.ps1, copiar ~/.telegram-agent y ~/.omniroute, y eliminar el autostart .lnk antiguo si existe. Estado migrado a INFRAESTRUCTURA/estado/."
|
||||
},
|
||||
@@ -81,7 +85,8 @@
|
||||
"titulo": "Primera version de Morning Routine (WoL, Tapo, AutoLogon y Arranque Apps)",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "Scripts para encender PC via RPi+Tapo, hacer autologon en Windows y lanzar Chrome, Edge, WhatsApp y Telegram al arrancar"
|
||||
},
|
||||
@@ -93,9 +98,382 @@
|
||||
"titulo": "Fix bot Telegram control PC: confirmacion + watchdog",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "Corregidos 4 problemas del bot telegram_bot_pc.py: (1) confirmacion obligatoria antes de apagar, (2) watchdog automatico de estado tras apagar (30s + ping), (3) watchdog de encendido con 3 reintentos (90s + 3x15s ping), (4) documentado problema autologon usuario nuevo. Archivos: telegram_bot_pc.py (corregido), docs/TELEGRAM_BOT_PC_FIXES.md, docs/FIX_PROBLEMA_AUTOLOGON_USUARIO_NUEVO.md"
|
||||
},
|
||||
{
|
||||
"id": "2026-09-20-bot-idd-desplegado-en-vps-con-control-pc",
|
||||
"fecha": "2026-09-20",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "servicios",
|
||||
"titulo": "Bot IDD desplegado en VPS con control PC",
|
||||
"transferible": false,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
],
|
||||
"detalle": "Bot Telegram IDD en /opt/telegram_bot_idd (VPS 185.187.169.109), servicio telegram-idd-bot.service, control remoto PC (apagar/encender/estado WoL). Panel IDD actualizado con botones de control PC."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-20-shutdown-listener-instalado-como-tarea-programada",
|
||||
"fecha": "2026-09-20",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "watchers",
|
||||
"titulo": "Shutdown listener instalado como tarea programada",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "shutdown_listener.py añadido a instalar_watchers.ps1. Escucha puerto 5555, arranca al login, reinicio automático. Recibe comandos de Home Assistant para apagar el PC."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-20-pc-status-proxy-en-vps-via-t-nel-rpi",
|
||||
"fecha": "2026-09-20",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "servicios",
|
||||
"titulo": "PC Status Proxy en VPS via túnel RPi",
|
||||
"transferible": false,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
],
|
||||
"detalle": "Servicio pc-status-proxy.service en VPS verifica estado PC via SSH a RPi (puerto 2222) → curl a 192.168.1.239:5555. Bot IDD usa localhost:15555 para consultar estado real del PC."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-20-timeout-home-assistant-aumentado-5s-15s-para-proxy-shutdown",
|
||||
"fecha": "2026-09-20",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "servicios",
|
||||
"titulo": "Timeout Home Assistant aumentado 5s→15s para proxy shutdown",
|
||||
"transferible": true,
|
||||
"aplicado_en": [
|
||||
"sobremesa",
|
||||
"portatil"
|
||||
],
|
||||
"detalle": "rest_command.shutdown_pc_local timeout aumentado de 5s a 15s en /opt/homeassistant/config/configuration.yaml. El proxy VPS tarda ~5s por túnel SSH→RPi→PC. Evita timeout que abortaba script completo de apagado."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-20-scripts-test-apagado-luces-sin-regleta-continue-on-error",
|
||||
"fecha": "2026-09-20",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "servicios",
|
||||
"titulo": "Scripts test apagado luces sin regleta (continue_on_error)",
|
||||
"transferible": false,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
],
|
||||
"detalle": "Creados scripts V2 en Home Assistant para probar apagado de luces sin apagar regleta: apagar_pc_test_luces_v2 con continue_on_error: true. Webhook: test-luces-v2-juanjo. Problema detectado: comandos Alexa custom no se ejecutan (requiere investigación)."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-20-usuario-temporal-juan-m-en-login-script-fix-autologon-ps1-li",
|
||||
"fecha": "2026-09-20",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "scripts",
|
||||
"titulo": "Usuario temporal Juan M en login - script fix_autologon.ps1 listo",
|
||||
"transferible": false,
|
||||
"aplicado_en": [
|
||||
"sobremesa"
|
||||
],
|
||||
"detalle": "Documentado problema usuario temporal Juan M en pantalla login. No es usuario real, es perfil corrupto de Windows. Solución: ejecutar scripts\\fix_autologon.ps1 como Admin y reiniciar. Autologon correctamente configurado pero perfil persiste en caché."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-29-migraci-n-de-watchers-al-vps",
|
||||
"fecha": "2026-09-29",
|
||||
"equipo": "sobremesa",
|
||||
"categoria": "servicios",
|
||||
"titulo": "Migración de watchers al VPS",
|
||||
"transferible": true,
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"id": "2026-09-30-fix-telegram-watcher-telethon-1-44-encoding-utf-8",
|
||||
"fecha": "2026-09-30",
|
||||
"equipo": "portatil",
|
||||
"categoria": "watchers",
|
||||
"titulo": "Fix telegram_watcher (telethon 1.44 + encoding UTF-8)",
|
||||
"transferible": true,
|
||||
"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."
|
||||
},
|
||||
{
|
||||
"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
|
||||
|
||||
> **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
|
||||
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.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -52,6 +52,76 @@
|
||||
"fecha": "2026-09-07",
|
||||
"hora": "08:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"texto": "Dejar listo el ejecuta programas (#168)",
|
||||
"fecha": "2026-09-23",
|
||||
"hora": "09:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"texto": "Subir código VC009 al servidor — copiar publish a C:\\inetpub\\wwwroot\\apiprolongo\\ en IIS VC009",
|
||||
"fecha": "2026-09-23",
|
||||
"hora": "09:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"texto": "DeCA — cerrar pendientes con Oscar: validación matrícula, fecha finalización transporte, inspección",
|
||||
"fecha": "2026-09-23",
|
||||
"hora": "09:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"texto": "Recapitulación gestión etiquetas con Dani",
|
||||
"fecha": "2026-09-23",
|
||||
"hora": "09:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"texto": "Preguntar a Oscar el JUEVES por bloqueo de palets (#155)",
|
||||
"fecha": "2026-09-23",
|
||||
"hora": "10:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"texto": "Pasarle a la IA los codigos nuevos de las plumas de Consum (Oscar los dio de alta)",
|
||||
"fecha": "diario",
|
||||
"hora": "09:00",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"texto": "Leccion 1 Power BI: SM Coste Electrico + quiz Iristrace PNCH",
|
||||
"fecha": "diario",
|
||||
"hora": "09:00",
|
||||
"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'
|
||||
$NodeExe = 'C:\Program Files\nodejs\node.exe'
|
||||
$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
|
||||
|
||||
|
||||
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.
|
||||
const 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 =
|
||||
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
|
||||
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')
|
||||
token = os.getenv('HOMEASSISTANT_TOKEN')
|
||||
|
||||
@@ -15,7 +15,7 @@ if sys.platform == 'win32':
|
||||
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
|
||||
|
||||
# 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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
# 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)
|
||||
Write-Host "`nSe configurará autologon con:" -ForegroundColor Cyan
|
||||
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'
|
||||
$PluginDir = Join-Path $env:USERPROFILE '.config\opencode\plugins'
|
||||
$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
|
||||
|
||||
@@ -38,6 +46,14 @@ if (-not (Test-Path $PluginDir)) { New-Item -ItemType Directory -Force -Path $Pl
|
||||
Copy-Item -LiteralPath $PluginSrc -Destination $PluginDst -Force
|
||||
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 ------------------------------------------------------
|
||||
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
|
||||
|
||||
# 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 --------------------------------------------------------------
|
||||
if (-not $NoStart) {
|
||||
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) -----------------
|
||||
|
||||
@@ -25,7 +25,7 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$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
|
||||
$Python = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
@@ -33,7 +33,8 @@ if (-not $Python) { throw 'No se encuentra python en el PATH.' }
|
||||
|
||||
$Watchers = @(
|
||||
@{ Task = 'Telegram Watcher'; Script = Join-Path $RepoRoot 'scripts\telegram_watcher.py' },
|
||||
@{ Task = 'Recordatorios Msgbox'; Script = Join-Path $RepoRoot 'scripts\recordatorios_msgbox.py' }
|
||||
@{ Task = 'Recordatorios Msgbox'; Script = Join-Path $RepoRoot 'scripts\recordatorios_msgbox.py' },
|
||||
@{ Task = 'Shutdown Listener'; Script = Join-Path $RepoRoot 'scripts\shutdown_listener.py' }
|
||||
)
|
||||
|
||||
Write-Host '== Instalando tareas de watchers (INFRAESTRUCTURA) ==' -ForegroundColor Cyan
|
||||
|
||||
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"""
|
||||
try:
|
||||
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",
|
||||
"docker ps | grep homeassistant | grep -q Up && echo OK || echo NOK"],
|
||||
capture_output=True,
|
||||
@@ -49,7 +49,7 @@ Add-Type -AssemblyName System.Windows.Forms
|
||||
try:
|
||||
subprocess.run([
|
||||
"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"
|
||||
])
|
||||
except Exception:
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 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)
|
||||
|
||||
# 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():
|
||||
print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...")
|
||||
print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...", flush=True)
|
||||
ps_command = """
|
||||
$procesos = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.ProcessName -notmatch 'OpenCode|WindowsTerminal|cmd|powershell|Code|conhost' }
|
||||
foreach ($p in $procesos) {
|
||||
@@ -22,54 +32,68 @@ def cerrar_ventanas():
|
||||
}
|
||||
"""
|
||||
subprocess.run(["powershell", "-Command", ps_command], capture_output=True)
|
||||
print(" -> Ventanas cerradas.")
|
||||
print(" -> Ventanas cerradas.", flush=True)
|
||||
|
||||
|
||||
def apagar_luces_alexa():
|
||||
"""Apaga las luces 'barras' y 'cuartillo' via VoiceMonkey"""
|
||||
print("2. Apagando luces de Alexa (barras + cuartillo)...")
|
||||
token = os.getenv("VOICEMONKEY_TOKEN")
|
||||
if not token:
|
||||
print(" -> [ERROR] Falta VOICEMONKEY_TOKEN en .env")
|
||||
"""Apaga las luces 'barras' y 'cuartillo' via HOME ASSISTANT (sustituye a VoiceMonkey)."""
|
||||
print("2. Apagando luces de Alexa via Home Assistant (barras + cuartillo)...", flush=True)
|
||||
try:
|
||||
from ha_alexa import command
|
||||
except Exception as e:
|
||||
print(f" -> [ERROR] No se pudo cargar ha_alexa: {e}", flush=True)
|
||||
return
|
||||
|
||||
# VoiceMonkey: crear dos "monkeys" (triggers virtuales) llamados 'apagar-barras' y 'apagar-cuartillo'
|
||||
# que en su rutina de Alexa ejecutan: "Alexa, apaga barras" y "Alexa, apaga cuartillo"
|
||||
|
||||
luces = [
|
||||
("barras", "apagar-barras"),
|
||||
("cuartillo", "apagar-cuartillo")
|
||||
]
|
||||
|
||||
for nombre, monkey in luces:
|
||||
url = f"https://api-v2.voicemonkey.io/trigger?token={token}&device={monkey}"
|
||||
try:
|
||||
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}")
|
||||
for nombre in ("barras", "cuartillo"):
|
||||
ok = command(f"apaga {nombre}")
|
||||
print(f" -> {'OK' if ok else 'ERROR'} al apagar '{nombre}'", flush=True)
|
||||
|
||||
|
||||
def apagar_pantalla_tapo():
|
||||
print("3. Apagando pantalla (Tapo) via túnel 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()\\\"'"'''
|
||||
"""Apaga el enchufe Tapo de la pantalla via cadena local -> VPS -> RPi.
|
||||
|
||||
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:
|
||||
subprocess.run(cmd, shell=True, capture_output=True, timeout=15)
|
||||
print(" -> Pantalla apagada.")
|
||||
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=25)
|
||||
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:
|
||||
print(f" -> Error al apagar pantalla: {e}")
|
||||
print(f" -> [SKIP] Error al apagar pantalla: {e}", flush=True)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== INICIANDO PROTOCOLO DE APAGADO ===")
|
||||
print("=== INICIANDO PROTOCOLO DE APAGADO ===", flush=True)
|
||||
cerrar_ventanas()
|
||||
apagar_luces_alexa()
|
||||
apagar_pantalla_tapo()
|
||||
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')
|
||||
|
||||
# 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)
|
||||
|
||||
def cerrar_ventanas():
|
||||
@@ -101,7 +101,7 @@ p100.turnOff()"""
|
||||
|
||||
# Comando SSH en dos saltos: local -> VPS -> RPi
|
||||
cmd = [
|
||||
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo",
|
||||
"ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
|
||||
"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}\"'"
|
||||
]
|
||||
|
||||
@@ -23,7 +23,7 @@ if sys.platform == 'win32':
|
||||
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
|
||||
|
||||
# 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)
|
||||
|
||||
def cerrar_ventanas():
|
||||
@@ -101,14 +101,14 @@ p100.turnOff()"""
|
||||
|
||||
# Paso 1: Crear script en VPS
|
||||
cmd1 = [
|
||||
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo",
|
||||
"ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
|
||||
"root@185.187.169.109",
|
||||
f"echo '{script_content}' > /tmp/tapo_off.py"
|
||||
]
|
||||
|
||||
# Paso 2: Copiar a RPi y ejecutar
|
||||
cmd2 = [
|
||||
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo",
|
||||
"ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
|
||||
"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'"
|
||||
]
|
||||
|
||||
@@ -23,7 +23,7 @@ if sys.platform == 'win32':
|
||||
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
|
||||
|
||||
# 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)
|
||||
|
||||
def cerrar_ventanas():
|
||||
@@ -90,7 +90,7 @@ p100.turnOff()"""
|
||||
|
||||
# Comando SSH en dos saltos: local -> VPS -> RPi
|
||||
cmd = [
|
||||
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo",
|
||||
"ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
|
||||
"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}\"'"
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@ Protocolo de apagado completo del PC (VERSION DE PRUEBA - SIN SHUTDOWN)
|
||||
|
||||
Pasos:
|
||||
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)
|
||||
4. [COMENTADO] Shutdown del PC
|
||||
|
||||
@@ -24,9 +24,19 @@ if sys.platform == 'win32':
|
||||
sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict')
|
||||
|
||||
# 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)
|
||||
|
||||
# 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():
|
||||
"""Cierra todas las ventanas excepto terminal/OpenCode"""
|
||||
print("1. Cerrando ventanas abiertas (excepto terminal de OpenCode)...")
|
||||
@@ -44,37 +54,16 @@ def cerrar_ventanas():
|
||||
print(" ✓ Ventanas cerradas.")
|
||||
|
||||
def apagar_luces_alexa():
|
||||
"""Apaga las luces 'barras' y 'cuartillo' via VoiceMonkey"""
|
||||
print("2. Apagando luces de Alexa (barras + cuartillo)...")
|
||||
token = os.getenv("VOICEMONKEY_TOKEN")
|
||||
if not token:
|
||||
print(" ✗ [ERROR] Falta VOICEMONKEY_TOKEN en .env")
|
||||
"""Apaga las luces 'barras' y 'cuartillo' via HOME ASSISTANT (sustituye a VoiceMonkey)."""
|
||||
print("2. Apagando luces de Alexa via Home Assistant (barras + cuartillo)...")
|
||||
try:
|
||||
from ha_alexa import command
|
||||
except Exception as e:
|
||||
print(f" ✗ [ERROR] No se pudo cargar ha_alexa: {e}")
|
||||
return
|
||||
|
||||
# VoiceMonkey: crear dos "monkeys" (triggers virtuales) llamados 'apagar-barras' y 'apagar-cuartillo'
|
||||
# que en su rutina de Alexa ejecutan: "Alexa, apaga barras" y "Alexa, apaga cuartillo"
|
||||
|
||||
luces = [
|
||||
("barras", "apagar-barras"),
|
||||
("cuartillo", "apagar-cuartillo")
|
||||
]
|
||||
|
||||
for nombre, monkey in luces:
|
||||
url = f"https://api-v2.voicemonkey.io/trigger?token={token}&device={monkey}"
|
||||
try:
|
||||
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/")
|
||||
for nombre in ("barras", "cuartillo"):
|
||||
ok = command(f"apaga {nombre}")
|
||||
print(f" {'✓' if ok else '✗'} apagar '{nombre}'")
|
||||
|
||||
def apagar_pantalla_tapo():
|
||||
"""Apaga el enchufe Tapo de la pantalla via tunel RPi"""
|
||||
@@ -89,7 +78,7 @@ p100.turnOff()"""
|
||||
|
||||
# Comando SSH en dos saltos: local -> VPS -> RPi
|
||||
cmd = [
|
||||
"ssh", "-i", r"C:\Users\juanm\Documents\GitHub\contabo",
|
||||
"ssh", "-i", os.path.expanduser("~/Documents/GitHub/contabo"),
|
||||
"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}\"'"
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@ from flask import Flask, request, jsonify
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 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)
|
||||
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
# Configuración
|
||||
|
||||
@@ -10,6 +10,12 @@ import asyncio, sys, os, json, time, re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding='utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
C = {
|
||||
'red': '\033[91m', 'green': '\033[92m', 'yellow': '\033[93m',
|
||||
'blue': '\033[94m', 'magenta': '\033[95m', 'cyan': '\033[96m',
|
||||
@@ -295,7 +301,7 @@ async def main():
|
||||
|
||||
# Registro de eventos de confirmacion de lectura (Doble Check) -> SOLO IDD
|
||||
last_read_triggers = {}
|
||||
@client.on(events.MessageRead(outbox=True))
|
||||
@client.on(events.MessageRead())
|
||||
async def on_message_read(event):
|
||||
try:
|
||||
if is_silenciado():
|
||||
|
||||
@@ -17,7 +17,7 @@ if sys.platform == 'win32':
|
||||
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.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)
|
||||
|
||||
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')
|
||||
|
||||
# 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)
|
||||
|
||||
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]
|
||||
BIN
tools/LibreHardwareMonitor/Aga.Controls.dll
Normal file
BIN
tools/LibreHardwareMonitor/Aga.Controls.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/Aga.Controls.pdb
Normal file
BIN
tools/LibreHardwareMonitor/Aga.Controls.pdb
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/HidSharp.dll
Normal file
BIN
tools/LibreHardwareMonitor/HidSharp.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/LibreHardwareMonitor.exe
Normal file
BIN
tools/LibreHardwareMonitor/LibreHardwareMonitor.exe
Normal file
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
</configuration>
|
||||
BIN
tools/LibreHardwareMonitor/LibreHardwareMonitorLib.dll
Normal file
BIN
tools/LibreHardwareMonitor/LibreHardwareMonitorLib.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/LibreHardwareMonitorLib.pdb
Normal file
BIN
tools/LibreHardwareMonitor/LibreHardwareMonitorLib.pdb
Normal file
Binary file not shown.
2588
tools/LibreHardwareMonitor/LibreHardwareMonitorLib.xml
Normal file
2588
tools/LibreHardwareMonitor/LibreHardwareMonitorLib.xml
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tools/LibreHardwareMonitor/Microsoft.Win32.TaskScheduler.dll
Normal file
BIN
tools/LibreHardwareMonitor/Microsoft.Win32.TaskScheduler.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/Newtonsoft.Json.dll
Normal file
BIN
tools/LibreHardwareMonitor/Newtonsoft.Json.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/OxyPlot.WindowsForms.dll
Normal file
BIN
tools/LibreHardwareMonitor/OxyPlot.WindowsForms.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/OxyPlot.dll
Normal file
BIN
tools/LibreHardwareMonitor/OxyPlot.dll
Normal file
Binary file not shown.
BIN
tools/LibreHardwareMonitor/System.CodeDom.dll
Normal file
BIN
tools/LibreHardwareMonitor/System.CodeDom.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
228
vps/MIGRACION_SERVICIOS_VPS.md
Normal file
228
vps/MIGRACION_SERVICIOS_VPS.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Migración de Servicios al VPS
|
||||
|
||||
**Fecha:** 29 Sep 2026
|
||||
**Servicios migrados:**
|
||||
- Telegram Watcher
|
||||
- Shutdown Listener
|
||||
|
||||
---
|
||||
|
||||
## ✅ Estado Actual
|
||||
|
||||
### En el PC Local (sobremesa)
|
||||
- ❌ **Recordatorios Msgbox** → ELIMINADO (no se quiere más)
|
||||
- ❌ **Telegram Watcher** → MIGRADO AL VPS
|
||||
- ❌ **Shutdown Listener** → MIGRADO AL VPS
|
||||
- ✅ **OmniRoute Watchdog** → MANTIENE LOCAL (necesario para OpenCode)
|
||||
|
||||
### En el VPS (185.187.169.109)
|
||||
- ✅ **Telegram Watcher** → Servicio systemd
|
||||
- ✅ **Shutdown Listener** → Servicio systemd
|
||||
|
||||
---
|
||||
|
||||
## 📋 Instalación en el VPS
|
||||
|
||||
### 1. Conectarse al VPS
|
||||
|
||||
```bash
|
||||
ssh -i C:\Users\juanm\Documents\GitHub\contabo root@185.187.169.109
|
||||
```
|
||||
|
||||
### 2. Ejecutar script de configuración
|
||||
|
||||
```bash
|
||||
/root/configurar_servicios.sh
|
||||
```
|
||||
|
||||
Te pedirá:
|
||||
- **TELEGRAM_API_ID**: API ID de Telegram (obtener de https://my.telegram.org)
|
||||
- **TELEGRAM_API_HASH**: API Hash de Telegram
|
||||
- **TELEGRAM_BOT_TOKEN**: Token del bot de Telegram (obtener de @BotFather)
|
||||
- **SHUTDOWN_TOKEN**: Token de seguridad (presiona Enter para usar el actual)
|
||||
|
||||
### 3. Verificar que los servicios están corriendo
|
||||
|
||||
```bash
|
||||
systemctl status telegram-watcher
|
||||
systemctl status shutdown-listener
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitorización
|
||||
|
||||
### Ver logs en tiempo real
|
||||
|
||||
```bash
|
||||
# Telegram Watcher
|
||||
journalctl -u telegram-watcher -f
|
||||
|
||||
# Shutdown Listener
|
||||
journalctl -u shutdown-listener -f
|
||||
```
|
||||
|
||||
### Ver mensajes de Telegram
|
||||
|
||||
```bash
|
||||
tail -f /var/log/telegram_watcher/mensajes.log
|
||||
```
|
||||
|
||||
### Reiniciar servicios
|
||||
|
||||
```bash
|
||||
systemctl restart telegram-watcher
|
||||
systemctl restart shutdown-listener
|
||||
```
|
||||
|
||||
### Ver estado
|
||||
|
||||
```bash
|
||||
systemctl status telegram-watcher
|
||||
systemctl status shutdown-listener
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuración del Shutdown Listener
|
||||
|
||||
### Requisitos previos
|
||||
|
||||
Para que el Shutdown Listener del VPS pueda apagar el PC:
|
||||
|
||||
1. **SSH habilitado en el PC Windows**
|
||||
- Instalar OpenSSH Server (Configuración → Aplicaciones → Características opcionales → OpenSSH Server)
|
||||
- Iniciar servicio: `Start-Service sshd`
|
||||
- Habilitar al inicio: `Set-Service -Name sshd -StartupType 'Automatic'`
|
||||
|
||||
2. **Configurar clave SSH del VPS en el PC**
|
||||
|
||||
En el VPS:
|
||||
```bash
|
||||
# Generar clave si no existe
|
||||
ssh-keygen -t rsa -b 4096 -f /root/.ssh/id_rsa -N ""
|
||||
|
||||
# Copiar clave pública
|
||||
cat /root/.ssh/id_rsa.pub
|
||||
```
|
||||
|
||||
En el PC Windows:
|
||||
```powershell
|
||||
# Crear directorio SSH si no existe
|
||||
mkdir C:\Users\juanm\.ssh -ErrorAction SilentlyContinue
|
||||
|
||||
# Pegar la clave pública del VPS en authorized_keys
|
||||
# (copiar el contenido de id_rsa.pub del VPS)
|
||||
Add-Content C:\Users\juanm\.ssh\authorized_keys "<PEGAR_CLAVE_PUBLICA_AQUI>"
|
||||
```
|
||||
|
||||
3. **Verificar conectividad desde el VPS**
|
||||
|
||||
```bash
|
||||
ssh juanm@192.168.1.239 "echo 'Conexión OK'"
|
||||
```
|
||||
|
||||
### Usar el Shutdown Listener
|
||||
|
||||
Desde cualquier lugar (Home Assistant, curl, etc.):
|
||||
|
||||
```bash
|
||||
curl -X POST http://185.187.169.109:5555/shutdown \
|
||||
-H "Authorization: Bearer H7iLGJixPp3J3pthCmnR2Ibq0tvERD5THyr_foAeN_8"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Seguridad
|
||||
|
||||
### Variables de entorno sensibles
|
||||
|
||||
Los servicios usan variables de entorno para credenciales:
|
||||
- `TELEGRAM_API_ID`
|
||||
- `TELEGRAM_API_HASH`
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `SHUTDOWN_TOKEN`
|
||||
|
||||
Estas están en `/etc/systemd/system/*.service` (solo accesible por root).
|
||||
|
||||
### Firewall
|
||||
|
||||
El Shutdown Listener escucha en el puerto **5555**. Asegúrate de:
|
||||
- Permitir conexiones desde tu red local
|
||||
- **NO** exponer públicamente (solo VPN o red interna)
|
||||
|
||||
---
|
||||
|
||||
## 📂 Ubicaciones de archivos
|
||||
|
||||
### En el VPS
|
||||
|
||||
| Archivo | Ubicación |
|
||||
|---------|-----------|
|
||||
| Script Telegram Watcher | `/opt/telegram_watcher/telegram_watcher.py` |
|
||||
| Script Shutdown Listener | `/opt/shutdown_listener/shutdown_listener.py` |
|
||||
| Servicio Telegram Watcher | `/etc/systemd/system/telegram-watcher.service` |
|
||||
| Servicio Shutdown Listener | `/etc/systemd/system/shutdown-listener.service` |
|
||||
| Log de mensajes | `/var/log/telegram_watcher/mensajes.log` |
|
||||
| Script de configuración | `/root/configurar_servicios.sh` |
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### El Telegram Watcher no arranca
|
||||
|
||||
```bash
|
||||
# Ver logs
|
||||
journalctl -u telegram-watcher -n 50
|
||||
|
||||
# Problemas comunes:
|
||||
# 1. Credenciales incorrectas → verificar variables en el .service
|
||||
# 2. Telethon no instalado → pip3 install telethon
|
||||
# 3. Permisos del log → mkdir -p /var/log/telegram_watcher && chmod 755 /var/log/telegram_watcher
|
||||
```
|
||||
|
||||
### El Shutdown Listener no puede conectar al PC
|
||||
|
||||
```bash
|
||||
# Verificar conectividad SSH
|
||||
ssh -i /root/.ssh/id_rsa juanm@192.168.1.239 "echo OK"
|
||||
|
||||
# Si falla:
|
||||
# 1. Verificar que el PC está encendido y en la red
|
||||
# 2. Verificar IP del PC (puede haber cambiado)
|
||||
# 3. Verificar que OpenSSH Server está corriendo en el PC
|
||||
# 4. Verificar que la clave pública está en C:\Users\juanm\.ssh\authorized_keys
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notas
|
||||
|
||||
1. **Sesión de Telegram**: El watcher usa el BOT_TOKEN (no sesión personal) para evitar el problema de `AuthKeyDuplicatedError`.
|
||||
|
||||
2. **Logs**: Los mensajes se guardan tanto en journald como en `/var/log/telegram_watcher/mensajes.log`.
|
||||
|
||||
3. **Reinicio automático**: Ambos servicios se reinician automáticamente si fallan (configurado en systemd).
|
||||
|
||||
4. **IP del PC**: La IP `192.168.1.239` está hardcodeada. Si cambia, actualizar en `/etc/systemd/system/shutdown-listener.service`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist de migración
|
||||
|
||||
- [x] Cerrar procesos locales (Telegram Watcher, Shutdown Listener, Recordatorios Msgbox)
|
||||
- [x] Eliminar tareas programadas de Windows
|
||||
- [x] Crear scripts adaptados para VPS
|
||||
- [x] Copiar archivos al VPS
|
||||
- [x] Crear servicios systemd
|
||||
- [ ] **Ejecutar `/root/configurar_servicios.sh` en el VPS**
|
||||
- [ ] Configurar SSH en el PC Windows
|
||||
- [ ] Copiar clave pública del VPS al PC
|
||||
- [ ] Probar shutdown remoto
|
||||
- [ ] Verificar que los logs se generan correctamente
|
||||
- [ ] Actualizar AGENTS.md con la nueva configuración
|
||||
|
||||
---
|
||||
|
||||
**Última actualización:** 29 Sep 2026
|
||||
80
vps/configurar_servicios_vps.sh
Normal file
80
vps/configurar_servicios_vps.sh
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# configurar_servicios_vps.sh — Configurar servicios en el VPS
|
||||
# Este script debe ejecutarse EN EL VPS como root
|
||||
|
||||
echo "================================"
|
||||
echo "Configuración de servicios VPS"
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
# Solicitar credenciales
|
||||
read -p "TELEGRAM_API_ID: " TELEGRAM_API_ID
|
||||
read -p "TELEGRAM_API_HASH: " TELEGRAM_API_HASH
|
||||
read -p "TELEGRAM_BOT_TOKEN: " TELEGRAM_BOT_TOKEN
|
||||
read -p "SHUTDOWN_TOKEN [presiona Enter para usar el actual]: " SHUTDOWN_TOKEN
|
||||
|
||||
# Usar token actual si no se proporciona uno nuevo
|
||||
if [ -z "$SHUTDOWN_TOKEN" ]; then
|
||||
SHUTDOWN_TOKEN="H7iLGJixPp3J3pthCmnR2Ibq0tvERD5THyr_foAeN_8"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Actualizando servicios con las credenciales..."
|
||||
|
||||
# Actualizar telegram-watcher.service
|
||||
sed -i "s|Environment=TELEGRAM_API_ID=.*|Environment=TELEGRAM_API_ID=$TELEGRAM_API_ID|" /etc/systemd/system/telegram-watcher.service
|
||||
sed -i "s|Environment=TELEGRAM_API_HASH=.*|Environment=TELEGRAM_API_HASH=$TELEGRAM_API_HASH|" /etc/systemd/system/telegram-watcher.service
|
||||
sed -i "s|Environment=TELEGRAM_BOT_TOKEN=.*|Environment=TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN|" /etc/systemd/system/telegram-watcher.service
|
||||
|
||||
# Actualizar shutdown-listener.service
|
||||
sed -i "s|Environment=SHUTDOWN_TOKEN=.*|Environment=SHUTDOWN_TOKEN=$SHUTDOWN_TOKEN|" /etc/systemd/system/shutdown-listener.service
|
||||
|
||||
# Instalar dependencias Python
|
||||
echo ""
|
||||
echo "Instalando dependencias..."
|
||||
pip3 install telethon flask python-dotenv
|
||||
|
||||
# Crear directorio de logs
|
||||
mkdir -p /var/log/telegram_watcher
|
||||
chmod 755 /var/log/telegram_watcher
|
||||
|
||||
# Dar permisos de ejecución
|
||||
chmod +x /opt/telegram_watcher/telegram_watcher.py
|
||||
chmod +x /opt/shutdown_listener/shutdown_listener.py
|
||||
|
||||
# Recargar systemd
|
||||
echo ""
|
||||
echo "Recargando systemd..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# Habilitar servicios
|
||||
echo "Habilitando servicios..."
|
||||
systemctl enable telegram-watcher.service
|
||||
systemctl enable shutdown-listener.service
|
||||
|
||||
# Iniciar servicios
|
||||
echo "Iniciando servicios..."
|
||||
systemctl start telegram-watcher.service
|
||||
systemctl start shutdown-listener.service
|
||||
|
||||
# Mostrar estado
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "Estado de los servicios:"
|
||||
echo "================================"
|
||||
systemctl status telegram-watcher.service --no-pager
|
||||
echo ""
|
||||
systemctl status shutdown-listener.service --no-pager
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "Configuración completada"
|
||||
echo "================================"
|
||||
echo ""
|
||||
echo "Para ver logs en tiempo real:"
|
||||
echo " journalctl -u telegram-watcher -f"
|
||||
echo " journalctl -u shutdown-listener -f"
|
||||
echo ""
|
||||
echo "Log de mensajes de Telegram:"
|
||||
echo " tail -f /var/log/telegram_watcher/mensajes.log"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user