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
37 changes: 35 additions & 2 deletions src/telegram_codex_bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,37 @@ async def _safe_send_typing_action(chat: Chat, *, source: str) -> None:
}


_FORWARDED_COMMAND_RE = re.compile(
r"^/(?P<name>[A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?P<rest>.*)$"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match forwarded commands across newlines

When a forwarded command has a multi-line argument, such as /goal.继续\n补充说明 or /goal@CodexBot first\nsecond, this regex does not match because Python's .* stops at the first newline. _normalize_forward_command_text then returns the original text, so the newly added dot normalization and bot-mention stripping are skipped for exactly the kind of long goal/prompt text users can send from Telegram.

Useful? React with 👍 / 👎.

)
_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
Expand Down Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions tests/telegram_codex_bot/test_forward_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading