@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,447 @@
1
+ """
2
+ Módulo de generación LLM para Conny Ultra.
3
+
4
+ Contiene la lógica de generación de respuestas:
5
+ - _llm(): Generación básica
6
+ - _llm_conv_pitch(): Generación con pitch para prospectos confuse
7
+ - _llm_conv(): Generación conversacional con historial
8
+ - _demo_llm_conv_quality_chain(): Chain con validación y repair
9
+ - _demo_llm_quality_chain(): Chain de calidad con retries
10
+
11
+ Este módulo fue extraído de conny.py para reducir su tamaño y mejorar mantenibilidad.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Any, Callable, Dict, List, Optional, Tuple
17
+
18
+ try:
19
+ from conny_config import Config
20
+ except ImportError:
21
+ class Config:
22
+ GROQ_API_KEY = ""
23
+ GEMINI_API_KEY = ""
24
+ GEMINI_API_KEY_2 = ""
25
+ GEMINI_API_KEY_3 = ""
26
+ OPENROUTER_API_KEY = ""
27
+
28
+
29
+ class GeneratorManager:
30
+ """
31
+ Gestor de generación LLM para Conny Ultra.
32
+
33
+ Proporciona métodos para:
34
+ - Generación básica de texto
35
+ - Generación conversacional con historial
36
+ - Generación con pitch especializado
37
+ - Chains de calidad con validación y retry
38
+ """
39
+
40
+ def __init__(self, llm_engine=None, generator=None):
41
+ self._llm_engine = llm_engine
42
+ self._generator = generator
43
+
44
+ def _get_demo_engine(self, demo_model_pref: str = "auto", bmodel_key: str = ""):
45
+ """
46
+ Devuelve el proveedor LLM según preferencia de sesión.
47
+
48
+ Formatos soportados:
49
+ auto → engine global
50
+ gemini → GeminiProvider con gemini-2.5-flash
51
+ gemini:gemini-2.5-pro → GeminiProvider con modelo específico
52
+ groq → GroqProvider con llama-3.3-70b-versatile
53
+ groq:llama-3.1-8b-instant → GroqProvider con modelo específico
54
+ openrouter → OpenRouterProvider
55
+ openrouter:anthropic/claude-sonnet-4 → OpenRouter con modelo específico
56
+ """
57
+ from llm_engine import (
58
+ GeminiProvider, GroqProvider, OpenRouterProvider
59
+ )
60
+
61
+ pref = demo_model_pref
62
+
63
+ if ":" in pref:
64
+ provider, model_name = pref.split(":", 1)
65
+ else:
66
+ provider, model_name = pref, None
67
+
68
+ if provider == "groq" and Config.GROQ_API_KEY:
69
+ eng = GroqProvider(Config.GROQ_API_KEY)
70
+ if model_name:
71
+ eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
72
+ return eng
73
+
74
+ elif provider == "gemini":
75
+ key = Config.GEMINI_API_KEY or Config.GEMINI_API_KEY_2 or Config.GEMINI_API_KEY_3
76
+ if key:
77
+ eng = GeminiProvider(key, "gemini_demo")
78
+ if model_name:
79
+ eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
80
+ else:
81
+ eng.MDLS = {
82
+ "reasoning": "gemini-2.5-flash",
83
+ "fast": "gemini-2.5-flash",
84
+ "lite": "gemini-2.5-flash-lite"
85
+ }
86
+ return eng
87
+ if Config.OPENROUTER_API_KEY:
88
+ eng = OpenRouterProvider(Config.OPENROUTER_API_KEY)
89
+ m = model_name or "google/gemini-2.5-flash"
90
+ eng.MDLS = {"reasoning": m, "fast": m, "lite": m}
91
+ return eng
92
+
93
+ elif provider == "openrouter" and Config.OPENROUTER_API_KEY:
94
+ eng = OpenRouterProvider(Config.OPENROUTER_API_KEY)
95
+ if model_name:
96
+ eng.MDLS = {"reasoning": model_name, "fast": model_name, "lite": model_name}
97
+ return eng
98
+
99
+ return self._llm_engine or (self._generator.llm if self._generator else None)
100
+
101
+ async def _llm(
102
+ self,
103
+ sys_p: str,
104
+ usr_p: str,
105
+ temp: float = 0.82,
106
+ max_t: int = 8192,
107
+ model_tier: str = "fast",
108
+ demo_model_pref: str = "auto"
109
+ ) -> Optional[str]:
110
+ """
111
+ Generación básica de texto con LLM.
112
+
113
+ Args:
114
+ sys_p: Prompt del sistema
115
+ usr_p: Prompt del usuario
116
+ temp: Temperatura de generación
117
+ max_t: Máximo de tokens de salida
118
+ model_tier: Tier del modelo (fast, reasoning, lite)
119
+ demo_model_pref: Preferencia de modelo demo
120
+
121
+ Returns:
122
+ Texto generado o None en caso de error
123
+ """
124
+ import logging
125
+ log = logging.getLogger("conny_generator")
126
+
127
+ msgs = [{"role": "system", "content": sys_p}, {"role": "user", "content": usr_p}]
128
+
129
+ try:
130
+ eng = self._get_demo_engine(demo_model_pref)
131
+ if not eng:
132
+ raise RuntimeError("LLM no init")
133
+
134
+ r, meta = await eng.complete(
135
+ msgs,
136
+ model_tier=model_tier,
137
+ temperature=temp,
138
+ max_tokens=max_t,
139
+ use_cache=False,
140
+ )
141
+
142
+ log.info(f"[demo] {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
143
+
144
+ if self._generator:
145
+ return self._generator._postprocess(r, self._generator.PersonalityProfile())
146
+ return r
147
+
148
+ except Exception as e:
149
+ log.error(f"[demo] llm error: {e}")
150
+ return None
151
+
152
+ async def _llm_conv_pitch(
153
+ self,
154
+ pitch_sys: str,
155
+ history: List[Dict[str, Any]],
156
+ text: str,
157
+ temp: float = 0.85,
158
+ max_t: int = 8192,
159
+ recent_limit: int = 12,
160
+ demo_model_pref: str = "auto"
161
+ ) -> Optional[str]:
162
+ """
163
+ LLM con el pitch de Black One para prospectos confundidos.
164
+
165
+ Args:
166
+ pitch_sys: Prompt de sistema con el pitch
167
+ history: Historial de conversación
168
+ text: Mensaje actual
169
+ temp: Temperatura
170
+ max_t: Máximo de tokens
171
+ recent_limit: Límite de mensajes recientes a incluir
172
+ demo_model_pref: Preferencia de modelo
173
+
174
+ Returns:
175
+ Texto generado o None
176
+ """
177
+ import logging
178
+ log = logging.getLogger("conny_generator")
179
+
180
+ msgs = [{"role": "system", "content": pitch_sys}]
181
+ for m in history[-recent_limit:]:
182
+ msgs.append({"role": m["role"], "content": m["content"]})
183
+ msgs.append({"role": "user", "content": text})
184
+
185
+ try:
186
+ eng = self._get_demo_engine(demo_model_pref)
187
+ if not eng:
188
+ raise RuntimeError("LLM no init")
189
+
190
+ r, meta = await eng.complete(
191
+ msgs,
192
+ model_tier="fast",
193
+ temperature=temp,
194
+ max_tokens=max_t,
195
+ use_cache=False
196
+ )
197
+
198
+ log.info(f"[demo][pitch] {meta.get('provider','?')}")
199
+
200
+ if self._generator:
201
+ return self._generator._postprocess(r, self._generator.PersonalityProfile())
202
+ return r
203
+
204
+ except Exception as e:
205
+ log.error(f"[demo][pitch] error: {e}")
206
+ return None
207
+
208
+ async def _llm_conv(
209
+ self,
210
+ sys_p: str,
211
+ history: List[Dict[str, Any]],
212
+ text: str,
213
+ temp: float = 0.85,
214
+ max_t: int = 8192,
215
+ model_tier: str = "fast",
216
+ recent_limit: int = 12,
217
+ demo_model_pref: str = "auto"
218
+ ) -> Optional[str]:
219
+ """
220
+ Generación conversacional con historial.
221
+
222
+ Args:
223
+ sys_p: Prompt del sistema
224
+ history: Historial de conversación
225
+ text: Mensaje actual
226
+ temp: Temperatura
227
+ max_t: Máximo de tokens
228
+ model_tier: Tier del modelo
229
+ recent_limit: Límite de mensajes recientes
230
+ demo_model_pref: Preferencia de modelo
231
+
232
+ Returns:
233
+ Texto generado o None
234
+ """
235
+ import logging
236
+ log = logging.getLogger("conny_generator")
237
+
238
+ msgs = [{"role": "system", "content": sys_p}]
239
+ for m in history[-recent_limit:]:
240
+ msgs.append({"role": m["role"], "content": m["content"]})
241
+ msgs.append({"role": "user", "content": text})
242
+
243
+ try:
244
+ eng = self._get_demo_engine(demo_model_pref)
245
+ if not eng:
246
+ raise RuntimeError("LLM no init")
247
+
248
+ r, meta = await eng.complete(
249
+ msgs,
250
+ model_tier=model_tier,
251
+ temperature=temp,
252
+ max_tokens=max_t,
253
+ use_cache=False,
254
+ )
255
+
256
+ log.info(f"[demo] {meta.get('provider','?')} model={meta.get('model','?')[:30]}")
257
+
258
+ if self._generator:
259
+ return self._generator._postprocess(r, self._generator.PersonalityProfile())
260
+ return r
261
+
262
+ except Exception as e:
263
+ log.error(f"[demo] llm_conv error: {e}")
264
+ return None
265
+
266
+ async def _demo_llm_conv_quality_chain(
267
+ self,
268
+ system_prompt: str,
269
+ validator: Callable[[str], bool],
270
+ repair_instructions: str,
271
+ history: List[Dict[str, Any]],
272
+ text: str,
273
+ temp: float = 0.72,
274
+ max_t: int = 8192,
275
+ model_tier: str = "fast",
276
+ recent_limit: int = 8,
277
+ demo_model_pref: str = "auto"
278
+ ) -> Tuple[Optional[str], bool]:
279
+ """
280
+ Chain de calidad con validación y repair para conversaciones demo.
281
+
282
+ Intenta primero la generación normal, si falla la validación
283
+ reintenta con instrucciones de repair.
284
+
285
+ Args:
286
+ system_prompt: Prompt del sistema
287
+ validator: Función que valida la respuesta
288
+ repair_instructions: Instrucciones para repair si falla validación
289
+ history: Historial de conversación
290
+ text: Mensaje actual
291
+ temp: Temperatura
292
+ max_t: Máximo de tokens
293
+ model_tier: Tier del modelo
294
+ recent_limit: Límite de mensajes recientes
295
+ demo_model_pref: Preferencia de modelo
296
+
297
+ Returns:
298
+ Tuple (respuesta_valida o None, tuvo_output)
299
+ """
300
+ import logging
301
+ log = logging.getLogger("conny_generator")
302
+
303
+ _chain_start = time.time()
304
+ _CHAIN_TIMEOUT_S = 45 # Timeout entre intentos
305
+
306
+ attempts = [
307
+ (system_prompt, temp, max_t, model_tier, recent_limit),
308
+ (
309
+ system_prompt
310
+ + "\n\nREPARA LA RESPUESTA:\n"
311
+ + repair_instructions.strip()
312
+ + "\n- no repitas introducciones\n- no suenes a bot ni a guion de demo",
313
+ 0.58,
314
+ max_t,
315
+ "reasoning",
316
+ recent_limit,
317
+ ),
318
+ ]
319
+
320
+ had_output = False
321
+
322
+ for prompt_now, temp_now, max_now, tier_now, limit_now in attempts:
323
+ if time.time() - _chain_start > _CHAIN_TIMEOUT_S:
324
+ log.warning("[demo] conv_quality_chain abortada por timeout (%ds)", _CHAIN_TIMEOUT_S)
325
+ break
326
+
327
+ candidate = await self._llm_conv(
328
+ prompt_now,
329
+ history=history,
330
+ text=text,
331
+ temp=temp_now,
332
+ max_t=max_now,
333
+ model_tier=tier_now,
334
+ recent_limit=limit_now,
335
+ demo_model_pref=demo_model_pref,
336
+ )
337
+
338
+ if candidate and candidate.strip():
339
+ had_output = True
340
+ if validator(candidate):
341
+ return candidate, True
342
+
343
+ return None, had_output
344
+
345
+ async def _demo_llm_quality_chain(
346
+ self,
347
+ system_prompt: str,
348
+ validator: Callable[[str], bool],
349
+ repair_instructions: str,
350
+ user_message: str,
351
+ temp: float = 0.72,
352
+ max_t: int = 8192,
353
+ model_tier: str = "fast",
354
+ demo_model_pref: str = "auto"
355
+ ) -> Tuple[Optional[str], bool]:
356
+ """
357
+ Chain de calidad simple (sin historial) para generación demo.
358
+
359
+ Args:
360
+ system_prompt: Prompt del sistema
361
+ validator: Función que valida la respuesta
362
+ repair_instructions: Instrucciones para repair
363
+ user_message: Mensaje del usuario
364
+ temp: Temperatura
365
+ max_t: Máximo de tokens
366
+ model_tier: Tier del modelo
367
+ demo_model_pref: Preferencia de modelo
368
+
369
+ Returns:
370
+ Tuple (respuesta o None, tuvo_output)
371
+ """
372
+ import logging
373
+ log = logging.getLogger("conny_generator")
374
+
375
+ _chain_start = time.time()
376
+ _CHAIN_TIMEOUT_S = 45
377
+
378
+ attempts = [
379
+ (system_prompt, temp, max_t, model_tier),
380
+ (
381
+ system_prompt
382
+ + "\n\nREPARA LA RESPUESTA:\n"
383
+ + repair_instructions.strip()
384
+ + "\n- no repitas introducciones\n- no suenes a bot",
385
+ 0.58,
386
+ max_t,
387
+ "reasoning",
388
+ ),
389
+ ]
390
+
391
+ had_output = False
392
+
393
+ for prompt_now, temp_now, max_now, tier_now in attempts:
394
+ if time.time() - _chain_start > _CHAIN_TIMEOUT_S:
395
+ log.warning("[demo] quality_chain abortada por timeout")
396
+ break
397
+
398
+ candidate = await self._llm(
399
+ prompt_now,
400
+ user_message,
401
+ temp=temp_now,
402
+ max_t=max_now,
403
+ model_tier=tier_now,
404
+ demo_model_pref=demo_model_pref,
405
+ )
406
+
407
+ if candidate and candidate.strip():
408
+ had_output = True
409
+ if validator(candidate):
410
+ return candidate, True
411
+
412
+ return None, had_output
413
+
414
+
415
+ def estimate_tokens(text: str) -> int:
416
+ """
417
+ Estima el número de tokens en un texto.
418
+
419
+ Args:
420
+ text: Texto a estimar
421
+
422
+ Returns:
423
+ Estimación de tokens (approx 4 chars por token)
424
+ """
425
+ return len(text) // 4
426
+
427
+
428
+ def budget_tokens(
429
+ system_prompt: str,
430
+ max_t: int = 8192,
431
+ safety_margin: float = 0.9
432
+ ) -> Tuple[int, int]:
433
+ """
434
+ Calcula presupuesto de tokens para generación.
435
+
436
+ Args:
437
+ system_prompt: Prompt del sistema
438
+ max_t: Máximo de tokens disponibles
439
+ safety_margin: Margen de seguridad (0.0-1.0)
440
+
441
+ Returns:
442
+ Tuple (tokens_disponibles_output, tokens_reservados)
443
+ """
444
+ system_tokens = estimate_tokens(system_prompt)
445
+ available = int(max_t * safety_margin)
446
+ output_budget = max(available - system_tokens, 512)
447
+ return output_budget, system_tokens
@@ -0,0 +1,159 @@
1
+ """conny_google_auth.py — Google OAuth2 flow via WhatsApp chat.
2
+ OpenClaw-inspired: credentials live in vault files, NOT in .env.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+ from urllib.parse import urlencode
12
+
13
+ import httpx
14
+
15
+ log = logging.getLogger("conny.google_auth")
16
+
17
+ SCOPES = [
18
+ "https://www.googleapis.com/auth/calendar",
19
+ "https://www.googleapis.com/auth/calendar.events",
20
+ ]
21
+
22
+ VAULT_DIR = Path("integrations/vault")
23
+
24
+
25
+ def _load_google_creds(instance_id: str = "default") -> Dict[str, str]:
26
+ """Load Google credentials from vault (OpenClaw pattern: vault > env)."""
27
+ # Priority 1: Vault file (uploaded by admin)
28
+ creds_file = VAULT_DIR / instance_id / "google_credentials.json"
29
+ if creds_file.exists():
30
+ try:
31
+ data = json.loads(creds_file.read_text())
32
+ inner = data.get("installed") or data.get("web") or data
33
+ return {
34
+ "client_id": inner.get("client_id", ""),
35
+ "client_secret": inner.get("client_secret", ""),
36
+ "redirect_uri": inner.get("redirect_uris", ["urn:ietf:wg:oauth:2.0:oob"])[0],
37
+ }
38
+ except Exception:
39
+ pass
40
+
41
+ # Priority 2: Environment variables (fallback)
42
+ return {
43
+ "client_id": os.getenv("GOOGLE_CLIENT_ID", ""),
44
+ "client_secret": os.getenv("GOOGLE_CLIENT_SECRET", ""),
45
+ "redirect_uri": os.getenv("GOOGLE_REDIRECT_URI", "urn:ietf:wg:oauth:2.0:oob"),
46
+ }
47
+
48
+
49
+ def get_oauth_url(instance_id: str = "default") -> Optional[str]:
50
+ """Generate Google OAuth2 consent URL."""
51
+ creds = _load_google_creds(instance_id)
52
+ if not creds["client_id"]:
53
+ return None
54
+
55
+ params = {
56
+ "client_id": creds["client_id"],
57
+ "redirect_uri": creds["redirect_uri"],
58
+ "response_type": "code",
59
+ "scope": " ".join(SCOPES),
60
+ "access_type": "offline",
61
+ "prompt": "consent",
62
+ "state": instance_id,
63
+ }
64
+ return f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
65
+
66
+
67
+ async def exchange_code_for_tokens(code: str, instance_id: str = "default") -> Optional[Dict]:
68
+ """Exchange authorization code for access + refresh tokens."""
69
+ creds = _load_google_creds(instance_id)
70
+ if not creds["client_id"] or not creds["client_secret"]:
71
+ return None
72
+
73
+ async with httpx.AsyncClient(timeout=15.0) as client:
74
+ r = await client.post(
75
+ "https://oauth2.googleapis.com/token",
76
+ data={
77
+ "code": code.strip(),
78
+ "client_id": creds["client_id"],
79
+ "client_secret": creds["client_secret"],
80
+ "redirect_uri": creds["redirect_uri"],
81
+ "grant_type": "authorization_code",
82
+ },
83
+ )
84
+ if r.status_code == 200:
85
+ tokens = r.json()
86
+ tokens_dir = VAULT_DIR / instance_id
87
+ tokens_dir.mkdir(parents=True, exist_ok=True)
88
+ (tokens_dir / "google_tokens.json").write_text(json.dumps(tokens, indent=2))
89
+ log.info(f"[google_auth] tokens saved for {instance_id}")
90
+ return tokens
91
+ else:
92
+ log.error(f"[google_auth] exchange failed: {r.status_code} {r.text[:200]}")
93
+ return None
94
+
95
+
96
+ async def refresh_access_token(instance_id: str = "default") -> Optional[str]:
97
+ """Refresh access token using stored refresh token."""
98
+ tokens_file = VAULT_DIR / instance_id / "google_tokens.json"
99
+ if not tokens_file.exists():
100
+ return None
101
+
102
+ tokens = json.loads(tokens_file.read_text())
103
+ refresh_token = tokens.get("refresh_token")
104
+ if not refresh_token:
105
+ return None
106
+
107
+ creds = _load_google_creds(instance_id)
108
+
109
+ async with httpx.AsyncClient(timeout=10.0) as client:
110
+ r = await client.post(
111
+ "https://oauth2.googleapis.com/token",
112
+ data={
113
+ "client_id": creds["client_id"],
114
+ "client_secret": creds["client_secret"],
115
+ "refresh_token": refresh_token,
116
+ "grant_type": "refresh_token",
117
+ },
118
+ )
119
+ if r.status_code == 200:
120
+ new_tokens = r.json()
121
+ tokens["access_token"] = new_tokens["access_token"]
122
+ tokens["expires_in"] = new_tokens.get("expires_in", 3600)
123
+ tokens_file.write_text(json.dumps(tokens, indent=2))
124
+ return new_tokens["access_token"]
125
+ else:
126
+ log.error(f"[google_auth] refresh failed: {r.status_code}")
127
+ return None
128
+
129
+
130
+ async def get_access_token(instance_id: str = "default") -> Optional[str]:
131
+ """Get a valid access token (refresh if needed)."""
132
+ tokens_file = VAULT_DIR / instance_id / "google_tokens.json"
133
+ if not tokens_file.exists():
134
+ return None
135
+ tokens = json.loads(tokens_file.read_text())
136
+ # Always refresh to ensure valid token
137
+ refreshed = await refresh_access_token(instance_id)
138
+ return refreshed or tokens.get("access_token")
139
+
140
+
141
+ def has_credentials(instance_id: str = "default") -> bool:
142
+ """Check if instance has Google OAuth credentials configured."""
143
+ creds = _load_google_creds(instance_id)
144
+ return bool(creds["client_id"])
145
+
146
+
147
+ def has_tokens(instance_id: str = "default") -> bool:
148
+ """Check if instance has completed OAuth (has tokens)."""
149
+ return (VAULT_DIR / instance_id / "google_tokens.json").exists()
150
+
151
+
152
+ def is_oauth_code(text: str) -> bool:
153
+ """Detect if a message looks like a Google OAuth authorization code."""
154
+ t = text.strip()
155
+ if t.startswith("4/") and len(t) > 30:
156
+ return True
157
+ if len(t) >= 30 and len(t) <= 120 and "/" in t and " " not in t:
158
+ return True
159
+ return False