@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
package/npm/conny.js CHANGED
@@ -18,6 +18,15 @@ const workspaceConfigPath = path.join(connyHome, "config.json");
18
18
  const sharedTelegramRoutesPath = path.join(connyHome, "shared_telegram_routes.json");
19
19
  const entrypoint = path.join(repoDir, "conny_app.py");
20
20
  const legacyEntrypoint = path.join(repoDir, "conny_cli.py");
21
+ const criticalPythonPackages = [
22
+ "rich>=13.0.0",
23
+ "deep-translator>=1.11.0",
24
+ "httpx==0.27.0",
25
+ "python-dotenv==1.0.1",
26
+ "fastapi==0.115.0",
27
+ "pydantic>=2.0",
28
+ "questionary>=2.0.0",
29
+ ];
21
30
 
22
31
  const SKIP_NAMES = new Set([
23
32
  ".git",
@@ -143,7 +152,7 @@ const commandGroups = [
143
152
  function printBanner() {
144
153
  console.log();
145
154
  console.log(chalk.hex('#EC4899').bold(' conny-agent') +
146
- chalk.hex('#8B5CF6').dim(` · v${packageVersion} · kimika.ai`));
155
+ chalk.hex('#8B5CF6').dim(` · v${packageVersion} · innvisor.ai`));
147
156
  console.log(chalk.hex('#444')(' ─────────────────────────'));
148
157
  console.log();
149
158
  }
@@ -265,12 +274,54 @@ function runtimeLooksHealthy(runtime) {
265
274
  }
266
275
  const probe = spawnSync(
267
276
  runtime,
268
- ["-c", "import fastapi,httpx,dotenv; print('ok')"],
277
+ ["-c", "import rich,deep_translator,fastapi,httpx,dotenv,pydantic,questionary; print('ok')"],
269
278
  { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", env: process.env }
270
279
  );
271
280
  return probe.status === 0;
272
281
  }
273
282
 
283
+ function installRuntimeDependencies(runtime, spinner, allowFullFailure = true) {
284
+ let result;
285
+ if (spinner) {
286
+ spinner.text = chalk.hex('#8B5CF6')("Installing Conny CLI runtime dependencies...");
287
+ }
288
+ result = spawnSync(
289
+ runtime,
290
+ ["-m", "pip", "install", "--disable-pip-version-check", ...criticalPythonPackages],
291
+ { stdio: ["ignore", "pipe", "pipe"], env: process.env }
292
+ );
293
+ if (result.status !== 0) {
294
+ if (spinner) spinner.fail(chalk.red("✕ Could not install Conny's required Python packages."));
295
+ if (result.stderr) console.error(chalk.red(result.stderr.toString()));
296
+ process.exit(1);
297
+ }
298
+
299
+ const requirementsPath = path.join(repoDir, "requirements.txt");
300
+ if (fs.existsSync(requirementsPath)) {
301
+ if (spinner) {
302
+ spinner.text = chalk.hex('#8B5CF6')("Installing optional production dependencies...");
303
+ }
304
+ result = spawnSync(
305
+ runtime,
306
+ ["-m", "pip", "install", "--disable-pip-version-check", "-r", requirementsPath],
307
+ { stdio: ["ignore", "pipe", "pipe"], env: process.env }
308
+ );
309
+ if (result.status !== 0 && !allowFullFailure) {
310
+ if (spinner) spinner.fail(chalk.red("✕ Could not install requirements.txt."));
311
+ if (result.stderr) console.error(chalk.red(result.stderr.toString()));
312
+ process.exit(1);
313
+ }
314
+ if (result.status !== 0 && spinner) {
315
+ spinner.text = chalk.hex('#8B5CF6')("Optional dependencies skipped; CLI runtime is ready.");
316
+ }
317
+ }
318
+
319
+ if (!runtimeLooksHealthy(runtime)) {
320
+ if (spinner) spinner.fail(chalk.red("✕ Runtime verification failed after dependency installation."));
321
+ process.exit(1);
322
+ }
323
+ }
324
+
274
325
  function ensureRuntime(spinner) {
275
326
  let runtime = resolveRuntime();
276
327
  if (runtime && runtimeLooksHealthy(runtime)) {
@@ -330,16 +381,7 @@ function ensureRuntime(spinner) {
330
381
  process.exit(1);
331
382
  }
332
383
 
333
- localSpinner.text = chalk.hex('#8B5CF6')("Instalando dependencias base (requirements.txt)...");
334
- result = spawnSync(runtime, ["-m", "pip", "install", "--disable-pip-version-check", "-r", path.join(repoDir, "requirements.txt")], {
335
- stdio: ["ignore", "pipe", "pipe"],
336
- env: process.env,
337
- });
338
- if (result.status !== 0) {
339
- localSpinner.fail(chalk.red("✕ Falló la instalación de las dependencias."));
340
- if (result.stderr) console.error(chalk.red(result.stderr.toString()));
341
- process.exit(1);
342
- }
384
+ installRuntimeDependencies(runtime, localSpinner, true);
343
385
 
344
386
  if (!spinner) {
345
387
  localSpinner.succeed(chalk.green("✓ Lista."));
@@ -385,11 +427,11 @@ function bootstrapFromPackage() {
385
427
  )
386
428
  );
387
429
  }
388
- console.log(chalk.hex("#8B5CF6")("Verificando dependencias (pip)..."));
389
- const runtime = resolveRuntime() || ensureRuntime(spinner);
390
- spawnSync(runtime, ["-m", "pip", "install", "--disable-pip-version-check", "-r", path.join(repoDir, "requirements.txt")], { stdio: "ignore" });
430
+ console.log(chalk.hex("#8B5CF6")("Verifying Python dependencies..."));
431
+ const runtime = resolveRuntime() || ensureRuntime(spinner);
432
+ installRuntimeDependencies(runtime, spinner, true);
391
433
 
392
- if (!fs.existsSync(sharedTelegramRoutesPath)) {
434
+ if (!fs.existsSync(sharedTelegramRoutesPath)) {
393
435
  fs.writeFileSync(sharedTelegramRoutesPath, JSON.stringify({ default_instance: "", routes: {} }, null, 2));
394
436
  }
395
437
  ensureDir(path.join(connyHome, "instances"));
@@ -405,7 +447,11 @@ function needsBootstrap() {
405
447
  if (!fs.existsSync(entrypoint)) {
406
448
  return true;
407
449
  }
408
- if (!resolveRuntime()) {
450
+ const runtime = resolveRuntime();
451
+ if (!runtime) {
452
+ return true;
453
+ }
454
+ if (!runtimeLooksHealthy(runtime)) {
409
455
  return true;
410
456
  }
411
457
  if (readInstalledVersion() !== packageVersion) {
@@ -441,6 +487,7 @@ function execConny(argv) {
441
487
  const args = process.argv.slice(2);
442
488
  const isHelp = args.length === 0 || args.includes("-h") || args.includes("--help") || args.includes("help");
443
489
  const isVersion = args.includes("-v") || args.includes("--version") || args.includes("version");
490
+ const isBootstrapCheck = args.includes("--bootstrap-check");
444
491
  const isJson = args.includes("--json");
445
492
 
446
493
  if (isVersion) {
@@ -456,6 +503,15 @@ if (needsBootstrap()) {
456
503
  bootstrapFromPackage();
457
504
  }
458
505
 
506
+ if (isBootstrapCheck) {
507
+ const runtime = resolveRuntime();
508
+ if (!runtime || !runtimeLooksHealthy(runtime)) {
509
+ fail(`Conny runtime is not ready in ${connyHome}`);
510
+ }
511
+ console.log(`conny runtime ok ${packageVersion}`);
512
+ process.exit(0);
513
+ }
514
+
459
515
  if (isHelp) {
460
516
  printBanner();
461
517
  printCommands();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innvisor/conny-ai",
3
- "version": "9.7.0",
3
+ "version": "9.8.0",
4
4
  "description": "Open-source CLI and runtime for building Conny AI receptionist agents on WhatsApp and Telegram.",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Rubio",
@@ -21,10 +21,20 @@
21
21
  "conny.py",
22
22
  "*.py",
23
23
  "!patch*.py",
24
+ "!fix*.py",
25
+ "!implement_*.py",
26
+ "!create_dev_account.py",
27
+ "!verify_*.py",
28
+ "!conny_watchdog.py",
29
+ "!conny_ultra*.db*",
24
30
  "!scratch*.py",
25
31
  "!test_*.py",
26
32
  "!chat_conny.py",
27
- "brand-assets/**/*",
33
+ "brand-assets/conny-logo.png",
34
+ "brand-assets/Conny.web.logo.png",
35
+ "brand-assets/Logo_Conny_Petalo_Claro.png",
36
+ "brand-assets/web.background.png",
37
+ "brand-assets/A_dark_luxury_web_background_202605210700.jpeg",
28
38
  "conny_cli.py",
29
39
  "conny_cli_bb.py",
30
40
  "conny_tui.py",
@@ -99,4 +109,4 @@
99
109
  "figlet": "^1.11.0",
100
110
  "ora": "^5.4.1"
101
111
  }
102
- }
112
+ }
package/run.sh CHANGED
@@ -12,6 +12,13 @@ read_env_value() {
12
12
  fi
13
13
  }
14
14
 
15
+ # Export all .env variables so they're available to the Python process
16
+ if [ -f "$ENV_FILE" ]; then
17
+ set -a
18
+ source "$ENV_FILE"
19
+ set +a
20
+ fi
21
+
15
22
  PYTHON_OVERRIDE="${CONNY_PYTHON_BIN:-${PYTHON_BIN:-$(read_env_value CONNY_PYTHON_BIN)}}"
16
23
  if [ -z "$PYTHON_OVERRIDE" ]; then
17
24
  PYTHON_OVERRIDE="$(read_env_value PYTHON_BIN)"
@@ -208,7 +208,17 @@ EJEMPLO MALO:
208
208
  log.info(f"[admin] {meta.get('provider','?')} latency={meta.get('latency_ms',0)}ms")
209
209
  except Exception as e:
210
210
  log.error(f"[admin] LLM error: {e}")
211
- response = "perdona, se me fue la señal un momento ||| qué me decías?"
211
+ if hasattr(e, "public_message"):
212
+ response = (
213
+ "No voy a ocultarte esto con un fallback. ||| "
214
+ f"{e.public_message} ||| "
215
+ "Continuar con fallback? Responde exactamente: continuar fallback. No recomendado si quieres que todo lo decida el LLM."
216
+ )
217
+ else:
218
+ response = (
219
+ f"El cerebro LLM falló antes de responder. Detalle: {str(e)[:500]} ||| "
220
+ "Continuar con fallback? Responde exactamente: continuar fallback. No recomendado."
221
+ )
212
222
 
213
223
  if not response or not response.strip():
214
224
  response = "cuéntame más, estoy tomando nota de todo"
@@ -693,9 +703,13 @@ class AuthEngine:
693
703
 
694
704
  async def _start_activation(self, chat_id: str, token_raw: str) -> List[str]:
695
705
  from conny import db
706
+ from conny_utils import is_admin_activation_token
696
707
  token = token_raw.strip().upper(); td = db.get_activation_token(token)
697
708
  if not td: return ["Token no válido."]
698
- db.set_auth_session(chat_id, flow="activate", step="name", temp_data={"token": token})
709
+ token_type = "admin_pro" if is_admin_activation_token(token) else "business_owner"
710
+ db.set_auth_session(chat_id, flow="activate", step="name", temp_data={"token": token, "token_type": token_type})
711
+ if token_type == "admin_pro":
712
+ return ["Código Conny Pro válido. Cómo te llamas?"]
699
713
  return ["Código válido. Cómo te llamas?"]
700
714
 
701
715
  async def _handle_activation_flow(self, chat_id: str, text: str, session: Dict) -> List[str]:
@@ -712,8 +726,25 @@ class AuthEngine:
712
726
  tmp["password_hash"] = hash_password(text.strip()); db.set_auth_session(chat_id, "activate", "confirm", tmp)
713
727
  return ["Confirmas? (si/no)"]
714
728
  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"]
729
+ from conny_utils import _parse_admin_ids
730
+ token = tmp.get("token", "")
731
+ token_type = tmp.get("token_type", "business_owner")
732
+ role = "admin_pro" if token_type == "admin_pro" else "owner"
733
+ db.create_admin(chat_id=chat_id, email=tmp["email"], password_hash=tmp["password_hash"], name=tmp["name"], role=role, token=token)
734
+ if token:
735
+ db.consume_activation_token(token, chat_id)
736
+ clinic = db.get_clinic()
737
+ admin_ids = _parse_admin_ids(clinic.get("admin_chat_ids", []))
738
+ if chat_id not in admin_ids:
739
+ admin_ids.append(chat_id)
740
+ db.update_clinic(admin_chat_ids=admin_ids)
741
+ db.clear_auth_session(chat_id)
742
+ if token_type == "admin_pro":
743
+ return [
744
+ "Listo. Conny Pro Admin quedó activado para este chat.",
745
+ "Desde aquí puedes administrar la instancia, crear flujos y corregir la operación con permisos de operador."
746
+ ]
747
+ return ["Listo, cuenta creada. Ahora cuéntame del negocio"]
717
748
  return ["Cancelado."]
718
749
 
719
750
  async def _handle_login_flow(self, chat_id: str, text: str, session: Dict) -> List[str]:
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+
9
+
10
+ def _safe_id(value: str) -> str:
11
+ clean = re.sub(r"[^a-zA-Z0-9_.@-]+", "_", str(value or "").strip())
12
+ return clean[:96] or "unknown"
13
+
14
+
15
+ class AdminSoulMemory:
16
+ """Filesystem memory for the operator layer, inspired by OpenClaw-style soul folders."""
17
+
18
+ def __init__(self, root: str | Path = "soul/admins"):
19
+ self.root = Path(root)
20
+
21
+ def admin_dir(self, chat_id: str) -> Path:
22
+ path = self.root / _safe_id(chat_id)
23
+ path.mkdir(parents=True, exist_ok=True)
24
+ return path
25
+
26
+ def load_context(self, chat_id: str, limit_chars: int = 4000) -> str:
27
+ base = self.admin_dir(chat_id)
28
+ chunks: List[str] = []
29
+ for filename, title in (
30
+ ("profile.json", "Perfil del admin"),
31
+ ("business_facts.md", "Datos enseñados por el admin"),
32
+ ("ops_memory.md", "Memoria operativa"),
33
+ ):
34
+ path = base / filename
35
+ if path.exists():
36
+ text = path.read_text(encoding="utf-8", errors="ignore").strip()
37
+ if text:
38
+ chunks.append(f"## {title}\n{text[-limit_chars:]}")
39
+ return "\n\n".join(chunks)[-limit_chars:]
40
+
41
+ def remember_turn(
42
+ self,
43
+ *,
44
+ chat_id: str,
45
+ admin_text: str,
46
+ conny_reply: str,
47
+ clinic: Dict[str, Any],
48
+ ) -> None:
49
+ base = self.admin_dir(chat_id)
50
+ now = datetime.utcnow().isoformat() + "Z"
51
+
52
+ profile_path = base / "profile.json"
53
+ profile = {}
54
+ if profile_path.exists():
55
+ try:
56
+ profile = json.loads(profile_path.read_text(encoding="utf-8"))
57
+ except Exception:
58
+ profile = {}
59
+ profile.update(
60
+ {
61
+ "chat_id": str(chat_id),
62
+ "last_seen_at": now,
63
+ "clinic_name": clinic.get("name", ""),
64
+ "setup_done": bool(clinic.get("setup_done")),
65
+ }
66
+ )
67
+ profile_path.write_text(json.dumps(profile, ensure_ascii=False, indent=2), encoding="utf-8")
68
+
69
+ conversation_path = base / "conversation_log.jsonl"
70
+ with conversation_path.open("a", encoding="utf-8") as fh:
71
+ fh.write(
72
+ json.dumps(
73
+ {
74
+ "ts": now,
75
+ "admin": admin_text,
76
+ "conny": conny_reply,
77
+ "clinic": clinic.get("name", ""),
78
+ },
79
+ ensure_ascii=False,
80
+ )
81
+ + "\n"
82
+ )
83
+
84
+ text_low = admin_text.lower()
85
+ if any(token in text_low for token in ("precio", "horario", "cliente", "servicio", "api", "instancia", "demo")):
86
+ facts_path = base / "business_facts.md"
87
+ with facts_path.open("a", encoding="utf-8") as fh:
88
+ fh.write(f"\n- {now}: {admin_text.strip()}\n")
89
+
90
+ if any(token in text_low for token in ("bug", "error", "falla", "terminal", "pm2", "webhook", "telegram", "whatsapp")):
91
+ ops_path = base / "ops_memory.md"
92
+ with ops_path.open("a", encoding="utf-8") as fh:
93
+ fh.write(f"\n- {now}: {admin_text.strip()}\n")
@@ -1197,23 +1197,28 @@ async def api_create_token(request: Request):
1197
1197
  if not clinic_label:
1198
1198
  raise HTTPException(status_code=400, detail="clinic_label requerido")
1199
1199
 
1200
+ token_type = str(body.get("token_type") or body.get("type") or "").strip().lower()
1201
+ admin_requested = bool(body.get("admin")) or token_type in ("admin", "admin_pro", "pro")
1202
+
1200
1203
  # Generar token
1201
- token = generate_activation_token(clinic_label)
1204
+ token = generate_admin_activation_token(clinic_label) if admin_requested else generate_activation_token(clinic_label)
1205
+ stored_label = f"ADMIN_PRO:{clinic_label}" if admin_requested else clinic_label
1202
1206
 
1203
1207
  # Calcular expiracion
1204
1208
  expires_at = (datetime.now() + timedelta(hours=Config.TOKEN_EXPIRY_HOURS)).isoformat()
1205
1209
 
1206
1210
  # Guardar en DB
1207
- saved = db.create_activation_token(token, clinic_label, expires_at)
1211
+ saved = db.create_activation_token(token, stored_label, expires_at)
1208
1212
  if not saved:
1209
1213
  raise HTTPException(status_code=500, detail="No se pudo guardar el token")
1210
1214
 
1211
- log.info(f"[api] token creado para '{clinic_label}': {token[:20]}...")
1215
+ log.info(f"[api] token creado para '{stored_label}': {token[:20]}...")
1212
1216
 
1213
1217
  return {
1214
1218
  "ok": True,
1215
1219
  "token": token,
1216
1220
  "clinic_label": clinic_label,
1221
+ "token_type": "admin_pro" if admin_requested else "business_owner",
1217
1222
  "expires_at": expires_at,
1218
1223
  "instructions": f"Envia este token exacto al administrador de {clinic_label}. Expira en {Config.TOKEN_EXPIRY_HOURS}h."
1219
1224
  }
@@ -1277,7 +1282,7 @@ async def api_auth_register(request: Request):
1277
1282
  if not email or not password or not name or not token:
1278
1283
  raise HTTPException(status_code=400, detail="Faltan campos requeridos")
1279
1284
 
1280
- if not token.startswith("ACTV-"):
1285
+ if not is_activation_token(token):
1281
1286
  raise HTTPException(status_code=400, detail="Token no valido")
1282
1287
 
1283
1288
  token_data = db.get_activation_token(token)
@@ -1304,10 +1309,11 @@ async def api_auth_register(request: Request):
1304
1309
  pass_hash = hash_password(password)
1305
1310
  try:
1306
1311
  with db._conn() as c:
1312
+ role = "admin_pro" if is_admin_activation_token(token) else "owner"
1307
1313
  c.execute("""
1308
1314
  INSERT OR REPLACE INTO admins (chat_id, email, password_hash, name, role, activated_by_token, is_active)
1309
- VALUES (?, ?, ?, ?, 'owner', ?, 1)
1310
- """, (f"owner_{secrets.token_hex(4)}", email, pass_hash, name, token))
1315
+ VALUES (?, ?, ?, ?, ?, ?, 1)
1316
+ """, (f"owner_{secrets.token_hex(4)}", email, pass_hash, name, role, token))
1311
1317
  except Exception as e:
1312
1318
  log.error(f"Error insertando admin: {e}")
1313
1319
 
@@ -1350,15 +1356,26 @@ async def api_auth_dev_register(request: Request):
1350
1356
  if not email or not password or not dev_token:
1351
1357
  raise HTTPException(status_code=400, detail="Todos los campos son requeridos")
1352
1358
 
1353
- if not Config.MASTER_API_KEY or not secrets.compare_digest(dev_token, Config.MASTER_API_KEY):
1354
- raise HTTPException(status_code=401, detail="Token de acceso para desarrolladores incorrecto")
1359
+ if Config.MASTER_API_KEY and secrets.compare_digest(dev_token, Config.MASTER_API_KEY):
1360
+ token_mode = "master"
1361
+ else:
1362
+ token_mode = "admin_pro"
1363
+ if not is_admin_activation_token(dev_token):
1364
+ raise HTTPException(status_code=401, detail="Token de acceso para desarrolladores incorrecto")
1365
+ token_data = db.get_activation_token(dev_token)
1366
+ if not token_data:
1367
+ raise HTTPException(status_code=404, detail="Token Conny Pro Admin inexistente")
1368
+ if token_data.get("used_at"):
1369
+ raise HTTPException(status_code=400, detail="El token Conny Pro Admin ya fue usado")
1355
1370
 
1356
1371
  hashed = hash_password(password)
1357
1372
  success = db.create_dev_account(email, hashed)
1358
1373
  if not success:
1359
1374
  raise HTTPException(status_code=500, detail="Error al registrar la cuenta de desarrollador")
1375
+ if token_mode == "admin_pro":
1376
+ db.consume_activation_token(dev_token, f"dev:{email}")
1360
1377
 
1361
- return {"ok": True, "message": "Cuenta de desarrollador registrada con exito"}
1378
+ return {"ok": True, "message": "Cuenta de desarrollador registrada con exito", "token_type": token_mode}
1362
1379
 
1363
1380
  @app.get("/api/tokens")
1364
1381
  async def api_list_tokens(request: Request):
@@ -178,7 +178,7 @@ except ImportError:
178
178
  # ══════════════════════════════════════════════════════════════════════════════
179
179
  # CONFIGURACIÓN
180
180
  # ══════════════════════════════════════════════════════════════════════════════
181
- VERSION = "9.7.0"
181
+ VERSION = "9.8.0"
182
182
  CONNY_HOME = os.getenv("CONNY_HOME", str(Path.home() / ".conny"))
183
183
  CONNY_DIR = os.getenv("CONNY_DIR", str(Path(__file__).resolve().parent))
184
184
  INSTANCES_DIR = os.getenv("INSTANCES_DIR", str(Path.home() / "conny-instances"))
@@ -5149,6 +5149,12 @@ def cmd_demo(args):
5149
5149
 
5150
5150
  ev_path = f"{inst.dir}/.env"
5151
5151
  update_env_key(ev_path, "DEMO_MODE", "false")
5152
+ # Also update project root .env for PM2-managed process
5153
+ if inst.is_base:
5154
+ try:
5155
+ update_env_key("/home/ubuntu/conny/.env", "DEMO_MODE", "false")
5156
+ except Exception:
5157
+ pass
5152
5158
  pm2_name = "conny" if inst.is_base else f"conny-{inst.name}"
5153
5159
  with Spinner("Desactivando modo demo...") as sp:
5154
5160
  pm2("restart", pm2_name)
@@ -5253,6 +5259,11 @@ def cmd_demo(args):
5253
5259
  update_env_key(ev_path, "DEMO_BUSINESS_NAME", business_name)
5254
5260
  update_env_key(ev_path, "DEMO_SECTOR", demo_sector)
5255
5261
  update_env_key(ev_path, "DEMO_SESSION_TTL", str(ttl_min * 60))
5262
+ if inst.is_base:
5263
+ try:
5264
+ update_env_key("/home/ubuntu/conny/.env", "DEMO_MODE", "true")
5265
+ except Exception:
5266
+ pass
5256
5267
  load_env.cache_clear() # Fix Bug 4: forzar lectura fresca del .env
5257
5268
  sp.finish("Configuración aplicada")
5258
5269
 
@@ -6399,11 +6410,15 @@ def cmd_token(args):
6399
6410
 
6400
6411
  Uso:
6401
6412
  conny token → genera para la instancia base
6413
+ conny token --admin → genera token Conny Pro Admin
6402
6414
  conny token 1 → genera para la instancia 1
6403
6415
  conny token "Clinica Demo" → genera con ese nombre
6404
6416
  conny token all → genera para todas las instancias
6405
6417
  """
6406
6418
  name = getattr(args, 'name', '') or getattr(args, 'subcommand', '') or ''
6419
+ admin_mode = bool(getattr(args, "admin", False)) or name.lower() in ("--admin", "admin", "pro")
6420
+ if name.lower() in ("--admin", "admin", "pro"):
6421
+ name = ""
6407
6422
 
6408
6423
  def _gen_token(inst, label_override=""):
6409
6424
  import sqlite3
@@ -6414,11 +6429,11 @@ def cmd_token(args):
6414
6429
  label = label_override or inst.label
6415
6430
 
6416
6431
  # Generar token
6417
- sanitized = re.sub(r'[^a-zA-Z0-9]', '', label.lower())[:10]
6432
+ sanitized = re.sub(r'[^a-zA-Z0-9]', '', label.lower())[:10].upper()
6418
6433
  if not sanitized:
6419
- sanitized = "generic"
6420
- entropy = secrets.token_hex(16).upper()
6421
- token = f"ACTV-{sanitized}-{entropy}"
6434
+ sanitized = "GENERIC"
6435
+ entropy = secrets.token_hex(18 if admin_mode else 16).upper()
6436
+ token = f"{'ADMN' if admin_mode else 'ACTV'}-{sanitized}-{entropy}"
6422
6437
 
6423
6438
  expires_at = (datetime.now() + timedelta(hours=72)).isoformat()
6424
6439
 
@@ -6435,18 +6450,23 @@ def cmd_token(args):
6435
6450
  created_at TEXT NOT NULL
6436
6451
  )
6437
6452
  """)
6453
+ stored_label = f"ADMIN_PRO:{label}" if admin_mode else label
6438
6454
  cur.execute("""
6439
- INSERT INTO activation_tokens
6455
+ INSERT INTO activation_tokens
6440
6456
  (token, clinic_label, expires_at, created_at)
6441
6457
  VALUES (?, ?, ?, ?)
6442
- """, (token, label, expires_at, datetime.now().isoformat()))
6458
+ """, (token, stored_label, expires_at, datetime.now().isoformat()))
6443
6459
  conn.commit()
6444
6460
  conn.close()
6445
6461
 
6446
- ok(f"Token generado offline para: {q(C.YLW, label)}")
6462
+ kind = "Conny Pro Admin" if admin_mode else "activación"
6463
+ ok(f"Token {kind} generado offline para: {q(C.YLW, label)}")
6447
6464
  print(f"\n {q(C.YLW, token, bold=True)}\n")
6448
6465
  info(f"Expira: {expires_at[:16]}")
6449
- info("Envíalo al admin — lo usará para desbloquear el Dashboard o en WhatsApp")
6466
+ if admin_mode:
6467
+ info("Activa el modo operador: el admin lo escribe en WhatsApp/Telegram para tomar control de la instancia.")
6468
+ else:
6469
+ info("Envíalo al admin — lo usará para desbloquear el Dashboard o en WhatsApp")
6450
6470
  except Exception as e:
6451
6471
  fail(f"No se pudo generar offline: {e}")
6452
6472
 
@@ -11710,6 +11730,7 @@ def main():
11710
11730
  parser.add_argument("--quiet", "-q", action="store_true")
11711
11731
  parser.add_argument("--json", "-j", action="store_true", help="Output JSON")
11712
11732
  parser.add_argument("--auto", "-y", action="store_true", help="Auto-confirm")
11733
+ parser.add_argument("--admin", action="store_true", help="Genera token Conny Pro Admin")
11713
11734
  parser.add_argument("--dry-run", "-n", action="store_true", dest="dry_run",
11714
11735
  help="Preview sin ejecutar (sync)")
11715
11736
  parser.add_argument("--no-restart", action="store_true", dest="no_restart",