From 0d4847216e3e2cc4ff5e9fd94551b482eb12fc6f Mon Sep 17 00:00:00 2001 From: ttiee <469784630@qq.com> Date: Sun, 15 Mar 2026 15:11:29 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(telegram):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Telegram=20=E5=BC=95=E5=AF=BC=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #6 --- README.md | 23 +- ...26-03-15-onboarding-guided-entry-design.md | 194 ++++++++++++++ .../2026-03-15-onboarding-guided-entry.md | 238 ++++++++++++++++++ src/nonebot_plugin_codex/__init__.py | 22 ++ src/nonebot_plugin_codex/service.py | 147 +++++++++++ src/nonebot_plugin_codex/telegram.py | 141 ++++++++++- src/nonebot_plugin_codex/telegram_commands.py | 10 + tests/test_telegram_commands.py | 8 +- tests/test_telegram_handlers.py | 121 ++++++++- 9 files changed, 884 insertions(+), 20 deletions(-) create mode 100644 docs/plans/2026-03-15-onboarding-guided-entry-design.md create mode 100644 docs/plans/2026-03-15-onboarding-guided-entry.md diff --git a/README.md b/README.md index 87fffb8..ad4ab02 100644 --- a/README.md +++ b/README.md @@ -108,19 +108,34 @@ codex_workdir = "/home/yourname" 一个典型工作流通常是这样的: ```text -/codex 帮我检查当前仓库为什么测试失败 +/codex /cd /home/yourname/projects/demo -/permission danger /mode resume 然后继续直接发送普通文本消息续聊 ``` +`/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。 + +你也可以直接把首条任务跟在 `/codex` 后面: + +```text +/codex 帮我检查当前仓库为什么测试失败 +/permission danger +``` + 你也可以把一次性任务交给 `exec` 模式: ```text /exec 用三点总结这个仓库 README 还缺什么 ``` +如果你希望显式打开引导入口,也可以使用: + +```text +/help +/start +``` + ## 配置说明 完整配置如下,配置名与当前实现保持一致: @@ -165,7 +180,9 @@ codex_stream_read_limit = 1048576 | 命令 | 说明 | | --- | --- | -| `/codex [prompt]` | 连接 Codex 并可附带首条任务 | +| `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex | +| `/help` | 打开使用引导面板 | +| `/start` | 打开使用引导面板 | | `/mode [resume\|exec]` | 查看或切换默认模式 | | `/exec ` | 以一次性 `exec` 模式执行任务 | | `/new` | 新建当前聊天会话 | diff --git a/docs/plans/2026-03-15-onboarding-guided-entry-design.md b/docs/plans/2026-03-15-onboarding-guided-entry-design.md new file mode 100644 index 0000000..385e4c4 --- /dev/null +++ b/docs/plans/2026-03-15-onboarding-guided-entry-design.md @@ -0,0 +1,194 @@ +# Telegram Onboarding Guided Entry Design + +## Goal + +Add a lightweight onboarding entry panel for Telegram users so first-time and returning users can understand the current chat state and reach the most common next actions without memorizing commands. + +## Problem + +The plugin already supports a Telegram command menu, `/codex`, directory browsing, settings selectors, and history browsing. However, the first-run flow is still fragmented: + +- `/codex` without a prompt only sends a plain status message. +- There is no explicit `/help` or `/start` entrypoint. +- Common next actions are spread across separate commands and panels. +- Telegram mobile users pay a high cost for command recall and repeated command entry. + +This leaves the plugin functional but not well-oriented for first-time use. + +## Constraints + +- Preserve existing command semantics, especially `/codex `. +- Keep onboarding lightweight; do not build a multi-step wizard. +- Reuse existing callback and stale-panel patterns. +- Avoid bundling the separate "unified settings panel" issue into this work. +- Keep button labels and body copy concise for Telegram mobile screens. + +## Approaches Considered + +### 1. Improve `/codex` plain text only + +Revise the existing `/codex` no-argument response text without adding buttons or new commands. + +Pros: + +- Minimal code change +- Almost no new state handling + +Cons: + +- Still command-centric +- Limited improvement for mobile users +- No obvious path from status to action + +### 2. Unified onboarding panel for `/codex`, `/help`, and `/start` + +Route `/codex` without a prompt, `/help`, and `/start` to the same lightweight onboarding panel with summary text and a few high-frequency buttons. + +Pros: + +- Matches Telegram expectations for `/start` +- Preserves current `/codex` behavior for prompted runs +- Reuses existing panel/callback model cleanly +- Delivers a meaningful first-run UX improvement without over-design + +Cons: + +- Requires new callback plumbing and tests + +### 3. Multi-step onboarding wizard + +Guide users through connection, workdir selection, and settings step by step. + +Pros: + +- Strongest hand-holding for first-time users + +Cons: + +- Too much interaction state +- Higher implementation and maintenance cost +- Over-scoped relative to current issue + +## Recommended Approach + +Use approach 2. + +Implement one onboarding panel shared by `/codex` without a prompt, `/help`, and `/start`. Keep `/codex ` unchanged. The panel should summarize current state and provide direct entry to a small set of common next actions by reusing existing flows. + +## User-Facing Behavior + +### Entry points + +- `/codex `: unchanged, immediately executes the prompt +- `/codex`: opens onboarding panel +- `/help`: opens onboarding panel +- `/start`: opens onboarding panel + +### Panel content + +The panel body should show: + +- a short title indicating this is the getting-started entry +- current mode +- current workdir +- current settings summary +- whether the current chat already has an active/bound session +- a very short recommended workflow + +### Panel actions + +Buttons should cover only high-frequency actions: + +- open directory browser +- open settings entry +- open history sessions +- start a fresh session +- close panel + +The panel should not introduce a new multi-step workflow or persistent onboarding state. + +## Architecture + +Add a new onboarding panel flow parallel to the existing directory, history, and setting panel flows. + +### Service layer + +Add onboarding panel state management in `src/nonebot_plugin_codex/service.py`: + +- onboarding callback prefix constant +- stale message constant +- onboarding panel state dataclass +- open/get/remember/close methods +- callback encoding and decoding helpers +- rendering function returning body text and inline keyboard markup + +The state should use the same token/version/message-id pattern already used by other Telegram panels. + +### Telegram handler layer + +Add onboarding-specific methods in `src/nonebot_plugin_codex/telegram.py`: + +- send onboarding panel +- edit or resend onboarding panel +- `/help` handler +- `/start` handler +- onboarding callback predicate and callback handler + +The callback handler should mostly delegate to existing flows: + +- directory button -> existing directory browser send flow +- settings button -> existing setting panel entry +- history button -> existing history browser flow +- new session button -> existing reset chat logic +- close button -> close onboarding panel message + +### Plugin registration + +Update `src/nonebot_plugin_codex/__init__.py` and `src/nonebot_plugin_codex/telegram_commands.py` to register: + +- `/help` +- `/start` + +The command metadata should remain centralized in `telegram_commands.py`. + +## Data Flow + +1. User sends `/codex`, `/help`, or `/start`. +2. Handler activates the chat when appropriate and asks service to open onboarding panel state. +3. Service renders summary text and inline keyboard. +4. Telegram handler sends the panel and remembers its message ID. +5. User presses a button. +6. Callback handler validates token/version, then delegates to the corresponding existing flow or closes the panel. + +## Error Handling + +- Invalid or stale callback payload returns a stale-panel callback alert, matching existing panel behavior. +- If editing the original panel fails, handlers resend the panel and store the new message ID. +- Existing delegated flows keep their current error handling and user-facing wording. + +## Testing Strategy + +Add or update tests for: + +- `/codex` without a prompt now sends onboarding panel markup +- `/help` and `/start` open the same onboarding panel +- onboarding callback dispatch to directory, settings, history, and new-session actions +- stale callback handling +- command metadata includes `help` and `start` + +Run: + +- `pdm run pytest -q` +- `pdm run ruff check .` + +## Risks + +- The panel can become too dense if too many actions are added. +- The onboarding issue can accidentally absorb the separate unified settings issue. +- New text can drift from command metadata if command descriptions are duplicated manually. + +## Mitigations + +- Keep panel scope intentionally narrow. +- Reuse existing panels instead of creating new selector logic. +- Continue using centralized command metadata for slash-command registration and usage text. diff --git a/docs/plans/2026-03-15-onboarding-guided-entry.md b/docs/plans/2026-03-15-onboarding-guided-entry.md new file mode 100644 index 0000000..0e67c35 --- /dev/null +++ b/docs/plans/2026-03-15-onboarding-guided-entry.md @@ -0,0 +1,238 @@ +# Onboarding Guided Entry Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a shared Telegram onboarding panel for `/codex`, `/help`, and `/start` that shows current state and links users into the existing high-frequency workflows. + +**Architecture:** Reuse the existing Telegram panel model used by directory, history, and settings flows. Add one lightweight onboarding panel state machine in the service layer, route three commands to the same rendering path, and keep button actions delegated to the already-existing browser and session flows. + +**Tech Stack:** Python 3.10+, NoneBot 2, nonebot-adapter-telegram, pytest, Pydantic dataclasses + +--- + +### Task 1: Lock onboarding command metadata with tests + +**Files:** +- Modify: `tests/test_telegram_commands.py` +- Modify: `src/nonebot_plugin_codex/telegram_commands.py` + +**Step 1: Write the failing test** + +Add assertions that: + +- `help` and `start` appear in `TELEGRAM_COMMAND_SPECS` +- `build_telegram_commands()` includes both commands with Chinese descriptions +- `build_plugin_usage()` includes `/help` and `/start` + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_commands.py -q` +Expected: FAIL because the command metadata does not include `help` or `start` + +**Step 3: Write minimal implementation** + +Update `src/nonebot_plugin_codex/telegram_commands.py` to add: + +- `help`: "打开使用引导面板" +- `start`: "打开使用引导面板" + +Keep metadata centralized in the existing tuple. + +**Step 4: Run test to verify it passes** + +Run: `pdm run pytest tests/test_telegram_commands.py -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add tests/test_telegram_commands.py src/nonebot_plugin_codex/telegram_commands.py +git commit -m "test: cover onboarding command metadata" +``` + +### Task 2: Lock `/codex` no-argument onboarding behavior in handler tests + +**Files:** +- Modify: `tests/test_telegram_handlers.py` + +**Step 1: Write the failing test** + +Add tests that: + +- `/codex` without a prompt sends an onboarding panel instead of plain status-only text +- the sent payload includes inline keyboard markup +- `/help` and `/start` call the same onboarding panel flow + +Extend `FakeService` only as needed for new onboarding methods and data. + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL because `TelegramHandlers` has no onboarding panel behavior or `/help` and `/start` handlers + +**Step 3: Write minimal implementation** + +Implement only enough test scaffolding to support the new expectations after production code is added. + +**Step 4: Run test to verify it still fails for the right reason** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL due to missing production methods, not broken test setup + +**Step 5: Commit** + +```bash +git add tests/test_telegram_handlers.py +git commit -m "test: define onboarding handler expectations" +``` + +### Task 3: Add onboarding panel state and rendering in service layer + +**Files:** +- Modify: `src/nonebot_plugin_codex/service.py` +- Test: `tests/test_telegram_handlers.py` + +**Step 1: Write the failing test** + +Add focused handler tests that depend on service capabilities: + +- onboarding panel can be opened and remembered +- callback payloads can be dispatched for supported actions +- stale callback payloads surface the onboarding stale message + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL because onboarding callback prefix/state/rendering helpers do not exist + +**Step 3: Write minimal implementation** + +In `src/nonebot_plugin_codex/service.py`, add: + +- `ONBOARDING_CALLBACK_PREFIX` +- `ONBOARDING_STALE_MESSAGE` +- `OnboardingPanelState` +- `encode_onboarding_callback(...)` +- `decode_onboarding_callback(...)` +- `open_onboarding_panel(...)` +- `get_onboarding_panel(...)` +- `remember_onboarding_panel_message(...)` +- `close_onboarding_panel(...)` +- `render_onboarding_panel(...)` + +Keep text generation compact and reuse existing preference/session accessors. + +**Step 4: Run test to verify it passes** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: PASS for onboarding state/rendering expectations + +**Step 5: Commit** + +```bash +git add src/nonebot_plugin_codex/service.py tests/test_telegram_handlers.py +git commit -m "feat: add onboarding panel state and rendering" +``` + +### Task 4: Wire onboarding handlers and callback routing + +**Files:** +- Modify: `src/nonebot_plugin_codex/telegram.py` +- Modify: `src/nonebot_plugin_codex/__init__.py` +- Test: `tests/test_telegram_handlers.py` + +**Step 1: Write the failing test** + +Add or extend tests for: + +- `handle_help()` opens onboarding panel +- `handle_start()` opens onboarding panel +- onboarding callback actions: + - `browse` opens directory browser + - `history` opens history browser + - `settings` opens mode/settings panel entry + - `new` resets chat and returns the expected notice + - `close` closes the panel message + +**Step 2: Run test to verify it fails** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: FAIL because handlers and callback routing are missing + +**Step 3: Write minimal implementation** + +Update `src/nonebot_plugin_codex/telegram.py` and `src/nonebot_plugin_codex/__init__.py` to: + +- register `/help` and `/start` +- route `/codex` without a prompt to the onboarding panel +- add onboarding callback detection and handling +- delegate actions to existing send/reset flows + +Do not alter `/codex ` execution behavior. + +**Step 4: Run test to verify it passes** + +Run: `pdm run pytest tests/test_telegram_handlers.py -q` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/nonebot_plugin_codex/telegram.py src/nonebot_plugin_codex/__init__.py tests/test_telegram_handlers.py +git commit -m "feat: wire telegram onboarding entry panel" +``` + +### Task 5: Update README and final verification + +**Files:** +- Modify: `README.md` +- Modify: `tests/test_telegram_commands.py` +- Modify: `tests/test_telegram_handlers.py` + +**Step 1: Write the failing test** + +If needed, tighten command usage assertions so README-facing command metadata stays aligned with `/help` and `/start`. + +**Step 2: Run targeted tests** + +Run: + +- `pdm run pytest tests/test_telegram_commands.py -q` +- `pdm run pytest tests/test_telegram_handlers.py -q` + +Expected: PASS before docs update + +**Step 3: Write minimal implementation** + +Update `README.md` command and usage sections to mention: + +- `/help` +- `/start` +- `/codex` as the primary onboarding entry without a prompt + +Keep the docs concise. + +**Step 4: Run full verification** + +Run: + +- `pdm run pytest -q` +- `pdm run ruff check .` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add README.md tests/test_telegram_commands.py tests/test_telegram_handlers.py +git commit -m "docs: document telegram onboarding entry points" +``` + +Plan complete and saved to `docs/plans/2026-03-15-onboarding-guided-entry.md`. + +Two execution options: + +1. Subagent-Driven (this session) - I dispatch fresh subagent per task, review between tasks, fast iteration +2. Parallel Session (separate) - Open new session with executing-plans, batch execution with checkpoints + +The user already asked to continue to implementation and final PR, so execute in this session. diff --git a/src/nonebot_plugin_codex/__init__.py b/src/nonebot_plugin_codex/__init__.py index 1c4c913..e12fa87 100644 --- a/src/nonebot_plugin_codex/__init__.py +++ b/src/nonebot_plugin_codex/__init__.py @@ -86,6 +86,8 @@ async def _sync_telegram_commands(bot: Bot) -> None: await sync_telegram_commands(bot) codex_cmd = on_command("codex", priority=10, block=True) + help_cmd = on_command("help", priority=10, block=True) + start_cmd = on_command("start", priority=10, block=True) mode_cmd = on_command("mode", priority=10, block=True) exec_cmd = on_command("exec", priority=10, block=True) new_cmd = on_command("new", priority=10, block=True) @@ -117,6 +119,12 @@ async def _sync_telegram_commands(bot: Bot) -> None: block=True, rule=handlers.is_setting_callback, ) + onboarding_callback = on_type( + CallbackQueryEvent, + priority=10, + block=True, + rule=handlers.is_onboarding_callback, + ) @codex_cmd.handle() async def _handle_codex( @@ -124,6 +132,14 @@ async def _handle_codex( ) -> None: await handlers.handle_codex(bot, event, args) + @help_cmd.handle() + async def _handle_help(bot: Bot, event: MessageEvent) -> None: + await handlers.handle_help(bot, event) + + @start_cmd.handle() + async def _handle_start(bot: Bot, event: MessageEvent) -> None: + await handlers.handle_start(bot, event) + @mode_cmd.handle() async def _handle_mode( bot: Bot, event: MessageEvent, args: Message = CommandArg() @@ -198,6 +214,12 @@ async def _handle_history_callback(bot: Bot, event: CallbackQueryEvent) -> None: async def _handle_setting_callback(bot: Bot, event: CallbackQueryEvent) -> None: await handlers.handle_setting_callback(bot, event) + @onboarding_callback.handle() + async def _handle_onboarding_callback( + bot: Bot, event: CallbackQueryEvent + ) -> None: + await handlers.handle_onboarding_callback(bot, event) + @follow_up.handle() async def _handle_follow_up(bot: Bot, event: MessageEvent) -> None: await handlers.handle_follow_up(bot, event) diff --git a/src/nonebot_plugin_codex/service.py b/src/nonebot_plugin_codex/service.py index 96f350c..09b94d9 100644 --- a/src/nonebot_plugin_codex/service.py +++ b/src/nonebot_plugin_codex/service.py @@ -38,6 +38,8 @@ SETTING_CALLBACK_PREFIX = "csp" SETTING_STALE_MESSAGE = "设置面板已失效,请重新执行对应命令" SUPPORTED_SETTING_PANELS = {"mode", "model", "effort", "permission"} +ONBOARDING_CALLBACK_PREFIX = "cop" +ONBOARDING_STALE_MESSAGE = "引导面板已失效,请重新执行 /codex" @dataclass(slots=True) @@ -195,6 +197,14 @@ class SettingPanelState: message_id: int | None = None +@dataclass(slots=True) +class OnboardingPanelState: + chat_key: str + token: str + version: int + message_id: int | None = None + + def build_chat_key(chat_type: str, chat_id: int) -> str: if chat_type == "private": return f"private_{chat_id}" @@ -330,6 +340,22 @@ def decode_setting_callback(payload: str) -> tuple[str, int, str, str | None]: return token, version, action, value +def encode_onboarding_callback(token: str, version: int, action: str) -> str: + return f"{ONBOARDING_CALLBACK_PREFIX}:{token}:{version}:{action}" + + +def decode_onboarding_callback(payload: str) -> tuple[str, int, str]: + parts = payload.split(":") + if len(parts) != 4 or parts[0] != ONBOARDING_CALLBACK_PREFIX: + raise ValueError("无效的引导回调。") + token = parts[1] + try: + version = int(parts[2]) + except ValueError as exc: + raise ValueError("无效的引导回调。") from exc + return token, version, parts[3] + + def parse_event_line(line: str) -> dict[str, Any] | None: try: payload = json.loads(line) @@ -501,6 +527,7 @@ def __init__( self.directory_browsers: dict[str, DirectoryBrowserState] = {} self.history_browsers: dict[str, HistoryBrowserState] = {} self.setting_panels: dict[str, SettingPanelState] = {} + self.onboarding_panels: dict[str, OnboardingPanelState] = {} self._native_history_entries: list[HistoricalSessionSummary] = [] self._native_history_loaded = False self._history_log_cache: dict[str, HistoryLogCacheEntry] = {} @@ -1639,6 +1666,126 @@ def close_setting_panel(self, chat_key: str, token: str, version: int) -> None: self.get_setting_panel(chat_key, token=token, version=version) self.setting_panels.pop(chat_key, None) + def _replace_onboarding_panel_state( + self, + chat_key: str, + *, + previous: OnboardingPanelState | None = None, + ) -> OnboardingPanelState: + state = OnboardingPanelState( + chat_key=chat_key, + token=previous.token if previous else self._make_browser_token(), + version=(previous.version + 1) if previous else 1, + message_id=previous.message_id if previous else None, + ) + self.onboarding_panels[chat_key] = state + return state + + def open_onboarding_panel(self, chat_key: str) -> OnboardingPanelState: + self.get_preferences(chat_key) + return self._replace_onboarding_panel_state(chat_key) + + def get_onboarding_panel( + self, + chat_key: str, + token: str | None = None, + version: int | None = None, + ) -> OnboardingPanelState: + state = self.onboarding_panels.get(chat_key) + if state is None: + raise ValueError(ONBOARDING_STALE_MESSAGE) + if token is not None and state.token != token: + raise ValueError(ONBOARDING_STALE_MESSAGE) + if version is not None and state.version != version: + raise ValueError(ONBOARDING_STALE_MESSAGE) + return state + + def remember_onboarding_panel_message( + self, + chat_key: str, + token: str, + message_id: int | None, + ) -> None: + if message_id is None: + return + panel = self.get_onboarding_panel(chat_key, token=token) + panel.message_id = message_id + + def close_onboarding_panel(self, chat_key: str, token: str, version: int) -> None: + self.get_onboarding_panel(chat_key, token=token, version=version) + self.onboarding_panels.pop(chat_key, None) + + def render_onboarding_panel( + self, chat_key: str + ) -> tuple[str, InlineKeyboardMarkup]: + panel = self.get_onboarding_panel(chat_key) + preferences = self.get_preferences(chat_key) + session = self.sessions.get(chat_key) + active_mode = ( + session.active_mode + if session is not None and session.active_mode in {"resume", "exec"} + else preferences.default_mode + ) + has_bound_session = bool( + session + and ( + session.active + or session.thread_id + or session.native_thread_id + or session.exec_thread_id + ) + ) + lines = [ + "开始使用 Codex", + f"当前模式:{active_mode}", + f"当前工作目录:{preferences.workdir}", + f"当前设置:{format_preferences_summary(preferences)}", + f"当前会话:{'可继续' if has_bound_session else '未开始'}", + ( + "推荐:直接发送任务,或先切换目录;" + "一次性任务用 /exec;恢复上下文看历史会话。" + ), + ] + keyboard = [ + [ + InlineKeyboardButton( + text="切换目录", + callback_data=encode_onboarding_callback( + panel.token, panel.version, "browse" + ), + ), + InlineKeyboardButton( + text="当前设置", + callback_data=encode_onboarding_callback( + panel.token, panel.version, "settings" + ), + ), + ], + [ + InlineKeyboardButton( + text="历史会话", + callback_data=encode_onboarding_callback( + panel.token, panel.version, "history" + ), + ), + InlineKeyboardButton( + text="新开会话", + callback_data=encode_onboarding_callback( + panel.token, panel.version, "new" + ), + ), + ], + [ + InlineKeyboardButton( + text="关闭", + callback_data=encode_onboarding_callback( + panel.token, panel.version, "close" + ), + ) + ], + ] + return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard) + def navigate_setting_panel( self, chat_key: str, diff --git a/src/nonebot_plugin_codex/telegram.py b/src/nonebot_plugin_codex/telegram.py index b61bc14..b3b019c 100644 --- a/src/nonebot_plugin_codex/telegram.py +++ b/src/nonebot_plugin_codex/telegram.py @@ -15,11 +15,14 @@ HISTORY_STALE_MESSAGE, BROWSER_CALLBACK_PREFIX, HISTORY_CALLBACK_PREFIX, + ONBOARDING_STALE_MESSAGE, + ONBOARDING_CALLBACK_PREFIX, SETTING_STALE_MESSAGE, SETTING_CALLBACK_PREFIX, CodexBridgeService, chunk_text, build_chat_key, + decode_onboarding_callback, format_result_text, decode_browser_callback, decode_history_callback, @@ -346,6 +349,11 @@ async def is_setting_callback(self, event: CallbackQueryEvent) -> bool: f"{SETTING_CALLBACK_PREFIX}:" ) + async def is_onboarding_callback(self, event: CallbackQueryEvent) -> bool: + return isinstance(event.data, str) and event.data.startswith( + f"{ONBOARDING_CALLBACK_PREFIX}:" + ) + def callback_message_id(self, event: CallbackQueryEvent) -> int | None: message = getattr(event, "message", None) return getattr(message, "message_id", None) @@ -358,6 +366,16 @@ async def send_browser(self, bot: Bot, event: MessageEvent, chat_key: str) -> No chat_key, browser.token, getattr(message, "message_id", None) ) + async def send_browser_to_chat( + self, bot: Bot, chat_id: int, chat_key: str + ) -> None: + browser = self.service.open_directory_browser(chat_key) + text, markup = self.service.render_directory_browser(chat_key) + message = await self.send_chat_message(bot, chat_id, text, reply_markup=markup) + self.service.remember_browser_message( + chat_key, browser.token, getattr(message, "message_id", None) + ) + async def send_history_browser( self, bot: Bot, event: MessageEvent, chat_key: str ) -> None: @@ -371,6 +389,19 @@ async def send_history_browser( getattr(message, "message_id", None), ) + async def send_history_browser_to_chat( + self, bot: Bot, chat_id: int, chat_key: str + ) -> None: + await self.service.refresh_history_sessions() + browser = self.service.open_history_browser(chat_key) + text, markup = self.service.render_history_browser(chat_key) + message = await self.send_chat_message(bot, chat_id, text, reply_markup=markup) + self.service.remember_history_browser_message( + chat_key, + browser.token, + getattr(message, "message_id", None), + ) + async def send_setting_panel( self, bot: Bot, @@ -387,6 +418,34 @@ async def send_setting_panel( getattr(message, "message_id", None), ) + async def send_setting_panel_to_chat( + self, + bot: Bot, + chat_id: int, + chat_key: str, + kind: str, + ) -> None: + panel = self.service.open_setting_panel(chat_key, kind) + text, markup = self.service.render_setting_panel(chat_key) + message = await self.send_chat_message(bot, chat_id, text, reply_markup=markup) + self.service.remember_setting_panel_message( + chat_key, + panel.token, + getattr(message, "message_id", None), + ) + + async def send_onboarding_panel( + self, bot: Bot, event: MessageEvent, chat_key: str + ) -> None: + panel = self.service.open_onboarding_panel(chat_key) + text, markup = self.service.render_onboarding_panel(chat_key) + message = await self.send_event_message(bot, event, text, reply_markup=markup) + self.service.remember_onboarding_panel_message( + chat_key, + panel.token, + getattr(message, "message_id", None), + ) + async def edit_or_resend_browser( self, bot: Bot, @@ -502,17 +561,13 @@ async def handle_codex(self, bot: Bot, event: MessageEvent, args: Message) -> No await self.execute_prompt(bot, event, prompt) return - await self.send_event_message( - bot, - event, - ( - "Codex 已连接。\n" - f"当前模式:{self.service.get_session(chat_key).active_mode}\n" - "普通消息继续当前模式,/mode 切换默认模式," - "/exec 执行一次性任务,/new 新开,/stop 退出。\n" - f"当前设置:{self.current_summary(chat_key)}" - ), - ) + await self.send_onboarding_panel(bot, event, chat_key) + + async def handle_help(self, bot: Bot, event: MessageEvent) -> None: + await self.send_onboarding_panel(bot, event, self.chat_key(event)) + + async def handle_start(self, bot: Bot, event: MessageEvent) -> None: + await self.send_onboarding_panel(bot, event, self.chat_key(event)) async def handle_mode(self, bot: Bot, event: MessageEvent, args: Message) -> None: chat_key = self.chat_key(event) @@ -771,6 +826,70 @@ async def handle_setting_callback(self, bot: Bot, event: CallbackQueryEvent) -> event.id, text=self.error_text(exc), show_alert=True ) + async def handle_onboarding_callback( + self, bot: Bot, event: CallbackQueryEvent + ) -> None: + if not isinstance(event.data, str): + await bot.answer_callback_query( + event.id, text=ONBOARDING_STALE_MESSAGE, show_alert=True + ) + return + + try: + chat_key = self.chat_key(event) + chat_id = self.event_chat(event).id + token, version, action = decode_onboarding_callback(event.data) + self.service.get_onboarding_panel(chat_key, token=token, version=version) + if action == "browse": + await self.send_browser_to_chat(bot, chat_id, chat_key) + await bot.answer_callback_query(event.id) + return + if action == "history": + await self.send_history_browser_to_chat(bot, chat_id, chat_key) + await bot.answer_callback_query(event.id) + return + if action == "settings": + await self.send_setting_panel_to_chat(bot, chat_id, chat_key, "mode") + await bot.answer_callback_query(event.id) + return + if action == "new": + await self.service.reset_chat(chat_key, keep_active=True) + await self.send_chat_message( + bot, + chat_id, + ( + "已清空当前 Codex 会话。下一条普通消息会按以下设置新开会话:\n" + f"{self.current_summary(chat_key)}" + ), + ) + await bot.answer_callback_query(event.id, text="已新开会话。") + return + if action == "close": + self.service.close_onboarding_panel(chat_key, token, version) + message_id = self.callback_message_id(event) + if message_id is not None: + await self.edit_message( + bot, + chat_id=chat_id, + message_id=message_id, + text="使用引导已关闭。", + reply_markup=None, + ) + await bot.answer_callback_query(event.id, text="已关闭。") + return + raise ValueError("未知引导操作。") + except ValueError as exc: + text = str(exc) or ONBOARDING_STALE_MESSAGE + await bot.answer_callback_query( + event.id, + text=text, + show_alert=text == ONBOARDING_STALE_MESSAGE, + ) + except RuntimeError as exc: + await bot.answer_callback_query( + event.id, text=self.error_text(exc), show_alert=True + ) + async def handle_follow_up(self, bot: Bot, event: MessageEvent) -> None: chat_key = self.chat_key(event) session = self.service.get_session(chat_key) diff --git a/src/nonebot_plugin_codex/telegram_commands.py b/src/nonebot_plugin_codex/telegram_commands.py index 6f2e71b..b40f09e 100644 --- a/src/nonebot_plugin_codex/telegram_commands.py +++ b/src/nonebot_plugin_codex/telegram_commands.py @@ -18,6 +18,16 @@ class TelegramCommandSpec: description="连接 Codex 并可附带首条任务", usage="/codex [prompt]", ), + TelegramCommandSpec( + name="help", + description="打开使用引导面板", + usage="/help", + ), + TelegramCommandSpec( + name="start", + description="打开使用引导面板", + usage="/start", + ), TelegramCommandSpec( name="mode", description="查看或切换默认模式", diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index c172161..150852a 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -10,6 +10,8 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() -> None: assert [spec.name for spec in TELEGRAM_COMMAND_SPECS] == [ "codex", + "help", + "start", "mode", "exec", "new", @@ -26,6 +28,8 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() assert [command.model_dump() for command in build_telegram_commands()] == [ {"command": "codex", "description": "连接 Codex 并可附带首条任务"}, + {"command": "help", "description": "打开使用引导面板"}, + {"command": "start", "description": "打开使用引导面板"}, {"command": "mode", "description": "查看或切换默认模式"}, {"command": "exec", "description": "以一次性 exec 模式执行任务"}, {"command": "new", "description": "新建当前聊天会话"}, @@ -43,6 +47,6 @@ def test_build_telegram_commands_uses_expected_order_and_chinese_descriptions() def test_build_plugin_usage_lists_all_commands() -> None: assert build_plugin_usage() == ( - "/codex [prompt], /mode, /exec, /new, /stop, /models, /model, /effort, " - "/permission, /pwd, /cd, /home, /sessions" + "/codex [prompt], /help, /start, /mode, /exec, /new, /stop, /models, " + "/model, /effort, /permission, /pwd, /cd, /home, /sessions" ) diff --git a/tests/test_telegram_handlers.py b/tests/test_telegram_handlers.py index f2c44fb..c83d592 100644 --- a/tests/test_telegram_handlers.py +++ b/tests/test_telegram_handlers.py @@ -144,6 +144,8 @@ def __init__(self) -> None: self.browser_text = "目录浏览" self.history_text = "Codex 历史会话" self.setting_text = "模式设置" + self.onboarding_text = "开始使用 Codex" + self.onboarding_markup = SimpleNamespace(name="onboarding") self.default_mode = "resume" self.execute_calls: list[tuple[str, str | None]] = [] self.browser_token = "token" @@ -157,6 +159,9 @@ def __init__(self) -> None: self.setting_kind = "mode" self.setting_updates: list[str] = [] self.updated_workdirs: list[str] = [] + self.onboarding_token = "onboarding" + self.onboarding_version = 1 + self.onboarding_closed = False def get_session(self, chat_key: str) -> ChatSession: return self.session @@ -327,6 +332,36 @@ async def update_default_mode(self, chat_key: str, mode: str) -> str: self.setting_updates.append(mode) return f"当前默认模式:{mode}" + def open_onboarding_panel(self, chat_key: str) -> SimpleNamespace: + return SimpleNamespace(token=self.onboarding_token) + + def render_onboarding_panel(self, chat_key: str) -> tuple[str, Any]: + return self.onboarding_text, self.onboarding_markup + + def remember_onboarding_panel_message( + self, chat_key: str, token: str, message_id: int | None + ) -> None: + return None + + def get_onboarding_panel( + self, + chat_key: str, + token: str | None = None, + version: int | None = None, + ) -> SimpleNamespace: + if token is not None and token != self.onboarding_token: + raise ValueError("引导面板已失效,请重新执行 /codex") + if version is not None and version != self.onboarding_version: + raise ValueError("引导面板已失效,请重新执行 /codex") + return SimpleNamespace( + token=self.onboarding_token, + version=self.onboarding_version, + message_id=1, + ) + + def close_onboarding_panel(self, chat_key: str, token: str, version: int) -> None: + self.onboarding_closed = True + def make_real_service( tmp_path: Path, @@ -374,7 +409,7 @@ def make_real_service_without_model_cache(tmp_path: Path) -> CodexBridgeService: @pytest.mark.asyncio -async def test_handle_codex_without_prompt_sends_status_message() -> None: +async def test_handle_codex_without_prompt_sends_onboarding_panel() -> None: service = FakeService() handlers = TelegramHandlers(service) bot = FakeBot() @@ -382,8 +417,21 @@ async def test_handle_codex_without_prompt_sends_status_message() -> None: await handlers.handle_codex(bot, event, FakeMessage("")) - assert "Codex 已连接" in bot.sent[0]["text"] - assert "当前模式" in bot.sent[0]["text"] + assert bot.sent[0]["text"] == "开始使用 Codex" + assert bot.sent[0]["reply_markup"] is service.onboarding_markup + + +@pytest.mark.asyncio +@pytest.mark.parametrize("handler_name", ["handle_help", "handle_start"]) +async def test_help_and_start_open_onboarding_panel(handler_name: str) -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await getattr(handlers, handler_name)(bot, FakeEvent("")) + + assert bot.sent[0]["text"] == "开始使用 Codex" + assert bot.sent[0]["reply_markup"] is service.onboarding_markup @pytest.mark.asyncio @@ -479,6 +527,71 @@ async def test_handle_sessions_opens_history_browser() -> None: assert bot.sent[0]["text"] == "Codex 历史会话" +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("payload", "expected_text"), + [ + ("cop:onboarding:1:browse", "目录浏览"), + ("cop:onboarding:1:history", "Codex 历史会话"), + ("cop:onboarding:1:settings", "模式设置"), + ], +) +async def test_handle_onboarding_callback_opens_existing_panels( + payload: str, + expected_text: str, +) -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await handlers.handle_onboarding_callback(bot, FakeCallbackEvent(payload)) + + assert bot.sent[0]["text"] == expected_text + + +@pytest.mark.asyncio +async def test_handle_onboarding_callback_new_resets_chat() -> None: + service = FakeService() + service.session.thread_id = "thread-1" + handlers = TelegramHandlers(service) + bot = FakeBot() + + await handlers.handle_onboarding_callback( + bot, FakeCallbackEvent("cop:onboarding:1:new") + ) + + assert "已清空当前 Codex 会话" in bot.sent[0]["text"] + assert bot.answered[0]["text"] == "已新开会话。" + + +@pytest.mark.asyncio +async def test_handle_onboarding_callback_close_closes_panel() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await handlers.handle_onboarding_callback( + bot, FakeCallbackEvent("cop:onboarding:1:close") + ) + + assert service.onboarding_closed is True + assert bot.edited[0]["text"] == "使用引导已关闭。" + assert bot.answered[0]["text"] == "已关闭。" + + +@pytest.mark.asyncio +async def test_handle_onboarding_callback_rejects_stale_payload() -> None: + handlers = TelegramHandlers(FakeService()) + bot = FakeBot() + + await handlers.handle_onboarding_callback( + bot, FakeCallbackEvent("cop:stale:1:browse") + ) + + assert bot.answered[0]["text"] == "引导面板已失效,请重新执行 /codex" + assert bot.answered[0]["show_alert"] is True + + @pytest.mark.asyncio async def test_handle_follow_up_rejects_when_running() -> None: service = FakeService() @@ -656,5 +769,5 @@ async def test_handle_codex_without_prompt_works_when_model_cache_is_missing( await handlers.handle_codex(bot, FakeEvent(""), FakeMessage("")) - assert "Codex 已连接" in bot.sent[0]["text"] + assert "开始使用" in bot.sent[0]["text"] assert "模型: gpt-5" in bot.sent[0]["text"]