@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
@@ -0,0 +1,563 @@
1
+ """Message sending, buffering, typing simulation, and audio transcription."""
2
+ from __future__ import annotations
3
+ # TODO: These methods were part of ConnyUltra class.
4
+ # They reference self._pending_buffers, self._demo_sessions, Config, httpx, etc.
5
+ # To fully decouple: inject dependencies via constructor or pass as params.
6
+
7
+ def _calc_smart_wait(self, chat_id: str, text: str) -> float:
8
+ """
9
+ Calcula tiempo de espera inteligente. No es random fijo.
10
+ Analiza el contexto real para decidir cuanto esperar.
11
+
12
+ Logica:
13
+ - Setup flow (respondiendo preguntas directas) (3-8s)
14
+ - Mensaje muy corto (si/no/ok/dato puntual) (3-10s)
15
+ - Respuesta corta (1 dato, una oracion) (5-15s)
16
+ - Mensaje normal (1-2 oraciones) (9-22s)
17
+ - Mensaje largo (parrafo, varias preguntas) (16-40s)
18
+ - Si Conny acaba de preguntar algo (-40%) del tiempo base
19
+
20
+ Maximo absoluto: 55s
21
+ """
22
+ text = text.strip()
23
+ chars = len(text)
24
+
25
+ # ── Leer contexto de la BD ─────────────────────────────────────────────
26
+ try:
27
+ clinic = db.get_clinic()
28
+ is_setup = not clinic.get("setup_done")
29
+ history = db.get_history(chat_id, limit=3)
30
+
31
+ # Demo mode: siempre rápido para no perder al prospecto
32
+ if Config.DEMO_MODE:
33
+ if chars <= 12:
34
+ return round(random.uniform(2.0, 4.5), 1)
35
+ elif chars <= 60:
36
+ return round(random.uniform(3.0, 7.0), 1)
37
+ else:
38
+ return round(random.uniform(5.0, 10.0), 1)
39
+
40
+ # Setup: siempre rapido — el usuario responde preguntas directas cortas
41
+ if is_setup:
42
+ if chars < 60:
43
+ return round(random.uniform(3.0, 7.0), 1)
44
+ return round(random.uniform(5.0, 11.0), 1)
45
+
46
+ if not history and _is_greeting_only(text):
47
+ return float(Config.GREETING_ONLY_IDLE_SECONDS)
48
+
49
+ # Ver si Conny acaba de hacer una pregunta al usuario
50
+ last_bot = next(
51
+ (m for m in reversed(history) if m["role"] == "assistant"), None
52
+ )
53
+ bot_asked = False
54
+ if last_bot:
55
+ bot_content = last_bot.get("content", "").lower()
56
+ # Pregunta directa: tiene "?" o palabras tipicas de solicitud de dato
57
+ bot_asked = "?" in bot_content or any(
58
+ w in bot_content for w in [
59
+ "cual", "como", "cuando", "tienes", "nombre", "telefono",
60
+ "servicio", "fecha", "hora", "confirmas", "dime"
61
+ ]
62
+ )
63
+ except Exception:
64
+ is_setup = False
65
+ bot_asked = False
66
+ history = []
67
+
68
+ # ── Rango base segun longitud del texto ───────────────────────────────
69
+ # Muy corto: "si", "no", "ok", "dale", numero, nombre
70
+ if chars <= 12:
71
+ lo, hi = 3.0, 9.0
72
+ # Corto: dato simple, respuesta directa
73
+ elif chars <= 40:
74
+ lo, hi = 5.0, 14.0
75
+ # Oracion normal
76
+ elif chars <= 100:
77
+ lo, hi = 9.0, 22.0
78
+ # Parrafo
79
+ elif chars <= 220:
80
+ lo, hi = 16.0, 35.0
81
+ # Mensaje largo con varias preguntas/contexto
82
+ else:
83
+ lo, hi = 22.0, 50.0
84
+
85
+ # Si Conny pregunto algo y el usuario responde -> ir mas rapido
86
+ if bot_asked:
87
+ lo = max(3.0, lo * 0.55)
88
+ hi = max(8.0, hi * 0.60)
89
+
90
+ # Nunca superar 55s
91
+ hi = min(hi, 55.0)
92
+
93
+ return round(random.uniform(lo, hi), 1)
94
+
95
+ async def enqueue_message(
96
+ self,
97
+ chat_id: str,
98
+ text: str,
99
+ urgent: bool = False,
100
+ message_id: str = "",
101
+ route: Optional[Dict[str, Any]] = None,
102
+ attachments: Optional[List[Dict[str, Any]]] = None,
103
+ ):
104
+ """Encola mensaje con buffer inteligente basado en contexto real."""
105
+ attachments = attachments or []
106
+
107
+ # ── Commands: process inline (no buffer needed for slash commands)
108
+ if text.strip().startswith("/"):
109
+ try:
110
+ from conny_commands import get_command_handler
111
+ instance_id = getattr(self, "_instance_id", "default")
112
+ clinic = db.get_clinic()
113
+ admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
114
+ is_admin = (chat_id in admin_ids or db.get_admin(chat_id) is not None)
115
+ cmd_handler = get_command_handler(instance_id)
116
+ result = await cmd_handler.handle(chat_id, text.strip(), is_admin=is_admin, clinic=clinic, db=db)
117
+ if result:
118
+ await self._send_bubbles(chat_id, result, message_id="", route=route)
119
+ return
120
+ except Exception as e:
121
+ log.warning(f"[command] error: {e}")
122
+ # If command not recognized, let it pass to process_message as normal text
123
+ pass
124
+
125
+ # ── MODO SIMULACIÓN ──────────────────────────────────────────────────
126
+ if self.simulator and self.simulator.is_simulating(chat_id):
127
+ bubbles = await self.simulator.handle_step(chat_id, text)
128
+ if bubbles:
129
+ await self._send_bubbles(chat_id, bubbles, message_id=message_id, route=route)
130
+ return
131
+
132
+ route = self._resolve_route(chat_id, route)
133
+ platform = _route_platform(route)
134
+ key = _buffer_key(chat_id, route)
135
+ is_wa = platform == "whatsapp" and bool(Config.WHATSAPP_BRIDGE_URL)
136
+
137
+ # ── Fire /read con delay natural — tarea completamente independiente ──────
138
+ # Separada del buffer para que nunca interfiera con el flush.
139
+ # read_delay simula el tiempo que tarda una persona en leer antes de marcar azul.
140
+ if is_wa and message_id:
141
+ chars = len(text.strip())
142
+ is_demo = Config.DEMO_MODE
143
+ if chars <= 8: rd_lo, rd_hi = 0.8, 2.0
144
+ elif chars <= 30: rd_lo, rd_hi = 1.2, 3.5
145
+ elif chars <= 80: rd_lo, rd_hi = 2.5, 6.0
146
+ elif chars <= 200: rd_lo, rd_hi = 4.0, 10.0
147
+ else: rd_lo, rd_hi = 6.0, 15.0
148
+ if is_demo:
149
+ rd_lo *= 0.35; rd_hi *= 0.35
150
+ read_delay = round(random.uniform(rd_lo, rd_hi), 1)
151
+
152
+ async def _fire_read(mid: str, delay: float):
153
+ await asyncio.sleep(delay)
154
+ try:
155
+ async with httpx.AsyncClient(timeout=4.0) as hx:
156
+ await hx.post(
157
+ f"{Config.WHATSAPP_BRIDGE_URL}/read",
158
+ json={"to": chat_id, "messageId": mid}
159
+ )
160
+ except Exception:
161
+ pass
162
+
163
+ asyncio.create_task(_fire_read(message_id, read_delay))
164
+
165
+ if urgent:
166
+ log.info(f"Urgente [{chat_id[:8]}]: bypass buffer")
167
+ if key in self._pending_buffers:
168
+ task = self._pending_buffers[key].get("task")
169
+ if task and not task.done():
170
+ task.cancel()
171
+ prev_entry = self._pending_buffers.pop(key, {})
172
+ prev = " ".join(prev_entry.get("messages", []))
173
+ attachments = prev_entry.get("attachments", []) + attachments
174
+ combined = (prev + " " + text).strip() if prev else text
175
+ else:
176
+ combined = text
177
+ bubbles = await self.process_message(chat_id, combined, attachments=attachments, route=route)
178
+ await self._send_bubbles(chat_id, bubbles, message_id="", route=route) # read ya disparado arriba
179
+ return
180
+
181
+ # Buffer normal — lógica original intacta
182
+ if key in self._pending_buffers:
183
+ task = self._pending_buffers[key].get("task")
184
+ if task and not task.done():
185
+ task.cancel()
186
+ if text:
187
+ self._pending_buffers[key]["messages"].append(text)
188
+ self._pending_buffers[key]["attachments"].extend(attachments)
189
+ else:
190
+ self._pending_buffers[key] = {
191
+ "chat_id": chat_id,
192
+ "messages": [text] if text else [],
193
+ "attachments": list(attachments),
194
+ "task": None,
195
+ "message_id": message_id,
196
+ "route": route,
197
+ }
198
+
199
+ wait_seed = text or " ".join(att.get("filename", "") for att in attachments) or "archivo"
200
+ wait = self._calc_smart_wait(chat_id, wait_seed)
201
+
202
+ async def delayed():
203
+ await asyncio.sleep(wait)
204
+ await self._flush_buffer(key)
205
+
206
+ task = asyncio.create_task(delayed())
207
+ self._pending_buffers[key]["task"] = task
208
+
209
+ n = len(self._pending_buffers[key]["messages"])
210
+ log.info(f"buffer [{platform}:{chat_id[:8]}] msg #{n}, flush en {wait:.1f}s")
211
+
212
+ async def _flush_buffer(self, key: str):
213
+ """Vacía el buffer y procesa mensajes."""
214
+ entry = self._pending_buffers.pop(key, None)
215
+ if not entry:
216
+ return
217
+
218
+ chat_id = entry.get("chat_id", "")
219
+ route = entry.get("route") or self._resolve_route(chat_id)
220
+ combined = " ".join(entry.get("messages", []))
221
+ message_id = entry.get("message_id", "")
222
+ attachments = entry.get("attachments", [])
223
+ log.info(f"flush [{key}] {len(entry.get('messages', []))} msgs")
224
+
225
+ try:
226
+ bubbles = await self.process_message(chat_id, combined, attachments=attachments, route=route)
227
+ if bubbles:
228
+ await self._send_bubbles(chat_id, bubbles, message_id=message_id, route=route)
229
+ except Exception as e:
230
+ log.error(f"Flush error for {chat_id}: {e}", exc_info=True)
231
+ # No enviamos nada aquí, dejamos que process_message maneje el error interno
232
+ # Si llegó aquí es porque algo falló MUY feo fuera del try de process_message
233
+ await self._send_message(chat_id, "Lo siento, tuve un error técnico inesperado. ¿Podrías repetir?", route=route)
234
+
235
+ async def _send_bubbles(
236
+ self,
237
+ chat_id: str,
238
+ bubbles: List[str],
239
+ message_id: str = "",
240
+ route: Optional[Dict[str, Any]] = None,
241
+ ):
242
+ """Envía burbujas con typing proporcional y pausas naturales."""
243
+ route = self._resolve_route(chat_id, route)
244
+ platform = _route_platform(route)
245
+ is_wa = platform in ("whatsapp", "evolution")
246
+
247
+ # Demo voice: send first bubble as audio for wow factor
248
+ if Config.DEMO_MODE and is_wa and bubbles and os.getenv("ELEVENLABS_API_KEY"):
249
+ try:
250
+ from conny_demo_voice import generate_demo_audio, should_send_voice_in_demo
251
+ history_len = len(db.get_history(chat_id)) if db else 0
252
+ if should_send_voice_in_demo(bubbles[0], history_len // 2, False):
253
+ audio_path = await generate_demo_audio(bubbles[0])
254
+ if audio_path:
255
+ await self._send_audio(chat_id, audio_path, route=route)
256
+ os.unlink(audio_path)
257
+ # Still send text after audio for accessibility
258
+ await asyncio.sleep(1.0)
259
+ except Exception as e:
260
+ log.debug(f"[demo_voice] skipped: {e}")
261
+
262
+ for i, bubble in enumerate(bubbles):
263
+ if not bubble.strip():
264
+ continue
265
+
266
+ # Typing proporcional al bubble + read receipt en primera burbuja
267
+ mid = message_id if i == 0 else ""
268
+ await self._typing_action(chat_id, text=bubble, message_id=mid, route=route)
269
+
270
+ # Duración del typing que se fijó en el bridge (misma fórmula que _typing_action)
271
+ # para que pause >= typing_duration y nunca lleguemos a /send antes de que expire
272
+ chars = len(bubble)
273
+ typing_duration_s = max(1.5, min(chars * 0.05, 8.0)) # same as max(1500,min(chars*50,8000))/1000
274
+ typing_time = chars / 38 # velocidad de escritura humana ~38 chars/s
275
+
276
+ pause = max(
277
+ typing_duration_s + 0.15, # siempre > duración del timer — margen 150ms
278
+ Config.BUBBLE_PAUSE_MIN,
279
+ min(typing_time + random.uniform(0.1, 0.5), Config.BUBBLE_PAUSE_MAX + 0.8)
280
+ )
281
+
282
+ await asyncio.sleep(pause)
283
+ await self._send_message(chat_id, bubble, route=route)
284
+
285
+ if i < len(bubbles) - 1:
286
+ # Pausa inter-burbuja más humana — evita que WA trate las ráfagas como spam
287
+ inter_pause = random.uniform(1.4, 2.8) if is_wa else random.uniform(0.8, 1.8)
288
+ await asyncio.sleep(inter_pause)
289
+
290
+ # No forzamos offline después de responder.
291
+ # El bridge mantiene presencia humana y expira solo tras el timeout configurado.
292
+
293
+ async def _typing_action(
294
+ self,
295
+ chat_id: str,
296
+ text: str = "",
297
+ message_id: str = "",
298
+ route: Optional[Dict[str, Any]] = None,
299
+ ):
300
+ """
301
+ Indica 'escribiendo...' proporcional al texto que va a enviar.
302
+ En WhatsApp Bridge también marca como leído si hay message_id.
303
+ """
304
+ try:
305
+ route = self._resolve_route(chat_id, route)
306
+ platform = _route_platform(route)
307
+ # Duración proporcional: ~50ms por char, entre 1.5s y 8s
308
+ chars = len(text) if text else 60
309
+ duration = max(1500, min(int(chars * 50), 8000))
310
+
311
+ if platform == "telegram":
312
+ async with httpx.AsyncClient(timeout=5.0) as client:
313
+ await client.post(
314
+ f"https://api.telegram.org/bot{Config.TELEGRAM_TOKEN}/sendChatAction",
315
+ json={"chat_id": chat_id, "action": "typing"}
316
+ )
317
+
318
+ elif platform == "whatsapp_cloud":
319
+ pass # no soportado en API v17+
320
+
321
+ elif platform == "evolution":
322
+ if Config.EVOLUTION_URL and Config.EVOLUTION_API_KEY:
323
+ async with httpx.AsyncClient(timeout=5.0) as client:
324
+ await client.post(
325
+ f"{Config.EVOLUTION_URL}/chat/sendPresence/{Config.EVOLUTION_INSTANCE}",
326
+ headers={"apikey": Config.EVOLUTION_API_KEY},
327
+ json={"number": chat_id, "presence": "composing", "delay": duration}
328
+ )
329
+
330
+ elif platform == "whatsapp":
331
+ if Config.WHATSAPP_BRIDGE_URL:
332
+ async with httpx.AsyncClient(timeout=8.0) as client:
333
+ # Aparecer online PRIMERO — sin esto WhatsApp no muestra "escribiendo"
334
+ # y el mensaje llega con un solo chulo gris
335
+ try:
336
+ await client.post(
337
+ f"{Config.WHATSAPP_BRIDGE_URL}/presence",
338
+ json={"status": "available", "timeout": 1800000},
339
+ timeout=3.0
340
+ )
341
+ except Exception:
342
+ pass
343
+ # Typing proporcional al mensaje
344
+ await client.post(
345
+ f"{Config.WHATSAPP_BRIDGE_URL}/typing",
346
+ json={"to": chat_id, "duration": duration}
347
+ )
348
+ except Exception:
349
+ pass
350
+
351
+
352
+ def sanitize_outgoing(self, text: Optional[str]) -> Optional[str]:
353
+ """
354
+ Sanitiza TODO mensaje saliente antes de enviarlo.
355
+ Previene JSON bleed, debug messages y respuestas vacías.
356
+ """
357
+ if not text:
358
+ return None
359
+ t = text.strip()
360
+ if not t:
361
+ return None
362
+ if t.startswith('{') or t.startswith('['):
363
+ log.error(f"[sanitize] JSON bleed blocked: {t[:80]}")
364
+ return None
365
+ if '|||' in t:
366
+ t = t.split('|||')[0].strip()
367
+ internal_phrases = [
368
+ "todavía no tengo este chat enlazado",
369
+ "ya recibí tu mensaje",
370
+ "no tengo este chat",
371
+ "[error",
372
+ "[internal",
373
+ "{",
374
+ ]
375
+ if any(phrase in t.lower() for phrase in internal_phrases):
376
+ log.error(f"[sanitize] internal debug message blocked: {t[:80]}")
377
+ return None
378
+ return t if t else None
379
+
380
+
381
+ async def _send_audio(self, chat_id: str, audio_path: str, route: Optional[Dict[str, Any]] = None):
382
+ """Send audio file as voice note via WhatsApp bridge."""
383
+ route = self._resolve_route(chat_id, route)
384
+ try:
385
+ import base64
386
+ with open(audio_path, "rb") as f:
387
+ audio_b64 = base64.b64encode(f.read()).decode()
388
+ async with httpx.AsyncClient(timeout=15.0) as client:
389
+ r = await client.post(
390
+ f"{Config.WHATSAPP_BRIDGE_URL}/send-audio",
391
+ json={"to": chat_id, "audio": audio_b64, "ptt": True},
392
+ )
393
+ if r.status_code in (200, 201, 202):
394
+ log.info(f"[voice] audio sent to {chat_id[:10]}...")
395
+ else:
396
+ log.warning(f"[voice] send failed: {r.status_code}")
397
+ except Exception as e:
398
+ log.debug(f"[voice] send_audio error: {e}")
399
+
400
+ async def _send_message(self, chat_id: str, text: str, route: Optional[Dict[str, Any]] = None):
401
+ """
402
+ Envia mensaje al paciente/admin segun la plataforma configurada.
403
+ Plataformas soportadas: telegram | whatsapp_cloud | evolution | whatsapp
404
+ """
405
+ text = self.sanitize_outgoing(text)
406
+ if not text:
407
+ return
408
+ if chat_id in self._emoji_chats_off:
409
+ text = self._strip_emojis(text)
410
+ text = text.replace('\u00bf', '').replace('\u00a1', '').strip() # ¿ ¡
411
+ if not text:
412
+ return
413
+
414
+ route = self._resolve_route(chat_id, route)
415
+ platform = _route_platform(route)
416
+
417
+ try:
418
+ if platform == "telegram":
419
+ async with httpx.AsyncClient(timeout=15.0) as client:
420
+ await client.post(
421
+ f"https://api.telegram.org/bot{Config.TELEGRAM_TOKEN}/sendMessage",
422
+ json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"}
423
+ )
424
+
425
+ elif platform == "whatsapp_cloud":
426
+ # Meta WhatsApp Cloud API — gratuita hasta 1000 conversaciones/mes
427
+ if not Config.WA_ACCESS_TOKEN or not Config.WA_PHONE_ID:
428
+ log.error("[wa_cloud] WA_ACCESS_TOKEN o WA_PHONE_ID no configurados")
429
+ return
430
+ async with httpx.AsyncClient(timeout=15.0) as client:
431
+ r = await client.post(
432
+ f"https://graph.facebook.com/v19.0/{Config.WA_PHONE_ID}/messages",
433
+ headers={
434
+ "Authorization": f"Bearer {Config.WA_ACCESS_TOKEN}",
435
+ "Content-Type": "application/json",
436
+ },
437
+ json={
438
+ "messaging_product": "whatsapp",
439
+ "recipient_type": "individual",
440
+ "to": chat_id, # numero internacional sin +: 573001234567
441
+ "type": "text",
442
+ "text": {"body": text, "preview_url": False}
443
+ }
444
+ )
445
+ if r.status_code >= 400:
446
+ log.error(f"[wa_cloud] send error {r.status_code}: {r.text[:200]}")
447
+
448
+ elif platform == "evolution":
449
+ # Evolution API — auto-hospedada, conecta WhatsApp Web
450
+ if not Config.EVOLUTION_URL or not Config.EVOLUTION_API_KEY:
451
+ log.error("[evolution] EVOLUTION_URL o EVOLUTION_API_KEY no configurados")
452
+ return
453
+ # Delay proporcional al texto — simula tipeo humano y evita que
454
+ # WhatsApp solo entregue 1 chulo (el delay 0 activa el rate-limit)
455
+ _chars = len(text)
456
+ _human_delay = min(max(int(_chars * 40), 800), 4000) # 40ms/char, 800ms–4s
457
+ async with httpx.AsyncClient(timeout=15.0) as client:
458
+ r = await client.post(
459
+ f"{Config.EVOLUTION_URL}/message/sendText/{Config.EVOLUTION_INSTANCE}",
460
+ headers={
461
+ "apikey": Config.EVOLUTION_API_KEY,
462
+ "Content-Type": "application/json",
463
+ },
464
+ json={
465
+ "number": chat_id, # 573001234567 o 573001234567@s.whatsapp.net
466
+ "text": text,
467
+ "delay": _human_delay
468
+ }
469
+ )
470
+
471
+ elif platform == "whatsapp":
472
+ # Custom WhatsApp Bridge (Baileys)
473
+ if not Config.WHATSAPP_BRIDGE_URL:
474
+ log.error("[wa_bridge] WHATSAPP_BRIDGE_URL no configurado")
475
+ return
476
+ # Retry 1 vez: si el bridge está ocupado o hay un hipo de red
477
+ _last_err = None
478
+ for _attempt in range(2):
479
+ try:
480
+ async with httpx.AsyncClient(timeout=20.0) as client:
481
+ r = await client.post(
482
+ f"{Config.WHATSAPP_BRIDGE_URL}/send",
483
+ json={"to": chat_id, "message": text}
484
+ )
485
+ if r.status_code < 400:
486
+ log.info(f"[wa_bridge] enviado OK ({r.status_code}) intento={_attempt+1}")
487
+ break
488
+ else:
489
+ _last_err = f"HTTP {r.status_code}: {r.text[:200]}"
490
+ log.error(f"[wa_bridge] send error intento={_attempt+1} — {_last_err}")
491
+ if _attempt == 0:
492
+ await asyncio.sleep(2.0) # esperar antes del retry
493
+ except Exception as _e:
494
+ _last_err = str(_e)
495
+ log.error(f"[wa_bridge] send exception intento={_attempt+1}: {_last_err}")
496
+ if _attempt == 0:
497
+ await asyncio.sleep(2.0)
498
+ else:
499
+ log.error(f"[wa_bridge] FALLÓ después de 2 intentos: {_last_err}")
500
+
501
+ else:
502
+ log.error(f"Plataforma desconocida: {platform!r}")
503
+
504
+ except Exception as e:
505
+ log.error(f"[{platform}] send_message error: {e}")
506
+
507
+
508
+ def _strip_emojis(self, text: str) -> str:
509
+ """Elimina todos los emojis."""
510
+ emoji_pattern = re.compile(
511
+ "["
512
+ "\U0001F600-\U0001F64F"
513
+ "\U0001F300-\U0001F5FF"
514
+ "\U0001F680-\U0001F6FF"
515
+ "\U0001F1E0-\U0001F1FF"
516
+ "\U00002500-\U00002BEF"
517
+ "\U00002702-\U000027B0"
518
+ "\U000024C2-\U0001F251"
519
+ "\U0001F900-\U0001F9FF"
520
+ "\U0001FA00-\U0001FA6F"
521
+ "\U0001FA70-\U0001FAFF"
522
+ "\u2600-\u26FF"
523
+ "\u2700-\u27BF"
524
+ "]+",
525
+ flags=re.UNICODE
526
+ )
527
+ return emoji_pattern.sub("", text).strip()
528
+
529
+ async def transcribe_audio(self, file_id: str, platform: str = "telegram",
530
+ wa_media_id: str = None) -> str:
531
+ """Delega transcripción a AudioHandler si está disponible."""
532
+ if _AUDIO_HANDLER_AVAILABLE and hasattr(self, "_audio_handler"):
533
+ return await self._audio_handler.transcribe_audio(file_id, platform, wa_media_id)
534
+ return "[no pude escuchar, puedes escribirlo?]"
535
+
536
+ def is_urgent(self, text: str) -> bool:
537
+ """Detecta si el mensaje es urgente."""
538
+ analysis = self.analyzer.analyze(text)
539
+ return analysis.urgency in [UrgencyLevel.CRITICAL, UrgencyLevel.HIGH]
540
+
541
+ def _analysis_to_dict(self, analysis: MessageAnalysis) -> Dict:
542
+ """Convierte MessageAnalysis a dict JSON-serializable (enums -> strings)."""
543
+ try:
544
+ return {
545
+ "intent": analysis.intent.name,
546
+ "intent_confidence": analysis.intent_confidence,
547
+ "secondary_intents": [(i.name, s) for i, s in (analysis.secondary_intents or [])],
548
+ "sentiment": analysis.sentiment.name,
549
+ "sentiment_score": analysis.sentiment_score,
550
+ "urgency": analysis.urgency.name,
551
+ "emotional_state": analysis.emotional_state,
552
+ "is_question": analysis.is_question,
553
+ "requires_action": analysis.requires_action,
554
+ "requires_search": analysis.requires_search,
555
+ "entities": analysis.entities,
556
+ "keywords": analysis.keywords,
557
+ "language": analysis.language,
558
+ "closing_score": getattr(analysis, "closing_score", 0.0),
559
+ "lead_temperature": getattr(analysis, "lead_temperature", "cold"),
560
+ }
561
+ except Exception:
562
+ return {}
563
+