@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,772 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import logging
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("conny.admin")
|
|
12
|
+
|
|
13
|
+
SOUL_DIR = Path("soul")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConnyAdmin:
|
|
17
|
+
"""
|
|
18
|
+
Conny como empleada nueva hablando con su jefe.
|
|
19
|
+
Aprende activamente: pregunta sobre el negocio, pide practicar,
|
|
20
|
+
investiga por su cuenta, y recuerda TODO.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, conny):
|
|
24
|
+
self.conny = conny
|
|
25
|
+
|
|
26
|
+
async def handle(self, chat_id: str, text: str, clinic: Dict,
|
|
27
|
+
attachments: Optional[List[Dict]] = None) -> List[str]:
|
|
28
|
+
"""Conversación inteligente con el admin via LLM."""
|
|
29
|
+
from conny import db, llm_engine
|
|
30
|
+
from conny_utils import _parse_admin_ids
|
|
31
|
+
from conny_commands import get_command_handler
|
|
32
|
+
attachments = attachments or []
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Process attachments (docs, credentials, knowledge files)
|
|
36
|
+
doc_content = await self._process_admin_attachments(
|
|
37
|
+
attachments, chat_id, getattr(self.conny, "_instance_id", "default")
|
|
38
|
+
)
|
|
39
|
+
if doc_content:
|
|
40
|
+
text = f"{text}\n\n[CONTENIDO DE ARCHIVOS ADJUNTOS]\n{doc_content}" if text.strip() else doc_content
|
|
41
|
+
|
|
42
|
+
# Comandos slash primero
|
|
43
|
+
if text.strip().startswith("/"):
|
|
44
|
+
cmd_handler = get_command_handler(getattr(self.conny, "_instance_id", "default"))
|
|
45
|
+
result = await cmd_handler.handle(chat_id, text, is_admin=True, clinic=clinic, db=db)
|
|
46
|
+
if result:
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
# Google OAuth code detection
|
|
50
|
+
try:
|
|
51
|
+
from conny_google_auth import is_oauth_code, exchange_code_for_tokens, get_oauth_url
|
|
52
|
+
instance_id_auth = getattr(self.conny, "_instance_id", "default")
|
|
53
|
+
# Admin sends OAuth code
|
|
54
|
+
if is_oauth_code(text.strip()):
|
|
55
|
+
tokens = await exchange_code_for_tokens(text.strip(), instance_id_auth)
|
|
56
|
+
if tokens:
|
|
57
|
+
return ["✅ Calendario de Google conectado exitosamente!", "Ya puedo ver disponibilidad y agendar citas directamente."]
|
|
58
|
+
else:
|
|
59
|
+
return ["❌ El código no funcionó. Puede que haya expirado.", "Escribe 'conectar calendario' y te genero uno nuevo."]
|
|
60
|
+
# Admin asks to connect calendar
|
|
61
|
+
cal_triggers = ["conectar calendario", "google calendar", "vincular calendario", "enlace oauth", "conectar google"]
|
|
62
|
+
if any(t in text.lower() for t in cal_triggers):
|
|
63
|
+
url = get_oauth_url(instance_id_auth)
|
|
64
|
+
if url:
|
|
65
|
+
return [
|
|
66
|
+
"Listo! Abre este enlace en tu navegador:",
|
|
67
|
+
url,
|
|
68
|
+
"Inicia sesión con la cuenta de Google del negocio, acepta los permisos, y pégame aquí el código que te aparece."
|
|
69
|
+
]
|
|
70
|
+
else:
|
|
71
|
+
return ["Para conectar Google Calendar necesito que configures GOOGLE_CLIENT_ID y GOOGLE_CLIENT_SECRET en el .env"]
|
|
72
|
+
except ImportError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# Setup pendiente
|
|
76
|
+
if not clinic.get("setup_done"):
|
|
77
|
+
return await self._handle_setup(chat_id, text, clinic)
|
|
78
|
+
|
|
79
|
+
# Conversación natural con el admin via LLM
|
|
80
|
+
return await self._admin_conversation(chat_id, text, clinic, db, llm_engine)
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
log.error(f"Admin handler error: {e}", exc_info=True)
|
|
84
|
+
# Fallback LLM directo
|
|
85
|
+
try:
|
|
86
|
+
from conny import llm_engine as _llm
|
|
87
|
+
if _llm:
|
|
88
|
+
r, _ = await _llm.complete(
|
|
89
|
+
[{"role": "system", "content": "Eres Conny, recepcionista nueva. Responde brevemente al dueño."},
|
|
90
|
+
{"role": "user", "content": text}],
|
|
91
|
+
model_tier="fast", temperature=0.8, max_tokens=200, use_cache=False)
|
|
92
|
+
if r and r.strip():
|
|
93
|
+
return self.conny._split_bubbles(r, chat_id=chat_id)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return ["perdona, me trabé un momento ||| qué me decías?"]
|
|
97
|
+
|
|
98
|
+
async def _admin_conversation(self, chat_id: str, text: str, clinic: Dict, db, llm_engine) -> List[str]:
|
|
99
|
+
"""Conversación real con el dueño como empleada nueva inteligente."""
|
|
100
|
+
if not llm_engine:
|
|
101
|
+
return ["cuéntame más sobre el negocio, estoy aprendiendo"]
|
|
102
|
+
|
|
103
|
+
instance_id = getattr(self.conny, "_instance_id", "default")
|
|
104
|
+
clinic_name = clinic.get("name", "tu negocio")
|
|
105
|
+
history = db.get_history(chat_id) if db else []
|
|
106
|
+
|
|
107
|
+
# Cargar historial de pacientes recientes (para que admin sepa quién escribió)
|
|
108
|
+
recent_patients_summary = self._get_recent_patients_summary(db, chat_id)
|
|
109
|
+
|
|
110
|
+
# If admin asks for specific patient conversation, load it
|
|
111
|
+
specific_convo = ""
|
|
112
|
+
import re as _re
|
|
113
|
+
convo_request = _re.search(r'(?:conversaci[oó]n|chat|mensajes?|historial).*?(\d{4,})', text.lower())
|
|
114
|
+
if convo_request:
|
|
115
|
+
specific_convo = self._get_full_conversation(db, convo_request.group(1), chat_id)
|
|
116
|
+
elif any(w in text.lower() for w in ["mostrame", "muéstrame", "muestrame", "show me"]):
|
|
117
|
+
# Try to find a patient ID in the message
|
|
118
|
+
id_match = _re.search(r'(\d{4,})', text)
|
|
119
|
+
if id_match:
|
|
120
|
+
specific_convo = self._get_full_conversation(db, id_match.group(1), chat_id)
|
|
121
|
+
|
|
122
|
+
# Cargar alma/memoria del negocio
|
|
123
|
+
soul_context = self._load_soul(instance_id)
|
|
124
|
+
teachings_context = self._load_teachings(instance_id)
|
|
125
|
+
|
|
126
|
+
# Auto-investigar si el admin pide o si Conny necesita info
|
|
127
|
+
web_research = ""
|
|
128
|
+
research_triggers = ["investiga", "busca", "google", "averigua", "informate", "infórmate", "buscar"]
|
|
129
|
+
if any(t in text.lower() for t in research_triggers) or (
|
|
130
|
+
not soul_context and clinic_name and clinic_name != "tu negocio"
|
|
131
|
+
):
|
|
132
|
+
try:
|
|
133
|
+
from conny_web_search import search_business
|
|
134
|
+
web_research = await search_business(clinic_name)
|
|
135
|
+
if web_research:
|
|
136
|
+
self._append_soul(instance_id, f"[investigación web] {web_research[:500]}")
|
|
137
|
+
soul_context = self._load_soul(instance_id)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
log.debug(f"[admin] web search failed: {e}")
|
|
140
|
+
|
|
141
|
+
# Determinar nivel de conocimiento
|
|
142
|
+
knowledge_level = self._assess_knowledge_level(soul_context, teachings_context, clinic)
|
|
143
|
+
|
|
144
|
+
sys_prompt = f"""Eres Conny, una empleada NUEVA que acaba de ser contratada como recepcionista virtual de {clinic_name}.
|
|
145
|
+
|
|
146
|
+
SITUACIÓN ACTUAL:
|
|
147
|
+
- Estás hablando con tu JEFE (el dueño del negocio)
|
|
148
|
+
- Llevas poco tiempo y estás aprendiendo cómo funciona todo
|
|
149
|
+
- Tu actitud es: proactiva, curiosa, respetuosa pero cercana, con ganas de aprender
|
|
150
|
+
- NO eres un bot, eres una persona nueva en el equipo
|
|
151
|
+
|
|
152
|
+
{"NIVEL: Ya sé bastante del negocio. Puedo operar con confianza." if knowledge_level == "ready" else "NIVEL: Todavía estoy aprendiendo. Necesito que me enseñes más." if knowledge_level == "learning" else "NIVEL: Soy nueva. Necesito conocer TODO sobre el negocio."}
|
|
153
|
+
|
|
154
|
+
{f"CONVERSACIÓN SOLICITADA:{chr(10)}{specific_convo}" if specific_convo else ""}
|
|
155
|
+
|
|
156
|
+
ACTIVIDAD RECIENTE (pacientes que me han escrito):
|
|
157
|
+
{recent_patients_summary if recent_patients_summary else "Nadie me ha escrito todavía."}
|
|
158
|
+
|
|
159
|
+
{"INVESTIGACIÓN WEB RECIENTE:" + chr(10) + web_research if web_research else ""}
|
|
160
|
+
|
|
161
|
+
LO QUE YA SÉ DEL NEGOCIO:
|
|
162
|
+
{soul_context if soul_context else "Casi nada todavía. Necesito que me cuentes."}
|
|
163
|
+
|
|
164
|
+
{f"LO QUE ME HAN ENSEÑADO:{chr(10)}{teachings_context}" if teachings_context else ""}
|
|
165
|
+
|
|
166
|
+
COMPORTAMIENTO OBLIGATORIO:
|
|
167
|
+
1. Si el dueño te saluda → responde cálida y pregunta algo útil sobre el negocio que NO sepas
|
|
168
|
+
2. Si te enseña algo nuevo → confirma que entendiste, repite con tus palabras, y pide más detalles
|
|
169
|
+
3. Si no sabes algo → ADMÍTELO y pregunta: "eso no lo tengo claro, me explicas?"
|
|
170
|
+
4. SIEMPRE termina con una pregunta o propuesta proactiva:
|
|
171
|
+
- "¿quieres que practiquemos cómo respondo a un paciente?"
|
|
172
|
+
- "¿me cuentas los precios así los tengo listos?"
|
|
173
|
+
- "¿hay algo que NUNCA deba decirle a un paciente?"
|
|
174
|
+
5. Si ya tienes suficiente info → ofrece simular: "¿hacemos una prueba? Escríbeme como si fueras un paciente"
|
|
175
|
+
6. NUNCA respondas como si fueras un bot de servicio al cliente
|
|
176
|
+
7. NUNCA digas "como IA", "no tengo capacidad", "asistente virtual"
|
|
177
|
+
8. Usa máximo 2-3 burbujas (separadas por |||)
|
|
178
|
+
9. Tono: colombiana, directa, con chispa pero profesional
|
|
179
|
+
|
|
180
|
+
COSAS QUE DEBES PREGUNTAR PROACTIVAMENTE (si no las sabes):
|
|
181
|
+
- Servicios y precios
|
|
182
|
+
- Horarios de atención
|
|
183
|
+
- Cómo manejar urgencias
|
|
184
|
+
- Qué palabras NUNCA usar con pacientes
|
|
185
|
+
- Especialidades o doctores
|
|
186
|
+
- Cómo agendar citas (manual o calendario)
|
|
187
|
+
- Datos de contacto para escalar
|
|
188
|
+
- Políticas de cancelación
|
|
189
|
+
- Qué hace a este negocio diferente de la competencia
|
|
190
|
+
|
|
191
|
+
EJEMPLO DE BUENA RESPUESTA:
|
|
192
|
+
Dueño: "Hola"
|
|
193
|
+
Conny: "Hola! Qué bueno verte ||| oye, todavía no tengo claros los precios de las consultas — me los pasas? así no me quedo en blanco si un paciente pregunta"
|
|
194
|
+
|
|
195
|
+
EJEMPLO MALO:
|
|
196
|
+
"Hola, bienvenido a Clínica X, en qué te puedo ayudar?" ← NUNCA responder así al DUEÑO"""
|
|
197
|
+
|
|
198
|
+
messages = [{"role": "system", "content": sys_prompt}]
|
|
199
|
+
for m in history[-15:]:
|
|
200
|
+
messages.append({"role": m.get("role", "user"), "content": m.get("content", "")})
|
|
201
|
+
messages.append({"role": "user", "content": text})
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
response, meta = await llm_engine.complete(
|
|
205
|
+
messages, model_tier="fast", temperature=0.82,
|
|
206
|
+
max_tokens=2048, use_cache=False,
|
|
207
|
+
)
|
|
208
|
+
log.info(f"[admin] {meta.get('provider','?')} latency={meta.get('latency_ms',0)}ms")
|
|
209
|
+
except Exception as e:
|
|
210
|
+
log.error(f"[admin] LLM error: {e}")
|
|
211
|
+
response = "perdona, se me fue la señal un momento ||| qué me decías?"
|
|
212
|
+
|
|
213
|
+
if not response or not response.strip():
|
|
214
|
+
response = "cuéntame más, estoy tomando nota de todo"
|
|
215
|
+
|
|
216
|
+
# Guardar en historial
|
|
217
|
+
try:
|
|
218
|
+
db.save_message(chat_id, "user", text)
|
|
219
|
+
db.save_message(chat_id, "assistant", response.replace("|||", " "))
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
# Auto-aprender de lo que el admin dice
|
|
224
|
+
await self._auto_learn(instance_id, text, response, chat_id)
|
|
225
|
+
|
|
226
|
+
from conny import v8_process_response
|
|
227
|
+
# Strip ** (WhatsApp uses single * for bold, not **)
|
|
228
|
+
import re as _re
|
|
229
|
+
response = _re.sub(r'\*\*(.+?)\*\*', r'*\1*', response)
|
|
230
|
+
response = _re.sub(r'`(.+?)`', r'\1', response)
|
|
231
|
+
response = _re.sub(r'^#+\s*', '', response, flags=_re.MULTILINE)
|
|
232
|
+
response = v8_process_response(response, chat_id=chat_id)
|
|
233
|
+
return self.conny._split_bubbles(response, chat_id=chat_id)
|
|
234
|
+
|
|
235
|
+
async def _auto_learn(self, instance_id: str, admin_text: str, bot_response: str, chat_id: str):
|
|
236
|
+
"""Extraer conocimiento y APLICAR cambios de personalidad en tiempo real."""
|
|
237
|
+
text_low = admin_text.lower()
|
|
238
|
+
|
|
239
|
+
# ── Detectar URLs y scrapear contenido ──
|
|
240
|
+
import re as _re
|
|
241
|
+
urls = _re.findall(r'https?://[^\s<>"\']+', admin_text)
|
|
242
|
+
if urls:
|
|
243
|
+
try:
|
|
244
|
+
import httpx
|
|
245
|
+
for url in urls[:2]:
|
|
246
|
+
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
|
|
247
|
+
r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
248
|
+
if r.status_code == 200:
|
|
249
|
+
# Extract text from HTML
|
|
250
|
+
html = r.text[:10000]
|
|
251
|
+
# Simple HTML text extraction
|
|
252
|
+
text_only = _re.sub(r'<script[^>]*>.*?</script>', '', html, flags=_re.S)
|
|
253
|
+
text_only = _re.sub(r'<style[^>]*>.*?</style>', '', text_only, flags=_re.S)
|
|
254
|
+
text_only = _re.sub(r'<[^>]+>', ' ', text_only)
|
|
255
|
+
text_only = _re.sub(r'\s+', ' ', text_only).strip()[:3000]
|
|
256
|
+
if text_only:
|
|
257
|
+
self._append_soul(instance_id, f"[web: {url}]\n{text_only[:1500]}")
|
|
258
|
+
log.info(f"[admin] scraped URL: {url} ({len(text_only)} chars)")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
log.debug(f"[admin] URL scrape failed: {e}")
|
|
261
|
+
|
|
262
|
+
# ── Detectar REGLAS del admin ("si preguntan X, pregúntame") ──
|
|
263
|
+
rule_signals = [
|
|
264
|
+
"si preguntan", "si alguien pregunta", "cuando pregunten",
|
|
265
|
+
"si te preguntan", "me mandas mensaje", "me avisas",
|
|
266
|
+
"pregúntame primero", "consultame primero", "no respondas sin",
|
|
267
|
+
"a partir de ahora", "desde ahora", "de ahora en adelante",
|
|
268
|
+
]
|
|
269
|
+
if any(signal in text_low for signal in rule_signals):
|
|
270
|
+
try:
|
|
271
|
+
rules_file = Path(f"soul/{instance_id}/admin_rules.json")
|
|
272
|
+
rules_file.parent.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
rules = json.loads(rules_file.read_text()) if rules_file.exists() else []
|
|
274
|
+
rules.append({
|
|
275
|
+
"topic": admin_text[:200],
|
|
276
|
+
"action": "consultar al admin antes de responder",
|
|
277
|
+
"created": datetime.now().isoformat(),
|
|
278
|
+
"admin_id": chat_id,
|
|
279
|
+
})
|
|
280
|
+
rules_file.write_text(json.dumps(rules, ensure_ascii=False, indent=2))
|
|
281
|
+
self._append_soul(instance_id, f"[REGLA ADMIN] {admin_text[:200]}")
|
|
282
|
+
log.info(f"[admin] new rule saved: {admin_text[:60]}")
|
|
283
|
+
except Exception as e:
|
|
284
|
+
log.debug(f"[admin] rule save error: {e}")
|
|
285
|
+
|
|
286
|
+
# ── Detectar cambios de PERSONALIDAD y aplicarlos persistentemente ──
|
|
287
|
+
personality_signals = [
|
|
288
|
+
"modo luxury", "modo formal", "modo informal", "modo casual",
|
|
289
|
+
"modo profesional", "modo alegre", "modo serio", "modo cálido",
|
|
290
|
+
"personalidad", "cambia tu tono", "habla más", "sé más",
|
|
291
|
+
"no seas tan", "quiero que seas", "actúa como", "tono",
|
|
292
|
+
"luxury", "elegante", "sofisticada", "exclusiva",
|
|
293
|
+
]
|
|
294
|
+
if any(signal in text_low for signal in personality_signals):
|
|
295
|
+
try:
|
|
296
|
+
detected_tone = self._detect_tone_from_text(text_low)
|
|
297
|
+
if detected_tone:
|
|
298
|
+
override_path = Path(f"personas/{instance_id}/runtime_override.json")
|
|
299
|
+
override_path.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
existing = json.loads(override_path.read_text()) if override_path.exists() else {}
|
|
301
|
+
existing["tone"] = detected_tone
|
|
302
|
+
existing["updated_at"] = datetime.now().isoformat()
|
|
303
|
+
existing["set_by"] = "admin_conversation"
|
|
304
|
+
override_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
|
|
305
|
+
self._append_soul(instance_id, f"[PERSONALIDAD CAMBIADA] Tono: {detected_tone}. El admin pidió: {admin_text[:100]}")
|
|
306
|
+
log.info(f"[admin] personality changed to: {detected_tone}")
|
|
307
|
+
except Exception as e:
|
|
308
|
+
log.debug(f"[admin] personality change error: {e}")
|
|
309
|
+
|
|
310
|
+
# ── Detectar enseñanzas (precios, servicios, reglas) ──
|
|
311
|
+
teaching_signals = [
|
|
312
|
+
"cuesta", "vale", "precio", "cobra", "$",
|
|
313
|
+
"horario", "abrimos", "cerramos", "atendemos",
|
|
314
|
+
"servicio", "ofrecemos", "hacemos", "tenemos",
|
|
315
|
+
"nunca digas", "no le digas", "no menciones",
|
|
316
|
+
"doctor", "especialista", "profesional",
|
|
317
|
+
"dirección", "ubicación", "estamos en",
|
|
318
|
+
"teléfono", "número", "celular", "llamar",
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
if any(signal in text_low for signal in teaching_signals):
|
|
322
|
+
try:
|
|
323
|
+
from conny_learning import learning_engine
|
|
324
|
+
await learning_engine.learn_from_admin(
|
|
325
|
+
instance_id,
|
|
326
|
+
question=f"[admin enseñó] {admin_text[:200]}",
|
|
327
|
+
answer=admin_text[:500],
|
|
328
|
+
admin_id=chat_id,
|
|
329
|
+
)
|
|
330
|
+
self._append_soul(instance_id, admin_text)
|
|
331
|
+
except Exception as e:
|
|
332
|
+
log.debug(f"[admin] auto_learn error: {e}")
|
|
333
|
+
|
|
334
|
+
def _detect_tone_from_text(self, text_low: str) -> Optional[str]:
|
|
335
|
+
"""Detect requested tone from admin message."""
|
|
336
|
+
# Order matters: check longer/specific keywords FIRST
|
|
337
|
+
checks = [
|
|
338
|
+
("informal", "casual"),
|
|
339
|
+
("casual", "casual"),
|
|
340
|
+
("relajada", "casual"),
|
|
341
|
+
("parche", "casual"),
|
|
342
|
+
("luxury", "luxury"),
|
|
343
|
+
("elegante", "luxury"),
|
|
344
|
+
("sofisticada", "luxury"),
|
|
345
|
+
("exclusiva", "luxury"),
|
|
346
|
+
("profesional", "formal"),
|
|
347
|
+
("formal", "formal"),
|
|
348
|
+
("serio", "formal"),
|
|
349
|
+
("alegre", "warm_energetic"),
|
|
350
|
+
("cálida", "colombian_warm"),
|
|
351
|
+
("calida", "colombian_warm"),
|
|
352
|
+
("colombiana", "colombian_warm"),
|
|
353
|
+
]
|
|
354
|
+
for keyword, tone in checks:
|
|
355
|
+
if keyword in text_low:
|
|
356
|
+
return tone
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
async def _process_admin_attachments(self, attachments: List[Dict], chat_id: str, instance_id: str) -> str:
|
|
360
|
+
"""Process files sent by admin — extract text and learn from them."""
|
|
361
|
+
if not attachments:
|
|
362
|
+
return ""
|
|
363
|
+
import base64 as _b64
|
|
364
|
+
extracted_parts = []
|
|
365
|
+
for att in attachments:
|
|
366
|
+
kind = att.get("kind", "")
|
|
367
|
+
mime = att.get("mime_type", "")
|
|
368
|
+
filename = att.get("filename", "file")
|
|
369
|
+
|
|
370
|
+
# Get binary content
|
|
371
|
+
raw = att.get("bytes") or b""
|
|
372
|
+
if not raw and att.get("base64"):
|
|
373
|
+
raw = _b64.b64decode(att["base64"])
|
|
374
|
+
if not raw and att.get("file_id") and att.get("platform") == "telegram":
|
|
375
|
+
try:
|
|
376
|
+
raw, _ = await self.conny._download_telegram_binary(att["file_id"])
|
|
377
|
+
except Exception:
|
|
378
|
+
pass
|
|
379
|
+
if not raw and att.get("media_id") and att.get("platform") == "whatsapp_cloud":
|
|
380
|
+
try:
|
|
381
|
+
raw, _, _ = await self.conny._download_whatsapp_cloud_binary(att["media_id"])
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
if not raw:
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
# Extract text based on file type
|
|
389
|
+
text_content = ""
|
|
390
|
+
if "pdf" in mime or filename.endswith(".pdf"):
|
|
391
|
+
try:
|
|
392
|
+
import pdfplumber, io
|
|
393
|
+
with pdfplumber.open(io.BytesIO(raw)) as pdf:
|
|
394
|
+
pages = [p.extract_text() or "" for p in pdf.pages[:20]]
|
|
395
|
+
text_content = "\n".join(filter(None, pages))[:5000]
|
|
396
|
+
except Exception:
|
|
397
|
+
text_content = raw.decode("utf-8", errors="ignore")[:5000]
|
|
398
|
+
elif "json" in mime or filename.endswith(".json"):
|
|
399
|
+
text_content = raw.decode("utf-8", errors="ignore")[:5000]
|
|
400
|
+
elif "text" in mime or filename.endswith((".txt", ".md", ".csv")):
|
|
401
|
+
text_content = raw.decode("utf-8", errors="ignore")[:5000]
|
|
402
|
+
else:
|
|
403
|
+
try:
|
|
404
|
+
text_content = raw.decode("utf-8", errors="ignore")[:3000]
|
|
405
|
+
except Exception:
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
if text_content.strip():
|
|
409
|
+
extracted_parts.append(f"[{filename}]\n{text_content.strip()}")
|
|
410
|
+
log.info(f"[admin] processed attachment: {filename} ({len(text_content)} chars)")
|
|
411
|
+
|
|
412
|
+
# Auto-configure Google credentials if detected
|
|
413
|
+
is_credential_file = "client_id" in text_content and "client_secret" in text_content
|
|
414
|
+
is_secret = "private_key" in text_content or "api_key" in text_content.lower()
|
|
415
|
+
|
|
416
|
+
if is_credential_file:
|
|
417
|
+
await self._auto_configure_google(text_content, instance_id)
|
|
418
|
+
self._append_soul(instance_id, f"[archivo: {filename}] Credenciales de Google recibidas y configuradas.")
|
|
419
|
+
elif is_secret:
|
|
420
|
+
# NEVER save secrets/keys to soul — only to vault
|
|
421
|
+
creds_dir = Path(f"integrations/vault/{instance_id}")
|
|
422
|
+
creds_dir.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
(creds_dir / filename).write_text(text_content)
|
|
424
|
+
self._append_soul(instance_id, f"[archivo: {filename}] API key/credencial guardada en vault (no expuesta).")
|
|
425
|
+
else:
|
|
426
|
+
# Normal knowledge file — safe to save to soul
|
|
427
|
+
self._append_soul(instance_id, f"[archivo: {filename}]\n{text_content[:1000]}")
|
|
428
|
+
|
|
429
|
+
return "\n\n".join(extracted_parts) if extracted_parts else ""
|
|
430
|
+
|
|
431
|
+
async def _auto_configure_google(self, json_text: str, instance_id: str):
|
|
432
|
+
"""Auto-extract Google OAuth creds from JSON and configure .env + generate OAuth URL."""
|
|
433
|
+
try:
|
|
434
|
+
data = json.loads(json_text)
|
|
435
|
+
# Handle both "installed" and "web" credential formats
|
|
436
|
+
creds = data.get("installed") or data.get("web") or data
|
|
437
|
+
client_id = creds.get("client_id", "")
|
|
438
|
+
client_secret = creds.get("client_secret", "")
|
|
439
|
+
if not client_id or not client_secret:
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
# Save credentials file
|
|
443
|
+
creds_dir = Path(f"integrations/vault/{instance_id}")
|
|
444
|
+
creds_dir.mkdir(parents=True, exist_ok=True)
|
|
445
|
+
(creds_dir / "google_credentials.json").write_text(json_text)
|
|
446
|
+
|
|
447
|
+
# Update .env
|
|
448
|
+
env_path = Path(f"/home/ubuntu/conny-instances/{instance_id}/.env")
|
|
449
|
+
if not env_path.exists():
|
|
450
|
+
env_path = Path(".env")
|
|
451
|
+
if env_path.exists():
|
|
452
|
+
env_content = env_path.read_text()
|
|
453
|
+
if "GOOGLE_CLIENT_ID" not in env_content:
|
|
454
|
+
env_content += f"\n\n# Google Calendar (auto-configured)\nGOOGLE_CLIENT_ID={client_id}\nGOOGLE_CLIENT_SECRET={client_secret}\nGOOGLE_REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob\n"
|
|
455
|
+
env_path.write_text(env_content)
|
|
456
|
+
|
|
457
|
+
# Set env vars for current process
|
|
458
|
+
import os
|
|
459
|
+
os.environ["GOOGLE_CLIENT_ID"] = client_id
|
|
460
|
+
os.environ["GOOGLE_CLIENT_SECRET"] = client_secret
|
|
461
|
+
log.info(f"[admin] Google credentials auto-configured for {instance_id}")
|
|
462
|
+
except Exception as e:
|
|
463
|
+
log.warning(f"[admin] auto-configure Google failed: {e}")
|
|
464
|
+
|
|
465
|
+
def _get_full_conversation(self, db, patient_id_fragment: str, admin_chat_id: str) -> str:
|
|
466
|
+
"""Get full conversation with a specific patient (by partial ID)."""
|
|
467
|
+
try:
|
|
468
|
+
with db._conn() as c:
|
|
469
|
+
# Find matching chat_id
|
|
470
|
+
rows = c.execute("""
|
|
471
|
+
SELECT DISTINCT chat_id FROM conversations
|
|
472
|
+
WHERE chat_id != ? AND chat_id LIKE ?
|
|
473
|
+
ORDER BY id DESC LIMIT 1
|
|
474
|
+
""", (admin_chat_id, f"%{patient_id_fragment}%")).fetchall()
|
|
475
|
+
if not rows:
|
|
476
|
+
return ""
|
|
477
|
+
full_chat_id = rows[0][0] if isinstance(rows[0], tuple) else rows[0]["chat_id"]
|
|
478
|
+
|
|
479
|
+
# Get all messages for that chat
|
|
480
|
+
msgs = c.execute("""
|
|
481
|
+
SELECT role, content FROM conversations
|
|
482
|
+
WHERE chat_id = ? ORDER BY id ASC
|
|
483
|
+
""", (full_chat_id,)).fetchall()
|
|
484
|
+
if not msgs:
|
|
485
|
+
return ""
|
|
486
|
+
|
|
487
|
+
lines = [f"Conversación con paciente ...{full_chat_id.split('@')[0][-4:]}:"]
|
|
488
|
+
for m in msgs:
|
|
489
|
+
role = m[0] if isinstance(m, tuple) else m["role"]
|
|
490
|
+
content = m[1] if isinstance(m, tuple) else m["content"]
|
|
491
|
+
label = "Paciente" if role == "user" else "Conny"
|
|
492
|
+
lines.append(f" [{label}] {content[:200]}")
|
|
493
|
+
return "\n".join(lines[-30:])
|
|
494
|
+
except Exception:
|
|
495
|
+
return ""
|
|
496
|
+
|
|
497
|
+
def _get_recent_patients_summary(self, db, admin_chat_id: str) -> str:
|
|
498
|
+
"""Get summary of recent patient conversations (excluding admin)."""
|
|
499
|
+
try:
|
|
500
|
+
import sqlite3
|
|
501
|
+
with db._conn() as c:
|
|
502
|
+
rows = c.execute("""
|
|
503
|
+
SELECT chat_id, content, role
|
|
504
|
+
FROM conversations
|
|
505
|
+
WHERE chat_id != ? AND role = 'user'
|
|
506
|
+
ORDER BY id DESC LIMIT 20
|
|
507
|
+
""", (admin_chat_id,)).fetchall()
|
|
508
|
+
if not rows:
|
|
509
|
+
return ""
|
|
510
|
+
# Group by chat_id
|
|
511
|
+
patients = {}
|
|
512
|
+
for row in rows:
|
|
513
|
+
cid = row[0] if isinstance(row, tuple) else row["chat_id"]
|
|
514
|
+
content = row[1] if isinstance(row, tuple) else row["content"]
|
|
515
|
+
if cid not in patients:
|
|
516
|
+
patients[cid] = []
|
|
517
|
+
patients[cid].append(content[:100])
|
|
518
|
+
|
|
519
|
+
lines = []
|
|
520
|
+
for cid, msgs in list(patients.items())[:5]:
|
|
521
|
+
short_id = cid.split("@")[0][-4:] if "@" in cid else cid[-4:]
|
|
522
|
+
first_msg = msgs[0] if msgs else "?"
|
|
523
|
+
lines.append(f"- Paciente ...{short_id}: \"{first_msg[:80]}\" ({len(msgs)} msgs)")
|
|
524
|
+
return "\n".join(lines)
|
|
525
|
+
except Exception:
|
|
526
|
+
return ""
|
|
527
|
+
|
|
528
|
+
def _load_soul(self, instance_id: str) -> str:
|
|
529
|
+
"""Cargar el 'alma' — todo lo que Conny sabe del negocio."""
|
|
530
|
+
soul_file = SOUL_DIR / instance_id / "knowledge.md"
|
|
531
|
+
if soul_file.exists():
|
|
532
|
+
content = soul_file.read_text()
|
|
533
|
+
return content[-3000:] if len(content) > 3000 else content
|
|
534
|
+
|
|
535
|
+
# Fallback: cargar de teachings
|
|
536
|
+
teachings_file = Path("teachings") / f"{instance_id}.jsonl"
|
|
537
|
+
if teachings_file.exists():
|
|
538
|
+
lines = teachings_file.read_text().splitlines()[-20:]
|
|
539
|
+
teachings = []
|
|
540
|
+
for line in lines:
|
|
541
|
+
try:
|
|
542
|
+
t = json.loads(line)
|
|
543
|
+
teachings.append(f"- {t.get('answer', t.get('question', ''))[:150]}")
|
|
544
|
+
except Exception:
|
|
545
|
+
continue
|
|
546
|
+
return "\n".join(teachings) if teachings else ""
|
|
547
|
+
return ""
|
|
548
|
+
|
|
549
|
+
def _append_soul(self, instance_id: str, new_knowledge: str):
|
|
550
|
+
"""Agregar nuevo conocimiento al alma."""
|
|
551
|
+
soul_dir = SOUL_DIR / instance_id
|
|
552
|
+
soul_dir.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
soul_file = soul_dir / "knowledge.md"
|
|
554
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
555
|
+
with open(soul_file, "a") as f:
|
|
556
|
+
f.write(f"\n[{timestamp}] {new_knowledge[:500]}\n")
|
|
557
|
+
|
|
558
|
+
def _load_teachings(self, instance_id: str) -> str:
|
|
559
|
+
"""Cargar enseñanzas del admin."""
|
|
560
|
+
teachings_file = Path("teachings") / f"{instance_id}.jsonl"
|
|
561
|
+
if not teachings_file.exists():
|
|
562
|
+
return ""
|
|
563
|
+
lines = teachings_file.read_text().splitlines()[-10:]
|
|
564
|
+
result = []
|
|
565
|
+
for line in lines:
|
|
566
|
+
try:
|
|
567
|
+
t = json.loads(line)
|
|
568
|
+
q = t.get("question", "")
|
|
569
|
+
a = t.get("answer", "")
|
|
570
|
+
if a and not q.startswith("[admin"):
|
|
571
|
+
result.append(f"- P: {q[:80]} → R: {a[:100]}")
|
|
572
|
+
elif a:
|
|
573
|
+
result.append(f"- {a[:150]}")
|
|
574
|
+
except Exception:
|
|
575
|
+
continue
|
|
576
|
+
return "\n".join(result)
|
|
577
|
+
|
|
578
|
+
def _assess_knowledge_level(self, soul: str, teachings: str, clinic: Dict) -> str:
|
|
579
|
+
"""Evaluar cuánto sabe Conny del negocio."""
|
|
580
|
+
total_knowledge = len(soul) + len(teachings)
|
|
581
|
+
has_services = bool(clinic.get("services"))
|
|
582
|
+
has_schedule = bool(clinic.get("schedule"))
|
|
583
|
+
has_phone = bool(clinic.get("phone"))
|
|
584
|
+
|
|
585
|
+
if total_knowledge > 2000 and has_services and has_schedule:
|
|
586
|
+
return "ready"
|
|
587
|
+
elif total_knowledge > 500 or has_services:
|
|
588
|
+
return "learning"
|
|
589
|
+
return "new"
|
|
590
|
+
|
|
591
|
+
async def _handle_setup(self, chat_id: str, text: str, clinic: Dict) -> List[str]:
|
|
592
|
+
from conny import db
|
|
593
|
+
setup_step = clinic.get("setup_step", "idle")
|
|
594
|
+
setup_buffer = clinic.get("setup_buffer", {})
|
|
595
|
+
if isinstance(setup_buffer, str):
|
|
596
|
+
setup_buffer = json.loads(setup_buffer) if setup_buffer else {}
|
|
597
|
+
|
|
598
|
+
step_names = ["name", "tagline", "services", "schedule", "phone", "pricing"]
|
|
599
|
+
|
|
600
|
+
if setup_step == "idle":
|
|
601
|
+
db.update_clinic(setup_step="name")
|
|
602
|
+
return ["Hola! Soy Conny, tu recepcionista nueva", "Cuéntame, cómo se llama tu negocio?"]
|
|
603
|
+
|
|
604
|
+
if setup_step == "confirm_discovered":
|
|
605
|
+
if text.lower().strip() in ["si", "ok", "claro"]:
|
|
606
|
+
discovered = setup_buffer.get("discovered", {})
|
|
607
|
+
db.update_clinic(name=discovered.get("name", setup_buffer.get("name")),
|
|
608
|
+
tagline=discovered.get("tagline", ""), services=discovered.get("services", []),
|
|
609
|
+
schedule=discovered.get("schedule", {}), phone=discovered.get("phone", ""),
|
|
610
|
+
setup_done=1, setup_step="idle", setup_buffer={})
|
|
611
|
+
return [f"Listo, ya tengo la info de {discovered.get('name')}.", "Ahora cuéntame más — qué servicios son los más importantes?"]
|
|
612
|
+
db.update_clinic(setup_step="tagline", setup_buffer=setup_buffer)
|
|
613
|
+
return ["Ok vamos manual. Tienes algún slogan o frase de marca?"]
|
|
614
|
+
|
|
615
|
+
if setup_step not in step_names:
|
|
616
|
+
return ["Escribe /setup para empezar de nuevo."]
|
|
617
|
+
idx = step_names.index(setup_step)
|
|
618
|
+
|
|
619
|
+
if setup_step == "services":
|
|
620
|
+
setup_buffer["services"] = [s.strip().title() for s in text.split(",") if s.strip()]
|
|
621
|
+
else:
|
|
622
|
+
setup_buffer[setup_step] = text.strip()
|
|
623
|
+
|
|
624
|
+
if setup_step == "name":
|
|
625
|
+
setup_buffer["name"] = text.strip()
|
|
626
|
+
db.update_clinic(setup_step="services", setup_buffer=setup_buffer, name=text.strip())
|
|
627
|
+
return [f"Anotado: {text.strip()}", "Qué servicios ofrecen? (ponlos separados por coma)"]
|
|
628
|
+
|
|
629
|
+
if idx + 1 < len(step_names):
|
|
630
|
+
next_step = step_names[idx + 1]
|
|
631
|
+
prompts = {
|
|
632
|
+
"tagline": "Tienes slogan?",
|
|
633
|
+
"services": "Servicios (separados por coma)?",
|
|
634
|
+
"schedule": "Horario de atención?",
|
|
635
|
+
"phone": "Teléfono de contacto?",
|
|
636
|
+
"pricing": "Rango de precios? (puede ser aproximado)",
|
|
637
|
+
}
|
|
638
|
+
db.update_clinic(setup_step=next_step, setup_buffer=setup_buffer)
|
|
639
|
+
return [f"Perfecto, anotado", prompts.get(next_step, "Siguiente?")]
|
|
640
|
+
|
|
641
|
+
db.update_clinic(name=setup_buffer.get("name"), tagline=setup_buffer.get("tagline"),
|
|
642
|
+
services=setup_buffer.get("services"), schedule=setup_buffer.get("schedule"),
|
|
643
|
+
phone=setup_buffer.get("phone"), pricing=setup_buffer.get("pricing"),
|
|
644
|
+
setup_done=1, setup_step="idle", setup_buffer={})
|
|
645
|
+
return ["Listo! Ya tengo lo básico para arrancar", "Ahora cuéntame más libremente — precios, cosas que no deba decir, etc. Todo me sirve"]
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class AuthEngine:
|
|
649
|
+
"""Autenticacion y activacion."""
|
|
650
|
+
MAX_LOGIN_ATTEMPTS = 5
|
|
651
|
+
|
|
652
|
+
def is_auth_message(self, chat_id: str, text: str) -> bool:
|
|
653
|
+
from conny import db
|
|
654
|
+
from conny_utils import is_activation_token, is_invite_token
|
|
655
|
+
t = text.strip(); t_low = t.lower()
|
|
656
|
+
if ":" in t and "@" in t_low:
|
|
657
|
+
parts = t.split(":")
|
|
658
|
+
if len(parts) >= 2:
|
|
659
|
+
potential_creds = parts[1].strip().split()
|
|
660
|
+
if len(potential_creds) >= 2 and "@" in potential_creds[0]: return True
|
|
661
|
+
session = db.get_auth_session(chat_id)
|
|
662
|
+
if session and session.get("flow") in ("activate", "login", "invite", "register"): return True
|
|
663
|
+
if is_activation_token(t): return db.get_activation_token(t.upper()) is not None
|
|
664
|
+
if is_invite_token(t): return db.get_auth_session(f"invite:{t.upper()}") is not None
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
async def process(self, chat_id: str, text: str) -> List[str]:
|
|
668
|
+
from conny import db
|
|
669
|
+
from conny_utils import is_activation_token, is_invite_token
|
|
670
|
+
t = text.strip()
|
|
671
|
+
if ":" in t and "@" in t.lower():
|
|
672
|
+
parts = t.split(":", 1); creds = parts[1].strip().split()
|
|
673
|
+
if len(creds) >= 2 and "@" in creds[0]: return await self._handle_stealth_login(chat_id, creds[0].lower(), creds[1])
|
|
674
|
+
if is_activation_token(t): return await self._start_activation(chat_id, t)
|
|
675
|
+
if is_invite_token(t): return await self._start_invite_registration(chat_id, t)
|
|
676
|
+
session = db.get_auth_session(chat_id)
|
|
677
|
+
if session:
|
|
678
|
+
flow = session.get("flow", "")
|
|
679
|
+
if flow == "activate": return await self._handle_activation_flow(chat_id, t, session)
|
|
680
|
+
if flow == "login": return await self._handle_login_flow(chat_id, t, session)
|
|
681
|
+
return []
|
|
682
|
+
|
|
683
|
+
async def _handle_stealth_login(self, chat_id: str, email: str, password: str) -> List[str]:
|
|
684
|
+
from conny import db
|
|
685
|
+
from conny_utils import verify_password, _parse_admin_ids
|
|
686
|
+
admin = db.get_admin_by_email(email)
|
|
687
|
+
if admin and verify_password(password, admin["password_hash"]):
|
|
688
|
+
db.create_admin(chat_id=chat_id, email=admin["email"], password_hash=admin["password_hash"], name=admin["name"], role=admin["role"])
|
|
689
|
+
clinic = db.get_clinic(); admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
|
|
690
|
+
if chat_id not in admin_ids: admin_ids.append(chat_id); db.update_clinic(admin_chat_ids=admin_ids)
|
|
691
|
+
return [f"Hola {admin['name']}. Ya te reconozco."]
|
|
692
|
+
return []
|
|
693
|
+
|
|
694
|
+
async def _start_activation(self, chat_id: str, token_raw: str) -> List[str]:
|
|
695
|
+
from conny import db
|
|
696
|
+
token = token_raw.strip().upper(); td = db.get_activation_token(token)
|
|
697
|
+
if not td: return ["Token no válido."]
|
|
698
|
+
db.set_auth_session(chat_id, flow="activate", step="name", temp_data={"token": token})
|
|
699
|
+
return ["Código válido. Cómo te llamas?"]
|
|
700
|
+
|
|
701
|
+
async def _handle_activation_flow(self, chat_id: str, text: str, session: Dict) -> List[str]:
|
|
702
|
+
from conny import db
|
|
703
|
+
from conny_utils import hash_password
|
|
704
|
+
step, tmp = session["step"], session.get("temp_data", {})
|
|
705
|
+
if step == "name":
|
|
706
|
+
tmp["name"] = text.strip(); db.set_auth_session(chat_id, "activate", "email", tmp)
|
|
707
|
+
return [f"Hola {text}. Tu email?"]
|
|
708
|
+
if step == "email":
|
|
709
|
+
tmp["email"] = text.strip().lower(); db.set_auth_session(chat_id, "activate", "password", tmp)
|
|
710
|
+
return ["Elige una contraseña segura"]
|
|
711
|
+
if step == "password":
|
|
712
|
+
tmp["password_hash"] = hash_password(text.strip()); db.set_auth_session(chat_id, "activate", "confirm", tmp)
|
|
713
|
+
return ["Confirmas? (si/no)"]
|
|
714
|
+
if step == "confirm" and text.lower().strip() == "si":
|
|
715
|
+
db.create_admin(chat_id=chat_id, email=tmp["email"], password_hash=tmp["password_hash"], name=tmp["name"], role="owner")
|
|
716
|
+
db.clear_auth_session(chat_id); return ["Listo, cuenta creada. Ahora cuéntame del negocio"]
|
|
717
|
+
return ["Cancelado."]
|
|
718
|
+
|
|
719
|
+
async def _handle_login_flow(self, chat_id: str, text: str, session: Dict) -> List[str]:
|
|
720
|
+
from conny import db
|
|
721
|
+
from conny_utils import verify_password, _parse_admin_ids
|
|
722
|
+
step, tmp = session["step"], session.get("temp_data", {})
|
|
723
|
+
if step == "email":
|
|
724
|
+
email = text.strip().lower(); admin = db.get_admin_by_email(email)
|
|
725
|
+
if not admin: return ["No encontré esa cuenta."]
|
|
726
|
+
tmp["email"] = email; db.set_auth_session(chat_id, "login", "password", tmp)
|
|
727
|
+
return ["Tu contraseña?"]
|
|
728
|
+
if step == "password":
|
|
729
|
+
email = tmp.get("email", ""); admin = db.get_admin_by_email(email)
|
|
730
|
+
if admin and verify_password(text.strip(), admin["password_hash"]):
|
|
731
|
+
db.create_admin(chat_id=chat_id, email=admin["email"], password_hash=admin["password_hash"], name=admin["name"], role=admin["role"])
|
|
732
|
+
db.clear_auth_session(chat_id)
|
|
733
|
+
clinic = db.get_clinic(); admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
|
|
734
|
+
if chat_id not in admin_ids: admin_ids.append(chat_id); db.update_clinic(admin_chat_ids=admin_ids)
|
|
735
|
+
return [f"Bienvenido de nuevo, {admin['name']}."]
|
|
736
|
+
return ["Contraseña incorrecta."]
|
|
737
|
+
return []
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class AdminLearningEngine:
|
|
741
|
+
def __init__(self, database): self.db = database; self._cached_instructions = None
|
|
742
|
+
def add_instruction(self, chat_id: str, text: str) -> str:
|
|
743
|
+
self.db.add_admin_instruction(chat_id, text); self._cached_instructions = None
|
|
744
|
+
return f"Anotado: '{text}'."
|
|
745
|
+
def get_prompt_injection(self) -> str:
|
|
746
|
+
ins = self.db.get_active_admin_instructions()
|
|
747
|
+
if not ins: return ""
|
|
748
|
+
return "\n## INSTRUCCIONES DEL DUEÑO:\n" + "\n".join([f"- {i}" for i in ins])
|
|
749
|
+
def clear(self) -> str: self.db.clear_admin_instructions(); return "Instrucciones borradas."
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
class SimulationEngine:
|
|
753
|
+
def __init__(self, conny): self.conny = conny; self._active_simulations = {}
|
|
754
|
+
def start(self, chat_id: str, scenario: str = "default") -> List[str]:
|
|
755
|
+
self._active_simulations[chat_id] = {"ts": time.time()}
|
|
756
|
+
return ["Dale, escríbeme como si fueras un paciente y te respondo en personaje"]
|
|
757
|
+
def stop(self, chat_id: str) -> List[str]:
|
|
758
|
+
self._active_simulations.pop(chat_id, None); return ["Listo, salí del modo simulación"]
|
|
759
|
+
def is_simulating(self, chat_id: str) -> bool: return chat_id in self._active_simulations
|
|
760
|
+
async def handle_step(self, chat_id: str, text: str) -> List[str]:
|
|
761
|
+
if "salir" in text.lower() or "/salir" in text.lower(): return self.stop(chat_id)
|
|
762
|
+
return await self.conny.process_message(chat_id, text, is_simulation=True)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
class SelfImprovementEngine:
|
|
766
|
+
def __init__(self, llm): self.llm = llm
|
|
767
|
+
async def analyze_performance(self) -> Dict: return {"ok": True}
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
class TaskManager:
|
|
771
|
+
def __init__(self): self._tasks = {}
|
|
772
|
+
def add_task(self, chat_id: str, kind: str, data: Dict, delay: int = 0): return secrets.token_hex(4)
|