Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class MessageOrchestrator:
def __init__(self, settings: Settings, deps: Dict[str, Any]):
self.settings = settings
self.deps = deps
self._known_commands: frozenset[str] = frozenset()

def _inject_deps(self, handler: Callable) -> Callable: # type: ignore[type-arg]
"""Wrap handler to inject dependencies into context.bot_data."""
Expand Down Expand Up @@ -306,12 +307,16 @@ def _register_agentic_handlers(self, app: Application) -> None:
("new", self.agentic_new),
("status", self.agentic_status),
("verbose", self.agentic_verbose),
("model", self.agentic_model),
("repo", self.agentic_repo),
("restart", command.restart_command),
]
if self.settings.enable_project_threads:
handlers.append(("sync_threads", command.sync_threads))

# Derive known commands dynamically — avoids drift when new commands are added
self._known_commands: frozenset[str] = frozenset(cmd for cmd, _ in handlers)

for cmd, handler in handlers:
app.add_handler(CommandHandler(cmd, self._inject_deps(handler)))

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

# Unknown slash commands -> Claude (passthrough in agentic mode).
# Registered commands are handled by CommandHandlers in group 0
# (higher priority). This catches any /command not matched there
# and forwards it to Claude, while skipping known commands to
# avoid double-firing.
app.add_handler(
MessageHandler(
filters.COMMAND,
self._inject_deps(self._handle_unknown_command),
),
group=10,
)

# File uploads -> Claude
app.add_handler(
MessageHandler(
Expand Down Expand Up @@ -415,6 +433,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg]
BotCommand("new", "Start a fresh session"),
BotCommand("status", "Show session status"),
BotCommand("verbose", "Set output verbosity (0/1/2)"),
BotCommand("model", "Switch Claude model"),
BotCommand("repo", "List repos / switch workspace"),
BotCommand("restart", "Restart the bot"),
]
Expand Down Expand Up @@ -578,6 +597,81 @@ async def agentic_verbose(
parse_mode="HTML",
)

def _get_model_override(self, context: ContextTypes.DEFAULT_TYPE) -> Optional[str]:
"""Return per-user model override, or None to use the default."""
return context.user_data.get("model_override")

@staticmethod
def _resolve_model_display(
user_override: Optional[str],
config_model: Optional[str],
last_model: Optional[str] = None,
) -> str:
"""Return a human-readable model string showing what will actually be used."""
if user_override:
return user_override
if config_model:
return config_model
if last_model:
return last_model
return "unknown (send a message first to detect)"

async def agentic_model(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Set Claude model: /model [model_name]."""
args = update.message.text.split()[1:] if update.message.text else []
user_override = self._get_model_override(context)
last_model = context.user_data.get("last_model")
current = self._resolve_model_display(
user_override, self.settings.claude_model, last_model
)

if not args:
source = "user override" if user_override else (
"server config" if self.settings.claude_model else "Claude Code default"
)
await update.message.reply_text(
f"Model: <b>{escape_html(current)}</b> ({source})\n\n"
"Usage: <code>/model model_name</code>\n"
"Reset: <code>/model default</code>",
parse_mode="HTML",
)
return

model_name = args[0].strip()
if not model_name or len(model_name) > 100:
await update.message.reply_text("Invalid model name.")
return
audit_logger = context.bot_data.get("audit_logger")
if model_name == "default":
context.user_data.pop("model_override", None)
default = self._resolve_model_display(None, self.settings.claude_model)
await update.message.reply_text(
f"Model reset to <b>{escape_html(default)}</b>",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(
user_id=update.effective_user.id,
command="model_reset",
args=[],
success=True,
)
else:
context.user_data["model_override"] = model_name
await update.message.reply_text(
f"Model set to <b>{escape_html(model_name)}</b>",
parse_mode="HTML",
)
if audit_logger:
await audit_logger.log_command(
user_id=update.effective_user.id,
command="model",
args=[model_name],
success=True,
)

def _format_verbose_progress(
self,
activity_log: List[Dict[str, Any]],
Expand Down Expand Up @@ -941,13 +1035,16 @@ async def agentic_text(
session_id=session_id,
on_stream=on_stream,
force_new=force_new,
model_override=self._get_model_override(context),
)

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

context.user_data["claude_session_id"] = claude_response.session_id
if claude_response.model:
context.user_data["last_model"] = claude_response.model

# Track directory changes
from .handlers.message import _update_working_directory_from_claude_response
Expand Down Expand Up @@ -1185,12 +1282,15 @@ async def agentic_document(
session_id=session_id,
on_stream=on_stream,
force_new=force_new,
model_override=self._get_model_override(context),
)

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

context.user_data["claude_session_id"] = claude_response.session_id
if claude_response.model:
context.user_data["last_model"] = claude_response.model

from .handlers.message import _update_working_directory_from_claude_response

Expand Down Expand Up @@ -1384,6 +1484,7 @@ async def _handle_agentic_media_message(
session_id=session_id,
on_stream=on_stream,
force_new=force_new,
model_override=self._get_model_override(context),
)
finally:
heartbeat.cancel()
Expand All @@ -1392,6 +1493,7 @@ async def _handle_agentic_media_message(
context.user_data["force_new_session"] = False

context.user_data["claude_session_id"] = claude_response.session_id
context.user_data["last_model"] = claude_response.model

from .handlers.message import _update_working_directory_from_claude_response

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

async def _handle_unknown_command(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Forward unknown slash commands to Claude in agentic mode.

Known commands are handled by their own CommandHandlers (group 0);
this handler fires for *every* COMMAND message in group 10 but
returns immediately when the command is registered, preventing
double execution.
"""
msg = update.effective_message
if not msg or not msg.text:
return
cmd = msg.text.split()[0].lstrip("/").split("@")[0].lower()
if cmd in self._known_commands:
return # let the registered CommandHandler take care of it
# Forward unrecognised /commands to Claude as natural language
await self.agentic_text(update, context)

def _voice_unavailable_message(self) -> str:
"""Return provider-aware guidance when voice feature is unavailable."""
return (
Expand Down
Loading