diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index a18248b0..1124d006 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -127,6 +127,7 @@ def __init__(self, settings: Settings, deps: Dict[str, Any]): self.settings = settings self.deps = deps self._active_requests: Dict[int, ActiveRequest] = {} + 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.""" @@ -324,6 +325,9 @@ def _register_agentic_handlers(self, app: Application) -> None: 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))) @@ -336,6 +340,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( @@ -1505,6 +1522,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 ( diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 320f54ae..ce5e419e 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -149,8 +149,8 @@ def test_agentic_registers_text_document_photo_handlers(agentic_settings, deps): if isinstance(call[0][0], CallbackQueryHandler) ] - # 4 message handlers (text, document, photo, voice) - assert len(msg_handlers) == 4 + # 5 message handlers (text, document, photo, voice, unknown commands passthrough) + assert len(msg_handlers) == 5 # 2 callback handlers (stop: + cd:) assert len(cb_handlers) == 2 @@ -930,3 +930,63 @@ async def help_command(update, context): assert called["value"] is False update.effective_message.reply_text.assert_called_once() + + +async def test_known_command_not_forwarded_to_claude(agentic_settings, deps): + """Known commands must NOT be forwarded to agentic_text.""" + from unittest.mock import AsyncMock, MagicMock, patch + + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + orchestrator.register_handlers(app) + + update = MagicMock() + update.effective_message.text = "/start" + context = MagicMock() + + with patch.object( + orchestrator, "agentic_text", new_callable=AsyncMock + ) as mock_claude: + await orchestrator._handle_unknown_command(update, context) + mock_claude.assert_not_called() + + +async def test_unknown_command_forwarded_to_claude(agentic_settings, deps): + """Unknown slash commands must be forwarded to agentic_text.""" + from unittest.mock import AsyncMock, MagicMock, patch + + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + orchestrator.register_handlers(app) + + update = MagicMock() + update.effective_message.text = "/workflow activate job-hunter" + context = MagicMock() + + with patch.object( + orchestrator, "agentic_text", new_callable=AsyncMock + ) as mock_claude: + await orchestrator._handle_unknown_command(update, context) + mock_claude.assert_called_once_with(update, context) + + +async def test_bot_suffixed_command_not_forwarded(agentic_settings, deps): + """Bot-suffixed known commands like /start@mybot must not reach Claude.""" + from unittest.mock import AsyncMock, MagicMock, patch + + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + orchestrator.register_handlers(app) + + update = MagicMock() + update.effective_message.text = "/start@mybot" + context = MagicMock() + + with patch.object( + orchestrator, "agentic_text", new_callable=AsyncMock + ) as mock_claude: + await orchestrator._handle_unknown_command(update, context) + mock_claude.assert_not_called()