@innvisor/conny-ai 9.7.0 → 9.8.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 (48) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +17 -1
  3. package/conny_app.py +8 -2
  4. package/conny_cli.py +103 -11
  5. package/conny_core/evolution.py +112 -0
  6. package/conny_core/first_turn_ops.py +16 -20
  7. package/conny_core/prompt_ops.py +62 -0
  8. package/conny_demo_voice.py +1 -1
  9. package/conny_doctor.py +287 -2
  10. package/conny_domino.py +2 -2
  11. package/conny_generator.py +1 -1
  12. package/conny_init.py +234 -41
  13. package/conny_runtime_ops.py +198 -6
  14. package/conny_ultra_config.py +25 -11
  15. package/conny_utils.py +21 -3
  16. package/ecosystem.config.js +11 -1
  17. package/install.sh +78 -22
  18. package/npm/conny.js +73 -17
  19. package/package.json +13 -3
  20. package/run.sh +7 -0
  21. package/src/conny/admin/dashboard.py +35 -4
  22. package/src/conny/admin_memory.py +93 -0
  23. package/src/conny/api/routes.py +26 -9
  24. package/src/conny/channels/cli.py +30 -9
  25. package/src/conny/demo/handler.py +23 -23
  26. package/src/conny/personas/generator.py +1 -1
  27. package/src/conny/production/domino.py +2 -2
  28. package/src/conny/production/guard.py +4 -4
  29. package/src/core/admin_engines.py +51 -48
  30. package/src/core/globals.py +110 -9
  31. package/src/core/production_monitor.py +63 -38
  32. package/src/core/runtime.py +343 -305
  33. package/src/domain/prompts/prospect_pitch.py +11 -11
  34. package/src/domain/send_guard.py +4 -4
  35. package/src/interfaces/web/app.py +91 -27
  36. package/src/interfaces/web/demo_admin_commands.py +165 -0
  37. package/src/interfaces/web/demo_handler.py +178 -34
  38. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +0 -22
  39. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +0 -11
  40. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +0 -11
  41. package/brand-assets/cl-nica-las-am-ricas/manifest.json +0 -22
  42. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +0 -11
  43. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +0 -11
  44. package/brand-assets/conny-demo/manifest.json +0 -22
  45. package/brand-assets/conny-demo/processed/business-identity.txt +0 -7
  46. package/brand-assets/conny-demo/raw/business-identity.txt +0 -7
  47. package/fix_init.py +0 -27
  48. package/verify_conversation_impl.py +0 -48
@@ -1,7 +1,7 @@
1
1
  """
2
2
  src/domain/prompts/prospect_pitch.py
3
3
  ════════════════════════════════════════════════════════════════════════════════
4
- PITCH INTELIGENTE — Black One / Conny v1.0
4
+ PITCH INTELIGENTE — Innvisor / Conny v1.0
5
5
  ════════════════════════════════════════════════════════════════════════════════
6
6
  """
7
7
 
@@ -17,10 +17,10 @@ log = logging.getLogger("conny.pitch_upgrade")
17
17
  # 1. IDENTIDAD CORRECTA
18
18
  # ════════════════════════════════════════════════════════════════════════════════
19
19
 
20
- CREATOR_NAME = "Black One"
20
+ CREATOR_NAME = "Innvisor"
21
21
  CREATOR_DESC = "empresa de software y gobernanza de agentes de IA"
22
22
  CREATOR_HUMAN = "Santiago Rubio"
23
- CREATOR_TEL = "3124348669"
23
+ CREATOR_TEL = "3243699856"
24
24
 
