@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.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. 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()