@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,297 @@
|
|
|
1
|
+
"""Webhook handlers and command routing for all supported platforms."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
# TODO: inject dependency — these constants come from config/session layer
|
|
8
|
+
from conny_config import Config, DEMO_CMD_ALIASES, DEMO_COMMANDS, DEMO_HELP_FULL
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("conny.router")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# -- Platform Detection --------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
def detect_incoming_platform(body: Dict[str, Any]) -> str:
|
|
16
|
+
"""Detect which platform sent the message."""
|
|
17
|
+
if body.get("entry"):
|
|
18
|
+
entry = body["entry"][0] if body["entry"] else {}
|
|
19
|
+
changes = entry.get("changes", [{}])[0] if entry.get("changes") else {}
|
|
20
|
+
value = changes.get("value", {})
|
|
21
|
+
if value.get("messages"):
|
|
22
|
+
return "whatsapp_cloud"
|
|
23
|
+
if value.get("statuses"):
|
|
24
|
+
return "whatsapp_cloud"
|
|
25
|
+
if body.get("message") or body.get("edited_message"):
|
|
26
|
+
return "telegram"
|
|
27
|
+
if body.get("data", {}).get("key", {}).get("fromMe"):
|
|
28
|
+
return "evolution"
|
|
29
|
+
if body.get("iswa"):
|
|
30
|
+
return "whatsapp"
|
|
31
|
+
return "unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# -- Command Detection ---------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def detect_command(text: str) -> Optional[str]:
|
|
37
|
+
"""Detect if text is a command (slash or natural language alias)."""
|
|
38
|
+
if not text:
|
|
39
|
+
return None
|
|
40
|
+
text_norm = text.lower().strip()
|
|
41
|
+
|
|
42
|
+
# Slash commands
|
|
43
|
+
if text_norm.startswith("/"):
|
|
44
|
+
return text_norm
|
|
45
|
+
|
|
46
|
+
# Natural language aliases
|
|
47
|
+
for alias, cmd in DEMO_CMD_ALIASES.items():
|
|
48
|
+
if text_norm == alias or text_norm == "/" + alias:
|
|
49
|
+
return "/" + cmd
|
|
50
|
+
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- Full Command Handler ------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def handle_command(
|
|
57
|
+
chat_id: str,
|
|
58
|
+
text: str,
|
|
59
|
+
demo_sessions: Dict[str, Any],
|
|
60
|
+
send_fn,
|
|
61
|
+
) -> Optional[List[str]]:
|
|
62
|
+
"""
|
|
63
|
+
Handle commands BEFORE session lookup.
|
|
64
|
+
Returns a list of response strings, or None if not a command.
|
|
65
|
+
"""
|
|
66
|
+
cmd = text.strip()
|
|
67
|
+
if not cmd.startswith("/"):
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# /help
|
|
71
|
+
if cmd in ("/help", "/ayuda", "/comandos"):
|
|
72
|
+
bn = demo_sessions.get(chat_id + "_name", "")
|
|
73
|
+
if bn:
|
|
74
|
+
return [DEMO_HELP_FULL]
|
|
75
|
+
return [
|
|
76
|
+
"Comandos: /help | /reset | /bot | /status | /memoria"
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# /reset
|
|
80
|
+
if cmd in ("/reset", "/reiniciar"):
|
|
81
|
+
keys_del = [k for k in list(demo_sessions) if k.startswith(chat_id + "_") and not k.endswith("_ts")]
|
|
82
|
+
for k in keys_del:
|
|
83
|
+
try:
|
|
84
|
+
del demo_sessions[k]
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return ["listo, sesión limpia ||| empezamos de nuevo"]
|
|
88
|
+
|
|
89
|
+
# /status
|
|
90
|
+
if cmd in ("/status", "/estado"):
|
|
91
|
+
bn = demo_sessions.get(chat_id + "_name", "")
|
|
92
|
+
return [f"Estado: {'demo activa' if bn else 'en onboarding'} ||| negocio: {bn or 'sin nombre'}"]
|
|
93
|
+
|
|
94
|
+
# /bot
|
|
95
|
+
if cmd in ("/bot", "/recepcionista"):
|
|
96
|
+
return ["modo recepcionista ||| háblame como cliente y te respondo en contexto"]
|
|
97
|
+
|
|
98
|
+
# /memoria
|
|
99
|
+
if cmd in ("/memoria",):
|
|
100
|
+
return ["no tengo memoria activa todavía ||| en la próxima versión lo tendre"]
|
|
101
|
+
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# -- Webhook Parser: Telegram --------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def parse_telegram_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
108
|
+
"""Parse Telegram webhook body into standard message format."""
|
|
109
|
+
msg = body.get("message") or body.get("edited_message")
|
|
110
|
+
if not msg:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
chat = msg.get("chat") or {}
|
|
114
|
+
chat_id = str(chat.get("id", ""))
|
|
115
|
+
if not chat_id:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
voice = msg.get("voice") or msg.get("audio")
|
|
119
|
+
audio_id = voice.get("file_id") if voice else None
|
|
120
|
+
|
|
121
|
+
document = msg.get("document")
|
|
122
|
+
photos = msg.get("photo") or []
|
|
123
|
+
caption = msg.get("caption", "").strip()
|
|
124
|
+
|
|
125
|
+
attachments = []
|
|
126
|
+
if document:
|
|
127
|
+
attachments.append({
|
|
128
|
+
"kind": "document",
|
|
129
|
+
"platform": "telegram",
|
|
130
|
+
"file_id": document.get("file_id", ""),
|
|
131
|
+
"filename": document.get("file_name", "document.bin"),
|
|
132
|
+
"mime_type": document.get("mime_type", "application/octet-stream"),
|
|
133
|
+
"caption": caption,
|
|
134
|
+
})
|
|
135
|
+
if photos:
|
|
136
|
+
photo = photos[-1]
|
|
137
|
+
attachments.append({
|
|
138
|
+
"kind": "image",
|
|
139
|
+
"platform": "telegram",
|
|
140
|
+
"file_id": photo.get("file_id", ""),
|
|
141
|
+
"filename": f"telegram_photo_{photo.get('file_unique_id', 'image')}.jpg",
|
|
142
|
+
"mime_type": "image/jpeg",
|
|
143
|
+
"caption": caption,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
text = msg.get("text", "").strip() or caption
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"chat_id": chat_id,
|
|
150
|
+
"text": text,
|
|
151
|
+
"audio_id": audio_id,
|
|
152
|
+
"attachments": attachments,
|
|
153
|
+
"platform": "telegram",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# -- Webhook Parser: WhatsApp Cloud -------------------------------------------
|
|
158
|
+
|
|
159
|
+
def parse_whatsapp_cloud_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
160
|
+
"""Parse WhatsApp Cloud API webhook body."""
|
|
161
|
+
try:
|
|
162
|
+
entry = body.get("entry", [{}])[0]
|
|
163
|
+
changes = entry.get("changes", [{}])[0]
|
|
164
|
+
value = changes.get("value", {})
|
|
165
|
+
msgs = value.get("messages", [])
|
|
166
|
+
if not msgs:
|
|
167
|
+
return None
|
|
168
|
+
msg = msgs[0]
|
|
169
|
+
chat_id = msg.get("from", "")
|
|
170
|
+
msg_type = msg.get("type", "text")
|
|
171
|
+
|
|
172
|
+
attachments = []
|
|
173
|
+
text = None
|
|
174
|
+
audio_id = None
|
|
175
|
+
|
|
176
|
+
if msg_type == "text":
|
|
177
|
+
text = msg.get("text", {}).get("body", "").strip()
|
|
178
|
+
elif msg_type in ("audio", "voice"):
|
|
179
|
+
audio_id = msg.get("audio", msg.get("voice", {})).get("id", "")
|
|
180
|
+
elif msg_type == "image":
|
|
181
|
+
attachments.append({
|
|
182
|
+
"kind": "image",
|
|
183
|
+
"platform": "whatsapp_cloud",
|
|
184
|
+
"media_id": msg.get("image", {}).get("id", ""),
|
|
185
|
+
"filename": f"wa_cloud_image_{msg.get('id', 'image')}.jpg",
|
|
186
|
+
"mime_type": msg.get("image", {}).get("mime_type", "image/jpeg"),
|
|
187
|
+
"caption": msg.get("image", {}).get("caption", ""),
|
|
188
|
+
})
|
|
189
|
+
text = msg.get("image", {}).get("caption", "").strip()
|
|
190
|
+
elif msg_type == "document":
|
|
191
|
+
attachments.append({
|
|
192
|
+
"kind": "document",
|
|
193
|
+
"platform": "whatsapp_cloud",
|
|
194
|
+
"media_id": msg.get("document", {}).get("id", ""),
|
|
195
|
+
"filename": msg.get("document", {}).get("filename", f"wa_cloud_{msg.get('id', 'document')}"),
|
|
196
|
+
"mime_type": msg.get("document", {}).get("mime_type", "application/octet-stream"),
|
|
197
|
+
"caption": msg.get("document", {}).get("caption", ""),
|
|
198
|
+
})
|
|
199
|
+
text = msg.get("document", {}).get("caption", "").strip()
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"chat_id": chat_id,
|
|
203
|
+
"text": text,
|
|
204
|
+
"audio_id": audio_id,
|
|
205
|
+
"attachments": attachments,
|
|
206
|
+
"platform": "whatsapp_cloud",
|
|
207
|
+
}
|
|
208
|
+
except (IndexError, KeyError, TypeError):
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# -- Webhook Parser: WhatsApp Bridge ------------------------------------------
|
|
213
|
+
|
|
214
|
+
def parse_whatsapp_bridge_message(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
215
|
+
"""Parse WhatsApp Bridge (Baileys) webhook body."""
|
|
216
|
+
try:
|
|
217
|
+
chat_id = body.get("key", {}).get("remoteJid", "")
|
|
218
|
+
if not chat_id:
|
|
219
|
+
return None
|
|
220
|
+
if chat_id.endswith("@g.us"):
|
|
221
|
+
return None # Skip group chats
|
|
222
|
+
|
|
223
|
+
attachments = []
|
|
224
|
+
text = body.get("message", {}).get("conversationMessage", {}).get("conversation", "").strip()
|
|
225
|
+
|
|
226
|
+
if body.get("isImage") and body.get("imageBase64"):
|
|
227
|
+
attachments.append({
|
|
228
|
+
"kind": "image",
|
|
229
|
+
"platform": "whatsapp",
|
|
230
|
+
"filename": f"wa_bridge_{body.get('messageId', 'image')}.jpg",
|
|
231
|
+
"mime_type": body.get("imageMime", "image/jpeg"),
|
|
232
|
+
"caption": text or "",
|
|
233
|
+
"base64": body.get("imageBase64", ""),
|
|
234
|
+
})
|
|
235
|
+
text = ""
|
|
236
|
+
|
|
237
|
+
if body.get("isDocument") and body.get("docBase64"):
|
|
238
|
+
attachments.append({
|
|
239
|
+
"kind": "document",
|
|
240
|
+
"platform": "whatsapp",
|
|
241
|
+
"filename": body.get("docName", f"wa_doc_{body.get('messageId', 'doc')}"),
|
|
242
|
+
"mime_type": body.get("docMime", "application/octet-stream"),
|
|
243
|
+
"caption": text or "",
|
|
244
|
+
"base64": body.get("docBase64", ""),
|
|
245
|
+
})
|
|
246
|
+
text = ""
|
|
247
|
+
|
|
248
|
+
audio_id = None
|
|
249
|
+
if body.get("isAudio") and body.get("audioBase64"):
|
|
250
|
+
b64_mime = body.get("audioMime", "audio/ogg")
|
|
251
|
+
audio_id = f"wa_b64:{b64_mime}:{body['audioBase64']}"
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"chat_id": chat_id,
|
|
255
|
+
"text": text,
|
|
256
|
+
"audio_id": audio_id,
|
|
257
|
+
"attachments": attachments,
|
|
258
|
+
"platform": "whatsapp",
|
|
259
|
+
}
|
|
260
|
+
except Exception:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# -- Full Webhook Parser -------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def parse_webhook(body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
267
|
+
"""Parse any webhook body into standard format."""
|
|
268
|
+
platform = detect_incoming_platform(body)
|
|
269
|
+
|
|
270
|
+
if platform == "telegram":
|
|
271
|
+
return parse_telegram_message(body)
|
|
272
|
+
elif platform == "whatsapp_cloud":
|
|
273
|
+
return parse_whatsapp_cloud_message(body)
|
|
274
|
+
elif platform == "whatsapp":
|
|
275
|
+
return parse_whatsapp_bridge_message(body)
|
|
276
|
+
elif platform == "evolution":
|
|
277
|
+
try:
|
|
278
|
+
data = body.get("data", {})
|
|
279
|
+
key = data.get("key", {})
|
|
280
|
+
if key.get("fromMe", False):
|
|
281
|
+
return None
|
|
282
|
+
chat_id = key.get("remoteJid", "")
|
|
283
|
+
msg_data = data.get("message", {})
|
|
284
|
+
conv = msg_data.get("conversationMessage", {}).get("conversation", "").strip()
|
|
285
|
+
ext = msg_data.get("extendedTextMessage", {})
|
|
286
|
+
text = conv or ext.get("text", "").strip() or ""
|
|
287
|
+
return {
|
|
288
|
+
"chat_id": chat_id,
|
|
289
|
+
"text": text,
|
|
290
|
+
"audio_id": None,
|
|
291
|
+
"attachments": [],
|
|
292
|
+
"platform": "evolution",
|
|
293
|
+
}
|
|
294
|
+
except Exception:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
return None
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Demo session management: state storage, key generation, TTL cleanup, language detection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
# TODO: inject dependency
|
|
8
|
+
# from conny_config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _DefaultConfig:
|
|
12
|
+
"""Fallback config when dependency is not injected."""
|
|
13
|
+
DEMO_SESSION_TTL = 1800
|
|
14
|
+
DEMO_MODE = False
|
|
15
|
+
DEMO_BUSINESS_NAME = "tu negocio"
|
|
16
|
+
DEMO_SECTOR = "estetica"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Config = _DefaultConfig
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SessionManager:
|
|
23
|
+
"""
|
|
24
|
+
Gestor de sesiones demo para Conny Ultra.
|
|
25
|
+
|
|
26
|
+
Maneja el estado de sesiones demo de usuarios, incluyendo:
|
|
27
|
+
- Nombre del negocio (bname_key)
|
|
28
|
+
- Contexto/descripcion del negocio (bctx_key)
|
|
29
|
+
- Estado de busqueda web (bfound_key, burl_key)
|
|
30
|
+
- Tracking de tricks/demo commands (btrick_key)
|
|
31
|
+
- Persona/archetype (bpersona_key)
|
|
32
|
+
- Tono detectado (btone_key)
|
|
33
|
+
- Modelo LLM preferido (bmodel_key)
|
|
34
|
+
- Idioma del owner (blang_key)
|
|
35
|
+
- Modo aprendizaje (blearn_key)
|
|
36
|
+
- Modo simulacion (bsim_key)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, sessions_dict: Dict[str, float] = None, emoji_chats_off: set = None):
|
|
40
|
+
self._demo_sessions: Dict[str, float] = sessions_dict if sessions_dict is not None else {}
|
|
41
|
+
self._emoji_chats_off: set = emoji_chats_off if emoji_chats_off is not None else set()
|
|
42
|
+
|
|
43
|
+
def is_demo_mode_active(self) -> bool:
|
|
44
|
+
"""Verifica si hay sesiones demo activas."""
|
|
45
|
+
now = time.time()
|
|
46
|
+
return any(
|
|
47
|
+
k.endswith("_ts") and (now - v) < Config.DEMO_SESSION_TTL
|
|
48
|
+
for k, v in self._demo_sessions.items()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def get_demo_session(self, chat_id: str) -> Dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Obtiene todos los valores de sesion para un chat_id.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
chat_id: Identificador del chat
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Dict con todas las claves de sesion del demo
|
|
60
|
+
"""
|
|
61
|
+
sk = f"demo_{chat_id}"
|
|
62
|
+
keys = [
|
|
63
|
+
f"{sk}_name", f"{sk}_ctx", f"{sk}_found", f"{sk}_url",
|
|
64
|
+
f"{sk}_trick", f"{sk}_persona", f"{sk}_tone", f"{sk}_model",
|
|
65
|
+
f"{sk}_owner_lang", f"{sk}_learn", f"{sk}_sim_mode"
|
|
66
|
+
]
|
|
67
|
+
return {k.replace(sk + "_", ""): self._demo_sessions.get(k) for k in keys}
|
|
68
|
+
|
|
69
|
+
def set_demo_session(self, chat_id: str, key: str, value: Any) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Establece un valor en la sesion demo.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
chat_id: Identificador del chat
|
|
75
|
+
key: Clave (sin prefijo demo_{chat_id}_)
|
|
76
|
+
value: Valor a almacenar
|
|
77
|
+
"""
|
|
78
|
+
sk = f"demo_{chat_id}"
|
|
79
|
+
self._demo_sessions[f"{sk}_{key}"] = value
|
|
80
|
+
if key == "ts":
|
|
81
|
+
self._touch_session(chat_id)
|
|
82
|
+
|
|
83
|
+
def clear_demo_session(self, chat_id: str) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Limpia todos los datos de sesion para un chat_id.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
chat_id: Identificador del chat
|
|
89
|
+
"""
|
|
90
|
+
sk = f"demo_{chat_id}"
|
|
91
|
+
keys_to_delete = [k for k in list(self._demo_sessions) if k.startswith(sk + "_")]
|
|
92
|
+
for k in keys_to_delete:
|
|
93
|
+
del self._demo_sessions[k]
|
|
94
|
+
|
|
95
|
+
def _touch_session(self, chat_id: str) -> None:
|
|
96
|
+
"""Actualiza el timestamp de la sesion."""
|
|
97
|
+
sk = f"demo_{chat_id}"
|
|
98
|
+
self._demo_sessions[f"{sk}_ts"] = time.time()
|
|
99
|
+
|
|
100
|
+
def cleanup_expired_sessions(self) -> int:
|
|
101
|
+
"""
|
|
102
|
+
Limpia sesiones expiradas basandose en TTL.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Numero de sesiones limpiadas
|
|
106
|
+
"""
|
|
107
|
+
now = time.time()
|
|
108
|
+
ttl = Config.DEMO_SESSION_TTL
|
|
109
|
+
|
|
110
|
+
expired_keys = []
|
|
111
|
+
for k, v in self._demo_sessions.items():
|
|
112
|
+
if k.endswith("_ts") and (now - v) > ttl * 2:
|
|
113
|
+
expired_keys.append(k)
|
|
114
|
+
|
|
115
|
+
for k in expired_keys:
|
|
116
|
+
chat_id = k.replace("_ts", "").replace("demo_", "")
|
|
117
|
+
self.clear_demo_session(chat_id)
|
|
118
|
+
|
|
119
|
+
return len(expired_keys)
|
|
120
|
+
|
|
121
|
+
def generate_session_keys(self, chat_id: str) -> Dict[str, str]:
|
|
122
|
+
"""
|
|
123
|
+
Genera todas las claves de sesion para un chat_id.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
chat_id: Identificador del chat
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Dict con todas las claves de sesion generadas
|
|
130
|
+
"""
|
|
131
|
+
sk = f"demo_{chat_id}"
|
|
132
|
+
return {
|
|
133
|
+
"bname_key": sk + "_name",
|
|
134
|
+
"bctx_key": sk + "_ctx",
|
|
135
|
+
"bfound_key": sk + "_found",
|
|
136
|
+
"burl_key": sk + "_url",
|
|
137
|
+
"btrick_key": sk + "_trick",
|
|
138
|
+
"bpersona_key": sk + "_persona",
|
|
139
|
+
"btone_key": sk + "_tone",
|
|
140
|
+
"bmodel_key": sk + "_model",
|
|
141
|
+
"blang_key": sk + "_owner_lang",
|
|
142
|
+
"blearn_key": sk + "_learn",
|
|
143
|
+
"bsim_key": sk + "_sim_mode",
|
|
144
|
+
"sk": sk,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def get_session_value(self, chat_id: str, key: str, default: Any = None) -> Any:
|
|
148
|
+
"""Obtiene un valor especifico de la sesion."""
|
|
149
|
+
sk = f"demo_{chat_id}"
|
|
150
|
+
return self._demo_sessions.get(f"{sk}_{key}", default)
|
|
151
|
+
|
|
152
|
+
def set_session_value(self, chat_id: str, key: str, value: Any) -> None:
|
|
153
|
+
"""Establece un valor especifico en la sesion."""
|
|
154
|
+
sk = f"demo_{chat_id}"
|
|
155
|
+
self._demo_sessions[f"{sk}_{key}"] = value
|
|
156
|
+
self._touch_session(chat_id)
|
|
157
|
+
|
|
158
|
+
def set_timestamp(self, chat_id: str) -> None:
|
|
159
|
+
"""Actualiza el timestamp de la sesion sin setear un valor de clave."""
|
|
160
|
+
self._touch_session(chat_id)
|
|
161
|
+
|
|
162
|
+
def touch_and_cleanup(self, chat_id: str) -> Tuple[bool, List[str]]:
|
|
163
|
+
"""
|
|
164
|
+
Toca el timestamp y limpia sesiones expiradas.
|
|
165
|
+
Retorna (is_new, keys_to_delete) donde keys_to_delete son las claves
|
|
166
|
+
que deben borrarse si is_new=True.
|
|
167
|
+
"""
|
|
168
|
+
now = time.time()
|
|
169
|
+
ttl = Config.DEMO_SESSION_TTL
|
|
170
|
+
sk = f"demo_{chat_id}"
|
|
171
|
+
last_seen = self._demo_sessions.get(sk + "_ts", 0)
|
|
172
|
+
is_new = (now - last_seen) > ttl
|
|
173
|
+
self._demo_sessions[sk + "_ts"] = now
|
|
174
|
+
|
|
175
|
+
self._demo_sessions = {
|
|
176
|
+
k: v for k, v in self._demo_sessions.items()
|
|
177
|
+
if not k.endswith("_ts") or (now - v) < ttl * 2
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if is_new:
|
|
181
|
+
keys_to_delete = [k for k in list(self._demo_sessions)
|
|
182
|
+
if k.startswith(sk + "_") and not k.endswith("_ts")]
|
|
183
|
+
else:
|
|
184
|
+
keys_to_delete = []
|
|
185
|
+
return is_new, keys_to_delete
|
|
186
|
+
|
|
187
|
+
def is_session_new(self, chat_id: str) -> bool:
|
|
188
|
+
"""Determina si la sesion es nueva (expiro el TTL)."""
|
|
189
|
+
sk = f"demo_{chat_id}"
|
|
190
|
+
now = time.time()
|
|
191
|
+
last_seen = self._demo_sessions.get(sk + "_ts", 0)
|
|
192
|
+
return (now - last_seen) > Config.DEMO_SESSION_TTL
|
|
193
|
+
|
|
194
|
+
def reset_session(self, chat_id: str) -> None:
|
|
195
|
+
"""Resetea completamente la sesion (para expiracion)."""
|
|
196
|
+
sk = f"demo_{chat_id}"
|
|
197
|
+
keys_del = [k for k in list(self._demo_sessions)
|
|
198
|
+
if k.startswith(sk + "_") and not k.endswith("_ts")]
|
|
199
|
+
for k in keys_del:
|
|
200
|
+
del self._demo_sessions[k]
|
|
201
|
+
self._touch_session(chat_id)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _detect_demo_owner_language(raw_text: str, current_lang: str = "es") -> str:
|
|
205
|
+
"""
|
|
206
|
+
Detecta el idioma del owner en modo demo basandose en senales explicitas.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
raw_text: Mensaje original del usuario
|
|
210
|
+
current_lang: Idioma actual detectado
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Codigo de idioma ('es', 'en', 'pt')
|
|
214
|
+
"""
|
|
215
|
+
# TODO: inject dependency
|
|
216
|
+
# from conny_helpers import _normalize_conv_text
|
|
217
|
+
def _normalize_conv_text(s):
|
|
218
|
+
return s.lower().strip() if s else ""
|
|
219
|
+
|
|
220
|
+
normalized = _normalize_conv_text(raw_text or "")
|
|
221
|
+
if not normalized:
|
|
222
|
+
return current_lang or "es"
|
|
223
|
+
|
|
224
|
+
explicit_en = (
|
|
225
|
+
"just english sorry", "sorry just english", "english sorry",
|
|
226
|
+
"english only", "speak english", "speak in english",
|
|
227
|
+
"i dont speak spanish", "i don t speak spanish",
|
|
228
|
+
"i dont talk spanish", "i don t talk spanish",
|
|
229
|
+
"no spanish", "only english", "what is this", "sorry what is this",
|
|
230
|
+
"i dont understand", "i don t understand",
|
|
231
|
+
"what did you say", "what did u say",
|
|
232
|
+
"thats not my business", "that s not my business",
|
|
233
|
+
"thats not us", "that s not us",
|
|
234
|
+
"wrong business", "wrong company",
|
|
235
|
+
)
|
|
236
|
+
explicit_pt = (
|
|
237
|
+
"so portugues", "so portugues", "falo portugues",
|
|
238
|
+
"nao falo espanhol", "nao falo espanhol",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if any(token in normalized for token in explicit_en):
|
|
242
|
+
return "en"
|
|
243
|
+
if any(token in normalized for token in explicit_pt):
|
|
244
|
+
return "pt"
|
|
245
|
+
|
|
246
|
+
# TODO: inject dependency
|
|
247
|
+
# from multilingual import MultilingualHandler
|
|
248
|
+
# detected = MultilingualHandler().detect(raw_text)
|
|
249
|
+
detected = "es"
|
|
250
|
+
|
|
251
|
+
if current_lang in {"en", "pt"} and detected == "es" and len(normalized.split()) <= 6:
|
|
252
|
+
return current_lang
|
|
253
|
+
|
|
254
|
+
return detected if detected in {"es", "en", "pt"} else (current_lang or "es")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _owner_confusion_or_language_signal(raw_text: str) -> bool:
|
|
258
|
+
"""
|
|
259
|
+
Detecta senales de confusion del owner o cambio de idioma.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
raw_text: Mensaje del usuario
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
True si detecta senal de confusion/language switch
|
|
266
|
+
"""
|
|
267
|
+
# TODO: inject dependency
|
|
268
|
+
# from conny_helpers import _normalize_conv_text
|
|
269
|
+
def _normalize_conv_text(s):
|
|
270
|
+
return s.lower().strip() if s else ""
|
|
271
|
+
|
|
272
|
+
normalized = _normalize_conv_text(raw_text or "")
|
|
273
|
+
if not normalized:
|
|
274
|
+
return False
|
|
275
|
+
|
|
276
|
+
signals = (
|
|
277
|
+
"just english sorry", "sorry just english", "english sorry",
|
|
278
|
+
"english only", "speak english", "speak in english", "only english",
|
|
279
|
+
"i dont speak spanish", "i don t speak spanish",
|
|
280
|
+
"i dont talk spanish", "i don t talk spanish",
|
|
281
|
+
"what is this", "sorry what is this",
|
|
282
|
+
"i dont understand", "i don t understand",
|
|
283
|
+
"what did you say", "what did u say",
|
|
284
|
+
"thats not my business", "that s not my business",
|
|
285
|
+
"that is not my business", "not my business",
|
|
286
|
+
"thats not us", "that s not us", "that is not us",
|
|
287
|
+
"wrong business", "wrong company", "wrong one", "not the right one",
|
|
288
|
+
"no hablo espanol", "no hablo espanol",
|
|
289
|
+
"solo ingles", "solo ingles",
|
|
290
|
+
)
|
|
291
|
+
return any(signal in normalized for signal in signals)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _lang_text(es_text: str, en_text: str, pt_text: Optional[str] = None,
|
|
295
|
+
owner_lang: str = "es") -> str:
|
|
296
|
+
"""
|
|
297
|
+
Retorna texto segun el idioma del owner.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
es_text: Texto en espanol
|
|
301
|
+
en_text: Texto en ingles
|
|
302
|
+
pt_text: Texto en portugues (opcional)
|
|
303
|
+
owner_lang: Idioma del owner
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Texto en el idioma apropiado
|
|
307
|
+
"""
|
|
308
|
+
if owner_lang == "en":
|
|
309
|
+
return en_text
|
|
310
|
+
if owner_lang == "pt" and pt_text is not None:
|
|
311
|
+
return pt_text
|
|
312
|
+
return es_text
|
|
File without changes
|