@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.
- package/.env.example +68 -0
- package/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/brand-assets/A_dark_luxury_web_background_202605210700.jpeg +0 -0
- package/brand-assets/Conny.web.logo.png +0 -0
- package/brand-assets/Logo_Conny_Petalo_Claro.png +0 -0
- package/brand-assets/cl-nica-de-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-de-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-de-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/manifest.json +22 -0
- package/brand-assets/cl-nica-las-am-ricas/processed/business-identity.txt +11 -0
- package/brand-assets/cl-nica-las-am-ricas/raw/business-identity.txt +11 -0
- package/brand-assets/conny-demo/manifest.json +22 -0
- package/brand-assets/conny-demo/processed/business-identity.txt +7 -0
- package/brand-assets/conny-demo/raw/business-identity.txt +7 -0
- package/brand-assets/conny-logo.png +0 -0
- package/brand-assets/web.background.png +0 -0
- package/brand_assets.py +323 -0
- package/conny +28 -0
- package/conny-chat.py +579 -0
- package/conny-omni.py +3843 -0
- package/conny.py +113 -0
- package/conny_agents/__init__.py +1 -0
- package/conny_agents/agenda.py +1 -0
- package/conny_agents/captacion.py +1 -0
- package/conny_agents/conocimiento.py +1 -0
- package/conny_agents/escalacion.py +1 -0
- package/conny_agents/objeciones.py +1 -0
- package/conny_agents/seguimiento.py +1 -0
- package/conny_app.py +287 -0
- package/conny_audio.py +350 -0
- package/conny_audio_learn.py +84 -0
- package/conny_brain_v10.py +804 -0
- package/conny_bridge.py +656 -0
- package/conny_calendar.py +169 -0
- package/conny_cli.py +11784 -0
- package/conny_cli_bb.py +437 -0
- package/conny_commands.py +243 -0
- package/conny_config.py +215 -0
- package/conny_core/__init__.py +3 -0
- package/conny_core/conversation_engine.py +446 -0
- package/conny_core/first_turn_ops.py +287 -0
- package/conny_core/persona_registry.py +157 -0
- package/conny_core/prompt_ops.py +561 -0
- package/conny_cron.py +72 -0
- package/conny_demo_v2.py +209 -0
- package/conny_demo_voice.py +134 -0
- package/conny_design.py +43 -0
- package/conny_doctor.py +319 -0
- package/conny_domino.py +696 -0
- package/conny_generator.py +447 -0
- package/conny_google_auth.py +159 -0
- package/conny_i18n.py +619 -0
- package/conny_init.py +509 -0
- package/conny_integrations/__init__.py +4 -0
- package/conny_integrations/llm.py +1 -0
- package/conny_integrations/vault.py +77 -0
- package/conny_integrations/whatsapp.py +1 -0
- package/conny_intelligence.py +65 -0
- package/conny_learning.py +154 -0
- package/conny_memory.py +243 -0
- package/conny_memory_engine.py +292 -0
- package/conny_nova_proxy.py +170 -0
- package/conny_nuke_robot_phrases.py +493 -0
- package/conny_pairing.py +253 -0
- package/conny_patch.py +291 -0
- package/conny_persona_cli.py +150 -0
- package/conny_router.py +308 -0
- package/conny_runtime_ops.py +271 -0
- package/conny_session.py +516 -0
- package/conny_skills/__init__.py +1 -0
- package/conny_skills/demo_mode.py +35 -0
- package/conny_skills/text_processing.py +1 -0
- package/conny_skills/tone_detection.py +1 -0
- package/conny_smart_features.py +333 -0
- package/conny_studio.py +161 -0
- package/conny_sync_fix.py +306 -0
- package/conny_tui.py +512 -0
- package/conny_tui_select.py +202 -0
- package/conny_ultra_config.py +411 -0
- package/conny_uncertainty.py +174 -0
- package/conny_utils.py +87 -0
- package/conny_voice.py +156 -0
- package/conny_voice_engine.py +124 -0
- package/conny_web_search.py +66 -0
- package/conny_weekly_report.py +85 -0
- package/conny_worm.py +88 -0
- package/core/__init__.py +25 -0
- package/ecosystem.config.js +24 -0
- package/fix_init.py +27 -0
- package/install.sh +78 -0
- package/knowledge_base.py +330 -0
- package/nova/rules/default.yaml +37 -0
- package/nova_bridge.py +509 -0
- package/npm/conny.js +471 -0
- package/package.json +102 -0
- package/personas/conny/base/default.yaml +35 -0
- package/personas/conny/base/estetica_whatsapp.yaml +36 -0
- package/requirements.txt +14 -0
- package/run.sh +47 -0
- package/search.py +465 -0
- package/smart_handoff.py +1150 -0
- package/src/__init__.py +0 -0
- package/src/conny/__init__.py +0 -0
- package/src/conny/admin/__init__.py +0 -0
- package/src/conny/admin/api.py +234 -0
- package/src/conny/admin/dashboard.py +772 -0
- package/src/conny/api/__init__.py +0 -0
- package/src/conny/api/routes.py +8851 -0
- package/src/conny/brain/__init__.py +15 -0
- package/src/conny/brain/engine.py +804 -0
- package/src/conny/brain/learning.py +154 -0
- package/src/conny/brain/memory.py +324 -0
- package/src/conny/brain/smart_features.py +333 -0
- package/src/conny/brain/uncertainty.py +167 -0
- package/src/conny/channels/__init__.py +0 -0
- package/src/conny/channels/audio.py +316 -0
- package/src/conny/channels/cli.py +11795 -0
- package/src/conny/channels/logo_art.py +11 -0
- package/src/conny/channels/voice.py +156 -0
- package/src/conny/core/__init__.py +0 -0
- package/src/conny/core/config.py +215 -0
- package/src/conny/core/cron.py +72 -0
- package/src/conny/core/messenger.py +563 -0
- package/src/conny/core/router.py +297 -0
- package/src/conny/core/session.py +312 -0
- package/src/conny/demo/__init__.py +0 -0
- package/src/conny/demo/handler.py +3110 -0
- package/src/conny/integrations/__init__.py +19 -0
- package/src/conny/integrations/calendar.py +169 -0
- package/src/conny/integrations/knowledge.py +312 -0
- package/src/conny/integrations/search.py +66 -0
- package/src/conny/personas/__init__.py +0 -0
- package/src/conny/personas/generator.py +447 -0
- package/src/conny/production/__init__.py +0 -0
- package/src/conny/production/domino.py +696 -0
- package/src/conny/production/guard.py +550 -0
- package/src/conny/production/handoff.py +1150 -0
- package/src/conny/production/monitor.py +353 -0
- package/src/conny/utils/__init__.py +2 -0
- package/src/conny/utils/helpers.py +75 -0
- package/src/conny/utils/i18n.py +619 -0
- package/src/core/admin_engines.py +772 -0
- package/src/core/globals.py +11845 -0
- package/src/core/orchestrator.py +273 -0
- package/src/core/production_monitor.py +353 -0
- package/src/core/runtime.py +5487 -0
- package/src/domain/onboarding_flow.py +230 -0
- package/src/domain/prompts/__init__.py +1 -0
- package/src/domain/prompts/prospect_pitch.py +282 -0
- package/src/domain/send_guard.py +636 -0
- package/src/domain/swarm/queen.py +96 -0
- package/src/infrastructure/llm_providers/engine.py +487 -0
- package/src/interfaces/mcp_server.py +73 -0
- package/src/interfaces/nova_bridge.py +58 -0
- package/src/interfaces/web/admin_api.py +1379 -0
- package/src/interfaces/web/app.py +9408 -0
- package/src/interfaces/web/demo_handler.py +3450 -0
- package/src/interfaces/web/static/generate_avatars.py +46 -0
- package/v7/__init__.py +46 -0
- package/v7/agents/__init__.py +46 -0
- package/v7/agents/agenda.py +77 -0
- package/v7/agents/base.py +216 -0
- package/v7/agents/captacion.py +60 -0
- package/v7/agents/conocimiento.py +69 -0
- package/v7/agents/escalacion.py +83 -0
- package/v7/agents/objeciones.py +109 -0
- package/v7/agents/seguimiento.py +71 -0
- package/v7/memory/__init__.py +46 -0
- package/v7/memory/patient_profile.py +200 -0
- package/v7/orchestrator.py +275 -0
- package/v7/postprocess.py +127 -0
- package/v7/router.py +239 -0
- package/verify_conversation_impl.py +48 -0
package/conny_cli_bb.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ast
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sqlite3
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BBContext:
|
|
19
|
+
conny_dir: str
|
|
20
|
+
colors: Any
|
|
21
|
+
print_logo: Callable[..., None]
|
|
22
|
+
section: Callable[..., None]
|
|
23
|
+
q: Callable[..., str]
|
|
24
|
+
kv: Callable[..., None]
|
|
25
|
+
info: Callable[..., None]
|
|
26
|
+
nl: Callable[..., None]
|
|
27
|
+
fail: Callable[..., None]
|
|
28
|
+
warn: Callable[..., None]
|
|
29
|
+
ok: Callable[..., None]
|
|
30
|
+
prompt: Callable[..., str]
|
|
31
|
+
confirm: Callable[..., bool]
|
|
32
|
+
select: Callable[..., int]
|
|
33
|
+
spinner_cls: Any
|
|
34
|
+
pick_instance: Callable[..., Any]
|
|
35
|
+
health: Callable[..., Any]
|
|
36
|
+
v8_api: Callable[..., Dict[str, Any]]
|
|
37
|
+
handler_chat: Callable[..., None]
|
|
38
|
+
handler_doctor: Callable[..., None]
|
|
39
|
+
handler_sync: Callable[..., None]
|
|
40
|
+
handler_guide: Callable[..., None]
|
|
41
|
+
handler_new: Callable[..., None]
|
|
42
|
+
handler_init: Callable[..., None]
|
|
43
|
+
handler_status: Callable[..., None]
|
|
44
|
+
handler_health: Callable[..., None]
|
|
45
|
+
handler_modelo: Callable[..., None]
|
|
46
|
+
handler_trainer_skills: Callable[..., None]
|
|
47
|
+
handler_trainer_control: Callable[..., None]
|
|
48
|
+
handler_bb_config: Callable[..., None]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def bb_persona_defaults() -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"name": "Conny",
|
|
54
|
+
"role": "recepcionista IA",
|
|
55
|
+
"archetype": "amigable",
|
|
56
|
+
"tone": "natural",
|
|
57
|
+
"tone_instruction": "",
|
|
58
|
+
"formality_level": 0.35,
|
|
59
|
+
"warmth_level": 0.80,
|
|
60
|
+
"humor_level": 0.10,
|
|
61
|
+
"verbosity": 0.35,
|
|
62
|
+
"greetings": [],
|
|
63
|
+
"closings": [],
|
|
64
|
+
"affirmations": [],
|
|
65
|
+
"forbidden_words": [],
|
|
66
|
+
"custom_phrases": [],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache(maxsize=4)
|
|
71
|
+
def _bb_personality_catalog_for_path(conny_dir: str) -> Dict[str, Dict[str, Any]]:
|
|
72
|
+
conny_path = Path(conny_dir) / "conny.py"
|
|
73
|
+
fallback = {
|
|
74
|
+
"amigable": {
|
|
75
|
+
"desc": "Cercana y natural. La opción segura por defecto.",
|
|
76
|
+
"formality": 0.35,
|
|
77
|
+
"warmth": 0.80,
|
|
78
|
+
"humor": 0.15,
|
|
79
|
+
"verbosity": 0.35,
|
|
80
|
+
"greetings": ["hola", "buenas"],
|
|
81
|
+
"affirmations": ["claro", "listo"],
|
|
82
|
+
"closings": ["cualquier cosa me escribes"],
|
|
83
|
+
"forbidden": ["estimado", "cordialmente"],
|
|
84
|
+
"tone_instruction": "",
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try:
|
|
88
|
+
module = ast.parse(conny_path.read_text(encoding="utf-8"))
|
|
89
|
+
for node in module.body:
|
|
90
|
+
if isinstance(node, ast.Assign):
|
|
91
|
+
for target in node.targets:
|
|
92
|
+
if isinstance(target, ast.Name) and target.id == "PERSONALITY_ARCHETYPES":
|
|
93
|
+
data = ast.literal_eval(node.value)
|
|
94
|
+
if isinstance(data, dict) and data:
|
|
95
|
+
return data
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return fallback
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bb_personality_catalog(ctx: BBContext) -> Dict[str, Dict[str, Any]]:
|
|
102
|
+
return _bb_personality_catalog_for_path(ctx.conny_dir)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def bb_read_persona_db(inst: Any) -> Dict[str, Any]:
|
|
106
|
+
db_path = Path(inst.db_path)
|
|
107
|
+
if not db_path.exists():
|
|
108
|
+
return {}
|
|
109
|
+
try:
|
|
110
|
+
conn = sqlite3.connect(str(db_path))
|
|
111
|
+
row = conn.execute("SELECT persona_config FROM clinic WHERE id=1").fetchone()
|
|
112
|
+
if not row:
|
|
113
|
+
row = conn.execute("SELECT persona_config FROM clinic LIMIT 1").fetchone()
|
|
114
|
+
conn.close()
|
|
115
|
+
except Exception:
|
|
116
|
+
return {}
|
|
117
|
+
if not row or not row[0]:
|
|
118
|
+
return {}
|
|
119
|
+
raw = row[0]
|
|
120
|
+
if isinstance(raw, dict):
|
|
121
|
+
return dict(raw)
|
|
122
|
+
if isinstance(raw, str):
|
|
123
|
+
try:
|
|
124
|
+
parsed = json.loads(raw)
|
|
125
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
126
|
+
except Exception:
|
|
127
|
+
return {}
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def bb_write_persona_db(ctx: BBContext, inst: Any, persona: Dict[str, Any]) -> bool:
|
|
132
|
+
db_path = Path(inst.db_path)
|
|
133
|
+
if not db_path.exists():
|
|
134
|
+
ctx.fail(f"No encontré la base de datos de {inst.label}: {db_path}")
|
|
135
|
+
return False
|
|
136
|
+
try:
|
|
137
|
+
conn = sqlite3.connect(str(db_path))
|
|
138
|
+
row = conn.execute("SELECT id FROM clinic WHERE id=1").fetchone()
|
|
139
|
+
payload = json.dumps(persona, ensure_ascii=False)
|
|
140
|
+
if row:
|
|
141
|
+
conn.execute("UPDATE clinic SET persona_config=? WHERE id=1", (payload,))
|
|
142
|
+
else:
|
|
143
|
+
any_row = conn.execute("SELECT id FROM clinic LIMIT 1").fetchone()
|
|
144
|
+
if any_row:
|
|
145
|
+
conn.execute("UPDATE clinic SET persona_config=? WHERE id=?", (payload, any_row[0]))
|
|
146
|
+
else:
|
|
147
|
+
conn.execute("INSERT INTO clinic (id, persona_config) VALUES (1, ?)", (payload,))
|
|
148
|
+
conn.commit()
|
|
149
|
+
conn.close()
|
|
150
|
+
return True
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
ctx.fail(f"No pude guardar la personalidad en SQLite: {exc}")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def bb_load_persona(inst: Any) -> Dict[str, Any]:
|
|
157
|
+
persona = bb_persona_defaults()
|
|
158
|
+
persona.update(bb_read_persona_db(inst))
|
|
159
|
+
return persona
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def bb_apply_persona(
|
|
163
|
+
ctx: BBContext,
|
|
164
|
+
inst: Any,
|
|
165
|
+
updates: Dict[str, Any],
|
|
166
|
+
spinner_label: str = "Actualizando agente...",
|
|
167
|
+
) -> bool:
|
|
168
|
+
current = bb_load_persona(inst)
|
|
169
|
+
current.update({key: value for key, value in updates.items() if value is not None})
|
|
170
|
+
|
|
171
|
+
if ctx.health(inst.port):
|
|
172
|
+
with ctx.spinner_cls(spinner_label) as spinner:
|
|
173
|
+
response = ctx.v8_api(inst, "/personality", method="PATCH", payload=updates, timeout=12)
|
|
174
|
+
if response.get("ok"):
|
|
175
|
+
spinner.finish("Agente actualizado")
|
|
176
|
+
return True
|
|
177
|
+
spinner.finish(f"API no respondió: {response.get('error', 'sin respuesta')}", ok=False)
|
|
178
|
+
ctx.warn("La instancia no aceptó el cambio por API; guardando directamente en la base local.")
|
|
179
|
+
|
|
180
|
+
if bb_write_persona_db(ctx, inst, current):
|
|
181
|
+
ctx.ok("Agente actualizado en la base local")
|
|
182
|
+
return True
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def bb_prompt_block(ctx: BBContext, label: str, initial: str = "") -> str:
|
|
187
|
+
editor = os.getenv("EDITOR") or (
|
|
188
|
+
"notepad" if os.name == "nt" else ("nano" if shutil.which("nano") else "vi" if shutil.which("vi") else "")
|
|
189
|
+
)
|
|
190
|
+
if editor and shutil.which(editor):
|
|
191
|
+
if ctx.confirm(f"¿Abrir {editor} para editar {label.lower()}?", default=True):
|
|
192
|
+
fd, tmp_path = tempfile.mkstemp(prefix="conny-bb-", suffix=".txt")
|
|
193
|
+
os.close(fd)
|
|
194
|
+
temp_file = Path(tmp_path)
|
|
195
|
+
temp_file.write_text((initial or "").strip() + "\n", encoding="utf-8")
|
|
196
|
+
subprocess.run([editor, str(temp_file)], check=False)
|
|
197
|
+
try:
|
|
198
|
+
value = temp_file.read_text(encoding="utf-8").strip()
|
|
199
|
+
finally:
|
|
200
|
+
temp_file.unlink(missing_ok=True)
|
|
201
|
+
return value or initial
|
|
202
|
+
return ctx.prompt(label, default=initial)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def bb_score_prompt(ctx: BBContext, label: str, current: Any, default: float) -> float:
|
|
206
|
+
raw_default = current if current not in (None, "") else default
|
|
207
|
+
raw = ctx.prompt(label, default=str(raw_default))
|
|
208
|
+
try:
|
|
209
|
+
value = float(raw)
|
|
210
|
+
except Exception:
|
|
211
|
+
ctx.warn("Valor inválido; mantengo el actual.")
|
|
212
|
+
return float(raw_default)
|
|
213
|
+
return max(0.0, min(1.0, value))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def bb_show_agent_summary(ctx: BBContext, inst: Any, persona: Optional[Dict[str, Any]] = None) -> None:
|
|
217
|
+
persona = persona or bb_load_persona(inst)
|
|
218
|
+
catalog = bb_personality_catalog(ctx)
|
|
219
|
+
archetype = persona.get("archetype", "amigable")
|
|
220
|
+
archetype_info = catalog.get(archetype, {})
|
|
221
|
+
ctx.section(f"Black Boss Config — {inst.label}", "Agente, prompt y personalidad")
|
|
222
|
+
ctx.kv("Agente", persona.get("name", "Conny"))
|
|
223
|
+
ctx.kv("Rol", persona.get("role", "recepcionista IA"))
|
|
224
|
+
ctx.kv("Arquetipo", f"{archetype} · {archetype_info.get('desc', 'sin descripción')}")
|
|
225
|
+
ctx.kv("Formalidad", f"{float(persona.get('formality_level', 0.35)):.2f}")
|
|
226
|
+
ctx.kv("Calidez", f"{float(persona.get('warmth_level', 0.80)):.2f}")
|
|
227
|
+
ctx.kv("Humor", f"{float(persona.get('humor_level', 0.10)):.2f}")
|
|
228
|
+
ctx.kv("Detalle", f"{float(persona.get('verbosity', 0.35)):.2f}")
|
|
229
|
+
tone_instruction = (persona.get("tone_instruction", "") or "").strip()
|
|
230
|
+
if tone_instruction:
|
|
231
|
+
ctx.info("Prompt maestro:")
|
|
232
|
+
print(f" {ctx.q(ctx.colors.G1, tone_instruction[:160] + ('…' if len(tone_instruction) > 160 else ''))}")
|
|
233
|
+
else:
|
|
234
|
+
ctx.info("Prompt maestro: usando el del arquetipo activo")
|
|
235
|
+
ctx.nl()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def bb_pick_archetype(ctx: BBContext, current_id: str) -> Optional[str]:
|
|
239
|
+
catalog = bb_personality_catalog(ctx)
|
|
240
|
+
keys = list(catalog.keys())
|
|
241
|
+
labels = [key.replace("_", " ").title() for key in keys]
|
|
242
|
+
descs = [catalog[key].get("desc", "") for key in keys]
|
|
243
|
+
idx = ctx.select(labels, descs=descs, title=f"Elige la personalidad base (actual: {current_id})")
|
|
244
|
+
return keys[idx] if 0 <= idx < len(keys) else None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def bb_forward_inst(handler: Callable[..., None], inst: Any, *, name: str = "", subcommand: str = "") -> None:
|
|
248
|
+
forwarded = argparse.Namespace(name=name, subcommand=subcommand or inst.name, command="")
|
|
249
|
+
handler(forwarded)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_bb(ctx: BBContext, args: Any) -> None:
|
|
253
|
+
action = (getattr(args, "subcommand", "") or "").strip().lower()
|
|
254
|
+
target_name = getattr(args, "name", "")
|
|
255
|
+
|
|
256
|
+
if not action:
|
|
257
|
+
ctx.print_logo(compact=True)
|
|
258
|
+
ctx.section("Black Boss", "Capa operativa rápida para Conny")
|
|
259
|
+
shortcuts = [
|
|
260
|
+
("conny bb config [n]", "Crear y ajustar el agente de una instancia"),
|
|
261
|
+
("conny bb chat [n]", "Entrar al chat operativo"),
|
|
262
|
+
("conny bb doctor", "Diagnóstico completo"),
|
|
263
|
+
("conny bb sync", "Clonar runtime exacto a todas las instancias"),
|
|
264
|
+
("conny bb new", "Crear nueva instancia"),
|
|
265
|
+
("conny bb guide", "Abrir guía operativa"),
|
|
266
|
+
]
|
|
267
|
+
for cmd_text, desc in shortcuts:
|
|
268
|
+
print(f" {ctx.q(ctx.colors.CYN, cmd_text):<30} {ctx.q(ctx.colors.G1, desc)}")
|
|
269
|
+
ctx.nl()
|
|
270
|
+
ctx.info("Usa el patrón: conny bb <acción> [instancia]")
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
bb_routes = {
|
|
274
|
+
"config": ctx.handler_bb_config,
|
|
275
|
+
"chat": ctx.handler_chat,
|
|
276
|
+
"doctor": ctx.handler_doctor,
|
|
277
|
+
"sync": ctx.handler_sync,
|
|
278
|
+
"guide": ctx.handler_guide,
|
|
279
|
+
"guia": ctx.handler_guide,
|
|
280
|
+
"new": ctx.handler_new,
|
|
281
|
+
"crear": ctx.handler_new,
|
|
282
|
+
"init": ctx.handler_init,
|
|
283
|
+
"start": ctx.handler_init,
|
|
284
|
+
"status": ctx.handler_status,
|
|
285
|
+
"health": ctx.handler_health,
|
|
286
|
+
}
|
|
287
|
+
handler = bb_routes.get(action)
|
|
288
|
+
if not handler:
|
|
289
|
+
ctx.fail(f"Acción BB desconocida: '{action}'")
|
|
290
|
+
ctx.info("Prueba con: conny bb config | chat | doctor | sync | new | guide")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
forwarded = argparse.Namespace(**vars(args))
|
|
294
|
+
forwarded.command = action
|
|
295
|
+
forwarded.subcommand = ""
|
|
296
|
+
forwarded.name = target_name
|
|
297
|
+
handler(forwarded)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def cmd_bb_config(ctx: BBContext, args: Any) -> None:
|
|
301
|
+
inst = ctx.pick_instance(args, "¿Cuál agente Conny quieres ajustar?")
|
|
302
|
+
if not inst:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
while True:
|
|
306
|
+
ctx.print_logo(compact=True, sector=inst.sector)
|
|
307
|
+
persona = bb_load_persona(inst)
|
|
308
|
+
bb_show_agent_summary(ctx, inst, persona)
|
|
309
|
+
|
|
310
|
+
options = [
|
|
311
|
+
"Crear / renombrar agente",
|
|
312
|
+
"Elegir personalidad base",
|
|
313
|
+
"Editar prompt maestro",
|
|
314
|
+
"Ajustar tono fino",
|
|
315
|
+
"Cambiar modelo LLM",
|
|
316
|
+
"Activar / desactivar skills",
|
|
317
|
+
"Control duro y frases prohibidas",
|
|
318
|
+
"Enseñarle una instrucción nueva",
|
|
319
|
+
"Ver resumen otra vez",
|
|
320
|
+
"Salir",
|
|
321
|
+
]
|
|
322
|
+
descs = [
|
|
323
|
+
"Nombre visible, rol y perfil del agente.",
|
|
324
|
+
"Aplica un arquetipo base listo para usar.",
|
|
325
|
+
"Edita la instrucción central del agente.",
|
|
326
|
+
"Formalidad, calidez, humor y nivel de detalle.",
|
|
327
|
+
"Abre el catálogo de modelos para esta instancia.",
|
|
328
|
+
"Gestiona skills del agente en caliente.",
|
|
329
|
+
"Ajusta frases prohibidas, saludo y estilo duro.",
|
|
330
|
+
"Le enseña un patrón nuevo sin tocar código.",
|
|
331
|
+
"Recarga la configuración actual.",
|
|
332
|
+
"Volver a la terminal.",
|
|
333
|
+
]
|
|
334
|
+
choice = ctx.select(options, descs=descs, title="¿Qué quieres cambiar en este agente?")
|
|
335
|
+
|
|
336
|
+
if choice == 0:
|
|
337
|
+
new_name = ctx.prompt("Nombre visible del agente", default=persona.get("name", "Conny"))
|
|
338
|
+
new_role = ctx.prompt("Rol del agente", default=persona.get("role", "recepcionista IA"))
|
|
339
|
+
bb_apply_persona(
|
|
340
|
+
ctx,
|
|
341
|
+
inst,
|
|
342
|
+
{"name": new_name, "role": new_role},
|
|
343
|
+
spinner_label="Actualizando identidad del agente...",
|
|
344
|
+
)
|
|
345
|
+
elif choice == 1:
|
|
346
|
+
archetype_id = bb_pick_archetype(ctx, persona.get("archetype", "amigable"))
|
|
347
|
+
if not archetype_id:
|
|
348
|
+
continue
|
|
349
|
+
data = bb_personality_catalog(ctx).get(archetype_id, {})
|
|
350
|
+
bb_apply_persona(
|
|
351
|
+
ctx,
|
|
352
|
+
inst,
|
|
353
|
+
{
|
|
354
|
+
"archetype": archetype_id,
|
|
355
|
+
"tone": data.get("desc", persona.get("tone", "natural")),
|
|
356
|
+
"tone_instruction": data.get("tone_instruction", ""),
|
|
357
|
+
"formality_level": data.get("formality", persona.get("formality_level", 0.35)),
|
|
358
|
+
"warmth_level": data.get("warmth", persona.get("warmth_level", 0.80)),
|
|
359
|
+
"humor_level": data.get("humor", persona.get("humor_level", 0.10)),
|
|
360
|
+
"verbosity": data.get("verbosity", persona.get("verbosity", 0.35)),
|
|
361
|
+
"greetings": data.get("greetings", []),
|
|
362
|
+
"affirmations": data.get("affirmations", []),
|
|
363
|
+
"closings": data.get("closings", []),
|
|
364
|
+
"forbidden_words": data.get("forbidden", []),
|
|
365
|
+
},
|
|
366
|
+
spinner_label="Aplicando personalidad base...",
|
|
367
|
+
)
|
|
368
|
+
elif choice == 2:
|
|
369
|
+
new_prompt = bb_prompt_block(ctx, "Prompt maestro", initial=persona.get("tone_instruction", ""))
|
|
370
|
+
if new_prompt.strip():
|
|
371
|
+
bb_apply_persona(
|
|
372
|
+
ctx,
|
|
373
|
+
inst,
|
|
374
|
+
{"tone_instruction": new_prompt.strip()},
|
|
375
|
+
spinner_label="Guardando prompt maestro...",
|
|
376
|
+
)
|
|
377
|
+
elif choice == 3:
|
|
378
|
+
tone = ctx.prompt("Descripción corta del tono", default=persona.get("tone", "natural"))
|
|
379
|
+
formality = bb_score_prompt(ctx, "Formalidad (0.0 a 1.0)", persona.get("formality_level"), 0.35)
|
|
380
|
+
warmth = bb_score_prompt(ctx, "Calidez (0.0 a 1.0)", persona.get("warmth_level"), 0.80)
|
|
381
|
+
humor = bb_score_prompt(ctx, "Humor (0.0 a 1.0)", persona.get("humor_level"), 0.10)
|
|
382
|
+
verbosity = bb_score_prompt(ctx, "Nivel de detalle (0.0 a 1.0)", persona.get("verbosity"), 0.35)
|
|
383
|
+
bb_apply_persona(
|
|
384
|
+
ctx,
|
|
385
|
+
inst,
|
|
386
|
+
{
|
|
387
|
+
"tone": tone,
|
|
388
|
+
"formality_level": formality,
|
|
389
|
+
"warmth_level": warmth,
|
|
390
|
+
"humor_level": humor,
|
|
391
|
+
"verbosity": verbosity,
|
|
392
|
+
},
|
|
393
|
+
spinner_label="Ajustando tono del agente...",
|
|
394
|
+
)
|
|
395
|
+
elif choice == 4:
|
|
396
|
+
bb_forward_inst(ctx.handler_modelo, inst, name=inst.name, subcommand=inst.name)
|
|
397
|
+
elif choice == 5:
|
|
398
|
+
bb_forward_inst(ctx.handler_trainer_skills, inst, name="", subcommand=inst.name)
|
|
399
|
+
elif choice == 6:
|
|
400
|
+
bb_forward_inst(ctx.handler_trainer_control, inst, name=inst.name, subcommand=inst.name)
|
|
401
|
+
elif choice == 7:
|
|
402
|
+
instruction = ctx.prompt("¿Qué quieres que aprenda este agente?", default="")
|
|
403
|
+
if instruction.strip():
|
|
404
|
+
with ctx.spinner_cls("Enseñando al agente...") as spinner:
|
|
405
|
+
response = ctx.v8_api(
|
|
406
|
+
inst,
|
|
407
|
+
"/trainer/prompt/evolve",
|
|
408
|
+
method="POST",
|
|
409
|
+
payload={"instruction": instruction.strip(), "admin_chat_id": "cli"},
|
|
410
|
+
timeout=20,
|
|
411
|
+
)
|
|
412
|
+
spinner.finish("Aprendido" if response.get("ok") else "Error", ok=bool(response.get("ok")))
|
|
413
|
+
if response.get("ok"):
|
|
414
|
+
ctx.ok(response.get("description", "Instrucción procesada"))
|
|
415
|
+
else:
|
|
416
|
+
ctx.fail(f"Error: {response.get('error', 'sin respuesta')}")
|
|
417
|
+
elif choice == 8:
|
|
418
|
+
continue
|
|
419
|
+
else:
|
|
420
|
+
break
|
|
421
|
+
ctx.nl()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
__all__ = [
|
|
425
|
+
"BBContext",
|
|
426
|
+
"bb_apply_persona",
|
|
427
|
+
"bb_load_persona",
|
|
428
|
+
"bb_persona_defaults",
|
|
429
|
+
"bb_personality_catalog",
|
|
430
|
+
"bb_prompt_block",
|
|
431
|
+
"bb_read_persona_db",
|
|
432
|
+
"bb_score_prompt",
|
|
433
|
+
"bb_show_agent_summary",
|
|
434
|
+
"bb_write_persona_db",
|
|
435
|
+
"cmd_bb",
|
|
436
|
+
"cmd_bb_config",
|
|
437
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""conny_commands.py — Slash command system for users and admins."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("conny.commands")
|
|
12
|
+
|
|
13
|
+
USER_COMMANDS = {
|
|
14
|
+
"/cita": "Ver o gestionar tu cita",
|
|
15
|
+
"/horarios": "Ver horarios del negocio",
|
|
16
|
+
"/servicios": "Ver lista de servicios",
|
|
17
|
+
"/precios": "Consultar tarifas",
|
|
18
|
+
"/ubicacion": "Dirección y cómo llegar",
|
|
19
|
+
"/ayuda": "Ver comandos disponibles",
|
|
20
|
+
"/hablar": "Hablar con un humano",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
ADMIN_COMMANDS = {
|
|
24
|
+
"/status": "Estado de la instancia",
|
|
25
|
+
"/gaps": "Preguntas sin responder",
|
|
26
|
+
"/persona": "Cambiar personalidad",
|
|
27
|
+
"/modelo": "Cambiar modelo LLM",
|
|
28
|
+
"/pausa": "Pausar Conny",
|
|
29
|
+
"/reanudar": "Reanudar Conny",
|
|
30
|
+
"/stats": "Estadísticas",
|
|
31
|
+
"/test": "Probar respuesta",
|
|
32
|
+
"/reload": "Recargar configuración",
|
|
33
|
+
"/aprender": "Enseñar respuesta",
|
|
34
|
+
"/personalidad": "Cambio rápido de personalidad",
|
|
35
|
+
"/broadcast": "Mensaje masivo",
|
|
36
|
+
"/blacklist": "Bloquear número",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CommandHandler:
|
|
41
|
+
"""Handles slash commands from both users and admins."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, instance_id: str = "default"):
|
|
44
|
+
self.instance_id = instance_id
|
|
45
|
+
self._paused = False
|
|
46
|
+
|
|
47
|
+
def is_command(self, text: str) -> bool:
|
|
48
|
+
return text.strip().startswith("/")
|
|
49
|
+
|
|
50
|
+
def is_paused(self) -> bool:
|
|
51
|
+
return self._paused
|
|
52
|
+
|
|
53
|
+
async def handle(self, chat_id: str, text: str, is_admin: bool = False,
|
|
54
|
+
clinic: Optional[Dict] = None, db=None) -> Optional[List[str]]:
|
|
55
|
+
text = text.strip()
|
|
56
|
+
if not text.startswith("/"):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
parts = text.split(maxsplit=1)
|
|
60
|
+
cmd = parts[0].lower()
|
|
61
|
+
args_str = parts[1] if len(parts) > 1 else ""
|
|
62
|
+
|
|
63
|
+
if is_admin:
|
|
64
|
+
result = await self._handle_admin(cmd, args_str, chat_id, clinic, db)
|
|
65
|
+
if result:
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
return await self._handle_user(cmd, args_str, chat_id, clinic, db)
|
|
69
|
+
|
|
70
|
+
async def _handle_user(self, cmd: str, args: str, chat_id: str,
|
|
71
|
+
clinic: Optional[Dict], db) -> Optional[List[str]]:
|
|
72
|
+
if cmd in ("/ayuda", "/help"):
|
|
73
|
+
lines = [
|
|
74
|
+
"Comandos disponibles:",
|
|
75
|
+
" /help — ver esta lista",
|
|
76
|
+
" /personalidad — ver y cambiar personalidades",
|
|
77
|
+
" /reset — empezar de cero con otro negocio",
|
|
78
|
+
" /modelo — cambiar modelo de IA",
|
|
79
|
+
"",
|
|
80
|
+
"También puedes escribir sin /:",
|
|
81
|
+
" 'formal' 'luxury' 'casual' — cambiar tono",
|
|
82
|
+
" 'reset' — reiniciar demo",
|
|
83
|
+
" 'stats' — ver estadísticas",
|
|
84
|
+
"",
|
|
85
|
+
"Quieres probarme? Dime el nombre de tu negocio y escríbeme como si fueras un cliente",
|
|
86
|
+
]
|
|
87
|
+
return ["\n".join(lines)]
|
|
88
|
+
|
|
89
|
+
if cmd == "/personalidad":
|
|
90
|
+
return [
|
|
91
|
+
"Personalidades disponibles:",
|
|
92
|
+
" formal — profesional, usted, sin jerga\n"
|
|
93
|
+
" amigable — cercana, tutea, cálida\n"
|
|
94
|
+
" luxury — sofisticada, exclusiva, elegante\n"
|
|
95
|
+
" directa — concisa, sin rodeos\n"
|
|
96
|
+
" juvenil — fresca, emojis, moderna\n"
|
|
97
|
+
" experta — técnica, confiable, precisa",
|
|
98
|
+
"Escribe el nombre de la personalidad para verla en acción"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
if cmd == "/horarios":
|
|
102
|
+
hours = (clinic or {}).get("schedule", "No configurado")
|
|
103
|
+
if isinstance(hours, dict):
|
|
104
|
+
hours = "\n".join(f" {k}: {v}" for k, v in hours.items())
|
|
105
|
+
return [f"Nuestro horario:\n{hours}"]
|
|
106
|
+
|
|
107
|
+
if cmd == "/servicios":
|
|
108
|
+
services = (clinic or {}).get("services", [])
|
|
109
|
+
if isinstance(services, str):
|
|
110
|
+
services = [s.strip() for s in services.split(",")]
|
|
111
|
+
if services:
|
|
112
|
+
return ["Nuestros servicios:\n" + "\n".join(f" • {s}" for s in services)]
|
|
113
|
+
return ["Servicios no configurados aún."]
|
|
114
|
+
|
|
115
|
+
if cmd in ("/ubicacion", "/ubicación"):
|
|
116
|
+
loc = (clinic or {}).get("location", "") or (clinic or {}).get("address", "")
|
|
117
|
+
return [f"Nos encuentras en:\n{loc}"] if loc else ["Ubicación no configurada."]
|
|
118
|
+
|
|
119
|
+
if cmd == "/cita":
|
|
120
|
+
return ["Para agendar una cita, dime:\n• Tu nombre\n• Servicio\n• Fecha y hora preferida"]
|
|
121
|
+
|
|
122
|
+
if cmd == "/hablar":
|
|
123
|
+
return ["Te paso con alguien del equipo. Un momento."]
|
|
124
|
+
|
|
125
|
+
if cmd == "/precios":
|
|
126
|
+
return ["Los precios dependen del servicio. ¿Cuál te interesa?"]
|
|
127
|
+
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
async def _handle_admin(self, cmd: str, args: str, chat_id: str,
|
|
131
|
+
clinic: Optional[Dict], db) -> Optional[List[str]]:
|
|
132
|
+
if cmd in ("/ayuda", "/help"):
|
|
133
|
+
lines = ["🔧 Comandos Admin:"]
|
|
134
|
+
for c, desc in ADMIN_COMMANDS.items():
|
|
135
|
+
lines.append(f" {c} — {desc}")
|
|
136
|
+
lines.append("\n📋 También puedes:")
|
|
137
|
+
lines.append(" 'conectar calendario' — vincular Google Calendar")
|
|
138
|
+
lines.append(" 'investiga X' — buscar en internet")
|
|
139
|
+
lines.append(" 'modo luxury/formal/casual' — cambiar tono")
|
|
140
|
+
lines.append(" Enviar archivos TXT/PDF/JSON — los leo y aprendo")
|
|
141
|
+
return ["\n".join(lines)]
|
|
142
|
+
|
|
143
|
+
if cmd == "/status":
|
|
144
|
+
return [f"Instancia: {self.instance_id}\nEstado: {'pausada' if self._paused else 'activa'}"]
|
|
145
|
+
|
|
146
|
+
if cmd == "/pausa":
|
|
147
|
+
self._paused = True
|
|
148
|
+
return ["⏸ Conny pausada. Mensajes no serán respondidos hasta /reanudar"]
|
|
149
|
+
|
|
150
|
+
if cmd == "/reanudar":
|
|
151
|
+
self._paused = False
|
|
152
|
+
return ["▶ Conny activa de nuevo."]
|
|
153
|
+
|
|
154
|
+
if cmd == "/gaps":
|
|
155
|
+
gaps_dir = Path("knowledge_gaps")
|
|
156
|
+
if not gaps_dir.exists():
|
|
157
|
+
return ["No hay gaps registrados."]
|
|
158
|
+
from datetime import datetime
|
|
159
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
160
|
+
today_file = gaps_dir / f"{today}.jsonl"
|
|
161
|
+
if not today_file.exists():
|
|
162
|
+
return ["No hay gaps de hoy."]
|
|
163
|
+
gaps = []
|
|
164
|
+
for line in open(today_file):
|
|
165
|
+
g = json.loads(line)
|
|
166
|
+
gaps.append(f"• {g['user_msg'][:80]} (conf: {g['confidence']:.0%})")
|
|
167
|
+
return [f"Knowledge gaps ({len(gaps)}):\n" + "\n".join(gaps[:10])]
|
|
168
|
+
|
|
169
|
+
if cmd in ("/personalidad", "/persona"):
|
|
170
|
+
if not args:
|
|
171
|
+
return [
|
|
172
|
+
"Personalidades disponibles:\n"
|
|
173
|
+
" formal — profesional, usted, sin jerga\n"
|
|
174
|
+
" amigable — cercana, tutea, cálida\n"
|
|
175
|
+
" luxury — sofisticada, exclusiva, elegante\n"
|
|
176
|
+
" casual — relajada, como amiga\n"
|
|
177
|
+
" directa — concisa, sin rodeos\n"
|
|
178
|
+
" juvenil — fresca, emojis, moderna\n"
|
|
179
|
+
" experta — técnica, confiable, precisa\n\n"
|
|
180
|
+
"Para cambiar: /personalidad tono=luxury\n"
|
|
181
|
+
"O escribe: 'modo luxury' / 'modo formal' / 'modo casual'"
|
|
182
|
+
]
|
|
183
|
+
updates = {}
|
|
184
|
+
for pair in re.findall(r'(\w+)=("[^"]+"|[^\s]+)', args):
|
|
185
|
+
key, val = pair
|
|
186
|
+
updates[key] = val.strip('"')
|
|
187
|
+
if updates:
|
|
188
|
+
override_path = Path(f"personas/{self.instance_id}/runtime_override.json")
|
|
189
|
+
override_path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
existing = json.loads(override_path.read_text()) if override_path.exists() else {}
|
|
191
|
+
existing.update(updates)
|
|
192
|
+
existing["updated_at"] = time.time()
|
|
193
|
+
override_path.write_text(json.dumps(existing, ensure_ascii=False, indent=2))
|
|
194
|
+
return [f"✅ Personalidad actualizada: {', '.join(f'{k}={v}' for k,v in updates.items())}"]
|
|
195
|
+
return ["No se detectaron cambios."]
|
|
196
|
+
|
|
197
|
+
if cmd == "/aprender":
|
|
198
|
+
if "→" in args or "->" in args:
|
|
199
|
+
sep = "→" if "→" in args else "->"
|
|
200
|
+
question, answer = args.split(sep, 1)
|
|
201
|
+
question = question.strip().strip('"')
|
|
202
|
+
answer = answer.strip().strip('"')
|
|
203
|
+
teachings_dir = Path("teachings")
|
|
204
|
+
teachings_dir.mkdir(exist_ok=True)
|
|
205
|
+
with open(teachings_dir / f"{self.instance_id}.jsonl", "a") as f:
|
|
206
|
+
f.write(json.dumps({"ts": time.time(), "question": question, "answer": answer}, ensure_ascii=False) + "\n")
|
|
207
|
+
|
|
208
|
+
# Auto-update clinic DB with structured data
|
|
209
|
+
q_low = question.lower()
|
|
210
|
+
try:
|
|
211
|
+
if db and any(w in q_low for w in ["horario", "hora", "atienden", "abrimos"]):
|
|
212
|
+
db.update_clinic(schedule=json.dumps({"general": answer}))
|
|
213
|
+
elif db and any(w in q_low for w in ["precio", "cuesta", "vale", "cobran"]):
|
|
214
|
+
db.update_clinic(pricing=answer)
|
|
215
|
+
elif db and any(w in q_low for w in ["servicio", "ofrecen", "hacen"]):
|
|
216
|
+
db.update_clinic(services=[s.strip() for s in answer.split(",")])
|
|
217
|
+
elif db and any(w in q_low for w in ["telefono", "número", "celular", "llamar"]):
|
|
218
|
+
db.update_clinic(phone=answer)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
return [f"listo, ya me lo sé: '{question[:40]}' → '{answer[:40]}'"]
|
|
223
|
+
return ["Uso: /aprender pregunta → respuesta"]
|
|
224
|
+
|
|
225
|
+
if cmd == "/modelo":
|
|
226
|
+
return [f"Modelo: {args or 'auto'}"] if args else ["Uso: /modelo gemini-2.5-flash"]
|
|
227
|
+
|
|
228
|
+
if cmd == "/reload":
|
|
229
|
+
return ["✅ Configuración recargada."]
|
|
230
|
+
|
|
231
|
+
if cmd == "/stats":
|
|
232
|
+
return [f"Estadísticas {self.instance_id}: (pendiente integración)"]
|
|
233
|
+
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_handlers: Dict[str, CommandHandler] = {}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_command_handler(instance_id: str = "default") -> CommandHandler:
|
|
241
|
+
if instance_id not in _handlers:
|
|
242
|
+
_handlers[instance_id] = CommandHandler(instance_id)
|
|
243
|
+
return _handlers[instance_id]
|