Skip to content

Commit 89e36c2

Browse files
authored
Merge pull request #1 from jhbarnett/feat/upstream-cherry-picks
feat: cherry-pick upstream PRs for slash commands, model switching, and rich formatting
2 parents a1f8f84 + cade693 commit 89e36c2

9 files changed

Lines changed: 965 additions & 67 deletions

File tree

src/bot/orchestrator.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ class MessageOrchestrator:
115115
def __init__(self, settings: Settings, deps: Dict[str, Any]):
116116
self.settings = settings
117117
self.deps = deps
118+
self._known_commands: frozenset[str] = frozenset()
118119

119120
def _inject_deps(self, handler: Callable) -> Callable: # type: ignore[type-arg]
120121
"""Wrap handler to inject dependencies into context.bot_data."""
@@ -306,12 +307,16 @@ def _register_agentic_handlers(self, app: Application) -> None:
306307
("new", self.agentic_new),
307308
("status", self.agentic_status),
308309
("verbose", self.agentic_verbose),
310+
("model", self.agentic_model),
309311
("repo", self.agentic_repo),
310312
("restart", command.restart_command),
311313
]
312314
if self.settings.enable_project_threads:
313315
handlers.append(("sync_threads", command.sync_threads))
314316

317+
# Derive known commands dynamically — avoids drift when new commands are added
318+
self._known_commands: frozenset[str] = frozenset(cmd for cmd, _ in handlers)
319+
315320
for cmd, handler in handlers:
316321
app.add_handler(CommandHandler(cmd, self._inject_deps(handler)))
317322

@@ -324,6 +329,19 @@ def _register_agentic_handlers(self, app: Application) -> None:
324329
group=10,
325330
)
326331

