@innvisor/conny-ai 9.7.0 → 9.8.2
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/CHANGELOG.md +66 -0
- package/README.md +17 -1
- package/conny_app.py +9 -3
- package/conny_cli.py +103 -11
- package/conny_core/evolution.py +112 -0
- package/conny_core/first_turn_ops.py +16 -20
- package/conny_core/prompt_ops.py +62 -0
- package/conny_demo_voice.py +1 -1
- package/conny_doctor.py +287 -2
- package/conny_domino.py +2 -2
- package/conny_generator.py +1 -1
- package/conny_i18n.py +81 -2
- package/conny_init.py +254 -41
- package/conny_runtime_ops.py +198 -6
- package/conny_tui.py +7 -0
- package/conny_ultra_config.py +25 -11
- package/conny_utils.py +21 -3
- package/ecosystem.config.js +11 -1
- package/install.sh +78 -22
- package/npm/conny.js +75 -21
- package/package.json +12 -2
- package/run.sh +7 -0
- package/src/conny/admin/dashboard.py +35 -4
- package/src/conny/admin_memory.py +93 -0
- package/src/conny/api/routes.py +26 -9
- package/src/conny/channels/cli.py +30 -9
- package/src/conny/demo/handler.py +23 -23
- package/src/conny/personas/generator.py +1 -1
- package/src/conny/production/domino.py +2 -2
- package/src/conny/production/guard.py +4 -4
- package/src/core/admin_engines.py +51 -48
- package/src/core/globals.py +110 -9
- package/src/core/production_monitor.py +63 -38
- package/src/core/runtime.py +343 -305
- package/src/domain/prompts/prospect_pitch.py +11 -11
- package/src/domain/send_guard.py +4 -4
- package/src/interfaces/web/app.py +91 -27
- package/src/interfaces/web/demo_admin_commands.py +165 -0
- package/src/interfaces/web/demo_handler.py +178 -34
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +0 -22
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +0 -11
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +0 -11
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +0 -22
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +0 -11
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +0 -11
- package/brand-assets/conny-demo/manifest.json +0 -22
- package/brand-assets/conny-demo/processed/business-identity.txt +0 -7
- package/brand-assets/conny-demo/raw/business-identity.txt +0 -7
- package/fix_init.py +0 -27
- 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} ·
|
|
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
|
-
|
|
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")("
|
|
389
|
-
|
|
390
|
-
|
|
430
|
+
console.log(chalk.hex("#8B5CF6")("Verifying Python dependencies..."));
|
|
431
|
+
const runtime = resolveRuntime() || ensureRuntime(spinner);
|
|
432
|
+
installRuntimeDependencies(runtime, spinner, true);
|
|
391
433
|
|
|
392
|
-
|
|
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
|
-
|
|
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,16 +503,23 @@ 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();
|
|
462
518
|
process.exit(0);
|
|
463
519
|
}
|
|
464
520
|
|
|
465
|
-
|
|
466
|
-
printBanner();
|
|
467
|
-
}
|
|
521
|
+
const launchArgs = args.length === 0 ? ["new"] : args;
|
|
468
522
|
|
|
469
|
-
if (!execConny(
|
|
523
|
+
if (!execConny(launchArgs)) {
|
|
470
524
|
fail(`No pude iniciar Conny desde ${connyHome}`);
|
|
471
525
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@innvisor/conny-ai",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.8.2",
|
|
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",
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
716
|
-
|
|
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")
|
package/src/conny/api/routes.py
CHANGED
|
@@ -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,
|
|
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 '{
|
|
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
|
|
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 (?, ?, ?, ?,
|
|
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
|
|
1354
|
-
|
|
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.
|
|
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 = "
|
|
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,
|
|
6458
|
+
""", (token, stored_label, expires_at, datetime.now().isoformat()))
|
|
6443
6459
|
conn.commit()
|
|
6444
6460
|
conn.close()
|
|
6445
6461
|
|
|
6446
|
-
|
|
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
|
-
|
|
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",
|