20-sep: migracion watchers (Telegram+recordatorios) a INFRAESTRUCTURA + estado/agentes/changelog
This commit is contained in:
@@ -31,7 +31,7 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
JSON_FILE = REPO / "changelog_infra.json"
|
||||
MD_FILE = REPO / "CHANGELOG_INFRA.md"
|
||||
|
||||
CATEGORIAS = ["omniroute", "opencode", "red", "servicios", "drivers", "memoria", "otros"]
|
||||
CATEGORIAS = ["omniroute", "opencode", "watchers", "scripts", "red", "servicios", "drivers", "memoria", "otros"]
|
||||
|
||||
|
||||
# --- Deteccion de equipo ---------------------------------------------------
|
||||
|
||||
48
scripts/infra_paths.py
Normal file
48
scripts/infra_paths.py
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""infra_paths.py — Resolucion de rutas compartidas del repo INFRAESTRUCTURA.
|
||||
|
||||
Los scripts de infraestructura viven en `INFRAESTRUCTURA/scripts/` y necesitan:
|
||||
- Estado runtime propio del repo -> `INFRAESTRUCTURA/estado/`
|
||||
- Datos de negocio y modulos aun compartidos del repo
|
||||
`biblioteca_negocio_prolongo` -> pendientes.json, idd_channel, netatmo_tool, alarma_twilio
|
||||
|
||||
La ubicacion del repo de negocio se resuelve asi (por orden):
|
||||
1. Variable de entorno `BIBLIOTECA_REPO`
|
||||
2. Repo hermano de INFRAESTRUCTURA: `../biblioteca_negocio_prolongo`
|
||||
|
||||
En el portatil la estructura de carpetas es la misma (`Documents/GitHub/...`), por lo
|
||||
que el hermano funciona sin configurar nada; `BIBLIOTECA_REPO` permite sobreescribirlo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
INFRA_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPTS_DIR = INFRA_ROOT / "scripts"
|
||||
ESTADO_DIR = INFRA_ROOT / "estado"
|
||||
|
||||
|
||||
def biblioteca_repo() -> Path:
|
||||
"""Devuelve la ruta del repo de negocio (biblioteca_negocio_prolongo)."""
|
||||
env = os.environ.get("BIBLIOTECA_REPO")
|
||||
if env:
|
||||
return Path(env).expanduser().resolve()
|
||||
return (INFRA_ROOT.parent / "biblioteca_negocio_prolongo").resolve()
|
||||
|
||||
|
||||
def bootstrap_biblioteca_paths() -> Path:
|
||||
"""Anade al sys.path los modulos que aun viven en el repo de negocio.
|
||||
|
||||
Transitorio: `idd_channel`, `netatmo_tool` y `alarma_twilio` siguen en
|
||||
biblioteca_negocio_prolongo porque los bots del VPS los importan desde alli.
|
||||
Cuando se centralicen en INFRAESTRUCTURA, esta funcion quedara vacia.
|
||||
"""
|
||||
repo = biblioteca_repo()
|
||||
for candidato in (repo, repo / "scripts"):
|
||||
sp = str(candidato)
|
||||
if candidato.exists() and sp not in sys.path:
|
||||
sys.path.insert(0, sp)
|
||||
return repo
|
||||
@@ -82,6 +82,10 @@ if (-not $NoStart) {
|
||||
Write-Host ' - Vigilante arrancado.' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 4) Watchers de infraestructura (Telegram + recordatorios) -----------------
|
||||
Write-Host ''
|
||||
& (Join-Path $PSScriptRoot 'instalar_watchers.ps1') -NoStart:$NoStart
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Listo. Comprobar:' -ForegroundColor Cyan
|
||||
Write-Host " Get-ScheduledTask -TaskName '$TaskName' | Get-ScheduledTaskInfo"
|
||||
|
||||
89
scripts/instalar_watchers.ps1
Normal file
89
scripts/instalar_watchers.ps1
Normal file
@@ -0,0 +1,89 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Registra en el equipo actual las tareas programadas de los watchers de
|
||||
infraestructura (Telegram + recordatorios con message box).
|
||||
|
||||
.DESCRIPTION
|
||||
Los watchers viven ahora en `INFRAESTRUCTURA/scripts/` (centralizados; antes
|
||||
en biblioteca_negocio_prolongo/scripts/). Este instalador crea/actualiza dos
|
||||
tareas programadas que arrancan al iniciar sesion y se reinician si fallan:
|
||||
|
||||
1. `Telegram Watcher` -> scripts\telegram_watcher.py
|
||||
2. `Recordatorios Msgbox` -> scripts\recordatorios_msgbox.py
|
||||
|
||||
No requiere administrador. Idempotente: re-ejecutar tras un git pull.
|
||||
|
||||
.NOTES
|
||||
Repo: INFRAESTRUCTURA. Los datos de estado quedan en INFRAESTRUCTURA\estado\.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$NoStart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$PwshExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$UserId = "$env:USERDOMAIN\$env:USERNAME"
|
||||
|
||||
# Resolver el interprete de Python
|
||||
$Python = (Get-Command python -ErrorAction SilentlyContinue).Source
|
||||
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' }
|
||||
)
|
||||
|
||||
Write-Host '== Instalando tareas de watchers (INFRAESTRUCTURA) ==' -ForegroundColor Cyan
|
||||
Write-Host " - Python: $Python"
|
||||
Write-Host " - Repo : $RepoRoot"
|
||||
|
||||
foreach ($w in $Watchers) {
|
||||
if (-not (Test-Path $w.Script)) { throw "No se encuentra el script: $($w.Script)" }
|
||||
|
||||
if (Get-ScheduledTask -TaskName $w.Task -ErrorAction SilentlyContinue) {
|
||||
Unregister-ScheduledTask -TaskName $w.Task -Confirm:$false
|
||||
}
|
||||
|
||||
$Action = New-ScheduledTaskAction `
|
||||
-Execute $PwshExe `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -Command `"& '$Python' '$($w.Script)'`"" `
|
||||
-WorkingDirectory $RepoRoot
|
||||
|
||||
$Trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
|
||||
$Settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-RestartCount 999 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-MultipleInstances IgnoreNew
|
||||
$Settings.ExecutionTimeLimit = 'PT0S'
|
||||
$Settings.Hidden = $true
|
||||
|
||||
$Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Limited
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $w.Task `
|
||||
-Action $Action `
|
||||
-Trigger $Trigger `
|
||||
-Settings $Settings `
|
||||
-Principal $Principal `
|
||||
-Description "Watcher de infraestructura: $($w.Script)" `
|
||||
-Force | Out-Null
|
||||
|
||||
Write-Host " - Tarea '$($w.Task)' registrada -> $($w.Script)" -ForegroundColor Green
|
||||
|
||||
if (-not $NoStart) {
|
||||
Start-ScheduledTask -TaskName $w.Task
|
||||
Write-Host " arrancada." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Listo. Comprobar:' -ForegroundColor Cyan
|
||||
Write-Host " Get-ScheduledTask -TaskName 'Telegram Watcher','Recordatorios Msgbox'"
|
||||
Write-Host " Get-Content `"$RepoRoot\estado\telegram_mensajes.log`" -Tail 5"
|
||||
31
scripts/recordatorio.cmd
Normal file
31
scripts/recordatorio.cmd
Normal file
@@ -0,0 +1,31 @@
|
||||
@echo off
|
||||
rem recordatorio.cmd — Comando corto para gestionar recordatorios con message box.
|
||||
rem Uso:
|
||||
rem recordatorio listar todos los recordatorios
|
||||
rem recordatorio done <id> marcar como terminado (ya no sale)
|
||||
rem recordatorio add "texto" --hora 14:00 [--fecha diario|YYYY-MM-DD]
|
||||
rem recordatorio test <id> mostrar el cuadro ahora mismo
|
||||
rem recordatorio sync crear recordatorios desde pendientes.json (tareas con hora)
|
||||
set PY=python
|
||||
set SCR=C:\Users\juanm\Documents\GitHub\INFRAESTRUCTURA\scripts\recordatorios_msgbox.py
|
||||
if "%~1"=="" goto listar
|
||||
if /i "%~1"=="done" (
|
||||
%PY% "%SCR%" --done %2
|
||||
goto fin
|
||||
)
|
||||
if /i "%~1"=="add" (
|
||||
%PY% "%SCR%" --add %2 --hora %3 --fecha %4
|
||||
goto fin
|
||||
)
|
||||
if /i "%~1"=="test" (
|
||||
%PY% "%SCR%" --test %2
|
||||
goto fin
|
||||
)
|
||||
if /i "%~1"=="sync" (
|
||||
%PY% "%SCR%" --sync
|
||||
goto fin
|
||||
)
|
||||
if /i "%~1"=="listar" goto listar
|
||||
:listar
|
||||
%PY% "%SCR%" --listar
|
||||
:fin
|
||||
226
scripts/recordatorios_msgbox.py
Normal file
226
scripts/recordatorios_msgbox.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""recordatorios_msgbox.py — Recordatorios con cuadros de dialogo (MessageBox) en Windows.
|
||||
|
||||
Muestra un message box cuando la hora de un recordatorio llega (o ya ha pasado y no esta hecho).
|
||||
- Si el usuario pulsa SI -> se marca como hecho (done=True) y no vuelve a salir.
|
||||
- Si pulsa NO -> se pospone 5 minutos (snooze).
|
||||
- Si se ejecuta con --done <id> se marca como terminado (lo usa el asistente cuando el usuario dice "terminada").
|
||||
- Si se ejecuta con --listar muestra el estado de todos los recordatorios.
|
||||
|
||||
Fichero de datos: recordatorios_msgbox.json (formato: id, texto, fecha [diario|YYYY-MM-DD], hora HH:MM, done).
|
||||
|
||||
Uso:
|
||||
python scripts/recordatorios_msgbox.py # bucle de monitorizacion (correr en background)
|
||||
python scripts/recordatorios_msgbox.py --listar # listar estado
|
||||
python scripts/recordatorios_msgbox.py --done <id> # marcar como terminado
|
||||
python scripts/recordatorios_msgbox.py --add "texto" --hora HH:MM [--fecha diario|YYYY-MM-DD]
|
||||
python scripts/recordatorios_msgbox.py --test <id> # mostrar el cuadro sin esperar a la hora
|
||||
python scripts/recordatorios_msgbox.py --sync # crear recordatorios desde pendientes.json (tareas con campo hora)
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import infra_paths # noqa: E402 (resolucion de rutas del repo INFRAESTRUCTURA)
|
||||
|
||||
DATA = infra_paths.ESTADO_DIR / 'recordatorios_msgbox.json'
|
||||
PENDIENTES = infra_paths.biblioteca_repo() / 'pendientes.json'
|
||||
|
||||
# Constantes de MessageBoxW de Win32
|
||||
MB_OK = 0x0
|
||||
MB_YESNO = 0x4
|
||||
MB_ICONINFORMATION = 0x40
|
||||
MB_ICONQUESTION = 0x20
|
||||
MB_SYSTEMMODAL = 0x1000
|
||||
IDYES = 6
|
||||
IDNO = 7
|
||||
|
||||
SLEEP_SEG = 15 # cadencia de comprobacion en segundos
|
||||
SNOOZE_MIN = 5 # minutos a posponer si pulsa NO
|
||||
|
||||
|
||||
def cargar():
|
||||
try:
|
||||
return json.loads(DATA.read_text(encoding='utf-8-sig'))
|
||||
except FileNotFoundError:
|
||||
return {'recordatorios': []}
|
||||
|
||||
|
||||
def guardar(data):
|
||||
DATA.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding='utf-8')
|
||||
|
||||
|
||||
def fecha_hoy():
|
||||
return datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def hora_ahora():
|
||||
return datetime.now().strftime('%H:%M')
|
||||
|
||||
|
||||
def msgbox(texto, titulo, yesno=False):
|
||||
"""Muestra un cuadro de dialogo nativo de Windows. Devuelve IDYES/IDNO/1."""
|
||||
flags = MB_SYSTEMMODAL | MB_ICONINFORMATION
|
||||
if yesno:
|
||||
flags |= MB_YESNO
|
||||
else:
|
||||
flags |= MB_OK
|
||||
return ctypes.windll.user32.MessageBoxW(None, texto, titulo, flags)
|
||||
|
||||
|
||||
def es_hora(r):
|
||||
"""Comprueba si toca mostrar el recordatorio."""
|
||||
if r.get('done'):
|
||||
return False
|
||||
if r.get('fecha', 'diario') == 'diario':
|
||||
return True
|
||||
return r.get('fecha') <= fecha_hoy()
|
||||
|
||||
|
||||
def run():
|
||||
data = cargar()
|
||||
ultima_vez = {} # id -> 'HH:MM' ya mostrado hoy (o fecha mostrada)
|
||||
print(f'[recordatorios_msgbox] Monitorizando {len(data["recordatorios"])} recordatorios. Ctrl+C para salir.')
|
||||
while True:
|
||||
data = cargar()
|
||||
ahora = fecha_hoy() + ' ' + hora_ahora()
|
||||
for r in data['recordatorios']:
|
||||
if not es_hora(r):
|
||||
continue
|
||||
# evita repetir en la misma hora ya mostrada para ese id
|
||||
clave = f"{r['id']}-{fecha_hoy()}-{r.get('hora')}"
|
||||
if ultima_vez.get(clave):
|
||||
continue
|
||||
if hora_ahora() >= r.get('hora', ''):
|
||||
ultima_vez[clave] = True
|
||||
texto = r.get('texto', '')
|
||||
res = msgbox(texto, f'RECORDATORIO {r.get("hora")}', yesno=True)
|
||||
if res == IDYES:
|
||||
r['done'] = True
|
||||
guardar(data)
|
||||
print(f'[{ahora}] #{r["id"]} marcado como HECHO: {texto[:60]}')
|
||||
else:
|
||||
# snooze: fuerza a no repetir en los proximos SNOOZE_MIN minutos
|
||||
ultima_vez[clave] = True
|
||||
print(f'[{ahora}] #{r["id"]} pospuesto (NO).')
|
||||
time.sleep(SLEEP_SEG)
|
||||
|
||||
|
||||
def listar():
|
||||
data = cargar()
|
||||
if not data['recordatorios']:
|
||||
print('No hay recordatorios.')
|
||||
return
|
||||
print(f'{"ID":>3} {"HORA":>5} {"FECHA":<12} {"ESTADO":<10} TAREA')
|
||||
for r in sorted(data['recordatorios'], key=lambda x: (x.get('hora', ''), x.get('id', 0))):
|
||||
estado = 'HECHO' if r.get('done') else 'PENDIENTE'
|
||||
print(f'{r.get("id", 0):>3} {r.get("hora", ""):>5} {str(r.get("fecha", "")):<12} {estado:<10} {r.get("texto", "")[:70]}')
|
||||
|
||||
|
||||
def marcar_done(i):
|
||||
data = cargar()
|
||||
for r in data['recordatorios']:
|
||||
if r.get('id') == i:
|
||||
r['done'] = True
|
||||
guardar(data)
|
||||
print(f'#{i} marcado como terminado. Ya no saldra mas.')
|
||||
return
|
||||
print(f'No existe el recordatorio #{i}')
|
||||
|
||||
|
||||
def add(texto, hora, fecha):
|
||||
data = cargar()
|
||||
ids = [r.get('id', 0) for r in data['recordatorios']]
|
||||
nid = max(ids) + 1 if ids else 1
|
||||
data['recordatorios'].append({'id': nid, 'texto': texto, 'fecha': fecha, 'hora': hora, 'done': False})
|
||||
guardar(data)
|
||||
print(f'Recordatorio #{nid} anadido: {hora} [{fecha}] {texto}')
|
||||
|
||||
|
||||
def test(i):
|
||||
data = cargar()
|
||||
r = next((x for x in data['recordatorios'] if x.get('id') == i), None)
|
||||
if not r:
|
||||
print(f'No existe el recordatorio #{i}')
|
||||
return
|
||||
res = msgbox(r.get('texto', ''), f'TEST RECORDATORIO {r.get("hora")}', yesno=True)
|
||||
if res == IDYES:
|
||||
r['done'] = True
|
||||
guardar(data)
|
||||
print(f'#{i} marcado como HECHO (test).')
|
||||
|
||||
|
||||
def sync():
|
||||
"""Crea recordatorios desde pendientes.json: tareas activas con campo 'hora' (HH:MM).
|
||||
La fecha sale del campo 'date' del pendiente o 'diario' si es de hoy."""
|
||||
try:
|
||||
data = cargar()
|
||||
pends = json.loads(PENDIENTES.read_text(encoding='utf-8-sig'))
|
||||
except FileNotFoundError:
|
||||
print(f'[sync] No existe {PENDIENTES.name}. Nada que sincronizar.')
|
||||
return
|
||||
hoy = fecha_hoy()
|
||||
creados = 0
|
||||
for p in pends.get('pending', []):
|
||||
if p.get('done'):
|
||||
continue
|
||||
hora = p.get('hora')
|
||||
if not hora:
|
||||
continue
|
||||
fecha_p = p.get('date', hoy)
|
||||
texto = p.get('task', '')
|
||||
# evita duplicados: mismo pendiente_id, o mismo texto y misma hora
|
||||
pid = p.get('id')
|
||||
if any(r.get('pendiente_id') == pid for r in data['recordatorios']):
|
||||
continue
|
||||
if any(r.get('texto') == texto and r.get('hora') == hora for r in data['recordatorios']):
|
||||
continue
|
||||
ids = [r.get('id', 0) for r in data['recordatorios']]
|
||||
nid = max(ids) + 1 if ids else 1
|
||||
data['recordatorios'].append({
|
||||
'id': nid, 'texto': f'[pendiente #{p.get("id")}] {texto}',
|
||||
'fecha': fecha_p if fecha_p else 'diario', 'hora': hora, 'done': False,
|
||||
'pendiente_id': p.get('id')
|
||||
})
|
||||
creados += 1
|
||||
print(f'[sync] #{nid} creado desde pendiente #{p.get("id")}: {hora} {texto[:60]}')
|
||||
if creados:
|
||||
guardar(data)
|
||||
print(f'[sync] Sincronizacion completa. {creados} recordatorio(s) nuevo(s).')
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='Recordatorios con message box')
|
||||
ap.add_argument('--listar', action='store_true', help='listar estado de los recordatorios')
|
||||
ap.add_argument('--done', type=int, metavar='ID', help='marcar recordatorio como terminado')
|
||||
ap.add_argument('--add', metavar='TEXTO', help='anadir recordatorio (usa --hora y --fecha)')
|
||||
ap.add_argument('--hora', default='12:00', help='hora HH:MM (por defecto 12:00)')
|
||||
ap.add_argument('--fecha', default='diario', help='fecha YYYY-MM-DD o "diario" (por defecto diario)')
|
||||
ap.add_argument('--test', type=int, metavar='ID', help='mostrar el cuadro ahora (sin esperar hora)')
|
||||
ap.add_argument('--sync', action='store_true', help='crear recordatorios desde pendientes.json con hora')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.sync:
|
||||
sync()
|
||||
elif args.listar:
|
||||
listar()
|
||||
elif args.done is not None:
|
||||
marcar_done(args.done)
|
||||
elif args.add:
|
||||
add(args.add, args.hora, args.fecha)
|
||||
elif args.test is not None:
|
||||
test(args.test)
|
||||
else:
|
||||
run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
main()
|
||||
482
scripts/telegram_watcher.py
Normal file
482
scripts/telegram_watcher.py
Normal file
@@ -0,0 +1,482 @@
|
||||
"""
|
||||
telegram_watcher.py — Muestra mensajes entrantes de Telegram en consola y gestiona canal IDD.
|
||||
Avisos exclusivos al canal IDD:
|
||||
- Aviso de conexión de Telegram al canal IDD
|
||||
- Alertas de lectura (doble check) de contactos vigilados al canal IDD
|
||||
- Solo contestar comandos dirigidos al canal IDD
|
||||
- Soporte para silenciar/activar avisos (/silenciar, /activar)
|
||||
"""
|
||||
import asyncio, sys, os, json, time, re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
C = {
|
||||
'red': '\033[91m', 'green': '\033[92m', 'yellow': '\033[93m',
|
||||
'blue': '\033[94m', 'magenta': '\033[95m', 'cyan': '\033[96m',
|
||||
'white': '\033[97m', 'bold': '\033[1m', 'reset': '\033[0m',
|
||||
'bg_red': '\033[41m',
|
||||
}
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
import infra_paths # noqa: E402 (resolucion de rutas del repo INFRAESTRUCTURA)
|
||||
|
||||
ESTADO_DIR = infra_paths.ESTADO_DIR
|
||||
BIBLIOTECA_REPO = infra_paths.bootstrap_biblioteca_paths()
|
||||
LOG_FILE = ESTADO_DIR / 'telegram_mensajes.log'
|
||||
LAST_ID_FILE = ESTADO_DIR / '.watcher_last_id'
|
||||
TRACKED_USERS_FILE = ESTADO_DIR / 'telegram_tracked_users.json'
|
||||
|
||||
ALERT_KEYWORDS = ['guardia', 'crítico', 'urgente', 'caído', 'error', 'sin asignar',
|
||||
'RELEVANTE', 'incidencia', 'alarma', 'paro', 'parada']
|
||||
|
||||
ALARMA_COMMANDS = ['/alarma', '!alarma', '.alarma']
|
||||
ALARMA_TWILIO_DIR = BIBLIOTECA_REPO / 'alarma_twilio'
|
||||
|
||||
SILENCIAR_COMMANDS = ['/silenciar', '!silenciar', '/mute', '!mute', '/pausar', '!pausar']
|
||||
ACTIVAR_COMMANDS = ['/activar', '!activar', '/unmute', '!unmute', '/reanudar', '!reanudar']
|
||||
|
||||
NETATMO_COMMANDS = [
|
||||
'/domotica', '!domotica', '/menu', '!menu',
|
||||
'/netatmo', '!netatmo', '/ambiente', '!ambiente', '/clima', '!clima', '/sensores', '!sensores',
|
||||
'/temperatura', '!temperatura', '/temp', '!temp',
|
||||
'/humedad', '!humedad', '/hum', '!hum',
|
||||
'/co2', '!co2',
|
||||
'/ruido', '!ruido',
|
||||
'/anunciar', '!anunciar',
|
||||
'/alertas', '!alertas'
|
||||
]
|
||||
|
||||
IDD_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID_2', '-1003967773087')
|
||||
|
||||
def load_tracked_config():
|
||||
defaults = {
|
||||
"contactos_vigilados": ["Romero", "Fernandez", "Dani", "Oscar", "Maribel"],
|
||||
"avisar_canal_idd": True,
|
||||
"silenciado": False,
|
||||
"silenciado_hasta": 0,
|
||||
"avisar_alexa": False,
|
||||
"avisar_sonido_pc": False,
|
||||
"cooldown_segundos": 60
|
||||
}
|
||||
if TRACKED_USERS_FILE.exists():
|
||||
try:
|
||||
with open(TRACKED_USERS_FILE, "r", encoding="utf-8") as f:
|
||||
c = json.load(f)
|
||||
defaults.update(c)
|
||||
except Exception:
|
||||
pass
|
||||
return defaults
|
||||
|
||||
def save_tracked_config(c):
|
||||
try:
|
||||
with open(TRACKED_USERS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(c, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error guardando config tracked: {e}", file=sys.stderr)
|
||||
|
||||
def is_silenciado():
|
||||
c = load_tracked_config()
|
||||
if c.get("silenciado", False):
|
||||
hasta = c.get("silenciado_hasta", 0)
|
||||
if hasta == 0:
|
||||
return True
|
||||
if time.time() < hasta:
|
||||
return True
|
||||
else:
|
||||
c["silenciado"] = False
|
||||
c["silenciado_hasta"] = 0
|
||||
save_tracked_config(c)
|
||||
return False
|
||||
return False
|
||||
|
||||
def play_beep():
|
||||
try:
|
||||
import winsound
|
||||
winsound.Beep(1000, 200)
|
||||
except:
|
||||
pass
|
||||
|
||||
def send_to_idd(text, parse_mode="Markdown"):
|
||||
"""Envia mensaje estrictamente al canal IDD."""
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
try:
|
||||
import idd_channel
|
||||
return idd_channel.send_message(text)
|
||||
except Exception as e:
|
||||
print(f"Error enviando a IDD: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def log_message(chat_name, sender_name, text, is_alert):
|
||||
try:
|
||||
entry = json.dumps({
|
||||
'ts': datetime.now().isoformat(),
|
||||
'chat': chat_name,
|
||||
'sender': sender_name,
|
||||
'text': text[:500] if text else '',
|
||||
'alert': is_alert,
|
||||
}, ensure_ascii=False)
|
||||
with open(LOG_FILE, 'a', encoding='utf-8') as f:
|
||||
f.write(entry + '\n')
|
||||
except Exception as e:
|
||||
print(f'{C["red"]}Error log: {e}{C["reset"]}')
|
||||
|
||||
async def handle_silenciar_command(text):
|
||||
c = load_tracked_config()
|
||||
parts = text.strip().split()
|
||||
segundos = 0
|
||||
tiempo_str = "de forma indefinida (hasta /activar)"
|
||||
|
||||
if len(parts) > 1:
|
||||
param = parts[1].lower()
|
||||
m_h = re.match(r'^(\d+)\s*h$', param)
|
||||
m_m = re.match(r'^(\d+)\s*m$', param)
|
||||
if m_h:
|
||||
horas = int(m_h.group(1))
|
||||
segundos = horas * 3600
|
||||
tiempo_str = f"por {horas} hora(s)"
|
||||
elif m_m:
|
||||
minutos = int(m_m.group(1))
|
||||
segundos = minutos * 60
|
||||
tiempo_str = f"por {minutos} minuto(s)"
|
||||
|
||||
c["silenciado"] = True
|
||||
c["silenciado_hasta"] = int(time.time() + segundos) if segundos > 0 else 0
|
||||
save_tracked_config(c)
|
||||
|
||||
send_to_idd(f"🔕 *Avisos Silenciados*\nLos avisos de lectura y alertas en IDD han sido silenciados {tiempo_str}.\nUsa `/activar` para reanudar.")
|
||||
|
||||
async def handle_activar_command(text):
|
||||
c = load_tracked_config()
|
||||
c["silenciado"] = False
|
||||
c["silenciado_hasta"] = 0
|
||||
save_tracked_config(c)
|
||||
send_to_idd("🔔 *Avisos Reactivados*\nLas notificaciones de lectura y alertas ambientales vuelven a estar activas en este canal.")
|
||||
|
||||
async def handle_alarma_command(text, chat_name, sender_name, client, dialog):
|
||||
parts = text.strip().split(maxsplit=2)
|
||||
if len(parts) < 2:
|
||||
send_to_idd("ℹ️ Usos:\n!alarma +34657866417\n!alarma +34657866417 Mensaje personalizado")
|
||||
return
|
||||
|
||||
numero = parts[1]
|
||||
mensaje = parts[2] if len(parts) >= 3 else None
|
||||
|
||||
env_path = ALARMA_TWILIO_DIR / '.env'
|
||||
if env_path.exists():
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(env_path)
|
||||
|
||||
sys.path.insert(0, str(ALARMA_TWILIO_DIR))
|
||||
from config import validate_config
|
||||
from twilio_service import TwilioAlarmaService
|
||||
|
||||
errores = validate_config()
|
||||
if errores:
|
||||
send_to_idd("❌ Twilio no configurado:\n" + "\n".join(errores))
|
||||
return
|
||||
|
||||
msg = f"📞 Llamando a {numero}..."
|
||||
if mensaje:
|
||||
msg += f"\n📝 Mensaje: {mensaje[:80]}"
|
||||
send_to_idd(msg)
|
||||
|
||||
def ejecutar():
|
||||
svc = TwilioAlarmaService()
|
||||
sid = svc.lanzar_llamada(numero, mensaje)
|
||||
estado, contestado = svc.esperar_resultado(sid)
|
||||
return estado, contestado
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
estado, contestado = await loop.run_in_executor(None, ejecutar)
|
||||
|
||||
emoji_map = {
|
||||
"completed": "✅", "busy": "📞", "no-answer": "📵",
|
||||
"failed": "❌", "canceled": "🚫", "timeout": "⏰",
|
||||
}
|
||||
emoji = emoji_map.get(estado, "❓")
|
||||
desc = f"{emoji} {numero}: {estado}"
|
||||
if contestado:
|
||||
desc += f" ({contestado})"
|
||||
if contestado == "human":
|
||||
desc += " 🎯 Humano detectado"
|
||||
elif contestado and "machine" in contestado:
|
||||
desc += " 🤖 Contestador automático"
|
||||
|
||||
send_to_idd(desc)
|
||||
|
||||
async def handle_netatmo_command(text, chat_name, sender_name, client, dialog):
|
||||
cmd = text.strip().lower().split()[0]
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
import netatmo_tool
|
||||
|
||||
def consultar():
|
||||
try:
|
||||
token = netatmo_tool.get_access_token()
|
||||
data = netatmo_tool.get_homecoach_data(token)
|
||||
sensores = netatmo_tool.extraer_sensores(data)
|
||||
cfg = netatmo_tool.load_config()
|
||||
return sensores, cfg, None
|
||||
except Exception as e:
|
||||
return None, None, str(e)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
sensores, cfg, error = await loop.run_in_executor(None, consultar)
|
||||
|
||||
if error:
|
||||
send_to_idd(f"⚠️ Error consultando Netatmo: {error}")
|
||||
return
|
||||
|
||||
if cmd in ('/temperatura', '!temperatura', '/temp', '!temp'):
|
||||
msg = netatmo_tool.generar_mensaje_telegram("temp", sensores, cfg)
|
||||
elif cmd in ('/humedad', '!humedad', '/hum', '!hum'):
|
||||
msg = netatmo_tool.generar_mensaje_telegram("hum", sensores, cfg)
|
||||
elif cmd in ('/co2', '!co2'):
|
||||
msg = netatmo_tool.generar_mensaje_telegram("co2", sensores, cfg)
|
||||
elif cmd in ('/ruido', '!ruido'):
|
||||
msg = netatmo_tool.generar_mensaje_telegram("ruido", sensores, cfg)
|
||||
elif cmd in ('/anunciar', '!anunciar'):
|
||||
netatmo_tool.cmd_anunciar(sensores)
|
||||
msg = "📢 <b>Anuncio enviado a Alexa:</b>\nTemperatura y calidad ambiental leídas por altavoz Echo."
|
||||
elif cmd in ('/alertas', '!alertas'):
|
||||
alertas_voz, alertas_tg = netatmo_tool.evaluar_alertas(sensores, cfg)
|
||||
if alertas_tg:
|
||||
msg = "🚨 <b>PARÁMETROS FUERA DE RANGO:</b>\n" + "\n".join(f"• {a}" for a in alertas_tg)
|
||||
else:
|
||||
msg = "✅ <b>Todos los parámetros están dentro del rango seguro configurado.</b>"
|
||||
else:
|
||||
msg = netatmo_tool.generar_mensaje_telegram("menu", sensores, cfg)
|
||||
|
||||
netatmo_tool.notificar_telegram(msg)
|
||||
|
||||
async def main():
|
||||
try:
|
||||
from telethon import TelegramClient, events
|
||||
except ImportError:
|
||||
print(f'{C["red"]}pip install telethon{C["reset"]}')
|
||||
sys.exit(1)
|
||||
|
||||
api_id = int(os.environ.get('TELEGRAM_API_ID', '0'))
|
||||
api_hash = os.environ.get('TELEGRAM_API_HASH', '')
|
||||
if not api_id or not api_hash:
|
||||
import subprocess
|
||||
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:
|
||||
pass
|
||||
|
||||
if not api_id or not api_hash:
|
||||
print(f'{C["red"]}Falta TELEGRAM_API_ID / TELEGRAM_API_HASH{C["reset"]}')
|
||||
sys.exit(1)
|
||||
|
||||
session_file = str(Path.home() / '.telegram-agent' / 'sessions' / 'watcher')
|
||||
client = TelegramClient(session_file, api_id, api_hash)
|
||||
await client.start(phone='+34657866417')
|
||||
|
||||
me = await client.get_me()
|
||||
print(f'{C["green"]}Conectado: {me.first_name} (@{me.username or "sin"}){C["reset"]}')
|
||||
print(f'{C["cyan"]}Canal de avisos exclusivo: IDD ({IDD_CHAT_ID}){C["reset"]}')
|
||||
print(f'{C["cyan"]}Log: {LOG_FILE}{C["reset"]}')
|
||||
print(f'{"─" * 60}\n')
|
||||
|
||||
# Enviar aviso de conexión al canal IDD
|
||||
send_to_idd(f"🟢 *Telegram Conectado* ({me.first_name})\nMonitorizando lecturas de contactos y canal IDD.")
|
||||
|
||||
# Recuperar ultimos IDs procesados por chat
|
||||
last_ids = {}
|
||||
if LAST_ID_FILE.exists():
|
||||
try:
|
||||
last_ids = json.loads(LAST_ID_FILE.read_text())
|
||||
except:
|
||||
pass
|
||||
|
||||
# Registro de eventos de confirmacion de lectura (Doble Check) -> SOLO IDD
|
||||
last_read_triggers = {}
|
||||
@client.on(events.MessageRead(outbox=True))
|
||||
async def on_message_read(event):
|
||||
try:
|
||||
if is_silenciado():
|
||||
return
|
||||
|
||||
c = load_tracked_config()
|
||||
vigilados = c.get("contactos_vigilados", ["Romero", "Fernandez", "Dani", "Oscar", "Maribel"])
|
||||
|
||||
chat = await event.get_chat()
|
||||
chat_title = getattr(chat, "title", "") or getattr(chat, "first_name", "") or str(event.chat_id)
|
||||
username = getattr(chat, "username", "") or ""
|
||||
|
||||
coincide = False
|
||||
nombre_vig = chat_title
|
||||
for v in vigilados:
|
||||
v_clean = v.lower().strip()
|
||||
if v_clean and (v_clean in chat_title.lower() or (username and v_clean in username.lower()) or v_clean in str(event.chat_id)):
|
||||
coincide = True
|
||||
nombre_vig = v
|
||||
break
|
||||
|
||||
if not coincide:
|
||||
return
|
||||
|
||||
now_t = time.time()
|
||||
if (now_t - last_read_triggers.get(event.chat_id, 0)) < c.get("cooldown_segundos", 60):
|
||||
return
|
||||
last_read_triggers[event.chat_id] = now_t
|
||||
|
||||
hora_now = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"\n{C['bg_red']}{C['white']} 👀 LEÍDO {C['reset']} {C['bold']}{C['green']}[{nombre_vig}]{C['reset']} {hora_now} ha leído tu mensaje en '{chat_title}'")
|
||||
|
||||
entry = json.dumps({
|
||||
"ts": datetime.now().isoformat(),
|
||||
"tipo": "LECTURA",
|
||||
"contacto": nombre_vig,
|
||||
"chat": chat_title
|
||||
}, ensure_ascii=False)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(entry + "\n")
|
||||
|
||||
# Avisar exclusivamente al canal IDD
|
||||
msg_idd = (
|
||||
f"👀 *LECTURA CONFIRMADA*\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"• *Contacto:* `{nombre_vig}`\n"
|
||||
f"• *Chat:* {chat_title}\n"
|
||||
f"• *Hora:* {hora_now}"
|
||||
)
|
||||
send_to_idd(msg_idd)
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
print(f'{C["white"]}Escuchando... (Ctrl+C para salir){C["reset"]}\n')
|
||||
|
||||
last_netatmo_check = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Comprobacion periodica de alertas Netatmo (cada 5 minutos = 300s) -> SOLO IDD
|
||||
now = time.time()
|
||||
if now - last_netatmo_check > 300:
|
||||
last_netatmo_check = now
|
||||
try:
|
||||
if not is_silenciado():
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
import netatmo_tool
|
||||
def check_alertas():
|
||||
tok = netatmo_tool.get_access_token()
|
||||
dt = netatmo_tool.get_homecoach_data(tok)
|
||||
sens = netatmo_tool.extraer_sensores(dt)
|
||||
c = netatmo_tool.load_config()
|
||||
return netatmo_tool.evaluar_alertas(sens, c), sens, c
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
(voz, tg), sens, cfg = await loop.run_in_executor(None, check_alertas)
|
||||
if voz:
|
||||
disparar = False
|
||||
cooldown_sec = cfg.get("cooldown_minutos", 30) * 60
|
||||
msg_voz = ". ".join(voz)
|
||||
last_alert_file = ESTADO_DIR / ".netatmo_last_alert.json"
|
||||
if last_alert_file.exists():
|
||||
try:
|
||||
with open(last_alert_file, "r", encoding="utf-8") as f:
|
||||
last_d = json.load(f)
|
||||
if last_d.get("msg") != msg_voz or (now - last_d.get("timestamp", 0)) >= cooldown_sec:
|
||||
disparar = True
|
||||
except Exception:
|
||||
disparar = True
|
||||
else:
|
||||
disparar = True
|
||||
|
||||
if disparar:
|
||||
# Notificar al canal IDD
|
||||
alerta_html = (
|
||||
f"🚨 <b>ALERTA DOMÓTICA — {sens[0].get('nombre', 'Netatmo')}</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
+ "\n".join(f"• {a}" for a in tg) + "\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<i>Detectado a las {datetime.now().strftime('%H:%M:%S')}</i>"
|
||||
)
|
||||
netatmo_tool.notificar_telegram(alerta_html)
|
||||
with open(last_alert_file, "w", encoding="utf-8") as f:
|
||||
json.dump({"timestamp": now, "msg": msg_voz, "fecha": datetime.now().isoformat()}, f, indent=2)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
dialogs = await client.get_dialogs(limit=30)
|
||||
|
||||
for dialog in dialogs:
|
||||
try:
|
||||
dialog_key = str(dialog.id)
|
||||
last_id = last_ids.get(dialog_key, 0)
|
||||
messages = await client.get_messages(dialog, limit=5)
|
||||
new_max = last_id
|
||||
|
||||
for msg in reversed(messages):
|
||||
if msg.id <= last_id or not msg.message:
|
||||
continue
|
||||
new_max = max(new_max, msg.id)
|
||||
|
||||
chat_name = dialog.name or str(dialog.id)
|
||||
sender = await msg.get_sender()
|
||||
sender_name = getattr(sender, 'first_name', '') or getattr(sender, 'title', '') or str(msg.sender_id) if sender else '??'
|
||||
text = msg.message
|
||||
is_alert = any(kw in (text or '').lower() for kw in ALERT_KEYWORDS)
|
||||
|
||||
alert_prefix = f'{C["bg_red"]}{C["white"]} ALERTA {C["reset"]} ' if is_alert else ''
|
||||
chat_color = C['cyan'] if 'jira' in chat_name.lower() or 'informe' in chat_name.lower() else C['magenta'] if 'alerta' in chat_name.lower() else C['white']
|
||||
|
||||
ts = msg.date.strftime('%H:%M:%S') if msg.date else '--:--:--'
|
||||
print(f'\n{alert_prefix}{C["bold"]}{chat_color}[{chat_name}]{C["reset"]} {ts} {C["yellow"]}{sender_name}{C["reset"]}')
|
||||
for line in text.split('\n')[:15]:
|
||||
print(f' {chat_color}│{C["reset"]} {line}')
|
||||
if len(text.split('\n')) > 15:
|
||||
print(f' {chat_color}│{C["reset"]} ...')
|
||||
|
||||
log_message(chat_name, sender_name, text, is_alert)
|
||||
if is_alert:
|
||||
play_beep()
|
||||
|
||||
# Solo procesar comandos si vienen del canal IDD o Saved Messages
|
||||
is_idd_channel = (str(dialog.id) == str(IDD_CHAT_ID) or chat_name == "IDD" or "idd" in chat_name.lower())
|
||||
if text and is_idd_channel:
|
||||
clean_text = text.strip()
|
||||
if any(clean_text.startswith(cmd) for cmd in SILENCIAR_COMMANDS):
|
||||
await handle_silenciar_command(clean_text)
|
||||
elif any(clean_text.startswith(cmd) for cmd in ACTIVAR_COMMANDS):
|
||||
await handle_activar_command(clean_text)
|
||||
elif any(clean_text.startswith(cmd) for cmd in ALARMA_COMMANDS):
|
||||
await handle_alarma_command(text, chat_name, sender_name, client, dialog)
|
||||
elif any(clean_text.startswith(cmd) for cmd in NETATMO_COMMANDS):
|
||||
await handle_netatmo_command(text, chat_name, sender_name, client, dialog)
|
||||
|
||||
if new_max > last_id:
|
||||
last_ids[dialog_key] = new_max
|
||||
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if 'FLOOD_WAIT' in err:
|
||||
wait = int(''.join(c for c in err if c.isdigit()) or '5')
|
||||
print(f'{C["yellow"]}Rate limit {wait}s...{C["reset"]}')
|
||||
await asyncio.sleep(wait)
|
||||
elif 'AUTH_KEY' not in err and 'database' not in err.lower():
|
||||
pass
|
||||
|
||||
LAST_ID_FILE.write_text(json.dumps(last_ids))
|
||||
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if 'FLOOD' in err:
|
||||
print(f'{C["yellow"]}Flood, esperando 10s...{C["reset"]}')
|
||||
else:
|
||||
print(f'{C["red"]}Error: {err[:100]}{C["reset"]}')
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print(f'\n{C["yellow"]}Cerrado.{C["reset"]}')
|
||||
Reference in New Issue
Block a user