332+
# Unknown slash commands -> Claude (passthrough in agentic mode).
333+
# Registered commands are handled by CommandHandlers in group 0
334+
# (higher priority). This catches any /command not matched there
335+
# and forwards it to Claude, while skipping known commands to
336+
# avoid double-firing.
337+
app.add_handler(
338+
MessageHandler(
339+
filters.COMMAND,
340+
self._inject_deps(self._handle_unknown_command),
341+
),
342+
group=10,
343+
)
344+
327345
# File uploads -> Claude
328346
app.add_handler(
329347
MessageHandler(
@@ -415,6 +433,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
415433
BotCommand("new", "Start a fresh session"),
416434
BotCommand("status", "Show session status"),
417435
BotCommand("verbose", "Set output verbosity (0/1/2)"),
436+
BotCommand("model", "Switch Claude model"),
418437
BotCommand("repo", "List repos / switch workspace"),
419438
BotCommand("restart", "Restart the bot"),
420439
]
@@ -578,6 +597,81 @@ async def agentic_verbose(
578597
parse_mode="HTML",
579598
)
580599

600+
def _get_model_override(self, context: ContextTypes.DEFAULT_TYPE) -> Optional[str]:
601+
"""Return per-user model override, or None to use the default."""
602+
return context.user_data.get("model_override")
603+
604+
@staticmethod
605+
def _resolve_model_display(
606+
user_override: Optional[str],
607+
config_model: Optional[str],
608+
last_model: Optional[str] = None,
609+
) -> str:
610+
"""Return a human-readable model string showing what will actually be used."""
611+
if user_override:
612+
return user_override
613+
if config_model:
614+
return config_model
615+
if last_model:
616+
return last_model
617+
return "unknown (send a message first to detect)"
618+
619+
async def agentic_model(
620+
self, update: Update, context: ContextTypes.DEFAULT_TYPE
621+
) -> None:
622+
"""Set Claude model: /model [model_name]."""
623+
args = update.message.text.split()[1:] if update.message.text else []
624+
user_override = self._get_model_override(context)
625+
last_model = context.user_data.get("last_model")
626+
current = self._resolve_model_display(
627+
user_override, self.settings.claude_model, last_model
628+
)
629+
630+
if not args:
631+
source = "user override" if user_override else (
632+
"server config" if self.settings.claude_model else "Claude Code default"
633+
)
634+
await update.message.reply_text(
635+
f"Model: <b>{escape_html(current)}</b> ({source})\n\n"
636+
"Usage: <code>/model model_name</code>\n"
637+
"Reset: <code>/model default</code>",
638+
parse_mode="HTML",
639+
)
640+
return
641+
642+
model_name = args[0].strip()
643+
if not model_name or len(model_name) > 100:
644+
await update.message.reply_text("Invalid model name.")
645+
return
646+
audit_logger = context.bot_data.get("audit_logger")
647+
if model_name == "default":
648+
context.user_data.pop("model_override", None)
649+
default = self._resolve_model_display(None, self.settings.claude_model)
650+
await update.message.reply_text(
651+
f"Model reset to <b>{escape_html(default)}</b>",
652+
parse_mode="HTML",
653+
)
654+
if audit_logger:
655+
await audit_logger.log_command(
656+
user_id=update.effective_user.id,
657+
command="model_reset",
658+
args=[],
659+
success=True,
660+
)
661+
else:
662+
context.user_data["model_override"] = model_name
663+
await update.message.reply_text(
664+
f"Model set to <b>{escape_html(model_name)}</b>",
665+
parse_mode="HTML",
666+
)
667+
if audit_logger:
668+
await audit_logger.log_command(
669+
user_id=update.effective_user.id,
670+
command="model",
671+
args=[model_name],
672+
success=True,
673+
)
674+
581675
def _format_verbose_progress(
582676
self,
583677
activity_log: List[Dict[str, Any]],
@@ -941,13 +1035,16 @@ async def agentic_text(
9411035
session_id=session_id,
9421036
on_stream=on_stream,
9431037
force_new=force_new,
1038+
model_override=self._get_model_override(context),
9441039
)
9451040

9461041
# New session created successfully — clear the one-shot flag
9471042
if force_new:
9481043
context.user_data["force_new_session"] = False
9491044

9501045
context.user_data["claude_session_id"] = claude_response.session_id
1046+
if claude_response.model:
1047+
context.user_data["last_model"] = claude_response.model
9511048

9521049
# Track directory changes
9531050
from .handlers.message import _update_working_directory_from_claude_response
@@ -1185,12 +1282,15 @@ async def agentic_document(
11851282
session_id=session_id,
11861283
on_stream=on_stream,
11871284
force_new=force_new,
1285+
model_override=self._get_model_override(context),
11881286
)
11891287

11901288
if force_new:
11911289
context.user_data["force_new_session"] = False
11921290

11931291
context.user_data["claude_session_id"] = claude_response.session_id
1292+
if claude_response.model:
1293+
context.user_data["last_model"] = claude_response.model
11941294

11951295
from .handlers.message import _update_working_directory_from_claude_response
11961296

@@ -1384,6 +1484,7 @@ async def _handle_agentic_media_message(
13841484
session_id=session_id,
13851485
on_stream=on_stream,
13861486
force_new=force_new,
1487+
model_override=self._get_model_override(context),
13871488
)
13881489
finally:
13891490
heartbeat.cancel()
@@ -1392,6 +1493,7 @@ async def _handle_agentic_media_message(
13921493
context.user_data["force_new_session"] = False
13931494

13941495
context.user_data["claude_session_id"] = claude_response.session_id
1496+
context.user_data["last_model"] = claude_response.model
13951497

13961498
from .handlers.message import _update_working_directory_from_claude_response
13971499

@@ -1450,6 +1552,25 @@ async def _handle_agentic_media_message(
14501552
except Exception as img_err:
14511553
logger.warning("Image send failed", error=str(img_err))
14521554

1555+
async def _handle_unknown_command(
1556+
self, update: Update, context: ContextTypes.DEFAULT_TYPE
1557+
) -> None:
1558+
"""Forward unknown slash commands to Claude in agentic mode.
1559+
1560+
Known commands are handled by their own CommandHandlers (group 0);
1561+
this handler fires for *every* COMMAND message in group 10 but
1562+
returns immediately when the command is registered, preventing
1563+
double execution.
1564+
"""
1565+
msg = update.effective_message
1566+
if not msg or not msg.text:
1567+
return
1568+
cmd = msg.text.split()[0].lstrip("/").split("@")[0].lower()
1569+
if cmd in self._known_commands:
1570+
return # let the registered CommandHandler take care of it
1571+
# Forward unrecognised /commands to Claude as natural language
1572+
await self.agentic_text(update, context)
1573+
14531574
def _voice_unavailable_message(self) -> str:
14541575
"""Return provider-aware guidance when voice feature is unavailable."""
14551576
return (

0 commit comments

Comments
 (0)