@innvisor/conny-ai 9.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/.env.example +68 -0
  2. package/CHANGELOG.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +369 -0
  5. package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
  6. package/brand-assets/Conny.web.logo.png +0 -0
  7. package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
  8. package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
  9. package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
  10. package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
  11. package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
  12. package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
  13. package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
  14. package/brand-assets/conny-demo/manifest.json +22 -0
  15. package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
  16. package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
  17. package/brand-assets/conny-logo.png +0 -0
  18. package/brand-assets/web.background.png +0 -0
  19. package/brand_assets.py +323 -0
  20. package/conny +28 -0
  21. package/conny-chat.py +579 -0
  22. package/conny-omni.py +3843 -0
  23. package/conny.py +113 -0
  24. package/conny_agents/__init__.py +1 -0
  25. package/conny_agents/agenda.py +1 -0
  26. package/conny_agents/captacion.py +1 -0
  27. package/conny_agents/conocimiento.py +1 -0
  28. package/conny_agents/escalacion.py +1 -0
  29. package/conny_agents/objeciones.py +1 -0
  30. package/conny_agents/seguimiento.py +1 -0
  31. package/conny_app.py +287 -0
  32. package/conny_audio.py +350 -0
  33. package/conny_audio_learn.py +84 -0
  34. package/conny_brain_v10.py +804 -0
  35. package/conny_bridge.py +656 -0
  36. package/conny_calendar.py +169 -0
  37. package/conny_cli.py +11784 -0
  38. package/conny_cli_bb.py +437 -0
  39. package/conny_commands.py +243 -0
  40. package/conny_config.py +215 -0
  41. package/conny_core/__init__.py +3 -0
  42. package/conny_core/conversation_engine.py +446 -0
  43. package/conny_core/first_turn_ops.py +287 -0
  44. package/conny_core/persona_registry.py +157 -0
  45. package/conny_core/prompt_ops.py +561 -0
  46. package/conny_cron.py +72 -0
  47. package/conny_demo_v2.py +209 -0
  48. package/conny_demo_voice.py +134 -0
  49. package/conny_design.py +43 -0
  50. package/conny_doctor.py +319 -0
  51. package/conny_domino.py +696 -0
  52. package/conny_generator.py +447 -0
  53. package/conny_google_auth.py +159 -0
  54. package/conny_i18n.py +619 -0
  55. package/conny_init.py +509 -0
  56. package/conny_integrations/__init__.py +4 -0
  57. package/conny_integrations/llm.py +1 -0
  58. package/conny_integrations/vault.py +77 -0
  59. package/conny_integrations/whatsapp.py +1 -0
  60. package/conny_intelligence.py +65 -0
  61. package/conny_learning.py +154 -0
  62. package/conny_memory.py +243 -0
  63. package/conny_memory_engine.py +292 -0
  64. package/conny_nova_proxy.py +170 -0
  65. package/conny_nuke_robot_phrases.py +493 -0
  66. package/conny_pairing.py +253 -0
  67. package/conny_patch.py +291 -0
  68. package/conny_persona_cli.py +150 -0
  69. package/conny_router.py +308 -0
  70. package/conny_runtime_ops.py +271 -0
  71. package/conny_session.py +516 -0
  72. package/conny_skills/__init__.py +1 -0
  73. package/conny_skills/demo_mode.py +35 -0
  74. package/conny_skills/text_processing.py +1 -0
  75. package/conny_skills/tone_detection.py +1 -0
  76. package/conny_smart_features.py +333 -0
  77. package/conny_studio.py +161 -0
  78. package/conny_sync_fix.py +306 -0
  79. package/conny_tui.py +512 -0
  80. package/conny_tui_select.py +202 -0
  81. package/conny_ultra_config.py +411 -0
  82. package/conny_uncertainty.py +174 -0
  83. package/conny_utils.py +87 -0
  84. package/conny_voice.py +156 -0
  85. package/conny_voice_engine.py +124 -0
  86. package/conny_web_search.py +66 -0
  87. package/conny_weekly_report.py +85 -0
  88. package/conny_worm.py +88 -0
  89. package/core/__init__.py +25 -0
  90. package/ecosystem.config.js +24 -0
  91. package/fix_init.py +27 -0
  92. package/install.sh +78 -0
  93. package/knowledge_base.py +330 -0
  94. package/nova/rules/default.yaml +37 -0
  95. package/nova_bridge.py +509 -0
  96. package/npm/conny.js +471 -0
  97. package/package.json +102 -0
  98. package/personas/conny/base/default.yaml +35 -0
  99. package/personas/conny/base/estetica_whatsapp.yaml +36 -0
  100. package/requirements.txt +14 -0
  101. package/run.sh +47 -0
  102. package/search.py +465 -0
  103. package/smart_handoff.py +1150 -0
  104. package/src/__init__.py +0 -0
  105. package/src/conny/__init__.py +0 -0
  106. package/src/conny/admin/__init__.py +0 -0
  107. package/src/conny/admin/api.py +234 -0
  108. package/src/conny/admin/dashboard.py +772 -0
  109. package/src/conny/api/__init__.py +0 -0
  110. package/src/conny/api/routes.py +8851 -0
  111. package/src/conny/brain/__init__.py +15 -0
  112. package/src/conny/brain/engine.py +804 -0
  113. package/src/conny/brain/learning.py +154 -0
  114. package/src/conny/brain/memory.py +324 -0
  115. package/src/conny/brain/smart_features.py +333 -0
  116. package/src/conny/brain/uncertainty.py +167 -0
  117. package/src/conny/channels/__init__.py +0 -0
  118. package/src/conny/channels/audio.py +316 -0
  119. package/src/conny/channels/cli.py +11795 -0
  120. package/src/conny/channels/logo_art.py +11 -0
  121. package/src/conny/channels/voice.py +156 -0
  122. package/src/conny/core/__init__.py +0 -0
  123. package/src/conny/core/config.py +215 -0
  124. package/src/conny/core/cron.py +72 -0
  125. package/src/conny/core/messenger.py +563 -0
  126. package/src/conny/core/router.py +297 -0
  127. package/src/conny/core/session.py +312 -0
  128. package/src/conny/demo/__init__.py +0 -0
  129. package/src/conny/demo/handler.py +3110 -0
  130. package/src/conny/integrations/__init__.py +19 -0
  131. package/src/conny/integrations/calendar.py +169 -0
  132. package/src/conny/integrations/knowledge.py +312 -0
  133. package/src/conny/integrations/search.py +66 -0
  134. package/src/conny/personas/__init__.py +0 -0
  135. package/src/conny/personas/generator.py +447 -0
  136. package/src/conny/production/__init__.py +0 -0
  137. package/src/conny/production/domino.py +696 -0
  138. package/src/conny/production/guard.py +550 -0
  139. package/src/conny/production/handoff.py +1150 -0
  140. package/src/conny/production/monitor.py +353 -0
  141. package/src/conny/utils/__init__.py +2 -0
  142. package/src/conny/utils/helpers.py +75 -0
  143. package/src/conny/utils/i18n.py +619 -0
  144. package/src/core/admin_engines.py +772 -0
  145. package/src/core/globals.py +11845 -0
  146. package/src/core/orchestrator.py +273 -0
  147. package/src/core/production_monitor.py +353 -0
  148. package/src/core/runtime.py +5487 -0
  149. package/src/domain/onboarding_flow.py +230 -0
  150. package/src/domain/prompts/__init__.py +1 -0
  151. package/src/domain/prompts/prospect_pitch.py +282 -0
  152. package/src/domain/send_guard.py +636 -0
  153. package/src/domain/swarm/queen.py +96 -0
  154. package/src/infrastructure/llm_providers/engine.py +487 -0
  155. package/src/interfaces/mcp_server.py +73 -0
  156. package/src/interfaces/nova_bridge.py +58 -0
  157. package/src/interfaces/web/admin_api.py +1379 -0
  158. package/src/interfaces/web/app.py +9408 -0
  159. package/src/interfaces/web/demo_handler.py +3450 -0
  160. package/src/interfaces/web/static/generate_avatars.py +46 -0
  161. package/v7/__init__.py +46 -0
  162. package/v7/agents/__init__.py +46 -0
  163. package/v7/agents/agenda.py +77 -0
  164. package/v7/agents/base.py +216 -0
  165. package/v7/agents/captacion.py +60 -0
  166. package/v7/agents/conocimiento.py +69 -0
  167. package/v7/agents/escalacion.py +83 -0
  168. package/v7/agents/objeciones.py +109 -0
  169. package/v7/agents/seguimiento.py +71 -0
  170. package/v7/memory/__init__.py +46 -0
  171. package/v7/memory/patient_profile.py +200 -0
  172. package/v7/orchestrator.py +275 -0
  173. package/v7/postprocess.py +127 -0
  174. package/v7/router.py +239 -0
  175. package/verify_conversation_impl.py +48 -0
