@innvisor/conny-ai 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
package/conny-omni.py
ADDED
|
@@ -0,0 +1,3843 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
conny-omni.py — El Ojo que Todo lo Ve v2.0
|
|
4
|
+
|
|
5
|
+
Centro de comando y control para todas las instancias Conny.
|
|
6
|
+
Solo habla con Santiago. Vigila, analiza, predice y actúa.
|
|
7
|
+
|
|
8
|
+
CAPACIDADES:
|
|
9
|
+
• Monitoreo en tiempo real de todas las instancias
|
|
10
|
+
• Métricas históricas con tendencias y predicciones
|
|
11
|
+
• Auto-healing: reinicia instancias caídas automáticamente
|
|
12
|
+
• Alertas inteligentes con prioridades y agrupación
|
|
13
|
+
• Analytics profundo: conversión, sentimiento, anomalías
|
|
14
|
+
• Multi-canal: Telegram, Slack, Discord, Email, Webhooks
|
|
15
|
+
• Dashboard web embebido
|
|
16
|
+
• Gestión completa desde chat natural
|
|
17
|
+
• Audit log de todas las acciones
|
|
18
|
+
• Reportes automáticos: diarios, semanales, mensuales
|
|
19
|
+
• Workflows automatizados
|
|
20
|
+
|
|
21
|
+
MODOS:
|
|
22
|
+
python3 conny-omni.py server → servidor completo (puerto 9001)
|
|
23
|
+
python3 conny-omni.py chat → chat terminal con Omni
|
|
24
|
+
python3 conny-omni.py status → estado rápido
|
|
25
|
+
python3 conny-omni.py watch → monitor live
|
|
26
|
+
python3 conny-omni.py dashboard → dashboard en terminal
|
|
27
|
+
python3 conny-omni.py report → generar reporte
|
|
28
|
+
python3 conny-omni.py analyze → análisis profundo
|
|
29
|
+
python3 conny-omni.py alerts → gestionar alertas
|
|
30
|
+
python3 conny-omni.py logs → ver audit log
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
import re
|
|
40
|
+
import time
|
|
41
|
+
import threading
|
|
42
|
+
import shutil
|
|
43
|
+
import sqlite3
|
|
44
|
+
import hashlib
|
|
45
|
+
import smtplib
|
|
46
|
+
import signal
|
|
47
|
+
from collections import defaultdict
|
|
48
|
+
from dataclasses import dataclass, field, asdict
|
|
49
|
+
from datetime import datetime, timedelta
|
|
50
|
+
from email.mime.text import MIMEText
|
|
51
|
+
from email.mime.multipart import MIMEMultipart
|
|
52
|
+
from functools import lru_cache
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Dict, List, Optional, Any, Callable, Tuple
|
|
55
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
56
|
+
import urllib.request
|
|
57
|
+
import statistics
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
import httpx
|
|
61
|
+
_HTTPX = True
|
|
62
|
+
except ImportError:
|
|
63
|
+
_HTTPX = False
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from fastapi import FastAPI, Request, Response, BackgroundTasks, WebSocket
|
|
67
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
68
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
69
|
+
from contextlib import asynccontextmanager
|
|
70
|
+
import uvicorn
|
|
71
|
+
HAS_FASTAPI = True
|
|
72
|
+
except ImportError:
|
|
73
|
+
HAS_FASTAPI = False
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
import readline
|
|
77
|
+
HIST_FILE = Path.home() / ".conny" / "omni_history"
|
|
78
|
+
HIST_FILE.parent.mkdir(exist_ok=True)
|
|
79
|
+
if HIST_FILE.exists():
|
|
80
|
+
readline.read_history_file(str(HIST_FILE))
|
|
81
|
+
readline.set_history_length(500)
|
|
82
|
+
import atexit
|
|
83
|
+
atexit.register(readline.write_history_file, str(HIST_FILE))
|
|
84
|
+
except ImportError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
88
|
+
# CONFIGURACIÓN
|
|
89
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
90
|
+
OMNI_VERSION = "2.0.0"
|
|
91
|
+
TEMPLATE_DIR = os.getenv("CONNY_DIR", "/home/ubuntu/conny")
|
|
92
|
+
INSTANCES_DIR = os.getenv("INSTANCES_DIR", "/home/ubuntu/conny-instances")
|
|
93
|
+
|
|
94
|
+
# ── Cargar .env maestro antes de leer variables ───────────────────────────────
|
|
95
|
+
# PM2 no carga el .env automáticamente — lo hacemos aquí manualmente
|
|
96
|
+
def _load_master_env():
|
|
97
|
+
"""Lee el .env maestro y exporta sus variables al entorno del proceso."""
|
|
98
|
+
env_path = os.path.join(TEMPLATE_DIR, ".env")
|
|
99
|
+
if not os.path.exists(env_path):
|
|
100
|
+
return
|
|
101
|
+
try:
|
|
102
|
+
with open(env_path) as f:
|
|
103
|
+
for line in f:
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
106
|
+
continue
|
|
107
|
+
key, _, val = line.partition("=")
|
|
108
|
+
key = key.strip()
|
|
109
|
+
val = val.strip().strip('"').strip("'")
|
|
110
|
+
# Solo setear si no está ya en el entorno (no pisar vars de PM2)
|
|
111
|
+
if key and val and key not in os.environ:
|
|
112
|
+
os.environ[key] = val
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
_load_master_env()
|
|
117
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
OMNI_PORT = int(os.getenv("OMNI_PORT", "9001"))
|
|
120
|
+
OMNI_KEY = os.getenv("OMNI_KEY", "omni_secret_change_me")
|
|
121
|
+
SANTIAGO_CHAT = os.getenv("SANTIAGO_CHAT_ID", "")
|
|
122
|
+
OMNI_TOKEN = os.getenv("OMNI_TELEGRAM_TOKEN", "")
|
|
123
|
+
NOVA_PORT = int(os.getenv("NOVA_PORT", "9002"))
|
|
124
|
+
|
|
125
|
+
# Intervalos
|
|
126
|
+
HEALTH_INTERVAL = int(os.getenv("OMNI_HEALTH_INTERVAL", "30"))
|
|
127
|
+
METRICS_INTERVAL = int(os.getenv("OMNI_METRICS_INTERVAL", "300")) # 5 min
|
|
128
|
+
AUTO_HEAL_ENABLED = os.getenv("OMNI_AUTO_HEAL", "true").lower() == "true"
|
|
129
|
+
AUTO_HEAL_DELAY = int(os.getenv("OMNI_AUTO_HEAL_DELAY", "120")) # esperar 2 min antes de reiniciar
|
|
130
|
+
|
|
131
|
+
# Notificaciones
|
|
132
|
+
SLACK_WEBHOOK = os.getenv("OMNI_SLACK_WEBHOOK", "")
|
|
133
|
+
DISCORD_WEBHOOK = os.getenv("OMNI_DISCORD_WEBHOOK", "")
|
|
134
|
+
PAGERDUTY_KEY = os.getenv("OMNI_PAGERDUTY_KEY", "")
|
|
135
|
+
SMTP_HOST = os.getenv("OMNI_SMTP_HOST", "")
|
|
136
|
+
SMTP_PORT = int(os.getenv("OMNI_SMTP_PORT", "587"))
|
|
137
|
+
SMTP_USER = os.getenv("OMNI_SMTP_USER", "")
|
|
138
|
+
SMTP_PASS = os.getenv("OMNI_SMTP_PASS", "")
|
|
139
|
+
ALERT_EMAIL = os.getenv("OMNI_ALERT_EMAIL", "")
|
|
140
|
+
CUSTOM_WEBHOOK = os.getenv("OMNI_CUSTOM_WEBHOOK", "")
|
|
141
|
+
|
|
142
|
+
# Base de datos
|
|
143
|
+
OMNI_DB = Path.home() / ".conny" / "omni.db"
|
|
144
|
+
OMNI_DB.parent.mkdir(exist_ok=True)
|
|
145
|
+
|
|
146
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
# COLORES
|
|
148
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
def _tty(): return sys.stdout.isatty() or bool(os.getenv("FORCE_COLOR"))
|
|
150
|
+
def _e(c): return f"\033[{c}m" if _tty() else ""
|
|
151
|
+
|
|
152
|
+
class C:
|
|
153
|
+
R = _e("0"); BOLD = _e("1"); DIM = _e("2"); ITALIC = _e("3")
|
|
154
|
+
P1 = _e("38;2;139;92;246"); P2 = _e("38;2;236;72;153"); P3 = _e("38;5;135")
|
|
155
|
+
P4 = _e("38;5;99"); P5 = _e("38;5;57")
|
|
156
|
+
W = _e("38;5;15"); G0 = _e("38;5;252"); G1 = _e("38;5;248")
|
|
157
|
+
G2 = _e("38;5;244"); G3 = _e("38;5;240"); G4 = _e("38;5;236")
|
|
158
|
+
GRN = _e("38;5;114"); RED = _e("38;5;203")
|
|
159
|
+
YLW = _e("38;5;221"); CYN = _e("38;5;117"); AMB = _e("38;2;236;72;153")
|
|
160
|
+
BLU = _e("38;5;75"); MAG = _e("38;2;139;92;246"); ORG = _e("38;2;236;72;153")
|
|
161
|
+
|
|
162
|
+
def q(color, text, bold=False):
|
|
163
|
+
return f"{C.BOLD if bold else ''}{color}{text}{C.R}"
|
|
164
|
+
|
|
165
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
166
|
+
# SECTORES (sincronizado con CLI)
|
|
167
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
168
|
+
SECTORS = {
|
|
169
|
+
"estetica": ("💉", "Clínica Estética", "183"),
|
|
170
|
+
"dental": ("🦷", "Clínica Dental", "117"),
|
|
171
|
+
"veterinaria": ("🐾", "Veterinaria", "114"),
|
|
172
|
+
"restaurante": ("🍽️", "Restaurante", "221"),
|
|
173
|
+
"hotel": ("🏨", "Hotel", "179"),
|
|
174
|
+
"gimnasio": ("💪", "Gimnasio", "203"),
|
|
175
|
+
"belleza": ("💇", "Salón de Belleza", "177"),
|
|
176
|
+
"spa": ("🧖", "Spa", "141"),
|
|
177
|
+
"medico": ("🩺", "Consultorio Médico", "117"),
|
|
178
|
+
"psicologo": ("🧠", "Psicología", "135"),
|
|
179
|
+
"abogado": ("⚖️", "Legal", "99"),
|
|
180
|
+
"inmobiliaria": ("🏠", "Inmobiliaria", "179"),
|
|
181
|
+
"taller": ("🔧", "Taller", "208"),
|
|
182
|
+
"academia": ("📚", "Academia", "221"),
|
|
183
|
+
"nutricion": ("🥗", "Nutrición", "114"),
|
|
184
|
+
"fisioterapia": ("🦴", "Fisioterapia", "117"),
|
|
185
|
+
"fotografia": ("📸", "Fotografía", "183"),
|
|
186
|
+
"coworking": ("🏢", "Coworking", "141"),
|
|
187
|
+
"tattoo": ("🎨", "Tattoo", "99"),
|
|
188
|
+
"otro": ("⚙️", "Otro", "248"),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def get_sector_info(sector_id: str) -> Tuple[str, str, str]:
|
|
192
|
+
return SECTORS.get(sector_id, SECTORS["otro"])
|
|
193
|
+
|
|
194
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
195
|
+
# UI PRIMITIVES
|
|
196
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
197
|
+
def ok(m): print(f" {q(C.GRN, '✓')} {q(C.W, m)}")
|
|
198
|
+
def fail(m): print(f" {q(C.RED, '✗')} {q(C.W, m)}")
|
|
199
|
+
def warn(m): print(f" {q(C.YLW, '!')} {q(C.G1, m)}")
|
|
200
|
+
def info(m): print(f" {q(C.P2, '·')} {q(C.G1, m)}")
|
|
201
|
+
def dim(m): print(f" {q(C.G3, m)}")
|
|
202
|
+
def nl(): print()
|
|
203
|
+
def hr(): print(" " + q(C.G3, "─" * 58))
|
|
204
|
+
|
|
205
|
+
def section(title, sub="", icon="✦"):
|
|
206
|
+
print()
|
|
207
|
+
print(f" {q(C.P1, icon, bold=True)} {q(C.W, title, bold=True)}")
|
|
208
|
+
if sub:
|
|
209
|
+
print(f" {q(C.G2, sub)}")
|
|
210
|
+
print()
|
|
211
|
+
|
|
212
|
+
def kv(key, val, color=None):
|
|
213
|
+
c = color or C.P2
|
|
214
|
+
print(f" {q(C.G2, f'{key:<22}')} {q(c, str(val))}")
|
|
215
|
+
|
|
216
|
+
def table(headers, rows, colors=None):
|
|
217
|
+
if not rows:
|
|
218
|
+
return
|
|
219
|
+
widths = [len(h) for h in headers]
|
|
220
|
+
for row in rows:
|
|
221
|
+
for i, cell in enumerate(row):
|
|
222
|
+
if i < len(widths):
|
|
223
|
+
clean = re.sub(r'\033\[[0-9;]*m', '', str(cell))
|
|
224
|
+
widths[i] = max(widths[i], len(clean))
|
|
225
|
+
|
|
226
|
+
header_line = " "
|
|
227
|
+
for i, h in enumerate(headers):
|
|
228
|
+
header_line += f"{q(C.G3, h.ljust(widths[i]))} "
|
|
229
|
+
print(header_line)
|
|
230
|
+
print(f" {q(C.G4, '─' * (sum(widths) + len(widths) * 2))}")
|
|
231
|
+
|
|
232
|
+
for row in rows:
|
|
233
|
+
line = " "
|
|
234
|
+
for i, cell in enumerate(row):
|
|
235
|
+
clean = re.sub(r'\033\[[0-9;]*m', '', str(cell))
|
|
236
|
+
padding = widths[i] - len(clean)
|
|
237
|
+
line += f"{cell}{' ' * padding} "
|
|
238
|
+
print(line)
|
|
239
|
+
|
|
240
|
+
def progress_bar(current, total, width=30, label=""):
|
|
241
|
+
pct = current / total if total > 0 else 0
|
|
242
|
+
filled = int(width * pct)
|
|
243
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
244
|
+
return f"{q(C.P2, bar)} {q(C.W, f'{pct*100:.0f}%')} {q(C.G3, label)}"
|
|
245
|
+
|
|
246
|
+
def spark_line(values: List[float], width: int = 20) -> str:
|
|
247
|
+
"""Mini gráfico de línea con caracteres."""
|
|
248
|
+
if not values:
|
|
249
|
+
return "─" * width
|
|
250
|
+
|
|
251
|
+
blocks = "▁▂▃▄▅▆▇█"
|
|
252
|
+
min_v, max_v = min(values), max(values)
|
|
253
|
+
range_v = max_v - min_v if max_v != min_v else 1
|
|
254
|
+
|
|
255
|
+
# Resample to width
|
|
256
|
+
step = len(values) / width
|
|
257
|
+
result = ""
|
|
258
|
+
for i in range(width):
|
|
259
|
+
idx = int(i * step)
|
|
260
|
+
v = values[min(idx, len(values) - 1)]
|
|
261
|
+
normalized = (v - min_v) / range_v
|
|
262
|
+
block_idx = int(normalized * (len(blocks) - 1))
|
|
263
|
+
result += blocks[block_idx]
|
|
264
|
+
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
def ascii_chart(data: Dict[str, float], width: int = 40) -> List[str]:
|
|
268
|
+
"""Gráfico de barras horizontal ASCII."""
|
|
269
|
+
if not data:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
max_val = max(data.values()) if data.values() else 1
|
|
273
|
+
max_label = max(len(str(k)) for k in data.keys())
|
|
274
|
+
lines = []
|
|
275
|
+
|
|
276
|
+
for label, value in data.items():
|
|
277
|
+
bar_width = int((value / max_val) * width) if max_val > 0 else 0
|
|
278
|
+
bar = "█" * bar_width
|
|
279
|
+
lines.append(f" {label:>{max_label}} {q(C.P2, bar)} {q(C.W, str(int(value)))}")
|
|
280
|
+
|
|
281
|
+
return lines
|
|
282
|
+
|
|
283
|
+
class Spinner:
|
|
284
|
+
FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
285
|
+
|
|
286
|
+
def __init__(self, msg):
|
|
287
|
+
self.msg = msg
|
|
288
|
+
self._stop = threading.Event()
|
|
289
|
+
self._thread = None
|
|
290
|
+
self._finished = False
|
|
291
|
+
|
|
292
|
+
def __enter__(self):
|
|
293
|
+
self.start()
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def __exit__(self, *a):
|
|
297
|
+
self.finish()
|
|
298
|
+
|
|
299
|
+
def start(self):
|
|
300
|
+
def _run():
|
|
301
|
+
i = 0
|
|
302
|
+
while not self._stop.is_set():
|
|
303
|
+
sys.stdout.write(f"\r {q(C.P2, self.FRAMES[i % len(self.FRAMES)])} {q(C.G1, self.msg)}")
|
|
304
|
+
sys.stdout.flush()
|
|
305
|
+
time.sleep(0.08)
|
|
306
|
+
i += 1
|
|
307
|
+
self._thread = threading.Thread(target=_run, daemon=True)
|
|
308
|
+
self._thread.start()
|
|
309
|
+
|
|
310
|
+
def update(self, msg):
|
|
311
|
+
self.msg = msg
|
|
312
|
+
|
|
313
|
+
def finish(self, msg=None, ok_=True):
|
|
314
|
+
if self._finished:
|
|
315
|
+
return
|
|
316
|
+
self._finished = True
|
|
317
|
+
self._stop.set()
|
|
318
|
+
if self._thread:
|
|
319
|
+
self._thread.join(timeout=0.2)
|
|
320
|
+
icon = q(C.GRN, "✓") if ok_ else q(C.RED, "✗")
|
|
321
|
+
sys.stdout.write(f"\r {icon} {q(C.W, msg or self.msg)}\n")
|
|
322
|
+
sys.stdout.flush()
|
|
323
|
+
|
|
324
|
+
def print_logo(compact=False):
|
|
325
|
+
if compact:
|
|
326
|
+
print(f" {q(C.P2, '◉', bold=True)} {q(C.W, 'conny omni', bold=True)} "
|
|
327
|
+
f"{q(C.G3, f'v{OMNI_VERSION}')}")
|
|
328
|
+
print()
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
from src.conny.channels.logo_art import LOGO_ART_LINES
|
|
333
|
+
except ImportError:
|
|
334
|
+
try:
|
|
335
|
+
from conny.channels.logo_art import LOGO_ART_LINES
|
|
336
|
+
except ImportError:
|
|
337
|
+
LOGO_ART_LINES = ["Conny OMNI"]
|
|
338
|
+
|
|
339
|
+
C_BLUE = _e("38;2;59;130;246")
|
|
340
|
+
|
|
341
|
+
text_lines = [
|
|
342
|
+
"",
|
|
343
|
+
"",
|
|
344
|
+
f" {C.P1}{C.BOLD}Conny OMNI {OMNI_VERSION}{C.R}",
|
|
345
|
+
f" {C.P2}The All-Seeing Eye{C.R}",
|
|
346
|
+
f" {C_BLUE}Target:{C.R} {C.W}Fleet Orchestrator{C.R}",
|
|
347
|
+
f" {C_BLUE}Status:{C.R} {C.G1}Live Monitoring{C.R}",
|
|
348
|
+
f" {C_BLUE}Node:{C.R} {C.G2}omni-server{C.R}",
|
|
349
|
+
f" {C_BLUE}~{C.R}"
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
print()
|
|
353
|
+
print(f" {C.G4}{'─' * 100}{C.R}")
|
|
354
|
+
print()
|
|
355
|
+
max_len = max(len(LOGO_ART_LINES), len(text_lines))
|
|
356
|
+
for i in range(max_len):
|
|
357
|
+
left = LOGO_ART_LINES[i] if i < len(LOGO_ART_LINES) else " " * 18
|
|
358
|
+
right = text_lines[i] if i < len(text_lines) else ""
|
|
359
|
+
print(f" {left} {right}")
|
|
360
|
+
print()
|
|
361
|
+
print(f" {C.G4}{'─' * 100}{C.R}")
|
|
362
|
+
print()
|
|
363
|
+
|
|
364
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
365
|
+
# BASE DE DATOS - Métricas históricas, eventos, audit log
|
|
366
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
367
|
+
def init_db():
|
|
368
|
+
"""Inicializar base de datos SQLite."""
|
|
369
|
+
conn = sqlite3.connect(str(OMNI_DB))
|
|
370
|
+
cursor = conn.cursor()
|
|
371
|
+
|
|
372
|
+
# Métricas históricas
|
|
373
|
+
cursor.execute("""
|
|
374
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
375
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
376
|
+
instance TEXT NOT NULL,
|
|
377
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
378
|
+
status TEXT,
|
|
379
|
+
latency_ms REAL,
|
|
380
|
+
memory_mb REAL,
|
|
381
|
+
cpu_percent REAL,
|
|
382
|
+
conversations INTEGER,
|
|
383
|
+
appointments INTEGER,
|
|
384
|
+
messages INTEGER,
|
|
385
|
+
error_rate REAL
|
|
386
|
+
)
|
|
387
|
+
""")
|
|
388
|
+
|
|
389
|
+
# Eventos
|
|
390
|
+
cursor.execute("""
|
|
391
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
392
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
393
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
394
|
+
instance TEXT,
|
|
395
|
+
event_type TEXT NOT NULL,
|
|
396
|
+
severity TEXT DEFAULT 'info',
|
|
397
|
+
details TEXT,
|
|
398
|
+
acknowledged INTEGER DEFAULT 0,
|
|
399
|
+
acknowledged_at DATETIME,
|
|
400
|
+
acknowledged_by TEXT
|
|
401
|
+
)
|
|
402
|
+
""")
|
|
403
|
+
|
|
404
|
+
# Audit log
|
|
405
|
+
cursor.execute("""
|
|
406
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
407
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
408
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
409
|
+
actor TEXT,
|
|
410
|
+
action TEXT NOT NULL,
|
|
411
|
+
target TEXT,
|
|
412
|
+
details TEXT,
|
|
413
|
+
ip_address TEXT
|
|
414
|
+
)
|
|
415
|
+
""")
|
|
416
|
+
|
|
417
|
+
# Alertas configuradas
|
|
418
|
+
cursor.execute("""
|
|
419
|
+
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
420
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
421
|
+
name TEXT NOT NULL,
|
|
422
|
+
instance TEXT,
|
|
423
|
+
metric TEXT NOT NULL,
|
|
424
|
+
operator TEXT NOT NULL,
|
|
425
|
+
threshold REAL NOT NULL,
|
|
426
|
+
severity TEXT DEFAULT 'warning',
|
|
427
|
+
channels TEXT DEFAULT 'telegram',
|
|
428
|
+
cooldown_minutes INTEGER DEFAULT 30,
|
|
429
|
+
enabled INTEGER DEFAULT 1,
|
|
430
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
431
|
+
last_triggered DATETIME
|
|
432
|
+
)
|
|
433
|
+
""")
|
|
434
|
+
|
|
435
|
+
# Workflows automáticos
|
|
436
|
+
cursor.execute("""
|
|
437
|
+
CREATE TABLE IF NOT EXISTS workflows (
|
|
438
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
439
|
+
name TEXT NOT NULL,
|
|
440
|
+
trigger_event TEXT NOT NULL,
|
|
441
|
+
trigger_filter TEXT,
|
|
442
|
+
actions TEXT NOT NULL,
|
|
443
|
+
enabled INTEGER DEFAULT 1,
|
|
444
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
445
|
+
last_run DATETIME,
|
|
446
|
+
run_count INTEGER DEFAULT 0
|
|
447
|
+
)
|
|
448
|
+
""")
|
|
449
|
+
|
|
450
|
+
# Conversaciones con Santiago
|
|
451
|
+
cursor.execute("""
|
|
452
|
+
CREATE TABLE IF NOT EXISTS santiago_conversations (
|
|
453
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
454
|
+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
455
|
+
role TEXT NOT NULL,
|
|
456
|
+
content TEXT NOT NULL
|
|
457
|
+
)
|
|
458
|
+
""")
|
|
459
|
+
|
|
460
|
+
# Preferencias aprendidas
|
|
461
|
+
cursor.execute("""
|
|
462
|
+
CREATE TABLE IF NOT EXISTS preferences (
|
|
463
|
+
key TEXT PRIMARY KEY,
|
|
464
|
+
value TEXT,
|
|
465
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
466
|
+
)
|
|
467
|
+
""")
|
|
468
|
+
|
|
469
|
+
# Índices para búsquedas rápidas
|
|
470
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_metrics_instance ON metrics(instance)")
|
|
471
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON metrics(timestamp)")
|
|
472
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp)")
|
|
473
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_severity ON events(severity)")
|
|
474
|
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp)")
|
|
475
|
+
|
|
476
|
+
conn.commit()
|
|
477
|
+
conn.close()
|
|
478
|
+
|
|
479
|
+
def db_execute(query: str, params: tuple = (), fetch: bool = False):
|
|
480
|
+
"""Ejecutar query en la base de datos."""
|
|
481
|
+
conn = sqlite3.connect(str(OMNI_DB))
|
|
482
|
+
conn.row_factory = sqlite3.Row
|
|
483
|
+
cursor = conn.cursor()
|
|
484
|
+
cursor.execute(query, params)
|
|
485
|
+
|
|
486
|
+
if fetch:
|
|
487
|
+
result = cursor.fetchall()
|
|
488
|
+
conn.close()
|
|
489
|
+
return [dict(row) for row in result]
|
|
490
|
+
|
|
491
|
+
conn.commit()
|
|
492
|
+
conn.close()
|
|
493
|
+
return cursor.lastrowid
|
|
494
|
+
|
|
495
|
+
def log_event(instance: str, event_type: str, details: str = "", severity: str = "info"):
|
|
496
|
+
"""Registrar un evento."""
|
|
497
|
+
db_execute(
|
|
498
|
+
"INSERT INTO events (instance, event_type, severity, details) VALUES (?, ?, ?, ?)",
|
|
499
|
+
(instance, event_type, severity, details)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def log_audit(actor: str, action: str, target: str = "", details: str = "", ip: str = ""):
|
|
503
|
+
"""Registrar en audit log."""
|
|
504
|
+
db_execute(
|
|
505
|
+
"INSERT INTO audit_log (actor, action, target, details, ip_address) VALUES (?, ?, ?, ?, ?)",
|
|
506
|
+
(actor, action, target, details, ip)
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def record_metrics(instance: str, status: str, latency_ms: float,
|
|
510
|
+
conversations: int = 0, appointments: int = 0, messages: int = 0):
|
|
511
|
+
"""Registrar métricas de una instancia."""
|
|
512
|
+
db_execute("""
|
|
513
|
+
INSERT INTO metrics (instance, status, latency_ms, conversations, appointments, messages)
|
|
514
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
515
|
+
""", (instance, status, latency_ms, conversations, appointments, messages))
|
|
516
|
+
|
|
517
|
+
def get_metrics_history(instance: str, hours: int = 24) -> List[Dict]:
|
|
518
|
+
"""Obtener historial de métricas."""
|
|
519
|
+
return db_execute("""
|
|
520
|
+
SELECT * FROM metrics
|
|
521
|
+
WHERE instance = ? AND timestamp > datetime('now', ?)
|
|
522
|
+
ORDER BY timestamp ASC
|
|
523
|
+
""", (instance, f'-{hours} hours'), fetch=True)
|
|
524
|
+
|
|
525
|
+
def get_recent_events(limit: int = 50, severity: str = None) -> List[Dict]:
|
|
526
|
+
"""Obtener eventos recientes."""
|
|
527
|
+
if severity:
|
|
528
|
+
return db_execute("""
|
|
529
|
+
SELECT * FROM events WHERE severity = ?
|
|
530
|
+
ORDER BY timestamp DESC LIMIT ?
|
|
531
|
+
""", (severity, limit), fetch=True)
|
|
532
|
+
return db_execute("""
|
|
533
|
+
SELECT * FROM events ORDER BY timestamp DESC LIMIT ?
|
|
534
|
+
""", (limit,), fetch=True)
|
|
535
|
+
|
|
536
|
+
def get_unacknowledged_events() -> List[Dict]:
|
|
537
|
+
"""Obtener eventos sin reconocer."""
|
|
538
|
+
return db_execute("""
|
|
539
|
+
SELECT * FROM events
|
|
540
|
+
WHERE acknowledged = 0 AND severity IN ('warning', 'critical', 'error')
|
|
541
|
+
ORDER BY timestamp DESC
|
|
542
|
+
""", fetch=True)
|
|
543
|
+
|
|
544
|
+
def acknowledge_event(event_id: int, by: str = "Santiago"):
|
|
545
|
+
"""Reconocer un evento."""
|
|
546
|
+
db_execute("""
|
|
547
|
+
UPDATE events
|
|
548
|
+
SET acknowledged = 1, acknowledged_at = datetime('now'), acknowledged_by = ?
|
|
549
|
+
WHERE id = ?
|
|
550
|
+
""", (by, event_id))
|
|
551
|
+
|
|
552
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
553
|
+
# HELPERS
|
|
554
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
555
|
+
def load_env(path: str) -> dict:
|
|
556
|
+
env = {}
|
|
557
|
+
try:
|
|
558
|
+
for line in Path(path).read_text().splitlines():
|
|
559
|
+
line = line.strip()
|
|
560
|
+
if line and not line.startswith('#') and '=' in line:
|
|
561
|
+
k, _, v = line.partition('=')
|
|
562
|
+
env[k.strip()] = v.strip().strip('"').strip("'")
|
|
563
|
+
except Exception:
|
|
564
|
+
pass
|
|
565
|
+
return env
|
|
566
|
+
|
|
567
|
+
def update_env_key(path: str, key: str, value: str) -> None:
|
|
568
|
+
env_path = Path(path)
|
|
569
|
+
lines = env_path.read_text().splitlines() if env_path.exists() else []
|
|
570
|
+
rendered = []
|
|
571
|
+
found = False
|
|
572
|
+
for raw in lines:
|
|
573
|
+
if "=" in raw and raw.split("=", 1)[0].strip() == key:
|
|
574
|
+
rendered.append(f"{key}={value}")
|
|
575
|
+
found = True
|
|
576
|
+
else:
|
|
577
|
+
rendered.append(raw)
|
|
578
|
+
if not found:
|
|
579
|
+
rendered.append(f"{key}={value}")
|
|
580
|
+
env_path.write_text("\n".join(rendered) + "\n")
|
|
581
|
+
|
|
582
|
+
def resolve_instance(instances: List["Instance"], name: str) -> Optional["Instance"]:
|
|
583
|
+
if not name:
|
|
584
|
+
return None
|
|
585
|
+
q = name.strip().lower()
|
|
586
|
+
for inst in instances:
|
|
587
|
+
candidates = {
|
|
588
|
+
inst.name.lower(),
|
|
589
|
+
inst.label.lower(),
|
|
590
|
+
inst.label.lower().replace(" ", "-"),
|
|
591
|
+
inst.name.lower().replace("-", " "),
|
|
592
|
+
}
|
|
593
|
+
if any(q == c for c in candidates):
|
|
594
|
+
return inst
|
|
595
|
+
for inst in instances:
|
|
596
|
+
haystack = " | ".join([
|
|
597
|
+
inst.name.lower(),
|
|
598
|
+
inst.label.lower(),
|
|
599
|
+
inst.label.lower().replace(" ", "-"),
|
|
600
|
+
inst.name.lower().replace("-", " "),
|
|
601
|
+
])
|
|
602
|
+
if q in haystack:
|
|
603
|
+
return inst
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
@dataclass
|
|
607
|
+
class Instance:
|
|
608
|
+
name: str
|
|
609
|
+
label: str
|
|
610
|
+
port: int
|
|
611
|
+
dir: str
|
|
612
|
+
env: dict
|
|
613
|
+
is_base: bool = False
|
|
614
|
+
sector: str = "otro"
|
|
615
|
+
|
|
616
|
+
@property
|
|
617
|
+
def sector_info(self) -> Tuple[str, str, str]:
|
|
618
|
+
return get_sector_info(self.sector)
|
|
619
|
+
|
|
620
|
+
@property
|
|
621
|
+
def pm2_name(self) -> str:
|
|
622
|
+
return "conny" if self.is_base else f"conny-{self.name}"
|
|
623
|
+
|
|
624
|
+
def get_all_instances() -> List[Instance]:
|
|
625
|
+
instances = []
|
|
626
|
+
|
|
627
|
+
be = load_env(f"{TEMPLATE_DIR}/.env")
|
|
628
|
+
if be:
|
|
629
|
+
instances.append(Instance(
|
|
630
|
+
name="base",
|
|
631
|
+
label=be.get("CLINIC_NAME", "Instancia base"),
|
|
632
|
+
port=int(be.get("PORT", 8001)),
|
|
633
|
+
dir=TEMPLATE_DIR,
|
|
634
|
+
env=dict(be),
|
|
635
|
+
is_base=True,
|
|
636
|
+
sector=be.get("SECTOR", "estetica")
|
|
637
|
+
))
|
|
638
|
+
|
|
639
|
+
if os.path.isdir(INSTANCES_DIR):
|
|
640
|
+
for name in sorted(os.listdir(INSTANCES_DIR)):
|
|
641
|
+
d = f"{INSTANCES_DIR}/{name}"
|
|
642
|
+
ep = f"{d}/.env"
|
|
643
|
+
if not os.path.isdir(d) or not os.path.exists(ep):
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
ev = load_env(ep)
|
|
647
|
+
meta_path = f"{d}/instance.json"
|
|
648
|
+
meta = {}
|
|
649
|
+
if os.path.exists(meta_path):
|
|
650
|
+
try:
|
|
651
|
+
meta = json.loads(Path(meta_path).read_text())
|
|
652
|
+
except Exception:
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
instances.append(Instance(
|
|
656
|
+
name=name,
|
|
657
|
+
label=meta.get("label", name.replace("-", " ").title()),
|
|
658
|
+
port=int(ev.get("PORT", 8002)),
|
|
659
|
+
dir=d,
|
|
660
|
+
env=dict(ev),
|
|
661
|
+
is_base=False,
|
|
662
|
+
sector=ev.get("SECTOR", meta.get("sector", "otro"))
|
|
663
|
+
))
|
|
664
|
+
|
|
665
|
+
return instances
|
|
666
|
+
|
|
667
|
+
async def http_get(url: str, timeout: float = 5.0, headers: dict = None) -> Optional[Dict]:
|
|
668
|
+
try:
|
|
669
|
+
if _HTTPX:
|
|
670
|
+
async with httpx.AsyncClient(timeout=timeout) as c:
|
|
671
|
+
r = await c.get(url, headers=headers or {})
|
|
672
|
+
return r.json() if r.status_code == 200 else None
|
|
673
|
+
else:
|
|
674
|
+
req = urllib.request.urlopen(url, timeout=int(timeout))
|
|
675
|
+
return json.loads(req.read())
|
|
676
|
+
except Exception:
|
|
677
|
+
return None
|
|
678
|
+
|
|
679
|
+
async def http_post(url: str, data: dict, headers: dict = None, timeout: float = 10.0) -> Optional[Dict]:
|
|
680
|
+
try:
|
|
681
|
+
if _HTTPX:
|
|
682
|
+
async with httpx.AsyncClient(timeout=timeout) as c:
|
|
683
|
+
r = await c.post(url, json=data, headers=headers or {})
|
|
684
|
+
return r.json() if r.status_code in (200, 201) else None
|
|
685
|
+
except Exception:
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
async def health_check(port: int) -> Tuple[Dict, float]:
|
|
689
|
+
"""Health check con latencia."""
|
|
690
|
+
start = time.time()
|
|
691
|
+
h = await http_get(f"http://localhost:{port}/health")
|
|
692
|
+
latency = (time.time() - start) * 1000
|
|
693
|
+
return h or {}, latency
|
|
694
|
+
|
|
695
|
+
async def set_instance_demo(
|
|
696
|
+
inst: Instance,
|
|
697
|
+
active: bool,
|
|
698
|
+
business_name: str = "",
|
|
699
|
+
sector: str = "",
|
|
700
|
+
session_ttl: int = 1800,
|
|
701
|
+
) -> Dict[str, Any]:
|
|
702
|
+
"""Activa o desactiva demo mode en una instancia y lo deja persistido."""
|
|
703
|
+
env_path = Path(inst.dir) / ".env"
|
|
704
|
+
current_env = load_env(str(env_path))
|
|
705
|
+
|
|
706
|
+
if active:
|
|
707
|
+
business_name = business_name or current_env.get("DEMO_BUSINESS_NAME") or current_env.get("CLINIC_NAME") or inst.label
|
|
708
|
+
sector = sector or current_env.get("DEMO_SECTOR") or current_env.get("SECTOR") or inst.sector or "otro"
|
|
709
|
+
update_env_key(str(env_path), "DEMO_MODE", "true")
|
|
710
|
+
update_env_key(str(env_path), "DEMO_BUSINESS_NAME", business_name)
|
|
711
|
+
update_env_key(str(env_path), "DEMO_SECTOR", sector)
|
|
712
|
+
update_env_key(str(env_path), "DEMO_SESSION_TTL", str(session_ttl))
|
|
713
|
+
else:
|
|
714
|
+
business_name = business_name or current_env.get("DEMO_BUSINESS_NAME") or inst.label
|
|
715
|
+
sector = sector or current_env.get("DEMO_SECTOR") or current_env.get("SECTOR") or inst.sector or "otro"
|
|
716
|
+
update_env_key(str(env_path), "DEMO_MODE", "false")
|
|
717
|
+
|
|
718
|
+
pm2_restart(inst.pm2_name)
|
|
719
|
+
await asyncio.sleep(4)
|
|
720
|
+
status = await http_get(f"http://localhost:{inst.port}/demo/status") or {}
|
|
721
|
+
health, latency = await health_check(inst.port)
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
"instance": inst.name,
|
|
725
|
+
"label": inst.label,
|
|
726
|
+
"active": bool(status.get("demo_mode", active)),
|
|
727
|
+
"business_name": status.get("business_name", business_name),
|
|
728
|
+
"sector": status.get("sector", sector),
|
|
729
|
+
"session_ttl": status.get("session_ttl", session_ttl),
|
|
730
|
+
"health": health.get("status", "offline"),
|
|
731
|
+
"latency_ms": round(latency, 1),
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async def get_instance_stats(port: int, master_key: str) -> Dict:
|
|
735
|
+
if not _HTTPX:
|
|
736
|
+
return {}
|
|
737
|
+
try:
|
|
738
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
739
|
+
r = await c.get(
|
|
740
|
+
f"http://localhost:{port}/analytics/summary?days=7",
|
|
741
|
+
headers={"X-Master-Key": master_key}
|
|
742
|
+
)
|
|
743
|
+
return r.json() if r.status_code == 200 else {}
|
|
744
|
+
except Exception:
|
|
745
|
+
return {}
|
|
746
|
+
|
|
747
|
+
async def get_recent_conversations(port: int, master_key: str, limit: int = 10) -> List[Dict]:
|
|
748
|
+
if not _HTTPX:
|
|
749
|
+
return []
|
|
750
|
+
try:
|
|
751
|
+
async with httpx.AsyncClient(timeout=8.0) as c:
|
|
752
|
+
r = await c.get(
|
|
753
|
+
f"http://localhost:{port}/conversations/patients?limit={limit}",
|
|
754
|
+
headers={"X-Master-Key": master_key}
|
|
755
|
+
)
|
|
756
|
+
return r.json().get("conversations", []) if r.status_code == 200 else []
|
|
757
|
+
except Exception:
|
|
758
|
+
return []
|
|
759
|
+
|
|
760
|
+
async def send_to_instance(port: int, master_key: str, chat_id: str, msg: str) -> bool:
|
|
761
|
+
result = await http_post(
|
|
762
|
+
f"http://localhost:{port}/send-message",
|
|
763
|
+
{"chat_id": chat_id, "message": msg},
|
|
764
|
+
{"X-Master-Key": master_key}
|
|
765
|
+
)
|
|
766
|
+
return result is not None
|
|
767
|
+
|
|
768
|
+
def pm2_command(action: str, name: str) -> bool:
|
|
769
|
+
import subprocess
|
|
770
|
+
result = subprocess.run(["pm2", action, name], capture_output=True)
|
|
771
|
+
return result.returncode == 0
|
|
772
|
+
|
|
773
|
+
def pm2_restart(name: str) -> bool:
|
|
774
|
+
return pm2_command("restart", name)
|
|
775
|
+
|
|
776
|
+
def pm2_stop(name: str) -> bool:
|
|
777
|
+
return pm2_command("stop", name)
|
|
778
|
+
|
|
779
|
+
def pm2_list() -> List[Dict]:
|
|
780
|
+
import subprocess
|
|
781
|
+
try:
|
|
782
|
+
result = subprocess.run(["pm2", "jlist"], capture_output=True, text=True)
|
|
783
|
+
if result.returncode == 0:
|
|
784
|
+
return json.loads(result.stdout)
|
|
785
|
+
except Exception:
|
|
786
|
+
pass
|
|
787
|
+
return []
|
|
788
|
+
|
|
789
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
790
|
+
# NOTIFICACIONES MULTI-CANAL
|
|
791
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
792
|
+
@dataclass
|
|
793
|
+
class Notification:
|
|
794
|
+
title: str
|
|
795
|
+
message: str
|
|
796
|
+
severity: str = "info" # info, warning, error, critical
|
|
797
|
+
instance: str = ""
|
|
798
|
+
channels: List[str] = field(default_factory=lambda: ["telegram"])
|
|
799
|
+
|
|
800
|
+
async def send_telegram(text: str, chat_id: str = None, token: str = None, parse_mode: str = "Markdown"):
|
|
801
|
+
# Refrescar desde environ por si fue cargado por _load_master_env post-import
|
|
802
|
+
cid = chat_id or os.environ.get("SANTIAGO_CHAT_ID", "") or SANTIAGO_CHAT
|
|
803
|
+
tok = token or os.environ.get("OMNI_TELEGRAM_TOKEN", "") or OMNI_TOKEN
|
|
804
|
+
if not cid or not tok:
|
|
805
|
+
print(f" [omni] send_telegram: sin token ({bool(tok)}) o chat_id ({bool(cid)})")
|
|
806
|
+
return False
|
|
807
|
+
|
|
808
|
+
url = f"https://api.telegram.org/bot{tok}/sendMessage"
|
|
809
|
+
try:
|
|
810
|
+
if _HTTPX:
|
|
811
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
812
|
+
r = await c.post(url, json={
|
|
813
|
+
"chat_id": cid,
|
|
814
|
+
"text": text,
|
|
815
|
+
"parse_mode": parse_mode
|
|
816
|
+
})
|
|
817
|
+
return r.status_code == 200
|
|
818
|
+
except Exception as e:
|
|
819
|
+
print(f" [omni] telegram error: {e}")
|
|
820
|
+
return False
|
|
821
|
+
|
|
822
|
+
async def send_slack(text: str, webhook: str = None):
|
|
823
|
+
wh = webhook or SLACK_WEBHOOK
|
|
824
|
+
if not wh:
|
|
825
|
+
return False
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
if _HTTPX:
|
|
829
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
830
|
+
r = await c.post(wh, json={"text": text})
|
|
831
|
+
return r.status_code == 200
|
|
832
|
+
except Exception:
|
|
833
|
+
pass
|
|
834
|
+
return False
|
|
835
|
+
|
|
836
|
+
async def send_discord(text: str, webhook: str = None):
|
|
837
|
+
wh = webhook or DISCORD_WEBHOOK
|
|
838
|
+
if not wh:
|
|
839
|
+
return False
|
|
840
|
+
|
|
841
|
+
try:
|
|
842
|
+
if _HTTPX:
|
|
843
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
844
|
+
r = await c.post(wh, json={"content": text})
|
|
845
|
+
return r.status_code in (200, 204)
|
|
846
|
+
except Exception:
|
|
847
|
+
pass
|
|
848
|
+
return False
|
|
849
|
+
|
|
850
|
+
async def send_email(subject: str, body: str, to: str = None):
|
|
851
|
+
recipient = to or ALERT_EMAIL
|
|
852
|
+
if not all([SMTP_HOST, SMTP_USER, SMTP_PASS, recipient]):
|
|
853
|
+
return False
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
msg = MIMEMultipart()
|
|
857
|
+
msg['From'] = SMTP_USER
|
|
858
|
+
msg['To'] = recipient
|
|
859
|
+
msg['Subject'] = f"[Conny Omni] {subject}"
|
|
860
|
+
msg.attach(MIMEText(body, 'plain'))
|
|
861
|
+
|
|
862
|
+
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
863
|
+
server.starttls()
|
|
864
|
+
server.login(SMTP_USER, SMTP_PASS)
|
|
865
|
+
server.send_message(msg)
|
|
866
|
+
return True
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
return False
|
|
870
|
+
|
|
871
|
+
async def send_custom_webhook(data: dict, url: str = None):
|
|
872
|
+
wh = url or CUSTOM_WEBHOOK
|
|
873
|
+
if not wh:
|
|
874
|
+
return False
|
|
875
|
+
|
|
876
|
+
try:
|
|
877
|
+
if _HTTPX:
|
|
878
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
879
|
+
r = await c.post(wh, json=data)
|
|
880
|
+
return r.status_code in (200, 201, 204)
|
|
881
|
+
except Exception:
|
|
882
|
+
pass
|
|
883
|
+
return False
|
|
884
|
+
|
|
885
|
+
async def send_notification(notif: Notification):
|
|
886
|
+
"""Enviar notificación a todos los canales configurados."""
|
|
887
|
+
severity_emoji = {
|
|
888
|
+
"info": "ℹ️",
|
|
889
|
+
"warning": "⚠️",
|
|
890
|
+
"error": "🔥",
|
|
891
|
+
"critical": "🚨"
|
|
892
|
+
}
|
|
893
|
+
emoji = severity_emoji.get(notif.severity, "📌")
|
|
894
|
+
|
|
895
|
+
# Texto formateado
|
|
896
|
+
text = f"{emoji} *{notif.title}*"
|
|
897
|
+
if notif.instance:
|
|
898
|
+
text += f"\n📍 {notif.instance}"
|
|
899
|
+
text += f"\n\n{notif.message}"
|
|
900
|
+
|
|
901
|
+
tasks = []
|
|
902
|
+
|
|
903
|
+
if "telegram" in notif.channels:
|
|
904
|
+
tasks.append(send_telegram(text))
|
|
905
|
+
|
|
906
|
+
if "slack" in notif.channels and SLACK_WEBHOOK:
|
|
907
|
+
tasks.append(send_slack(text))
|
|
908
|
+
|
|
909
|
+
if "discord" in notif.channels and DISCORD_WEBHOOK:
|
|
910
|
+
tasks.append(send_discord(text))
|
|
911
|
+
|
|
912
|
+
if "email" in notif.channels and SMTP_HOST:
|
|
913
|
+
tasks.append(send_email(notif.title, notif.message))
|
|
914
|
+
|
|
915
|
+
if "webhook" in notif.channels and CUSTOM_WEBHOOK:
|
|
916
|
+
tasks.append(send_custom_webhook({
|
|
917
|
+
"title": notif.title,
|
|
918
|
+
"message": notif.message,
|
|
919
|
+
"severity": notif.severity,
|
|
920
|
+
"instance": notif.instance,
|
|
921
|
+
"timestamp": datetime.utcnow().isoformat()
|
|
922
|
+
}))
|
|
923
|
+
|
|
924
|
+
if tasks:
|
|
925
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
926
|
+
|
|
927
|
+
# Log event
|
|
928
|
+
log_event(notif.instance, f"notification_{notif.severity}", notif.message, notif.severity)
|
|
929
|
+
|
|
930
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
931
|
+
# ALERTAS INTELIGENTES
|
|
932
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
933
|
+
_alert_cooldowns: Dict[str, datetime] = {}
|
|
934
|
+
|
|
935
|
+
async def check_alert_rules(instance: str, metrics: Dict):
|
|
936
|
+
"""Verificar reglas de alerta para una instancia."""
|
|
937
|
+
rules = db_execute("""
|
|
938
|
+
SELECT * FROM alert_rules WHERE enabled = 1 AND (instance = ? OR instance = '*')
|
|
939
|
+
""", (instance,), fetch=True)
|
|
940
|
+
|
|
941
|
+
for rule in rules:
|
|
942
|
+
rule_id = f"{rule['id']}_{instance}"
|
|
943
|
+
|
|
944
|
+
# Verificar cooldown
|
|
945
|
+
if rule_id in _alert_cooldowns:
|
|
946
|
+
if datetime.now() - _alert_cooldowns[rule_id] < timedelta(minutes=rule['cooldown_minutes']):
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
# Obtener valor de métrica
|
|
950
|
+
metric_value = metrics.get(rule['metric'])
|
|
951
|
+
if metric_value is None:
|
|
952
|
+
continue
|
|
953
|
+
|
|
954
|
+
# Evaluar condición
|
|
955
|
+
triggered = False
|
|
956
|
+
op = rule['operator']
|
|
957
|
+
threshold = rule['threshold']
|
|
958
|
+
|
|
959
|
+
if op == '>' and metric_value > threshold:
|
|
960
|
+
triggered = True
|
|
961
|
+
elif op == '<' and metric_value < threshold:
|
|
962
|
+
triggered = True
|
|
963
|
+
elif op == '>=' and metric_value >= threshold:
|
|
964
|
+
triggered = True
|
|
965
|
+
elif op == '<=' and metric_value <= threshold:
|
|
966
|
+
triggered = True
|
|
967
|
+
elif op == '==' and metric_value == threshold:
|
|
968
|
+
triggered = True
|
|
969
|
+
elif op == '!=' and metric_value != threshold:
|
|
970
|
+
triggered = True
|
|
971
|
+
|
|
972
|
+
if triggered:
|
|
973
|
+
_alert_cooldowns[rule_id] = datetime.now()
|
|
974
|
+
|
|
975
|
+
# Actualizar last_triggered
|
|
976
|
+
db_execute(
|
|
977
|
+
"UPDATE alert_rules SET last_triggered = datetime('now') WHERE id = ?",
|
|
978
|
+
(rule['id'],)
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
# Enviar notificación
|
|
982
|
+
channels = rule.get('channels', 'telegram').split(',')
|
|
983
|
+
await send_notification(Notification(
|
|
984
|
+
title=rule['name'],
|
|
985
|
+
message=f"{rule['metric']} = {metric_value} ({op} {threshold})",
|
|
986
|
+
severity=rule['severity'],
|
|
987
|
+
instance=instance,
|
|
988
|
+
channels=channels
|
|
989
|
+
))
|
|
990
|
+
|
|
991
|
+
def create_alert_rule(name: str, instance: str, metric: str, operator: str,
|
|
992
|
+
threshold: float, severity: str = "warning",
|
|
993
|
+
channels: str = "telegram", cooldown: int = 30):
|
|
994
|
+
"""Crear nueva regla de alerta."""
|
|
995
|
+
return db_execute("""
|
|
996
|
+
INSERT INTO alert_rules (name, instance, metric, operator, threshold, severity, channels, cooldown_minutes)
|
|
997
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
998
|
+
""", (name, instance, metric, operator, threshold, severity, channels, cooldown))
|
|
999
|
+
|
|
1000
|
+
def get_alert_rules() -> List[Dict]:
|
|
1001
|
+
return db_execute("SELECT * FROM alert_rules ORDER BY id", fetch=True)
|
|
1002
|
+
|
|
1003
|
+
def delete_alert_rule(rule_id: int):
|
|
1004
|
+
db_execute("DELETE FROM alert_rules WHERE id = ?", (rule_id,))
|
|
1005
|
+
|
|
1006
|
+
def toggle_alert_rule(rule_id: int, enabled: bool):
|
|
1007
|
+
db_execute("UPDATE alert_rules SET enabled = ? WHERE id = ?", (1 if enabled else 0, rule_id))
|
|
1008
|
+
|
|
1009
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1010
|
+
# WORKFLOWS AUTOMÁTICOS
|
|
1011
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1012
|
+
async def execute_workflow_action(action: Dict, context: Dict):
|
|
1013
|
+
"""Ejecutar una acción de workflow."""
|
|
1014
|
+
action_type = action.get("type")
|
|
1015
|
+
|
|
1016
|
+
if action_type == "notify":
|
|
1017
|
+
await send_notification(Notification(
|
|
1018
|
+
title=action.get("title", "Workflow Alert"),
|
|
1019
|
+
message=action.get("message", "").format(**context),
|
|
1020
|
+
severity=action.get("severity", "info"),
|
|
1021
|
+
instance=context.get("instance", ""),
|
|
1022
|
+
channels=action.get("channels", ["telegram"])
|
|
1023
|
+
))
|
|
1024
|
+
|
|
1025
|
+
elif action_type == "restart":
|
|
1026
|
+
instance_name = action.get("instance") or context.get("instance")
|
|
1027
|
+
if instance_name:
|
|
1028
|
+
pm2_name = "conny" if instance_name == "base" else f"conny-{instance_name}"
|
|
1029
|
+
success = pm2_restart(pm2_name)
|
|
1030
|
+
log_audit("workflow", "restart", instance_name, f"success={success}")
|
|
1031
|
+
|
|
1032
|
+
elif action_type == "webhook":
|
|
1033
|
+
await send_custom_webhook({
|
|
1034
|
+
**context,
|
|
1035
|
+
"workflow_action": action
|
|
1036
|
+
}, action.get("url"))
|
|
1037
|
+
|
|
1038
|
+
elif action_type == "log":
|
|
1039
|
+
log_event(
|
|
1040
|
+
context.get("instance", ""),
|
|
1041
|
+
"workflow_log",
|
|
1042
|
+
action.get("message", "").format(**context),
|
|
1043
|
+
action.get("severity", "info")
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
async def process_event_for_workflows(event_type: str, event_data: Dict):
|
|
1047
|
+
"""Procesar un evento y ejecutar workflows si aplican."""
|
|
1048
|
+
workflows = db_execute("""
|
|
1049
|
+
SELECT * FROM workflows WHERE enabled = 1 AND trigger_event = ?
|
|
1050
|
+
""", (event_type,), fetch=True)
|
|
1051
|
+
|
|
1052
|
+
for wf in workflows:
|
|
1053
|
+
# Verificar filtro si existe
|
|
1054
|
+
trigger_filter = wf.get("trigger_filter")
|
|
1055
|
+
if trigger_filter:
|
|
1056
|
+
try:
|
|
1057
|
+
filter_dict = json.loads(trigger_filter)
|
|
1058
|
+
match = all(event_data.get(k) == v for k, v in filter_dict.items())
|
|
1059
|
+
if not match:
|
|
1060
|
+
continue
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
# Ejecutar acciones
|
|
1065
|
+
try:
|
|
1066
|
+
actions = json.loads(wf.get("actions", "[]"))
|
|
1067
|
+
for action in actions:
|
|
1068
|
+
await execute_workflow_action(action, event_data)
|
|
1069
|
+
|
|
1070
|
+
# Actualizar stats
|
|
1071
|
+
db_execute("""
|
|
1072
|
+
UPDATE workflows
|
|
1073
|
+
SET last_run = datetime('now'), run_count = run_count + 1
|
|
1074
|
+
WHERE id = ?
|
|
1075
|
+
""", (wf['id'],))
|
|
1076
|
+
except Exception as e:
|
|
1077
|
+
log_event("omni", "workflow_error", f"Workflow {wf['name']}: {e}", "error")
|
|
1078
|
+
|
|
1079
|
+
def create_workflow(name: str, trigger_event: str, actions: List[Dict],
|
|
1080
|
+
trigger_filter: Dict = None) -> int:
|
|
1081
|
+
"""Crear nuevo workflow."""
|
|
1082
|
+
return db_execute("""
|
|
1083
|
+
INSERT INTO workflows (name, trigger_event, trigger_filter, actions)
|
|
1084
|
+
VALUES (?, ?, ?, ?)
|
|
1085
|
+
""", (name, trigger_event, json.dumps(trigger_filter) if trigger_filter else None, json.dumps(actions)))
|
|
1086
|
+
|
|
1087
|
+
def get_workflows() -> List[Dict]:
|
|
1088
|
+
return db_execute("SELECT * FROM workflows ORDER BY id", fetch=True)
|
|
1089
|
+
|
|
1090
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1091
|
+
# AUTO-HEALING
|
|
1092
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1093
|
+
_down_since: Dict[str, datetime] = {}
|
|
1094
|
+
_restart_attempts: Dict[str, int] = {}
|
|
1095
|
+
|
|
1096
|
+
async def auto_heal_instance(instance: Instance):
|
|
1097
|
+
"""Intentar recuperar una instancia caída."""
|
|
1098
|
+
if not AUTO_HEAL_ENABLED:
|
|
1099
|
+
return
|
|
1100
|
+
|
|
1101
|
+
name = instance.name
|
|
1102
|
+
now = datetime.now()
|
|
1103
|
+
|
|
1104
|
+
# Verificar si ya pasó el delay
|
|
1105
|
+
if name in _down_since:
|
|
1106
|
+
down_time = now - _down_since[name]
|
|
1107
|
+
if down_time.total_seconds() < AUTO_HEAL_DELAY:
|
|
1108
|
+
return # Esperar más
|
|
1109
|
+
else:
|
|
1110
|
+
_down_since[name] = now
|
|
1111
|
+
return # Primera vez, empezar a contar
|
|
1112
|
+
|
|
1113
|
+
# Verificar intentos
|
|
1114
|
+
attempts = _restart_attempts.get(name, 0)
|
|
1115
|
+
if attempts >= 3:
|
|
1116
|
+
# Demasiados intentos, escalar
|
|
1117
|
+
if attempts == 3:
|
|
1118
|
+
await send_notification(Notification(
|
|
1119
|
+
title="Auto-heal fallido",
|
|
1120
|
+
message=f"3 intentos de reinicio sin éxito. Requiere intervención manual.",
|
|
1121
|
+
severity="critical",
|
|
1122
|
+
instance=name,
|
|
1123
|
+
channels=["telegram", "email"]
|
|
1124
|
+
))
|
|
1125
|
+
_restart_attempts[name] = 4 # Evitar spam
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
# Intentar reiniciar
|
|
1129
|
+
log_audit("omni_auto_heal", "restart_attempt", name, f"attempt={attempts + 1}")
|
|
1130
|
+
success = pm2_restart(instance.pm2_name)
|
|
1131
|
+
|
|
1132
|
+
if success:
|
|
1133
|
+
_restart_attempts[name] = attempts + 1
|
|
1134
|
+
await send_notification(Notification(
|
|
1135
|
+
title="Auto-heal: Reiniciando",
|
|
1136
|
+
message=f"Intento {attempts + 1}/3",
|
|
1137
|
+
severity="warning",
|
|
1138
|
+
instance=name
|
|
1139
|
+
))
|
|
1140
|
+
|
|
1141
|
+
# Esperar y verificar
|
|
1142
|
+
await asyncio.sleep(15)
|
|
1143
|
+
h, _ = await health_check(instance.port)
|
|
1144
|
+
|
|
1145
|
+
if h.get("status") == "online":
|
|
1146
|
+
del _down_since[name]
|
|
1147
|
+
_restart_attempts[name] = 0
|
|
1148
|
+
await send_notification(Notification(
|
|
1149
|
+
title="Auto-heal: Recuperado",
|
|
1150
|
+
message="Instancia online nuevamente",
|
|
1151
|
+
severity="info",
|
|
1152
|
+
instance=name
|
|
1153
|
+
))
|
|
1154
|
+
log_event(name, "auto_healed", f"Recuperado después de {attempts + 1} intentos", "info")
|
|
1155
|
+
else:
|
|
1156
|
+
_restart_attempts[name] = attempts + 1
|
|
1157
|
+
log_event(name, "auto_heal_failed", f"pm2 restart falló", "error")
|
|
1158
|
+
|
|
1159
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1160
|
+
# ANÁLISIS Y PREDICCIÓN
|
|
1161
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1162
|
+
def analyze_trends(instance: str, hours: int = 24) -> Dict:
|
|
1163
|
+
"""Analizar tendencias de una instancia."""
|
|
1164
|
+
metrics = get_metrics_history(instance, hours)
|
|
1165
|
+
|
|
1166
|
+
if len(metrics) < 2:
|
|
1167
|
+
return {"status": "insufficient_data"}
|
|
1168
|
+
|
|
1169
|
+
latencies = [m['latency_ms'] for m in metrics if m.get('latency_ms')]
|
|
1170
|
+
conversations = [m['conversations'] for m in metrics if m.get('conversations') is not None]
|
|
1171
|
+
|
|
1172
|
+
analysis = {
|
|
1173
|
+
"instance": instance,
|
|
1174
|
+
"period_hours": hours,
|
|
1175
|
+
"data_points": len(metrics),
|
|
1176
|
+
"latency": {},
|
|
1177
|
+
"availability": {},
|
|
1178
|
+
"trends": {}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
# Latencia
|
|
1182
|
+
if latencies:
|
|
1183
|
+
analysis["latency"] = {
|
|
1184
|
+
"avg": statistics.mean(latencies),
|
|
1185
|
+
"min": min(latencies),
|
|
1186
|
+
"max": max(latencies),
|
|
1187
|
+
"stddev": statistics.stdev(latencies) if len(latencies) > 1 else 0,
|
|
1188
|
+
"p95": sorted(latencies)[int(len(latencies) * 0.95)] if len(latencies) >= 20 else max(latencies),
|
|
1189
|
+
"current": latencies[-1],
|
|
1190
|
+
"trend": "up" if len(latencies) >= 10 and statistics.mean(latencies[-5:]) > statistics.mean(latencies[-10:-5]) * 1.1 else "stable"
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
# Disponibilidad
|
|
1194
|
+
total = len(metrics)
|
|
1195
|
+
online = sum(1 for m in metrics if m.get('status') == 'online')
|
|
1196
|
+
analysis["availability"] = {
|
|
1197
|
+
"percentage": (online / total * 100) if total > 0 else 0,
|
|
1198
|
+
"total_checks": total,
|
|
1199
|
+
"online_checks": online,
|
|
1200
|
+
"outages": total - online
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
# Tendencias
|
|
1204
|
+
if conversations and len(conversations) >= 2:
|
|
1205
|
+
first_half = statistics.mean(conversations[:len(conversations)//2])
|
|
1206
|
+
second_half = statistics.mean(conversations[len(conversations)//2:])
|
|
1207
|
+
|
|
1208
|
+
if second_half > first_half * 1.1:
|
|
1209
|
+
analysis["trends"]["conversations"] = "increasing"
|
|
1210
|
+
elif second_half < first_half * 0.9:
|
|
1211
|
+
analysis["trends"]["conversations"] = "decreasing"
|
|
1212
|
+
else:
|
|
1213
|
+
analysis["trends"]["conversations"] = "stable"
|
|
1214
|
+
|
|
1215
|
+
return analysis
|
|
1216
|
+
|
|
1217
|
+
def detect_anomalies(instance: str, hours: int = 6) -> List[Dict]:
|
|
1218
|
+
"""Detectar anomalías en las métricas."""
|
|
1219
|
+
metrics = get_metrics_history(instance, hours)
|
|
1220
|
+
|
|
1221
|
+
if len(metrics) < 10:
|
|
1222
|
+
return []
|
|
1223
|
+
|
|
1224
|
+
anomalies = []
|
|
1225
|
+
latencies = [m['latency_ms'] for m in metrics if m.get('latency_ms')]
|
|
1226
|
+
|
|
1227
|
+
if len(latencies) >= 10:
|
|
1228
|
+
mean = statistics.mean(latencies)
|
|
1229
|
+
stddev = statistics.stdev(latencies)
|
|
1230
|
+
|
|
1231
|
+
for i, m in enumerate(metrics):
|
|
1232
|
+
lat = m.get('latency_ms')
|
|
1233
|
+
if lat and abs(lat - mean) > 3 * stddev:
|
|
1234
|
+
anomalies.append({
|
|
1235
|
+
"type": "latency_spike",
|
|
1236
|
+
"timestamp": m['timestamp'],
|
|
1237
|
+
"value": lat,
|
|
1238
|
+
"expected_range": f"{mean - 2*stddev:.0f} - {mean + 2*stddev:.0f}",
|
|
1239
|
+
"severity": "warning" if lat < mean + 5 * stddev else "error"
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
# Caídas repentinas
|
|
1243
|
+
prev_status = None
|
|
1244
|
+
for m in metrics:
|
|
1245
|
+
status = m.get('status')
|
|
1246
|
+
if prev_status == 'online' and status == 'offline':
|
|
1247
|
+
anomalies.append({
|
|
1248
|
+
"type": "sudden_offline",
|
|
1249
|
+
"timestamp": m['timestamp'],
|
|
1250
|
+
"severity": "error"
|
|
1251
|
+
})
|
|
1252
|
+
prev_status = status
|
|
1253
|
+
|
|
1254
|
+
return anomalies
|
|
1255
|
+
|
|
1256
|
+
def predict_issues(instance: str) -> List[Dict]:
|
|
1257
|
+
"""Predecir posibles problemas basado en tendencias."""
|
|
1258
|
+
analysis = analyze_trends(instance, 24)
|
|
1259
|
+
predictions = []
|
|
1260
|
+
|
|
1261
|
+
if analysis.get("status") == "insufficient_data":
|
|
1262
|
+
return predictions
|
|
1263
|
+
|
|
1264
|
+
# Latencia en aumento
|
|
1265
|
+
lat = analysis.get("latency", {})
|
|
1266
|
+
if lat.get("trend") == "up" and lat.get("current", 0) > lat.get("avg", 0) * 1.5:
|
|
1267
|
+
predictions.append({
|
|
1268
|
+
"type": "latency_degradation",
|
|
1269
|
+
"probability": "high",
|
|
1270
|
+
"message": "Latencia en aumento. Posible sobrecarga inminente.",
|
|
1271
|
+
"recommendation": "Considerar escalar o revisar recursos."
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
# Disponibilidad baja
|
|
1275
|
+
avail = analysis.get("availability", {})
|
|
1276
|
+
if avail.get("percentage", 100) < 95:
|
|
1277
|
+
predictions.append({
|
|
1278
|
+
"type": "stability_issues",
|
|
1279
|
+
"probability": "medium",
|
|
1280
|
+
"message": f"Disponibilidad del {avail.get('percentage', 0):.1f}% en las últimas 24h.",
|
|
1281
|
+
"recommendation": "Revisar logs y configuración."
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
return predictions
|
|
1285
|
+
|
|
1286
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1287
|
+
# REPORTES
|
|
1288
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1289
|
+
async def generate_daily_report() -> str:
|
|
1290
|
+
"""Generar reporte diario completo."""
|
|
1291
|
+
instances = get_all_instances()
|
|
1292
|
+
|
|
1293
|
+
# Health checks en paralelo
|
|
1294
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
1295
|
+
|
|
1296
|
+
# Stats de cada instancia
|
|
1297
|
+
stats_results = await asyncio.gather(*[
|
|
1298
|
+
get_instance_stats(i.port, i.env.get("MASTER_API_KEY", ""))
|
|
1299
|
+
for i in instances
|
|
1300
|
+
])
|
|
1301
|
+
|
|
1302
|
+
lines = [
|
|
1303
|
+
f"📊 *Reporte Diario — {datetime.now().strftime('%d/%m/%Y')}*",
|
|
1304
|
+
""
|
|
1305
|
+
]
|
|
1306
|
+
|
|
1307
|
+
# Resumen general
|
|
1308
|
+
online = sum(1 for h, _ in health_results if h.get("status") == "online")
|
|
1309
|
+
total_conversations = 0
|
|
1310
|
+
total_appointments = 0
|
|
1311
|
+
|
|
1312
|
+
lines.append(f"*Resumen General*")
|
|
1313
|
+
lines.append(f"• Instancias: {online}/{len(instances)} online")
|
|
1314
|
+
|
|
1315
|
+
# Por instancia
|
|
1316
|
+
lines.append("")
|
|
1317
|
+
lines.append("*Por Instancia:*")
|
|
1318
|
+
|
|
1319
|
+
for inst, (health, latency), stats in zip(instances, health_results, stats_results):
|
|
1320
|
+
emoji, sector_name, _ = inst.sector_info
|
|
1321
|
+
is_up = health.get("status") == "online"
|
|
1322
|
+
status_icon = "🟢" if is_up else "🔴"
|
|
1323
|
+
|
|
1324
|
+
convs = stats.get("total_conversations", 0)
|
|
1325
|
+
apts = stats.get("total_appointments", 0)
|
|
1326
|
+
total_conversations += convs
|
|
1327
|
+
total_appointments += apts
|
|
1328
|
+
|
|
1329
|
+
wa = health.get("whatsapp", {})
|
|
1330
|
+
plat = "WA" if wa.get("connected") else "TG"
|
|
1331
|
+
|
|
1332
|
+
lines.append(f"{status_icon} {emoji} *{inst.label}* ({plat})")
|
|
1333
|
+
if is_up:
|
|
1334
|
+
lines.append(f" └ {convs} convs | {apts} citas | {latency:.0f}ms")
|
|
1335
|
+
else:
|
|
1336
|
+
lines.append(f" └ OFFLINE")
|
|
1337
|
+
|
|
1338
|
+
lines.append("")
|
|
1339
|
+
lines.append(f"*Totales:*")
|
|
1340
|
+
lines.append(f"• {total_conversations} conversaciones")
|
|
1341
|
+
lines.append(f"• {total_appointments} citas agendadas")
|
|
1342
|
+
|
|
1343
|
+
# Eventos importantes
|
|
1344
|
+
events = get_recent_events(20)
|
|
1345
|
+
critical_events = [e for e in events if e['severity'] in ('warning', 'error', 'critical')]
|
|
1346
|
+
|
|
1347
|
+
if critical_events:
|
|
1348
|
+
lines.append("")
|
|
1349
|
+
lines.append(f"*⚠️ Eventos ({len(critical_events)}):*")
|
|
1350
|
+
for e in critical_events[:5]:
|
|
1351
|
+
ts = e['timestamp'][:16] if e.get('timestamp') else ''
|
|
1352
|
+
lines.append(f"• [{ts}] {e.get('event_type', '')} - {e.get('instance', '')}")
|
|
1353
|
+
|
|
1354
|
+
# Predicciones
|
|
1355
|
+
all_predictions = []
|
|
1356
|
+
for inst in instances:
|
|
1357
|
+
preds = predict_issues(inst.name)
|
|
1358
|
+
for p in preds:
|
|
1359
|
+
p['instance'] = inst.name
|
|
1360
|
+
all_predictions.append(p)
|
|
1361
|
+
|
|
1362
|
+
if all_predictions:
|
|
1363
|
+
lines.append("")
|
|
1364
|
+
lines.append("*🔮 Predicciones:*")
|
|
1365
|
+
for p in all_predictions[:3]:
|
|
1366
|
+
lines.append(f"• {p['instance']}: {p['message']}")
|
|
1367
|
+
|
|
1368
|
+
return "\n".join(lines)
|
|
1369
|
+
|
|
1370
|
+
async def generate_weekly_report() -> str:
|
|
1371
|
+
"""Generar reporte semanal."""
|
|
1372
|
+
instances = get_all_instances()
|
|
1373
|
+
|
|
1374
|
+
lines = [
|
|
1375
|
+
f"📈 *Reporte Semanal — Semana {datetime.now().strftime('%V/%Y')}*",
|
|
1376
|
+
""
|
|
1377
|
+
]
|
|
1378
|
+
|
|
1379
|
+
# Análisis por instancia
|
|
1380
|
+
for inst in instances:
|
|
1381
|
+
analysis = analyze_trends(inst.name, 168) # 7 días
|
|
1382
|
+
|
|
1383
|
+
if analysis.get("status") == "insufficient_data":
|
|
1384
|
+
continue
|
|
1385
|
+
|
|
1386
|
+
emoji, sector_name, _ = inst.sector_info
|
|
1387
|
+
lines.append(f"{emoji} *{inst.label}*")
|
|
1388
|
+
|
|
1389
|
+
avail = analysis.get("availability", {})
|
|
1390
|
+
lines.append(f" Disponibilidad: {avail.get('percentage', 0):.1f}%")
|
|
1391
|
+
|
|
1392
|
+
lat = analysis.get("latency", {})
|
|
1393
|
+
if lat:
|
|
1394
|
+
lines.append(f" Latencia: {lat.get('avg', 0):.0f}ms avg (p95: {lat.get('p95', 0):.0f}ms)")
|
|
1395
|
+
|
|
1396
|
+
trends = analysis.get("trends", {})
|
|
1397
|
+
if trends.get("conversations"):
|
|
1398
|
+
lines.append(f" Tendencia: {trends['conversations']}")
|
|
1399
|
+
|
|
1400
|
+
lines.append("")
|
|
1401
|
+
|
|
1402
|
+
# Top eventos
|
|
1403
|
+
events = db_execute("""
|
|
1404
|
+
SELECT event_type, COUNT(*) as count
|
|
1405
|
+
FROM events
|
|
1406
|
+
WHERE timestamp > datetime('now', '-7 days')
|
|
1407
|
+
GROUP BY event_type
|
|
1408
|
+
ORDER BY count DESC
|
|
1409
|
+
LIMIT 5
|
|
1410
|
+
""", fetch=True)
|
|
1411
|
+
|
|
1412
|
+
if events:
|
|
1413
|
+
lines.append("*Eventos más frecuentes:*")
|
|
1414
|
+
for e in events:
|
|
1415
|
+
lines.append(f"• {e['event_type']}: {e['count']}")
|
|
1416
|
+
|
|
1417
|
+
return "\n".join(lines)
|
|
1418
|
+
|
|
1419
|
+
async def send_daily_summary():
|
|
1420
|
+
"""Enviar resumen diario."""
|
|
1421
|
+
report = await generate_daily_report()
|
|
1422
|
+
await send_telegram(report)
|
|
1423
|
+
log_audit("omni", "send_daily_report", "", "")
|
|
1424
|
+
|
|
1425
|
+
async def send_weekly_summary():
|
|
1426
|
+
"""Enviar resumen semanal."""
|
|
1427
|
+
report = await generate_weekly_report()
|
|
1428
|
+
await send_telegram(report)
|
|
1429
|
+
log_audit("omni", "send_weekly_report", "", "")
|
|
1430
|
+
|
|
1431
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1432
|
+
# LLM - Cerebro de Omni
|
|
1433
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1434
|
+
def get_conversation_history(limit: int = 20) -> List[Dict]:
|
|
1435
|
+
"""Obtener historial de conversaciones con Santiago."""
|
|
1436
|
+
rows = db_execute("""
|
|
1437
|
+
SELECT role, content FROM santiago_conversations
|
|
1438
|
+
ORDER BY id DESC LIMIT ?
|
|
1439
|
+
""", (limit,), fetch=True)
|
|
1440
|
+
return list(reversed(rows))
|
|
1441
|
+
|
|
1442
|
+
def save_conversation(role: str, content: str):
|
|
1443
|
+
"""Guardar mensaje en historial."""
|
|
1444
|
+
db_execute(
|
|
1445
|
+
"INSERT INTO santiago_conversations (role, content) VALUES (?, ?)",
|
|
1446
|
+
(role, content)
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
def get_preference(key: str, default: str = None) -> str:
|
|
1450
|
+
"""Obtener preferencia guardada."""
|
|
1451
|
+
rows = db_execute("SELECT value FROM preferences WHERE key = ?", (key,), fetch=True)
|
|
1452
|
+
return rows[0]['value'] if rows else default
|
|
1453
|
+
|
|
1454
|
+
def set_preference(key: str, value: str):
|
|
1455
|
+
"""Guardar preferencia."""
|
|
1456
|
+
db_execute("""
|
|
1457
|
+
INSERT OR REPLACE INTO preferences (key, value, updated_at)
|
|
1458
|
+
VALUES (?, ?, datetime('now'))
|
|
1459
|
+
""", (key, value))
|
|
1460
|
+
|
|
1461
|
+
async def omni_brain(user_input: str, all_status: List[Dict], base_env: dict) -> str:
|
|
1462
|
+
"""LLM que sabe todo sobre las instancias y puede tomar acciones."""
|
|
1463
|
+
|
|
1464
|
+
# Construir contexto de instancias
|
|
1465
|
+
instance_lines = []
|
|
1466
|
+
for s in all_status:
|
|
1467
|
+
h = s.get("health", {})
|
|
1468
|
+
stats = s.get("stats", {})
|
|
1469
|
+
is_up = h.get("status") == "online"
|
|
1470
|
+
|
|
1471
|
+
emoji, sector_name, _ = get_sector_info(s.get("sector", "otro"))
|
|
1472
|
+
wa = h.get("whatsapp", {})
|
|
1473
|
+
plat = f"WA {wa.get('phone', '')}" if wa.get("connected") else "Telegram"
|
|
1474
|
+
|
|
1475
|
+
convs = stats.get("total_conversations", "?")
|
|
1476
|
+
apts = stats.get("total_appointments", "?")
|
|
1477
|
+
nova = "Nova ON" if s.get("env", {}).get("NOVA_ENABLED") == "true" else ""
|
|
1478
|
+
latency = s.get("latency", 0)
|
|
1479
|
+
|
|
1480
|
+
instance_lines.append(
|
|
1481
|
+
f" {emoji} {s['name']}: {'ONLINE' if is_up else 'OFFLINE'} | {plat} | "
|
|
1482
|
+
f"{sector_name} | {convs} convs | {apts} citas | {latency:.0f}ms"
|
|
1483
|
+
+ (f" | {nova}" if nova else "")
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
instances_ctx = "\n".join(instance_lines) if instance_lines else " (sin instancias)"
|
|
1487
|
+
|
|
1488
|
+
# Eventos recientes
|
|
1489
|
+
events = get_recent_events(10)
|
|
1490
|
+
events_ctx = ""
|
|
1491
|
+
if events:
|
|
1492
|
+
events_ctx = "\n\nEVENTOS RECIENTES:\n" + "\n".join(
|
|
1493
|
+
f" [{e.get('timestamp', '')[:16]}] {e.get('severity', '').upper()}: "
|
|
1494
|
+
f"{e.get('event_type', '')} — {e.get('instance', '')}"
|
|
1495
|
+
for e in events[:5]
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
# Alertas sin reconocer
|
|
1499
|
+
unack = get_unacknowledged_events()
|
|
1500
|
+
alerts_ctx = ""
|
|
1501
|
+
if unack:
|
|
1502
|
+
alerts_ctx = f"\n\n🔔 ALERTAS SIN RECONOCER: {len(unack)}\n" + "\n".join(
|
|
1503
|
+
f" • [{e.get('timestamp', '')[:16]}] {e.get('event_type', '')} - {e.get('instance', '')}"
|
|
1504
|
+
for e in unack[:3]
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
# Predicciones
|
|
1508
|
+
predictions_ctx = ""
|
|
1509
|
+
for s in all_status[:3]: # Solo las primeras para no sobrecargar
|
|
1510
|
+
preds = predict_issues(s['name'])
|
|
1511
|
+
if preds:
|
|
1512
|
+
predictions_ctx += f"\n\n🔮 PREDICCIONES PARA {s['name']}:\n"
|
|
1513
|
+
for p in preds:
|
|
1514
|
+
predictions_ctx += f" • {p['message']}"
|
|
1515
|
+
|
|
1516
|
+
# Historial de conversación
|
|
1517
|
+
history = get_conversation_history(10)
|
|
1518
|
+
hist_text = "\n".join(
|
|
1519
|
+
f"{'Santiago' if h['role'] == 'user' else 'Omni'}: {h['content'][:200]}"
|
|
1520
|
+
for h in history
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
# Preferencias de Santiago
|
|
1524
|
+
preferences = get_preference("communication_style", "profesional pero cercano")
|
|
1525
|
+
|
|
1526
|
+
system = f"""Eres Conny Omni — el centro de comando de Santiago para toda la plataforma Conny.
|
|
1527
|
+
Santiago es el dueño y tú eres su asistente ejecutiva de confianza.
|
|
1528
|
+
|
|
1529
|
+
═══════════════════════════════════════════════════════════════
|
|
1530
|
+
ESTADO ACTUAL DE INSTANCIAS:
|
|
1531
|
+
{instances_ctx}
|
|
1532
|
+
{events_ctx}
|
|
1533
|
+
{alerts_ctx}
|
|
1534
|
+
{predictions_ctx}
|
|
1535
|
+
═══════════════════════════════════════════════════════════════
|
|
1536
|
+
|
|
1537
|
+
HISTORIAL DE CONVERSACIÓN:
|
|
1538
|
+
{hist_text or "(inicio de conversación)"}
|
|
1539
|
+
|
|
1540
|
+
═══════════════════════════════════════════════════════════════
|
|
1541
|
+
CÓMO ERES:
|
|
1542
|
+
- Estilo: {preferences}
|
|
1543
|
+
- Directa y ejecutiva. Máximo 4 oraciones a menos que te pidan detalle.
|
|
1544
|
+
- Cuando Santiago pregunta por una instancia, das estado completo con números.
|
|
1545
|
+
- Si hay algo offline, en warning o predicciones, lo mencionas primero.
|
|
1546
|
+
- Puedes ver tendencias, predecir problemas y sugerir acciones.
|
|
1547
|
+
- Conoces los sectores: clínicas, restaurantes, hoteles, gimnasios, etc.
|
|
1548
|
+
|
|
1549
|
+
ACCIONES QUE PUEDES EJECUTAR (incluye al FINAL si necesitas):
|
|
1550
|
+
ACTION:{{"type":"detail","instance":"nombre"}}
|
|
1551
|
+
ACTION:{{"type":"summary_all"}}
|
|
1552
|
+
ACTION:{{"type":"restart","instance":"nombre"}}
|
|
1553
|
+
ACTION:{{"type":"send_message","instance":"nombre","chat_id":"...","message":"..."}}
|
|
1554
|
+
ACTION:{{"type":"create_alert","name":"...","instance":"*","metric":"latency_ms","operator":">","threshold":500}}
|
|
1555
|
+
ACTION:{{"type":"analyze","instance":"nombre"}}
|
|
1556
|
+
ACTION:{{"type":"predict","instance":"nombre"}}
|
|
1557
|
+
ACTION:{{"type":"acknowledge_alerts"}}
|
|
1558
|
+
ACTION:{{"type":"backup","instance":"nombre"}}
|
|
1559
|
+
ACTION:{{"type":"scale","instance":"nombre","workers":4}}
|
|
1560
|
+
ACTION:{{"type":"set_demo","instance":"nombre","active":true,"business_name":"...","sector":"estetica","session_ttl":1800}}
|
|
1561
|
+
ACTION:{{"type":"demo_status","instance":"nombre"}}
|
|
1562
|
+
|
|
1563
|
+
Responde en español, informal con Santiago pero preciso con los datos.
|
|
1564
|
+
|
|
1565
|
+
IMPORTANTE — Solo incluye una ACTION si Santiago te lo pide explícitamente o si detectas un problema real que requiere acción inmediata. NO incluyas ACTION:summary_all ni ninguna otra acción por defecto en cada respuesta. Si Santiago solo pregunta o saluda, responde con texto únicamente."""
|
|
1566
|
+
|
|
1567
|
+
messages = [
|
|
1568
|
+
{"role": "system", "content": system},
|
|
1569
|
+
{"role": "user", "content": user_input}
|
|
1570
|
+
]
|
|
1571
|
+
|
|
1572
|
+
# Proveedores LLM
|
|
1573
|
+
providers = []
|
|
1574
|
+
if base_env.get("GROQ_API_KEY"):
|
|
1575
|
+
providers.append(("groq", "https://api.groq.com/openai/v1",
|
|
1576
|
+
base_env["GROQ_API_KEY"], "llama-3.3-70b-versatile"))
|
|
1577
|
+
|
|
1578
|
+
gemini_keys = ["GEMINI_API_KEY"] + [f"GEMINI_API_KEY_{i}" for i in range(2, 8)]
|
|
1579
|
+
for k in gemini_keys:
|
|
1580
|
+
if base_env.get(k):
|
|
1581
|
+
providers.append(("gemini", "native", base_env[k], "gemini-2.0-flash"))
|
|
1582
|
+
|
|
1583
|
+
if base_env.get("OPENROUTER_API_KEY"):
|
|
1584
|
+
providers.append(("openrouter", "https://openrouter.ai/api/v1",
|
|
1585
|
+
base_env["OPENROUTER_API_KEY"], "google/gemini-2.0-flash-001"))
|
|
1586
|
+
|
|
1587
|
+
if not providers:
|
|
1588
|
+
return "No hay LLMs configurados en el .env de Conny."
|
|
1589
|
+
|
|
1590
|
+
for name, base_url, api_key, model in providers:
|
|
1591
|
+
try:
|
|
1592
|
+
if not _HTTPX:
|
|
1593
|
+
break
|
|
1594
|
+
|
|
1595
|
+
if name == "gemini":
|
|
1596
|
+
contents = [
|
|
1597
|
+
{"role": "user" if m["role"] == "user" else "model",
|
|
1598
|
+
"parts": [{"text": m["content"]}]}
|
|
1599
|
+
for m in messages if m["role"] != "system"
|
|
1600
|
+
]
|
|
1601
|
+
sys_parts = [m for m in messages if m["role"] == "system"]
|
|
1602
|
+
payload = {
|
|
1603
|
+
"contents": contents,
|
|
1604
|
+
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 600}
|
|
1605
|
+
}
|
|
1606
|
+
if sys_parts:
|
|
1607
|
+
payload["systemInstruction"] = {"parts": [{"text": sys_parts[0]["content"]}]}
|
|
1608
|
+
|
|
1609
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
|
1610
|
+
async with httpx.AsyncClient(timeout=25.0) as c:
|
|
1611
|
+
r = await c.post(url, json=payload)
|
|
1612
|
+
r.raise_for_status()
|
|
1613
|
+
return r.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
1614
|
+
|
|
1615
|
+
else:
|
|
1616
|
+
async with httpx.AsyncClient(timeout=25.0) as c:
|
|
1617
|
+
r = await c.post(
|
|
1618
|
+
f"{base_url}/chat/completions",
|
|
1619
|
+
headers={
|
|
1620
|
+
"Authorization": f"Bearer {api_key}",
|
|
1621
|
+
"Content-Type": "application/json"
|
|
1622
|
+
},
|
|
1623
|
+
json={
|
|
1624
|
+
"model": model,
|
|
1625
|
+
"messages": messages,
|
|
1626
|
+
"temperature": 0.3,
|
|
1627
|
+
"max_tokens": 600
|
|
1628
|
+
}
|
|
1629
|
+
)
|
|
1630
|
+
r.raise_for_status()
|
|
1631
|
+
return r.json()["choices"][0]["message"]["content"].strip()
|
|
1632
|
+
|
|
1633
|
+
except Exception as e:
|
|
1634
|
+
continue
|
|
1635
|
+
|
|
1636
|
+
return "Sin respuesta de LLM — verifica las API keys."
|
|
1637
|
+
|
|
1638
|
+
def extract_action(text: str) -> Optional[Dict]:
|
|
1639
|
+
"""Extraer acción del texto de respuesta."""
|
|
1640
|
+
m = re.search(r'ACTION:\s*(\{[^\n]+\})', text)
|
|
1641
|
+
if m:
|
|
1642
|
+
try:
|
|
1643
|
+
return json.loads(m.group(1))
|
|
1644
|
+
except Exception:
|
|
1645
|
+
pass
|
|
1646
|
+
return None
|
|
1647
|
+
|
|
1648
|
+
def clean_response(text: str) -> str:
|
|
1649
|
+
"""Limpiar respuesta quitando acciones."""
|
|
1650
|
+
return re.sub(r'\n?ACTION:\s*\{[^\n]+\}', '', text).strip()
|
|
1651
|
+
|
|
1652
|
+
async def execute_action(action: Dict, instances: List[Instance], base_env: dict) -> str:
|
|
1653
|
+
"""Ejecutar acción solicitada por Omni."""
|
|
1654
|
+
action_type = action.get("type", "")
|
|
1655
|
+
|
|
1656
|
+
if action_type == "detail":
|
|
1657
|
+
name = action.get("instance", "")
|
|
1658
|
+
inst = resolve_instance(instances, name)
|
|
1659
|
+
if not inst:
|
|
1660
|
+
return f"No encontré instancia '{name}'"
|
|
1661
|
+
|
|
1662
|
+
h, latency = await health_check(inst.port)
|
|
1663
|
+
mk = inst.env.get("MASTER_API_KEY", "")
|
|
1664
|
+
stats = await get_instance_stats(inst.port, mk) if h else {}
|
|
1665
|
+
convs = await get_recent_conversations(inst.port, mk, 3) if h else []
|
|
1666
|
+
analysis = analyze_trends(inst.name, 24)
|
|
1667
|
+
|
|
1668
|
+
emoji, sector_name, _ = inst.sector_info
|
|
1669
|
+
wa = h.get("whatsapp", {})
|
|
1670
|
+
plat = f"WA {wa.get('phone', '')}" if wa.get("connected") else "Telegram"
|
|
1671
|
+
status = "ONLINE" if h.get("status") == "online" else "OFFLINE"
|
|
1672
|
+
|
|
1673
|
+
lines = [
|
|
1674
|
+
f"*{emoji} {h.get('clinic', inst.label)}* — {status}",
|
|
1675
|
+
f"Plataforma: {plat} | Sector: {sector_name}",
|
|
1676
|
+
f"Puerto: {inst.port} | Latencia: {latency:.0f}ms",
|
|
1677
|
+
]
|
|
1678
|
+
|
|
1679
|
+
if stats:
|
|
1680
|
+
lines.append(f"\n📊 *Esta semana:*")
|
|
1681
|
+
lines.append(f"• {stats.get('total_conversations', 0)} conversaciones")
|
|
1682
|
+
lines.append(f"• {stats.get('total_appointments', 0)} citas")
|
|
1683
|
+
cr = stats.get("conversion_rate")
|
|
1684
|
+
if cr:
|
|
1685
|
+
lines.append(f"• Conversión: {cr}%")
|
|
1686
|
+
|
|
1687
|
+
if analysis.get("availability"):
|
|
1688
|
+
avail = analysis["availability"]
|
|
1689
|
+
lines.append(f"\n📈 *Últimas 24h:*")
|
|
1690
|
+
lines.append(f"• Disponibilidad: {avail.get('percentage', 0):.1f}%")
|
|
1691
|
+
if analysis.get("latency"):
|
|
1692
|
+
lat = analysis["latency"]
|
|
1693
|
+
lines.append(f"• Latencia avg: {lat.get('avg', 0):.0f}ms (p95: {lat.get('p95', 0):.0f}ms)")
|
|
1694
|
+
|
|
1695
|
+
if convs:
|
|
1696
|
+
lines.append(f"\n💬 *Últimas conversaciones:*")
|
|
1697
|
+
for c in convs[:3]:
|
|
1698
|
+
name = c.get("name") or "Desconocido"
|
|
1699
|
+
last = (c.get("last_user_msg") or "")[:60]
|
|
1700
|
+
lines.append(f"• {name}: \"{last}\"")
|
|
1701
|
+
|
|
1702
|
+
return "\n".join(lines)
|
|
1703
|
+
|
|
1704
|
+
elif action_type == "summary_all":
|
|
1705
|
+
return await generate_daily_report()
|
|
1706
|
+
|
|
1707
|
+
elif action_type == "restart":
|
|
1708
|
+
name = action.get("instance", "")
|
|
1709
|
+
inst = resolve_instance(instances, name)
|
|
1710
|
+
if not inst:
|
|
1711
|
+
return f"No encontré instancia '{name}'"
|
|
1712
|
+
|
|
1713
|
+
log_audit("santiago", "restart", inst.name, "via omni chat")
|
|
1714
|
+
success = pm2_restart(inst.pm2_name)
|
|
1715
|
+
|
|
1716
|
+
if success:
|
|
1717
|
+
await asyncio.sleep(5)
|
|
1718
|
+
h, _ = await health_check(inst.port)
|
|
1719
|
+
if h.get("status") == "online":
|
|
1720
|
+
return f"✅ {inst.label} reiniciada y online"
|
|
1721
|
+
return f"⏳ {inst.label} reiniciada, verificando..."
|
|
1722
|
+
return f"❌ Error reiniciando {inst.label}"
|
|
1723
|
+
|
|
1724
|
+
elif action_type == "send_message":
|
|
1725
|
+
name = action.get("instance", "")
|
|
1726
|
+
chat_id = action.get("chat_id", "")
|
|
1727
|
+
message = action.get("message", "")
|
|
1728
|
+
|
|
1729
|
+
inst = resolve_instance(instances, name)
|
|
1730
|
+
if not inst:
|
|
1731
|
+
return f"No encontré instancia '{name}'"
|
|
1732
|
+
|
|
1733
|
+
mk = inst.env.get("MASTER_API_KEY", "")
|
|
1734
|
+
success = await send_to_instance(inst.port, mk, chat_id, message)
|
|
1735
|
+
log_audit("santiago", "send_message", inst.name, f"chat_id={chat_id}")
|
|
1736
|
+
|
|
1737
|
+
return "✅ Mensaje enviado" if success else "❌ Error enviando mensaje"
|
|
1738
|
+
|
|
1739
|
+
elif action_type == "analyze":
|
|
1740
|
+
name = action.get("instance", "")
|
|
1741
|
+
inst = resolve_instance(instances, name)
|
|
1742
|
+
if not inst:
|
|
1743
|
+
return f"No encontré instancia '{name}'"
|
|
1744
|
+
|
|
1745
|
+
analysis = analyze_trends(inst.name, 24)
|
|
1746
|
+
anomalies = detect_anomalies(inst.name, 6)
|
|
1747
|
+
|
|
1748
|
+
lines = [f"📊 *Análisis de {inst.label}*"]
|
|
1749
|
+
|
|
1750
|
+
if analysis.get("status") == "insufficient_data":
|
|
1751
|
+
lines.append("Datos insuficientes para análisis completo")
|
|
1752
|
+
else:
|
|
1753
|
+
if analysis.get("availability"):
|
|
1754
|
+
avail = analysis["availability"]
|
|
1755
|
+
lines.append(f"\n*Disponibilidad (24h):* {avail.get('percentage', 0):.1f}%")
|
|
1756
|
+
|
|
1757
|
+
if analysis.get("latency"):
|
|
1758
|
+
lat = analysis["latency"]
|
|
1759
|
+
lines.append(f"\n*Latencia:*")
|
|
1760
|
+
lines.append(f"• Promedio: {lat.get('avg', 0):.0f}ms")
|
|
1761
|
+
lines.append(f"• P95: {lat.get('p95', 0):.0f}ms")
|
|
1762
|
+
lines.append(f"• Tendencia: {lat.get('trend', 'stable')}")
|
|
1763
|
+
|
|
1764
|
+
if anomalies:
|
|
1765
|
+
lines.append(f"\n⚠️ *Anomalías detectadas:* {len(anomalies)}")
|
|
1766
|
+
for a in anomalies[:3]:
|
|
1767
|
+
lines.append(f"• {a.get('type', '')}: {a.get('timestamp', '')[:16]}")
|
|
1768
|
+
|
|
1769
|
+
return "\n".join(lines)
|
|
1770
|
+
|
|
1771
|
+
elif action_type == "predict":
|
|
1772
|
+
name = action.get("instance", "")
|
|
1773
|
+
inst = resolve_instance(instances, name)
|
|
1774
|
+
if not inst:
|
|
1775
|
+
return f"No encontré instancia '{name}'"
|
|
1776
|
+
|
|
1777
|
+
predictions = predict_issues(inst.name)
|
|
1778
|
+
|
|
1779
|
+
if not predictions:
|
|
1780
|
+
return f"🔮 Sin predicciones de problemas para {inst.label}"
|
|
1781
|
+
|
|
1782
|
+
lines = [f"🔮 *Predicciones para {inst.label}:*"]
|
|
1783
|
+
for p in predictions:
|
|
1784
|
+
lines.append(f"\n• *{p.get('type', '')}* ({p.get('probability', '')} probabilidad)")
|
|
1785
|
+
lines.append(f" {p.get('message', '')}")
|
|
1786
|
+
lines.append(f" 💡 {p.get('recommendation', '')}")
|
|
1787
|
+
|
|
1788
|
+
return "\n".join(lines)
|
|
1789
|
+
|
|
1790
|
+
elif action_type == "acknowledge_alerts":
|
|
1791
|
+
unack = get_unacknowledged_events()
|
|
1792
|
+
for e in unack:
|
|
1793
|
+
acknowledge_event(e['id'], "Santiago")
|
|
1794
|
+
return f"✅ {len(unack)} alertas reconocidas"
|
|
1795
|
+
|
|
1796
|
+
elif action_type == "create_alert":
|
|
1797
|
+
rule_id = create_alert_rule(
|
|
1798
|
+
name=action.get("name", "Nueva alerta"),
|
|
1799
|
+
instance=action.get("instance", "*"),
|
|
1800
|
+
metric=action.get("metric", "latency_ms"),
|
|
1801
|
+
operator=action.get("operator", ">"),
|
|
1802
|
+
threshold=float(action.get("threshold", 500)),
|
|
1803
|
+
severity=action.get("severity", "warning"),
|
|
1804
|
+
channels=action.get("channels", "telegram")
|
|
1805
|
+
)
|
|
1806
|
+
return f"✅ Alerta creada (ID: {rule_id})"
|
|
1807
|
+
|
|
1808
|
+
elif action_type == "backup":
|
|
1809
|
+
name = action.get("instance", "")
|
|
1810
|
+
inst = resolve_instance(instances, name)
|
|
1811
|
+
if not inst:
|
|
1812
|
+
return f"No encontré instancia '{name}'"
|
|
1813
|
+
|
|
1814
|
+
import subprocess
|
|
1815
|
+
result = subprocess.run(
|
|
1816
|
+
["conny", "backup", inst.name],
|
|
1817
|
+
capture_output=True,
|
|
1818
|
+
text=True
|
|
1819
|
+
)
|
|
1820
|
+
log_audit("santiago", "backup", inst.name, "via omni")
|
|
1821
|
+
return "✅ Backup iniciado" if result.returncode == 0 else f"❌ Error: {result.stderr[:100]}"
|
|
1822
|
+
|
|
1823
|
+
elif action_type == "scale":
|
|
1824
|
+
name = action.get("instance", "")
|
|
1825
|
+
workers = action.get("workers", 2)
|
|
1826
|
+
|
|
1827
|
+
inst = resolve_instance(instances, name)
|
|
1828
|
+
if not inst:
|
|
1829
|
+
return f"No encontré instancia '{name}'"
|
|
1830
|
+
|
|
1831
|
+
import subprocess
|
|
1832
|
+
result = subprocess.run(
|
|
1833
|
+
["pm2", "scale", inst.pm2_name, str(workers)],
|
|
1834
|
+
capture_output=True
|
|
1835
|
+
)
|
|
1836
|
+
log_audit("santiago", "scale", inst.name, f"workers={workers}")
|
|
1837
|
+
return f"✅ Escalado a {workers} workers" if result.returncode == 0 else "❌ Error escalando"
|
|
1838
|
+
|
|
1839
|
+
elif action_type == "demo_status":
|
|
1840
|
+
name = action.get("instance", "")
|
|
1841
|
+
inst = resolve_instance(instances, name)
|
|
1842
|
+
if not inst:
|
|
1843
|
+
return f"No encontré instancia '{name}'"
|
|
1844
|
+
status = await http_get(f"http://localhost:{inst.port}/demo/status") or {}
|
|
1845
|
+
health, latency = await health_check(inst.port)
|
|
1846
|
+
demo_mode = "ACTIVO" if status.get("demo_mode") else "apagado"
|
|
1847
|
+
return "\n".join([
|
|
1848
|
+
f"🎭 *Demo mode — {inst.label}*",
|
|
1849
|
+
f"Estado: {demo_mode}",
|
|
1850
|
+
f"Negocio: {status.get('business_name', inst.label)}",
|
|
1851
|
+
f"Sector: {status.get('sector', inst.sector)}",
|
|
1852
|
+
f"TTL sesión: {status.get('session_ttl', 0)}s",
|
|
1853
|
+
f"Instancia: {health.get('status', 'offline')} | {latency:.0f}ms",
|
|
1854
|
+
])
|
|
1855
|
+
|
|
1856
|
+
elif action_type == "set_demo":
|
|
1857
|
+
name = action.get("instance", "")
|
|
1858
|
+
inst = resolve_instance(instances, name)
|
|
1859
|
+
if not inst:
|
|
1860
|
+
return f"No encontré instancia '{name}'"
|
|
1861
|
+
active = bool(action.get("active", True))
|
|
1862
|
+
session_ttl = int(action.get("session_ttl", 1800) or 1800)
|
|
1863
|
+
result = await set_instance_demo(
|
|
1864
|
+
inst,
|
|
1865
|
+
active=active,
|
|
1866
|
+
business_name=action.get("business_name", "") or "",
|
|
1867
|
+
sector=action.get("sector", "") or "",
|
|
1868
|
+
session_ttl=session_ttl,
|
|
1869
|
+
)
|
|
1870
|
+
log_audit(
|
|
1871
|
+
"santiago",
|
|
1872
|
+
"set_demo",
|
|
1873
|
+
inst.name,
|
|
1874
|
+
f"active={result['active']} business={result['business_name']}",
|
|
1875
|
+
)
|
|
1876
|
+
state = "activado" if result["active"] else "desactivado"
|
|
1877
|
+
return "\n".join([
|
|
1878
|
+
f"✅ Demo {state} en {result['label']}",
|
|
1879
|
+
f"Negocio: {result['business_name']}",
|
|
1880
|
+
f"Sector: {result['sector']}",
|
|
1881
|
+
f"TTL: {result['session_ttl']}s",
|
|
1882
|
+
f"Estado: {result['health']} | {result['latency_ms']:.0f}ms",
|
|
1883
|
+
])
|
|
1884
|
+
|
|
1885
|
+
return ""
|
|
1886
|
+
|
|
1887
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1888
|
+
# BACKGROUND TASKS
|
|
1889
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
1890
|
+
async def health_monitor():
|
|
1891
|
+
"""Monitor de salud que corre cada HEALTH_INTERVAL segundos."""
|
|
1892
|
+
while True:
|
|
1893
|
+
try:
|
|
1894
|
+
instances = get_all_instances()
|
|
1895
|
+
|
|
1896
|
+
for inst in instances:
|
|
1897
|
+
h, latency = await health_check(inst.port)
|
|
1898
|
+
status = h.get("status", "offline")
|
|
1899
|
+
|
|
1900
|
+
# Registrar métricas
|
|
1901
|
+
mk = inst.env.get("MASTER_API_KEY", "")
|
|
1902
|
+
stats = await get_instance_stats(inst.port, mk) if h else {}
|
|
1903
|
+
|
|
1904
|
+
record_metrics(
|
|
1905
|
+
inst.name,
|
|
1906
|
+
status,
|
|
1907
|
+
latency,
|
|
1908
|
+
stats.get("total_conversations", 0),
|
|
1909
|
+
stats.get("total_appointments", 0),
|
|
1910
|
+
stats.get("total_messages", 0)
|
|
1911
|
+
)
|
|
1912
|
+
|
|
1913
|
+
# Verificar alertas
|
|
1914
|
+
metrics_dict = {
|
|
1915
|
+
"status": 1 if status == "online" else 0,
|
|
1916
|
+
"latency_ms": latency,
|
|
1917
|
+
"conversations": stats.get("total_conversations", 0),
|
|
1918
|
+
"appointments": stats.get("total_appointments", 0)
|
|
1919
|
+
}
|
|
1920
|
+
await check_alert_rules(inst.name, metrics_dict)
|
|
1921
|
+
|
|
1922
|
+
# Auto-heal si está offline
|
|
1923
|
+
if status != "online":
|
|
1924
|
+
log_event(inst.name, "offline_detected", f"Port {inst.port}", "warning")
|
|
1925
|
+
await auto_heal_instance(inst)
|
|
1926
|
+
else:
|
|
1927
|
+
# Limpiar estado de down si estaba caído
|
|
1928
|
+
if inst.name in _down_since:
|
|
1929
|
+
del _down_since[inst.name]
|
|
1930
|
+
if inst.name in _restart_attempts:
|
|
1931
|
+
del _restart_attempts[inst.name]
|
|
1932
|
+
|
|
1933
|
+
except Exception as e:
|
|
1934
|
+
log_event("omni", "health_monitor_error", str(e), "error")
|
|
1935
|
+
|
|
1936
|
+
await asyncio.sleep(HEALTH_INTERVAL)
|
|
1937
|
+
|
|
1938
|
+
async def metrics_collector():
|
|
1939
|
+
"""Recolector de métricas detalladas cada METRICS_INTERVAL segundos."""
|
|
1940
|
+
while True:
|
|
1941
|
+
try:
|
|
1942
|
+
instances = get_all_instances()
|
|
1943
|
+
|
|
1944
|
+
for inst in instances:
|
|
1945
|
+
mk = inst.env.get("MASTER_API_KEY", "")
|
|
1946
|
+
stats = await get_instance_stats(inst.port, mk)
|
|
1947
|
+
|
|
1948
|
+
if stats:
|
|
1949
|
+
# Guardar métricas adicionales si las hay
|
|
1950
|
+
pass
|
|
1951
|
+
|
|
1952
|
+
except Exception as e:
|
|
1953
|
+
log_event("omni", "metrics_collector_error", str(e), "error")
|
|
1954
|
+
|
|
1955
|
+
await asyncio.sleep(METRICS_INTERVAL)
|
|
1956
|
+
|
|
1957
|
+
async def scheduled_reports():
|
|
1958
|
+
"""Programador de reportes automáticos."""
|
|
1959
|
+
while True:
|
|
1960
|
+
now = datetime.now()
|
|
1961
|
+
|
|
1962
|
+
# Reporte diario a las 8am
|
|
1963
|
+
if now.hour == 8 and now.minute == 0:
|
|
1964
|
+
await send_daily_summary()
|
|
1965
|
+
log_audit("omni", "scheduled_daily_report", "", "")
|
|
1966
|
+
|
|
1967
|
+
# Reporte semanal los lunes a las 9am
|
|
1968
|
+
if now.weekday() == 0 and now.hour == 9 and now.minute == 0:
|
|
1969
|
+
await send_weekly_summary()
|
|
1970
|
+
log_audit("omni", "scheduled_weekly_report", "", "")
|
|
1971
|
+
|
|
1972
|
+
await asyncio.sleep(60) # Verificar cada minuto
|
|
1973
|
+
|
|
1974
|
+
async def cleanup_old_data():
|
|
1975
|
+
"""Limpiar datos antiguos periódicamente."""
|
|
1976
|
+
while True:
|
|
1977
|
+
try:
|
|
1978
|
+
# Métricas > 30 días
|
|
1979
|
+
db_execute("""
|
|
1980
|
+
DELETE FROM metrics WHERE timestamp < datetime('now', '-30 days')
|
|
1981
|
+
""")
|
|
1982
|
+
|
|
1983
|
+
# Eventos > 90 días
|
|
1984
|
+
db_execute("""
|
|
1985
|
+
DELETE FROM events WHERE timestamp < datetime('now', '-90 days')
|
|
1986
|
+
""")
|
|
1987
|
+
|
|
1988
|
+
# Audit log > 180 días
|
|
1989
|
+
db_execute("""
|
|
1990
|
+
DELETE FROM audit_log WHERE timestamp < datetime('now', '-180 days')
|
|
1991
|
+
""")
|
|
1992
|
+
|
|
1993
|
+
# Conversaciones > 60 días
|
|
1994
|
+
db_execute("""
|
|
1995
|
+
DELETE FROM santiago_conversations WHERE timestamp < datetime('now', '-60 days')
|
|
1996
|
+
""")
|
|
1997
|
+
|
|
1998
|
+
except Exception as e:
|
|
1999
|
+
log_event("omni", "cleanup_error", str(e), "error")
|
|
2000
|
+
|
|
2001
|
+
await asyncio.sleep(86400) # Una vez al día
|
|
2002
|
+
|
|
2003
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2004
|
+
# EVENTO QUEUE (en memoria para acceso rápido)
|
|
2005
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2006
|
+
_event_queue: List[Dict] = []
|
|
2007
|
+
|
|
2008
|
+
async def process_incoming_event(event: Dict):
|
|
2009
|
+
"""Procesar evento entrante."""
|
|
2010
|
+
event["received_at"] = datetime.now().isoformat()
|
|
2011
|
+
|
|
2012
|
+
# Guardar en memoria
|
|
2013
|
+
_event_queue.append(event)
|
|
2014
|
+
if len(_event_queue) > 500:
|
|
2015
|
+
_event_queue[:] = _event_queue[-500:]
|
|
2016
|
+
|
|
2017
|
+
# Guardar en DB
|
|
2018
|
+
log_event(
|
|
2019
|
+
event.get("clinic", event.get("instance", "")),
|
|
2020
|
+
event.get("event", "unknown"),
|
|
2021
|
+
event.get("details", ""),
|
|
2022
|
+
event.get("severity", "info")
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
# Procesar workflows
|
|
2026
|
+
await process_event_for_workflows(event.get("event", ""), event)
|
|
2027
|
+
|
|
2028
|
+
# Alertas críticas inmediatas
|
|
2029
|
+
CRITICAL_EVENTS = {
|
|
2030
|
+
"new_whatsapp_number_requested",
|
|
2031
|
+
"instance_error",
|
|
2032
|
+
"client_complaint",
|
|
2033
|
+
"payment_failed",
|
|
2034
|
+
"security_alert",
|
|
2035
|
+
"data_breach"
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
if event.get("event") in CRITICAL_EVENTS:
|
|
2039
|
+
emoji_map = {
|
|
2040
|
+
"new_whatsapp_number_requested": "📱",
|
|
2041
|
+
"instance_error": "🔥",
|
|
2042
|
+
"client_complaint": "⚠️",
|
|
2043
|
+
"payment_failed": "💳",
|
|
2044
|
+
"security_alert": "🔐",
|
|
2045
|
+
"data_breach": "🚨"
|
|
2046
|
+
}
|
|
2047
|
+
emoji = emoji_map.get(event.get("event"), "📌")
|
|
2048
|
+
|
|
2049
|
+
await send_notification(Notification(
|
|
2050
|
+
title=event.get("event", "Evento"),
|
|
2051
|
+
message=event.get("details", ""),
|
|
2052
|
+
severity="critical",
|
|
2053
|
+
instance=event.get("clinic", ""),
|
|
2054
|
+
channels=["telegram", "email"] if event.get("event") in {"security_alert", "data_breach"} else ["telegram"]
|
|
2055
|
+
))
|
|
2056
|
+
|
|
2057
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2058
|
+
# FASTAPI SERVER
|
|
2059
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2060
|
+
_santiago_ws_connections: List[WebSocket] = []
|
|
2061
|
+
|
|
2062
|
+
if HAS_FASTAPI:
|
|
2063
|
+
@asynccontextmanager
|
|
2064
|
+
async def lifespan(app_: FastAPI):
|
|
2065
|
+
init_db()
|
|
2066
|
+
|
|
2067
|
+
# Background tasks
|
|
2068
|
+
asyncio.create_task(health_monitor())
|
|
2069
|
+
asyncio.create_task(metrics_collector())
|
|
2070
|
+
asyncio.create_task(scheduled_reports())
|
|
2071
|
+
asyncio.create_task(cleanup_old_data())
|
|
2072
|
+
|
|
2073
|
+
print_logo(compact=True)
|
|
2074
|
+
info(f"Omni server online — puerto {OMNI_PORT}")
|
|
2075
|
+
info(f"Dashboard: http://localhost:{OMNI_PORT}/dashboard")
|
|
2076
|
+
|
|
2077
|
+
if OMNI_TOKEN:
|
|
2078
|
+
ok("Telegram configurado")
|
|
2079
|
+
if SLACK_WEBHOOK:
|
|
2080
|
+
ok("Slack configurado")
|
|
2081
|
+
if DISCORD_WEBHOOK:
|
|
2082
|
+
ok("Discord configurado")
|
|
2083
|
+
if SMTP_HOST:
|
|
2084
|
+
ok("Email configurado")
|
|
2085
|
+
if AUTO_HEAL_ENABLED:
|
|
2086
|
+
ok(f"Auto-heal activo (delay: {AUTO_HEAL_DELAY}s)")
|
|
2087
|
+
|
|
2088
|
+
nl()
|
|
2089
|
+
yield
|
|
2090
|
+
|
|
2091
|
+
omni_app = FastAPI(title="Conny Omni", version=OMNI_VERSION, lifespan=lifespan)
|
|
2092
|
+
omni_app.add_middleware(
|
|
2093
|
+
CORSMiddleware,
|
|
2094
|
+
allow_origins=["*"],
|
|
2095
|
+
allow_methods=["*"],
|
|
2096
|
+
allow_headers=["*"]
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
def auth_required(request: Request) -> bool:
|
|
2100
|
+
return request.headers.get("X-Omni-Key", "") == OMNI_KEY
|
|
2101
|
+
|
|
2102
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2103
|
+
# ENDPOINTS PÚBLICOS
|
|
2104
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2105
|
+
|
|
2106
|
+
@omni_app.get("/health")
|
|
2107
|
+
async def omni_health():
|
|
2108
|
+
instances = get_all_instances()
|
|
2109
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2110
|
+
online = sum(1 for h, _ in health_results if h.get("status") == "online")
|
|
2111
|
+
|
|
2112
|
+
return {
|
|
2113
|
+
"status": "online",
|
|
2114
|
+
"version": OMNI_VERSION,
|
|
2115
|
+
"instances": len(instances),
|
|
2116
|
+
"online": online,
|
|
2117
|
+
"events_queue": len(_event_queue),
|
|
2118
|
+
"auto_heal": AUTO_HEAL_ENABLED
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
@omni_app.get("/dashboard", response_class=HTMLResponse)
|
|
2122
|
+
async def dashboard_html():
|
|
2123
|
+
"""Dashboard web embebido."""
|
|
2124
|
+
instances = get_all_instances()
|
|
2125
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2126
|
+
|
|
2127
|
+
rows_html = ""
|
|
2128
|
+
for inst, (h, latency) in zip(instances, health_results):
|
|
2129
|
+
is_up = h.get("status") == "online"
|
|
2130
|
+
emoji, sector_name, color = inst.sector_info
|
|
2131
|
+
status_class = "online" if is_up else "offline"
|
|
2132
|
+
wa = h.get("whatsapp", {})
|
|
2133
|
+
plat = f"WA {wa.get('phone', '')[-7:]}" if wa.get("connected") else "Telegram"
|
|
2134
|
+
|
|
2135
|
+
rows_html += f"""
|
|
2136
|
+
<tr class="{status_class}">
|
|
2137
|
+
<td>{emoji} {inst.label}</td>
|
|
2138
|
+
<td><span class="status-dot {status_class}"></span> {"Online" if is_up else "Offline"}</td>
|
|
2139
|
+
<td>{plat}</td>
|
|
2140
|
+
<td>{latency:.0f}ms</td>
|
|
2141
|
+
<td>{sector_name}</td>
|
|
2142
|
+
</tr>
|
|
2143
|
+
"""
|
|
2144
|
+
|
|
2145
|
+
# Eventos recientes
|
|
2146
|
+
events = get_recent_events(10)
|
|
2147
|
+
events_html = ""
|
|
2148
|
+
for e in events:
|
|
2149
|
+
severity_class = e.get('severity', 'info')
|
|
2150
|
+
events_html += f"""
|
|
2151
|
+
<tr class="{severity_class}">
|
|
2152
|
+
<td>{e.get('timestamp', '')[:16]}</td>
|
|
2153
|
+
<td>{e.get('instance', '')}</td>
|
|
2154
|
+
<td>{e.get('event_type', '')}</td>
|
|
2155
|
+
<td><span class="severity {severity_class}">{e.get('severity', '').upper()}</span></td>
|
|
2156
|
+
</tr>
|
|
2157
|
+
"""
|
|
2158
|
+
|
|
2159
|
+
html = f"""
|
|
2160
|
+
<!DOCTYPE html>
|
|
2161
|
+
<html>
|
|
2162
|
+
<head>
|
|
2163
|
+
<title>Conny Omni Dashboard</title>
|
|
2164
|
+
<meta http-equiv="refresh" content="30">
|
|
2165
|
+
<style>
|
|
2166
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
2167
|
+
body {{
|
|
2168
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2169
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
2170
|
+
color: #e0e0e0;
|
|
2171
|
+
min-height: 100vh;
|
|
2172
|
+
padding: 20px;
|
|
2173
|
+
}}
|
|
2174
|
+
.container {{ max-width: 1400px; margin: 0 auto; }}
|
|
2175
|
+
h1 {{
|
|
2176
|
+
color: #f5a623;
|
|
2177
|
+
font-size: 2rem;
|
|
2178
|
+
margin-bottom: 20px;
|
|
2179
|
+
display: flex;
|
|
2180
|
+
align-items: center;
|
|
2181
|
+
gap: 10px;
|
|
2182
|
+
}}
|
|
2183
|
+
.logo {{ font-size: 2.5rem; }}
|
|
2184
|
+
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; }}
|
|
2185
|
+
.card {{
|
|
2186
|
+
background: rgba(255,255,255,0.05);
|
|
2187
|
+
border-radius: 12px;
|
|
2188
|
+
padding: 20px;
|
|
2189
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
2190
|
+
}}
|
|
2191
|
+
.card h2 {{
|
|
2192
|
+
color: #f5a623;
|
|
2193
|
+
font-size: 1.2rem;
|
|
2194
|
+
margin-bottom: 15px;
|
|
2195
|
+
padding-bottom: 10px;
|
|
2196
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
2197
|
+
}}
|
|
2198
|
+
table {{ width: 100%; border-collapse: collapse; }}
|
|
2199
|
+
th, td {{
|
|
2200
|
+
padding: 10px;
|
|
2201
|
+
text-align: left;
|
|
2202
|
+
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
2203
|
+
}}
|
|
2204
|
+
th {{ color: #888; font-weight: 500; font-size: 0.85rem; }}
|
|
2205
|
+
.status-dot {{
|
|
2206
|
+
width: 10px;
|
|
2207
|
+
height: 10px;
|
|
2208
|
+
border-radius: 50%;
|
|
2209
|
+
display: inline-block;
|
|
2210
|
+
margin-right: 5px;
|
|
2211
|
+
}}
|
|
2212
|
+
.status-dot.online {{ background: #4caf50; box-shadow: 0 0 10px #4caf50; }}
|
|
2213
|
+
.status-dot.offline {{ background: #f44336; box-shadow: 0 0 10px #f44336; }}
|
|
2214
|
+
.severity {{
|
|
2215
|
+
padding: 2px 8px;
|
|
2216
|
+
border-radius: 4px;
|
|
2217
|
+
font-size: 0.75rem;
|
|
2218
|
+
font-weight: 600;
|
|
2219
|
+
}}
|
|
2220
|
+
.severity.info {{ background: #2196f3; }}
|
|
2221
|
+
.severity.warning {{ background: #ff9800; }}
|
|
2222
|
+
.severity.error {{ background: #f44336; }}
|
|
2223
|
+
.severity.critical {{ background: #9c27b0; }}
|
|
2224
|
+
tr.offline td {{ opacity: 0.6; }}
|
|
2225
|
+
.footer {{
|
|
2226
|
+
text-align: center;
|
|
2227
|
+
margin-top: 20px;
|
|
2228
|
+
color: #666;
|
|
2229
|
+
font-size: 0.85rem;
|
|
2230
|
+
}}
|
|
2231
|
+
.stats {{
|
|
2232
|
+
display: flex;
|
|
2233
|
+
gap: 30px;
|
|
2234
|
+
margin-bottom: 20px;
|
|
2235
|
+
}}
|
|
2236
|
+
.stat {{
|
|
2237
|
+
text-align: center;
|
|
2238
|
+
}}
|
|
2239
|
+
.stat-value {{
|
|
2240
|
+
font-size: 2.5rem;
|
|
2241
|
+
font-weight: 700;
|
|
2242
|
+
color: #f5a623;
|
|
2243
|
+
}}
|
|
2244
|
+
.stat-label {{
|
|
2245
|
+
color: #888;
|
|
2246
|
+
font-size: 0.9rem;
|
|
2247
|
+
}}
|
|
2248
|
+
</style>
|
|
2249
|
+
</head>
|
|
2250
|
+
<body>
|
|
2251
|
+
<div class="container">
|
|
2252
|
+
<h1><span class="logo">◉</span> Conny Omni</h1>
|
|
2253
|
+
|
|
2254
|
+
<div class="stats">
|
|
2255
|
+
<div class="stat">
|
|
2256
|
+
<div class="stat-value">{sum(1 for h, _ in health_results if h.get('status') == 'online')}</div>
|
|
2257
|
+
<div class="stat-label">Online</div>
|
|
2258
|
+
</div>
|
|
2259
|
+
<div class="stat">
|
|
2260
|
+
<div class="stat-value">{len(instances)}</div>
|
|
2261
|
+
<div class="stat-label">Total</div>
|
|
2262
|
+
</div>
|
|
2263
|
+
<div class="stat">
|
|
2264
|
+
<div class="stat-value">{len(_event_queue)}</div>
|
|
2265
|
+
<div class="stat-label">Eventos</div>
|
|
2266
|
+
</div>
|
|
2267
|
+
<div class="stat">
|
|
2268
|
+
<div class="stat-value">{len(get_unacknowledged_events())}</div>
|
|
2269
|
+
<div class="stat-label">Alertas</div>
|
|
2270
|
+
</div>
|
|
2271
|
+
</div>
|
|
2272
|
+
|
|
2273
|
+
<div class="grid">
|
|
2274
|
+
<div class="card">
|
|
2275
|
+
<h2>📊 Instancias</h2>
|
|
2276
|
+
<table>
|
|
2277
|
+
<tr>
|
|
2278
|
+
<th>Nombre</th>
|
|
2279
|
+
<th>Estado</th>
|
|
2280
|
+
<th>Plataforma</th>
|
|
2281
|
+
<th>Latencia</th>
|
|
2282
|
+
<th>Sector</th>
|
|
2283
|
+
</tr>
|
|
2284
|
+
{rows_html}
|
|
2285
|
+
</table>
|
|
2286
|
+
</div>
|
|
2287
|
+
|
|
2288
|
+
<div class="card">
|
|
2289
|
+
<h2>📋 Eventos Recientes</h2>
|
|
2290
|
+
<table>
|
|
2291
|
+
<tr>
|
|
2292
|
+
<th>Tiempo</th>
|
|
2293
|
+
<th>Instancia</th>
|
|
2294
|
+
<th>Evento</th>
|
|
2295
|
+
<th>Severidad</th>
|
|
2296
|
+
</tr>
|
|
2297
|
+
{events_html}
|
|
2298
|
+
</table>
|
|
2299
|
+
</div>
|
|
2300
|
+
</div>
|
|
2301
|
+
|
|
2302
|
+
<div class="footer">
|
|
2303
|
+
Conny Omni v{OMNI_VERSION} · Auto-refresh cada 30s · {datetime.now().strftime('%H:%M:%S')}
|
|
2304
|
+
</div>
|
|
2305
|
+
</div>
|
|
2306
|
+
</body>
|
|
2307
|
+
</html>
|
|
2308
|
+
"""
|
|
2309
|
+
return html
|
|
2310
|
+
|
|
2311
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2312
|
+
# ENDPOINTS AUTENTICADOS
|
|
2313
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2314
|
+
|
|
2315
|
+
@omni_app.post("/omni/event")
|
|
2316
|
+
async def receive_event(request: Request, bg: BackgroundTasks):
|
|
2317
|
+
if not auth_required(request):
|
|
2318
|
+
return Response(status_code=403)
|
|
2319
|
+
|
|
2320
|
+
data = await request.json()
|
|
2321
|
+
bg.add_task(process_incoming_event, data)
|
|
2322
|
+
return {"ok": True, "queued": len(_event_queue)}
|
|
2323
|
+
|
|
2324
|
+
@omni_app.get("/omni/events")
|
|
2325
|
+
async def list_events(request: Request, limit: int = 50, severity: str = None):
|
|
2326
|
+
if not auth_required(request):
|
|
2327
|
+
return Response(status_code=403)
|
|
2328
|
+
return {"events": get_recent_events(limit, severity), "total": len(_event_queue)}
|
|
2329
|
+
|
|
2330
|
+
@omni_app.get("/omni/status")
|
|
2331
|
+
async def omni_status_endpoint(request: Request):
|
|
2332
|
+
if not auth_required(request):
|
|
2333
|
+
return Response(status_code=403)
|
|
2334
|
+
|
|
2335
|
+
instances = get_all_instances()
|
|
2336
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2337
|
+
|
|
2338
|
+
return {
|
|
2339
|
+
"timestamp": datetime.now().isoformat(),
|
|
2340
|
+
"instances": [
|
|
2341
|
+
{
|
|
2342
|
+
"name": i.name,
|
|
2343
|
+
"label": i.label,
|
|
2344
|
+
"port": i.port,
|
|
2345
|
+
"sector": i.sector,
|
|
2346
|
+
"status": h.get("status", "offline"),
|
|
2347
|
+
"latency_ms": lat,
|
|
2348
|
+
"clinic": h.get("clinic", ""),
|
|
2349
|
+
"whatsapp": h.get("whatsapp", {}),
|
|
2350
|
+
"nova_enabled": i.env.get("NOVA_ENABLED", "false")
|
|
2351
|
+
}
|
|
2352
|
+
for i, (h, lat) in zip(instances, health_results)
|
|
2353
|
+
]
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
@omni_app.get("/omni/metrics/{instance}")
|
|
2357
|
+
async def get_instance_metrics(instance: str, request: Request, hours: int = 24):
|
|
2358
|
+
if not auth_required(request):
|
|
2359
|
+
return Response(status_code=403)
|
|
2360
|
+
|
|
2361
|
+
metrics = get_metrics_history(instance, hours)
|
|
2362
|
+
analysis = analyze_trends(instance, hours)
|
|
2363
|
+
anomalies = detect_anomalies(instance, min(hours, 6))
|
|
2364
|
+
predictions = predict_issues(instance)
|
|
2365
|
+
|
|
2366
|
+
return {
|
|
2367
|
+
"instance": instance,
|
|
2368
|
+
"period_hours": hours,
|
|
2369
|
+
"metrics": metrics,
|
|
2370
|
+
"analysis": analysis,
|
|
2371
|
+
"anomalies": anomalies,
|
|
2372
|
+
"predictions": predictions
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
@omni_app.get("/omni/alerts")
|
|
2376
|
+
async def list_alerts(request: Request):
|
|
2377
|
+
if not auth_required(request):
|
|
2378
|
+
return Response(status_code=403)
|
|
2379
|
+
return {"alerts": get_alert_rules()}
|
|
2380
|
+
|
|
2381
|
+
@omni_app.post("/omni/alerts")
|
|
2382
|
+
async def create_alert(request: Request):
|
|
2383
|
+
if not auth_required(request):
|
|
2384
|
+
return Response(status_code=403)
|
|
2385
|
+
|
|
2386
|
+
data = await request.json()
|
|
2387
|
+
rule_id = create_alert_rule(
|
|
2388
|
+
name=data.get("name", "Nueva alerta"),
|
|
2389
|
+
instance=data.get("instance", "*"),
|
|
2390
|
+
metric=data.get("metric"),
|
|
2391
|
+
operator=data.get("operator"),
|
|
2392
|
+
threshold=data.get("threshold"),
|
|
2393
|
+
severity=data.get("severity", "warning"),
|
|
2394
|
+
channels=data.get("channels", "telegram"),
|
|
2395
|
+
cooldown=data.get("cooldown", 30)
|
|
2396
|
+
)
|
|
2397
|
+
log_audit("api", "create_alert", str(rule_id), json.dumps(data))
|
|
2398
|
+
return {"ok": True, "id": rule_id}
|
|
2399
|
+
|
|
2400
|
+
@omni_app.delete("/omni/alerts/{rule_id}")
|
|
2401
|
+
async def remove_alert(rule_id: int, request: Request):
|
|
2402
|
+
if not auth_required(request):
|
|
2403
|
+
return Response(status_code=403)
|
|
2404
|
+
|
|
2405
|
+
delete_alert_rule(rule_id)
|
|
2406
|
+
log_audit("api", "delete_alert", str(rule_id), "")
|
|
2407
|
+
return {"ok": True}
|
|
2408
|
+
|
|
2409
|
+
@omni_app.get("/omni/workflows")
|
|
2410
|
+
async def list_workflows(request: Request):
|
|
2411
|
+
if not auth_required(request):
|
|
2412
|
+
return Response(status_code=403)
|
|
2413
|
+
return {"workflows": get_workflows()}
|
|
2414
|
+
|
|
2415
|
+
@omni_app.post("/omni/workflows")
|
|
2416
|
+
async def create_workflow_endpoint(request: Request):
|
|
2417
|
+
if not auth_required(request):
|
|
2418
|
+
return Response(status_code=403)
|
|
2419
|
+
|
|
2420
|
+
data = await request.json()
|
|
2421
|
+
wf_id = create_workflow(
|
|
2422
|
+
name=data.get("name"),
|
|
2423
|
+
trigger_event=data.get("trigger_event"),
|
|
2424
|
+
actions=data.get("actions", []),
|
|
2425
|
+
trigger_filter=data.get("trigger_filter")
|
|
2426
|
+
)
|
|
2427
|
+
log_audit("api", "create_workflow", str(wf_id), json.dumps(data))
|
|
2428
|
+
return {"ok": True, "id": wf_id}
|
|
2429
|
+
|
|
2430
|
+
@omni_app.get("/omni/audit")
|
|
2431
|
+
async def get_audit_log(request: Request, limit: int = 100):
|
|
2432
|
+
if not auth_required(request):
|
|
2433
|
+
return Response(status_code=403)
|
|
2434
|
+
|
|
2435
|
+
logs = db_execute("""
|
|
2436
|
+
SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT ?
|
|
2437
|
+
""", (limit,), fetch=True)
|
|
2438
|
+
return {"logs": logs}
|
|
2439
|
+
|
|
2440
|
+
@omni_app.post("/omni/message/{instance}")
|
|
2441
|
+
async def send_message_endpoint(instance: str, request: Request):
|
|
2442
|
+
if not auth_required(request):
|
|
2443
|
+
return Response(status_code=403)
|
|
2444
|
+
|
|
2445
|
+
data = await request.json()
|
|
2446
|
+
instances = get_all_instances()
|
|
2447
|
+
inst = next((i for i in instances if instance.lower() in i.name.lower()), None)
|
|
2448
|
+
|
|
2449
|
+
if not inst:
|
|
2450
|
+
return JSONResponse({"ok": False, "error": f"No encontré '{instance}'"}, status_code=404)
|
|
2451
|
+
|
|
2452
|
+
mk = inst.env.get("MASTER_API_KEY", "")
|
|
2453
|
+
success = await send_to_instance(inst.port, mk, data.get("chat_id"), data.get("message"))
|
|
2454
|
+
log_audit("api", "send_message", inst.name, f"chat_id={data.get('chat_id')}")
|
|
2455
|
+
|
|
2456
|
+
return {"ok": success}
|
|
2457
|
+
|
|
2458
|
+
@omni_app.post("/omni/action/{instance}/{action}")
|
|
2459
|
+
async def execute_instance_action(instance: str, action: str, request: Request):
|
|
2460
|
+
if not auth_required(request):
|
|
2461
|
+
return Response(status_code=403)
|
|
2462
|
+
|
|
2463
|
+
instances = get_all_instances()
|
|
2464
|
+
inst = next((i for i in instances if instance.lower() in i.name.lower()), None)
|
|
2465
|
+
|
|
2466
|
+
if not inst:
|
|
2467
|
+
return JSONResponse({"ok": False, "error": f"No encontré '{instance}'"}, status_code=404)
|
|
2468
|
+
|
|
2469
|
+
if action == "restart":
|
|
2470
|
+
success = pm2_restart(inst.pm2_name)
|
|
2471
|
+
log_audit("api", "restart", inst.name, "")
|
|
2472
|
+
return {"ok": success}
|
|
2473
|
+
|
|
2474
|
+
elif action == "stop":
|
|
2475
|
+
success = pm2_stop(inst.pm2_name)
|
|
2476
|
+
log_audit("api", "stop", inst.name, "")
|
|
2477
|
+
return {"ok": success}
|
|
2478
|
+
|
|
2479
|
+
elif action == "analyze":
|
|
2480
|
+
analysis = analyze_trends(inst.name, 24)
|
|
2481
|
+
return {"ok": True, "analysis": analysis}
|
|
2482
|
+
|
|
2483
|
+
elif action == "predict":
|
|
2484
|
+
predictions = predict_issues(inst.name)
|
|
2485
|
+
return {"ok": True, "predictions": predictions}
|
|
2486
|
+
|
|
2487
|
+
return JSONResponse({"ok": False, "error": f"Acción desconocida: {action}"}, status_code=400)
|
|
2488
|
+
|
|
2489
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2490
|
+
# TELEGRAM WEBHOOK
|
|
2491
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
2492
|
+
|
|
2493
|
+
@omni_app.post("/omni/webhook/{token_suffix}")
|
|
2494
|
+
async def telegram_webhook(token_suffix: str, request: Request, bg: BackgroundTasks):
|
|
2495
|
+
_tok_check = os.environ.get("OMNI_TELEGRAM_TOKEN", "") or OMNI_TOKEN
|
|
2496
|
+
if _tok_check and not _tok_check.split(":")[-1][:10] == token_suffix:
|
|
2497
|
+
return Response(status_code=403)
|
|
2498
|
+
|
|
2499
|
+
body = await request.json()
|
|
2500
|
+
msg = body.get("message", {})
|
|
2501
|
+
|
|
2502
|
+
if not msg:
|
|
2503
|
+
return {"ok": True}
|
|
2504
|
+
|
|
2505
|
+
chat_id = str(msg.get("chat", {}).get("id", ""))
|
|
2506
|
+
text = msg.get("text", "").strip()
|
|
2507
|
+
|
|
2508
|
+
if not text:
|
|
2509
|
+
return {"ok": True}
|
|
2510
|
+
|
|
2511
|
+
# Solo Santiago puede hablar con Omni
|
|
2512
|
+
if SANTIAGO_CHAT and chat_id != str(SANTIAGO_CHAT):
|
|
2513
|
+
return {"ok": True}
|
|
2514
|
+
|
|
2515
|
+
bg.add_task(handle_telegram_message, chat_id, text)
|
|
2516
|
+
return {"ok": True}
|
|
2517
|
+
|
|
2518
|
+
@omni_app.websocket("/omni/ws")
|
|
2519
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
2520
|
+
"""WebSocket para updates en tiempo real."""
|
|
2521
|
+
await websocket.accept()
|
|
2522
|
+
_santiago_ws_connections.append(websocket)
|
|
2523
|
+
|
|
2524
|
+
try:
|
|
2525
|
+
while True:
|
|
2526
|
+
data = await websocket.receive_text()
|
|
2527
|
+
# Procesar comandos via WebSocket
|
|
2528
|
+
if data.startswith("/"):
|
|
2529
|
+
response = await process_ws_command(data)
|
|
2530
|
+
await websocket.send_text(json.dumps(response))
|
|
2531
|
+
except Exception:
|
|
2532
|
+
pass
|
|
2533
|
+
finally:
|
|
2534
|
+
if websocket in _santiago_ws_connections:
|
|
2535
|
+
_santiago_ws_connections.remove(websocket)
|
|
2536
|
+
|
|
2537
|
+
async def process_ws_command(command: str) -> Dict:
|
|
2538
|
+
"""Procesar comando de WebSocket."""
|
|
2539
|
+
cmd = command.strip().lower()
|
|
2540
|
+
|
|
2541
|
+
if cmd == "/status":
|
|
2542
|
+
instances = get_all_instances()
|
|
2543
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2544
|
+
return {
|
|
2545
|
+
"type": "status",
|
|
2546
|
+
"instances": [
|
|
2547
|
+
{"name": i.name, "status": h.get("status", "offline"), "latency": lat}
|
|
2548
|
+
for i, (h, lat) in zip(instances, health_results)
|
|
2549
|
+
]
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
if cmd == "/events":
|
|
2553
|
+
return {"type": "events", "events": get_recent_events(20)}
|
|
2554
|
+
|
|
2555
|
+
if cmd == "/alerts":
|
|
2556
|
+
return {"type": "alerts", "unacknowledged": get_unacknowledged_events()}
|
|
2557
|
+
|
|
2558
|
+
return {"type": "error", "message": "Comando no reconocido"}
|
|
2559
|
+
|
|
2560
|
+
async def broadcast_to_ws(message: Dict):
|
|
2561
|
+
"""Broadcast a todas las conexiones WebSocket."""
|
|
2562
|
+
if not _santiago_ws_connections:
|
|
2563
|
+
return
|
|
2564
|
+
|
|
2565
|
+
text = json.dumps(message)
|
|
2566
|
+
for ws in _santiago_ws_connections[:]:
|
|
2567
|
+
try:
|
|
2568
|
+
await ws.send_text(text)
|
|
2569
|
+
except Exception:
|
|
2570
|
+
if ws in _santiago_ws_connections:
|
|
2571
|
+
_santiago_ws_connections.remove(ws)
|
|
2572
|
+
|
|
2573
|
+
async def handle_telegram_message(chat_id: str, text: str):
|
|
2574
|
+
"""Procesar mensaje de Telegram de Santiago."""
|
|
2575
|
+
instances = get_all_instances()
|
|
2576
|
+
base_env = load_env(f"{TEMPLATE_DIR}/.env")
|
|
2577
|
+
|
|
2578
|
+
# Health de todas en paralelo
|
|
2579
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2580
|
+
stats_results = await asyncio.gather(*[
|
|
2581
|
+
get_instance_stats(i.port, i.env.get("MASTER_API_KEY", ""))
|
|
2582
|
+
for i in instances
|
|
2583
|
+
])
|
|
2584
|
+
|
|
2585
|
+
all_status = [
|
|
2586
|
+
{
|
|
2587
|
+
"name": i.name,
|
|
2588
|
+
"label": i.label,
|
|
2589
|
+
"port": i.port,
|
|
2590
|
+
"sector": i.sector,
|
|
2591
|
+
"env": i.env,
|
|
2592
|
+
"health": h,
|
|
2593
|
+
"latency": lat,
|
|
2594
|
+
"stats": s
|
|
2595
|
+
}
|
|
2596
|
+
for i, (h, lat), s in zip(instances, health_results, stats_results)
|
|
2597
|
+
]
|
|
2598
|
+
|
|
2599
|
+
# Comandos rápidos
|
|
2600
|
+
quick_commands = {
|
|
2601
|
+
"/status": lambda: generate_quick_status(all_status),
|
|
2602
|
+
"/help": lambda: get_help_text(),
|
|
2603
|
+
"/alerts": lambda: format_alerts(),
|
|
2604
|
+
"/ack": lambda: acknowledge_all_alerts(),
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
if text.lower() in quick_commands:
|
|
2608
|
+
response = await quick_commands[text.lower()]() if asyncio.iscoroutinefunction(quick_commands[text.lower()]) else quick_commands[text.lower()]()
|
|
2609
|
+
await send_telegram(response, chat_id)
|
|
2610
|
+
return
|
|
2611
|
+
|
|
2612
|
+
demo_cmd = re.match(r"^/demo(?:\s+(.+?))?(?:\s+(on|off|status))?$", text.strip(), re.IGNORECASE)
|
|
2613
|
+
if demo_cmd:
|
|
2614
|
+
raw_name = (demo_cmd.group(1) or "").strip()
|
|
2615
|
+
raw_mode = (demo_cmd.group(2) or "").strip().lower()
|
|
2616
|
+
if raw_mode == "status" or (raw_name.lower() == "status" and not raw_mode):
|
|
2617
|
+
name = "" if raw_name.lower() == "status" else raw_name
|
|
2618
|
+
if name:
|
|
2619
|
+
inst = resolve_instance(instances, name)
|
|
2620
|
+
response = await execute_action({"type": "demo_status", "instance": name}, instances, base_env) if inst else f"No encontré instancia '{name}'"
|
|
2621
|
+
else:
|
|
2622
|
+
lines = ["🎭 *Demo mode por instancia*"]
|
|
2623
|
+
for inst in instances:
|
|
2624
|
+
status = await http_get(f"http://localhost:{inst.port}/demo/status") or {}
|
|
2625
|
+
lines.append(f"• {inst.label}: {'ACTIVO' if status.get('demo_mode') else 'apagado'}")
|
|
2626
|
+
response = "\n".join(lines)
|
|
2627
|
+
await send_telegram(response, chat_id)
|
|
2628
|
+
return
|
|
2629
|
+
if not raw_name:
|
|
2630
|
+
await send_telegram("Uso: /demo <instancia> <on|off|status>", chat_id)
|
|
2631
|
+
return
|
|
2632
|
+
active = raw_mode != "off"
|
|
2633
|
+
response = await execute_action({"type": "set_demo", "instance": raw_name, "active": active}, instances, base_env)
|
|
2634
|
+
await send_telegram(response, chat_id)
|
|
2635
|
+
return
|
|
2636
|
+
|
|
2637
|
+
# LLM
|
|
2638
|
+
save_conversation("user", text)
|
|
2639
|
+
|
|
2640
|
+
reply = await omni_brain(text, all_status, base_env)
|
|
2641
|
+
action = extract_action(reply)
|
|
2642
|
+
clean = clean_response(reply)
|
|
2643
|
+
|
|
2644
|
+
await send_telegram(clean, chat_id)
|
|
2645
|
+
save_conversation("assistant", clean)
|
|
2646
|
+
|
|
2647
|
+
if action:
|
|
2648
|
+
result = await execute_action(action, instances, base_env)
|
|
2649
|
+
if result:
|
|
2650
|
+
await send_telegram(result, chat_id)
|
|
2651
|
+
save_conversation("assistant", f"[ACTION RESULT]: {result}")
|
|
2652
|
+
|
|
2653
|
+
log_audit("santiago", "chat", "", text[:100])
|
|
2654
|
+
|
|
2655
|
+
def generate_quick_status(all_status: List[Dict]) -> str:
|
|
2656
|
+
"""Generar estado rápido."""
|
|
2657
|
+
online = sum(1 for s in all_status if s.get("health", {}).get("status") == "online")
|
|
2658
|
+
total = len(all_status)
|
|
2659
|
+
|
|
2660
|
+
lines = [f"📊 *Estado Rápido* ({datetime.now().strftime('%H:%M')})", ""]
|
|
2661
|
+
lines.append(f"Online: {online}/{total}")
|
|
2662
|
+
lines.append("")
|
|
2663
|
+
|
|
2664
|
+
for s in all_status:
|
|
2665
|
+
h = s.get("health", {})
|
|
2666
|
+
emoji, _, _ = get_sector_info(s.get("sector", "otro"))
|
|
2667
|
+
is_up = h.get("status") == "online"
|
|
2668
|
+
icon = "🟢" if is_up else "🔴"
|
|
2669
|
+
lat = s.get("latency", 0)
|
|
2670
|
+
|
|
2671
|
+
lines.append(f"{icon} {emoji} {s['label'][:20]} ({lat:.0f}ms)")
|
|
2672
|
+
|
|
2673
|
+
return "\n".join(lines)
|
|
2674
|
+
|
|
2675
|
+
def get_help_text() -> str:
|
|
2676
|
+
"""Obtener texto de ayuda."""
|
|
2677
|
+
return """*Comandos rápidos:*
|
|
2678
|
+
/status - Estado de todas las instancias
|
|
2679
|
+
/alerts - Ver alertas pendientes
|
|
2680
|
+
/ack - Reconocer todas las alertas
|
|
2681
|
+
/help - Este mensaje
|
|
2682
|
+
|
|
2683
|
+
*También puedes preguntarme:*
|
|
2684
|
+
• "¿Cómo va clinica-bella?"
|
|
2685
|
+
• "Reinicia el restaurante"
|
|
2686
|
+
• "Dame un resumen de la semana"
|
|
2687
|
+
• "¿Hay algo raro?"
|
|
2688
|
+
• "Crea una alerta de latencia > 500ms"
|
|
2689
|
+
• "¿Qué predicciones hay?"
|
|
2690
|
+
"""
|
|
2691
|
+
|
|
2692
|
+
def format_alerts() -> str:
|
|
2693
|
+
"""Formatear alertas pendientes."""
|
|
2694
|
+
unack = get_unacknowledged_events()
|
|
2695
|
+
|
|
2696
|
+
if not unack:
|
|
2697
|
+
return "✅ No hay alertas pendientes"
|
|
2698
|
+
|
|
2699
|
+
lines = [f"🔔 *{len(unack)} alertas pendientes:*", ""]
|
|
2700
|
+
for e in unack[:10]:
|
|
2701
|
+
ts = e.get('timestamp', '')[:16]
|
|
2702
|
+
lines.append(f"• [{ts}] {e.get('event_type', '')} - {e.get('instance', '')}")
|
|
2703
|
+
|
|
2704
|
+
if len(unack) > 10:
|
|
2705
|
+
lines.append(f"... y {len(unack) - 10} más")
|
|
2706
|
+
|
|
2707
|
+
lines.append("")
|
|
2708
|
+
lines.append("Usa /ack para reconocerlas todas")
|
|
2709
|
+
|
|
2710
|
+
return "\n".join(lines)
|
|
2711
|
+
|
|
2712
|
+
def acknowledge_all_alerts() -> str:
|
|
2713
|
+
"""Reconocer todas las alertas."""
|
|
2714
|
+
unack = get_unacknowledged_events()
|
|
2715
|
+
for e in unack:
|
|
2716
|
+
acknowledge_event(e['id'], "Santiago")
|
|
2717
|
+
return f"✅ {len(unack)} alertas reconocidas"
|
|
2718
|
+
|
|
2719
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2720
|
+
# CLI COMMANDS
|
|
2721
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2722
|
+
|
|
2723
|
+
async def cmd_status():
|
|
2724
|
+
"""Estado rápido de todas las instancias."""
|
|
2725
|
+
print_logo(compact=True)
|
|
2726
|
+
section("Estado de Instancias")
|
|
2727
|
+
|
|
2728
|
+
instances = get_all_instances()
|
|
2729
|
+
if not instances:
|
|
2730
|
+
warn("No hay instancias configuradas")
|
|
2731
|
+
return
|
|
2732
|
+
|
|
2733
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2734
|
+
online = sum(1 for h, _ in health_results if h.get("status") == "online")
|
|
2735
|
+
|
|
2736
|
+
print(f" {q(C.G2, 'Online:')} "
|
|
2737
|
+
f"{q(C.GRN if online == len(instances) else C.YLW, f'{online}/{len(instances)}')}")
|
|
2738
|
+
nl()
|
|
2739
|
+
|
|
2740
|
+
headers = ["INSTANCIA", "ESTADO", "SECTOR", "PLATAFORMA", "LATENCIA"]
|
|
2741
|
+
rows = []
|
|
2742
|
+
|
|
2743
|
+
for inst, (h, latency) in zip(instances, health_results):
|
|
2744
|
+
is_up = h.get("status") == "online"
|
|
2745
|
+
emoji, sector_name, _ = inst.sector_info
|
|
2746
|
+
|
|
2747
|
+
icon = q(C.GRN, "●") if is_up else q(C.RED, "●")
|
|
2748
|
+
status = "Online" if is_up else "Offline"
|
|
2749
|
+
|
|
2750
|
+
wa = h.get("whatsapp", {})
|
|
2751
|
+
plat = f"WA {wa.get('phone', '')[-7:]}" if wa.get("connected") else "Telegram"
|
|
2752
|
+
|
|
2753
|
+
rows.append([
|
|
2754
|
+
f"{icon} {inst.label[:20]}",
|
|
2755
|
+
status,
|
|
2756
|
+
f"{emoji} {sector_name[:12]}",
|
|
2757
|
+
plat,
|
|
2758
|
+
f"{latency:.0f}ms"
|
|
2759
|
+
])
|
|
2760
|
+
|
|
2761
|
+
table(headers, rows)
|
|
2762
|
+
nl()
|
|
2763
|
+
|
|
2764
|
+
# Alertas pendientes
|
|
2765
|
+
unack = get_unacknowledged_events()
|
|
2766
|
+
if unack:
|
|
2767
|
+
warn(f"{len(unack)} alertas sin reconocer")
|
|
2768
|
+
|
|
2769
|
+
# Predicciones
|
|
2770
|
+
all_predictions = []
|
|
2771
|
+
for inst in instances[:5]:
|
|
2772
|
+
preds = predict_issues(inst.name)
|
|
2773
|
+
all_predictions.extend(preds)
|
|
2774
|
+
|
|
2775
|
+
if all_predictions:
|
|
2776
|
+
nl()
|
|
2777
|
+
info(f"🔮 {len(all_predictions)} predicciones activas")
|
|
2778
|
+
|
|
2779
|
+
async def cmd_watch():
|
|
2780
|
+
"""Monitor live que refresca cada 5 segundos."""
|
|
2781
|
+
info("Watch mode — Ctrl+C para salir")
|
|
2782
|
+
|
|
2783
|
+
try:
|
|
2784
|
+
while True:
|
|
2785
|
+
os.system("clear")
|
|
2786
|
+
|
|
2787
|
+
instances = get_all_instances()
|
|
2788
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2789
|
+
online = sum(1 for h, _ in health_results if h.get("status") == "online")
|
|
2790
|
+
now = datetime.now().strftime("%H:%M:%S")
|
|
2791
|
+
|
|
2792
|
+
print()
|
|
2793
|
+
print(f" {q(C.AMB, '◉', bold=True)} {q(C.W, 'Conny Omni', bold=True)} "
|
|
2794
|
+
f"{q(C.G3, now)} "
|
|
2795
|
+
f"{q(C.GRN if online == len(instances) else C.YLW, f'{online}/{len(instances)} online')}")
|
|
2796
|
+
print(f" {q(C.G4, '─' * 70)}")
|
|
2797
|
+
|
|
2798
|
+
headers = ["INSTANCIA", "ESTADO", "SECTOR", "PLATAFORMA", "LATENCIA", "TENDENCIA"]
|
|
2799
|
+
rows = []
|
|
2800
|
+
|
|
2801
|
+
for inst, (h, latency) in zip(instances, health_results):
|
|
2802
|
+
is_up = h.get("status") == "online"
|
|
2803
|
+
emoji, sector_name, _ = inst.sector_info
|
|
2804
|
+
|
|
2805
|
+
icon = q(C.GRN, "●") if is_up else q(C.RED, "●")
|
|
2806
|
+
|
|
2807
|
+
wa = h.get("whatsapp", {})
|
|
2808
|
+
plat = f"WA" if wa.get("connected") else "TG"
|
|
2809
|
+
|
|
2810
|
+
# Tendencia de latencia
|
|
2811
|
+
metrics = get_metrics_history(inst.name, 1)
|
|
2812
|
+
latencies = [m.get("latency_ms", 0) for m in metrics if m.get("latency_ms")]
|
|
2813
|
+
trend = ""
|
|
2814
|
+
if len(latencies) >= 5:
|
|
2815
|
+
recent_avg = statistics.mean(latencies[-5:])
|
|
2816
|
+
old_avg = statistics.mean(latencies[:5]) if len(latencies) >= 10 else recent_avg
|
|
2817
|
+
if recent_avg > old_avg * 1.2:
|
|
2818
|
+
trend = q(C.RED, "↑")
|
|
2819
|
+
elif recent_avg < old_avg * 0.8:
|
|
2820
|
+
trend = q(C.GRN, "↓")
|
|
2821
|
+
else:
|
|
2822
|
+
trend = q(C.G3, "→")
|
|
2823
|
+
|
|
2824
|
+
rows.append([
|
|
2825
|
+
f"{icon} {inst.label[:18]}",
|
|
2826
|
+
"Online" if is_up else "OFFLINE",
|
|
2827
|
+
f"{emoji}",
|
|
2828
|
+
plat,
|
|
2829
|
+
f"{latency:.0f}ms",
|
|
2830
|
+
trend + " " + spark_line(latencies[-10:], 10) if latencies else ""
|
|
2831
|
+
])
|
|
2832
|
+
|
|
2833
|
+
table(headers, rows)
|
|
2834
|
+
|
|
2835
|
+
# Eventos recientes
|
|
2836
|
+
events = get_recent_events(5)
|
|
2837
|
+
if events:
|
|
2838
|
+
nl()
|
|
2839
|
+
print(f" {q(C.G2, 'Eventos recientes:')}")
|
|
2840
|
+
for e in events:
|
|
2841
|
+
ts = e.get('timestamp', '')[:16]
|
|
2842
|
+
sev = e.get('severity', 'info')
|
|
2843
|
+
sev_color = {"critical": C.RED, "error": C.RED, "warning": C.YLW}.get(sev, C.G3)
|
|
2844
|
+
print(f" {q(C.G3, ts)} {q(sev_color, sev.upper()[:4])} "
|
|
2845
|
+
f"{q(C.G1, e.get('event_type', '')[:20])} "
|
|
2846
|
+
f"{q(C.P2, e.get('instance', ''))}")
|
|
2847
|
+
|
|
2848
|
+
nl()
|
|
2849
|
+
print(f" {q(C.G3, 'Actualiza cada 5s · Ctrl+C para salir')}")
|
|
2850
|
+
|
|
2851
|
+
await asyncio.sleep(5)
|
|
2852
|
+
|
|
2853
|
+
except asyncio.CancelledError:
|
|
2854
|
+
pass
|
|
2855
|
+
|
|
2856
|
+
async def cmd_dashboard():
|
|
2857
|
+
"""Dashboard interactivo en terminal."""
|
|
2858
|
+
info("Dashboard mode — Ctrl+C para salir")
|
|
2859
|
+
|
|
2860
|
+
try:
|
|
2861
|
+
while True:
|
|
2862
|
+
os.system("clear")
|
|
2863
|
+
print_logo(compact=True)
|
|
2864
|
+
|
|
2865
|
+
instances = get_all_instances()
|
|
2866
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2867
|
+
stats_results = await asyncio.gather(*[
|
|
2868
|
+
get_instance_stats(i.port, i.env.get("MASTER_API_KEY", ""))
|
|
2869
|
+
for i in instances
|
|
2870
|
+
])
|
|
2871
|
+
|
|
2872
|
+
online = sum(1 for h, _ in health_results if h.get("status") == "online")
|
|
2873
|
+
|
|
2874
|
+
# Stats globales
|
|
2875
|
+
total_convs = sum(s.get("total_conversations", 0) for s in stats_results)
|
|
2876
|
+
total_apts = sum(s.get("total_appointments", 0) for s in stats_results)
|
|
2877
|
+
|
|
2878
|
+
print(f" {q(C.W, 'RESUMEN', bold=True)}")
|
|
2879
|
+
print(f" ┌───────────────┬───────────────┬───────────────┬───────────────┐")
|
|
2880
|
+
print(f" │ {q(C.GRN, f'{online}', bold=True):>12} │ {q(C.W, f'{len(instances)}', bold=True):>12} │ "
|
|
2881
|
+
f"{q(C.P2, f'{total_convs}', bold=True):>12} │ {q(C.AMB, f'{total_apts}', bold=True):>12} │")
|
|
2882
|
+
print(f" │ {'Online':^13} │ {'Total':^13} │ {'Convs':^13} │ {'Citas':^13} │")
|
|
2883
|
+
print(f" └───────────────┴───────────────┴───────────────┴───────────────┘")
|
|
2884
|
+
nl()
|
|
2885
|
+
|
|
2886
|
+
# Instancias por sector
|
|
2887
|
+
by_sector = defaultdict(list)
|
|
2888
|
+
for inst, (h, lat), stats in zip(instances, health_results, stats_results):
|
|
2889
|
+
by_sector[inst.sector].append((inst, h, lat, stats))
|
|
2890
|
+
|
|
2891
|
+
for sector_id, items in sorted(by_sector.items()):
|
|
2892
|
+
emoji, sector_name, _ = get_sector_info(sector_id)
|
|
2893
|
+
print(f" {emoji} {q(C.W, sector_name, bold=True)}")
|
|
2894
|
+
|
|
2895
|
+
for inst, h, lat, stats in items:
|
|
2896
|
+
is_up = h.get("status") == "online"
|
|
2897
|
+
icon = q(C.GRN, "●") if is_up else q(C.RED, "●")
|
|
2898
|
+
convs = stats.get("total_conversations", 0)
|
|
2899
|
+
apts = stats.get("total_appointments", 0)
|
|
2900
|
+
|
|
2901
|
+
print(f" {icon} {inst.label[:25]:<25} "
|
|
2902
|
+
f"{lat:>4.0f}ms {convs:>3} conv {apts:>3} citas")
|
|
2903
|
+
|
|
2904
|
+
nl()
|
|
2905
|
+
|
|
2906
|
+
# Alertas
|
|
2907
|
+
unack = get_unacknowledged_events()
|
|
2908
|
+
if unack:
|
|
2909
|
+
print(f" {q(C.YLW, f'⚠️ {len(unack)} alertas pendientes', bold=True)}")
|
|
2910
|
+
for e in unack[:3]:
|
|
2911
|
+
print(f" • {e.get('event_type', '')} - {e.get('instance', '')}")
|
|
2912
|
+
|
|
2913
|
+
nl()
|
|
2914
|
+
_dashboard_ts = datetime.now().strftime("%H:%M:%S")
|
|
2915
|
+
print(f" {q(C.G3, f'Actualiza cada 10s · {_dashboard_ts}')}")
|
|
2916
|
+
|
|
2917
|
+
await asyncio.sleep(10)
|
|
2918
|
+
|
|
2919
|
+
except asyncio.CancelledError:
|
|
2920
|
+
pass
|
|
2921
|
+
|
|
2922
|
+
async def cmd_chat():
|
|
2923
|
+
"""Chat interactivo con Omni."""
|
|
2924
|
+
print_logo()
|
|
2925
|
+
|
|
2926
|
+
instances = get_all_instances()
|
|
2927
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
2928
|
+
stats_results = await asyncio.gather(*[
|
|
2929
|
+
get_instance_stats(i.port, i.env.get("MASTER_API_KEY", ""))
|
|
2930
|
+
for i in instances
|
|
2931
|
+
])
|
|
2932
|
+
|
|
2933
|
+
online = sum(1 for h, _ in health_results if h.get("status") == "online")
|
|
2934
|
+
|
|
2935
|
+
all_status = [
|
|
2936
|
+
{
|
|
2937
|
+
"name": i.name,
|
|
2938
|
+
"label": i.label,
|
|
2939
|
+
"port": i.port,
|
|
2940
|
+
"sector": i.sector,
|
|
2941
|
+
"env": i.env,
|
|
2942
|
+
"health": h,
|
|
2943
|
+
"latency": lat,
|
|
2944
|
+
"stats": s
|
|
2945
|
+
}
|
|
2946
|
+
for i, (h, lat), s in zip(instances, health_results, stats_results)
|
|
2947
|
+
]
|
|
2948
|
+
|
|
2949
|
+
base_env = load_env(f"{TEMPLATE_DIR}/.env")
|
|
2950
|
+
|
|
2951
|
+
info(f"{online}/{len(instances)} instancias online. Pregúntame lo que necesites.")
|
|
2952
|
+
dim("Comandos: /status /watch /dashboard /alerts /ack /analyze /predict /exit")
|
|
2953
|
+
nl()
|
|
2954
|
+
|
|
2955
|
+
while True:
|
|
2956
|
+
try:
|
|
2957
|
+
sys.stdout.write(f" {q(C.AMB, 'Santiago', bold=True)} {q(C.P3, '›')} ")
|
|
2958
|
+
sys.stdout.flush()
|
|
2959
|
+
text = input("").strip()
|
|
2960
|
+
except (EOFError, KeyboardInterrupt):
|
|
2961
|
+
nl()
|
|
2962
|
+
info("Hasta luego.")
|
|
2963
|
+
break
|
|
2964
|
+
|
|
2965
|
+
if not text:
|
|
2966
|
+
continue
|
|
2967
|
+
|
|
2968
|
+
# Comandos especiales
|
|
2969
|
+
if text in ("/exit", "exit", "salir", "q"):
|
|
2970
|
+
info("Hasta luego.")
|
|
2971
|
+
break
|
|
2972
|
+
|
|
2973
|
+
if text == "/status":
|
|
2974
|
+
await cmd_status()
|
|
2975
|
+
continue
|
|
2976
|
+
|
|
2977
|
+
if text == "/watch":
|
|
2978
|
+
try:
|
|
2979
|
+
await cmd_watch()
|
|
2980
|
+
except KeyboardInterrupt:
|
|
2981
|
+
pass
|
|
2982
|
+
continue
|
|
2983
|
+
|
|
2984
|
+
if text == "/dashboard":
|
|
2985
|
+
try:
|
|
2986
|
+
await cmd_dashboard()
|
|
2987
|
+
except KeyboardInterrupt:
|
|
2988
|
+
pass
|
|
2989
|
+
continue
|
|
2990
|
+
|
|
2991
|
+
if text == "/alerts":
|
|
2992
|
+
unack = get_unacknowledged_events()
|
|
2993
|
+
if not unack:
|
|
2994
|
+
ok("Sin alertas pendientes")
|
|
2995
|
+
else:
|
|
2996
|
+
warn(f"{len(unack)} alertas:")
|
|
2997
|
+
for e in unack[:10]:
|
|
2998
|
+
ts = e.get('timestamp', '')[:16]
|
|
2999
|
+
print(f" [{ts}] {e.get('event_type', '')} - {e.get('instance', '')}")
|
|
3000
|
+
continue
|
|
3001
|
+
|
|
3002
|
+
if text == "/ack":
|
|
3003
|
+
unack = get_unacknowledged_events()
|
|
3004
|
+
for e in unack:
|
|
3005
|
+
acknowledge_event(e['id'], "Santiago")
|
|
3006
|
+
ok(f"{len(unack)} alertas reconocidas")
|
|
3007
|
+
continue
|
|
3008
|
+
|
|
3009
|
+
if text.startswith("/analyze"):
|
|
3010
|
+
parts = text.split()
|
|
3011
|
+
if len(parts) > 1:
|
|
3012
|
+
name = parts[1]
|
|
3013
|
+
analysis = analyze_trends(name, 24)
|
|
3014
|
+
if analysis.get("status") == "insufficient_data":
|
|
3015
|
+
warn("Datos insuficientes")
|
|
3016
|
+
else:
|
|
3017
|
+
section(f"Análisis de {name}")
|
|
3018
|
+
if analysis.get("availability"):
|
|
3019
|
+
avail = analysis["availability"]
|
|
3020
|
+
kv("Disponibilidad", f"{avail.get('percentage', 0):.1f}%")
|
|
3021
|
+
if analysis.get("latency"):
|
|
3022
|
+
lat = analysis["latency"]
|
|
3023
|
+
kv("Latencia avg", f"{lat.get('avg', 0):.0f}ms")
|
|
3024
|
+
kv("Latencia p95", f"{lat.get('p95', 0):.0f}ms")
|
|
3025
|
+
kv("Tendencia", lat.get("trend", "stable"))
|
|
3026
|
+
else:
|
|
3027
|
+
info("Uso: /analyze <instancia>")
|
|
3028
|
+
continue
|
|
3029
|
+
|
|
3030
|
+
if text == "/predict":
|
|
3031
|
+
for inst in instances:
|
|
3032
|
+
preds = predict_issues(inst.name)
|
|
3033
|
+
if preds:
|
|
3034
|
+
print(f" {q(C.P2, inst.label)}:")
|
|
3035
|
+
for p in preds:
|
|
3036
|
+
print(f" • {p.get('message', '')}")
|
|
3037
|
+
if not any(predict_issues(i.name) for i in instances):
|
|
3038
|
+
ok("Sin predicciones de problemas")
|
|
3039
|
+
continue
|
|
3040
|
+
|
|
3041
|
+
if text.startswith("/demo"):
|
|
3042
|
+
parts = text.split()
|
|
3043
|
+
if len(parts) < 2:
|
|
3044
|
+
info("Uso: /demo <instancia> <on|off|status>")
|
|
3045
|
+
nl()
|
|
3046
|
+
continue
|
|
3047
|
+
name = parts[1]
|
|
3048
|
+
mode = parts[2].lower() if len(parts) > 2 else "status"
|
|
3049
|
+
if mode == "status":
|
|
3050
|
+
result = await execute_action({"type": "demo_status", "instance": name}, instances, base_env)
|
|
3051
|
+
else:
|
|
3052
|
+
result = await execute_action({"type": "set_demo", "instance": name, "active": mode != "off"}, instances, base_env)
|
|
3053
|
+
for line in result.split('\n'):
|
|
3054
|
+
print(f" {q(C.G1, line)}")
|
|
3055
|
+
nl()
|
|
3056
|
+
continue
|
|
3057
|
+
|
|
3058
|
+
# LLM
|
|
3059
|
+
nl()
|
|
3060
|
+
sys.stdout.write(f" {q(C.P2, '·')} {q(C.G3, 'pensando...')}")
|
|
3061
|
+
sys.stdout.flush()
|
|
3062
|
+
|
|
3063
|
+
save_conversation("user", text)
|
|
3064
|
+
|
|
3065
|
+
reply = await omni_brain(text, all_status, base_env)
|
|
3066
|
+
action = extract_action(reply)
|
|
3067
|
+
clean = clean_response(reply)
|
|
3068
|
+
|
|
3069
|
+
sys.stdout.write(f"\r {q(C.AMB, '◉')} {q(C.W, 'Omni', bold=True)} \n")
|
|
3070
|
+
|
|
3071
|
+
# Mostrar respuesta
|
|
3072
|
+
for line in clean.split('\n'):
|
|
3073
|
+
print(f" {q(C.G0, line)}")
|
|
3074
|
+
|
|
3075
|
+
save_conversation("assistant", clean)
|
|
3076
|
+
|
|
3077
|
+
if action:
|
|
3078
|
+
result = await execute_action(action, instances, base_env)
|
|
3079
|
+
if result:
|
|
3080
|
+
nl()
|
|
3081
|
+
for line in result.split('\n'):
|
|
3082
|
+
print(f" {q(C.G1, line)}")
|
|
3083
|
+
save_conversation("assistant", f"[ACTION]: {result}")
|
|
3084
|
+
|
|
3085
|
+
nl()
|
|
3086
|
+
|
|
3087
|
+
# Refrescar estado cada 5 mensajes
|
|
3088
|
+
if len(get_conversation_history(100)) % 10 == 0:
|
|
3089
|
+
health_results = await asyncio.gather(*[health_check(i.port) for i in instances])
|
|
3090
|
+
stats_results = await asyncio.gather(*[
|
|
3091
|
+
get_instance_stats(i.port, i.env.get("MASTER_API_KEY", ""))
|
|
3092
|
+
for i in instances
|
|
3093
|
+
])
|
|
3094
|
+
all_status = [
|
|
3095
|
+
{
|
|
3096
|
+
"name": i.name,
|
|
3097
|
+
"label": i.label,
|
|
3098
|
+
"port": i.port,
|
|
3099
|
+
"sector": i.sector,
|
|
3100
|
+
"env": i.env,
|
|
3101
|
+
"health": h,
|
|
3102
|
+
"latency": lat,
|
|
3103
|
+
"stats": s
|
|
3104
|
+
}
|
|
3105
|
+
for i, (h, lat), s in zip(instances, health_results, stats_results)
|
|
3106
|
+
]
|
|
3107
|
+
|
|
3108
|
+
async def cmd_report(report_type: str = "daily"):
|
|
3109
|
+
"""Generar y mostrar reporte."""
|
|
3110
|
+
print_logo(compact=True)
|
|
3111
|
+
|
|
3112
|
+
if report_type == "daily":
|
|
3113
|
+
with Spinner("Generando reporte diario...") as sp:
|
|
3114
|
+
report = await generate_daily_report()
|
|
3115
|
+
sp.finish("Reporte generado")
|
|
3116
|
+
else:
|
|
3117
|
+
with Spinner("Generando reporte semanal...") as sp:
|
|
3118
|
+
report = await generate_weekly_report()
|
|
3119
|
+
sp.finish("Reporte generado")
|
|
3120
|
+
|
|
3121
|
+
nl()
|
|
3122
|
+
for line in report.split('\n'):
|
|
3123
|
+
clean_line = re.sub(r'\*([^*]+)\*', lambda m: q(C.W, m.group(1), bold=True), line)
|
|
3124
|
+
print(f" {clean_line}")
|
|
3125
|
+
nl()
|
|
3126
|
+
|
|
3127
|
+
if confirm("¿Enviar por Telegram?"):
|
|
3128
|
+
await send_telegram(report)
|
|
3129
|
+
ok("Enviado")
|
|
3130
|
+
|
|
3131
|
+
def confirm(msg: str) -> bool:
|
|
3132
|
+
sys.stdout.write(f" {q(C.P2, '?')} {q(C.W, msg)} {q(C.G3, '[S/n]')} ")
|
|
3133
|
+
sys.stdout.flush()
|
|
3134
|
+
try:
|
|
3135
|
+
v = input("").strip().lower()
|
|
3136
|
+
except (EOFError, KeyboardInterrupt):
|
|
3137
|
+
print()
|
|
3138
|
+
return False
|
|
3139
|
+
return v in ("", "s", "si", "sí", "y", "yes")
|
|
3140
|
+
|
|
3141
|
+
async def cmd_alerts_cli():
|
|
3142
|
+
"""Gestionar alertas desde CLI."""
|
|
3143
|
+
print_logo(compact=True)
|
|
3144
|
+
section("Gestión de Alertas")
|
|
3145
|
+
|
|
3146
|
+
rules = get_alert_rules()
|
|
3147
|
+
|
|
3148
|
+
if rules:
|
|
3149
|
+
info(f"{len(rules)} reglas configuradas:")
|
|
3150
|
+
nl()
|
|
3151
|
+
|
|
3152
|
+
headers = ["ID", "NOMBRE", "INSTANCIA", "MÉTRICA", "UMBRAL", "ESTADO"]
|
|
3153
|
+
rows = []
|
|
3154
|
+
|
|
3155
|
+
for r in rules:
|
|
3156
|
+
enabled = q(C.GRN, "ON") if r.get("enabled") else q(C.RED, "OFF")
|
|
3157
|
+
rows.append([
|
|
3158
|
+
str(r['id']),
|
|
3159
|
+
r['name'][:20],
|
|
3160
|
+
r['instance'],
|
|
3161
|
+
f"{r['metric']} {r['operator']} {r['threshold']}",
|
|
3162
|
+
r['severity'],
|
|
3163
|
+
enabled
|
|
3164
|
+
])
|
|
3165
|
+
|
|
3166
|
+
table(headers, rows)
|
|
3167
|
+
else:
|
|
3168
|
+
info("No hay reglas de alerta configuradas")
|
|
3169
|
+
|
|
3170
|
+
nl()
|
|
3171
|
+
|
|
3172
|
+
# Alertas pendientes
|
|
3173
|
+
unack = get_unacknowledged_events()
|
|
3174
|
+
if unack:
|
|
3175
|
+
warn(f"{len(unack)} alertas sin reconocer")
|
|
3176
|
+
for e in unack[:5]:
|
|
3177
|
+
ts = e.get('timestamp', '')[:16]
|
|
3178
|
+
dim(f" [{ts}] {e.get('event_type', '')} - {e.get('instance', '')}")
|
|
3179
|
+
|
|
3180
|
+
async def cmd_logs():
|
|
3181
|
+
"""Ver audit log."""
|
|
3182
|
+
print_logo(compact=True)
|
|
3183
|
+
section("Audit Log")
|
|
3184
|
+
|
|
3185
|
+
logs = db_execute("""
|
|
3186
|
+
SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT 30
|
|
3187
|
+
""", fetch=True)
|
|
3188
|
+
|
|
3189
|
+
if not logs:
|
|
3190
|
+
info("Sin entradas en el audit log")
|
|
3191
|
+
return
|
|
3192
|
+
|
|
3193
|
+
for log in logs:
|
|
3194
|
+
ts = log.get('timestamp', '')[:16]
|
|
3195
|
+
actor = log.get('actor', '')
|
|
3196
|
+
action = log.get('action', '')
|
|
3197
|
+
target = log.get('target', '')
|
|
3198
|
+
|
|
3199
|
+
print(f" {q(C.G3, ts)} {q(C.P2, actor[:12])} "
|
|
3200
|
+
f"{q(C.W, action[:15])} {q(C.G1, target)}")
|
|
3201
|
+
|
|
3202
|
+
async def cmd_analyze_cli(instance: str = None):
|
|
3203
|
+
"""Análisis profundo."""
|
|
3204
|
+
print_logo(compact=True)
|
|
3205
|
+
|
|
3206
|
+
if not instance:
|
|
3207
|
+
instances = get_all_instances()
|
|
3208
|
+
if not instances:
|
|
3209
|
+
fail("No hay instancias")
|
|
3210
|
+
return
|
|
3211
|
+
|
|
3212
|
+
# Análisis global
|
|
3213
|
+
section("Análisis Global")
|
|
3214
|
+
|
|
3215
|
+
for inst in instances:
|
|
3216
|
+
analysis = analyze_trends(inst.name, 24)
|
|
3217
|
+
|
|
3218
|
+
if analysis.get("status") == "insufficient_data":
|
|
3219
|
+
dim(f" {inst.label}: datos insuficientes")
|
|
3220
|
+
continue
|
|
3221
|
+
|
|
3222
|
+
emoji, _, _ = inst.sector_info
|
|
3223
|
+
avail = analysis.get("availability", {}).get("percentage", 0)
|
|
3224
|
+
lat_avg = analysis.get("latency", {}).get("avg", 0)
|
|
3225
|
+
|
|
3226
|
+
avail_color = C.GRN if avail >= 99 else (C.YLW if avail >= 95 else C.RED)
|
|
3227
|
+
lat_color = C.GRN if lat_avg < 200 else (C.YLW if lat_avg < 500 else C.RED)
|
|
3228
|
+
|
|
3229
|
+
print(f" {emoji} {q(C.W, inst.label[:25], bold=True)}")
|
|
3230
|
+
print(f" Disponibilidad: {q(avail_color, f'{avail:.1f}%')} "
|
|
3231
|
+
f"Latencia: {q(lat_color, f'{lat_avg:.0f}ms')}")
|
|
3232
|
+
|
|
3233
|
+
# Predicciones
|
|
3234
|
+
preds = predict_issues(inst.name)
|
|
3235
|
+
if preds:
|
|
3236
|
+
for p in preds[:2]:
|
|
3237
|
+
print(f" {q(C.YLW, '⚠')} {q(C.G2, p.get('message', '')[:50])}")
|
|
3238
|
+
|
|
3239
|
+
nl()
|
|
3240
|
+
else:
|
|
3241
|
+
# Análisis de instancia específica
|
|
3242
|
+
section(f"Análisis: {instance}")
|
|
3243
|
+
|
|
3244
|
+
analysis = analyze_trends(instance, 48)
|
|
3245
|
+
anomalies = detect_anomalies(instance, 12)
|
|
3246
|
+
predictions = predict_issues(instance)
|
|
3247
|
+
|
|
3248
|
+
if analysis.get("status") == "insufficient_data":
|
|
3249
|
+
warn("Datos insuficientes para análisis completo")
|
|
3250
|
+
return
|
|
3251
|
+
|
|
3252
|
+
# Disponibilidad
|
|
3253
|
+
if analysis.get("availability"):
|
|
3254
|
+
avail = analysis["availability"]
|
|
3255
|
+
kv("Disponibilidad (48h)", f"{avail.get('percentage', 0):.2f}%")
|
|
3256
|
+
kv("Checks totales", str(avail.get("total_checks", 0)))
|
|
3257
|
+
kv("Outages", str(avail.get("outages", 0)))
|
|
3258
|
+
|
|
3259
|
+
nl()
|
|
3260
|
+
|
|
3261
|
+
# Latencia
|
|
3262
|
+
if analysis.get("latency"):
|
|
3263
|
+
lat = analysis["latency"]
|
|
3264
|
+
info("Latencia:")
|
|
3265
|
+
kv(" Promedio", f"{lat.get('avg', 0):.0f}ms")
|
|
3266
|
+
kv(" Mínimo", f"{lat.get('min', 0):.0f}ms")
|
|
3267
|
+
kv(" Máximo", f"{lat.get('max', 0):.0f}ms")
|
|
3268
|
+
kv(" P95", f"{lat.get('p95', 0):.0f}ms")
|
|
3269
|
+
kv(" Desv. estándar", f"{lat.get('stddev', 0):.1f}ms")
|
|
3270
|
+
kv(" Tendencia", lat.get("trend", "stable"))
|
|
3271
|
+
|
|
3272
|
+
# Gráfico de latencia
|
|
3273
|
+
metrics = get_metrics_history(instance, 24)
|
|
3274
|
+
latencies = [m.get("latency_ms", 0) for m in metrics if m.get("latency_ms")]
|
|
3275
|
+
if latencies:
|
|
3276
|
+
nl()
|
|
3277
|
+
info("Latencia últimas 24h:")
|
|
3278
|
+
print(f" {q(C.P2, spark_line(latencies, 50))}")
|
|
3279
|
+
print(f" {q(C.G3, f'Min: {min(latencies):.0f}ms')} "
|
|
3280
|
+
f"{q(C.G3, f'Max: {max(latencies):.0f}ms')}")
|
|
3281
|
+
|
|
3282
|
+
# Anomalías
|
|
3283
|
+
if anomalies:
|
|
3284
|
+
nl()
|
|
3285
|
+
warn(f"{len(anomalies)} anomalías detectadas:")
|
|
3286
|
+
for a in anomalies[:5]:
|
|
3287
|
+
ts = a.get('timestamp', '')[:16]
|
|
3288
|
+
dim(f" [{ts}] {a.get('type', '')} - {a.get('value', '')}")
|
|
3289
|
+
|
|
3290
|
+
# Predicciones
|
|
3291
|
+
if predictions:
|
|
3292
|
+
nl()
|
|
3293
|
+
info("🔮 Predicciones:")
|
|
3294
|
+
for p in predictions:
|
|
3295
|
+
print(f" {q(C.YLW, '•')} {q(C.W, p.get('type', ''), bold=True)} "
|
|
3296
|
+
f"({p.get('probability', '')})")
|
|
3297
|
+
dim(f" {p.get('message', '')}")
|
|
3298
|
+
dim(f" 💡 {p.get('recommendation', '')}")
|
|
3299
|
+
|
|
3300
|
+
async def setup_telegram_webhook(base_url: str):
|
|
3301
|
+
"""Configurar webhook de Telegram."""
|
|
3302
|
+
# Refrescar desde environ por si _load_master_env lo cargó después del import
|
|
3303
|
+
tok = os.environ.get("OMNI_TELEGRAM_TOKEN", "") or OMNI_TOKEN
|
|
3304
|
+
if not tok or not base_url:
|
|
3305
|
+
warn("OMNI_TELEGRAM_TOKEN o BASE_URL no configurados — revisa el .env")
|
|
3306
|
+
return
|
|
3307
|
+
|
|
3308
|
+
token_suffix = tok.split(":")[-1][:10]
|
|
3309
|
+
webhook_url = f"{base_url.rstrip('/')}/omni/webhook/{token_suffix}"
|
|
3310
|
+
|
|
3311
|
+
url = f"https://api.telegram.org/bot{tok}/setWebhook"
|
|
3312
|
+
|
|
3313
|
+
try:
|
|
3314
|
+
if _HTTPX:
|
|
3315
|
+
async with httpx.AsyncClient(timeout=10.0) as c:
|
|
3316
|
+
r = await c.post(url, json={"url": webhook_url})
|
|
3317
|
+
if r.json().get("ok"):
|
|
3318
|
+
ok(f"Webhook configurado: {webhook_url}")
|
|
3319
|
+
else:
|
|
3320
|
+
warn(f"Error: {r.text[:100]}")
|
|
3321
|
+
except Exception as e:
|
|
3322
|
+
warn(f"No pude configurar webhook: {e}")
|
|
3323
|
+
|
|
3324
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
3325
|
+
# MAIN
|
|
3326
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
3327
|
+
def main():
|
|
3328
|
+
# Inicializar DB
|
|
3329
|
+
init_db()
|
|
3330
|
+
|
|
3331
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else "server"
|
|
3332
|
+
|
|
3333
|
+
if cmd == "status":
|
|
3334
|
+
asyncio.run(cmd_status())
|
|
3335
|
+
|
|
3336
|
+
elif cmd == "watch":
|
|
3337
|
+
asyncio.run(cmd_watch())
|
|
3338
|
+
|
|
3339
|
+
elif cmd == "dashboard":
|
|
3340
|
+
asyncio.run(cmd_dashboard())
|
|
3341
|
+
|
|
3342
|
+
elif cmd == "chat":
|
|
3343
|
+
asyncio.run(cmd_chat())
|
|
3344
|
+
|
|
3345
|
+
elif cmd == "report":
|
|
3346
|
+
report_type = sys.argv[2] if len(sys.argv) > 2 else "daily"
|
|
3347
|
+
asyncio.run(cmd_report(report_type))
|
|
3348
|
+
|
|
3349
|
+
elif cmd == "analyze":
|
|
3350
|
+
instance = sys.argv[2] if len(sys.argv) > 2 else None
|
|
3351
|
+
asyncio.run(cmd_analyze_cli(instance))
|
|
3352
|
+
|
|
3353
|
+
elif cmd == "demo":
|
|
3354
|
+
if len(sys.argv) < 4:
|
|
3355
|
+
fail("Uso: conny-omni.py demo <instancia> <on|off|status>")
|
|
3356
|
+
sys.exit(1)
|
|
3357
|
+
instance_name = sys.argv[2]
|
|
3358
|
+
mode = sys.argv[3].lower()
|
|
3359
|
+
instances = get_all_instances()
|
|
3360
|
+
inst = resolve_instance(instances, instance_name)
|
|
3361
|
+
if not inst:
|
|
3362
|
+
fail(f"No encontré instancia '{instance_name}'")
|
|
3363
|
+
sys.exit(1)
|
|
3364
|
+
if mode == "status":
|
|
3365
|
+
result = asyncio.run(execute_action({"type": "demo_status", "instance": instance_name}, instances, {}))
|
|
3366
|
+
else:
|
|
3367
|
+
result = asyncio.run(execute_action({"type": "set_demo", "instance": instance_name, "active": mode != "off"}, instances, {}))
|
|
3368
|
+
print(result)
|
|
3369
|
+
|
|
3370
|
+
elif cmd == "alerts":
|
|
3371
|
+
asyncio.run(cmd_alerts_cli())
|
|
3372
|
+
|
|
3373
|
+
elif cmd == "logs":
|
|
3374
|
+
asyncio.run(cmd_logs())
|
|
3375
|
+
|
|
3376
|
+
elif cmd == "summary":
|
|
3377
|
+
asyncio.run(send_daily_summary())
|
|
3378
|
+
ok("Resumen enviado a Telegram")
|
|
3379
|
+
|
|
3380
|
+
elif cmd == "weekly":
|
|
3381
|
+
asyncio.run(send_weekly_summary())
|
|
3382
|
+
ok("Reporte semanal enviado")
|
|
3383
|
+
|
|
3384
|
+
elif cmd == "server":
|
|
3385
|
+
if not HAS_FASTAPI:
|
|
3386
|
+
fail("Instala dependencias: pip install fastapi uvicorn httpx")
|
|
3387
|
+
sys.exit(1)
|
|
3388
|
+
|
|
3389
|
+
base_env = load_env(f"{TEMPLATE_DIR}/.env")
|
|
3390
|
+
base_url = base_env.get("BASE_URL", "")
|
|
3391
|
+
|
|
3392
|
+
print_logo()
|
|
3393
|
+
section("Configuración del Servidor")
|
|
3394
|
+
|
|
3395
|
+
kv("Puerto", str(OMNI_PORT))
|
|
3396
|
+
kv("Instancias", str(len(get_all_instances())))
|
|
3397
|
+
kv("Auto-heal", "Activo" if AUTO_HEAL_ENABLED else "Inactivo",
|
|
3398
|
+
C.GRN if AUTO_HEAL_ENABLED else C.G3)
|
|
3399
|
+
kv("Health interval", f"{HEALTH_INTERVAL}s")
|
|
3400
|
+
kv("Metrics interval", f"{METRICS_INTERVAL}s")
|
|
3401
|
+
nl()
|
|
3402
|
+
|
|
3403
|
+
info("Canales de notificación:")
|
|
3404
|
+
channels = []
|
|
3405
|
+
# Re-leer después del _load_master_env por si PM2 no lo tenía al inicio
|
|
3406
|
+
_tok = os.environ.get("OMNI_TELEGRAM_TOKEN", "") or OMNI_TOKEN
|
|
3407
|
+
_cid = os.environ.get("SANTIAGO_CHAT_ID", "") or SANTIAGO_CHAT
|
|
3408
|
+
if _tok:
|
|
3409
|
+
channels.append("Telegram")
|
|
3410
|
+
ok(f" Telegram: @adconnybot → chat {_cid or '(sin chat_id)'}")
|
|
3411
|
+
else:
|
|
3412
|
+
warn(" Telegram: no configurado (revisa OMNI_TELEGRAM_TOKEN en .env)")
|
|
3413
|
+
|
|
3414
|
+
if SLACK_WEBHOOK:
|
|
3415
|
+
channels.append("Slack")
|
|
3416
|
+
ok(" Slack: configurado")
|
|
3417
|
+
|
|
3418
|
+
if DISCORD_WEBHOOK:
|
|
3419
|
+
channels.append("Discord")
|
|
3420
|
+
ok(" Discord: configurado")
|
|
3421
|
+
|
|
3422
|
+
if SMTP_HOST:
|
|
3423
|
+
channels.append("Email")
|
|
3424
|
+
ok(f" Email: {ALERT_EMAIL or SMTP_USER}")
|
|
3425
|
+
|
|
3426
|
+
if CUSTOM_WEBHOOK:
|
|
3427
|
+
channels.append("Webhook")
|
|
3428
|
+
ok(" Custom webhook: configurado")
|
|
3429
|
+
|
|
3430
|
+
if not channels:
|
|
3431
|
+
warn(" Sin canales configurados")
|
|
3432
|
+
|
|
3433
|
+
nl()
|
|
3434
|
+
|
|
3435
|
+
# Configurar webhook de Telegram si está disponible
|
|
3436
|
+
_tok_startup = os.environ.get("OMNI_TELEGRAM_TOKEN", "") or OMNI_TOKEN
|
|
3437
|
+
if _tok_startup and base_url:
|
|
3438
|
+
asyncio.run(setup_telegram_webhook(base_url))
|
|
3439
|
+
|
|
3440
|
+
nl()
|
|
3441
|
+
info(f"Dashboard: http://localhost:{OMNI_PORT}/dashboard")
|
|
3442
|
+
info(f"API: http://localhost:{OMNI_PORT}/omni/status")
|
|
3443
|
+
nl()
|
|
3444
|
+
|
|
3445
|
+
# Arrancar servidor
|
|
3446
|
+
uvicorn.run(
|
|
3447
|
+
omni_app,
|
|
3448
|
+
host="0.0.0.0",
|
|
3449
|
+
port=OMNI_PORT,
|
|
3450
|
+
log_level="warning",
|
|
3451
|
+
access_log=False
|
|
3452
|
+
)
|
|
3453
|
+
|
|
3454
|
+
elif cmd == "setup-webhook":
|
|
3455
|
+
base_env = load_env(f"{TEMPLATE_DIR}/.env")
|
|
3456
|
+
base_url = sys.argv[2] if len(sys.argv) > 2 else base_env.get("BASE_URL", "")
|
|
3457
|
+
asyncio.run(setup_telegram_webhook(base_url))
|
|
3458
|
+
|
|
3459
|
+
elif cmd == "test-notify":
|
|
3460
|
+
# Probar notificaciones
|
|
3461
|
+
async def test():
|
|
3462
|
+
print_logo(compact=True)
|
|
3463
|
+
section("Test de Notificaciones")
|
|
3464
|
+
|
|
3465
|
+
notif = Notification(
|
|
3466
|
+
title="Test de Omni",
|
|
3467
|
+
message=f"Mensaje de prueba enviado a las {datetime.now().strftime('%H:%M:%S')}",
|
|
3468
|
+
severity="info",
|
|
3469
|
+
instance="test",
|
|
3470
|
+
channels=["telegram", "slack", "discord", "email", "webhook"]
|
|
3471
|
+
)
|
|
3472
|
+
|
|
3473
|
+
with Spinner("Enviando notificaciones...") as sp:
|
|
3474
|
+
await send_notification(notif)
|
|
3475
|
+
sp.finish("Notificaciones enviadas")
|
|
3476
|
+
|
|
3477
|
+
nl()
|
|
3478
|
+
info("Verifica que llegaron a todos los canales configurados")
|
|
3479
|
+
|
|
3480
|
+
asyncio.run(test())
|
|
3481
|
+
|
|
3482
|
+
elif cmd == "create-alert":
|
|
3483
|
+
# Crear alerta desde CLI
|
|
3484
|
+
print_logo(compact=True)
|
|
3485
|
+
section("Crear Alerta")
|
|
3486
|
+
|
|
3487
|
+
name = input(" Nombre de la alerta: ").strip()
|
|
3488
|
+
if not name:
|
|
3489
|
+
fail("Nombre requerido")
|
|
3490
|
+
sys.exit(1)
|
|
3491
|
+
|
|
3492
|
+
instance = input(" Instancia (* para todas): ").strip() or "*"
|
|
3493
|
+
|
|
3494
|
+
metrics = ["latency_ms", "status", "conversations", "appointments", "error_rate"]
|
|
3495
|
+
print(" Métricas disponibles:", ", ".join(metrics))
|
|
3496
|
+
metric = input(" Métrica: ").strip()
|
|
3497
|
+
if metric not in metrics:
|
|
3498
|
+
warn(f"Usando métrica personalizada: {metric}")
|
|
3499
|
+
|
|
3500
|
+
operators = [">", "<", ">=", "<=", "==", "!="]
|
|
3501
|
+
print(" Operadores:", ", ".join(operators))
|
|
3502
|
+
operator = input(" Operador: ").strip()
|
|
3503
|
+
if operator not in operators:
|
|
3504
|
+
fail("Operador inválido")
|
|
3505
|
+
sys.exit(1)
|
|
3506
|
+
|
|
3507
|
+
try:
|
|
3508
|
+
threshold = float(input(" Umbral: ").strip())
|
|
3509
|
+
except ValueError:
|
|
3510
|
+
fail("Umbral debe ser numérico")
|
|
3511
|
+
sys.exit(1)
|
|
3512
|
+
|
|
3513
|
+
severities = ["info", "warning", "error", "critical"]
|
|
3514
|
+
print(" Severidades:", ", ".join(severities))
|
|
3515
|
+
severity = input(" Severidad [warning]: ").strip() or "warning"
|
|
3516
|
+
|
|
3517
|
+
channels = input(" Canales (telegram,slack,email) [telegram]: ").strip() or "telegram"
|
|
3518
|
+
|
|
3519
|
+
try:
|
|
3520
|
+
cooldown = int(input(" Cooldown en minutos [30]: ").strip() or "30")
|
|
3521
|
+
except ValueError:
|
|
3522
|
+
cooldown = 30
|
|
3523
|
+
|
|
3524
|
+
rule_id = create_alert_rule(name, instance, metric, operator, threshold,
|
|
3525
|
+
severity, channels, cooldown)
|
|
3526
|
+
|
|
3527
|
+
nl()
|
|
3528
|
+
ok(f"Alerta creada con ID: {rule_id}")
|
|
3529
|
+
log_audit("cli", "create_alert", str(rule_id), name)
|
|
3530
|
+
|
|
3531
|
+
elif cmd == "delete-alert":
|
|
3532
|
+
if len(sys.argv) < 3:
|
|
3533
|
+
fail("Uso: conny-omni.py delete-alert <id>")
|
|
3534
|
+
sys.exit(1)
|
|
3535
|
+
|
|
3536
|
+
try:
|
|
3537
|
+
rule_id = int(sys.argv[2])
|
|
3538
|
+
delete_alert_rule(rule_id)
|
|
3539
|
+
ok(f"Alerta {rule_id} eliminada")
|
|
3540
|
+
log_audit("cli", "delete_alert", str(rule_id), "")
|
|
3541
|
+
except ValueError:
|
|
3542
|
+
fail("ID debe ser numérico")
|
|
3543
|
+
|
|
3544
|
+
elif cmd == "create-workflow":
|
|
3545
|
+
# Crear workflow desde CLI
|
|
3546
|
+
print_logo(compact=True)
|
|
3547
|
+
section("Crear Workflow")
|
|
3548
|
+
|
|
3549
|
+
name = input(" Nombre del workflow: ").strip()
|
|
3550
|
+
if not name:
|
|
3551
|
+
fail("Nombre requerido")
|
|
3552
|
+
sys.exit(1)
|
|
3553
|
+
|
|
3554
|
+
events = [
|
|
3555
|
+
"instance_offline", "instance_online", "high_latency",
|
|
3556
|
+
"new_conversation", "new_appointment", "error",
|
|
3557
|
+
"whatsapp_connected", "whatsapp_disconnected"
|
|
3558
|
+
]
|
|
3559
|
+
print(" Eventos disponibles:")
|
|
3560
|
+
for e in events:
|
|
3561
|
+
print(f" - {e}")
|
|
3562
|
+
|
|
3563
|
+
trigger_event = input(" Evento trigger: ").strip()
|
|
3564
|
+
|
|
3565
|
+
print(" Acciones disponibles: notify, restart, webhook, log")
|
|
3566
|
+
|
|
3567
|
+
actions = []
|
|
3568
|
+
while True:
|
|
3569
|
+
action_type = input(" Tipo de acción (vacío para terminar): ").strip()
|
|
3570
|
+
if not action_type:
|
|
3571
|
+
break
|
|
3572
|
+
|
|
3573
|
+
action = {"type": action_type}
|
|
3574
|
+
|
|
3575
|
+
if action_type == "notify":
|
|
3576
|
+
action["title"] = input(" Título: ").strip()
|
|
3577
|
+
action["message"] = input(" Mensaje: ").strip()
|
|
3578
|
+
action["severity"] = input(" Severidad [warning]: ").strip() or "warning"
|
|
3579
|
+
action["channels"] = input(" Canales [telegram]: ").strip().split(",") or ["telegram"]
|
|
3580
|
+
|
|
3581
|
+
elif action_type == "restart":
|
|
3582
|
+
action["instance"] = input(" Instancia (vacío = del evento): ").strip()
|
|
3583
|
+
|
|
3584
|
+
elif action_type == "webhook":
|
|
3585
|
+
action["url"] = input(" URL del webhook: ").strip()
|
|
3586
|
+
|
|
3587
|
+
elif action_type == "log":
|
|
3588
|
+
action["message"] = input(" Mensaje: ").strip()
|
|
3589
|
+
action["severity"] = input(" Severidad [info]: ").strip() or "info"
|
|
3590
|
+
|
|
3591
|
+
actions.append(action)
|
|
3592
|
+
ok(f" Acción {action_type} añadida")
|
|
3593
|
+
|
|
3594
|
+
if not actions:
|
|
3595
|
+
fail("Se requiere al menos una acción")
|
|
3596
|
+
sys.exit(1)
|
|
3597
|
+
|
|
3598
|
+
wf_id = create_workflow(name, trigger_event, actions)
|
|
3599
|
+
nl()
|
|
3600
|
+
ok(f"Workflow creado con ID: {wf_id}")
|
|
3601
|
+
log_audit("cli", "create_workflow", str(wf_id), name)
|
|
3602
|
+
|
|
3603
|
+
elif cmd == "workflows":
|
|
3604
|
+
print_logo(compact=True)
|
|
3605
|
+
section("Workflows")
|
|
3606
|
+
|
|
3607
|
+
workflows = get_workflows()
|
|
3608
|
+
|
|
3609
|
+
if not workflows:
|
|
3610
|
+
info("No hay workflows configurados")
|
|
3611
|
+
else:
|
|
3612
|
+
for wf in workflows:
|
|
3613
|
+
enabled = q(C.GRN, "ON") if wf.get("enabled") else q(C.RED, "OFF")
|
|
3614
|
+
runs = wf.get("run_count", 0)
|
|
3615
|
+
wf_id = wf.get("id", "?")
|
|
3616
|
+
|
|
3617
|
+
print(f" {q(C.P2, f'[{wf_id}]')} {q(C.W, wf['name'], bold=True)} {enabled}")
|
|
3618
|
+
print(f" Trigger: {wf.get('trigger_event', '')}")
|
|
3619
|
+
print(f" Ejecutado: {runs} veces")
|
|
3620
|
+
|
|
3621
|
+
try:
|
|
3622
|
+
actions = json.loads(wf.get("actions", "[]"))
|
|
3623
|
+
print(f" Acciones: {', '.join(a.get('type', '') for a in actions)}")
|
|
3624
|
+
except:
|
|
3625
|
+
pass
|
|
3626
|
+
|
|
3627
|
+
nl()
|
|
3628
|
+
|
|
3629
|
+
elif cmd == "metrics":
|
|
3630
|
+
# Ver métricas de una instancia
|
|
3631
|
+
if len(sys.argv) < 3:
|
|
3632
|
+
fail("Uso: conny-omni.py metrics <instancia> [horas]")
|
|
3633
|
+
sys.exit(1)
|
|
3634
|
+
|
|
3635
|
+
instance = sys.argv[2]
|
|
3636
|
+
hours = int(sys.argv[3]) if len(sys.argv) > 3 else 24
|
|
3637
|
+
|
|
3638
|
+
print_logo(compact=True)
|
|
3639
|
+
section(f"Métricas: {instance}", f"Últimas {hours} horas")
|
|
3640
|
+
|
|
3641
|
+
metrics = get_metrics_history(instance, hours)
|
|
3642
|
+
|
|
3643
|
+
if not metrics:
|
|
3644
|
+
warn("Sin métricas disponibles")
|
|
3645
|
+
sys.exit(0)
|
|
3646
|
+
|
|
3647
|
+
# Estadísticas
|
|
3648
|
+
latencies = [m.get("latency_ms", 0) for m in metrics if m.get("latency_ms")]
|
|
3649
|
+
online_count = sum(1 for m in metrics if m.get("status") == "online")
|
|
3650
|
+
|
|
3651
|
+
kv("Data points", str(len(metrics)))
|
|
3652
|
+
kv("Disponibilidad", f"{(online_count/len(metrics)*100):.1f}%")
|
|
3653
|
+
|
|
3654
|
+
if latencies:
|
|
3655
|
+
nl()
|
|
3656
|
+
info("Latencia:")
|
|
3657
|
+
kv(" Promedio", f"{statistics.mean(latencies):.0f}ms")
|
|
3658
|
+
kv(" Mínimo", f"{min(latencies):.0f}ms")
|
|
3659
|
+
kv(" Máximo", f"{max(latencies):.0f}ms")
|
|
3660
|
+
if len(latencies) > 1:
|
|
3661
|
+
kv(" Desv. est.", f"{statistics.stdev(latencies):.1f}ms")
|
|
3662
|
+
|
|
3663
|
+
nl()
|
|
3664
|
+
info("Gráfico:")
|
|
3665
|
+
print(f" {q(C.P2, spark_line(latencies, 50))}")
|
|
3666
|
+
|
|
3667
|
+
# Conversaciones
|
|
3668
|
+
convs = [m.get("conversations", 0) for m in metrics if m.get("conversations") is not None]
|
|
3669
|
+
if convs and any(c > 0 for c in convs):
|
|
3670
|
+
nl()
|
|
3671
|
+
info("Conversaciones:")
|
|
3672
|
+
kv(" Total", str(max(convs)))
|
|
3673
|
+
print(f" {q(C.GRN, spark_line(convs, 50))}")
|
|
3674
|
+
|
|
3675
|
+
elif cmd == "export":
|
|
3676
|
+
# Exportar datos
|
|
3677
|
+
output_file = sys.argv[2] if len(sys.argv) > 2 else f"omni_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
|
3678
|
+
|
|
3679
|
+
print_logo(compact=True)
|
|
3680
|
+
|
|
3681
|
+
with Spinner("Exportando datos...") as sp:
|
|
3682
|
+
data = {
|
|
3683
|
+
"exported_at": datetime.now().isoformat(),
|
|
3684
|
+
"version": OMNI_VERSION,
|
|
3685
|
+
"events": get_recent_events(1000),
|
|
3686
|
+
"alert_rules": get_alert_rules(),
|
|
3687
|
+
"workflows": get_workflows(),
|
|
3688
|
+
"audit_log": db_execute("SELECT * FROM audit_log ORDER BY timestamp DESC LIMIT 500", fetch=True),
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
# Métricas de las últimas 24h por instancia
|
|
3692
|
+
instances = get_all_instances()
|
|
3693
|
+
data["metrics"] = {}
|
|
3694
|
+
for inst in instances:
|
|
3695
|
+
data["metrics"][inst.name] = get_metrics_history(inst.name, 24)
|
|
3696
|
+
|
|
3697
|
+
Path(output_file).write_text(json.dumps(data, indent=2, default=str))
|
|
3698
|
+
sp.finish(f"Exportado: {output_file}")
|
|
3699
|
+
|
|
3700
|
+
kv("Eventos", str(len(data["events"])))
|
|
3701
|
+
kv("Alertas", str(len(data["alert_rules"])))
|
|
3702
|
+
kv("Workflows", str(len(data["workflows"])))
|
|
3703
|
+
|
|
3704
|
+
elif cmd == "cleanup":
|
|
3705
|
+
# Limpiar datos antiguos
|
|
3706
|
+
print_logo(compact=True)
|
|
3707
|
+
section("Limpieza de datos")
|
|
3708
|
+
|
|
3709
|
+
with Spinner("Limpiando métricas antiguas (>30 días)...") as sp:
|
|
3710
|
+
db_execute("DELETE FROM metrics WHERE timestamp < datetime('now', '-30 days')")
|
|
3711
|
+
sp.finish("Métricas limpiadas")
|
|
3712
|
+
|
|
3713
|
+
with Spinner("Limpiando eventos antiguos (>90 días)...") as sp:
|
|
3714
|
+
db_execute("DELETE FROM events WHERE timestamp < datetime('now', '-90 days')")
|
|
3715
|
+
sp.finish("Eventos limpiados")
|
|
3716
|
+
|
|
3717
|
+
with Spinner("Limpiando audit log antiguo (>180 días)...") as sp:
|
|
3718
|
+
db_execute("DELETE FROM audit_log WHERE timestamp < datetime('now', '-180 days')")
|
|
3719
|
+
sp.finish("Audit log limpiado")
|
|
3720
|
+
|
|
3721
|
+
with Spinner("Limpiando conversaciones antiguas (>60 días)...") as sp:
|
|
3722
|
+
db_execute("DELETE FROM santiago_conversations WHERE timestamp < datetime('now', '-60 days')")
|
|
3723
|
+
sp.finish("Conversaciones limpiadas")
|
|
3724
|
+
|
|
3725
|
+
with Spinner("Optimizando base de datos...") as sp:
|
|
3726
|
+
conn = sqlite3.connect(str(OMNI_DB))
|
|
3727
|
+
conn.execute("VACUUM")
|
|
3728
|
+
conn.close()
|
|
3729
|
+
sp.finish("Base de datos optimizada")
|
|
3730
|
+
|
|
3731
|
+
nl()
|
|
3732
|
+
ok("Limpieza completada")
|
|
3733
|
+
|
|
3734
|
+
elif cmd == "reset-db":
|
|
3735
|
+
# Resetear base de datos (peligroso)
|
|
3736
|
+
print_logo(compact=True)
|
|
3737
|
+
warn("¡PELIGRO! Esto eliminará TODOS los datos de Omni.")
|
|
3738
|
+
|
|
3739
|
+
confirmation = input(" Escribe 'CONFIRMAR' para continuar: ").strip()
|
|
3740
|
+
if confirmation != "CONFIRMAR":
|
|
3741
|
+
info("Cancelado")
|
|
3742
|
+
sys.exit(0)
|
|
3743
|
+
|
|
3744
|
+
with Spinner("Eliminando base de datos...") as sp:
|
|
3745
|
+
if OMNI_DB.exists():
|
|
3746
|
+
OMNI_DB.unlink()
|
|
3747
|
+
init_db()
|
|
3748
|
+
sp.finish("Base de datos reiniciada")
|
|
3749
|
+
|
|
3750
|
+
log_audit("cli", "reset_db", "", "")
|
|
3751
|
+
|
|
3752
|
+
elif cmd in ("help", "--help", "-h"):
|
|
3753
|
+
print_logo()
|
|
3754
|
+
|
|
3755
|
+
cmds = [
|
|
3756
|
+
("server", "Arrancar servidor completo (puerto 9001)"),
|
|
3757
|
+
("chat", "Chat interactivo con Omni"),
|
|
3758
|
+
("status", "Estado rápido de todas las instancias"),
|
|
3759
|
+
("watch", "Monitor live (refresca cada 5s)"),
|
|
3760
|
+
("dashboard", "Dashboard interactivo en terminal"),
|
|
3761
|
+
("report [daily|weekly]", "Generar y mostrar reporte"),
|
|
3762
|
+
("summary", "Enviar resumen diario a Telegram"),
|
|
3763
|
+
("weekly", "Enviar reporte semanal a Telegram"),
|
|
3764
|
+
("analyze [instancia]", "Análisis profundo"),
|
|
3765
|
+
("alerts", "Ver reglas de alerta"),
|
|
3766
|
+
("create-alert", "Crear nueva alerta"),
|
|
3767
|
+
("delete-alert <id>", "Eliminar alerta"),
|
|
3768
|
+
("workflows", "Ver workflows configurados"),
|
|
3769
|
+
("create-workflow", "Crear nuevo workflow"),
|
|
3770
|
+
("logs", "Ver audit log"),
|
|
3771
|
+
("metrics <inst> [horas]", "Ver métricas históricas"),
|
|
3772
|
+
("export [archivo]", "Exportar datos a JSON"),
|
|
3773
|
+
("cleanup", "Limpiar datos antiguos"),
|
|
3774
|
+
("test-notify", "Probar notificaciones"),
|
|
3775
|
+
("setup-webhook [url]", "Configurar webhook Telegram"),
|
|
3776
|
+
]
|
|
3777
|
+
|
|
3778
|
+
section("Comandos")
|
|
3779
|
+
for c, d in cmds:
|
|
3780
|
+
print(f" {q(C.P2, f'conny-omni.py {c:<24}', bold=True)}{q(C.G2, d)}")
|
|
3781
|
+
|
|
3782
|
+
nl()
|
|
3783
|
+
hr()
|
|
3784
|
+
nl()
|
|
3785
|
+
|
|
3786
|
+
section("Variables de Entorno", "Configura en .env o exporta")
|
|
3787
|
+
|
|
3788
|
+
env_vars = [
|
|
3789
|
+
("OMNI_PORT", "9001", "Puerto del servidor"),
|
|
3790
|
+
("OMNI_KEY", "secreto", "Clave de autenticación API"),
|
|
3791
|
+
("OMNI_TELEGRAM_TOKEN", "", "Token del bot personal"),
|
|
3792
|
+
("SANTIAGO_CHAT_ID", "", "Chat ID de Santiago"),
|
|
3793
|
+
("OMNI_HEALTH_INTERVAL", "30", "Segundos entre health checks"),
|
|
3794
|
+
("OMNI_METRICS_INTERVAL", "300", "Segundos entre recolección de métricas"),
|
|
3795
|
+
("OMNI_AUTO_HEAL", "true", "Auto-reiniciar instancias caídas"),
|
|
3796
|
+
("OMNI_AUTO_HEAL_DELAY", "120", "Segundos antes de auto-heal"),
|
|
3797
|
+
("OMNI_SLACK_WEBHOOK", "", "Webhook de Slack"),
|
|
3798
|
+
("OMNI_DISCORD_WEBHOOK", "", "Webhook de Discord"),
|
|
3799
|
+
("OMNI_SMTP_HOST", "", "Servidor SMTP para emails"),
|
|
3800
|
+
("OMNI_SMTP_USER", "", "Usuario SMTP"),
|
|
3801
|
+
("OMNI_SMTP_PASS", "", "Contraseña SMTP"),
|
|
3802
|
+
("OMNI_ALERT_EMAIL", "", "Email para alertas"),
|
|
3803
|
+
("OMNI_CUSTOM_WEBHOOK", "", "Webhook personalizado"),
|
|
3804
|
+
]
|
|
3805
|
+
|
|
3806
|
+
for var, default, desc in env_vars:
|
|
3807
|
+
d = f"[{default}]" if default else ""
|
|
3808
|
+
print(f" {q(C.CYN, var):<30} {q(C.G3, d):<12} {q(C.G2, desc)}")
|
|
3809
|
+
|
|
3810
|
+
nl()
|
|
3811
|
+
|
|
3812
|
+
section("Ejemplos de Uso")
|
|
3813
|
+
examples = [
|
|
3814
|
+
"conny-omni.py server",
|
|
3815
|
+
"conny-omni.py chat",
|
|
3816
|
+
"conny-omni.py analyze clinica-bella",
|
|
3817
|
+
"conny-omni.py metrics base 48",
|
|
3818
|
+
"conny-omni.py create-alert",
|
|
3819
|
+
]
|
|
3820
|
+
for ex in examples:
|
|
3821
|
+
print(f" {q(C.G1, ex)}")
|
|
3822
|
+
|
|
3823
|
+
nl()
|
|
3824
|
+
|
|
3825
|
+
elif cmd == "version":
|
|
3826
|
+
print(f"conny-omni {OMNI_VERSION}")
|
|
3827
|
+
|
|
3828
|
+
else:
|
|
3829
|
+
fail(f"Comando desconocido: '{cmd}'")
|
|
3830
|
+
info("Usa 'conny-omni.py help' para ver comandos")
|
|
3831
|
+
sys.exit(1)
|
|
3832
|
+
|
|
3833
|
+
|
|
3834
|
+
if __name__ == "__main__":
|
|
3835
|
+
# Manejar Ctrl+C
|
|
3836
|
+
def signal_handler(sig, frame):
|
|
3837
|
+
print()
|
|
3838
|
+
info("Interrumpido")
|
|
3839
|
+
sys.exit(0)
|
|
3840
|
+
|
|
3841
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
3842
|
+
|
|
3843
|
+
main()
|