diff --git a/src/telegram_codex_bot/bot.py b/src/telegram_codex_bot/bot.py index 0b3dd1a..b13008d 100644 --- a/src/telegram_codex_bot/bot.py +++ b/src/telegram_codex_bot/bot.py @@ -300,6 +300,37 @@ async def _safe_send_typing_action(chat: Chat, *, source: str) -> None: } +_FORWARDED_COMMAND_RE = re.compile( + r"^/(?P[A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?P.*)$" +) +_COMMAND_ARG_SEPARATORS = ".。" + + +def _normalize_forward_command_text(cmd_text: str) -> str: + """Normalize Telegram slash commands before forwarding them to Codex.""" + match = _FORWARDED_COMMAND_RE.match(cmd_text) + if not match: + return cmd_text + + command_name = match.group("name") + rest = match.group("rest") or "" + normalized = f"/{command_name}" + + # Telegram users sometimes type "/goal.继续..." from Chinese input. Codex + # expects a space between a known slash command and its argument; without it + # the TUI can leave the text pending and the bot reports a low-level send + # failure. Keep this limited to known Codex commands so arbitrary forwarded + # commands keep their original spelling. + if ( + command_name.lower() in CC_COMMANDS + and rest + and rest[0] in _COMMAND_ARG_SEPARATORS + ): + rest = " " + rest[1:].lstrip() + + return normalized + rest + + class _DirectoryBrowserKwargs(TypedDict, total=False): root_label: str root_path: str @@ -1983,8 +2014,10 @@ async def forward_command_handler( session_manager.set_group_chat_id(user.id, thread_id, chat.id) cmd_text = update.message.text or "" - # The full text is already a slash command like "/clear" or "/compact foo" - cc_slash = cmd_text.split("@")[0] # strip bot mention + # The full text is already a slash command like "/clear" or "/compact foo". + # Strip only a Telegram bot mention in the command token; keep @mentions + # in command arguments intact. + cc_slash = _normalize_forward_command_text(cmd_text) wid = session_manager.resolve_window_for_thread(user.id, thread_id) target = session_manager.resolve_target_for_thread(user.id, thread_id) if not wid and not target: diff --git a/tests/telegram_codex_bot/test_forward_command.py b/tests/telegram_codex_bot/test_forward_command.py index ff54921..58255ba 100644 --- a/tests/telegram_codex_bot/test_forward_command.py +++ b/tests/telegram_codex_bot/test_forward_command.py @@ -120,6 +120,68 @@ async def test_goal_sends_command_to_tmux(self): mock_send.assert_awaited_once_with(context.bot, 1, 42, "@5", "/goal") + @pytest.mark.asyncio + async def test_goal_dot_argument_is_normalized_before_forwarding(self): + """/goal.中文参数 → /goal 中文参数 for Codex.""" + update = _make_update("/goal.继续完成验证") + context = _make_context() + + with ( + patch("telegram_codex_bot.bot.is_user_allowed", return_value=True), + patch("telegram_codex_bot.bot._get_thread_id", return_value=42), + patch("telegram_codex_bot.bot.session_manager") as mock_sm, + patch( + "telegram_codex_bot.bot._send_or_queue_agent_input", + new_callable=AsyncMock, + ) as mock_send, + patch("telegram_codex_bot.bot.safe_reply", new_callable=AsyncMock), + ): + mock_sm.resolve_window_for_thread.return_value = "@5" + mock_sm.resolve_target_for_thread.return_value = AgentTarget( + "local", "local", window_id="@5" + ) + mock_sm.get_display_name.return_value = "project" + mock_send.return_value = (True, "ok", False) + + from telegram_codex_bot.bot import forward_command_handler + + await forward_command_handler(update, context) + + mock_send.assert_awaited_once_with( + context.bot, 1, 42, "@5", "/goal 继续完成验证" + ) + + @pytest.mark.asyncio + async def test_bot_mention_keeps_command_arguments(self): + """/goal@bot args → /goal args without dropping @ in args.""" + update = _make_update("/goal@CodexBot inspect @filename") + context = _make_context() + + with ( + patch("telegram_codex_bot.bot.is_user_allowed", return_value=True), + patch("telegram_codex_bot.bot._get_thread_id", return_value=42), + patch("telegram_codex_bot.bot.session_manager") as mock_sm, + patch( + "telegram_codex_bot.bot._send_or_queue_agent_input", + new_callable=AsyncMock, + ) as mock_send, + patch("telegram_codex_bot.bot.safe_reply", new_callable=AsyncMock), + ): + mock_sm.resolve_window_for_thread.return_value = "@5" + mock_sm.resolve_target_for_thread.return_value = AgentTarget( + "local", "local", window_id="@5" + ) + mock_sm.get_display_name.return_value = "project" + mock_send.return_value = (True, "ok", False) + + from telegram_codex_bot.bot import forward_command_handler + + await forward_command_handler(update, context) + + mock_send.assert_awaited_once_with( + context.bot, 1, 42, "@5", "/goal inspect @filename" + ) + @pytest.mark.asyncio async def test_command_queues_during_interactive_ui(self): update = _make_update("/model")