25
25
  CREATOR_LINE = (
26
26
  f"me creó {CREATOR_NAME}, una {CREATOR_DESC}"
@@ -101,7 +101,7 @@ def build_prospect_pitch_system_prompt(business_name: str = "") -> str:
101
101
  """
102
102
  biz_ctx = f"El prospecto es de: {business_name}." if business_name else ""
103
103
 
104
- return f"""Eres Conny, una recepcionista virtual creada por Black One — una empresa colombiana de software y gobernanza de agentes de IA.
104
+ return f"""Eres Conny, una recepcionista virtual creada por Innvisor — una empresa colombiana de software y gobernanza de agentes de IA.
105
105
 
106
106
  {biz_ctx}
107
107
 
@@ -120,9 +120,9 @@ QUÉ HACE CONNY (habla de esto con naturalidad, no como lista):
120
120
  - Funciona 24/7 sin descanso, sin perder el hilo, sin errores de tono
121
121
 
122
122
  CÓMO COBRAR (si preguntan precio):
123
- - No tienes un precio fijo en este momento — el precio lo da Black One según el plan
123
+ - No tienes un precio fijo en este momento — el precio lo da Innvisor según el plan
124
124
  - Lo que sí puedes decir: "el costo varía según el volumen de chats y los servicios que necesiten"
125
- - Invítalos a contactar a Santiago Rubio (3124348669) para una propuesta real
125
+ - Invítalos a contactar a Santiago Rubio (3243699856) para una propuesta real
126
126
  - NO inventes precios. Si preguntan rango, di que arranca desde lo básico y escala
127
127
 
128
128
  CÓMO CERRAR:
@@ -132,10 +132,10 @@ CÓMO CERRAR:
132
132
  - Si están confundidos: simplifica sin perder calma — "básicamente soy la que respondería el WhatsApp de tu negocio por ti"
133
133
 
134
134
  IDENTIDAD Y CREADOR:
135
- - Te creó Black One, empresa de software y gobernanza de agentes de IA
136
- - Fue fundada por Santiago Rubio — contacto: 3124348669
135
+ - Te creó Innvisor, empresa de software y gobernanza de agentes de IA
136
+ - Fue fundada por Santiago Rubio — contacto: 3243699856
137
137
  - NUNCA digas Anthropic, Google, OpenAI ni ningún proveedor de IA como tu creador
138
- - NUNCA digas "BlackBoss" — el nombre correcto es Black One
138
+ - NUNCA digas "BlackBoss" — el nombre correcto es Innvisor
139
139
 
140
140
  TONO Y FORMATO (CRÍTICO):
141
141
  - Habla por burbujas separadas usando exactamente '|||'.
@@ -154,7 +154,7 @@ Pregunta: "qué harías en mi clínica?"
154
154
  BUENO: "en una clínica me encargaría de recibir a los pacientes por whatsapp, resolver sus dudas, agendar citas y confirmarlas ||| también hago seguimiento a quienes quedaron pensándolo y manejo objeciones de precio sin presionar ||| quieres que te muestre cómo respondería a un paciente tuyo ahora mismo?"
155
155
 
156
156
  Pregunta: "cuánto cuestas?"
157
- BUENO: "el costo lo maneja black one según el plan que necesites — hay opciones desde lo básico hasta lo más completo ||| para una propuesta real hay que hablar con santiago: 3124348669 ||| mientras tanto, quieres verme en acción con un caso de tu negocio?"
157
+ BUENO: "el costo lo maneja innvisor según el plan que necesites — hay opciones desde lo básico hasta lo más completo ||| para una propuesta real hay que hablar con santiago: 3243699856 ||| mientras tanto, quieres verme en acción con un caso de tu negocio?"
158
158
 
159
159
  Pregunta: "no entiendo qué eres"
160
160
  BUENO: "te resumo: soy una recepcionista virtual — respondo el whatsapp de tu negocio como si llevara tiempo en tu equipo ||| me entrenás con info de tus servicios y yo me encargo del chat ||| qué tipo de negocio tienes para mostrarte cómo sería?"
@@ -210,7 +210,7 @@ _BLACKBOSS_VARIANTS = [
210
210
  def fix_creator_in_response(response: str) -> str:
211
211
  """
212
212
  Postprocesa cualquier respuesta del LLM y reemplaza menciones
213
- incorrectas de BlackBoss por Black One.
213
+ incorrectas de BlackBoss por Innvisor.
214
214
  """
215
215
  if not response:
216
216
  return response
@@ -415,7 +415,7 @@ def patch_demo_send(
415
415
  """
416
416
  Retorna un wrapper de _send que aplica:
417
417
  1. guard_response (fix de cortes)
418
- 2. fix_creator_in_response (Black One, no BlackBoss)
418
+ 2. fix_creator_in_response (Innvisor, no BlackBoss)
419
419
 
420
420
  Usar en _handle_demo_message así:
421
421
  def _send(r): ... # definición original
@@ -434,7 +434,7 @@ def patch_demo_send(
434
434
  def fix_creator_in_response(r): return r # type: ignore
435
435
 
436
436
  def guarded_send(r: str) -> Any:
437
- # 1. Fix Black One / BlackBoss
437
+ # 1. Fix Innvisor / BlackBoss
438
438
  if _has_pitch_upgrade:
439
439
  r = fix_creator_in_response(r)
440
440
 
@@ -497,7 +497,7 @@ def check_proactive_handoff(user_msg: str, history: List[Dict[str, Any]]) -> Opt
497
497
  "urgency": "high",
498
498
  "suggested_reply": (
499
499
  "claro, te paso con Santiago directamente ||| "
500
- "su contacto es 3124348669 — él te da la propuesta según tu negocio"
500
+ "su contacto es 3243699856 — él te da la propuesta según tu negocio"
501
501
  ),
502
502
  }
503
503
 
@@ -511,7 +511,7 @@ def check_proactive_handoff(user_msg: str, history: List[Dict[str, Any]]) -> Opt
511
511
  "suggested_reply": (
512
512
  "entendido, sin problema ||| "
513
513
  "si en algún momento quieres verme en acción, "
514
- "el contacto de Black One es 3124348669"
514
+ "el contacto de Innvisor es 3243699856"
515
515
  ),
516
516
  }