@@ -0,0 +1,202 @@
1
+ """conny_tui_select.py — Arrow-key interactive selection menu (Nova-style)."""
2
+ from __future__ import annotations
3
+
4
+ import getpass
5
+ import os
6
+ import sys
7
+ import shutil
8
+ import select as _select
9
+ from typing import List, Optional
10
+
11
+ IS_WINDOWS = sys.platform.startswith("win")
12
+ if not IS_WINDOWS:
13
+ import termios
14
+ import tty
15
+ else:
16
+ import msvcrt
17
+
18
+ # Brand colors
19
+ PURPLE = "\033[38;5;141m"
20
+ MAGENTA = "\033[38;5;213m"
21
+ CYAN = "\033[38;5;159m"
22
+ DIM = "\033[38;5;242m"
23
+ BOLD = "\033[1m"
24
+ RESET = "\033[0m"
25
+ GREEN = "\033[38;5;114m"
26
+ WHITE = "\033[38;5;231m"
27
+
28
+
29
+ def is_tty() -> bool:
30
+ try:
31
+ return bool(sys.stdin.isatty()) and bool(sys.stdout.isatty())
32
+ except Exception:
33
+ return False
34
+
35
+
36
+ def _term_width() -> int:
37
+ return shutil.get_terminal_size((80, 24)).columns
38
+
39
+
40
+ def select_menu(
41
+ options: List[str],
42
+ *,
43
+ title: str = "",
44
+ descriptions: Optional[List[str]] = None,
45
+ default: int = 0,
46
+ ) -> int:
47
+ """Interactive menu with arrow key navigation. Returns selected index."""
48
+ if not options:
49
+ return 0
50
+ if not is_tty():
51
+ return _fallback(options, title=title, default=default)
52
+
53
+ descriptions = descriptions or []
54
+ current = max(0, min(default, len(options) - 1))
55
+ line_count = 0
56
+
57
+ def draw(first=False):
58
+ nonlocal line_count
59
+ out = []
60
+ if not first and line_count:
61
+ out.append(f"\033[{line_count}F")
62
+ for _ in range(line_count):
63
+ out.append("\033[2K\033[1E")
64
+ out.append(f"\033[{line_count}F")
65
+
66
+ lc = 0
67
+ if title:
68
+ out.append(f"\n {CYAN}{BOLD}{title}{RESET}\n")
69
+ lc += 2
70
+
71
+ for i, opt in enumerate(options):
72
+ if i == current:
73
+ out.append(f" {MAGENTA}▶ {WHITE}{BOLD}{opt}{RESET}\n")
74
+ else:
75
+ out.append(f" {DIM}{opt}{RESET}\n")
76
+ lc += 1
77
+ if i < len(descriptions) and descriptions[i]:
78
+ out.append(f" {DIM}{descriptions[i][:60]}{RESET}\n")
79
+ lc += 1
80
+
81
+ out.append(f"\n {DIM}↑/↓ select · Enter confirm{RESET}\n")
82
+ lc += 2
83
+ line_count = lc
84
+ sys.stdout.write("".join(out))
85
+ sys.stdout.flush()
86
+
87
+ def read_key_unix():
88
+ fd = sys.stdin.fileno()
89
+ old = termios.tcgetattr(fd)
90
+ tty.setraw(fd)
91
+ try:
92
+ ch = os.read(fd, 1)
93
+ if ch in (b"\r", b"\n"):
94
+ termios.tcflush(fd, termios.TCIFLUSH)
95
+ return "ENTER"
96
+ if ch == b"\x03":
97
+ return "CTRL_C"
98
+ if ch == b"\x1b":
99
+ ready, _, _ = _select.select([fd], [], [], 0.05)
100
+ if not ready:
101
+ return "ESC"
102
+ ch2 = os.read(fd, 1)
103
+ if ch2 == b"[":
104
+ ready2, _, _ = _select.select([fd], [], [], 0.05)
105
+ if not ready2:
106
+ return "["
107
+ ch3 = os.read(fd, 1)
108
+ if ch3 == b"A": return "UP"
109
+ if ch3 == b"B": return "DOWN"
110
+ return ""
111
+ return ch.decode(errors="ignore")
112
+ finally:
113
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
114
+
115
+ def read_key_win():
116
+ while True:
117
+ if msvcrt.kbhit():
118
+ ch = msvcrt.getch()
119
+ if ch in (b"\r", b"\n"):
120
+ return "ENTER"
121
+ if ch == b"\x03":
122
+ return "CTRL_C"
123
+ if ch in (b"\x00", b"\xe0"):
124
+ ch2 = msvcrt.getch()
125
+ if ch2 == b"H": return "UP"
126
+ if ch2 == b"P": return "DOWN"
127
+ return ch.decode(errors="ignore")
128
+
129
+ read_key = read_key_win if IS_WINDOWS else read_key_unix
130
+
131
+ draw(first=True)
132
+ while True:
133
+ key = read_key()
134
+ if key == "ENTER":
135
+ sys.stdout.write("\n")
136
+ return current
137
+ if key == "CTRL_C":
138
+ sys.stdout.write("\n")
139
+ raise KeyboardInterrupt
140
+ if key == "UP" and current > 0:
141
+ current -= 1; draw()
142
+ elif key == "DOWN" and current < len(options) - 1:
143
+ current += 1; draw()
144
+ elif key.isdigit():
145
+ idx = int(key) - 1
146
+ if 0 <= idx < len(options):
147
+ current = idx; draw()
148
+
149
+
150
+ def _fallback(options, title="", default=0):
151
+ if title:
152
+ print(f"\n {title}")
153
+ for i, opt in enumerate(options, 1):
154
+ marker = "▸" if i - 1 == default else " "
155
+ print(f" {marker} {i}. {opt}")
156
+ try:
157
+ ans = input("\n → ")
158
+ except (EOFError, KeyboardInterrupt):
159
+ return default
160
+ if ans.isdigit() and 0 < int(ans) <= len(options):
161
+ return int(ans) - 1
162
+ return default
163
+
164
+
165
+ def confirm(text: str, default: bool = True) -> bool:
166
+ """Y/N as arrow-key selector (never disappears)."""
167
+ if not is_tty():
168
+ try:
169
+ ans = input(f" {text} (y/n) ")
170
+ return ans.lower() in ("y", "yes", "s", "si", "sí", "")
171
+ except: return default
172
+
173
+ options = ["Yes", "No"] if default else ["No", "Yes"]
174
+ idx = select_menu(options, title=f" {text}", default=0)
175
+ if default:
176
+ return idx == 0
177
+ else:
178
+ return idx == 1
179
+
180
+
181
+ def text_input(label: str, default: str = "", required: bool = True, is_password: bool = False) -> str:
182
+ """Text input that stays visible."""
183
+ suffix = f" {DIM}(default: {default}){RESET}" if default and not is_password else ""
184
+ while True:
185
+ try:
186
+ sys.stdout.write(f" {MAGENTA}▶{RESET} {WHITE}{BOLD}{label}{RESET}{suffix}: ")
187
+ sys.stdout.flush()
188
+ if is_password:
189
+ val = getpass.getpass(prompt="")
190
+ else:
191
+ val = sys.stdin.readline().strip()
192
+ except (EOFError, KeyboardInterrupt):
193
+ print()
194
+ return default
195
+ if val:
196
+ return val
197
+ if default:
198
+ return default
199
+ if not required:
200
+ return ""
201
+ sys.stdout.write(f" {DIM}(required){RESET}\n")
202
+ sys.stdout.flush()
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env python3
2
+ """CONNY ULTRA CONFIG v9.7.0 — interactive runtime control panel."""
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Tuple
12
+
13
+ from conny_runtime_ops import (
14
+ detect_tunnel_processes,
15
+ find_pm2_processes,
16
+ health_payload,
17
+ instance_runtime_info,
18
+ port_is_open,
19
+ python_candidates,
20
+ resolve_python,
21
+ rewrite_tunnel_command_port,
22
+ telegram_webhook_info,
23
+ write_env_value,
24
+ )
25
+ from conny_tui_select import (
26
+ BOLD,
27
+ CYAN,
28
+ DIM,
29
+ GREEN,
30
+ MAGENTA,
31
+ RESET,
32
+ WHITE,
33
+ confirm,
34
+ select_menu,
35
+ text_input,
36
+ )
37
+
38
+
39
+ def clear_screen() -> None:
40
+ print("\033[H\033[J", end="")
41
+
42
+
43
+ def wait_for_enter() -> None:
44
+ input(f"\n{DIM}[Presiona Enter para continuar]{RESET}")
45
+
46
+
47
+ def _mask(value: str) -> str:
48
+ value = str(value or "").strip()
49
+ if not value:
50
+ return "vacía"
51
+ if len(value) <= 8:
52
+ return "*" * len(value)
53
+ return value[:4] + "…" + value[-4:]
54
+
55
+
56
+ def _load_state(instance_name: str) -> Dict[str, Any]:
57
+ info = instance_runtime_info(instance_name)
58
+ health = health_payload(info["port"]) or {}
59
+ pm2_rows = find_pm2_processes(info["name"])
60
+ python = resolve_python(info["name"])
61
+ webhook = telegram_webhook_info(info["telegram_token"]) if info["platform"] == "telegram" else {}
62
+ tunnels = detect_tunnel_processes()
63
+ return {
64
+ "info": info,
65
+ "health": health,
66
+ "pm2": pm2_rows,
67
+ "python": python,
68
+ "webhook": webhook,
69
+ "tunnels": tunnels,
70
+ }
71
+
72
+
73
+ def _render_header(instance_name: str, subtitle: str) -> None:
74
+ clear_screen()
75
+ print(f"┌{'─'*68}┐")
76
+ print(f"│{BOLD} CONNY ULTRA CONFIG v9.7.0 {RESET}│")
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}┘")
81
+ print()
82
+
83
+
84
+ def _provider_env_keys() -> List[Tuple[str, str]]:
85
+ return [
86
+ ("GEMINI_API_KEY", "Gemini 1"),
87
+ ("GEMINI_API_KEY_2", "Gemini 2"),
88
+ ("GEMINI_API_KEY_3", "Gemini 3"),
89
+ ("GEMINI_API_KEY_4", "Gemini 4"),
90
+ ("GEMINI_API_KEY_5", "Gemini 5"),
91
+ ("GEMINI_API_KEY_6", "Gemini 6"),
92
+ ("GEMINI_API_KEY_7", "Gemini 7"),
93
+ ("OPENAI_API_KEY", "OpenAI"),
94
+ ("OPENROUTER_API_KEY", "OpenRouter"),
95
+ ("GROQ_API_KEY", "Groq"),
96
+ ("ANTHROPIC_API_KEY", "Anthropic"),
97
+ ("BRAVE_API_KEY", "Brave Search"),
98
+ ("APIFY_API_KEY", "Apify"),
99
+ ("SERP_API_KEY", "SerpAPI"),
100
+ ]
101
+
102
+
103
+ def _test_provider(provider_key: str, secret: str) -> Tuple[bool, str]:
104
+ provider_key = provider_key.upper()
105
+ secret = str(secret or "").strip()
106
+ if not secret:
107
+ return False, "sin API key"
108
+ try:
109
+ import httpx
110
+ except Exception:
111
+ return False, "httpx no disponible"
112
+ try:
113
+ with httpx.Client(timeout=12.0) as client:
114
+ if provider_key.startswith("GEMINI"):
115
+ response = client.post(
116
+ f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={secret}",
117
+ json={"contents": [{"parts": [{"text": "ping"}]}]},
118
+ )
119
+ elif provider_key == "OPENAI_API_KEY":
120
+ response = client.get(
121
+ "https://api.openai.com/v1/models",
122
+ headers={"Authorization": f"Bearer {secret}"},
123
+ )
124
+ elif provider_key == "OPENROUTER_API_KEY":
125
+ response = client.get(
126
+ "https://openrouter.ai/api/v1/models",
127
+ headers={"Authorization": f"Bearer {secret}"},
128
+ )
129
+ elif provider_key == "GROQ_API_KEY":
130
+ response = client.get(
131
+ "https://api.groq.com/openai/v1/models",
132
+ headers={"Authorization": f"Bearer {secret}"},
133
+ )
134
+ elif provider_key == "ANTHROPIC_API_KEY":
135
+ response = client.get(
136
+ "https://api.anthropic.com/v1/models",
137
+ headers={"x-api-key": secret, "anthropic-version": "2023-06-01"},
138
+ )
139
+ elif provider_key == "BRAVE_API_KEY":
140
+ response = client.get(
141
+ "https://api.search.brave.com/res/v1/web/search",
142
+ params={"q": "Conny AI"},
143
+ headers={"X-Subscription-Token": secret},
144
+ )
145
+ elif provider_key == "APIFY_API_KEY":
146
+ response = client.get(
147
+ "https://api.apify.com/v2/users/me",
148
+ params={"token": secret},
149
+ )
150
+ elif provider_key == "SERP_API_KEY":
151
+ response = client.get(
152
+ "https://serpapi.com/account",
153
+ params={"api_key": secret},
154
+ )
155
+ else:
156
+ return False, "proveedor no soportado"
157
+ except Exception as exc:
158
+ return False, str(exc)[:90]
159
+ if response.status_code < 300:
160
+ return True, f"HTTP {response.status_code}"
161
+ return False, f"HTTP {response.status_code}"
162
+
163
+
164
+ def _sync_telegram_webhook(state: Dict[str, Any]) -> Tuple[bool, str]:
165
+ info = state["info"]
166
+ token = info["telegram_token"]
167
+ base_url = info["base_url"]
168
+ secret = info["webhook_secret"]
169
+ if not token or not base_url or not secret:
170
+ return False, "faltan TELEGRAM_TOKEN, BASE_URL o WEBHOOK_SECRET"
171
+ target_url = f"{base_url.rstrip('/')}/webhook/{secret}"
172
+ try:
173
+ import httpx
174
+ with httpx.Client(timeout=10.0) as client:
175
+ response = client.post(
176
+ f"https://api.telegram.org/bot{token}/setWebhook",
177
+ json={"url": target_url},
178
+ )
179
+ payload = response.json()
180
+ if response.status_code == 200 and payload.get("ok"):
181
+ return True, target_url
182
+ return False, payload.get("description", f"HTTP {response.status_code}")
183
+ except Exception as exc:
184
+ return False, str(exc)[:90]
185
+
186
+
187
+ def _retarget_tunnels(target_port: int, tunnels: List[Dict[str, Any]]) -> Tuple[bool, str]:
188
+ changed = 0
189
+ for tunnel in tunnels:
190
+ current_cmd = str(tunnel.get("command", "")).strip()
191
+ new_cmd = rewrite_tunnel_command_port(current_cmd, target_port)
192
+ if not current_cmd or new_cmd == current_cmd:
193
+ continue
194
+ try:
195
+ subprocess.run(["kill", str(tunnel["pid"])], capture_output=True, check=False)
196
+ subprocess.Popen(
197
+ ["bash", "-lc", f"nohup {new_cmd} >/tmp/conny-tunnel-{tunnel['pid']}.log 2>&1 &"],
198
+ stdout=subprocess.DEVNULL,
199
+ stderr=subprocess.DEVNULL,
200
+ )
201
+ changed += 1
202
+ except Exception:
203
+ continue
204
+ if changed:
205
+ return True, f"{changed} túnel(es) reorientados a :{target_port}"
206
+ return False, "no encontré túneles compatibles para reorientar"
207
+
208
+
209
+ def module_network(instance_name: str) -> None:
210
+ state = _load_state(instance_name)
211
+ info = state["info"]
212
+ pm2_rows = state["pm2"]
213
+ tunnels = state["tunnels"]
214
+ port = info["port"]
215
+ _render_header(instance_name or "base", "NETWORK MANAGEMENT")
216
+ pm2_status = pm2_rows[0].get("pm2_env", {}).get("status", "offline") if pm2_rows else "not registered"
217
+ print(f"{GREEN}Puerto local esperado:{RESET} {port}")
218
+ print(f"{GREEN}Escucha en localhost:{RESET} {'sí' if port_is_open(port) else 'no'}")
219
+ print(f"{GREEN}Proceso PM2:{RESET} {info['pm2_name']} ({pm2_status})")
220
+ print(f"{GREEN}Túneles detectados:{RESET}")
221
+ if tunnels:
222
+ for tunnel in tunnels:
223
+ ports = ", ".join(str(p) for p in tunnel.get("ports", [])) or "sin puerto parseado"
224
+ print(f" {DIM}pid={tunnel['pid']} ports={ports} :: {tunnel['command'][:100]}{RESET}")
225
+ else:
226
+ print(f" {DIM}ninguno detectado{RESET}")
227
+
228
+ options = [
229
+ "Cambiar puerto de la instancia",
230
+ "Reorientar túneles al puerto actual",
231
+ "Volver",
232
+ ]
233
+ choice = select_menu(options, title="Acción de red")
234
+ if choice == 0:
235
+ new_port = text_input("Nuevo puerto", default=str(port))
236
+ if new_port.isdigit():
237
+ write_env_value(info["env_path"], "PORT", new_port)
238
+ meta_path = Path(info["root"]) / "instance.json"
239
+ if meta_path.exists():
240
+ try:
241
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
242
+ meta["port"] = int(new_port)
243
+ meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
244
+ except Exception:
245
+ pass
246
+ print(f"\n{GREEN}✓ Puerto actualizado a {new_port}. Reinicia la instancia para aplicarlo.{RESET}")
247
+ elif choice == 1:
248
+ ok, msg = _retarget_tunnels(port, tunnels)
249
+ tone = GREEN if ok else MAGENTA
250
+ print(f"\n{tone}{msg}{RESET}")
251
+ wait_for_enter()
252
+
253
+
254
+ def module_models(instance_name: str) -> None:
255
+ state = _load_state(instance_name)
256
+ info = state["info"]
257
+ env = info["env"]
258
+ _render_header(instance_name or "base", "MODELS & LLM PROVIDERS")
259
+ print(f"{GREEN}Modelos activos:{RESET}")
260
+ print(f" reasoning: {env.get('LLM_REASONING', 'google/gemini-2.5-pro')}")
261
+ print(f" fast: {env.get('LLM_FAST', 'google/gemini-2.5-flash')}")
262
+ print(f" lite: {env.get('LLM_LITE', 'google/gemini-2.5-flash-lite')}")
263
+ print()
264
+ for key, label in _provider_env_keys():
265
+ print(f" {label:<14} {DIM}{_mask(env.get(key, ''))}{RESET}")
266
+ print()
267
+
268
+ options = [
269
+ "Editar API key",
270
+ "Probar API key",
271
+ "Cambiar modelo por tier",
272
+ "Volver",
273
+ ]
274
+ choice = select_menu(options, title="Acción de modelos")
275
+ if choice == 0:
276
+ idx = select_menu([label for _, label in _provider_env_keys()], title="¿Cuál key quieres editar?")
277
+ key, label = _provider_env_keys()[idx]
278
+ current = env.get(key, "")
279
+ new_value = text_input(f"{label} API key", default=current, is_password=bool(current), required=False)
280
+ write_env_value(info["env_path"], key, new_value)
281
+ print(f"\n{GREEN}✓ {label} actualizada{RESET}")
282
+ elif choice == 1:
283
+ idx = select_menu([label for _, label in _provider_env_keys()], title="¿Cuál key quieres probar?")
284
+ key, label = _provider_env_keys()[idx]
285
+ ok, msg = _test_provider(key, env.get(key, ""))
286
+ print(f"\n{GREEN if ok else MAGENTA}{label}: {msg}{RESET}")
287
+ elif choice == 2:
288
+ tier = select_menu(["LLM_REASONING", "LLM_FAST", "LLM_LITE"], title="Tier")
289
+ keys = ["LLM_REASONING", "LLM_FAST", "LLM_LITE"]
290
+ selected = keys[tier]
291
+ current = env.get(selected, "")
292
+ new_value = text_input(f"{selected}", default=current or "google/gemini-2.5-flash")
293
+ write_env_value(info["env_path"], selected, new_value)
294
+ print(f"\n{GREEN}✓ {selected} actualizado{RESET}")
295
+ wait_for_enter()
296
+
297
+
298
+ def module_gateway(instance_name: str) -> None:
299
+ state = _load_state(instance_name)
300
+ info = state["info"]
301
+ webhook = state["webhook"]
302
+ _render_header(instance_name or "base", "GATEWAY & WEBHOOKS")
303
+ expected = f"{info['base_url'].rstrip('/')}/webhook/{info['webhook_secret']}" if info["base_url"] and info["webhook_secret"] else "incompleto"
304
+ print(f"{GREEN}BASE_URL:{RESET} {info['base_url'] or 'vacío'}")
305
+ print(f"{GREEN}Webhook esperado:{RESET} {expected}")
306
+ print(f"{GREEN}Webhook Telegram actual:{RESET} {webhook.get('url', 'sin registrar')}")
307
+ print(f"{GREEN}Pendientes:{RESET} {webhook.get('pending_update_count', 0)}")
308
+ if webhook.get("last_error_message"):
309
+ print(f"{MAGENTA}Último error:{RESET} {webhook['last_error_message']}")
310
+ print()
311
+ choice = select_menu(
312
+ ["Auto-sincronizar webhook", "Editar BASE_URL", "Volver"],
313
+ title="Acción de gateway",
314
+ )
315
+ if choice == 0:
316
+ ok, msg = _sync_telegram_webhook(state)
317
+ print(f"\n{GREEN if ok else MAGENTA}{msg}{RESET}")
318
+ elif choice == 1:
319
+ new_url = text_input("Nueva BASE_URL", default=info["base_url"], required=False)
320
+ write_env_value(info["env_path"], "BASE_URL", new_url)
321
+ print(f"\n{GREEN}✓ BASE_URL actualizada{RESET}")
322
+ wait_for_enter()
323
+
324
+
325
+ def module_environment(instance_name: str) -> None:
326
+ state = _load_state(instance_name)
327
+ info = state["info"]
328
+ _render_header(instance_name or "base", "ENVIRONMENT & PATH TUNING")
329
+ print(f"{GREEN}Intérprete activo detectado:{RESET} {state['python']['path'] if state['python'] else 'ninguno'}")
330
+ print(f"{GREEN}Candidatos:{RESET}")
331
+ candidates = python_candidates(info["name"])
332
+ for candidate in candidates:
333
+ marker = "✓" if candidate["exists"] else "·"
334
+ print(f" {marker} {candidate['source']:<18} {DIM}{candidate['path']}{RESET}")
335
+ print()
336
+ choice = select_menu(
337
+ ["Fijar intérprete manual para esta instancia", "Verificar run.sh", "Volver"],
338
+ title="Acción de entorno",
339
+ )
340
+ if choice == 0:
341
+ valid = [c for c in candidates if c["exists"]]
342
+ if not valid:
343
+ print(f"\n{MAGENTA}No encontré candidatos válidos.{RESET}")
344
+ else:
345
+ idx = select_menu([f"{c['source']} :: {c['path']}" for c in valid], title="Selecciona intérprete")
346
+ selected = valid[idx]
347
+ write_env_value(info["env_path"], "CONNY_PYTHON_BIN", selected["path"])
348
+ print(f"\n{GREEN}✓ CONNY_PYTHON_BIN fijado a {selected['path']}{RESET}")
349
+ elif choice == 1:
350
+ run_path = Path(info["root"]) / "run.sh"
351
+ print(f"\n{GREEN}{run_path}{RESET}")
352
+ print(run_path.read_text(encoding="utf-8", errors="replace")[:2000] if run_path.exists() else "run.sh no existe")
353
+ wait_for_enter()
354
+
355
+
356
+ def module_doctor(instance_name: str) -> None:
357
+ _render_header(instance_name or "base", "ADVANCED SYSTEM DOCTOR")
358
+ print(f"{MAGENTA}Iniciando Self-Healing...{RESET}\n")
359
+ try:
360
+ import conny_doctor
361
+ doctor = conny_doctor.ConnyDoctor(instance_name or "base")
362
+ asyncio.run(doctor.run_self_healing())
363
+ except Exception as exc:
364
+ print(f"{MAGENTA}Error ejecutando doctor: {exc}{RESET}")
365
+ wait_for_enter()
366
+
367
+
368
+ def run_ultra_config(instance_name: str = "") -> None:
369
+ active = (instance_name or "base").strip()
370
+ while True:
371
+ _render_header(active, "CONTROL TOTAL DEL USUARIO")
372
+ state = _load_state(active)
373
+ port = state["info"]["port"]
374
+ health = "online" if state["health"].get("status") == "online" else "offline"
375
+ print(f"{GREEN}Estado rápido:{RESET} puerto :{port} · health {health} · pm2 {state['info']['pm2_name']}")
376
+ print()
377
+ options = [
378
+ "NETWORK MANAGEMENT",
379
+ "MODELS & LLM PROVIDERS",
380
+ "GATEWAY & WEBHOOKS",
381
+ "ENVIRONMENT & PATH TUNING",
382
+ "ADVANCED SYSTEM DOCTOR",
383
+ "SALIR",
384
+ ]
385
+ descs = [
386
+ "Puertos locales, PM2 y túneles públicos",
387
+ "API keys, pruebas y tiering de modelos",
388
+ "Webhook esperado, URL activa y resincronización",
389
+ "Intérprete Python, overrides y run.sh",
390
+ "Autorreparación de runtime, deps y procesos",
391
+ "Cerrar configuración",
392
+ ]
393
+ choice = select_menu(options, title="Conny config", descriptions=descs)
394
+ if choice == 0:
395
+ module_network(active)
396
+ elif choice == 1:
397
+ module_models(active)
398
+ elif choice == 2:
399
+ module_gateway(active)
400
+ elif choice == 3:
401
+ module_environment(active)
402
+ elif choice == 4:
403
+ module_doctor(active)
404
+ else:
405
+ clear_screen()
406
+ return
407
+
408
+
409
+ if __name__ == "__main__":
410
+ selected = sys.argv[1] if len(sys.argv) > 1 else ""
411
+ run_ultra_config(selected)