From e1090e13438e94d850f6aa9a6e3a4e45bc125956 Mon Sep 17 00:00:00 2001 From: ttiee <469784630@qq.com> Date: Thu, 12 Mar 2026 09:33:42 +0800 Subject: [PATCH 1/2] docs(repo): add contribution guidance and issue templates Add repository-level agent guidance, GitHub issue/PR templates, and tracked planning/maintenance docs for the current follow-up work. Refs #1 --- .github/ISSUE_TEMPLATE/bug.md | 31 ---- .github/ISSUE_TEMPLATE/bug_report.yml | 122 ++++++--------- .github/ISSUE_TEMPLATE/config.yml | 6 + .github/ISSUE_TEMPLATE/feature.md | 26 --- .github/ISSUE_TEMPLATE/feature_request.yml | 59 ++++--- .github/pull_request_template.md | 21 +++ AGENTS.md | 55 +++++++ .../2026-03-12-repository-follow-ups.md | 55 +++++++ ...03-12-repository-standardization-design.md | 117 ++++++++++++++ .../2026-03-12-repository-standardization.md | 148 ++++++++++++++++++ 10 files changed, 488 insertions(+), 152 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature.md create mode 100644 .github/pull_request_template.md create mode 100644 AGENTS.md create mode 100644 docs/maintenance/2026-03-12-repository-follow-ups.md create mode 100644 docs/plans/2026-03-12-repository-standardization-design.md create mode 100644 docs/plans/2026-03-12-repository-standardization.md diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index ab5ee96..0000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Bug 反馈 (经典模板) -about: 有关 bug 的报告 -title: "[Bug]" -labels: bug, triage -assignees: "" ---- - -## 请确认: - -* [ ] 问题的标题明确 -* [ ] 我翻阅过其他的issue并且找不到类似的问题 - -# Bug - -## 问题 - - -## 如何复现 - - -## 预期行为 - - -## 使用环境: -- Python 版本: -- Nonebot2 版本: -- 插件 版本: - -## 日志/截图 - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3047b1f..2cbdae1 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,108 +1,78 @@ -name: Bug 反馈 -title: "[Bug]: " -description: 提交 Bug 反馈以帮助我们改进代码 -labels: ["bug"] +name: Bug Report +description: Report a confirmed defect or regression +title: "bug: " +labels: + - bug body: - - type: checkboxes - id: ensure + - type: markdown attributes: - label: 确认项 - description: 请确认以下选项 - options: - - label: 问题的标题明确 - required: true - - label: 我翻阅过其他的 issue 并且找不到类似的问题 - required: true - - label: 我已经尝试过在最新的代码中修复这个问题 - required: false - - type: dropdown - id: env-os - attributes: - label: 操作系统 - description: 选择运行 NoneBot 的系统 - options: - - Windows - - MacOS - - Linux - - Other - validations: - required: true + value: | + Provide a reproducible report. Include exact commands, config, and observed behavior. - type: input - id: env-python-ver + id: summary attributes: - label: Python 版本 - description: 填写运行 NoneBot 的 Python 版本 - placeholder: e.g. 3.11.0 + label: Summary + description: One-sentence description of the bug + placeholder: codex_workdir is ignored by /home and the directory browser Home action validations: required: true - - type: input - id: env-nb-ver - attributes: - label: NoneBot 版本 - description: 填写 NoneBot 版本 - placeholder: e.g. 2.3.0 - validations: - required: true - - - type: input - id: env-adapter + - type: textarea + id: environment attributes: - label: 适配器 - description: 填写 NoneBot 加载使用的适配器 - placeholder: e.g. OneBot V11 + label: Environment + description: Python, PDM, NoneBot, adapter, OS, and any relevant Codex setup details + placeholder: | + Python: + PDM: + NoneBot: + Adapter: + OS: + Codex CLI: validations: required: true - - type: input - id: env-plugin + - type: textarea + id: reproduce attributes: - label: 插件版本 - description: 填写使用的插件版本 - placeholder: e.g. 0.1.0 + label: Steps To Reproduce + description: Provide exact steps or commands + placeholder: | + 1. Configure codex_workdir = "/tmp/work" + 2. Start the bot + 3. Run /home + 4. Observe the reported workdir validations: required: true - type: textarea - id: describe + id: expected attributes: - label: 描述问题 - description: 清晰简洁地说明问题是什么 + label: Expected Behavior validations: required: true - type: textarea - id: reproduction + id: actual attributes: - label: 复现步骤 - description: 提供能复现此问题的详细操作步骤 - placeholder: | - 1. 首先…… - 2. 然后…… - 3. 发生…… + label: Actual Behavior validations: required: true - type: textarea - id: expected + id: evidence attributes: - label: 期望的结果 - description: 清晰简洁地描述你期望发生的事情 + label: Verification Evidence + description: Include failing tests, logs, screenshots, or command output summaries - type: textarea - id: logs + id: affected attributes: - label: 截图或日志 - description: 提供有助于诊断问题的任何日志和截图 - - - type: textarea - id: config - attributes: - label: Nonebot 配置项 - description: Nonebot 配置项 (如果你的配置文件中包含敏感信息,请自行删除) - render: dotenv + label: Affected Files Or Commands placeholder: | - # e.g. - # KEY=VALUE - # KEY2=VALUE2 \ No newline at end of file + src/nonebot_plugin_codex/service.py + src/nonebot_plugin_codex/telegram.py + /home + /cd + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1b2eefc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: Repository Plans + url: https://github.com/ttiee/nonebot-plugin-codex/tree/main/docs/plans + about: Review existing design and implementation planning documents before opening broad-scoped requests. + diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 3b6b103..0000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Feature 特性请求 (经典模板) -about: 为本插件加份菜 -title: "[Feature] " -labels: enhancement, triage -assignees: "" ---- - -## 请确认: - -* [ ] 新特性的目的明确 - - -## Feature -### 概要 - - - -### 是否已有相关实现 - -暂无 - - -### 其他内容 - -暂无 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index f9201cc..b79d9c0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,30 +1,51 @@ -name: 功能建议 -title: "[Feature]: " -description: 提出关于项目新功能的想法 -labels: ["enhancement"] +name: Feature Request +description: Propose an improvement or new capability +title: "feat: " +labels: + - enhancement body: - - type: checkboxes - id: ensure + - type: markdown attributes: - label: 确认项 - description: 请确认以下选项 - options: - - label: 新特性的目的明确 - required: true - - label: 我已经使用过该插件并且了解其功能 - required: true + value: | + Describe the user problem first, then the proposed solution. + + - type: input + id: summary + attributes: + label: Summary + placeholder: Add button panels for mode/model/effort/permission commands + validations: + required: true + - type: textarea id: problem attributes: - label: 希望能解决的问题 - description: 在使用中遇到什么问题而需要新的功能? + label: Problem Statement + description: What is hard, missing, or error-prone today? validations: required: true - type: textarea - id: feature + id: proposal attributes: - label: 描述所需要的功能 - description: 请说明需要的功能或解决方法 + label: Proposed Solution validations: - required: true \ No newline at end of file + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + + - type: textarea + id: scope + attributes: + label: Scope And Constraints + description: Compatibility, migration, UX, or testing constraints that matter + + - type: textarea + id: verification + attributes: + label: Verification Plan + description: Tests, manual checks, or rollout validation you expect + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..74cd470 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + +- + +## Linked Issue + +Closes # + +## What Changed + +- + +## Verification + +- [ ] `pdm run pytest -q` +- [ ] `pdm run ruff check .` + +## Risks / Follow-Ups + +- + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cb994e2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# Repository Guidelines + +## Project Scope + +`nonebot-plugin-codex` is a NoneBot plugin that exposes local Codex CLI workflows through Telegram. The repository is a Python package using a `src/` layout and PDM for dependency management. + +## Repository Layout + +- `src/nonebot_plugin_codex/`: plugin source code +- `tests/`: pytest-based automated tests +- `docs/plans/`: design and implementation planning documents +- `docs/maintenance/`: tracked maintenance items and issue-ready notes + +## Working Norms + +- Keep changes aligned with the current plugin architecture and configuration compatibility. +- Prefer small, reviewable changes with explicit tests for behavior changes. +- Do not silently change documented config semantics; update docs and tests together. +- Preserve backward compatibility for user-facing commands unless the change is explicitly intentional and documented. + +## Development Commands + +- Install dependencies: `pdm sync -G:all` +- Run tests: `pdm run pytest -q` +- Run focused tests: `pdm run pytest tests/test_service.py tests/test_telegram_handlers.py -q` +- Run lint: `pdm run ruff check .` + +Use `pdm run ...` for repository commands so the project environment and `src/` import path are configured correctly. + +## Code Change Expectations + +- Follow TDD for behavior changes: write or update the failing test first, verify the failure, then implement the minimum fix. +- Reuse existing callback/state patterns before adding new interaction mechanisms. +- Keep repository files ASCII unless an existing file already uses non-ASCII content and it is appropriate to match it. +- Avoid unrelated refactors while fixing a bug or delivering a scoped improvement. + +## Issue Expectations + +- Every non-trivial change should be traceable to an issue or a documented maintenance note. +- Issue reports should include reproduction steps, expected behavior, actual behavior, and the affected files or commands. +- Use `docs/maintenance/` markdown files as durable issue drafts when preparing GitHub issues from local work. + +## Pull Request Expectations + +- Link the PR to the relevant issue. +- Summarize user-visible behavior changes and internal refactors separately. +- Include fresh verification evidence, at minimum: + - `pdm run pytest -q` + - `pdm run ruff check .` +- Call out any unverified areas or follow-up work explicitly. + +## Safety Notes + +- Respect persisted compatibility paths such as `data/codex_bridge/preferences.json` and `~/.codex/*` unless the change explicitly updates migration behavior. +- Do not remove or rewrite user-authored documents under `docs/` unless the task requires it. diff --git a/docs/maintenance/2026-03-12-repository-follow-ups.md b/docs/maintenance/2026-03-12-repository-follow-ups.md new file mode 100644 index 0000000..ccbfc96 --- /dev/null +++ b/docs/maintenance/2026-03-12-repository-follow-ups.md @@ -0,0 +1,55 @@ +# Repository Follow-Ups + +## Summary + +The repository has a confirmed configuration bug around `codex_workdir`, several selector commands that still rely on manual argument entry instead of button-driven Telegram UX, and missing repository-level collaboration templates. + +## Confirmed Problems + +### 1. `codex_workdir` does not actually drive runtime defaults + +The README documents `codex_workdir` as: + +- the default workdir +- the base for relative `/cd` +- the Home target in the directory browser + +Current runtime behavior does not match that contract: + +- default chat preferences fall back to the OS home directory +- `/home` resets to the OS home directory +- directory browser `Home` jumps to the OS home directory + +Affected files: + +- `src/nonebot_plugin_codex/service.py` +- `src/nonebot_plugin_codex/telegram.py` +- `README.md` + +### 2. Selection-heavy commands still require manual text input + +The repository already uses inline keyboard flows for `/cd` and `/sessions`, but these commands still depend on remembering arguments: + +- `/mode` +- `/model` +- `/effort` +- `/permission` + +This is inconsistent with the current UX direction and increases input friction on Telegram. + +### 3. Repository collaboration standards are not yet codified + +The project does not currently provide repository-level contribution guidance or GitHub issue/PR templates aligned with the existing stack and verification commands. + +## Proposed Follow-Up Work + +1. Add `AGENTS.md` and GitHub issue/PR templates. +2. Fix `codex_workdir` so the configured path is the runtime default home/workdir baseline. +3. Convert `/mode`, `/model`, `/effort`, and `/permission` to button panels while keeping existing text arguments compatible. +4. Add regression tests for all of the above behavior. + +## Verification Targets + +- `pdm run pytest -q` +- `pdm run ruff check .` +- focused tests covering service and Telegram handler behavior for the new workdir and selector panel flows diff --git a/docs/plans/2026-03-12-repository-standardization-design.md b/docs/plans/2026-03-12-repository-standardization-design.md new file mode 100644 index 0000000..15843e7 --- /dev/null +++ b/docs/plans/2026-03-12-repository-standardization-design.md @@ -0,0 +1,117 @@ +# Repository Standardization Design + +## Background + +This repository already has working code and a basic test suite, but it lacks repository-level contribution guidance and reusable GitHub issue/PR templates. The current Telegram UX also mixes button-driven flows (`/cd`, `/sessions`) with manual argument entry (`/mode`, `/model`, `/effort`, `/permission`), and the documented `codex_workdir` setting does not actually drive runtime defaults. + +## Goals + +- Add repository-level collaboration guidance with a focused `AGENTS.md`. +- Add GitHub issue and PR templates so maintenance work is repeatable and reviewable. +- Write a tracked markdown backlog item for the currently identified problems, and open a GitHub issue from it if the CLI environment allows. +- Fix `codex_workdir` so documented behavior matches runtime behavior. +- Convert `/mode`, `/model`, `/effort`, and `/permission` from manual-argument commands into button-driven panels, consistent with the existing directory/session browsers. +- Add regression tests that lock in the new behavior. + +## Non-Goals + +- No broad redesign of the plugin command set beyond the four selection-heavy commands above. +- No new external dependencies. +- No addition of broader community docs such as `CONTRIBUTING.md` in this pass. + +## Approach Options + +### Option 1: Minimal fixes only + +Add the missing docs, fix `codex_workdir`, and leave the command UX unchanged. + +- Pros: smallest diff +- Cons: leaves obvious UX inconsistency in place + +### Option 2: Standardize workflow and command selection UX + +Add repository/process docs, file the issue, fix `codex_workdir`, and convert the four selector commands to button panels built on the existing callback pattern. + +- Pros: matches the user's stated goal, keeps architecture consistent, and improves both maintainer workflow and Telegram usability +- Cons: touches both docs and interaction code in one change set + +### Option 3: Full maintainer-doc expansion + +Do Option 2 plus add `CONTRIBUTING.md`, labels guidance, and a broader governance layer. + +- Pros: more complete project scaffolding +- Cons: wider scope than necessary for the current repository size + +## Recommended Design + +Use Option 2. + +### Repository Guidance + +Add a top-level `AGENTS.md` aimed at repository contributors and AI agents. It should define: + +- project purpose and stack +- where code lives +- required verification commands +- expectations for tests, issue references, and PR descriptions +- guardrails around touching user data/config-compatible paths + +### GitHub Templates + +Add: + +- `.github/ISSUE_TEMPLATE/bug_report.yml` +- `.github/ISSUE_TEMPLATE/feature_request.yml` +- `.github/ISSUE_TEMPLATE/config.yml` +- `.github/pull_request_template.md` + +The templates should bias toward reproducibility, expected/actual behavior, verification evidence, and linked issues. + +### Backlog And Issue Workflow + +Create a markdown file under `docs/` that captures the identified bugs and improvements in repository language. Use that as the source of truth for a GitHub issue body and then create the issue with `gh issue create`. + +### `codex_workdir` Fix + +The service currently defaults to `Path.home()` in places where the documented configured workdir should be used. The fix is to make the configured `CodexBridgeSettings.workdir` the single default "workspace home" used by: + +- new chat preferences +- `/home` +- directory browser `Home` +- any fallback that currently assumes the OS home directory + +This should remain compatible with explicit per-chat overrides saved in preferences. + +### Button Panels For Selection Commands + +Reuse the existing callback-driven browser pattern rather than inventing a second interaction system. + +Add a lightweight settings browser state that can render and handle button panels for: + +- mode +- model +- effort +- permission + +The panel should show current values and let the user change them by tapping buttons. Existing text-argument usage should continue to work for backward compatibility. + +### Testing Strategy + +Use TDD: + +- write failing tests for `codex_workdir` default/home behavior +- write failing tests for the new button-panel rendering and callback handling +- then implement the minimum service and handler changes + +Tests should cover both service-level behavior and Telegram handler-level interaction where appropriate. + +## Risks And Mitigations + +- Risk: callback state becomes more fragmented. + Mitigation: follow the existing browser/history callback structure and naming conventions. + +- Risk: fixing `codex_workdir` breaks persisted preference behavior. + Mitigation: only use configured workdir for defaults/fallbacks, not to overwrite explicit stored chat preferences. + +- Risk: issue/PR automation may fail due to repo permissions or branch state. + Mitigation: keep the markdown source file regardless of CLI outcome and report any GitHub-side blocker explicitly. diff --git a/docs/plans/2026-03-12-repository-standardization.md b/docs/plans/2026-03-12-repository-standardization.md new file mode 100644 index 0000000..012d891 --- /dev/null +++ b/docs/plans/2026-03-12-repository-standardization.md @@ -0,0 +1,148 @@ +# Repository Standardization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Standardize repository collaboration flow, file a tracked maintenance issue, fix `codex_workdir`, and convert selector commands to button-driven Telegram panels with regression tests. + +**Architecture:** Reuse the existing Telegram callback/browser architecture already used by directory and history browsing. Keep text commands backward-compatible while adding button panels and make `CodexBridgeSettings.workdir` the single configured home/default path used across service and handler flows. + +**Tech Stack:** Python 3.10+, NoneBot2, nonebot-adapter-telegram, pytest, GitHub CLI + +--- + +### Task 1: Add Repository Collaboration Files + +**Files:** +- Create: `AGENTS.md` +- Create: `.github/ISSUE_TEMPLATE/bug_report.yml` +- Create: `.github/ISSUE_TEMPLATE/feature_request.yml` +- Create: `.github/ISSUE_TEMPLATE/config.yml` +- Create: `.github/pull_request_template.md` + +**Step 1: Add the repository-level guidance files** + +Write concise repository instructions covering structure, test/lint commands, issue/PR expectations, and change safety. + +**Step 2: Review templates for repository fit** + +Check that templates reference the actual commands and project structure in this repo. + +**Step 3: Verify the files exist and are readable** + +Run: `rg --files AGENTS.md .github` +Expected: new repository guidance and GitHub template files are listed + +### Task 2: Capture Backlog And Open GitHub Issue + +**Files:** +- Create: `docs/maintenance/2026-03-12-repository-follow-ups.md` + +**Step 1: Write the backlog markdown** + +Capture the confirmed bugs and UX improvements in issue-ready wording. + +**Step 2: Attempt GitHub issue creation** + +Run: `gh issue create --title "" --body-file docs/maintenance/2026-03-12-repository-follow-ups.md` +Expected: returns an issue URL + +**Step 3: If creation fails, record the blocker** + +Do not drop the markdown file; use it as the fallback deliverable. + +### Task 3: Write Failing Tests For `codex_workdir` + +**Files:** +- Modify: `tests/test_service.py` +- Modify: `tests/test_telegram_handlers.py` + +**Step 1: Add tests for configured workdir defaults** + +Add tests asserting: +- default preferences use `CodexBridgeSettings.workdir` +- directory browser `home` action uses configured workdir +- `/home` uses configured workdir rather than OS home + +**Step 2: Run targeted tests and confirm failure** + +Run: `pdm run pytest tests/test_service.py tests/test_telegram_handlers.py -q` +Expected: new tests fail because current code still uses `Path.home()` + +### Task 4: Implement `codex_workdir` Runtime Fix + +**Files:** +- Modify: `src/nonebot_plugin_codex/service.py` +- Modify: `src/nonebot_plugin_codex/telegram.py` + +**Step 1: Replace hard-coded `Path.home()` defaults with configured workdir** + +Keep stored per-chat overrides intact; only change defaults and "home" semantics. + +**Step 2: Re-run the targeted tests** + +Run: `pdm run pytest tests/test_service.py tests/test_telegram_handlers.py -q` +Expected: the new workdir tests pass + +### Task 5: Write Failing Tests For Button Panels + +**Files:** +- Modify: `tests/test_service.py` +- Modify: `tests/test_telegram_handlers.py` + +**Step 1: Add service/handler tests for new selector panels** + +Cover: +- panel rendering for mode/model/effort/permission +- callback handling updates the right preference +- legacy text arguments still work + +**Step 2: Run focused tests and confirm failure** + +Run: `pdm run pytest tests/test_service.py tests/test_telegram_handlers.py -q` +Expected: failures because selector panel state and callbacks do not exist yet + +### Task 6: Implement Button-Driven Selector Panels + +**Files:** +- Modify: `src/nonebot_plugin_codex/service.py` +- Modify: `src/nonebot_plugin_codex/telegram.py` +- Modify: `src/nonebot_plugin_codex/__init__.py` + +**Step 1: Add callback encoding/state/rendering for selector panels** + +Follow the same general pattern as directory/history browsers. + +**Step 2: Update handlers to open panels when no argument is supplied** + +Keep argument-based command handling intact for compatibility. + +**Step 3: Add callback routing for selector interactions** + +Ensure stale-panel handling mirrors existing callback flows. + +**Step 4: Re-run focused tests** + +Run: `pdm run pytest tests/test_service.py tests/test_telegram_handlers.py -q` +Expected: selector panel tests pass + +### Task 7: Full Verification And GitHub PR + +**Files:** +- Modify: any files touched above as needed + +**Step 1: Run full verification** + +Run: `pdm run pytest -q` +Expected: all tests pass + +Run: `pdm run ruff check .` +Expected: all checks pass + +**Step 2: Review diff and create a branch if needed** + +Ensure the issue reference appears in commit/PR metadata. + +**Step 3: Create PR linked to the issue** + +Run: `gh pr create --fill --body-file <prepared pr body file>` +Expected: returns a PR URL linked to the created issue From df19f35ab7330ca1d25e52ae2f439529eaf530a3 Mon Sep 17 00:00:00 2001 From: ttiee <469784630@qq.com> Date: Thu, 12 Mar 2026 09:33:59 +0800 Subject: [PATCH 2/2] feat(telegram): honor codex workdir and add settings panels Use the configured codex_workdir as the runtime home/default workdir and add inline button panels for mode, model, effort, and permission selection. Refs #1 --- src/nonebot_plugin_codex/__init__.py | 10 ++ src/nonebot_plugin_codex/service.py | 251 ++++++++++++++++++++++++++- src/nonebot_plugin_codex/telegram.py | 154 +++++++++++----- tests/test_service.py | 75 ++++++++ tests/test_telegram_handlers.py | 121 ++++++++++++- 5 files changed, 565 insertions(+), 46 deletions(-) diff --git a/src/nonebot_plugin_codex/__init__.py b/src/nonebot_plugin_codex/__init__.py index e5c55d0..850db64 100644 --- a/src/nonebot_plugin_codex/__init__.py +++ b/src/nonebot_plugin_codex/__init__.py @@ -82,6 +82,12 @@ block=True, rule=handlers.is_history_callback, ) + setting_callback = on_type( + CallbackQueryEvent, + priority=10, + block=True, + rule=handlers.is_setting_callback, + ) @codex_cmd.handle() async def _handle_codex( @@ -159,6 +165,10 @@ async def _handle_browser_callback(bot: Bot, event: CallbackQueryEvent) -> None: async def _handle_history_callback(bot: Bot, event: CallbackQueryEvent) -> None: await handlers.handle_history_callback(bot, event) + @setting_callback.handle() + async def _handle_setting_callback(bot: Bot, event: CallbackQueryEvent) -> None: + await handlers.handle_setting_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 c3ab491..9399b60 100644 --- a/src/nonebot_plugin_codex/service.py +++ b/src/nonebot_plugin_codex/service.py @@ -34,6 +34,9 @@ HISTORY_CALLBACK_PREFIX = "chs" HISTORY_PAGE_SIZE = 6 HISTORY_STALE_MESSAGE = "历史会话面板已失效,请重新执行 /sessions" +SETTING_CALLBACK_PREFIX = "csp" +SETTING_STALE_MESSAGE = "设置面板已失效,请重新执行对应命令" +SUPPORTED_SETTING_PANELS = {"mode", "model", "effort", "permission"} @dataclass(slots=True) @@ -165,6 +168,15 @@ class DirectoryBrowserState: message_id: int | None = None +@dataclass(slots=True) +class SettingPanelState: + chat_key: str + kind: 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}" @@ -276,6 +288,30 @@ def decode_history_callback(payload: str) -> tuple[str, int, str, int | None]: return token, version, action, index +def encode_setting_callback( + token: str, + version: int, + action: str, + value: str | None = None, +) -> str: + suffix = "" if value is None else f":{value}" + return f"{SETTING_CALLBACK_PREFIX}:{token}:{version}:{action}{suffix}" + + +def decode_setting_callback(payload: str) -> tuple[str, int, str, str | None]: + parts = payload.split(":") + if len(parts) not in {4, 5} or parts[0] != SETTING_CALLBACK_PREFIX: + raise ValueError("无效的设置回调。") + token = parts[1] + try: + version = int(parts[2]) + except ValueError as exc: + raise ValueError("无效的设置回调。") from exc + action = parts[3] + value = parts[4] if len(parts) == 5 else None + return token, version, action, value + + def parse_event_line(line: str) -> dict[str, Any] | None: try: payload = json.loads(line) @@ -446,9 +482,17 @@ def __init__( self.preference_overrides = self._load_preferences() self.directory_browsers: dict[str, DirectoryBrowserState] = {} self.history_browsers: dict[str, HistoryBrowserState] = {} + self.setting_panels: dict[str, SettingPanelState] = {} self._native_history_entries: list[HistoricalSessionSummary] = [] self._native_history_loaded = False + def _configured_workdir(self) -> str: + configured = Path(self.settings.workdir).expanduser() + try: + return str(configured.resolve()) + except OSError: + return str(configured) + def _spawn_native_client(self) -> Any: if self.native_client is None: return None @@ -1342,6 +1386,205 @@ async def apply_history_session(self, chat_key: str, token: str, version: int) - notice_lines.append("下一条普通消息会继续该会话。") return "\n".join(notice_lines) + def _replace_setting_panel_state( + self, + chat_key: str, + kind: str, + *, + previous: SettingPanelState | None = None, + ) -> SettingPanelState: + if kind not in SUPPORTED_SETTING_PANELS: + raise ValueError("未知设置面板。") + state = SettingPanelState( + chat_key=chat_key, + kind=kind, + 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.setting_panels[chat_key] = state + return state + + def open_setting_panel(self, chat_key: str, kind: str) -> SettingPanelState: + self._ensure_not_running(chat_key) + self.get_preferences(chat_key) + return self._replace_setting_panel_state(chat_key, kind) + + def get_setting_panel( + self, + chat_key: str, + token: str | None = None, + version: int | None = None, + ) -> SettingPanelState: + state = self.setting_panels.get(chat_key) + if state is None: + raise ValueError(SETTING_STALE_MESSAGE) + if token is not None and state.token != token: + raise ValueError(SETTING_STALE_MESSAGE) + if version is not None and state.version != version: + raise ValueError(SETTING_STALE_MESSAGE) + return state + + def remember_setting_panel_message( + self, + chat_key: str, + token: str, + message_id: int | None, + ) -> None: + if message_id is None: + return + panel = self.get_setting_panel(chat_key, token=token) + panel.message_id = message_id + + 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 navigate_setting_panel( + self, + chat_key: str, + token: str, + version: int, + action: str, + ) -> SettingPanelState: + panel = self.get_setting_panel(chat_key, token=token, version=version) + if action != "refresh": + raise ValueError("未知设置操作。") + return self._replace_setting_panel_state(chat_key, panel.kind, previous=panel) + + def render_setting_panel(self, chat_key: str) -> tuple[str, InlineKeyboardMarkup]: + panel = self.get_setting_panel(chat_key) + preferences = self.get_preferences(chat_key) + lines: list[str] + options: list[tuple[str, str]] + + if panel.kind == "mode": + session = self.sessions.get(chat_key) + active_mode = session.active_mode if session else preferences.default_mode + lines = [ + "模式设置", + f"当前默认模式:{preferences.default_mode}", + f"当前活跃模式:{active_mode}", + ] + options = [ + ( + "resume", + "✓ resume" if preferences.default_mode == "resume" else "resume", + ), + ("exec", "✓ exec" if preferences.default_mode == "exec" else "exec"), + ] + elif panel.kind == "model": + lines = [ + "模型设置", + f"当前设置:{format_preferences_summary(preferences)}", + ] + options = [ + ( + model.slug, + f"{'✓ ' if model.slug == preferences.model else ''}{model.slug}", + ) + for model in self.list_models() + ] + elif panel.kind == "effort": + supported = self.get_supported_efforts(preferences.model) + lines = [ + "推理强度设置", + f"当前模型:{preferences.model}", + f"当前推理强度:{preferences.reasoning_effort}", + f"支持:{' / '.join(supported)}", + ] + options = [ + ( + effort, + ( + f"✓ {effort}" + if effort == preferences.reasoning_effort + else effort + ), + ) + for effort in supported + ] + elif panel.kind == "permission": + lines = [ + "权限模式设置", + f"当前权限模式:{preferences.permission_mode}", + "safe = workspace-write", + "danger = 绕过审批与沙箱", + ] + options = [ + ( + "safe", + "✓ safe" if preferences.permission_mode == "safe" else "safe", + ), + ( + "danger", + ( + "✓ danger" + if preferences.permission_mode == "danger" + else "danger" + ), + ), + ] + else: + raise ValueError("未知设置面板。") + + keyboard = [ + [ + InlineKeyboardButton( + text=label, + callback_data=encode_setting_callback( + panel.token, + panel.version, + "set", + value, + ), + ) + ] + for value, label in options + ] + keyboard.append( + [ + InlineKeyboardButton( + text="刷新", + callback_data=encode_setting_callback( + panel.token, + panel.version, + "refresh", + ), + ), + InlineKeyboardButton( + text="关闭", + callback_data=encode_setting_callback( + panel.token, + panel.version, + "close", + ), + ), + ] + ) + return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard) + + async def apply_setting_panel_selection( + self, + chat_key: str, + token: str, + version: int, + value: str, + ) -> str: + panel = self.get_setting_panel(chat_key, token=token, version=version) + if panel.kind == "mode": + notice = await self.update_default_mode(chat_key, value) + elif panel.kind == "model": + notice = await self.update_model(chat_key, value) + elif panel.kind == "effort": + notice = await self.update_reasoning_effort(chat_key, value) + elif panel.kind == "permission": + notice = await self.update_permission_mode(chat_key, value) + else: + raise ValueError("未知设置面板。") + self._replace_setting_panel_state(chat_key, panel.kind, previous=panel) + return notice + def load_models(self) -> dict[str, ModelInfo]: try: payload = json.loads( @@ -1419,7 +1662,9 @@ def _load_preferences(self) -> dict[str, ChatPreferences]: reasoning_effort=reasoning_effort, permission_mode=permission_mode, workdir=( - workdir if isinstance(workdir, str) and workdir else str(Path.home()) + workdir + if isinstance(workdir, str) and workdir + else self._configured_workdir() ), default_mode=( default_mode @@ -1490,7 +1735,7 @@ def _default_preferences(self) -> ChatPreferences: model=model.slug, reasoning_effort=effort, permission_mode="safe", - workdir=str(Path.home()), + workdir=self._configured_workdir(), default_mode="resume", ) @@ -1717,7 +1962,7 @@ def navigate_directory_browser( if action == "home": return self._replace_browser_state( chat_key, - str(Path.home()), + self._configured_workdir(), page=0, previous=browser, ) diff --git a/src/nonebot_plugin_codex/telegram.py b/src/nonebot_plugin_codex/telegram.py index beb77bd..d66e401 100644 --- a/src/nonebot_plugin_codex/telegram.py +++ b/src/nonebot_plugin_codex/telegram.py @@ -4,7 +4,6 @@ import time import asyncio from typing import Any -from pathlib import Path from nonebot.adapters.telegram import Bot from nonebot.adapters.telegram.message import Message @@ -16,14 +15,16 @@ HISTORY_STALE_MESSAGE, BROWSER_CALLBACK_PREFIX, HISTORY_CALLBACK_PREFIX, + SETTING_STALE_MESSAGE, + SETTING_CALLBACK_PREFIX, CodexBridgeService, chunk_text, build_chat_key, format_result_text, decode_browser_callback, decode_history_callback, + decode_setting_callback, should_forward_follow_up, - format_preferences_summary, ) RETRY_AFTER_PATTERN = re.compile(r"retry after (\d+(?:\.\d+)?)", re.IGNORECASE) @@ -281,6 +282,11 @@ async def is_history_callback(self, event: CallbackQueryEvent) -> bool: f"{HISTORY_CALLBACK_PREFIX}:" ) + async def is_setting_callback(self, event: CallbackQueryEvent) -> bool: + return isinstance(event.data, str) and event.data.startswith( + f"{SETTING_CALLBACK_PREFIX}:" + ) + def callback_message_id(self, event: CallbackQueryEvent) -> int | None: message = getattr(event, "message", None) return getattr(message, "message_id", None) @@ -306,6 +312,22 @@ async def send_history_browser( getattr(message, "message_id", None), ) + async def send_setting_panel( + self, + bot: Bot, + event: MessageEvent, + 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_event_message(bot, event, text, reply_markup=markup) + self.service.remember_setting_panel_message( + chat_key, + panel.token, + getattr(message, "message_id", None), + ) + async def edit_or_resend_browser( self, bot: Bot, @@ -370,6 +392,37 @@ async def edit_or_resend_history_browser( getattr(message, "message_id", None), ) + async def edit_or_resend_setting_panel( + self, + bot: Bot, + event: CallbackQueryEvent, + chat_key: str, + ) -> None: + panel = self.service.get_setting_panel(chat_key) + text, markup = self.service.render_setting_panel(chat_key) + message_id = self.callback_message_id(event) or panel.message_id + chat_id = self.event_chat(event).id + try: + if message_id is None: + raise ValueError("missing message id") + await self.edit_message( + bot, + chat_id=chat_id, + message_id=message_id, + text=text, + reply_markup=markup, + ) + self.service.remember_setting_panel_message(chat_key, panel.token, message_id) + except Exception: + 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 handle_codex(self, bot: Bot, event: MessageEvent, args: Message) -> None: chat_key = self.chat_key(event) session = self.service.activate_chat(chat_key) @@ -399,19 +452,13 @@ async def handle_codex(self, bot: Bot, event: MessageEvent, args: Message) -> No async def handle_mode(self, bot: Bot, event: MessageEvent, args: Message) -> None: chat_key = self.chat_key(event) mode = args.extract_plain_text().strip() - if not mode: - preferences = self.service.get_preferences(chat_key) - session = self.service.get_session(chat_key) - await self.send_event_message( - bot, - event, - f"当前默认模式:{preferences.default_mode}\n当前活跃模式:{session.active_mode}", - ) - return try: + if not mode: + await self.send_setting_panel(bot, event, chat_key, "mode") + return notice = await self.service.update_default_mode(chat_key, mode) await self.send_event_message(bot, event, notice) - except (ValueError, RuntimeError) as exc: + except (FileNotFoundError, ValueError, RuntimeError) as exc: await self.send_event_message(bot, event, self.error_text(exc)) async def handle_exec(self, bot: Bot, event: MessageEvent, args: Message) -> None: @@ -448,17 +495,8 @@ async def handle_model(self, bot: Bot, event: MessageEvent, args: Message) -> No chat_key = self.chat_key(event) slug = args.extract_plain_text().strip() try: - preferences = self.service.get_preferences(chat_key) if not slug: - efforts = "/".join(self.service.get_supported_efforts(preferences.model)) - await self.send_event_message( - bot, - event, - ( - f"当前设置:{format_preferences_summary(preferences)}\n" - f"当前模型支持推理强度:{efforts}" - ), - ) + await self.send_setting_panel(bot, event, chat_key, "model") return notice = await self.service.update_model(chat_key, slug) await self.send_event_message(bot, event, notice) @@ -469,17 +507,8 @@ async def handle_effort(self, bot: Bot, event: MessageEvent, args: Message) -> N chat_key = self.chat_key(event) effort = args.extract_plain_text().strip() try: - preferences = self.service.get_preferences(chat_key) - supported = "/".join(self.service.get_supported_efforts(preferences.model)) if not effort: - await self.send_event_message( - bot, - event, - ( - f"当前推理强度:{preferences.reasoning_effort}\n" - f"当前模型 `{preferences.model}` 支持:{supported}" - ), - ) + await self.send_setting_panel(bot, event, chat_key, "effort") return notice = await self.service.update_reasoning_effort(chat_key, effort) await self.send_event_message(bot, event, notice) @@ -492,16 +521,8 @@ async def handle_permission( chat_key = self.chat_key(event) permission = args.extract_plain_text().strip() try: - preferences = self.service.get_preferences(chat_key) if not permission: - await self.send_event_message( - bot, - event, - ( - f"当前权限模式:{preferences.permission_mode}\n" - "safe = workspace-write,danger = 绕过审批与沙箱。" - ), - ) + await self.send_setting_panel(bot, event, chat_key, "permission") return notice = await self.service.update_permission_mode(chat_key, permission) await self.send_event_message(bot, event, notice) @@ -529,7 +550,8 @@ async def handle_cd(self, bot: Bot, event: MessageEvent, args: Message) -> None: async def handle_home(self, bot: Bot, event: MessageEvent) -> None: try: notice = await self.service.update_workdir( - self.chat_key(event), str(Path.home()) + self.chat_key(event), + self.service.settings.workdir, ) await self.send_event_message(bot, event, notice) except (ValueError, RuntimeError) as exc: @@ -636,6 +658,54 @@ async def handle_history_callback(self, bot: Bot, event: CallbackQueryEvent) -> event.id, text=self.error_text(exc), show_alert=True ) + async def handle_setting_callback(self, bot: Bot, event: CallbackQueryEvent) -> None: + if not isinstance(event.data, str): + await bot.answer_callback_query( + event.id, text=SETTING_STALE_MESSAGE, show_alert=True + ) + return + + try: + chat_key = self.chat_key(event) + chat_id = self.event_chat(event).id + token, version, action, value = decode_setting_callback(event.data) + if action == "set": + if not value: + raise ValueError("设置值无效。") + await self.service.apply_setting_panel_selection( + chat_key, token, version, value + ) + await self.edit_or_resend_setting_panel(bot, event, chat_key) + await bot.answer_callback_query(event.id, text="已更新。") + return + if action == "close": + self.service.close_setting_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 + self.service.navigate_setting_panel(chat_key, token, version, action) + await self.edit_or_resend_setting_panel(bot, event, chat_key) + await bot.answer_callback_query(event.id) + except ValueError as exc: + text = str(exc) or SETTING_STALE_MESSAGE + await bot.answer_callback_query( + event.id, + text=text, + show_alert=text == SETTING_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/tests/test_service.py b/tests/test_service.py index 7441192..b6e8af4 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -69,6 +69,38 @@ def test_build_exec_argv_for_safe_and_resume_mode() -> None: assert argv[-2:] == ["thread-1", "hello"] +def test_default_preferences_use_configured_workdir( + tmp_path: Path, model_cache_file: Path +) -> None: + service = make_service(tmp_path, model_cache_file) + + preferences = service.get_preferences("private_1") + + assert preferences.workdir == str(tmp_path.resolve()) + + +def test_directory_browser_home_uses_configured_workdir( + tmp_path: Path, model_cache_file: Path +) -> None: + service = make_service(tmp_path, model_cache_file) + outside_dir = tmp_path.parent + + browser = service._replace_browser_state( # noqa: SLF001 + "private_1", + str(outside_dir), + page=0, + ) + + browser = service.navigate_directory_browser( + "private_1", + browser.token, + browser.version, + "home", + ) + + assert browser.current_path == str(tmp_path.resolve()) + + @pytest.mark.asyncio async def test_update_default_mode_persists_preference_and_switches_active_mode( tmp_path: Path, model_cache_file: Path @@ -162,3 +194,46 @@ async def test_apply_history_session_uses_existing_cwd_when_original_missing( assert "原工作目录不存在,已保留当前工作目录。" in notice assert f"当前工作目录:{current_dir.resolve()}" in notice + + +@pytest.mark.parametrize( + ("kind", "expected_heading"), + [ + ("mode", "模式设置"), + ("model", "模型设置"), + ("effort", "推理强度设置"), + ("permission", "权限模式设置"), + ], +) +def test_render_setting_panels_show_expected_headings( + tmp_path: Path, + model_cache_file: Path, + kind: str, + expected_heading: str, +) -> None: + service = make_service(tmp_path, model_cache_file) + service.activate_chat("private_1") + + service.open_setting_panel("private_1", kind) + text, markup = service.render_setting_panel("private_1") + + assert expected_heading in text + assert markup.inline_keyboard + + +@pytest.mark.asyncio +async def test_apply_permission_setting_panel_updates_preference( + tmp_path: Path, model_cache_file: Path +) -> None: + service = make_service(tmp_path, model_cache_file) + panel = service.open_setting_panel("private_1", "permission") + + notice = await service.apply_setting_panel_selection( + "private_1", + panel.token, + panel.version, + "danger", + ) + + assert "danger" in notice + assert service.get_preferences("private_1").permission_mode == "danger" diff --git a/tests/test_telegram_handlers.py b/tests/test_telegram_handlers.py index e386e27..0e180d4 100644 --- a/tests/test_telegram_handlers.py +++ b/tests/test_telegram_handlers.py @@ -78,14 +78,20 @@ async def answer_callback_query(self, callback_id: str, **kwargs: Any) -> None: class FakeService: def __init__(self) -> None: self.session = ChatSession() - self.settings = SimpleNamespace(chunk_size=3500) + self.settings = SimpleNamespace(chunk_size=3500, workdir="/tmp/configured-home") self.browser_text = "目录浏览" self.history_text = "Codex 历史会话" + self.setting_text = "模式设置" self.default_mode = "resume" self.execute_calls: list[tuple[str, str | None]] = [] self.browser_token = "token" self.browser_version = 1 self.browser_applied = False + self.setting_token = "setting" + self.setting_version = 1 + self.setting_kind = "mode" + self.setting_updates: list[str] = [] + self.updated_workdirs: list[str] = [] def get_session(self, chat_key: str) -> ChatSession: return self.session @@ -134,6 +140,7 @@ def remember_browser_message( return None async def update_workdir(self, chat_key: str, target: str) -> str: + self.updated_workdirs.append(target) return f"当前工作目录:{target}" def get_browser(self, chat_key: str) -> SimpleNamespace: @@ -174,6 +181,58 @@ def remember_history_browser_message( ) -> None: return None + def open_setting_panel(self, chat_key: str, kind: str) -> SimpleNamespace: + self.setting_kind = kind + return SimpleNamespace(token=self.setting_token) + + def render_setting_panel(self, chat_key: str) -> tuple[str, None]: + texts = { + "mode": "模式设置", + "model": "模型设置", + "effort": "推理强度设置", + "permission": "权限模式设置", + } + return texts[self.setting_kind], None + + def remember_setting_panel_message( + self, chat_key: str, token: str, message_id: int | None + ) -> None: + return None + + def get_setting_panel(self, chat_key: str) -> SimpleNamespace: + return SimpleNamespace( + token=self.setting_token, + version=self.setting_version, + kind=self.setting_kind, + message_id=1, + ) + + def navigate_setting_panel( + self, + chat_key: str, + token: str, + version: int, + action: str, + ) -> None: + return None + + async def apply_setting_panel_selection( + self, + chat_key: str, + token: str, + version: int, + value: str, + ) -> str: + self.setting_updates.append(value) + return f"当前设置:{value}" + + def close_setting_panel(self, chat_key: str, token: str, version: int) -> None: + return None + + async def update_default_mode(self, chat_key: str, mode: str) -> str: + self.setting_updates.append(mode) + return f"当前默认模式:{mode}" + @pytest.mark.asyncio async def test_handle_codex_without_prompt_sends_status_message() -> None: @@ -244,3 +303,63 @@ async def test_handle_browser_callback_apply_updates_directory() -> None: assert service.browser_applied is True assert bot.answered[0]["text"] == "工作目录已更新。" + + +@pytest.mark.asyncio +async def test_handle_home_uses_configured_workdir() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await handlers.handle_home(bot, FakeEvent("")) + + assert service.updated_workdirs == [service.settings.workdir] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler_name", "kind", "expected_text"), + [ + ("handle_mode", "mode", "模式设置"), + ("handle_model", "model", "模型设置"), + ("handle_effort", "effort", "推理强度设置"), + ("handle_permission", "permission", "权限模式设置"), + ], +) +async def test_selection_commands_without_argument_open_setting_panels( + handler_name: str, + kind: str, + expected_text: str, +) -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await getattr(handlers, handler_name)(bot, FakeEvent(""), FakeMessage("")) + + assert service.setting_kind == kind + assert bot.sent[0]["text"] == expected_text + + +@pytest.mark.asyncio +async def test_handle_mode_with_argument_still_updates_default_mode() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + + await handlers.handle_mode(bot, FakeEvent(""), FakeMessage("exec")) + + assert service.setting_updates == ["exec"] + + +@pytest.mark.asyncio +async def test_handle_setting_callback_updates_setting() -> None: + service = FakeService() + handlers = TelegramHandlers(service) + bot = FakeBot() + event = FakeCallbackEvent("csp:setting:1:set:danger") + + await handlers.handle_setting_callback(bot, event) + + assert service.setting_updates == ["danger"] + assert bot.answered[0]["text"] == "已更新。"