@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,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