@innvisor/conny-ai 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
|
@@ -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
|
+
|