@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/conny_runtime_ops.py
CHANGED
|
@@ -6,18 +6,23 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
8
|
import shutil
|
|
9
|
+
import select
|
|
10
|
+
import shlex
|
|
9
11
|
import socket
|
|
10
12
|
import subprocess
|
|
11
13
|
import sys
|
|
14
|
+
import time
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
from typing import Any, Dict, List, Optional
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
CONNY_HOME = Path(os.getenv("CONNY_HOME", str(Path.home() / ".conny")))
|
|
17
20
|
CONNY_DIR = Path(os.getenv("CONNY_DIR", str(Path(__file__).resolve().parent)))
|
|
18
|
-
INSTANCES_DIR = Path(os.getenv("INSTANCES_DIR", str(
|
|
21
|
+
INSTANCES_DIR = Path(os.getenv("INSTANCES_DIR", str(CONNY_HOME / "instances")))
|
|
22
|
+
ACTIVE_INSTANCE_PATH = Path(os.getenv("CONNY_ACTIVE_INSTANCE", str(CONNY_HOME / "active_instance")))
|
|
19
23
|
|
|
20
24
|
_TUNNEL_PORT_PATTERNS = (
|
|
25
|
+
re.compile(r"-R\s+\d+:(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)", re.I),
|
|
21
26
|
re.compile(r"localhost:(\d+)"),
|
|
22
27
|
re.compile(r"127\.0\.0\.1:(\d+)"),
|
|
23
28
|
re.compile(r"0\.0\.0\.0:(\d+)"),
|
|
@@ -26,6 +31,30 @@ _TUNNEL_PORT_PATTERNS = (
|
|
|
26
31
|
re.compile(r"lt\s+--port\s+(\d+)", re.I),
|
|
27
32
|
)
|
|
28
33
|
|
|
34
|
+
_PUBLIC_URL_PATTERN = re.compile(r"https://[a-zA-Z0-9.-]+(?:lhr\.life|localhost\.run)(?:/[^\s]*)?")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_ENV_KEY_PATTERN = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _clean_env_value(value: str) -> str:
|
|
41
|
+
value = str(value or "").strip()
|
|
42
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
43
|
+
value = value[1:-1].strip()
|
|
44
|
+
if value.lower() in {"pending", "none", "null"}:
|
|
45
|
+
return ""
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format_env_line(key: str, value: str) -> str:
|
|
50
|
+
value = _clean_env_value(value)
|
|
51
|
+
if not value:
|
|
52
|
+
return f"{key}="
|
|
53
|
+
if any(ch.isspace() for ch in value) or any(ch in value for ch in ['"', "'", "#"]):
|
|
54
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
55
|
+
return f'{key}="{escaped}"'
|
|
56
|
+
return f"{key}={value}"
|
|
57
|
+
|
|
29
58
|
|
|
30
59
|
def load_env_file(path: Path) -> Dict[str, str]:
|
|
31
60
|
data: Dict[str, str] = {}
|
|
@@ -36,7 +65,10 @@ def load_env_file(path: Path) -> Dict[str, str]:
|
|
|
36
65
|
if not line or line.startswith("#") or "=" not in line:
|
|
37
66
|
continue
|
|
38
67
|
key, value = line.split("=", 1)
|
|
39
|
-
|
|
68
|
+
key = key.strip()
|
|
69
|
+
if key.startswith("export "):
|
|
70
|
+
key = key.split(None, 1)[1].strip()
|
|
71
|
+
data[key] = _clean_env_value(value)
|
|
40
72
|
return data
|
|
41
73
|
|
|
42
74
|
|
|
@@ -47,22 +79,51 @@ def write_env_value(path: Path, key: str, value: str) -> None:
|
|
|
47
79
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
48
80
|
updated: List[str] = []
|
|
49
81
|
for raw_line in lines:
|
|
50
|
-
|
|
51
|
-
|
|
82
|
+
match = _ENV_KEY_PATTERN.match(raw_line)
|
|
83
|
+
if match and match.group(1) == key:
|
|
84
|
+
updated.append(_format_env_line(key, value))
|
|
52
85
|
found = True
|
|
53
86
|
else:
|
|
54
87
|
updated.append(raw_line)
|
|
55
88
|
if not found:
|
|
56
89
|
if updated and updated[-1].strip():
|
|
57
90
|
updated.append("")
|
|
58
|
-
updated.append(
|
|
91
|
+
updated.append(_format_env_line(key, value))
|
|
59
92
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
93
|
path.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
|
|
61
94
|
|
|
62
95
|
|
|
96
|
+
def active_instance_name() -> str:
|
|
97
|
+
explicit = os.getenv("CONNY_INSTANCE", "").strip()
|
|
98
|
+
if explicit:
|
|
99
|
+
return explicit
|
|
100
|
+
if ACTIVE_INSTANCE_PATH.exists():
|
|
101
|
+
return ACTIVE_INSTANCE_PATH.read_text(encoding="utf-8", errors="replace").strip()
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def set_active_instance(instance_name: str) -> None:
|
|
106
|
+
instance_name = str(instance_name or "").strip()
|
|
107
|
+
if not instance_name:
|
|
108
|
+
return
|
|
109
|
+
ACTIVE_INSTANCE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
ACTIVE_INSTANCE_PATH.write_text(instance_name + "\n", encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
|
|
63
113
|
def instance_root(instance_name: str) -> Path:
|
|
64
114
|
normalized = (instance_name or "").strip()
|
|
65
|
-
if normalized
|
|
115
|
+
if normalized == "base":
|
|
116
|
+
return CONNY_DIR
|
|
117
|
+
if normalized in {"", "conny", "default"}:
|
|
118
|
+
active = active_instance_name()
|
|
119
|
+
if active:
|
|
120
|
+
active_root = INSTANCES_DIR / active
|
|
121
|
+
if active_root.exists():
|
|
122
|
+
return active_root
|
|
123
|
+
if normalized in {"conny", "default"}:
|
|
124
|
+
candidate = INSTANCES_DIR / normalized
|
|
125
|
+
if candidate.exists():
|
|
126
|
+
return candidate
|
|
66
127
|
return CONNY_DIR
|
|
67
128
|
return INSTANCES_DIR / normalized
|
|
68
129
|
|
|
@@ -98,6 +159,64 @@ def instance_runtime_info(instance_name: str) -> Dict[str, Any]:
|
|
|
98
159
|
}
|
|
99
160
|
|
|
100
161
|
|
|
162
|
+
MIRRORED_ENV_KEYS = {
|
|
163
|
+
"INSTANCE_ID",
|
|
164
|
+
"PORT",
|
|
165
|
+
"HOST",
|
|
166
|
+
"BASE_URL",
|
|
167
|
+
"PUBLIC_BASE_URL",
|
|
168
|
+
"DASHBOARD_URL",
|
|
169
|
+
"PUBLIC_DASHBOARD_URL",
|
|
170
|
+
"DEMO_MODE",
|
|
171
|
+
"PLATFORM",
|
|
172
|
+
"SECTOR",
|
|
173
|
+
"BUSINESS_NAME",
|
|
174
|
+
"WEBHOOK_SECRET",
|
|
175
|
+
"TUNNEL_PROVIDER",
|
|
176
|
+
"TUNNEL_PID",
|
|
177
|
+
"TUNNEL_COMMAND",
|
|
178
|
+
"TELEGRAM_TOKEN",
|
|
179
|
+
"LLM_PROVIDER",
|
|
180
|
+
"LLM_MODEL",
|
|
181
|
+
"LLM_REASONING",
|
|
182
|
+
"LLM_FAST",
|
|
183
|
+
"LLM_LITE",
|
|
184
|
+
"CUSTOM_API_BASE",
|
|
185
|
+
"OPENAI_API_KEY",
|
|
186
|
+
"ANTHROPIC_API_KEY",
|
|
187
|
+
"GEMINI_API_KEY",
|
|
188
|
+
"GEMINI_API_KEY_2",
|
|
189
|
+
"GEMINI_API_KEY_3",
|
|
190
|
+
"GEMINI_API_KEY_4",
|
|
191
|
+
"GEMINI_API_KEY_5",
|
|
192
|
+
"GEMINI_API_KEY_6",
|
|
193
|
+
"GEMINI_API_KEY_7",
|
|
194
|
+
"GROQ_API_KEY",
|
|
195
|
+
"OPENROUTER_API_KEY",
|
|
196
|
+
"BRAVE_API_KEY",
|
|
197
|
+
"APIFY_API_KEY",
|
|
198
|
+
"SERP_API_KEY",
|
|
199
|
+
"WA_CLOUD_TOKEN",
|
|
200
|
+
"WA_PHONE_NUMBER_ID",
|
|
201
|
+
"CONNY_PYTHON_BIN",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def mirror_instance_env_to_base(instance_name: str) -> None:
|
|
206
|
+
info = instance_runtime_info(instance_name)
|
|
207
|
+
if info["root"] == CONNY_DIR:
|
|
208
|
+
return
|
|
209
|
+
env = load_env_file(info["env_path"])
|
|
210
|
+
if not env:
|
|
211
|
+
return
|
|
212
|
+
base_env_path = CONNY_DIR / ".env"
|
|
213
|
+
for key in sorted(MIRRORED_ENV_KEYS):
|
|
214
|
+
if key in env:
|
|
215
|
+
write_env_value(base_env_path, key, env.get(key, ""))
|
|
216
|
+
write_env_value(base_env_path, "ACTIVE_INSTANCE", info["name"])
|
|
217
|
+
set_active_instance(info["name"])
|
|
218
|
+
|
|
219
|
+
|
|
101
220
|
def port_is_open(port: int, host: str = "127.0.0.1", timeout: float = 0.5) -> bool:
|
|
102
221
|
try:
|
|
103
222
|
with socket.create_connection((host, int(port)), timeout=timeout):
|
|
@@ -186,6 +305,7 @@ def extract_tunnel_target_ports(command_line: str) -> List[int]:
|
|
|
186
305
|
def rewrite_tunnel_command_port(command_line: str, new_port: int) -> str:
|
|
187
306
|
updated = str(command_line or "")
|
|
188
307
|
replacements = [
|
|
308
|
+
(re.compile(r"(-R\s+\d+:(?:localhost|127\.0\.0\.1|0\.0\.0\.0):)(\d+)", re.I), rf"\g<1>{int(new_port)}"),
|
|
189
309
|
(re.compile(r"(localhost:)(\d+)"), rf"\g<1>{int(new_port)}"),
|
|
190
310
|
(re.compile(r"(127\.0\.0\.1:)(\d+)"), rf"\g<1>{int(new_port)}"),
|
|
191
311
|
(re.compile(r"(0\.0\.0\.0:)(\d+)"), rf"\g<1>{int(new_port)}"),
|
|
@@ -200,6 +320,78 @@ def rewrite_tunnel_command_port(command_line: str, new_port: int) -> str:
|
|
|
200
320
|
return updated
|
|
201
321
|
|
|
202
322
|
|
|
323
|
+
def localhost_run_command(port: int) -> List[str]:
|
|
324
|
+
return [
|
|
325
|
+
"ssh",
|
|
326
|
+
"-o", "StrictHostKeyChecking=no",
|
|
327
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
328
|
+
"-o", "ServerAliveInterval=60",
|
|
329
|
+
"-N",
|
|
330
|
+
"-R", f"80:localhost:{int(port)}",
|
|
331
|
+
"nokey@localhost.run",
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def start_localhost_run_tunnel(port: int, timeout: float = 25.0) -> Dict[str, Any]:
|
|
336
|
+
if not shutil.which("ssh"):
|
|
337
|
+
return {"ok": False, "url": "", "pid": None, "error": "ssh no está instalado"}
|
|
338
|
+
command = localhost_run_command(port)
|
|
339
|
+
try:
|
|
340
|
+
proc = subprocess.Popen(
|
|
341
|
+
command,
|
|
342
|
+
stdout=subprocess.PIPE,
|
|
343
|
+
stderr=subprocess.STDOUT,
|
|
344
|
+
text=True,
|
|
345
|
+
bufsize=1,
|
|
346
|
+
start_new_session=True,
|
|
347
|
+
)
|
|
348
|
+
except Exception as exc:
|
|
349
|
+
return {"ok": False, "url": "", "pid": None, "error": str(exc)}
|
|
350
|
+
|
|
351
|
+
deadline = time.time() + float(timeout)
|
|
352
|
+
output: List[str] = []
|
|
353
|
+
stream = proc.stdout
|
|
354
|
+
while time.time() < deadline:
|
|
355
|
+
if proc.poll() is not None:
|
|
356
|
+
break
|
|
357
|
+
if stream is None:
|
|
358
|
+
time.sleep(0.25)
|
|
359
|
+
continue
|
|
360
|
+
try:
|
|
361
|
+
ready, _, _ = select.select([stream], [], [], 0.5)
|
|
362
|
+
except Exception:
|
|
363
|
+
ready = []
|
|
364
|
+
if not ready:
|
|
365
|
+
continue
|
|
366
|
+
line = stream.readline()
|
|
367
|
+
if not line:
|
|
368
|
+
continue
|
|
369
|
+
output.append(line.rstrip())
|
|
370
|
+
match = _PUBLIC_URL_PATTERN.search(line)
|
|
371
|
+
if match:
|
|
372
|
+
return {
|
|
373
|
+
"ok": True,
|
|
374
|
+
"url": match.group(0).rstrip("/"),
|
|
375
|
+
"pid": proc.pid,
|
|
376
|
+
"command": shlex.join(command),
|
|
377
|
+
"port": int(port),
|
|
378
|
+
"output": "\n".join(output[-10:]),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
proc.terminate()
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
return {
|
|
386
|
+
"ok": False,
|
|
387
|
+
"url": "",
|
|
388
|
+
"pid": proc.pid,
|
|
389
|
+
"command": shlex.join(command),
|
|
390
|
+
"port": int(port),
|
|
391
|
+
"error": "\n".join(output[-10:]) or f"no public URL received after {int(timeout)}s",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
203
395
|
def detect_tunnel_processes() -> List[Dict[str, Any]]:
|
|
204
396
|
try:
|
|
205
397
|
result = subprocess.run(
|
package/conny_tui.py
CHANGED
|
@@ -30,6 +30,13 @@ CONNY_DIR = os.getenv("CONNY_DIR", str(Path(__file__).resolve().parent))
|
|
|
30
30
|
INSTANCES_DIR = os.getenv("INSTANCES_DIR", str(Path.home() / "conny-instances"))
|
|
31
31
|
CLI_SCRIPT = os.getenv("CONNY_CLI", str(Path(__file__).resolve().parent / "conny_cli.py"))
|
|
32
32
|
|
|
33
|
+
try:
|
|
34
|
+
_package_path = Path(CONNY_DIR) / "package.json"
|
|
35
|
+
if _package_path.exists():
|
|
36
|
+
VERSION = json.loads(_package_path.read_text(encoding="utf-8")).get("version", VERSION)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
33
40
|
# ── Colores curses (índices de par) ─────────────────────────────────────────
|
|
34
41
|
CP_LOGO1 = 1 # Rosa — letras CONNY (gradiente línea 1)
|
|
35
42
|
CP_LOGO2 = 10 # Rosa claro (gradiente línea 2)
|
package/conny_ultra_config.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Interactive runtime control panel for Conny."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
@@ -15,6 +15,7 @@ from conny_runtime_ops import (
|
|
|
15
15
|
find_pm2_processes,
|
|
16
16
|
health_payload,
|
|
17
17
|
instance_runtime_info,
|
|
18
|
+
mirror_instance_env_to_base,
|
|
18
19
|
port_is_open,
|
|
19
20
|
python_candidates,
|
|
20
21
|
resolve_python,
|
|
@@ -70,14 +71,19 @@ def _load_state(instance_name: str) -> Dict[str, Any]:
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
|
|
74
|
+
def _mirror_if_needed(info: Dict[str, Any]) -> None:
|
|
75
|
+
if info["name"] == "base":
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
mirror_instance_env_to_base(info["name"])
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
73
83
|
def _render_header(instance_name: str, subtitle: str) -> None:
|
|
74
84
|
clear_screen()
|
|
75
|
-
print(f"
|
|
76
|
-
print(f"
|
|
77
|
-
print(f"├{'─'*68}┤")
|
|
78
|
-
print(f"│ {WHITE}{BOLD}Instancia:{RESET} {instance_name or 'base':<57}│")
|
|
79
|
-
print(f"│ {CYAN}{subtitle:<66}{RESET}│")
|
|
80
|
-
print(f"└{'─'*68}┘")
|
|
85
|
+
print(f"{MAGENTA}{BOLD}Conny Config{RESET} {DIM}· {instance_name or 'base'} · {subtitle}{RESET}")
|
|
86
|
+
print(f"{DIM}{'─' * 60}{RESET}")
|
|
81
87
|
print()
|
|
82
88
|
|
|
83
89
|
|
|
@@ -162,7 +168,7 @@ def _test_provider(provider_key: str, secret: str) -> Tuple[bool, str]:
|
|
|
162
168
|
|
|
163
169
|
|
|
164
170
|
def _sync_telegram_webhook(state: Dict[str, Any]) -> Tuple[bool, str]:
|
|
165
|
-
info = state["info"]
|
|
171
|
+
info = instance_runtime_info(state["info"]["name"])
|
|
166
172
|
token = info["telegram_token"]
|
|
167
173
|
base_url = info["base_url"]
|
|
168
174
|
secret = info["webhook_secret"]
|
|
@@ -170,6 +176,8 @@ def _sync_telegram_webhook(state: Dict[str, Any]) -> Tuple[bool, str]:
|
|
|
170
176
|
return False, "faltan TELEGRAM_TOKEN, BASE_URL o WEBHOOK_SECRET"
|
|
171
177
|
target_url = f"{base_url.rstrip('/')}/webhook/{secret}"
|
|
172
178
|
try:
|
|
179
|
+
if info.get("pm2_name"):
|
|
180
|
+
subprocess.run(["pm2", "restart", info["pm2_name"], "--update-env"], capture_output=True, check=False, timeout=20)
|
|
173
181
|
import httpx
|
|
174
182
|
with httpx.Client(timeout=10.0) as client:
|
|
175
183
|
response = client.post(
|
|
@@ -235,6 +243,7 @@ def module_network(instance_name: str) -> None:
|
|
|
235
243
|
new_port = text_input("Nuevo puerto", default=str(port))
|
|
236
244
|
if new_port.isdigit():
|
|
237
245
|
write_env_value(info["env_path"], "PORT", new_port)
|
|
246
|
+
_mirror_if_needed(info)
|
|
238
247
|
meta_path = Path(info["root"]) / "instance.json"
|
|
239
248
|
if meta_path.exists():
|
|
240
249
|
try:
|
|
@@ -278,6 +287,7 @@ def module_models(instance_name: str) -> None:
|
|
|
278
287
|
current = env.get(key, "")
|
|
279
288
|
new_value = text_input(f"{label} API key", default=current, is_password=bool(current), required=False)
|
|
280
289
|
write_env_value(info["env_path"], key, new_value)
|
|
290
|
+
_mirror_if_needed(info)
|
|
281
291
|
print(f"\n{GREEN}✓ {label} actualizada{RESET}")
|
|
282
292
|
elif choice == 1:
|
|
283
293
|
idx = select_menu([label for _, label in _provider_env_keys()], title="¿Cuál key quieres probar?")
|
|
@@ -291,6 +301,7 @@ def module_models(instance_name: str) -> None:
|
|
|
291
301
|
current = env.get(selected, "")
|
|
292
302
|
new_value = text_input(f"{selected}", default=current or "google/gemini-2.5-flash")
|
|
293
303
|
write_env_value(info["env_path"], selected, new_value)
|
|
304
|
+
_mirror_if_needed(info)
|
|
294
305
|
print(f"\n{GREEN}✓ {selected} actualizado{RESET}")
|
|
295
306
|
wait_for_enter()
|
|
296
307
|
|
|
@@ -318,6 +329,8 @@ def module_gateway(instance_name: str) -> None:
|
|
|
318
329
|
elif choice == 1:
|
|
319
330
|
new_url = text_input("Nueva BASE_URL", default=info["base_url"], required=False)
|
|
320
331
|
write_env_value(info["env_path"], "BASE_URL", new_url)
|
|
332
|
+
write_env_value(info["env_path"], "PUBLIC_BASE_URL", new_url)
|
|
333
|
+
_mirror_if_needed(info)
|
|
321
334
|
print(f"\n{GREEN}✓ BASE_URL actualizada{RESET}")
|
|
322
335
|
wait_for_enter()
|
|
323
336
|
|
|
@@ -345,6 +358,7 @@ def module_environment(instance_name: str) -> None:
|
|
|
345
358
|
idx = select_menu([f"{c['source']} :: {c['path']}" for c in valid], title="Selecciona intérprete")
|
|
346
359
|
selected = valid[idx]
|
|
347
360
|
write_env_value(info["env_path"], "CONNY_PYTHON_BIN", selected["path"])
|
|
361
|
+
_mirror_if_needed(info)
|
|
348
362
|
print(f"\n{GREEN}✓ CONNY_PYTHON_BIN fijado a {selected['path']}{RESET}")
|
|
349
363
|
elif choice == 1:
|
|
350
364
|
run_path = Path(info["root"]) / "run.sh"
|
|
@@ -358,7 +372,7 @@ def module_doctor(instance_name: str) -> None:
|
|
|
358
372
|
print(f"{MAGENTA}Iniciando Self-Healing...{RESET}\n")
|
|
359
373
|
try:
|
|
360
374
|
import conny_doctor
|
|
361
|
-
doctor = conny_doctor.ConnyDoctor(instance_name or "
|
|
375
|
+
doctor = conny_doctor.ConnyDoctor(instance_name or "conny")
|
|
362
376
|
asyncio.run(doctor.run_self_healing())
|
|
363
377
|
except Exception as exc:
|
|
364
378
|
print(f"{MAGENTA}Error ejecutando doctor: {exc}{RESET}")
|
|
@@ -366,9 +380,9 @@ def module_doctor(instance_name: str) -> None:
|
|
|
366
380
|
|
|
367
381
|
|
|
368
382
|
def run_ultra_config(instance_name: str = "") -> None:
|
|
369
|
-
active = (instance_name or "
|
|
383
|
+
active = (instance_name or "conny").strip()
|
|
370
384
|
while True:
|
|
371
|
-
_render_header(active, "
|
|
385
|
+
_render_header(active, "Runtime overview")
|
|
372
386
|
state = _load_state(active)
|
|
373
387
|
port = state["info"]["port"]
|
|
374
388
|
health = "online" if state["health"].get("status") == "online" else "offline"
|
package/conny_utils.py
CHANGED
|
@@ -6,12 +6,21 @@ import re
|
|
|
6
6
|
from typing import Any, Dict, List, Optional
|
|
7
7
|
|
|
8
8
|
ACTIVATION_PREFIX = "ACTV-"
|
|
9
|
+
ADMIN_ACTIVATION_PREFIX = "ADMN-"
|
|
9
10
|
INVITE_PREFIX = "JINV-"
|
|
10
11
|
|
|
11
12
|
def is_activation_token(text: str) -> bool:
|
|
12
13
|
"""Detecta si el mensaje es un token de activacion."""
|
|
13
14
|
t = text.strip().upper()
|
|
14
|
-
return
|
|
15
|
+
return (
|
|
16
|
+
(t.startswith(ACTIVATION_PREFIX) and len(t) >= 30)
|
|
17
|
+
or is_admin_activation_token(t)
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def is_admin_activation_token(text: str) -> bool:
|
|
21
|
+
"""Detecta tokens de Conny Pro Admin."""
|
|
22
|
+
t = text.strip().upper()
|
|
23
|
+
return t.startswith(ADMIN_ACTIVATION_PREFIX) and len(t) >= 30
|
|
15
24
|
|
|
16
25
|
def is_invite_token(text: str) -> bool:
|
|
17
26
|
"""Detecta si el mensaje es un token de invitacion."""
|
|
@@ -24,12 +33,21 @@ def generate_activation_token(label: str) -> str:
|
|
|
24
33
|
Formato: ACTV-[label_sanitizado]-[32_chars_hex]
|
|
25
34
|
"""
|
|
26
35
|
import string
|
|
27
|
-
sanitized = re.sub(r'[^a-zA-Z0-9]', '', label.lower())[:10]
|
|
36
|
+
sanitized = re.sub(r'[^a-zA-Z0-9]', '', label.lower())[:10].upper()
|
|
28
37
|
if not sanitized:
|
|
29
|
-
sanitized = "
|
|
38
|
+
sanitized = "GENERIC"
|
|
30
39
|
entropy = secrets.token_hex(16).upper()
|
|
31
40
|
return f"{ACTIVATION_PREFIX}{sanitized}-{entropy}"
|
|
32
41
|
|
|
42
|
+
def generate_admin_activation_token(label: str) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Genera un token de activacion para Conny Pro Admin.
|
|
45
|
+
Este token activa una cuenta operadora, no una conversacion de cliente.
|
|
46
|
+
"""
|
|
47
|
+
sanitized = (re.sub(r'[^a-zA-Z0-9]', '', label.lower())[:10] or "admin").upper()
|
|
48
|
+
entropy = secrets.token_hex(18).upper()
|
|
49
|
+
return f"{ADMIN_ACTIVATION_PREFIX}{sanitized}-{entropy}"
|
|
50
|
+
|
|
33
51
|
def hash_password(password: str) -> str:
|
|
34
52
|
"""Hash de contrasena con PBKDF2 + salt."""
|
|
35
53
|
salt = secrets.token_hex(16)
|
package/ecosystem.config.js
CHANGED
|
@@ -10,6 +10,16 @@ module.exports = {
|
|
|
10
10
|
error_file: "/home/ubuntu/conny/logs/conny-error.log",
|
|
11
11
|
watch: false,
|
|
12
12
|
},
|
|
13
|
+
{
|
|
14
|
+
name: "whatsapp-bridge",
|
|
15
|
+
script: "/home/ubuntu/whatsapp-bridge/start.sh",
|
|
16
|
+
cwd: "/home/ubuntu/whatsapp-bridge",
|
|
17
|
+
restart_delay: 3000,
|
|
18
|
+
max_restarts: 10,
|
|
19
|
+
out_file: "/home/ubuntu/whatsapp-bridge/logs/bridge.log",
|
|
20
|
+
error_file: "/home/ubuntu/whatsapp-bridge/logs/bridge-error.log",
|
|
21
|
+
watch: false,
|
|
22
|
+
},
|
|
13
23
|
{
|
|
14
24
|
name: "conny-clinica-de-las-americas",
|
|
15
25
|
script: "/home/ubuntu/conny-instances/clinica-de-las-americas/run.sh",
|
|
@@ -21,4 +31,4 @@ module.exports = {
|
|
|
21
31
|
watch: false,
|
|
22
32
|
}
|
|
23
33
|
]
|
|
24
|
-
}
|
|
34
|
+
};
|
package/install.sh
CHANGED
|
@@ -6,11 +6,54 @@ set -e
|
|
|
6
6
|
C_PRIMARY="\033[38;5;135m"
|
|
7
7
|
C_SUCCESS="\033[38;5;46m"
|
|
8
8
|
C_MUTED="\033[38;5;240m"
|
|
9
|
+
C_WARN="\033[38;5;214m"
|
|
9
10
|
BOLD="\033[1m"
|
|
10
11
|
RESET="\033[0m"
|
|
11
12
|
|
|
12
|
-
echo -e "\n ${C_PRIMARY}${BOLD}✦ Conny AI
|
|
13
|
-
echo -e " ${C_MUTED}
|
|
13
|
+
echo -e "\n ${C_PRIMARY}${BOLD}✦ Conny AI Installer${RESET}"
|
|
14
|
+
echo -e " ${C_MUTED}Production-ready AI receptionist runtime for WhatsApp and Telegram.${RESET}"
|
|
15
|
+
echo -e " ${C_MUTED}────────────────────────────────────────────────────────${RESET}"
|
|
16
|
+
|
|
17
|
+
TMP_LOGS=()
|
|
18
|
+
|
|
19
|
+
cleanup_logs() {
|
|
20
|
+
for log_file in "${TMP_LOGS[@]}"; do
|
|
21
|
+
[ -f "$log_file" ] && rm -f "$log_file"
|
|
22
|
+
done
|
|
23
|
+
}
|
|
24
|
+
trap cleanup_logs EXIT
|
|
25
|
+
|
|
26
|
+
run_with_activity() {
|
|
27
|
+
local label="$1"
|
|
28
|
+
shift
|
|
29
|
+
local log_file
|
|
30
|
+
log_file="$(mktemp)"
|
|
31
|
+
TMP_LOGS+=("$log_file")
|
|
32
|
+
|
|
33
|
+
echo -ne " ${C_PRIMARY}⠋${RESET} ${label}"
|
|
34
|
+
"$@" >"$log_file" 2>&1 &
|
|
35
|
+
local pid=$!
|
|
36
|
+
local frames=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
|
37
|
+
local i=0
|
|
38
|
+
local elapsed=0
|
|
39
|
+
|
|
40
|
+
while kill -0 "$pid" 2>/dev/null; do
|
|
41
|
+
printf "\r ${C_PRIMARY}%s${RESET} %s ${C_MUTED}(%ss)${RESET}" "${frames[$((i % ${#frames[@]}))]}" "$label" "$elapsed"
|
|
42
|
+
sleep 1
|
|
43
|
+
i=$((i + 1))
|
|
44
|
+
elapsed=$((elapsed + 1))
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
if wait "$pid"; then
|
|
48
|
+
printf "\r ${C_SUCCESS}✓${RESET} %s ${C_MUTED}(%ss)${RESET}\n" "$label" "$elapsed"
|
|
49
|
+
return 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
printf "\r \033[31m✕${RESET} %s\n" "$label"
|
|
53
|
+
echo -e " \033[31mCommand failed. Installer log:${RESET}"
|
|
54
|
+
sed 's/^/ /' "$log_file"
|
|
55
|
+
return 1
|
|
56
|
+
}
|
|
14
57
|
|
|
15
58
|
# Handle sudo gracefully (Termux / Root environments)
|
|
16
59
|
SUDO=""
|
|
@@ -18,20 +61,22 @@ if command -v sudo &> /dev/null; then
|
|
|
18
61
|
SUDO="sudo"
|
|
19
62
|
fi
|
|
20
63
|
|
|
21
|
-
# 1. Install chafa if possible
|
|
64
|
+
# 1. Install chafa if possible. In Termux/proot, `pkg` exists but cannot run as root.
|
|
22
65
|
if ! command -v chafa &> /dev/null; then
|
|
23
|
-
echo -e "\n ${BOLD}1.
|
|
24
|
-
|
|
25
|
-
|
|
66
|
+
echo -e "\n ${BOLD}1. Preparing terminal visuals${RESET}"
|
|
67
|
+
CURRENT_UID="$(id -u 2>/dev/null || echo 1)"
|
|
68
|
+
if command -v pkg &> /dev/null && [ "$CURRENT_UID" != "0" ]; then
|
|
69
|
+
run_with_activity "Installing optional True-Color renderer with pkg" pkg install -y chafa || true
|
|
26
70
|
elif command -v apt-get &> /dev/null; then
|
|
27
|
-
$SUDO apt-get update -yqq && $SUDO apt-get install -yqq chafa || true
|
|
71
|
+
run_with_activity "Installing optional True-Color renderer with apt" bash -c "$SUDO apt-get update -yqq && $SUDO apt-get install -yqq chafa" || true
|
|
28
72
|
elif command -v brew &> /dev/null; then
|
|
29
|
-
brew install chafa || true
|
|
73
|
+
run_with_activity "Installing optional True-Color renderer with Homebrew" brew install chafa || true
|
|
30
74
|
else
|
|
31
|
-
echo -e " ${
|
|
75
|
+
echo -e " ${C_WARN}!${RESET} Optional renderer not available. Conny will use the classic logo."
|
|
32
76
|
fi
|
|
33
77
|
else
|
|
34
|
-
echo -e "\n ${BOLD}1.
|
|
78
|
+
echo -e "\n ${BOLD}1. Terminal visuals ready${RESET}"
|
|
79
|
+
echo -e " ${C_SUCCESS}✓${RESET} True-Color renderer detected."
|
|
35
80
|
fi
|
|
36
81
|
|
|
37
82
|
# 2. Verify Python sanely (3.9+), without hardcoding minor versions
|
|
@@ -51,28 +96,39 @@ done
|
|
|
51
96
|
|
|
52
97
|
if [ -n "$PYTHON_BIN" ]; then
|
|
53
98
|
PY_VERSION="$($PYTHON_BIN -c 'import sys; print(".".join(map(str, sys.version_info[:3])))')"
|
|
54
|
-
echo -e "\n ${BOLD}2.
|
|
99
|
+
echo -e "\n ${BOLD}2. Runtime compatibility${RESET}"
|
|
100
|
+
echo -e " ${C_SUCCESS}✓${RESET} Python runtime detected: ${C_SUCCESS}${PYTHON_BIN} ${PY_VERSION}${RESET}"
|
|
55
101
|
else
|
|
56
|
-
echo -e "\n ${BOLD}2.
|
|
57
|
-
echo -e " ${
|
|
102
|
+
echo -e "\n ${BOLD}2. Runtime compatibility${RESET}"
|
|
103
|
+
echo -e " ${C_WARN}!${RESET} Python 3.9+ was not detected locally."
|
|
104
|
+
echo -e " ${C_MUTED}Conny will try to provision its isolated runtime on first launch.${RESET}"
|
|
58
105
|
fi
|
|
59
106
|
|
|
60
107
|
# 3. Install NPM Package
|
|
61
108
|
if ! command -v npm &> /dev/null; then
|
|
62
|
-
echo -e "\n \033[31mError: Node.js
|
|
109
|
+
echo -e "\n \033[31mError: Node.js and npm are required before installing Conny.\033[0m"
|
|
63
110
|
exit 1
|
|
64
111
|
fi
|
|
65
112
|
|
|
66
|
-
echo -e "\n ${BOLD}3.
|
|
67
|
-
npm uninstall -g conny-ai @blackboss/conny || true
|
|
113
|
+
echo -e "\n ${BOLD}3. Removing previous global builds${RESET}"
|
|
114
|
+
run_with_activity "Cleaning old Conny packages" npm uninstall -g conny-ai @innvisor/conny-ai @blackboss/conny || true
|
|
68
115
|
|
|
69
|
-
echo -e "\n ${BOLD}4.
|
|
70
|
-
|
|
116
|
+
echo -e "\n ${BOLD}4. Installing Conny from GitHub${RESET}"
|
|
117
|
+
echo -e " ${C_MUTED}Source: ${CONNY_INSTALL_PACKAGE:-github:sxrubyo/conny#main}${RESET}"
|
|
118
|
+
run_with_activity "Downloading and linking Conny CLI" npm install -g "${CONNY_INSTALL_PACKAGE:-github:sxrubyo/conny#main}"
|
|
71
119
|
|
|
72
|
-
echo -e "\n ${BOLD}5.
|
|
120
|
+
echo -e "\n ${BOLD}5. Verifying the command line experience${RESET}"
|
|
73
121
|
if command -v conny >/dev/null 2>&1; then
|
|
74
|
-
conny --version
|
|
122
|
+
if ! conny --version; then
|
|
123
|
+
echo -e "\n \033[31mError: Conny was installed, but the CLI could not start.\033[0m"
|
|
124
|
+
exit 1
|
|
125
|
+
fi
|
|
126
|
+
run_with_activity "Preparing Python runtime and required CLI packages" conny --bootstrap-check
|
|
127
|
+
else
|
|
128
|
+
echo -e "\n \033[31mError: npm finished, but the 'conny' command is not available in PATH.\033[0m"
|
|
129
|
+
exit 1
|
|
75
130
|
fi
|
|
76
131
|
|
|
77
|
-
echo -e "\n ${C_SUCCESS}${BOLD}
|
|
78
|
-
echo -e "
|
|
132
|
+
echo -e "\n ${C_SUCCESS}${BOLD}✓ Conny is installed and ready.${RESET}"
|
|
133
|
+
echo -e " Start your first guided setup with ${C_PRIMARY}conny init${RESET}."
|
|
134
|
+
echo -e " For system repair and diagnostics, run ${C_PRIMARY}conny doctor --fix${RESET}.\n"
|