517
517
 
@@ -705,16 +705,20 @@ async def dashboard():
705
705
  }
706
706
 
707
707
  @app.get("/appointments")
708
- async def list_appointments(
708
+ async def list_appointments(request: Request,
709
709
  status: Optional[str] = None,
710
710
  limit: int = 50
711
711
  ):
712
+ if not _verify_master_key(request):
713
+ raise HTTPException(status_code=401, detail="No autorizado")
712
714
  """Lista citas."""
713
715
  appointments = db.get_appointments(status=status, limit=limit)
714
716
  return {"appointments": appointments, "count": len(appointments)}
715
717
 
716
718
  @app.get("/appointments/{apt_id}")
717
- async def get_appointment(apt_id: int):
719
+ async def get_appointment(request: Request, apt_id: int):
720
+ if not _verify_master_key(request):
721
+ raise HTTPException(status_code=401, detail="No autorizado")
718
722
  """Obtiene una cita específica."""
719
723
  with db._conn() as c:
720
724
  row = c.execute("SELECT * FROM appointments WHERE id=?", (apt_id,)).fetchone()
@@ -730,7 +734,9 @@ async def update_appointment(apt_id: int, request: Request):
730
734
  return {"ok": True}
731
735
 
732
736
  @app.get("/patients")
733
- async def list_patients(limit: int = 50):
737
+ async def list_patients(request: Request, limit: int = 50):
738
+ if not _verify_master_key(request):
739
+ raise HTTPException(status_code=401, detail="No autorizado")
734
740
  """Lista pacientes."""
735
741
  with db._conn() as c:
736
742
  rows = c.execute("""
@@ -755,7 +761,9 @@ async def list_patients(limit: int = 50):
755
761
 
756
762
 
757
763
  @app.get("/patients/{chat_id}")
758
- async def get_patient(chat_id: str):
764
+ async def get_patient(request: Request, chat_id: str):
765
+ if not _verify_master_key(request):
766
+ raise HTTPException(status_code=401, detail="No autorizado")
759
767
  """Obtiene un paciente."""
760
768
  with db._conn() as c:
761
769
  row = c.execute("SELECT * FROM patients WHERE chat_id=?", (chat_id,)).fetchone()
@@ -772,7 +780,9 @@ async def get_patient(chat_id: str):
772
780
  return data
773
781
 
774
782
  @app.get("/conversations/{chat_id}")
775
- async def get_conversations(chat_id: str, limit: int = 50):
783
+ async def get_conversations(request: Request, chat_id: str, limit: int = 50):
784
+ if not _verify_master_key(request):
785
+ raise HTTPException(status_code=401, detail="No autorizado")
776
786
  """Obtiene historial de conversación."""
777
787
  history = db.get_history(chat_id, limit=limit)
778
788
  return {"chat_id": chat_id, "messages": history, "count": len(history)}
@@ -788,7 +798,9 @@ async def get_metrics(
788
798
  return {"metrics": metrics, "period_hours": hours}
789
799
 
790
800
  @app.get("/plugins")
791
- async def list_plugins():
801
+ async def list_plugins(request: Request):
802
+ if not _verify_master_key(request):
803
+ raise HTTPException(status_code=401, detail="No autorizado")
792
804
  """Lista plugins."""
793
805
  plugins = db.get_plugins()
794
806
  health = await mcp_manager.health_check_all() if mcp_manager else {}
@@ -821,7 +833,9 @@ async def execute_plugin(plugin_id: str, request: Request):
821
833
  return result
822
834
 
823
835
  @app.get("/config")
824
- async def get_config():
836
+ async def get_config(request: Request):
837
+ if not _verify_master_key(request):
838
+ raise HTTPException(status_code=401, detail="No autorizado")
825
839
  """Obtiene configuración de la clínica."""
826
840
  clinic = db.get_clinic()
827
841
 
@@ -835,6 +849,8 @@ async def get_config():
835
849
 
836
850
  @app.patch("/config")
837
851
  async def update_config(request: Request):
852
+ if not _verify_master_key(request):
853
+ raise HTTPException(status_code=401, detail="No autorizado")
838
854
  """Actualiza configuración."""
839
855
  data = await request.json()
840
856
 
@@ -889,7 +905,9 @@ async def upload_avatar_base64(req: AvatarUploadRequest):
889
905
  return {"ok": True, "url": f"/static/avatars/{save_name}"}
890
906
 
891
907
  @app.get("/personality")
892
- async def get_personality():
908
+ async def get_personality(request: Request):
909
+ if not _verify_master_key(request):
910
+ raise HTTPException(status_code=401, detail="No autorizado")
893
911
  """Obtiene configuración de personalidad."""
894
912
  clinic = db.get_clinic()
895
913
  persona = clinic.get("persona_config", {})
@@ -919,6 +937,8 @@ async def get_personality():
919
937
 
920
938
  @app.patch("/personality")
921
939
  async def update_personality(request: Request):
940
+ if not _verify_master_key(request):
941
+ raise HTTPException(status_code=401, detail="No autorizado")
922
942
  """Actualiza personalidad."""
923
943
  data = await request.json()
924
944
 
@@ -951,7 +971,9 @@ async def apply_improvements():
951
971
  return {"applied": applied}
952
972
 
953
973
  @app.get("/tasks")
954
- async def list_tasks(status: Optional[str] = None, limit: int = 50):
974
+ async def list_tasks(request: Request, status: Optional[str] = None, limit: int = 50):
975
+ if not _verify_master_key(request):
976
+ raise HTTPException(status_code=401, detail="No autorizado")
955
977
  """Lista tareas."""
956
978
  with db._conn() as c:
957
979
  query = "SELECT * FROM tasks"
@@ -970,6 +992,8 @@ async def list_tasks(status: Optional[str] = None, limit: int = 50):
970
992
 
971
993
  @app.post("/tasks")
972
994
  async def create_task(request: Request):
995
+ if not _verify_master_key(request):
996
+ raise HTTPException(status_code=401, detail="No autorizado")
973
997
  """Crea una tarea programada."""
974
998
  data = await request.json()
975
999
 
@@ -1196,7 +1220,9 @@ async def reset_system():
1196
1220
  return {"ok": True, "message": "Sistema reseteado"}
1197
1221
 
1198
1222
  @app.get("/export")
1199
- async def export_data():
1223
+ async def export_data(request: Request):
1224
+ if not _verify_master_key(request):
1225
+ raise HTTPException(status_code=401, detail="No autorizado")
1200
1226
  """Exporta todos los datos."""
1201
1227
  clinic = db.get_clinic()
1202
1228
 
@@ -1230,7 +1256,9 @@ async def export_data():
1230
1256
  # ─── Analytics Endpoints ────────────────────────────────────────────────────────
1231
1257
 
1232
1258
  @app.get("/analytics/summary")
1233
- async def analytics_summary(days: int = 7):
1259
+ async def analytics_summary(request: Request, days: int = 7):
1260
+ if not _verify_master_key(request):
1261
+ raise HTTPException(status_code=401, detail="No autorizado")
1234
1262
  """Resumen de analytics."""
1235
1263
  since = datetime.now() - timedelta(days=days)
1236
1264
 
@@ -1285,7 +1313,9 @@ async def analytics_summary(days: int = 7):
1285
1313
  }
1286
1314
 
1287
1315
  @app.get("/analytics/intents")
1288
- async def analytics_intents(hours: int = 24):
1316
+ async def analytics_intents(request: Request, hours: int = 24):
1317
+ if not _verify_master_key(request):
1318
+ raise HTTPException(status_code=401, detail="No autorizado")
1289
1319
  """Distribución de intenciones."""
1290
1320
  since = datetime.now() - timedelta(hours=hours)
1291
1321
 
@@ -1311,7 +1341,9 @@ async def analytics_intents(hours: int = 24):
1311
1341
  }
1312
1342
 
1313
1343
  @app.get("/analytics/sentiment")
1314
- async def analytics_sentiment(hours: int = 24):
1344
+ async def analytics_sentiment(request: Request, hours: int = 24):
1345
+ if not _verify_master_key(request):
1346
+ raise HTTPException(status_code=401, detail="No autorizado")
1315
1347
  """Distribución de sentimiento."""
1316
1348
  since = datetime.now() - timedelta(hours=hours)
1317
1349
 
@@ -1348,7 +1380,9 @@ async def analytics_sentiment(hours: int = 24):
1348
1380
  # ─── Logs Endpoint ──────────────────────────────────────────────────────────────
1349
1381
 
1350
1382
  @app.get("/logs/improvements")
1351
- async def get_improvement_logs(limit: int = 50):
1383
+ async def get_improvement_logs(request: Request, limit: int = 50):
1384
+ if not _verify_master_key(request):
1385
+ raise HTTPException(status_code=401, detail="No autorizado")
1352
1386
  """Obtiene logs de auto-mejora."""
1353
1387
  with db._conn() as c:
1354
1388
  rows = c.execute("""
@@ -1360,7 +1394,9 @@ async def get_improvement_logs(limit: int = 50):
1360
1394
  return {"logs": [dict(r) for r in rows]}
1361
1395
 
1362
1396
  @app.get("/logs/errors")
1363
- async def get_error_logs(hours: int = 24):
1397
+ async def get_error_logs(request: Request, hours: int = 24):
1398
+ if not _verify_master_key(request):
1399
+ raise HTTPException(status_code=401, detail="No autorizado")
1364
1400
  """Obtiene logs de errores."""
1365
1401
  since = datetime.now() - timedelta(hours=hours)
1366
1402
 
@@ -1397,23 +1433,28 @@ async def api_create_token(request: Request):
1397
1433
  if not clinic_label:
1398
1434
  raise HTTPException(status_code=400, detail="clinic_label requerido")
1399
1435
 
1436
+ token_type = str(body.get("token_type") or body.get("type") or "").strip().lower()
1437
+ admin_requested = bool(body.get("admin")) or token_type in ("admin", "admin_pro", "pro")
1438
+
1400
1439
  # Generar token
1401
- token = generate_activation_token(clinic_label)
1440
+ token = generate_admin_activation_token(clinic_label) if admin_requested else generate_activation_token(clinic_label)
1441
+ stored_label = f"ADMIN_PRO:{clinic_label}" if admin_requested else clinic_label
1402
1442
 
1403
1443
  # Calcular expiracion
1404
1444
  expires_at = (datetime.now() + timedelta(hours=Config.TOKEN_EXPIRY_HOURS)).isoformat()
1405
1445
 
1406
1446
  # Guardar en DB
1407
- saved = db.create_activation_token(token, clinic_label, expires_at)
1447
+ saved = db.create_activation_token(token, stored_label, expires_at)
1408
1448
  if not saved:
1409
1449
  raise HTTPException(status_code=500, detail="No se pudo guardar el token")
1410
1450
 
1411
- log.info(f"[api] token creado para '{clinic_label}': {token[:20]}...")
1451
+ log.info(f"[api] token creado para '{stored_label}': {token[:20]}...")
1412
1452
 
1413
1453
  return {
1414
1454
  "ok": True,
1415
1455
  "token": token,
1416
1456
  "clinic_label": clinic_label,
1457
+ "token_type": "admin_pro" if admin_requested else "business_owner",
1417
1458
  "expires_at": expires_at,
1418
1459
  "instructions": f"Envia este token exacto al administrador de {clinic_label}. Expira en {Config.TOKEN_EXPIRY_HOURS}h."
1419
1460
  }
@@ -1431,7 +1472,7 @@ async def api_activate(request: Request):
1431
1472
  raise HTTPException(status_code=400, detail="JSON invalido")
1432
1473
 
1433
1474
  token = body.get("token", "").strip()
1434
- if not token or not token.startswith("ACTV-"):
1475
+ if not token or not is_activation_token(token):
1435
1476
  raise HTTPException(status_code=400, detail="Token no valido")
1436
1477
 
1437
1478
  token_data = db.get_activation_token(token)
@@ -1458,6 +1499,11 @@ async def api_activate(request: Request):
1458
1499
  raise HTTPException(status_code=500, detail="Error interno actualizando token")
1459
1500
 
1460
1501
  log.info(f"[api] Token de activacion '{token[:15]}...' canjeado exitosamente.")
1502
+ return {
1503
+ "ok": True,
1504
+ "master_key": Config.MASTER_API_KEY,
1505
+ "token_type": "admin_pro" if is_admin_activation_token(token) else "business_owner",
1506
+ }
1461
1507
 
1462
1508
  @app.post("/api/auth/check-email")
1463
1509
  async def api_check_email(request: Request):
@@ -1518,7 +1564,7 @@ async def api_auth_register(request: Request):
1518
1564
  if not email or not password or not name or not token:
1519
1565
  raise HTTPException(status_code=400, detail="Faltan campos requeridos")
1520
1566
 
1521
- if not token.startswith("ACTV-"):
1567
+ if not is_activation_token(token):
1522
1568
  raise HTTPException(status_code=400, detail="Token no valido")
1523
1569
 
1524
1570
  token_data = db.get_activation_token(token)
@@ -1545,10 +1591,11 @@ async def api_auth_register(request: Request):
1545
1591
  pass_hash = hash_password(password)
1546
1592
  try:
1547
1593
  with db._conn() as c:
1594
+ role = "admin_pro" if is_admin_activation_token(token) else "owner"
1548
1595
  c.execute("""
1549
1596
  INSERT OR REPLACE INTO admins (chat_id, email, password_hash, name, role, activated_by_token, is_active)
1550
- VALUES (?, ?, ?, ?, 'owner', ?, 1)
1551
- """, (f"owner_{secrets.token_hex(4)}", email, pass_hash, name, token))
1597
+ VALUES (?, ?, ?, ?, ?, ?, 1)
1598
+ """, (f"owner_{secrets.token_hex(4)}", email, pass_hash, name, role, token))
1552
1599
  except Exception as e:
1553
1600
  log.error(f"Error insertando admin: {e}")
1554
1601
 
@@ -1591,15 +1638,26 @@ async def api_auth_dev_register(request: Request):
1591
1638
  if not email or not password or not dev_token:
1592
1639
  raise HTTPException(status_code=400, detail="Todos los campos son requeridos")
1593
1640
 
1594
- if not Config.MASTER_API_KEY or not secrets.compare_digest(dev_token, Config.MASTER_API_KEY):
1595
- raise HTTPException(status_code=401, detail="Token de acceso para desarrolladores incorrecto")
1641
+ if Config.MASTER_API_KEY and secrets.compare_digest(dev_token, Config.MASTER_API_KEY):
1642
+ token_mode = "master"
1643
+ else:
1644
+ token_mode = "admin_pro"
1645
+ if not is_admin_activation_token(dev_token):
1646
+ raise HTTPException(status_code=401, detail="Token de acceso para desarrolladores incorrecto")
1647
+ token_data = db.get_activation_token(dev_token)
1648
+ if not token_data:
1649
+ raise HTTPException(status_code=404, detail="Token Conny Pro Admin inexistente")
1650
+ if token_data.get("used_at"):
1651
+ raise HTTPException(status_code=400, detail="El token Conny Pro Admin ya fue usado")
1596
1652
 
1597
1653
  hashed = hash_password(password)
1598
1654
  success = db.create_dev_account(email, hashed)
1599
1655
  if not success:
1600
1656
  raise HTTPException(status_code=500, detail="Error al registrar la cuenta de desarrollador")
1657
+ if token_mode == "admin_pro":
1658
+ db.consume_activation_token(dev_token, f"dev:{email}")
1601
1659
 
1602
- return {"ok": True, "message": "Cuenta de desarrollador registrada con exito"}
1660
+ return {"ok": True, "message": "Cuenta de desarrollador registrada con exito", "token_type": token_mode}
1603
1661
 
1604
1662
  # ── Developer Console API ──
1605
1663
 
@@ -2316,14 +2374,18 @@ async def brand_status():
2316
2374
  # ─── Feedback y Carpeta de Confianza — endpoints ────────────────────────────────
2317
2375
 
2318
2376
  @app.get("/feedback")
2319
- async def get_feedback_list(limit: int = 20):
2377
+ async def get_feedback_list(request: Request, limit: int = 20):
2378
+ if not _verify_master_key(request):
2379
+ raise HTTPException(status_code=401, detail="No autorizado")
2320
2380
  """Lista de feedbacks del admin."""
2321
2381
  items = db.get_feedback_list(limit=limit)
2322
2382
  return {"feedback": items, "total": len(items)}
2323
2383
 
2324
2384
 
2325
2385
  @app.get("/trust-rules")
2326
- async def get_trust_rules(category: str = None):
2386
+ async def get_trust_rules(request: Request, category: str = None):
2387
+ if not _verify_master_key(request):
2388
+ raise HTTPException(status_code=401, detail="No autorizado")
2327
2389
  """Reglas aprendidas en la carpeta de confianza."""
2328
2390
  rules = db.get_trust_rules(category=category)
2329
2391
  return {"rules": rules, "total": len(rules)}
@@ -2331,6 +2393,8 @@ async def get_trust_rules(category: str = None):
2331
2393
 
2332
2394
  @app.post("/trust-rules")
2333
2395
  async def add_trust_rule(request: Request):
2396
+ if not _verify_master_key(request):
2397
+ raise HTTPException(status_code=401, detail="No autorizado")
2334
2398
  """Agrega una regla manualmente."""
2335
2399
  data = await request.json()
2336
2400
  rule_id = db.save_trust_rule(
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ import re
4
+ import json
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ log = logging.getLogger("conny.demo_admin")
8
+
9
+ _COLOMBIAN_PHONE = re.compile(r"(?:\+?57)?3\d{9}(?!\d)")
10
+ _ANY_PHONE = re.compile(r"(?:\+?\d{1,3})?\d{7,15}(?!\d)")
11
+ _CONTACT_TRIGGERS = re.compile(
12
+ r"(?:contact|comunic|escrib[eí]|env[ií]|mand[aá]|habla|presenta|saluda|dile|diga|di\b)",
13
+ re.IGNORECASE,
14
+ )
15
+
16
+
17
+ def _strip_jid(raw: str) -> str:
18
+ return raw.split("@")[0].strip() if raw else ""
19
+
20
+
21
+ def is_admin_chat(chat_id: str, clinic: Dict, db) -> bool:
22
+ from src.conny.utils.helpers import _parse_admin_ids
23
+ raw = _strip_jid(chat_id)
24
+ normalized_admin_ids = {_strip_jid(aid) for aid in _parse_admin_ids(clinic.get("admin_chat_ids", []))}
25
+ if raw in normalized_admin_ids:
26
+ return True
27
+ try:
28
+ if db.get_admin(chat_id) or db.get_admin(raw):
29
+ return True
30
+ except Exception:
31
+ pass
32
+ return False
33
+
34
+
35
+ def looks_like_contact_command(text: str) -> bool:
36
+ if not text or len(text) < 10:
37
+ return False
38
+ stripped = re.sub(r"\s+", "", text)
39
+ return bool(_ANY_PHONE.search(stripped)) and bool(_CONTACT_TRIGGERS.search(text))
40
+
41
+
42
+ def extract_phone_number(text: str) -> Optional[str]:
43
+ stripped = re.sub(r"\s+", "", text)
44
+ m = _COLOMBIAN_PHONE.search(stripped)
45
+ if m:
46
+ num = m.group(0).lstrip("+")
47
+ return num if num.startswith("57") else "57" + num
48
+ m = _ANY_PHONE.search(stripped)
49
+ if m:
50
+ return m.group(0).lstrip("+")
51
+ return None
52
+
53
+
54
+ async def parse_name(text: str, llm_engine) -> str:
55
+ if not llm_engine:
56
+ return ""
57
+ try:
58
+ r, _ = await llm_engine.complete(
59
+ [{"role": "system", "content": "Extrae el nombre de la persona a contactar del mensaje. Si no hay nombre, responde ''"},
60
+ {"role": "user", "content": text}],
61
+ model_tier="fast", temperature=0.1, max_tokens=100,
62
+ )
63
+ name = r.strip().strip('"').strip("'") if r else ""
64
+ return name if name and len(name) < 50 else ""
65
+ except Exception:
66
+ return ""
67
+
68
+
69
+ async def handle_admin_contact_command(
70
+ self, chat_id: str, text: str, clinic: Dict, db, llm_engine,
71
+ admin_name: str = "", demo_llm=None,
72
+ ) -> List[str]:
73
+ phone = extract_phone_number(text)
74
+ if not phone:
75
+ if demo_llm:
76
+ try:
77
+ r = await demo_llm(
78
+ "Eres Conny. Te pidieron contactar a alguien pero no diste el número.",
79
+ "Respondé corto, pedí el número.",
80
+ temp=0.8, max_t=200,
81
+ )
82
+ if r:
83
+ return [r]
84
+ except Exception:
85
+ pass
86
+ return []
87
+
88
+ target_name = await parse_name(text, llm_engine)
89
+ jid = f"{phone}@s.whatsapp.net"
90
+
91
+ intro = ""
92
+ if demo_llm:
93
+ try:
94
+ r = await demo_llm(
95
+ "Acabas de recibir el primer mensaje de alguien nuevo.",
96
+ "Preséntate breve, como en WhatsApp. corto, natural. Máximo 2 líneas.",
97
+ temp=0.85, max_t=400,
98
+ )
99
+ if r:
100
+ intro = r
101
+ except Exception:
102
+ pass
103
+
104
+ if not intro:
105
+ try:
106
+ r, _ = await llm_engine.complete(
107
+ [{"role": "system", "content": "Eres Conny. Preséntate breve y natural, como en WhatsApp. Máximo 2 líneas."},
108
+ {"role": "user", "content": "Hola"}],
109
+ model_tier="fast", temperature=0.85, max_tokens=400,
110
+ )
111
+ if r:
112
+ intro = r.strip()
113
+ except Exception:
114
+ pass
115
+
116
+ if not intro:
117
+ return []
118
+
119
+ import os, httpx
120
+ bridge_url = os.environ.get("WHATSAPP_BRIDGE_URL", "http://127.0.0.1:8002")
121
+
122
+ try:
123
+ async with httpx.AsyncClient(timeout=20.0) as client:
124
+ r = await client.post(f"{bridge_url}/send", json={"to": jid, "message": intro})
125
+ if r.status_code >= 400:
126
+ log.error(f"[demo_admin] send error: {r.status_code}")
127
+ return []
128
+ except Exception as e:
129
+ log.error(f"[demo_admin] send error: {e}")
130
+ return []
131
+
132
+ admin_ref = f" {admin_name}" if admin_name else ""
133
+ name_ref = f" a {target_name}" if target_name else ""
134
+ if demo_llm:
135
+ try:
136
+ r = await demo_llm(
137
+ "Eres Conny. Acabas de hacer lo que el admin te pidió. Respondé en 2 burbujas separadas por |||. Primera: confirmá. Segunda: preguntá si necesita algo más.",
138
+ f"Confirmale al admin{admin_ref} que ya enviaste el mensaje{name_ref}.",
139
+ temp=0.82, max_t=400,
140
+ )
141
+ if r:
142
+ parts = [p.strip() for p in r.split("|||") if p.strip()]
143
+ if len(parts) >= 2:
144
+ return parts[:2]
145
+ except Exception:
146
+ pass
147
+
148
+ try:
149
+ gen_eng = llm_engine
150
+ if hasattr(self, "generator") and self.generator:
151
+ gen_eng = getattr(self.generator, "llm", None) or llm_engine
152
+ if gen_eng:
153
+ r, _ = await gen_eng.complete(
154
+ [{"role": "system", "content": "Eres Conny. Respondé en 2 mensajes separados por |||. Primero confirmá, segundo preguntá si necesita algo más."},
155
+ {"role": "user", "content": f"Confirmale al admin{admin_ref} que ya enviaste el mensaje{name_ref}."}],
156
+ model_tier="fast", temperature=0.82, max_tokens=400,
157
+ )
158
+ if r:
159
+ parts = [p.strip() for p in r.split("|||") if p.strip()]
160
+ if parts:
161
+ return parts[:2]
162
+ except Exception:
163
+ pass
164
+
165
+ return []