From 1ba863d135b833f12f1a223de3cf9c2a352e85cf Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 11 May 2026 19:24:06 +0800 Subject: [PATCH] chore: import private baseline from gitea state --- .gitattributes | 4 +- .gitea/workflows/release-offline.yml | 73 ++ .github/ISSUE_TEMPLATE/bug_report.yml | 63 -- .github/ISSUE_TEMPLATE/feature_request.yml | 21 - .github/pull_request_template.md | 19 - .github/workflows/crowdin-bootstrap.yml | 64 -- .github/workflows/crowdin-sync.yml | 77 -- .github/workflows/docker-image-dev.yml | 154 --- .github/workflows/docker-image-main.yml | 162 --- .github/workflows/i18n-validate.yml | 38 - .github/workflows/precheck.yml | 62 -- .github/workflows/publish-webui-dist.yml | 98 -- .github/workflows/ruff-pr.yml | 30 - .github/workflows/ruff.yml | 48 - .gitignore | 386 +------ config/README.md | 8 + dashboard/.gitignore | 1 - dashboard/.vite/deps/_metadata.json | 8 - dashboard/.vite/deps/package.json | 3 - .../src/routes/plugins/InstallDialog.tsx | 147 --- dashboard/src/routes/plugins/InstalledTab.tsx | 90 -- .../src/routes/plugins/MarketplaceTab.tsx | 91 -- dashboard/src/routes/plugins/PluginCard.tsx | 243 ----- dashboard/src/routes/plugins/index.tsx | 944 ------------------ dashboard/src/routes/plugins/types.ts | 18 - deploy/server-maibot/Dockerfile.offline | 21 + deploy/server-maibot/Dockerfile.release | 14 + deploy/server-maibot/README_DEPLOY_STEPS.txt | 41 + deploy/server-maibot/activate-release.sh | 79 ++ .../server-maibot/bot.lecspace.com.nginx.conf | 37 + .../server-maibot/docker-compose.server.yml | 51 + .../docker-entrypoint.offline.sh | 40 + docs/CONTRIBUTE.md | 88 -- docs/README_CN.md | 162 --- docs/README_EN.md | 155 --- docs/a_memorix_sync.md | 48 - docs/crowdin_workflow_alignment_brief.md | 310 ------ .../github-actions-crowdin-workflow-report.md | 306 ------ docs/i18n.md | 224 ----- docs/minimal-cross-platform-plan.md | 577 ----------- .../.devcontainer/devcontainer.json | 21 + .../.github/workflows/docker-image.yml | 54 + .../MaiBot-Napcat-Adapter/__init__.py | 1 + .../MaiBot-Napcat-Adapter/_manifest.json | 35 + .../MaiBot-Napcat-Adapter/apis/__init__.py | 18 + .../MaiBot-Napcat-Adapter/apis/account.py | 366 +++++++ .../MaiBot-Napcat-Adapter/apis/file.py | 535 ++++++++++ .../MaiBot-Napcat-Adapter/apis/group.py | 593 +++++++++++ .../MaiBot-Napcat-Adapter/apis/message.py | 431 ++++++++ .../apis/message_tool_patch.py | 69 ++ .../MaiBot-Napcat-Adapter/apis/support.py | 275 +++++ .../MaiBot-Napcat-Adapter/apis/system.py | 290 ++++++ .../MaiBot-Napcat-Adapter/codecs/__init__.py | 1 + .../codecs/inbound/__init__.py | 5 + .../codecs/inbound/cards.py | 545 ++++++++++ .../codecs/inbound/message_codec.py | 661 ++++++++++++ .../codecs/inbound/text.py | 90 ++ .../codecs/notice/__init__.py | 5 + .../codecs/notice/enricher.py | 72 ++ .../codecs/notice/helpers.py | 83 ++ .../codecs/notice/message_codec.py | 120 +++ .../codecs/notice/meta_event_logger.py | 49 + .../codecs/notice/renderer.py | 63 ++ .../codecs/outbound/__init__.py | 5 + .../codecs/outbound/message_codec.py | 63 ++ .../codecs/outbound/segment_encoder.py | 500 ++++++++++ .../MaiBot-Napcat-Adapter/config.py | 631 ++++++++++++ .../MaiBot-Napcat-Adapter/constants.py | 13 + .../MaiBot-Napcat-Adapter/docs/README.md | 90 ++ .../MaiBot-Napcat-Adapter/docs/account-api.md | 59 ++ .../MaiBot-Napcat-Adapter/docs/file-api.md | 83 ++ .../MaiBot-Napcat-Adapter/docs/group-api.md | 70 ++ .../MaiBot-Napcat-Adapter/docs/message-api.md | 61 ++ .../MaiBot-Napcat-Adapter/docs/system-api.md | 54 + .../MaiBot-Napcat-Adapter/docs/typed-api.md | 83 ++ .../docs/verification.md | 55 + .../MaiBot-Napcat-Adapter/filters.py | 82 ++ .../heartbeat_monitor.py | 148 +++ .../MaiBot-Napcat-Adapter/plugin.py | 244 +++++ .../MaiBot-Napcat-Adapter/qq_emoji_list.py | 226 +++++ .../MaiBot-Napcat-Adapter/runtime/__init__.py | 7 + .../MaiBot-Napcat-Adapter/runtime/builder.py | 105 ++ .../MaiBot-Napcat-Adapter/runtime/bundle.py | 40 + .../MaiBot-Napcat-Adapter/runtime/router.py | 611 ++++++++++++ .../MaiBot-Napcat-Adapter/runtime_state.py | 118 +++ .../services/__init__.py | 19 + .../services/action_service.py | 119 +++ .../services/ban_state_store.py | 168 ++++ .../services/ban_tracker.py | 176 ++++ .../services/history_recovery_store.py | 423 ++++++++ .../services/official_bot_guard.py | 59 ++ .../services/query_service.py | 585 +++++++++++ .../MaiBot-Napcat-Adapter/transport.py | 449 +++++++++ .../MaiBot-Napcat-Adapter/types.py | 37 + plugins/__init__.py | 0 plugins/hello_world_plugin/_manifest.json | 41 - plugins/hello_world_plugin/plugin.py | 240 ----- .../A_memorix_test/data/benchmarks/README.md | 65 -- .../group_chat_stream_memory_benchmark.json | 728 -------------- ...oup_chat_stream_memory_benchmark_hard.json | 862 ---------------- .../long_novel_memory_benchmark.json | 120 --- .../real_dialogues/private_alice_weekend.json | 90 -- pytests/test_napcat_adapter_sdk.py | 8 +- pytests/test_napcat_history_recovery.py | 376 +++++++ src/A_memorix/.gitattributes | 2 - src/A_memorix/.gitignore | 245 ----- src/llm_models/model_client/openai_client.py | 98 ++ src/plugins/__init__.py | 1 - .../built_in/plugin_management/_manifest.json | 46 - .../built_in/plugin_management/plugin.py | 298 ------ 计划.md | 230 +++++ 111 files changed, 10873 insertions(+), 7347 deletions(-) create mode 100644 .gitea/workflows/release-offline.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/crowdin-bootstrap.yml delete mode 100644 .github/workflows/crowdin-sync.yml delete mode 100644 .github/workflows/docker-image-dev.yml delete mode 100644 .github/workflows/docker-image-main.yml delete mode 100644 .github/workflows/i18n-validate.yml delete mode 100644 .github/workflows/precheck.yml delete mode 100644 .github/workflows/publish-webui-dist.yml delete mode 100644 .github/workflows/ruff-pr.yml delete mode 100644 .github/workflows/ruff.yml create mode 100644 config/README.md delete mode 100644 dashboard/.gitignore delete mode 100644 dashboard/.vite/deps/_metadata.json delete mode 100644 dashboard/.vite/deps/package.json delete mode 100644 dashboard/src/routes/plugins/InstallDialog.tsx delete mode 100644 dashboard/src/routes/plugins/InstalledTab.tsx delete mode 100644 dashboard/src/routes/plugins/MarketplaceTab.tsx delete mode 100644 dashboard/src/routes/plugins/PluginCard.tsx delete mode 100644 dashboard/src/routes/plugins/index.tsx delete mode 100644 dashboard/src/routes/plugins/types.ts create mode 100644 deploy/server-maibot/Dockerfile.offline create mode 100644 deploy/server-maibot/Dockerfile.release create mode 100644 deploy/server-maibot/README_DEPLOY_STEPS.txt create mode 100644 deploy/server-maibot/activate-release.sh create mode 100644 deploy/server-maibot/bot.lecspace.com.nginx.conf create mode 100644 deploy/server-maibot/docker-compose.server.yml create mode 100644 deploy/server-maibot/docker-entrypoint.offline.sh delete mode 100644 docs/CONTRIBUTE.md delete mode 100644 docs/README_CN.md delete mode 100644 docs/README_EN.md delete mode 100644 docs/a_memorix_sync.md delete mode 100644 docs/crowdin_workflow_alignment_brief.md delete mode 100644 docs/github-actions-crowdin-workflow-report.md delete mode 100644 docs/i18n.md delete mode 100644 docs/minimal-cross-platform-plan.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/.devcontainer/devcontainer.json create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/.github/workflows/docker-image.yml create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/_manifest.json create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/account.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/file.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/group.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/message.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/message_tool_patch.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/support.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/apis/system.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/message_codec.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/text.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/enricher.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/helpers.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/message_codec.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/meta_event_logger.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/renderer.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/message_codec.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/segment_encoder.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/config.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/constants.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/README.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/account-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/file-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/group-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/message-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/system-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/typed-api.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/docs/verification.md create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/filters.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/heartbeat_monitor.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/plugin.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/qq_emoji_list.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/builder.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/bundle.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime/router.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/runtime_state.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/action_service.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/ban_state_store.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/history_recovery_store.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/official_bot_guard.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/transport.py create mode 100644 plugin-templates/MaiBot-Napcat-Adapter/types.py delete mode 100644 plugins/__init__.py delete mode 100644 plugins/hello_world_plugin/_manifest.json delete mode 100644 plugins/hello_world_plugin/plugin.py delete mode 100644 pytests/A_memorix_test/data/benchmarks/README.md delete mode 100644 pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark.json delete mode 100644 pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark_hard.json delete mode 100644 pytests/A_memorix_test/data/benchmarks/long_novel_memory_benchmark.json delete mode 100644 pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json create mode 100644 pytests/test_napcat_history_recovery.py delete mode 100644 src/A_memorix/.gitattributes delete mode 100644 src/A_memorix/.gitignore delete mode 100644 src/plugins/__init__.py delete mode 100644 src/plugins/built_in/plugin_management/_manifest.json delete mode 100644 src/plugins/built_in/plugin_management/plugin.py create mode 100644 计划.md diff --git a/.gitattributes b/.gitattributes index b867c9f7..6313b56c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1 @@ -*.bat text eol=crlf -*.cmd text eol=crlf -webui/dist/** binary \ No newline at end of file +* text=auto eol=lf diff --git a/.gitea/workflows/release-offline.yml b/.gitea/workflows/release-offline.yml new file mode 100644 index 00000000..5ff4229a --- /dev/null +++ b/.gitea/workflows/release-offline.yml @@ -0,0 +1,73 @@ +name: offline-release + +on: + workflow_dispatch: + +jobs: + package-and-deploy: + runs-on: build-host + steps: + - name: Prepare local worktree + env: + MAIBOT_REPO_URL: http://127.0.0.1:3000/${{ gitea.repository }}.git + MAIBOT_REPO_SHA: ${{ gitea.sha }} + shell: bash + run: | + set -euo pipefail + + worktree_root=/tmp/maibot-actions + worktree="${worktree_root}/${MAIBOT_REPO_SHA}" + + rm -rf "$worktree" + mkdir -p "$worktree_root" + + git clone --no-checkout "$MAIBOT_REPO_URL" "$worktree" + git -C "$worktree" checkout --force "$MAIBOT_REPO_SHA" + git -C "$worktree" clean -dffx + + app_tag="$(git -C "$worktree" rev-parse --short=12 HEAD)" + printf 'APP_TAG=%s\n' "$app_tag" >> "$GITHUB_ENV" + printf 'MAIBOT_WORKTREE=%s\n' "$worktree" >> "$GITHUB_ENV" + + - name: Stage release directory + shell: bash + run: | + set -euo pipefail + + release_root="${MAIBOT_RELEASE_ROOT:-/srv/maibot/releases}" + case "$release_root" in + /srv/maibot/releases|/srv/maibot/releases/*) ;; + *) + echo "release root must stay under /srv/maibot/releases" >&2 + exit 1 + ;; + esac + + release_dir="${release_root}/${APP_TAG}" + rm -rf "$release_dir" + mkdir -p "$release_dir" + git -C "$MAIBOT_WORKTREE" archive HEAD | tar -x -C "$release_dir" + + printf 'RELEASE_DIR=%s\n' "$release_dir" >> "$GITHUB_ENV" + + - name: Deploy release + shell: bash + run: | + set -euo pipefail + + runtime_root="${MAIBOT_RUNTIME_ROOT:-/root/maibot-offline}" + chmod +x "$RELEASE_DIR/deploy/server-maibot/activate-release.sh" + MAIBOT_RUNTIME_ROOT="$runtime_root" "$RELEASE_DIR/deploy/server-maibot/activate-release.sh" "$RELEASE_DIR" + + - name: Cleanup worktree + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + + worktree_root=/tmp/maibot-actions/ + case "${MAIBOT_WORKTREE:-}" in + ${worktree_root}*) + rm -rf "$MAIBOT_WORKTREE" + ;; + esac diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index da786357..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Bug Report -description: 提交 Bug -labels: ["BUG"] -body: -- type: checkboxes - attributes: - label: "检查项" - description: "请检查下列项目,并勾选确认。" - options: - - label: "我确认此问题在所有分支的最新版本中依旧存在" - required: true - - label: "我确认在 Issues 列表中并无其他人已经提出过与此问题相同或相似的问题" - required: true - - label: "我使用了 Docker" -- type: dropdown - attributes: - label: "使用的分支" - description: "请选择您正在使用的版本分支" - options: - - main - - dev - validations: - required: true -- type: input - attributes: - label: "具体版本号" - description: "请输入您使用的具体版本号" - placeholder: "例如:0.5.11、0.5.8、0.6.0" - validations: - required: true -- type: textarea - attributes: - label: 遇到的问题 - validations: - required: true -- type: textarea - attributes: - label: 报错信息 - validations: - required: true -- type: textarea - attributes: - label: 如何重现此问题? - placeholder: "若不知道请略过此问题" -- type: textarea - attributes: - label: 可能造成问题的原因 - placeholder: "若不知道请略过此问题" -- type: textarea - attributes: - label: 系统环境 - placeholder: "例如:Windows 11 专业版 64位 24H2 / Debian Bookworm" - validations: - required: true -- type: textarea - attributes: - label: Python 版本 - placeholder: "例如:Python 3.11" - validations: - required: true -- type: textarea - attributes: - label: 补充信息 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index ebd46868..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Feature Request -description: 新功能请求 -labels: ["Feature"] -body: -- type: checkboxes - attributes: - label: "检查项" - description: "请检查下列项目,并勾选确认。" - options: - - label: "我确认在Issues列表中并无其他人已经建议过相似的功能" - required: true - - label: "这个新功能可以解决目前存在的某个问题或BUG" - - label: "你已经更新了最新的dev分支,但是你的问题依然没有被解决" -- type: textarea - attributes: - label: 期望的功能描述 - validations: - required: true -- type: textarea - attributes: - label: 补充信息 \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 22f722d1..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,19 +0,0 @@ - -- ✅ 接受:与main直接相关的Bug修复:提交到dev分支 -- 新增功能类pr需要经过issue提前讨论,否则不会被合并 -- 🌐 i18n 提醒:除 bootstrap 或紧急修复外,请不要把非 `zh-CN` 目标翻译作为常规 GitHub 编辑面;常规翻译以 Crowdin -> `l10n_*` PR 回流为准,详见 `docs/i18n.md` - -# 请填写以下内容 -(删除掉中括号内的空格,并替换为**小写的x**) -1. - [ ] `main` 分支 **禁止修改**,请确认本次提交的分支 **不是 `main` 分支** -2. - [ ] 我确认我阅读了贡献指南 -3. - [ ] 本次更新类型为:BUG修复 - - [ ] 本次更新类型为:功能新增 -4. - [ ] 本次更新是否经过测试 -5. - [ ] 如果本次修改涉及 `src/A_memorix`,我确认已阅读 `src/A_memorix/MODIFICATION_POLICY.md`,不涉及则无需勾选 -6. 请填写破坏性更新的具体内容(如有): -7. 请简要说明本次更新的内容和目的: -# 其他信息 -- **关联 Issue**:Close # -- **截图/GIF**: -- **附加信息**: diff --git a/.github/workflows/crowdin-bootstrap.yml b/.github/workflows/crowdin-bootstrap.yml deleted file mode 100644 index 99e6895e..00000000 --- a/.github/workflows/crowdin-bootstrap.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Crowdin Bootstrap Target Translations - -on: - workflow_dispatch: - inputs: - base_branch: - description: "Repository branch whose committed target translations should be bootstrapped into Crowdin." - required: true - type: choice - options: - - main - - r-dev - confirm_bootstrap: - description: "Explicit confirmation for the one-time or exceptional bootstrap upload." - required: true - type: choice - options: - - "no" - - "yes-bootstrap-current-target-translations" - -permissions: - contents: read - -jobs: - bootstrap-target-translations: - runs-on: ubuntu-24.04 - - steps: - - name: Require explicit bootstrap confirmation - if: inputs.confirm_bootstrap != 'yes-bootstrap-current-target-translations' - run: | - echo "This workflow uploads committed target translations into Crowdin." >&2 - echo "Re-run it with confirm_bootstrap=yes-bootstrap-current-target-translations when you really want the bootstrap path." >&2 - exit 1 - - - uses: actions/checkout@v4 - with: - ref: ${{ inputs.base_branch }} - - - name: Bootstrap committed target translations into Crowdin - uses: crowdin/github-action@v2 - with: - config: crowdin.yml - skip_ref_checkout: true - upload_sources: true - upload_translations: true - download_translations: false - create_pull_request: false - env: - GITHUB_TOKEN: ${{ github.token }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} - - - name: Summarize bootstrap outcome - env: - BASE_BRANCH: ${{ inputs.base_branch }} - run: | - { - echo "## Crowdin bootstrap completed" - echo - echo "- Bootstrapped committed target-locale files from \`${BASE_BRANCH}\` into Crowdin." - echo "- This workflow is for one-time or exceptional seeding only." - echo "- The steady-state sync path remains \`crowdin-sync.yml\`, which uploads only \`zh-CN\` source assets on normal push-triggered runs and returns translations through \`l10n_${BASE_BRANCH}\` pull requests." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml deleted file mode 100644 index 126cfd9e..00000000 --- a/.github/workflows/crowdin-sync.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Crowdin Sync - -on: - workflow_dispatch: - schedule: - - cron: "17 */6 * * *" - push: - branches: - - main - - r-dev - paths: - - "crowdin.yml" - - "locales/zh-CN/*.json" - - "prompts/zh-CN/**/*.prompt" - - "dashboard/src/i18n/locales/zh.json" - -permissions: - contents: write - pull-requests: write - -jobs: - sync-current-branch: - if: github.event_name != 'schedule' - runs-on: ubuntu-24.04 - - steps: - - uses: actions/checkout@v4 - - name: Sync translations with Crowdin - uses: crowdin/github-action@v2 - with: - config: crowdin.yml - upload_sources: true - upload_translations: false - download_translations: true - localization_branch_name: l10n_${{ github.ref_name }} - create_pull_request: true - pull_request_title: "chore(i18n): sync Crowdin translations" - pull_request_body: "Automated translation sync from Crowdin." - pull_request_base_branch_name: ${{ github.ref_name }} - commit_message: "chore(i18n): sync Crowdin translations" - env: - GITHUB_TOKEN: ${{ github.token }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} - - sync-scheduled-branches: - if: github.event_name == 'schedule' - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - matrix: - base_branch: - - main - - r-dev - - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ matrix.base_branch }} - - name: Sync scheduled translations with Crowdin - uses: crowdin/github-action@v2 - with: - config: crowdin.yml - skip_ref_checkout: true - upload_sources: true - upload_translations: false - download_translations: true - localization_branch_name: l10n_${{ matrix.base_branch }} - create_pull_request: true - pull_request_title: "chore(i18n): sync Crowdin translations" - pull_request_body: "Automated translation sync from Crowdin." - pull_request_base_branch_name: ${{ matrix.base_branch }} - commit_message: "chore(i18n): sync Crowdin translations" - env: - GITHUB_TOKEN: ${{ github.token }} - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/docker-image-dev.yml b/.github/workflows/docker-image-dev.yml deleted file mode 100644 index 186a2539..00000000 --- a/.github/workflows/docker-image-dev.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: Docker Build and Push (Dev) - -on: - schedule: - - cron: '0 0 * * *' # every day at midnight UTC - # branches: - # - dev - workflow_dispatch: # 允许手动触发工作流 - inputs: - branch: - description: 'Branch to build' - required: false - default: 'dev' - -# Workflow's jobs -jobs: - build-amd64: - name: Build AMD64 Image - runs-on: ubuntu-24.04 - outputs: - digest: ${{ steps.build.outputs.digest }} - steps: - - name: Check out git repository - uses: actions/checkout@v4 - with: - ref: dev - fetch-depth: 0 - - # Clone required dependencies - # - name: Clone maim_message - # run: git clone https://github.com/MaiM-with-u/maim_message maim_message - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - buildkitd-flags: --debug - - # Log in docker hub - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Generate metadata for Docker images - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/maibot - - # Build and push AMD64 image by digest - - name: Build and push AMD64 - id: build - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64 - labels: ${{ steps.meta.outputs.labels }} - file: ./Dockerfile - cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:dev-amd64-buildcache - cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:dev-amd64-buildcache,mode=max - outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/maibot,push-by-digest=true,name-canonical=true,push=true - build-args: | - BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - VCS_REF=${{ github.sha }} - - build-arm64: - name: Build ARM64 Image - runs-on: ubuntu-24.04-arm - outputs: - digest: ${{ steps.build.outputs.digest }} - steps: - - name: Check out git repository - uses: actions/checkout@v4 - with: - ref: dev - fetch-depth: 0 - - # Clone required dependencies - # - name: Clone maim_message - # run: git clone https://github.com/MaiM-with-u/maim_message maim_message - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - buildkitd-flags: --debug - - # Log in docker hub - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Generate metadata for Docker images - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/maibot - - # Build and push ARM64 image by digest - - name: Build and push ARM64 - id: build - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/arm64/v8 - labels: ${{ steps.meta.outputs.labels }} - file: ./Dockerfile - cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:dev-arm64-buildcache - cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:dev-arm64-buildcache,mode=max - outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/maibot,push-by-digest=true,name-canonical=true,push=true - build-args: | - BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - VCS_REF=${{ github.sha }} - - create-manifest: - name: Create Multi-Arch Manifest - runs-on: ubuntu-24.04 - needs: - - build-amd64 - - build-arm64 - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # Log in docker hub - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Generate metadata for Docker images - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/maibot - tags: | - type=raw,value=dev - type=schedule,pattern=dev-{{date 'YYMMDD'}} - - - name: Create and Push Manifest - run: | - # 为每个标签创建多架构镜像 - for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr '\n' ' '); do - echo "Creating manifest for $tag" - docker buildx imagetools create -t $tag \ - ${{ secrets.DOCKERHUB_USERNAME }}/maibot@${{ needs.build-amd64.outputs.digest }} \ - ${{ secrets.DOCKERHUB_USERNAME }}/maibot@${{ needs.build-arm64.outputs.digest }} - done diff --git a/.github/workflows/docker-image-main.yml b/.github/workflows/docker-image-main.yml deleted file mode 100644 index 498c89ac..00000000 --- a/.github/workflows/docker-image-main.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: Docker Build and Push (Main) - -on: - push: - branches: - - main - - classical - tags: - - "v*.*.*" - - "v*" - - "*.*.*" - - "*.*.*-*" - workflow_dispatch: # 允许手动触发工作流 - inputs: - branch: - description: 'Branch to build' - required: false - default: 'main' - -# Workflow's jobs -jobs: - build-amd64: - name: Build AMD64 Image - runs-on: ubuntu-24.04 - outputs: - digest: ${{ steps.build.outputs.digest }} - steps: - - name: Check out git repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Clone required dependencies - # - name: Clone maim_message - # run: git clone https://github.com/MaiM-with-u/maim_message maim_message - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - buildkitd-flags: --debug - - # Log in docker hub - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Generate metadata for Docker images - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/maibot - - # Build and push AMD64 image by digest - - name: Build and push AMD64 - id: build - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64 - labels: ${{ steps.meta.outputs.labels }} - file: ./Dockerfile - cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:amd64-buildcache - cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:amd64-buildcache,mode=max - outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/maibot,push-by-digest=true,name-canonical=true,push=true - build-args: | - BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - VCS_REF=${{ github.sha }} - - build-arm64: - name: Build ARM64 Image - runs-on: ubuntu-24.04-arm - outputs: - digest: ${{ steps.build.outputs.digest }} - steps: - - name: Check out git repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Clone required dependencies - # - name: Clone maim_message - # run: git clone https://github.com/MaiM-with-u/maim_message maim_message - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - buildkitd-flags: --debug - - # Log in docker hub - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Generate metadata for Docker images - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/maibot - - # Build and push ARM64 image by digest - - name: Build and push ARM64 - id: build - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/arm64/v8 - labels: ${{ steps.meta.outputs.labels }} - file: ./Dockerfile - cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:arm64-buildcache - cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maibot:arm64-buildcache,mode=max - outputs: type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/maibot,push-by-digest=true,name-canonical=true,push=true - build-args: | - BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - VCS_REF=${{ github.sha }} - - create-manifest: - name: Create Multi-Arch Manifest - runs-on: ubuntu-24.04 - needs: - - build-amd64 - - build-arm64 - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # Log in docker hub - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Generate metadata for Docker images - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/maibot - tags: | - type=ref,event=branch - type=ref,event=tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha,prefix=${{ github.ref_name }}-,enable=${{ github.ref_type == 'branch' }} - - - name: Create and Push Manifest - run: | - # 为每个标签创建多架构镜像 - for tag in $(echo "${{ steps.meta.outputs.tags }}" | tr '\n' ' '); do - echo "Creating manifest for $tag" - docker buildx imagetools create -t $tag \ - ${{ secrets.DOCKERHUB_USERNAME }}/maibot@${{ needs.build-amd64.outputs.digest }} \ - ${{ secrets.DOCKERHUB_USERNAME }}/maibot@${{ needs.build-arm64.outputs.digest }} - done diff --git a/.github/workflows/i18n-validate.yml b/.github/workflows/i18n-validate.yml deleted file mode 100644 index 577d7808..00000000 --- a/.github/workflows/i18n-validate.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: i18n Validate - -on: - pull_request: - paths: - - "locales/**/*.json" - - "prompts/**/*.prompt" - - "dashboard/src/i18n/index.ts" - - "dashboard/src/i18n/locales/*.json" - - "scripts/i18n_validate.py" - - "src/common/i18n/**/*.py" - - "src/common/prompt_i18n.py" - - "src/prompt/prompt_manager.py" - push: - branches: - - main - - r-dev - paths: - - "locales/**/*.json" - - "prompts/**/*.prompt" - - "dashboard/src/i18n/index.ts" - - "dashboard/src/i18n/locales/*.json" - - "scripts/i18n_validate.py" - - "src/common/i18n/**/*.py" - - "src/common/prompt_i18n.py" - - "src/prompt/prompt_manager.py" - -jobs: - validate: - runs-on: ubuntu-24.04 - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Validate locale files - run: python scripts/i18n_validate.py diff --git a/.github/workflows/precheck.yml b/.github/workflows/precheck.yml deleted file mode 100644 index bf6f9529..00000000 --- a/.github/workflows/precheck.yml +++ /dev/null @@ -1,62 +0,0 @@ -# .github/workflows/precheck.yml -name: PR Precheck -on: [pull_request] - -permissions: - contents: read - issues: write - -jobs: - conflict-check: - runs-on: ubuntu-24.04 - outputs: - conflict: ${{ steps.check-conflicts.outputs.conflict }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - name: Check Conflicts - id: check-conflicts - env: - BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - set -euo pipefail - - git fetch origin "$BASE_REF":"refs/remotes/origin/$BASE_REF" --depth=1 - git config user.email "github-actions[bot]@users.noreply.github.com" - git config user.name "github-actions[bot]" - - if git merge --no-commit --no-ff "origin/$BASE_REF" > /tmp/precheck-merge.log 2>&1; then - echo "conflict=false" >> "$GITHUB_OUTPUT" - echo "No conflicts detected against origin/$BASE_REF" - git merge --abort > /dev/null 2>&1 || true - exit 0 - fi - - if git diff --name-only --diff-filter=U | grep -q .; then - echo "conflict=true" >> "$GITHUB_OUTPUT" - echo "Conflicts detected against origin/$BASE_REF:" - git diff --name-only --diff-filter=U - else - echo "conflict=false" >> "$GITHUB_OUTPUT" - echo "Merge check returned non-zero without unmerged files against origin/$BASE_REF" - cat /tmp/precheck-merge.log - fi - - git merge --abort > /dev/null 2>&1 || true - shell: bash - labeler: - runs-on: ubuntu-24.04 - needs: conflict-check - if: needs.conflict-check.outputs.conflict == 'true' - steps: - - uses: actions/github-script@v7 - with: - script: | - github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - labels: ['🚫冲突需处理'] - }) diff --git a/.github/workflows/publish-webui-dist.yml b/.github/workflows/publish-webui-dist.yml deleted file mode 100644 index 57424fb3..00000000 --- a/.github/workflows/publish-webui-dist.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Publish WebUI Dist - -on: - push: - branches: - - main - - dev - - r-dev - paths: - - "dashboard/**" - workflow_dispatch: - -permissions: - contents: read - -jobs: - build-and-publish: - runs-on: ubuntu-24.04 - environment: webui - steps: - - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.2.0" - - - name: Build dashboard - working-directory: dashboard - run: | - bun install - bun run build - - - name: Prepare dist package - run: | - rm -rf .webui_dist_pkg - mkdir -p .webui_dist_pkg/maibot_dashboard/dist - BASE_VERSION=$(python -c "import json; print(json.load(open('dashboard/package.json'))['version'])") - if [ "${GITHUB_REF_NAME}" = "main" ]; then - WEBUI_VERSION="${BASE_VERSION}" - else - TODAY=$(date -u +%Y%m%d) - WEBUI_VERSION="${BASE_VERSION}.dev${TODAY}${GITHUB_RUN_NUMBER}" - fi - cat > .webui_dist_pkg/pyproject.toml <=80.9.0", "wheel"] - build-backend = "setuptools.build_meta" - - [tool.setuptools] - include-package-data = true - - [tool.setuptools.packages.find] - where = ["."] - include = ["maibot_dashboard"] - exclude = ["maibot_dashboard.dist*"] - - [tool.setuptools.package-data] - maibot_dashboard = ["dist/**"] - EOF - cat > .webui_dist_pkg/README.md <<'EOF' - # MaiBot WebUI Dist - - 该包仅包含 MaiBot WebUI 的前端构建产物(dist)。 - EOF - cat > .webui_dist_pkg/maibot_dashboard/__init__.py <<'EOF' - from .resources import get_dist_path - - __all__ = ["get_dist_path"] - EOF - cat > .webui_dist_pkg/maibot_dashboard/resources.py <<'EOF' - from pathlib import Path - - def get_dist_path() -> Path: - return Path(__file__).parent / "dist" - EOF - cp -a dashboard/dist/. .webui_dist_pkg/maibot_dashboard/dist/ - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Build and publish - working-directory: .webui_dist_pkg - env: - PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - run: | - python -m pip install --upgrade build twine - python -m build - python -m twine upload -u __token__ -p "$PYPI_API_TOKEN" dist/* diff --git a/.github/workflows/ruff-pr.yml b/.github/workflows/ruff-pr.yml deleted file mode 100644 index 1176eb0c..00000000 --- a/.github/workflows/ruff-pr.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Ruff PR Check -on: - pull_request: - paths: - - "*.py" - - "**/*.py" - - "pyproject.toml" - - "ruff.toml" - - ".ruff.toml" - - "setup.cfg" - - "tox.ini" - - ".pre-commit-config.yaml" -jobs: - ruff: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Install Ruff and Run Checks - uses: astral-sh/ruff-action@v3 - with: - args: "--version" - version: "latest" - - name: Run Ruff Check (No Fix) - run: ruff check --output-format=github - shell: pwsh - - name: Run Ruff Format Check - run: ruff format --check --diff - shell: pwsh diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml deleted file mode 100644 index 22401da3..00000000 --- a/.github/workflows/ruff.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Ruff - -on: - # push: - # branches: - # - main - # - dev - # - dev-refactor # 例如:匹配所有以 feature/ 开头的分支 - # # 添加你希望触发此 workflow 的其他分支 - workflow_dispatch: # 允许手动触发工作流 -permissions: - contents: write - -jobs: - ruff: - runs-on: ubuntu-24.04 - # 关键修改:添加条件判断 - # 确保只有在 event_name 是 'push' 且不是由 Pull Request 引起的 push 时才运行 - if: github.event_name == 'push' && !startsWith(github.ref, 'refs/pull/') - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.head_ref || github.ref_name }} - - name: Install Ruff and Run Checks - uses: astral-sh/ruff-action@v3 - with: - args: "--version" - version: "latest" - - name: Run Ruff Fix - run: ruff check --fix --unsafe-fixes; if ($LASTEXITCODE -ne 0) { Write-Host "Ruff check completed with warnings" } - shell: pwsh - - name: Run Ruff Format - run: ruff format; if ($LASTEXITCODE -ne 0) { Write-Host "Ruff format completed with warnings" } - shell: pwsh - - name: 提交更改 - if: success() - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git add -A - $changes = git diff --quiet; $staged = git diff --staged --quiet - if (-not ($changes -and $staged)) { - git commit -m "🤖 自动格式化代码 [skip ci]" - git push - } - shell: pwsh diff --git a/.gitignore b/.gitignore index a9cceec5..67026a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,363 +1,43 @@ -data/ -!pytests/A_memorix_test/data/ -!pytests/A_memorix_test/data/benchmarks/ -!pytests/A_memorix_test/data/benchmarks/long_novel_memory_benchmark.json -!pytests/A_memorix_test/data/real_dialogues/ -!pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json -pytests/A_memorix_test/data/benchmarks/results/ -data1/ -mongodb/ -NapCat.Framework.Windows.Once/ -NapCat.Framework.Windows.OneKey/ -log/ -logs/ -out/ -SnowLuma-v1.6.10-win-x64/ -tool_call_benchmark.py -run_maibot_core.bat -run_voice.bat -run_napcat_adapter.bat -run_ad.bat -llm_tool_benchmark_results.json -MaiBot-Napcat-Adapter-main -MaiBot-Napcat-Adapter -/test -/log_debug -/src/test -nonebot-maibot-adapter/ -MaiMBot-LPMM -*.zip -run_bot.bat -run_all_in_wt.bat -run.bat -log_debug/ -NapCat.Shell.Windows.OneKey -run_amds.bat -run_none.bat -docs-mai/ -run.py -queue_update.txt -start_saka.bat -.env -.env.* -.cursor -start_all.bat -config/bot_config_dev.toml -config/bot_config.toml -config/bot_config.toml.bak -template/compare/bot_config_template.toml -template/compare/model_config_template.toml -# CLAUDE.md -cloudflare-workers/ -log_viewer/ -dev/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* -node_modules/ -dist/ -dist-ssr/ -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -result.json -# Byte-compiled / optimized / DLL files +# Python caches and virtualenvs __pycache__/ *.py[cod] -*$py.class -maibot_statistics.html -mongodb -napcat -run_dev.bat -elua.confirmed -# C extensions -*.so -/results - -# Distribution / packaging -.Python -build/ -develop-eggs/ -downloads/ -eggs/ -.eggs/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ .pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.venv +.mypy_cache/ +.ruff_cache/ +.venv/ env/ venv/ -ENV/ -env.bak/ -venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# Runtime state +data/ +logs/ +plugins/ +docker-config/ +config/*.toml +config/*.bak* +!config/README.md -# Rope project settings -.ropeproject +# Frontend local outputs +dashboard/.vite/ +dashboard/node_modules/ +dashboard/dist/ +dashboard/dist-ssr/ -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc - -# jieba -jieba.cache - -# .vscode -!.vscode/settings.json - -# direnv -/.direnv - -# JetBrains -.idea -*.iml -*.ipr - -# PyEnv -# If using PyEnv and configured to use a specific Python version locally -# a .local-version file will be created in the root of the project to specify the version. -.python-version - -OtherRes.txt - -/eula.confirmed -/privacy.confirmed - -logs - -.ruff_cache - -.vscode - -/config/* -config/mcp_config.json -!config/mcp_config.json.template -config/old/bot_config_20250405_212257.toml -temp/ - -# General +# Local environment and editor files +.env +.env.* +.idea/ .DS_Store -.AppleDouble -.LSOverride +*.local -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk -src/chat/focus_chat/working_memory/test/test1.txt -src/chat/focus_chat/working_memory/test/test4.txt -run_maiserver.bat -src/plugins/test_plugin_pic/actions/pic_action_config.toml -run_pet.bat - -/plugins/* -!/plugins -!/plugins/hello_world_plugin -!/plugins/emoji_manage_plugin -!/plugins/__init__.py - -config.toml - -interested_rates.txt -MaiBot.code-workspace -actionlint -.sisyphus/ -dist-electron/ -packages/ - -## Claude Code and OMC data -.claude/ -.omc/ -/.venv312 -/src/A_memorix/algorithm_redesign -agentlite/ +# Local deployment artifacts +*.log +*.pem +*.key +*.tar +*.tgz +*.zip +acme/ +backups/ +bin/ +_staging/ diff --git a/config/README.md b/config/README.md new file mode 100644 index 00000000..673abbfd --- /dev/null +++ b/config/README.md @@ -0,0 +1,8 @@ +Runtime config files are intentionally not committed here. + +Create these files locally or on the server under the runtime-mounted config directory: + +- `bot_config.toml` +- `model_config.toml` + +The server deployment mounts `docker-config/mmc` into `/MaiMBot/config`, so production secrets and bot-specific settings stay outside Git. diff --git a/dashboard/.gitignore b/dashboard/.gitignore deleted file mode 100644 index 8b137891..00000000 --- a/dashboard/.gitignore +++ /dev/null @@ -1 +0,0 @@ - diff --git a/dashboard/.vite/deps/_metadata.json b/dashboard/.vite/deps/_metadata.json deleted file mode 100644 index 49c6edbc..00000000 --- a/dashboard/.vite/deps/_metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "hash": "1b5cd9d5", - "configHash": "027a635a", - "lockfileHash": "36800971", - "browserHash": "e1e062e5", - "optimized": {}, - "chunks": {} -} \ No newline at end of file diff --git a/dashboard/.vite/deps/package.json b/dashboard/.vite/deps/package.json deleted file mode 100644 index 3dbc1ca5..00000000 --- a/dashboard/.vite/deps/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/dashboard/src/routes/plugins/InstallDialog.tsx b/dashboard/src/routes/plugins/InstallDialog.tsx deleted file mode 100644 index 33f8bdf6..00000000 --- a/dashboard/src/routes/plugins/InstallDialog.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Download } from 'lucide-react' - -import type { PluginInfo } from './types' - -interface InstallDialogProps { - open: boolean - plugin: PluginInfo | null - onOpenChange: (open: boolean) => void - onInstall: (branch: string) => void -} - -export function InstallDialog({ open, plugin, onOpenChange, onInstall }: InstallDialogProps) { - const [selectedBranch, setSelectedBranch] = useState('main') - const [customBranch, setCustomBranch] = useState('') - const [branchInputMode, setBranchInputMode] = useState<'preset' | 'custom'>('preset') - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) - - const handleInstall = () => { - const branch = branchInputMode === 'custom' ? customBranch : selectedBranch - - if (!branch || branch.trim() === '') { - return - } - - onInstall(branch) - onOpenChange(false) - } - - return ( - - - - 安装插件 - - 安装 {plugin?.manifest.name} - - - -
- {/* 基本信息 */} -
-

- 版本: {plugin?.manifest.version} -

-

- 作者: {typeof plugin?.manifest.author === 'string' - ? plugin.manifest.author - : plugin?.manifest.author?.name} -

-
- - {/* 高级选项开关 */} -
- setShowAdvancedOptions(checked as boolean)} - /> - -
- - {/* 高级选项内容 */} - {showAdvancedOptions && ( -
-
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- section heading above Tabs, not a form label */} - - - setBranchInputMode(value as 'preset' | 'custom')}> - - 预设分支 - 自定义分支 - - - {/* 预设分支选择 */} - {branchInputMode === 'preset' && ( -
- -
- )} - - {/* 自定义分支输入 */} - {branchInputMode === 'custom' && ( -
- setCustomBranch(e.target.value)} - /> -

- 输入 Git 分支名称、标签或提交哈希 -

-
- )} -
-
-
- )} - - {!showAdvancedOptions && ( -

- 将从默认分支 (main) 安装插件 -

- )} -
- - - - - -
-
- ) -} diff --git a/dashboard/src/routes/plugins/InstalledTab.tsx b/dashboard/src/routes/plugins/InstalledTab.tsx deleted file mode 100644 index 659cd83b..00000000 --- a/dashboard/src/routes/plugins/InstalledTab.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' -import { PluginCard } from './PluginCard' - -interface InstalledTabProps { - plugins: PluginInfo[] - searchQuery: string - categoryFilter: string - showCompatibleOnly: boolean - gitStatus: GitStatus | null - maimaiVersion: MaimaiVersion | null - pluginStats: Record - loadProgress: PluginLoadProgress | null - onInstall: (plugin: PluginInfo) => void - onUpdate: (plugin: PluginInfo) => void - onUninstall: (plugin: PluginInfo) => void - checkPluginCompatibility: (plugin: PluginInfo) => boolean - needsUpdate: (plugin: PluginInfo) => boolean - getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null - getIncompatibleReason: (plugin: PluginInfo) => string | null -} - -export function InstalledTab({ - plugins, - searchQuery, - categoryFilter, - showCompatibleOnly, - gitStatus, - maimaiVersion, - pluginStats, - loadProgress, - onInstall, - onUpdate, - onUninstall, - checkPluginCompatibility, - needsUpdate, - getStatusBadge, - getIncompatibleReason, -}: InstalledTabProps) { - // 过滤已安装插件 - const filteredPlugins = plugins.filter(plugin => { - // 跳过没有 manifest 的插件 - if (!plugin.manifest) { - return false - } - - // 只显示已安装 - if (!plugin.installed) { - return false - } - - // 搜索过滤 - const matchesSearch = searchQuery === '' || - plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - - // 分类过滤 - const matchesCategory = categoryFilter === 'all' || - (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) - - // 兼容性过滤 - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(plugin) - - return matchesSearch && matchesCategory && matchesCompatibility - }) - - return ( -
- {filteredPlugins.map((plugin) => ( - - ))} -
- ) -} diff --git a/dashboard/src/routes/plugins/MarketplaceTab.tsx b/dashboard/src/routes/plugins/MarketplaceTab.tsx deleted file mode 100644 index 1fbe6295..00000000 --- a/dashboard/src/routes/plugins/MarketplaceTab.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' -import { PluginCard } from './PluginCard' - -interface MarketplaceTabProps { - plugins: PluginInfo[] - searchQuery: string - categoryFilter: string - showCompatibleOnly: boolean - gitStatus: GitStatus | null - maimaiVersion: MaimaiVersion | null - pluginStats: Record - loadProgress: PluginLoadProgress | null - onInstall: (plugin: PluginInfo) => void - onUpdate: (plugin: PluginInfo) => void - onUninstall: (plugin: PluginInfo) => void - checkPluginCompatibility: (plugin: PluginInfo) => boolean - needsUpdate: (plugin: PluginInfo) => boolean - getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null - getIncompatibleReason: (plugin: PluginInfo) => string | null -} - -export function MarketplaceTab({ - plugins, - searchQuery, - categoryFilter, - showCompatibleOnly, - gitStatus, - maimaiVersion, - pluginStats, - loadProgress, - onInstall, - onUpdate, - onUninstall, - checkPluginCompatibility, - needsUpdate, - getStatusBadge, - getIncompatibleReason, -}: MarketplaceTabProps) { - // 过滤插件 - const filteredPlugins = plugins.filter(plugin => { - // 跳过没有 manifest 的插件 - if (!plugin.manifest) { - console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id) - return false - } - - // 全部插件只展示 plugin-repo 中存在的市场插件,本地独有插件只在“已安装”显示。 - if (plugin.source === 'local') { - return false - } - - // 搜索过滤 - const matchesSearch = searchQuery === '' || - plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - - // 分类过滤 - const matchesCategory = categoryFilter === 'all' || - (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) - - // 兼容性过滤 - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(plugin) - - return matchesSearch && matchesCategory && matchesCompatibility - }) - - return ( -
- {filteredPlugins.map((plugin) => ( - - ))} -
- ) -} diff --git a/dashboard/src/routes/plugins/PluginCard.tsx b/dashboard/src/routes/plugins/PluginCard.tsx deleted file mode 100644 index 994c65c0..00000000 --- a/dashboard/src/routes/plugins/PluginCard.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useNavigate } from '@tanstack/react-router' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { Progress } from '@/components/ui/progress' -import { AlertCircle, CheckCircle2, Download, Loader2, RefreshCw, Star, Trash2 } from 'lucide-react' - -import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' -import { CATEGORY_NAMES } from './types' - -interface PluginCardProps { - plugin: PluginInfo - gitStatus: GitStatus | null - maimaiVersion: MaimaiVersion | null - pluginStats: Record - loadProgress: PluginLoadProgress | null - onInstall: (plugin: PluginInfo) => void - onUpdate: (plugin: PluginInfo) => void - onUninstall: (plugin: PluginInfo) => void - checkPluginCompatibility: (plugin: PluginInfo) => boolean - needsUpdate: (plugin: PluginInfo) => boolean - getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null - getIncompatibleReason: (plugin: PluginInfo) => string | null -} - -export function PluginCard({ - plugin, - gitStatus, - maimaiVersion, - pluginStats, - loadProgress, - onInstall, - onUpdate, - onUninstall, - checkPluginCompatibility, - needsUpdate, - getStatusBadge, - getIncompatibleReason, -}: PluginCardProps) { - const navigate = useNavigate() - - return ( - - -
- {plugin.manifest?.name || plugin.id} -
- {plugin.manifest?.categories && plugin.manifest.categories[0] && ( - - {CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]} - - )} - {getStatusBadge(plugin)} -
-
- {plugin.manifest?.description || '无描述'} -
- -
- {/* 统计信息 */} -
-
- - {(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()} -
-
- - {(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)} -
-
- {/* 标签 */} -
- {plugin.manifest?.keywords && plugin.manifest.keywords.slice(0, 3).map((keyword) => ( - - {keyword} - - ))} - {plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && ( - - +{plugin.manifest.keywords.length - 3} - - )} -
- {/* 版本和作者 */} -
-
v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}
- {/* 支持版本 */} - {plugin.manifest?.host_application && ( -
- 支持: - - {plugin.manifest.host_application.min_version} - {plugin.manifest.host_application.max_version - ? ` - ${plugin.manifest.host_application.max_version}` - : ' - 最新版本' - } - -
- )} -
-
-
- -
- - {plugin.installed ? ( - needsUpdate(plugin) ? ( - - ) : ( - - ) - ) : ( - - )} -
-
- {/* 安装/卸载/更新进度显示 - 在卡片下方 */} - {loadProgress && - (loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') && - loadProgress.operation !== 'fetch' && - loadProgress.plugin_id === plugin.id && ( -
-
-
-
- {loadProgress.stage === 'loading' ? ( - - ) : loadProgress.stage === 'success' ? ( - - ) : ( - - )} - - {loadProgress.stage === 'loading' ? ( - <> - {loadProgress.operation === 'install' && '正在安装'} - {loadProgress.operation === 'uninstall' && '正在卸载'} - {loadProgress.operation === 'update' && '正在更新'} - - ) : loadProgress.stage === 'success' ? ( - <> - {loadProgress.operation === 'install' && '安装完成'} - {loadProgress.operation === 'uninstall' && '卸载完成'} - {loadProgress.operation === 'update' && '更新完成'} - - ) : ( - <> - {loadProgress.operation === 'install' && '安装失败'} - {loadProgress.operation === 'uninstall' && '卸载失败'} - {loadProgress.operation === 'update' && '更新失败'} - - )} - -
- {loadProgress.stage !== 'error' && ( - {loadProgress.progress}% - )} -
- {loadProgress.stage !== 'error' && ( - div]:bg-green-500' : ''}`} - /> - )} -
- {loadProgress.stage === 'error' ? (loadProgress.error || loadProgress.message || '操作失败') : loadProgress.message} -
-
-
- )} -
- ) -} diff --git a/dashboard/src/routes/plugins/index.tsx b/dashboard/src/routes/plugins/index.tsx deleted file mode 100644 index 992b21eb..00000000 --- a/dashboard/src/routes/plugins/index.tsx +++ /dev/null @@ -1,944 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { Progress } from '@/components/ui/progress' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2, RotateCw, Search, Settings2 } from 'lucide-react' - -import { RestartOverlay } from '@/components/restart-overlay' -import { useToast } from '@/hooks/use-toast' -import { RestartProvider, useRestart } from '@/lib/restart-context' -import { - checkGitStatus, - checkPluginInstalled, - connectPluginProgressWebSocket, - fetchPluginList, - getInstalledPluginVersion, - getInstalledPlugins, - getMaimaiVersion, - installPlugin, - isPluginCompatible, - uninstallPlugin, - updatePlugin, - type InstalledPlugin, -} from '@/lib/plugin-api' -import { getPluginStats, recordPluginDownload, type PluginStatsData } from '@/lib/plugin-stats' - -import { InstallDialog } from './InstallDialog' -import { InstalledTab } from './InstalledTab' -import { MarketplaceTab } from './MarketplaceTab' -import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress } from './types' - -// 主导出组件:包装 RestartProvider -export function PluginsPage() { - return ( - - - - ) -} - -// 内部组件:实际内容 -function PluginsPageContent() { - const navigate = useNavigate() - const { triggerRestart, isRestarting } = useRestart() - const [restartNoticeVisible, setRestartNoticeVisible] = useState( - () => localStorage.getItem('plugins-restart-notice-dismissed') !== 'true' - ) - const [searchQuery, setSearchQuery] = useState('') - const [categoryFilter, setCategoryFilter] = useState('all') - const [activeTab, setActiveTab] = useState('all') // all | installed | updates - const [showCompatibleOnly, setShowCompatibleOnly] = useState(true) // 默认只显示兼容的 - const [plugins, setPlugins] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [gitStatus, setGitStatus] = useState(null) - const [loadProgress, setLoadProgress] = useState(null) - const [maimaiVersion, setMaimaiVersion] = useState(null) - const [, setInstalledPlugins] = useState([]) - const [pluginStats, setPluginStats] = useState>({}) - - // 安装对话框状态 - const [installDialogOpen, setInstallDialogOpen] = useState(false) - const [installingPlugin, setInstallingPlugin] = useState(null) - - const { toast } = useToast() - - const dismissRestartNotice = () => { - localStorage.setItem('plugins-restart-notice-dismissed', 'true') - setRestartNoticeVisible(false) - } - - // 加载插件统计数据 - const loadPluginStats = async (pluginList: PluginInfo[]) => { - const statsPromises = pluginList.map(async (plugin) => { - try { - const stats = await getPluginStats(plugin.id) - return { id: plugin.id, stats } - } catch (error) { - console.warn(`Failed to load stats for ${plugin.id}:`, error) - return { id: plugin.id, stats: null } - } - }) - - const results = await Promise.all(statsPromises) - const statsMap: Record = {} - - results.forEach(({ id, stats }) => { - if (stats) { - statsMap[id] = stats - } - }) - - setPluginStats(statsMap) - } - - // 统一管理 WebSocket 和数据加载 - useEffect(() => { - let unsubscribeProgress: (() => Promise) | null = null - let isUnmounted = false - - const init = async () => { - // 1. 先连接 WebSocket(异步获取 token) - unsubscribeProgress = await connectPluginProgressWebSocket( - (progress) => { - if (isUnmounted) return - - setLoadProgress(progress) - - // 如果加载完成,清除进度 - if (progress.stage === 'success') { - setTimeout(() => { - if (!isUnmounted) { - setLoadProgress(null) - } - }, 2000) - } else if (progress.stage === 'error') { - setLoading(false) - setError(progress.error || '加载失败') - } - }, - (error) => { - console.error('WebSocket error:', error) - if (!isUnmounted) { - toast({ - title: 'WebSocket 连接失败', - description: '无法实时显示加载进度', - variant: 'destructive', - }) - } - } - ) - - // 2. 检查 Git 状态 - if (!isUnmounted) { - const statusResult = await checkGitStatus() - if (!statusResult.success) { - toast({ - title: 'Git 状态检查失败', - description: statusResult.error, - variant: 'destructive', - }) - setGitStatus({ installed: false, error: statusResult.error }) - } else { - setGitStatus(statusResult.data) - - if (!statusResult.data.installed) { - toast({ - title: 'Git 未安装', - description: statusResult.data.error || '请先安装 Git 才能使用插件安装功能', - variant: 'destructive', - }) - } - } - } - - // 3. 获取麦麦版本 - if (!isUnmounted) { - const versionResult = await getMaimaiVersion() - if (!versionResult.success) { - toast({ - title: '版本获取失败', - description: versionResult.error, - variant: 'destructive', - }) - } else { - setMaimaiVersion(versionResult.data) - } - } - // 4. 加载插件列表(包含已安装信息) - if (!isUnmounted) { - try { - setLoading(true) - setError(null) - const apiResult = await fetchPluginList() - if (!apiResult.success) { - if (!isUnmounted) { - setError(apiResult.error) - toast({ - title: '加载失败', - description: apiResult.error, - variant: 'destructive', - }) - } - return - } - const data = apiResult.data - - if (!isUnmounted) { - // 获取已安装插件列表 - const installedResult = await getInstalledPlugins() - if (!installedResult.success) { - toast({ - title: '获取已安装插件失败', - description: installedResult.error, - variant: 'destructive', - }) - return - } - const installed = installedResult.data - setInstalledPlugins(installed) - - // 将已安装信息合并到插件数据中 - const mergedData = data.map(plugin => { - const isInstalled = checkPluginInstalled(plugin.id, installed) - const installedVersion = getInstalledPluginVersion(plugin.id, installed) - - return { - ...plugin, - installed: isInstalled, - installed_version: installedVersion - } - }) - - - // 添加本地安装但不在市场的插件 - for (const installedPlugin of installed) { - const existsInMarket = mergedData.some(p => p.id === installedPlugin.id) - if (!existsInMarket && installedPlugin.manifest) { - const urls = installedPlugin.manifest.urls as PluginInfo['manifest']['urls'] | undefined - // 添加本地插件到列表 - mergedData.push({ - id: installedPlugin.id, - manifest: { - manifest_version: installedPlugin.manifest.manifest_version || 1, - id: installedPlugin.manifest.id || installedPlugin.id, - name: installedPlugin.manifest.name, - version: installedPlugin.manifest.version, - description: installedPlugin.manifest.description || '', - author: installedPlugin.manifest.author, - license: installedPlugin.manifest.license || 'Unknown', - host_application: installedPlugin.manifest.host_application, - homepage_url: installedPlugin.manifest.homepage_url || urls?.homepage, - repository_url: installedPlugin.manifest.repository_url || urls?.repository, - urls, - keywords: installedPlugin.manifest.keywords || [], - categories: installedPlugin.manifest.categories || [], - default_locale: (installedPlugin.manifest.default_locale as string) || 'zh-CN', - locales_path: installedPlugin.manifest.locales_path as string | undefined, - }, - downloads: 0, - rating: 0, - review_count: 0, - installed: true, - installed_version: installedPlugin.manifest.version, - source: 'local', - published_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - } - } - - setPlugins(mergedData) - - // 6. 加载所有插件的统计数据 - loadPluginStats(mergedData) - } - } finally { - if (!isUnmounted) { - setLoading(false) - } - } - } - } - - init() - - return () => { - isUnmounted = true - if (unsubscribeProgress) { - void unsubscribeProgress() - } - } - }, [toast]) - - // 获取插件状态徽章 - const getStatusBadge = (plugin: PluginInfo) => { - // 优先显示兼容性状态(已安装但不兼容也需要提示,避免用户误以为可继续更新) - if (maimaiVersion && !checkPluginCompatibility(plugin)) { - return ( - - - 不兼容 - - ) - } - - if (plugin.installed) { - // 版本比较:去除两边空格并进行比较 - const installedVer = plugin.installed_version?.trim() - const marketVer = plugin.manifest.version?.trim() - - if (installedVer !== marketVer) { - // 简单的版本比较:只有当市场版本比已安装版本新时才显示"可更新" - // 如果本地版本更新(比如手动更新或市场数据过期),则显示"已安装" - const installedParts = installedVer?.split('.').map(Number) || [0, 0, 0] - const marketParts = marketVer?.split('.').map(Number) || [0, 0, 0] - - // 比较主版本号、次版本号、修订号 - for (let i = 0; i < 3; i++) { - if ((marketParts[i] || 0) > (installedParts[i] || 0)) { - // 市场版本更新 - return ( - - - 可更新 - - ) - } else if ((marketParts[i] || 0) < (installedParts[i] || 0)) { - // 本地版本更新 - break - } - } - } - - return ( - - - 已安装 - - ) - } - return null - } - - // 检查插件兼容性 - // 规则: - // 1. manifest_version === 1 的插件在麦麦 >= 1.0.0 时一律视为不兼容(旧 manifest 已不再被宿主接受); - // 2. 否则若声明了 host_application 范围,则按版本范围判定。 - const checkPluginCompatibility = (plugin: PluginInfo): boolean => { - if (!maimaiVersion) return true - - // manifest v1 在 1.0.0+ 麦麦上不再兼容 - const manifestVersion = plugin.manifest?.manifest_version ?? 1 - if (manifestVersion <= 1 && maimaiVersion.version_major >= 1) { - return false - } - - if (!plugin.manifest?.host_application) return true - - return isPluginCompatible( - plugin.manifest.host_application.min_version, - plugin.manifest.host_application.max_version, - maimaiVersion - ) - } - - // 不兼容原因(用于 UI 提示) - const getIncompatibleReason = (plugin: PluginInfo): string | null => { - if (!maimaiVersion) return null - const manifestVersion = plugin.manifest?.manifest_version ?? 1 - if (manifestVersion <= 1 && maimaiVersion.version_major >= 1) { - return `该插件使用旧版 manifest (v${manifestVersion}),已不被麦麦 ${maimaiVersion.version} 支持` - } - if (plugin.manifest?.host_application && !isPluginCompatible( - plugin.manifest.host_application.min_version, - plugin.manifest.host_application.max_version, - maimaiVersion - )) { - const min = plugin.manifest.host_application.min_version || '未知' - const max = plugin.manifest.host_application.max_version - const range = max ? `${min} - ${max}` : `${min}+` - return `不兼容当前版本 (需要 ${range},当前 ${maimaiVersion.version})` - } - return null - } - - // 检查是否需要更新(市场版本比已安装版本新) - const needsUpdate = (plugin: PluginInfo): boolean => { - if (!plugin.installed || !plugin.installed_version || !plugin.manifest?.version) { - return false - } - // 不兼容的插件不允许更新 - if (!checkPluginCompatibility(plugin)) { - return false - } - - const installedVer = plugin.installed_version.trim() - const marketVer = plugin.manifest.version.trim() - - if (installedVer === marketVer) return false - - const installedParts = installedVer.split('.').map(Number) - const marketParts = marketVer.split('.').map(Number) - - // 比较主版本号、次版本号、修订号 - for (let i = 0; i < 3; i++) { - if ((marketParts[i] || 0) > (installedParts[i] || 0)) { - return true // 市场版本更新 - } else if ((marketParts[i] || 0) < (installedParts[i] || 0)) { - return false // 本地版本更新 - } - } - - return false - } - - // 打开安装对话框 - const openInstallDialog = (plugin: PluginInfo) => { - if (!gitStatus?.installed) { - toast({ - title: '无法安装', - description: 'Git 未安装', - variant: 'destructive', - }) - return - } - - // 检查插件兼容性 - if (maimaiVersion && !checkPluginCompatibility(plugin)) { - toast({ - title: '无法安装', - description: getIncompatibleReason(plugin) ?? '插件与当前麦麦版本不兼容', - variant: 'destructive', - }) - return - } - - setInstallingPlugin(plugin) - setInstallDialogOpen(true) - } - - // 安装插件处理 - const handleInstall = async (branch: string) => { - if (!installingPlugin) return - - if (!branch || branch.trim() === '') { - toast({ - title: '分支名称不能为空', - variant: 'destructive', - }) - return - } - - try { - setInstallDialogOpen(false) - - const installResult = await installPlugin( - installingPlugin.id, - installingPlugin.manifest.repository_url || installingPlugin.manifest.urls?.repository || '', - branch - ) - - if (!installResult.success) { - toast({ - title: '安装失败', - description: installResult.error, - variant: 'destructive', - }) - return - } - - // 记录下载统计 - recordPluginDownload(installingPlugin.id).catch(err => { - console.warn('Failed to record download:', err) - }) - - toast({ - title: '安装成功', - description: `${installingPlugin.manifest.name} 已成功安装`, - }) - - // 重新加载已安装插件列表 - const installedResult = await getInstalledPlugins() - if (!installedResult.success) { - toast({ - title: '获取已安装插件失败', - description: installedResult.error, - variant: 'destructive', - }) - return - } - const installed = installedResult.data - setInstalledPlugins(installed) - - // 重新合并已安装信息到插件列表 - setPlugins(prevPlugins => - prevPlugins.map(p => { - if (p.id === installingPlugin.id) { - const isInstalled = checkPluginInstalled(p.id, installed) - const installedVersion = getInstalledPluginVersion(p.id, installed) - - return { - ...p, - installed: isInstalled, - installed_version: installedVersion - } - } - return p - }) - ) - } catch (error) { - toast({ - title: '安装失败', - description: error instanceof Error ? error.message : '未知错误', - variant: 'destructive', - }) - } finally { - setInstallingPlugin(null) - } - } - - // 卸载插件处理 - const handleUninstall = async (plugin: PluginInfo) => { - try { - const uninstallResult = await uninstallPlugin(plugin.id) - - if (!uninstallResult.success) { - toast({ - title: '卸载失败', - description: uninstallResult.error, - variant: 'destructive', - }) - return - } - - toast({ - title: '卸载成功', - description: `${plugin.manifest.name} 已成功卸载`, - }) - - // 重新加载已安装插件列表 - const installedResult = await getInstalledPlugins() - if (!installedResult.success) { - toast({ - title: '获取已安装插件失败', - description: installedResult.error, - variant: 'destructive', - }) - return - } - const installed = installedResult.data - setInstalledPlugins(installed) - - // 重新合并已安装信息到插件列表 - setPlugins(prevPlugins => - prevPlugins.map(p => { - if (p.id === plugin.id) { - const isInstalled = checkPluginInstalled(p.id, installed) - const installedVersion = getInstalledPluginVersion(p.id, installed) - - return { - ...p, - installed: isInstalled, - installed_version: installedVersion - } - } - return p - }) - ) - } catch (error) { - toast({ - title: '卸载失败', - description: error instanceof Error ? error.message : '未知错误', - variant: 'destructive', - }) - } - } - - // 更新插件处理 - const handleUpdate = async (plugin: PluginInfo) => { - if (!gitStatus?.installed) { - toast({ - title: '无法更新', - description: 'Git 未安装', - variant: 'destructive', - }) - return - } - - // 不兼容的插件不允许更新 - if (maimaiVersion && !checkPluginCompatibility(plugin)) { - toast({ - title: '无法更新', - description: getIncompatibleReason(plugin) ?? '插件与当前麦麦版本不兼容', - variant: 'destructive', - }) - return - } - - try { - const updateResult = await updatePlugin( - plugin.id, - plugin.manifest.repository_url || plugin.manifest.urls?.repository || '', - 'main' - ) - - if (!updateResult.success) { - toast({ - title: '更新失败', - description: updateResult.error, - variant: 'destructive', - }) - return - } - - toast({ - title: '更新成功', - description: `${plugin.manifest.name} 已从 ${updateResult.data.old_version} 更新到 ${updateResult.data.new_version}`, - }) - - // 重新加载已安装插件列表 - const installedResult = await getInstalledPlugins() - if (!installedResult.success) { - toast({ - title: '获取已安装插件失败', - description: installedResult.error, - variant: 'destructive', - }) - return - } - const installed = installedResult.data - setInstalledPlugins(installed) - - // 重新合并已安装信息到插件列表 - setPlugins(prevPlugins => - prevPlugins.map(p => { - if (p.id === plugin.id) { - const isInstalled = checkPluginInstalled(p.id, installed) - const installedVersion = getInstalledPluginVersion(p.id, installed) - - return { - ...p, - installed: isInstalled, - installed_version: installedVersion - } - } - return p - }) - ) - } catch (error) { - toast({ - title: '更新失败', - description: error instanceof Error ? error.message : '未知错误', - variant: 'destructive', - }) - } - } - - // 过滤插件用于标签页统计 - const getFilteredPluginCount = (tab: 'all' | 'installed' | 'updates') => { - return plugins.filter(p => { - if (!p.manifest) return false - if (tab === 'all' && p.source === 'local') return false - const matchesSearch = searchQuery === '' || - p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - const matchesCategory = categoryFilter === 'all' || - (p.manifest.categories && p.manifest.categories.includes(categoryFilter)) - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(p) - - let matchesTab = true - if (tab === 'installed') { - matchesTab = p.installed === true - } else if (tab === 'updates') { - matchesTab = p.installed === true && needsUpdate(p) - } - - return matchesSearch && matchesCategory && matchesCompatibility && matchesTab - }).length - } - - // 过滤插件用于可更新标签页 - const filteredUpdatablePlugins = plugins.filter(plugin => { - if (!plugin.manifest) return false - - const matchesSearch = searchQuery === '' || - plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - - const matchesCategory = categoryFilter === 'all' || - (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) - - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(plugin) - - return plugin.installed && needsUpdate(plugin) && matchesSearch && matchesCategory && matchesCompatibility - }) - - return ( - -
- {/* 标题 */} -
-
-

插件市场

-

浏览和管理麦麦的插件

-
-
- - -
-
- - {/* 安装提示 */} - {restartNoticeVisible && ( - - -
-
- -

- 安装、卸载或更新插件后,部分插件需要重启麦麦才能生效 -

-
- -
-
-
- )} - - {/* Git 状态警告 */} - {gitStatus && !gitStatus.installed && ( - - -
- -
- - Git 未安装 - - - {gitStatus.error || '请先安装 Git 才能使用插件安装功能'} - -
-
-
- -

- 您可以从 git-scm.com 下载并安装 Git。 - 安装完成后,请重启麦麦应用。 -

-
-
- )} - - {/* 搜索和筛选栏 */} - -
-
- {/* 搜索框 */} -
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- - {/* 分类筛选 */} - -
- - {/* 兼容性筛选 */} -
- setShowCompatibleOnly(checked === true)} - /> - -
-
-
- - {/* 标签页 */} - - - - 全部插件 ({getFilteredPluginCount('all')}) - - - 已安装 ({getFilteredPluginCount('installed')}) - - - 可更新 ({getFilteredPluginCount('updates')}) - - - - - {/* 进度条 - 仅显示插件清单加载进度 */} - {loadProgress && loadProgress.stage === 'loading' && loadProgress.operation === 'fetch' && ( - -
-
-
- - 加载插件列表 -
- {loadProgress.progress}% -
- -
- {loadProgress.message} -
- {loadProgress.total_plugins > 0 && ( -
- 已加载 {loadProgress.loaded_plugins} / {loadProgress.total_plugins} 个插件 -
- )} -
-
- )} - - {/* 加载错误显示 */} - {loadProgress && loadProgress.stage === 'error' && loadProgress.error && ( - - -
- -
- - 加载失败 - - - {loadProgress.error} - -
-
-
-
- )} - - {/* 插件卡片网格 */} - {loading ? ( -
- - 加载插件列表中... -
- ) : error ? ( - -
- -

加载失败

-

{error}

- -
-
- ) : activeTab === 'all' ? ( - - ) : activeTab === 'installed' ? ( - - ) : ( -
- {filteredUpdatablePlugins.map((plugin) => ( -
- {/* PluginCard would go here */} -
- ))} -
- )} - - {/* 安装对话框 */} - - - {/* 重启遮罩层 */} - -
-
- ) -} diff --git a/dashboard/src/routes/plugins/types.ts b/dashboard/src/routes/plugins/types.ts deleted file mode 100644 index 43477865..00000000 --- a/dashboard/src/routes/plugins/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { PluginInfo } from '@/types/plugin' -import type { GitStatus, MaimaiVersion, PluginLoadProgress } from '@/lib/plugin-api' -import type { PluginStatsData } from '@/lib/plugin-stats' - -// 分类名称映射 -export const CATEGORY_NAMES: Record = { - 'Group Management': '群组管理', - 'Entertainment & Interaction': '娱乐互动', - 'Utility Tools': '实用工具', - 'Content Generation': '内容生成', - 'Multimedia': '多媒体', - 'External Integration': '外部集成', - 'Data Analysis & Insights': '数据分析与洞察', - 'Other': '其他', -} - -// 导出类型 -export type { PluginInfo, GitStatus, MaimaiVersion, PluginLoadProgress, PluginStatsData } diff --git a/deploy/server-maibot/Dockerfile.offline b/deploy/server-maibot/Dockerfile.offline new file mode 100644 index 00000000..12d09255 --- /dev/null +++ b/deploy/server-maibot/Dockerfile.offline @@ -0,0 +1,21 @@ +FROM python:3.13-slim + +WORKDIR /MaiMBot + +ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 +ENV PATH="/MaiMBot/.venv/bin:${PATH}" + +COPY pyproject.toml uv.lock ./ + +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir uv + +RUN uv sync --frozen --no-dev --no-install-project + +COPY . . + +RUN chmod +x deploy/server-maibot/docker-entrypoint.offline.sh + +EXPOSE 8001 + +ENTRYPOINT ["./deploy/server-maibot/docker-entrypoint.offline.sh"] diff --git a/deploy/server-maibot/Dockerfile.release b/deploy/server-maibot/Dockerfile.release new file mode 100644 index 00000000..503071bd --- /dev/null +++ b/deploy/server-maibot/Dockerfile.release @@ -0,0 +1,14 @@ +ARG MAIBOT_BASE_IMAGE=maibot-offline:latest +FROM ${MAIBOT_BASE_IMAGE} + +WORKDIR /MaiMBot + +RUN find /MaiMBot -mindepth 1 -maxdepth 1 ! -name '.venv' -exec rm -rf {} + + +COPY . . + +RUN chmod +x deploy/server-maibot/docker-entrypoint.offline.sh + +EXPOSE 8001 + +ENTRYPOINT ["./deploy/server-maibot/docker-entrypoint.offline.sh"] diff --git a/deploy/server-maibot/README_DEPLOY_STEPS.txt b/deploy/server-maibot/README_DEPLOY_STEPS.txt new file mode 100644 index 00000000..6124450d --- /dev/null +++ b/deploy/server-maibot/README_DEPLOY_STEPS.txt @@ -0,0 +1,41 @@ +Server runtime directory: /root/maibot-offline +Release archive directory: /srv/maibot/releases + +Repo-managed deployment files: +- deploy/server-maibot/Dockerfile.offline +- deploy/server-maibot/Dockerfile.release +- deploy/server-maibot/docker-entrypoint.offline.sh +- deploy/server-maibot/docker-compose.server.yml +- deploy/server-maibot/activate-release.sh +- deploy/server-maibot/bot.lecspace.com.nginx.conf + +Persistent files that stay on the server and do not go into Git: +- /root/maibot-offline/docker-config/mmc/* +- /root/maibot-offline/docker-config/napcat/* +- /root/maibot-offline/data/* +- /root/maibot-offline/depends-data/* +- /root/maibot-offline/bot.lecspace.com.key +- /root/maibot-offline/bot.lecspace.com_bundle.pem + +Gitea workflow: +- .gitea/workflows/release-offline.yml + +Current pipeline mode: +- single-host release on the repo-level `build-host` runner +- clones from local Gitea HTTP on `127.0.0.1:3000` +- stages source into `/srv/maibot/releases/` +- builds `maibot-offline:` from the staged release using local base image `maibot-offline:latest` +- tags the same image back to `maibot-offline:latest` +- deploys from `/root/maibot-offline` with `docker compose up -d` + +Optional environment overrides for the workflow runtime: +- `MAIBOT_RELEASE_ROOT` +- `MAIBOT_RUNTIME_ROOT` +- `MAIBOT_BASE_IMAGE` + +No repository secrets are required for the default same-host pipeline. + +Bootstrap note: +- `deploy/server-maibot/Dockerfile.offline` is only for the first bootstrap or for refreshing the runtime base image. +- The normal Gitea release pipeline uses `deploy/server-maibot/Dockerfile.release`, so it does not need Docker Hub or GitHub during each deploy. +- If `pyproject.toml` or `uv.lock` changes, refresh the local base image once before relying on the release pipeline again. diff --git a/deploy/server-maibot/activate-release.sh b/deploy/server-maibot/activate-release.sh new file mode 100644 index 00000000..6922bed7 --- /dev/null +++ b/deploy/server-maibot/activate-release.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_dir="${1:?usage: activate-release.sh }" +runtime_root="${MAIBOT_RUNTIME_ROOT:-/root/maibot-offline}" +base_image="${MAIBOT_BASE_IMAGE:-maibot-offline:latest}" + +case "$runtime_root" in + /root/maibot-offline|/root/maibot-offline/*) ;; + *) + echo "runtime root must stay under /root/maibot-offline" >&2 + exit 1 + ;; +esac + +if [ ! -d "$release_dir" ]; then + echo "release dir not found: $release_dir" >&2 + exit 1 +fi + +if ! docker image inspect "$base_image" >/dev/null 2>&1; then + echo "base image not found locally: $base_image" >&2 + echo "bootstrap it once with deploy/server-maibot/Dockerfile.offline before using the release pipeline" >&2 + exit 1 +fi + +for dep_file in pyproject.toml uv.lock; do + if [ -f "$runtime_root/$dep_file" ] && ! cmp -s "$release_dir/$dep_file" "$runtime_root/$dep_file"; then + echo "dependency metadata changed: $dep_file" >&2 + echo "refresh the local base image with deploy/server-maibot/Dockerfile.offline before using the release pipeline" >&2 + exit 1 + fi +done + +release_id="$(basename "$release_dir")" +release_image="maibot-offline:${release_id}" + +mkdir -p \ + "$runtime_root" \ + "$runtime_root/data/MaiMBot" \ + "$runtime_root/data/MaiMBot/emoji" \ + "$runtime_root/data/MaiMBot/plugins" \ + "$runtime_root/data/MaiMBot/logs" \ + "$runtime_root/data/qq" \ + "$runtime_root/depends-data" \ + "$runtime_root/docker-config/mmc" \ + "$runtime_root/docker-config/napcat" + +python3 - "$release_dir/deploy/server-maibot" "$runtime_root/deploy/server-maibot" <<'PY' +from pathlib import Path +import shutil +import sys + +src = Path(sys.argv[1]).resolve() +dst = Path(sys.argv[2]).resolve() + +if dst.exists(): + shutil.rmtree(dst) + +shutil.copytree( + src, + dst, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"), +) +PY + +docker build \ + --build-arg "MAIBOT_BASE_IMAGE=${base_image}" \ + -f "$release_dir/deploy/server-maibot/Dockerfile.release" \ + -t "$release_image" \ + "$release_dir" + +docker tag "$release_image" maibot-offline:latest + +cd "$runtime_root" +MAIBOT_CORE_IMAGE="$release_image" docker compose \ + --project-name maibot-offline \ + -f deploy/server-maibot/docker-compose.server.yml \ + up -d diff --git a/deploy/server-maibot/bot.lecspace.com.nginx.conf b/deploy/server-maibot/bot.lecspace.com.nginx.conf new file mode 100644 index 00000000..f56325f7 --- /dev/null +++ b/deploy/server-maibot/bot.lecspace.com.nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name bot.lecspace.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name bot.lecspace.com; + resolver 127.0.0.11 ipv6=off valid=30s; + + ssl_certificate /etc/nginx/certs/bot.lecspace.com_bundle.pem; + ssl_certificate_key /etc/nginx/certs/bot.lecspace.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + location / { + set $maibot_upstream http://maibot-core:8001; + proxy_pass $maibot_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + proxy_cache off; + } +} diff --git a/deploy/server-maibot/docker-compose.server.yml b/deploy/server-maibot/docker-compose.server.yml new file mode 100644 index 00000000..3c2deca8 --- /dev/null +++ b/deploy/server-maibot/docker-compose.server.yml @@ -0,0 +1,51 @@ +services: + core: + container_name: maibot-core + image: ${MAIBOT_CORE_IMAGE:-maibot-offline:latest} + environment: + - TZ=Asia/Shanghai + - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d + - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 + - MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 + - MAIBOT_STATISTICS_REPORT_PATH=/MaiMBot/data/maibot_statistics.html + ports: + - "18001:8001" + volumes: + - ../../docker-config/mmc:/MaiMBot/config + - ../../data/MaiMBot:/MaiMBot/data + - ../../data/MaiMBot/emoji:/data/emoji + - ../../data/MaiMBot/plugins:/MaiMBot/plugins + - ../../data/MaiMBot/logs:/MaiMBot/logs + - ../../depends-data:/MaiMBot/depends-data + restart: always + networks: + maibot_net: {} + smartflow-full_default: + aliases: + - maibot-core + + napcat: + container_name: maibot-napcat + image: ${NAPCAT_IMAGE:-mlikiowa/napcat-docker:latest} + environment: + - NAPCAT_UID=1000 + - NAPCAT_GID=1000 + - TZ=Asia/Shanghai + volumes: + - ../../docker-config/napcat:/app/napcat/config + - ../../data/qq:/app/.config/QQ + - ../../data/MaiMBot:/MaiMBot/data + restart: always + networks: + maibot_net: + aliases: + - napcat + smartflow-full_default: + aliases: + - napcat + +networks: + maibot_net: + driver: bridge + smartflow-full_default: + external: true diff --git a/deploy/server-maibot/docker-entrypoint.offline.sh b/deploy/server-maibot/docker-entrypoint.offline.sh new file mode 100644 index 00000000..9d1a9f85 --- /dev/null +++ b/deploy/server-maibot/docker-entrypoint.offline.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +ADAPTER_TEMPLATE="/MaiMBot/plugin-templates/MaiBot-Napcat-Adapter" +ADAPTER_TARGET="/MaiMBot/plugins/MaiBot-Napcat-Adapter" +CONFIG_DIR="/MaiMBot/config" +BOT_CONFIG_PATH="$CONFIG_DIR/bot_config.toml" +MODEL_CONFIG_PATH="$CONFIG_DIR/model_config.toml" + +mkdir -p /MaiMBot/plugins +mkdir -p "$CONFIG_DIR" + +if [ ! -e "$ADAPTER_TARGET" ] && [ -d "$ADAPTER_TEMPLATE" ]; then + cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET" +fi + +if [ ! -f "$BOT_CONFIG_PATH" ] || [ ! -f "$MODEL_CONFIG_PATH" ]; then + /MaiMBot/.venv/bin/python -c "from src.config.config import config_manager; print('config initialized')" +fi + +if [ -f "$BOT_CONFIG_PATH" ]; then + /MaiMBot/.venv/bin/python - "$BOT_CONFIG_PATH" <<'PY' +import re +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +content = path.read_text(encoding="utf-8") +updated = re.sub( + r'(^host\s*=\s*)"(127\.0\.0\.1|localhost)"', + r'\1"0.0.0.0"', + content, + flags=re.MULTILINE, +) +if updated != content: + path.write_text(updated, encoding="utf-8") +PY +fi + +exec /MaiMBot/.venv/bin/python bot.py "$@" diff --git a/docs/CONTRIBUTE.md b/docs/CONTRIBUTE.md deleted file mode 100644 index c372aebe..00000000 --- a/docs/CONTRIBUTE.md +++ /dev/null @@ -1,88 +0,0 @@ -# 如何给MaiCore做贡献v1.0 - -修改时间2025/4/5 - -如有修改建议或疑问,请在github上建立issue - -首先,非常感谢你抽出时间来做贡献!❤️ - -这份文档是告诉你,当你想向MaiCore提交代码,或者想要以其他形式加入MaiCore的开发,或周边插件的开发,你可以怎么做。 - -我们鼓励并重视任何形式的贡献,但无序的贡献只会使麦麦的维护与更新变成一团糟。因此,我们建议您在做出贡献之前,先查看本指南。 - - -> 另外,如果您喜欢这个项目,但只是没有时间做贡献,那也没关系。还有其他简单的方式来支持本项目并表达您的感激之情,我们也会非常高兴: -> - 给我们点一颗小星星(Star) -> - 在您的项目的readme中引用这个项目 - -## 目录 - -● [我有问题](#我有问题) -● [我想做贡献](#我想做贡献) -● [我想提出建议](#提出建议) - -## 我有问题 - -> 如果你想问一个问题,我们会假设你已经阅读了现有的文档。 - -在你提问之前,最好先搜索现有的[issue](/issues),看看是否有助于解决你的问题。如果你找到了匹配的issue,但仍需要追加说明,你可以在该issue下提出你的问题。同时,我们还建议你先在互联网上搜索答案。 - -如果你仍然觉得有必要提问并需要进行说明,我们建议你: - -● 开一个[新Issue](/issues/new)。并尽可能详细地描述你的问题。 - -● 提供尽可能多的上下文信息,让我们更好地理解你遇到的问题。比如:提供版本信息(哪个分支,版本号是多少,运行环境有哪些等),具体取决于你认为相关的内容。 - -只要你提出的issue明确且合理,我们都会回复并尝试解决您的问题。 - - -## 我想做贡献 - -> ### 项目所有权与维护 -> MaiMBot项目(现更名为MaiBot,核心为MaiCore)由千石可乐SengokuCola创建,采用GPL3开源协议。 -> MaiBot项目现已移动至MaiM-with-u组织下,目前主要内容由核心开发组维护,整体由核心开发组、reviewer和所有贡献者共同维护(该部分在未来将会明确)。 -> 为了保证设计的统一和节省不必要的精力,以及为了对项目有整体的把控,我们对不同类型的贡献采取不同的审核策略: -> -> #### 功能新增 -> - 定义:涉及新功能添加、架构调整、重要模块重构等 -> - 要求:原则上暂不接收,你可以发布issue提供新功能建议。 -> -> #### Bug修复 -> - 定义:修复现有功能中的错误,包括非预期行为(需要发布issue进行确认)和运行错误,不涉及新功能或架构变动 -> - 要求:由核心组成员或2名及以上reviewer同时确认才会被合并 -> - 关闭:包含预期行为改动,新功能,破坏原有功能,数据库破坏性改动等的pr将会被关闭 -> -> #### 文档修补 -> - 定义:修复现有文档中的错误,提供新的帮助文档 -> - 要求:现需要提交至组织下docs仓库,由reviewer确认后合并 - - -> ### 法律声明 -> 当你为本项目贡献代码/文档时,你必须确认: -> 1. 你贡献的内容100%是由你创作; -> 2. 你对这些内容拥有相应的权利; -> 3. 你贡献的内容将按项目许可协议使用。 - - -## 提出建议 - -这一部分指导您如何为MaiCore/MaiBot提交一个建议,包括全新的功能和对现有功能的小改进。遵循这些指南将有助于维护人员和社区了解您的建议并找到相关的建议。 - -在提交建议之前 - -● 请确保您正在使用最新版本(正式版请查看main分支,测试版查看dev分支)。 - -● 请确保您已经阅读了文档,以确认您的建议是否已经被实现,也许是通过单独的配置。 - -● 仔细阅读文档并了解项目目前是否支持该功能,也许可以通过单独的配置来实现。 - -● 进行一番[搜索](/issues)以查看是否已经有人提出了这个建议。如果有,请在现有的issue下添加评论,而不是新开一个issue。 - -● 请确保您的建议符合项目的范围和目标。你需要提出一个强有力的理由来说服项目的开发者这个功能的优点。请记住,我们希望的功能是对大多数用户有用的,而不仅仅是少数用户。如果你只是针对少数用户,请考虑编写一个插件。 - -### 附(暂定): -核心组成员:@SengokuCola @tcmofashi @Rikki-Zero - -reviewer:核心组+MaiBot主仓库合作者/权限者 - -贡献者:所有提交过贡献的用户 diff --git a/docs/README_CN.md b/docs/README_CN.md deleted file mode 100644 index 5347d906..00000000 --- a/docs/README_CN.md +++ /dev/null @@ -1,162 +0,0 @@ -
- - - 简体中文 | English - -
-
- -

麦麦 MaiBot MaiSaka

- - -

- Python Version - License - Status - Contributors - Forks - Stars - Ask DeepWiki -

-
- -
- - -MaiBot Character - -## 介绍 - -麦麦MaiSaka 是一个基于大语言模型的可交互智能体 - -MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务的“有帮助的助手”,她还是一个致力于了解你,并以真实人类的风格进行交互的数字生命,她不追求完美,她不追求高效,但追求亲切和真实。 - - -- 💭 **没有人喜欢GPT的语言风格**:麦麦使用了更加自然,贴合人类对话习惯的交互方式,不是长篇大论或者markdown格式的分点,而是或长或短的闲谈。 - -- 🎭 **不再是傻乎乎的一问一答**:懂得在合适的时间说话,把握聊天中的气氛,在合适的时候开口,在合适的时候闭嘴。 - -- 🧠 **麦麦·成为人类**:在多人对话中,麦麦会模仿其他人的的说话风格,还会自主理解新词或者小圈子里的黑话,不断进化。 - -- ❤️ **永远都在更加了解你**:基于心理学中人格理论,麦麦会不断积累对于你的了解,不论是你的信息,喜恶或是行为风格,她都记在心里。 - -- 🔌 **插件系统**:提供强大的 API 和事件系统,无限扩展可能。 - - - -### 快速导航 -

- 🌟 演示视频  |  - 📦 快速入门  |  - 📃 核心文档  |  - 💬 加入社区 -

- - -
- - - ---- - -## 🔥 更新和安装 - -> **最新版本: v1.0.0** ([📄 更新日志](../changelogs/changelog.md)) - -- **下载**: 前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本 -- **启动器**: [Mailauncher](https://github.com/MaiM-with-u/mailauncher/releases/) (仅支持 MacOS, 早期开发中) - -| 分支 | 说明 | -| :--- | :--- | -| `main` | ✅ **稳定发布版本 (推荐)** | -| `dev` | 🚧 开发测试版本,包含新功能,可能不稳定 | - -### 📚 部署教程 -👉 **[🚀 最新版本部署教程](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html)** - ---- - -## 💬 讨论与社区 - -我们欢迎所有对 MaiBot 感兴趣的朋友加入! - -| 类别 | 群组 | 说明 | -| :--- | :--- | :--- | -| **技术交流** | [麦麦脑电图](https://qm.qq.com/q/RzmCiRtHEW) | 技术交流/答疑 | -| **技术交流** | [麦麦大脑磁共振](https://qm.qq.com/q/VQ3XZrWgMs) | 技术交流/答疑 | -| **技术交流** | [麦麦要当VTB](https://qm.qq.com/q/wGePTl1UyY) | 技术交流/答疑 | -| **闲聊吹水** | [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec) | 仅限闲聊,不答疑 | -| **插件开发** | [插件开发群](https://qm.qq.com/q/1036092828) | 进阶开发与测试 | - ---- - -## 📚 文档 - -> [!NOTE] -> 部分内容可能更新不够及时,请注意版本对应。 - -- **[📚 核心 Wiki 文档](https://docs.mai-mai.org)**: 最全面的文档中心,了解麦麦的一切。 - -### 🧩 衍生项目 - -- **[Amaidesu](https://github.com/MaiM-with-u/Amaidesu)**: 让麦麦在B站开播 -- **[MoFox_Bot](https://github.com/MoFox-Studio/MoFox-Core)**: 基于 MaiCore 0.10.0 的增强型 Fork,更稳定更有趣。 -- **[MaiCraft](https://github.com/MaiM-with-u/Maicraft)**: 让麦麦陪你玩 Minecraft (暂时停止维护中)。 - ---- - -## 💡 设计理念 - -> **千石可乐说:** -> - 这个项目最初只是为了给牛牛 bot 添加一点额外的功能,但是功能越写越多,最后决定重写。其目的是为了创造一个活跃在 QQ 群聊的"生命体"。目的并不是为了写一个功能齐全的机器人,而是一个尽可能让人感知到真实的类人存在。 -> - 程序的功能设计理念基于一个核心的原则:"最像而不是好"。 -> - 如果人类真的需要一个 AI 来陪伴自己,并不是所有人都需要一个完美的,能解决所有问题的"helpful assistant",而是一个会犯错的,拥有自己感知和想法的"生命形式"。 - -> **xxxxx说:** -> *Code is open, but the soul is yours.* - ---- - -## 🙋 贡献和致谢 - -欢迎参与贡献!请先阅读 [贡献指南](CONTRIBUTE.md)。 - -### 🌟 贡献者 - - - contributors - - -### ❤️ 特别致谢 - -- **[萨卡班甲鱼](https://en.wikipedia.org/wiki/Sacabambaspis)**: 千石可乐很喜欢的生物。 -- **[略nd](https://space.bilibili.com/1344099355)**: 🎨 为麦麦绘制早期的精美人设。 -- **[NapCat](https://github.com/NapNeko/NapCatQQ)**: 🚀 现代化的基于 NTQQ 的 Bot 协议实现。 - ---- - -## 📊 仓库状态 - -![Alt](https://repobeats.axiom.co/api/embed/9faca9fccfc467931b87dd357b60c6362b5cfae0.svg "麦麦仓库状态") - -### Star 趋势 -[![Star 趋势](https://starchart.cc/MaiM-with-u/MaiBot.svg?variant=adaptive)](https://starchart.cc/MaiM-with-u/MaiBot) - ---- - -## 📌 注意事项 & License - -> [!IMPORTANT] -> 使用前请阅读 [用户协议 (EULA)](../EULA.md) 和 [隐私协议](../PRIVACY.md)。AI 生成内容请仔细甄别。 - -**License**: GPL-3.0 diff --git a/docs/README_EN.md b/docs/README_EN.md deleted file mode 100644 index 58f4cea2..00000000 --- a/docs/README_EN.md +++ /dev/null @@ -1,155 +0,0 @@ -
- - - 简体中文 | English - -
-
- -

MaiBot MaiSaka

- - -

- Python Version - License - Status - Contributors - Forks - Stars - Ask DeepWiki -

-
- -
- - -MaiBot Character - -## Introduction - -MaiSaka is an interactive agent based on large language models. - -MaiSaka is more than just a bot, and more than a "helpful assistant" that completes tasks. She is a digital life form that tries to understand you and interact in a genuinely human style. She does not pursue perfection or efficiency above all else. She pursues warmth and authenticity. - -- 💭 **No one likes GPT-sounding dialogue**: MaiSaka uses a more natural conversational style. Instead of long-winded markdown-heavy replies, she chats in a way that feels casual, varied, and human. -- 🎭 **No longer stuck in rigid Q&A**: She knows when to speak, how to read the room, when to join a conversation, and when to stay quiet. -- 🧠 **MaiSaka becoming human**: In group conversations, MaiSaka imitates how people around her speak, learns new slang and in-group language, and keeps evolving. -- ❤️ **Always learning more about you**: Inspired by personality theory in psychology, MaiSaka gradually builds an understanding of your preferences, traits, habits, and behavior style. -- 🔌 **Plugin system**: Provides powerful APIs and an event system with virtually unlimited room for extension. - -### Quick Navigation -

- 🌟 Demo Video  |  - 📦 Quick Start  |  - 📃 Core Documentation  |  - 💬 Join Community -

- - -
- - - ---- - -## 🔥 Updates and Installation - -> **Latest Version: v1.0.0** ([📄 Changelog](../changelogs/changelog.md)) - -- **Download**: Visit the [Release](https://github.com/MaiM-with-u/MaiBot/releases/) page to get the latest version. -- **Launcher**: [Mailauncher](https://github.com/MaiM-with-u/mailauncher/releases/) (MacOS only, still in early development). - -| Branch | Description | -| :--- | :--- | -| `main` | ✅ **Stable release (recommended)** | -| `dev` | 🚧 Development testing branch with new features, may be unstable | - -### 📚 Deployment Guide -👉 **[🚀 Latest Deployment Guide](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html)** - ---- - -## 💬 Discussion and Community - -We welcome everyone interested in MaiBot to join us. - -| Category | Group | Description | -| :--- | :--- | :--- | -| **Technical** | [MaiBrain EEG](https://qm.qq.com/q/RzmCiRtHEW) | Technical discussion / Q&A | -| **Technical** | [MaiBrain MRI](https://qm.qq.com/q/VQ3XZrWgMs) | Technical discussion / Q&A | -| **Technical** | [Mai Wants to Be a VTuber](https://qm.qq.com/q/wGePTl1UyY) | Technical discussion / Q&A | -| **Casual Chat** | [Mai Casual Chat Group](https://qm.qq.com/q/JxvHZnxyec) | Casual chat only, no support | -| **Plugin Development** | [Plugin Dev Group](https://qm.qq.com/q/1036092828) | Advanced development and testing | - ---- - -## 📚 Documentation - -> [!NOTE] -> Some content may not be updated promptly, so please pay attention to version compatibility. - -- **[📚 Core Wiki Documentation](https://docs.mai-mai.org)**: The most comprehensive documentation hub for everything about MaiSaka. - -### 🧩 Related Projects - -- **[Amaidesu](https://github.com/MaiM-with-u/Amaidesu)**: Let MaiSaka stream on Bilibili. -- **[MoFox_Bot](https://github.com/MoFox-Studio/MoFox-Core)**: An enhanced fork based on MaiCore 0.10.0, with improved stability and more fun features. -- **[MaiCraft](https://github.com/MaiM-with-u/Maicraft)**: Let MaiSaka accompany you in Minecraft (currently paused). - ---- - -## 💡 Design Philosophy - -> **SengokuCola says:** -> - This project originally started as a few extra features for the NiuNiu bot, but it kept growing until a full rewrite became inevitable. The goal was to create a "life form" active in QQ group chats, not a feature-complete bot, but something as human-like and real-feeling as possible. -> - The core design principle is: "more lifelike, not merely better." -> - If people truly want AI companionship, not everyone needs a perfect "helpful assistant" that solves every problem. Some people may want a life form that can make mistakes and has its own perceptions and thoughts. - -> **xxxxx says:** -> *Code is open, but the soul is yours.* - ---- - -## 🙋 Contributing and Acknowledgments - -Contributions are welcome. Please read the [Contribution Guide](CONTRIBUTE.md) first. - -### 🌟 Contributors - - - contributors - - -### ❤️ Special Thanks - -- **[Sacabambaspis](https://en.wikipedia.org/wiki/Sacabambaspis)**: SengokuCola's favorite creature. -- **[略nd](https://space.bilibili.com/1344099355)**: Drew MaiSaka's beautiful early character design. -- **[NapCat](https://github.com/NapNeko/NapCatQQ)**: A modern NTQQ-based bot protocol implementation. - ---- - -## 📊 Repository Status - -![Alt](https://repobeats.axiom.co/api/embed/9faca9fccfc467931b87dd357b60c6362b5cfae0.svg "MaiBot Repository Status") - -### Star History -[![Star History](https://starchart.cc/MaiM-with-u/MaiBot.svg?variant=adaptive)](https://starchart.cc/MaiM-with-u/MaiBot) - ---- - -## 📌 Notice & License - -> [!IMPORTANT] -> Please read the [End User License Agreement (EULA)](../EULA.md) and [Privacy Policy](../PRIVACY.md) before use. Please evaluate AI-generated content carefully. - -**License**: GPL-3.0 diff --git a/docs/a_memorix_sync.md b/docs/a_memorix_sync.md deleted file mode 100644 index 312fe9f6..00000000 --- a/docs/a_memorix_sync.md +++ /dev/null @@ -1,48 +0,0 @@ -# A_Memorix 同步说明 - -## 当前约定 - -- A_Memorix 主线源码位于 `src/A_memorix` -- 宿主接入层位于 `src/services/memory_service.py`、`src/webui/routers/memory.py` 与 dashboard 长期记忆页面 -- 运行配置位于 `config/a_memorix.toml` -- 运行数据由 `storage.data_dir` 决定(当前配置模板默认 `data/a-memorix/`) -- 旧离线脚本默认目录仍可能落在 `data/plugins/a-dawn.a-memorix/`(见脚本注释/参数说明) -- Web 上传暂存目录为 `data/memory_upload_staging/` -- 上游同步方式固定为 `git subtree` - -## 首次接入 - -```bash -./scripts/sync_a_memorix_subtree.sh add -``` - -默认同步源: - -- 远端:`https://github.com/A-Dawn/A_memorix.git` -- 分支:`MaiBot_branch` -- 前缀:`src/A_memorix` - -## 后续更新 - -```bash -./scripts/sync_a_memorix_subtree.sh pull -``` - -等价命令: - -```bash -git subtree pull --prefix=src/A_memorix https://github.com/A-Dawn/A_memorix.git MaiBot_branch --squash -``` - -## 本地修改边界 - -- `src/A_memorix` 只保留上游源码和必须的宿主兼容补丁 -- 宿主接入、配置暴露、图谱页与控制台页优先放在 MaiBot 侧 host service / memory router / dashboard 页面中 -- 不再通过插件框架特判承载 A_Memorix - -## 同步后检查 - -1. 确认 `config/a_memorix.toml` 中 `storage.data_dir` 与你的运行目录规划一致(默认模板为 `data/a-memorix`) -2. 运行 `python src/A_memorix/scripts/runtime_self_check.py --help` -3. 运行 `python -m pytest pytests/A_memorix_test/test_memory_service.py` -4. 运行 `cd dashboard && npm run test -- src/routes/resource/__tests__/knowledge-base.test.tsx` diff --git a/docs/crowdin_workflow_alignment_brief.md b/docs/crowdin_workflow_alignment_brief.md deleted file mode 100644 index 17a76147..00000000 --- a/docs/crowdin_workflow_alignment_brief.md +++ /dev/null @@ -1,310 +0,0 @@ -# Coding Agent Alignment Brief: GitHub-Side Crowdin Workflow for MaiBot - -## Purpose - -Use this document as the authoritative implementation and maintenance guide for the repository's GitHub-side Crowdin workflow. - -Its purpose is to keep Coding Agent aligned with the agreed operating model and prevent drift from the intended localization strategy. - -This document is normative for: -- GitHub Actions changes related to Crowdin sync and localization PR flow -- branch and PR behavior for localization updates -- repository-side validation behavior for localization assets -- boundaries between baseline workflow logic and repository-specific policy - -This document is **not** a generic Crowdin tutorial. -It describes the expected behavior for this repository specifically. - ---- - -## Core Operating Model - -The repository uses a **PR-based localization return flow**. - -### Source branches covered by localization sync -- `main` -- `r-dev` - -### Localization return branches -- `l10n_main` -- `l10n_r-dev` - -### Return path -- Crowdin translations must **not** be pushed directly into `main` or `r-dev`. -- Translations must return through `l10n_${branch}` branches. -- Localization changes must reach `main` or `r-dev` through pull requests. - -### Source-language assets -The source language is `zh-CN`. -Only source-language assets should trigger source uploads to Crowdin. - -Current source-language assets: -- `locales/zh-CN/*.json` -- `prompts/zh-CN/**/*.prompt` -- `dashboard/src/i18n/locales/zh.json` -- `crowdin.yml` - -### Prompt template extension -The repository currently treats `.prompt` as the only i18n-relevant prompt template extension. -Do not broaden prompt-related workflow triggers unless repository reality changes. - ---- - -## Mandatory Interpretation Rules - -Coding Agent must follow these interpretation rules exactly. - -### Rule 1: Do not reinterpret `l10n_` branches -`l10n_main` and `l10n_r-dev` are service/localization return branches. -They are not feature branches and they are not alternate long-lived development branches. - -### Rule 2: Do not bypass the PR model -Do not change the workflow so that translated assets are committed directly to `main` or `r-dev`. -The agreed model is: -- source branch sync -- Crowdin-side translation availability -- localization return branch update -- PR into the matching base branch -- maintainer review and merge - -### Rule 3: Do not claim approval in Crowdin means immediate GitHub update -A translation being reviewed or approved in Crowdin does **not** mean GitHub is updated instantly. -GitHub reflects Crowdin-side updates only when the sync workflow runs. - -That can happen via: -- push-triggered sync -- scheduled sync -- manual dispatch - -Any documentation, comments, or workflow descriptions produced by Coding Agent must preserve this distinction. - -### Rule 4: Treat GitHub-side content policy as repository-specific, not as a universal Crowdin rule -If the repository enforces extra validation rules on committed target locale content, those rules must be described as **repository-specific policy**. -They must not be described as an inherent or standard requirement of Crowdin's branch model. - -### Rule 5: Preserve source-upload loop prevention -Do not modify the workflow in a way that allows translated target files to trigger a new source upload cycle to Crowdin. -Only source-language assets should trigger source uploads. - ---- - -## Required GitHub Workflow Behavior - -### 1. `crowdin-sync.yml` - -This workflow must remain responsible for: -- uploading source-language assets to Crowdin -- downloading available translations from Crowdin -- creating or updating localization return branches -- creating or updating localization PRs into the matching source branch - -#### Required triggers -The workflow may be triggered by: -- manual dispatch -- schedule -- push to `main` or `r-dev` when source-language assets change - -#### Required branch mapping -The workflow must preserve this mapping: -- `main -> l10n_main -> PR into main` -- `r-dev -> l10n_r-dev -> PR into r-dev` - -#### Required boundary -The workflow must preserve the PR-based return flow. -It must not be converted into direct translation pushes into `main` or `r-dev`. - -#### Required wording discipline -When describing this workflow, Coding Agent must say that it downloads **currently available** Crowdin translations when the workflow runs. -Coding Agent must not imply that source-language pushes and translation return are a single synchronous transaction. - ---- - -### 2. `precheck.yml` - -This workflow must: -- run on pull requests -- check merge/conflict state against the **real PR base branch** - -#### Required base-branch logic -For every PR, conflict simulation must use the actual PR target branch. -Examples: -- feature branch into `main` -> compare against `main` -- feature branch into `r-dev` -> compare against `r-dev` -- `l10n_main` PR -> compare against `main` -- `l10n_r-dev` PR -> compare against `r-dev` - -#### Forbidden behavior -Coding Agent must not reintroduce any logic that hardcodes `main` as the comparison base for all PRs. - -#### Label behavior -If the repository already uses a conflict label, that behavior should remain intact unless there is a clearly documented reason to change it. - ---- - -### 3. `ruff-pr.yml` - -This workflow must remain focused on Python code quality. - -#### Required path discipline -Translation-only localization PRs must not trigger Ruff by default. -Ruff should run only when files relevant to Python code quality or Ruff configuration are changed. - -#### Practical intent -The goal is to reduce CI noise on localization PRs without weakening Python quality checks. - -#### Forbidden behavior -Do not expand Ruff triggers so broadly that translation-only localization PRs start running Ruff again by default. - ---- - -### 4. `i18n-validate.yml` - -This workflow must remain the repository-side structural validation layer for localization changes. - -#### Required validation role -It should validate localization assets and i18n-relevant code changes. -That includes both: -- backend locale JSON under `locales/` -- dashboard locale JSON under `dashboard/src/i18n/locales/` - -#### Prompt trigger scope -It must cover the actual prompt template extension used in the repository. -At present, that means `.prompt`. -Do not broaden the watched prompt-file patterns unless repository reality changes. - -#### Missing prompt behavior -If localized prompt files are intentionally allowed to fall back to `zh-CN` at runtime, workflow and documentation should represent that accurately. -Do not silently convert warning-only behavior into hard-failure behavior unless explicitly instructed. - ---- - -## Repository-Specific Policy Layer - -The repository may enforce extra policy rules on committed target locale files. -Examples may include: -- forbidding unchanged source-language carry-over in target locale files -- forbidding Chinese characters in `en-US` locale files -- placeholder consistency checks -- plural structure checks - -These rules are allowed. -However, Coding Agent must always describe them as: -- repository-specific validation policy -- layered on top of the baseline Crowdin PR workflow - -They must **not** be described as: -- a standard Crowdin requirement -- an inherent property of `l10n_` branches -- something every Crowdin GitHub integration does by default - ---- - -## Documentation Guardrails for Coding Agent - -Whenever Coding Agent writes or updates repository documentation about localization workflow, it must obey the following rules. - -### Always state these facts clearly -- `zh-CN` is the source language -- `main` and `r-dev` are the source branches covered by Crowdin sync -- translations return via `l10n_${branch}` branches -- localization changes reach `main` or `r-dev` through PRs -- GitHub receives translation updates when the sync workflow runs, not necessarily immediately when approval happens in Crowdin - -### Never blur these concepts -Do not conflate: -- Crowdin approval state -- GitHub sync timing -- PR creation/update timing -- PR merge timing - -These are related but distinct steps. - -### Always distinguish baseline workflow from extra repository policy -If describing validation rules beyond branch flow and sync behavior, explicitly mark them as repository-specific policy. - -### Keep statements operational and repository-specific -Do not write generic platform marketing language. -Do not convert this repository's workflow report into a generic localization tutorial. - ---- - -## Change Control Rules - -Coding Agent may adjust workflows only within the following boundaries. - -### Allowed changes -Coding Agent may: -- fix PR base-branch detection bugs -- reduce CI noise for translation-only PRs -- tighten path filters to better match repository reality -- improve workflow descriptions so they do not misrepresent sync timing -- improve documentation clarity around repository-specific validation policy - -### Allowed changes with explicit justification -Coding Agent may change repository-side i18n validation rules only if: -- the change is clearly labeled as repository-specific policy -- the justification is documented -- the change does not misrepresent itself as standard Crowdin behavior - -### Forbidden changes -Coding Agent must not: -- replace the `l10n_${branch}` PR model with direct pushes into source branches -- make translated target files trigger source uploads back to Crowdin -- hardcode `main` as the merge-check target for all PRs -- broaden prompt-file watchers without confirming actual repository usage -- present repository-specific locale policy as if it were part of Crowdin's default model -- imply that approved translations automatically and immediately appear in GitHub without a sync run - ---- - -## Expected End-to-End Flow - -### Flow A: Source-language update -1. A source-language asset change is pushed to `main` or `r-dev`. -2. The Crowdin sync workflow runs. -3. The workflow uploads source-language assets to Crowdin. -4. The workflow may also download any translations currently available in Crowdin. -5. The workflow creates or updates the matching localization return branch. -6. A PR is created or updated from `l10n_${branch}` into the matching base branch. - -### Flow B: Localization PR validation -1. A PR from `l10n_${branch}` into its matching base branch exists. -2. `precheck.yml` validates merge/conflict state against the real PR base branch. -3. `i18n-validate.yml` validates localization structure and repository-specific i18n policy. -4. `ruff-pr.yml` does not run unless the PR touches Python/Ruff-relevant files. -5. Maintainers review and merge the PR. - -### Flow C: Scheduled synchronization -1. The scheduled Crowdin sync workflow runs. -2. It processes the supported source branches. -3. If Crowdin currently has downloadable translation updates, GitHub updates or creates the corresponding `l10n_` branch PRs. - ---- - -## Coding Agent Self-Check Before Making Any Workflow or Docs Change - -Before changing localization-related workflows or documentation, Coding Agent must confirm all of the following: - -1. Am I preserving the `l10n_${branch}` PR-based return model? -2. Am I avoiding any claim that Crowdin approval instantly updates GitHub? -3. Am I keeping source-upload triggers limited to source-language assets? -4. Am I using the real PR base branch for conflict checks? -5. Am I keeping Ruff out of translation-only PRs by default? -6. Am I limiting prompt-file trigger patterns to what the repository actually uses? -7. If I describe extra locale-content rules, did I label them as repository-specific policy rather than standard Crowdin behavior? - -If any answer is no, the change is not aligned and must be revised. - ---- - -## Bottom Line - -The correct GitHub-side localization model for this repository is: -- `zh-CN` is the source language -- `main` and `r-dev` are the source branches -- Crowdin returns translations through `l10n_${branch}` branches -- localization changes reach source branches through PRs -- sync timing depends on GitHub-side workflow execution -- repository-specific locale validation policy may exist, but it must be described as extra policy, not as the default Crowdin model - -Coding Agent must preserve this model and must not drift into a direct-push workflow, a misleading sync description, or an over-generalized explanation of repository-specific validation rules. diff --git a/docs/github-actions-crowdin-workflow-report.md b/docs/github-actions-crowdin-workflow-report.md deleted file mode 100644 index 9357cb19..00000000 --- a/docs/github-actions-crowdin-workflow-report.md +++ /dev/null @@ -1,306 +0,0 @@ -# GitHub-Side Localization Workflow Report - -## Scope - -This document defines the repository-side localization workflow for Option B. -It focuses on GitHub Actions, branch conventions, pull requests, and validation gates around Crowdin. - -This is intentionally repository-specific operational guidance, not a generic Crowdin or GitHub Actions tutorial. - -External prerequisite: - -- the Crowdin project and GitHub Actions secrets must already be configured -- if Crowdin's native GitHub integration exists on the Crowdin side, it must not be used as a second write-back path for this repository - -## Repository Policy - -- `zh-CN` is the only source language of truth in the repository -- target-locale files committed in the repository are synchronization artifacts and reviewable outputs, not the normal long-term editing surface -- existing committed target translations must be bootstrapped into Crowdin once before steady-state sync is trusted -- after bootstrap, Crowdin is the normal editing surface for target translations -- GitHub Actions is the only allowed GitHub-side synchronization mechanism between this repository and Crowdin -- translations return through `l10n_*` pull requests, not direct pushes into `main` or `r-dev` - -## Branch Model - -- source branches covered by the localization workflow: - - `main` - - `r-dev` -- Crowdin return branches: - - `l10n_main` - - `l10n_r-dev` -- merge strategy: - - translations do not go directly into `main` or `r-dev` - - translations are reviewed through pull requests before merge - -## Source of Truth and Trigger Surface - -- source locale for JSON translations: `locales/zh-CN/*.json` -- source locale for prompt templates: `prompts/zh-CN/**/*.prompt` -- source locale for dashboard WebUI translations: `dashboard/src/i18n/locales/zh.json` -- current prompt template extension in the repository: `.prompt` -- dashboard WebUI keeps short runtime locale filenames (`zh`, `en`, `ja`, `ko`) in Git, but `dashboard/src/i18n/locales/zh.json` is still the repository-side `zh-CN` source asset for that file group - -Normal push-triggered source uploads remain strictly source-driven. -Translated target assets are not part of the steady-state upload trigger set. - -## Workflows Involved - -### 1. `crowdin-bootstrap.yml` - -Role: - -- provides a manual bootstrap path for existing committed target translations -- seeds Crowdin from the repository's current target-locale state -- keeps this exceptional upload path separate from normal source-driven sync - -Triggers: - -- manual dispatch only - -Visibility requirement: - -- because this workflow uses `workflow_dispatch`, GitHub only exposes it after the workflow file exists on the repository default branch -- in this repository, maintainers should merge the workflow file into `main` before expecting it to appear in the Actions UI or be runnable through `gh workflow run` - -Inputs: - -- `base_branch`: `main` or `r-dev` -- `confirm_bootstrap`: explicit confirmation string - -Behavior: - -- checks out the selected repository branch -- uploads sources and committed target translations to Crowdin -- does not download translations -- does not create or update `l10n_*` pull requests - -Guardrail: - -- this workflow is intentionally one-time or exceptional -- maintainers must not treat it as a continuous GitHub-to-Crowdin target-translation sync path - -### 2. `crowdin-sync.yml` - -Role: - -- uploads source-language assets to Crowdin -- downloads currently available translations from Crowdin when the workflow runs -- creates or updates localization pull requests back to the matching base branch - -Triggers: - -- manual dispatch -- scheduled sync every 6 hours: `17 */6 * * *` UTC -- push to `main` or `r-dev` when one of these paths changes: - - `crowdin.yml` - - `locales/zh-CN/*.json` - - `prompts/zh-CN/**/*.prompt` - - `dashboard/src/i18n/locales/zh.json` - -Branch behavior: - -- push-triggered runs sync the current Git branch and use a matching localization branch name: - - `main -> l10n_main -> PR into main` - - `r-dev -> l10n_r-dev -> PR into r-dev` -- scheduled runs explicitly cover both `main` and `r-dev` - -Permissions and credentials: - -- `contents: write` -- `pull-requests: write` -- `GITHUB_TOKEN` -- `CROWDIN_PROJECT_ID` -- `CROWDIN_PERSONAL_TOKEN` - -Important boundary: - -- the steady-state workflow keeps the PR-based return flow intact -- normal runs do not upload direct GitHub edits to target-locale files back into Crowdin - -### 3. `i18n-validate.yml` - -Role: - -- runs repository-side localization validation -- blocks structurally invalid or policy-breaking localization changes - -Triggers: - -- pull requests that touch: - - `locales/**/*.json` - - `prompts/**/*.prompt` - - `dashboard/src/i18n/index.ts` - - `dashboard/src/i18n/locales/*.json` - - `scripts/i18n_validate.py` - - `src/common/i18n/**/*.py` - - `src/common/prompt_i18n.py` - - `src/prompt/prompt_manager.py` -- pushes to `main` or `r-dev` for the same path set - -Validation scope: - -- JSON locale key alignment against `zh-CN` -- dashboard nested JSON locale key alignment against `dashboard/src/i18n/locales/zh.json` -- placeholder consistency -- dashboard i18next interpolation placeholder consistency -- plural structure consistency -- prompt placeholder consistency -- English locale protection against Chinese source-language leakage -- rejection of non-`zh-CN` entries that directly preserve Chinese source text - -Prompt behavior note: - -- missing target prompt files currently produce warnings, not hard failures -- runtime still falls back to `zh-CN` prompt templates when localized prompt files are absent - -### 4. `precheck.yml` - -Role: - -- checks whether a pull request conflicts with its real target branch -- preserves the existing conflict-label behavior - -Behavior: - -- checks out the PR head commit -- fetches the actual PR base branch from `github.event.pull_request.base.ref` -- performs a merge simulation against that real base branch -- marks the PR as conflicted only if the merge simulation produces unmerged files - -This means: - -- feature branches into `main` are checked against `main` -- feature branches into `r-dev` are checked against `r-dev` -- `l10n_main` PRs are checked against `main` -- `l10n_r-dev` PRs are checked against `r-dev` - -### 5. `ruff-pr.yml` - -Role: - -- runs Ruff lint and format checks for pull requests that are relevant to Python code quality - -Effect: - -- translation-only localization pull requests do not run Ruff by default -- Python or Ruff-related pull requests still run the existing Ruff checks - -## End-to-End GitHub Flow - -### A. One-time bootstrap of existing target translations - -1. A maintainer chooses `main` or `r-dev` as the branch whose committed target translations should seed Crowdin. -2. The maintainer manually runs `crowdin-bootstrap.yml` with explicit confirmation. -3. The workflow uploads the selected branch's current sources and committed target translations to Crowdin. -4. No `l10n_*` pull request is created by this bootstrap workflow. -5. After bootstrap, target-language maintenance should move to Crowdin as the normal editing surface. - -### B. Normal source-language update on `main` or `r-dev` - -1. A source-language change is pushed to `main` or `r-dev`. -2. `crowdin-sync.yml` uploads source-language assets to Crowdin, including the dashboard WebUI source file `dashboard/src/i18n/locales/zh.json`. -3. The same workflow may also download any translations currently available in Crowdin when that workflow run executes. -4. A localization pull request is opened or updated: - - `l10n_main -> main` - - `l10n_r-dev -> r-dev` - -### C. Translation return flow - -1. Translators work in Crowdin. -2. Repository updates do not appear in `main` or `r-dev` immediately at approval time. -3. Repository write-back happens when `crowdin-sync.yml` runs. -4. GitHub updates or creates `l10n_${branch}` pull requests. -5. Maintainers review and merge the localization pull request in the normal PR flow. - -### D. Scheduled sync - -1. Every 6 hours, GitHub Actions runs a scheduled localization sync. -2. The workflow explicitly processes both `main` and `r-dev`. -3. If Crowdin currently has downloadable translation updates, GitHub updates or creates the corresponding `l10n_` pull requests. - -## How the Setup Avoids Sync Loops - -- source uploads are triggered only from: - - `crowdin.yml` - - `locales/zh-CN/*.json` - - `prompts/zh-CN/**/*.prompt` - - `dashboard/src/i18n/locales/zh.json` -- translated target files do not trigger another steady-state upload cycle -- the bootstrap path is manual and confirmation-gated -- translations return through `l10n_` branches and PRs instead of direct pushes to base branches -- translation-only PRs do not trigger Ruff, which reduces unnecessary CI noise without weakening Python quality gates - -## GitHub-Usable Maintainer Operations - -### Trigger the bootstrap path - -GitHub UI: - -- Actions -> `Crowdin Bootstrap Target Translations` -- if it does not appear yet, first make sure the workflow file has already landed on the default branch (`main`) -- choose `main` or `r-dev` -- set `confirm_bootstrap` to `yes-bootstrap-current-target-translations` - -GitHub CLI: - -```bash -gh workflow run crowdin-bootstrap.yml \ - --ref main \ - -f base_branch=r-dev \ - -f confirm_bootstrap=yes-bootstrap-current-target-translations -``` - -Use this only when seeding Crowdin from already-committed target translations, or in another exceptional recovery scenario. - -### Trigger a normal manual sync - -GitHub UI: - -- Actions -> `Crowdin Sync` -- if a newly added manual workflow does not appear, confirm that workflow file is already on the default branch -- run the workflow on `main` or `r-dev` - -GitHub CLI: - -```bash -gh workflow run crowdin-sync.yml --ref main -``` - -### Inspect workflow runs - -```bash -gh run list --workflow crowdin-sync.yml --limit 5 -gh run list --workflow crowdin-bootstrap.yml --limit 5 -``` - -### Inspect resulting localization pull requests - -```bash -gh pr list --head l10n_main -gh pr list --head l10n_r-dev -``` - -### Verify that GitHub Actions is the repository write-back path - -- confirm there is a successful `crowdin-sync.yml` run corresponding to the latest `l10n_*` PR update -- confirm translated content returned through `l10n_main` or `l10n_r-dev`, not a direct push into `main` or `r-dev` -- do not rely on a separate Crowdin native GitHub integration PR or branch flow for this repository - -## Guardrails That Remain Intact - -- localization PRs are still checked against their real base branch -- repository-side localization validation still runs where expected -- translation-only PRs still avoid unnecessary Ruff noise by default -- Python-impacting PRs still run Python quality gates where appropriate -- the steady-state `zh-CN` source-trigger model remains unchanged - -## Bottom Line - -The GitHub-side localization workflow now supports the intended Option B model: - -- `zh-CN` remains the only repository source language -- existing committed target translations can be bootstrapped into Crowdin once through a manual workflow -- steady-state sync remains source-driven and GitHub Actions-only -- translated content still returns through `l10n_${branch}` pull requests -- existing PR validation and reduced-noise translation PR behavior remain intact diff --git a/docs/i18n.md b/docs/i18n.md deleted file mode 100644 index 38d233fe..00000000 --- a/docs/i18n.md +++ /dev/null @@ -1,224 +0,0 @@ -# i18n Guide - -MaiBot 现在使用 `JSON + Crowdin + Babel` 的国际化方案,不依赖 gettext 的 `.po/.mo` 运行时。 - -## 仓库翻译策略 - -- `zh-CN` 是仓库内唯一的 source language,也是唯一的 GitHub 侧 source of truth。 -- 非 `zh-CN` 的目标语言文件是同步产物和可评审输出,不是常规长期编辑面。 -- 仓库中已经提交过的目标语言文件,需要先通过一次 bootstrap 同步进 Crowdin,避免 Crowdin 把这些历史翻译当成“未翻译”并用 source 文本导出覆盖。 -- bootstrap 完成后,目标语言的常规维护应在 Crowdin 中完成,而不是直接在 GitHub 中持续编辑。 -- GitHub Actions 是仓库与 Crowdin 之间唯一允许的同步方式;不要把 Crowdin 的原生 GitHub integration 当作第二条回写路径。 -- 翻译回流仍然通过 `l10n_*` pull request 完成,不直接写回 `main` 或 `r-dev`。 - -## 目录结构 - -翻译文件位于 `locales//*.json`,当前默认语言是 `zh-CN`。 - -建议按模块拆分文件: - -- `core.json` -- `startup.json` -- `config.json` -- `prompts.json` - -长 Prompt 模板使用单文件本地化目录: - -```text -prompts/ - zh-CN/ - replyer.prompt - planner.prompt -``` - -注意: - -- `prompts/zh-CN/**/*.prompt` 是唯一中文 source 模板目录。 -- 不要把 `zh-CN` 原文整批复制到 `prompts/en-US/` 后直接提交。 -- 目标语言 prompt 文件应该由 Crowdin 下载生成;在本地还没有目标文件时,运行时会自动回退到 `zh-CN`。 - -Dashboard WebUI 的 locale 文件位于 `dashboard/src/i18n/locales/*.json`: - -```text -dashboard/src/i18n/locales/ - zh.json - en.json - ja.json - ko.json -``` - -注意: - -- `dashboard/src/i18n/locales/zh.json` 是 dashboard 在 Git 中的 source of truth。 -- dashboard 运行时继续使用 `zh` / `en` / `ja` / `ko` 这组短 locale code,但 Crowdin 侧仍把 `zh.json` 当作仓库里的 `zh-CN` source 资产来同步。 -- `en.json` / `ja.json` / `ko.json` 是同步产物和可评审输出,不是常规长期手工编辑面。 - -## 在代码中使用 - -统一从 [`src/common/i18n/__init__.py`](../src/common/i18n/__init__.py) 导入: - -```python -from src.common.i18n import t, tn - -logger.info(t("startup.launching_script", script_file=script_file)) -logger.info(tn("core.tasks_cancelled", count)) -``` - -可用能力: - -- `t(key, locale=None, **kwargs)`:普通翻译 -- `tn(key, count, locale=None, **kwargs)`:plural 翻译 -- `set_locale(locale)` / `get_locale()`:设置或读取当前默认 locale -- `format_datetime_localized(...)` -- `format_number_localized(...)` -- `format_decimal_localized(...)` - -Prompt 模板统一从 [`src/common/prompt_i18n.py`](../src/common/prompt_i18n.py) 加载: - -```python -from src.common.prompt_i18n import load_prompt - -template = load_prompt("replyer") -rendered = load_prompt("replyer", identity="Mai", bot_name="麦麦") -``` - -Prompt 加载规则: - -- 优先读取 `prompts/<当前 locale>/` -- 找不到时回退到 `prompts/zh-CN/` - -## locale 优先级 - -运行时按以下顺序决定 locale: - -1. 显式传入 `locale` -2. 当前上下文中的 locale 覆盖(如使用 `use_locale(...)`) -3. 环境变量 `MAIBOT_LOCALE` -4. 默认值 `zh-CN` - -## key 规范 - -- 使用稳定的点分 key,例如 `startup.env_created` -- 全部小写 -- 不要把中文原文直接当 key - -## 日常翻译流程 - -1. 先在 `locales/zh-CN/*.json`、`prompts/zh-CN/**/*.prompt` 或 `dashboard/src/i18n/locales/zh.json` 添加或修改 source 内容。 -2. 在代码中用 `t()` / `tn()` / `load_prompt()` 替换硬编码字符串。 -3. 运行 `python scripts/i18n_validate.py` 校验结构。 -4. 把 source 变更推送到 `main` 或 `r-dev`,或手动触发 [`crowdin-sync.yml`](../.github/workflows/crowdin-sync.yml)。 -5. 目标语言翻译在 Crowdin 中完成。 -6. GitHub Actions 下载当时 Crowdin 中可用的翻译结果,并通过 `l10n_main` / `l10n_r-dev` pull request 回流到仓库。 - -对于非 `zh-CN` 的目标 locale: - -- 下面这两条是本仓库的 repository-specific 校验策略,不是 Crowdin 默认行为。 -- 不要手工把中文 source 文案直接复制进目标语言文件后提交。 -- 英文 locale 文件中不应保留中文字符;这类残留会被校验脚本拦截。 -- Python 项目后续新增的 `ko` locale 也遵循同样规则。 -- `dashboard/src/i18n/locales/en.json` / `ja.json` / `ko.json` 也遵循同样规则。 - -### 什么时候可以直接改目标语言文件 - -以下场景才适合直接在 GitHub 中改非 `zh-CN` 文件: - -- 需要把仓库里已经存在的历史目标语言文件一次性 bootstrap 到 Crowdin。 -- 需要做紧急修复,而且你确认后续会把同样的修改补回 Crowdin,避免下一次同步被覆盖。 - -除了上面这些例外,不要把目标语言文件当作常规编辑入口。 - -## 校验脚本 - -运行: - -```bash -python scripts/i18n_validate.py -``` - -校验内容包括: - -- JSON 语法是否合法 -- 是否存在重复 key -- 是否存在空字符串 key -- 各语言 key 集合是否与 `zh-CN` 对齐 -- dashboard 嵌套 JSON 的 key 集合是否与 `dashboard/src/i18n/locales/zh.json` 对齐 -- 占位符集合是否一致 -- dashboard i18next `{{placeholder}}` 占位符集合是否一致 -- plural 结构是否一致 -- 非 `zh-CN` locale 是否直接保留了包含中文字符的 source 文案 -- prompt 模板已存在时,其占位符集合必须与 `prompts/zh-CN/` 对齐 - -对于 prompt 模板: - -- 缺少目标 locale 文件只会给 warning,不会阻断,因为运行时有 fallback -- 目标 locale 文件如果存在但占位符漂移,会直接校验失败 - -## 候选扫描 - -如果你想继续做下一批迁移,可以运行: - -```bash -python scripts/i18n_extract_candidates.py -``` - -这个脚本会扫描仓库中的 Python 文件,输出仍然包含中文字符串常量的位置,方便人工挑选下一批适合迁移到 i18n 的文案。 - -## Crowdin - -项目根目录的 [`crowdin.yml`](../crowdin.yml) 现在会上传三类 source: - -- `locales/zh-CN/*.json` -- `prompts/zh-CN/**/*.prompt` -- `dashboard/src/i18n/locales/zh.json` - -对于 Python 项目: - -- `locales/zh-CN/*.json` 的目标语言现在也包含韩语,不再排除 `ko` -- `prompts/zh-CN/**/*.prompt` 的目标语言现在也包含韩语,不再排除 `ko` - -对于 dashboard: - -- Crowdin 下载结果会回写到 `dashboard/src/i18n/locales/en.json`、`ja.json`、`ko.json` -- 其中英文文件名保持为 `en.json`,但在 Crowdin 配置里仍映射到 `en-US` - -GitHub Actions 中的 [`crowdin-sync.yml`](../.github/workflows/crowdin-sync.yml) 是日常稳态同步入口: - -- push 到 `main` / `r-dev` 时,只有 source 资产(包括 dashboard 的 `zh.json` source 文件)和 `crowdin.yml` 会触发正常上传。 -- workflow 运行时会上传 source,并下载当时 Crowdin 中可用的翻译结果。 -- 下载结果通过 `l10n_*` pull request 回流,而不是直接写回 `main` / `r-dev`。 - -[`crowdin-bootstrap.yml`](../.github/workflows/crowdin-bootstrap.yml) 是一次性或例外场景使用的 bootstrap 入口: - -- 只能手动触发。 -- 会把仓库当前已提交的目标语言文件上传到 Crowdin,用来保留历史翻译。 -- 不会作为日常 workflow 持续上传 GitHub 中的目标语言改动。 -- 这个 workflow 必须先存在于仓库默认分支后,才会在 GitHub 网页端出现,也才能被 `gh workflow run` 调用。 - -常用命令: - -```bash -python scripts/i18n_validate.py -gh workflow run crowdin-sync.yml --ref main -gh workflow run crowdin-bootstrap.yml --ref main -f base_branch=r-dev -f confirm_bootstrap=yes-bootstrap-current-target-translations -gh run list --workflow crowdin-sync.yml --limit 5 -gh pr list --head l10n_r-dev -``` - -更完整的 GitHub 侧操作说明见 [`docs/github-actions-crowdin-workflow-report.md`](./github-actions-crowdin-workflow-report.md)。 - -## 当前迁移范围 - -这一批已经覆盖: - -- `bot.py` 启动、重启、退出与协议确认提示 -- `src/config` 中第一批配置加载、热重载、校验异常提示 -- `src/main.py` 的主要启动链路提示 -- `src/common/prompt_i18n.py` 的 locale 感知 Prompt 加载 -- `prompts//` 的单文件 Prompt 模板结构 - -仍然可以继续做但这次没有全量翻译的内容: - -- 所有 Prompt 模板的高质量英文翻译 -- 内部协议字段 -- debug-only 文案 diff --git a/docs/minimal-cross-platform-plan.md b/docs/minimal-cross-platform-plan.md deleted file mode 100644 index 2f0a86bd..00000000 --- a/docs/minimal-cross-platform-plan.md +++ /dev/null @@ -1,577 +0,0 @@ -# Minimal Cross-Platform Runtime Plan - -## Goal - -Make MaiBot runtime identity handling platform-aware with the smallest safe change set. - -Success means: - -- One canonical `is_bot_self(platform, user_id)` with no argument-order traps. -- Platform-correct outbound bot sender IDs for configured platforms. -- Platform-aware bot filtering in stored message queries. -- No false assumption that QQ is the universal bot identity for unknown platforms. -- Existing QQ configuration and WebUI behavior preserved without regressions. - -This pass does **not** try to clean every QQ-oriented string, thread platform through PFC internals, or touch user-visible formats. - -## Out of Scope for This Plan - -The following are explicitly deferred because they are not required for runtime correctness in this pass: - -- **PFC platform propagation.** `ChatObserver._message_to_dict` currently omits platform from the serialized `user_info` dict; `observation_info.dict_to_session_message` reads `user_info_dict.get("platform", "")` which is always `""`. Fixing this cascades into `chat_observer.py`, `chat_states.py`, `observation_info.py`, and possibly `conversation.py`. Deferred to a follow-up plan. -- **PFC-internal identity checks.** Sites like `reply_checker.py:61`, `action_planner.py:157`, and `chat_observer.py:337` (inside `process_chat_history`, which has **no in-repo call sites**) operate on PFC serialized dicts where platform is always `""`. They cannot be migrated to `is_bot_self(platform, user_id)` until PFC serialization is fixed. Deferred. -- **PFC prompt platformization.** Replacing `QQ私聊`/`QQ聊天` in `action_planner.py`, `reply_generator.py`, `pfc.py`, and `emoji_plugin` requires threading platform through PFC prompt construction context. Deferred. -- MCP permission ID format changes (`qq:...` -> `{platform}:...`). -- Capability API default changes (`args.get("platform", "qq")` -> `"all_platforms"`). -- Dashboard/UI/config label cleanup. -- Config field renames (`enable_qq_tools`, `qq_api_base_url`, etc.). -- A new local WebUI bot identity constant (`WEBUI_BOT_USER_ID`). -- Historical database migration. - -## Verified Current State - -This plan is based on the checked-in code, not on assumptions from previous drafts. - -### Bot-Self Logic - -| Location | Signature | Status | -|----------|-----------|--------| -| `src/common/utils/system_utils.py:2` | `is_bot_self(user_id, platform)` | Stub; only matches literal `"bot_self"` + `"test_platform"` — never true in production | -| `src/chat/utils/utils.py:69` | `is_bot_self(platform, user_id)` | Real implementation; has dangerous QQ fallback at lines 112-113 | -| `src/person_info/person_info.py:247` | `_is_bot_self(self, platform, user_id)` | Duplicate logic with same QQ fallback | - -Wrong-order call sites (8 total): -- `src/learners/expression_learner.py` x3 (lines 158, 241, 301) -- `src/common/utils/utils_message.py` x4 (lines 370, 440, 476, 515) -- `src/webui/routers/chat/support.py` x1 (line 65) - -Correct-order callers (verify only, do not modify): -- `src/chat/replyer/private_generator.py` (lines 680, 816) -- `src/chat/replyer/group_generator.py` (line 831) -- `src/chat/planner_actions/planner.py` (line 245) -- `src/memory_system/chat_history_summarizer.py` (line 368) - -### PFC Serialization Gap (Known, Not Fixed Here) - -`ChatObserver._message_to_dict()` (lines 30-45) serializes messages **without** platform in `user_info`: - -```python -"user_info": { - "user_id": message.user_id, - "user_nickname": message.user_nickname, - # NOTE: no "platform" key -}, -``` - -Meanwhile, `observation_info.dict_to_session_message()` (line 30) reads: -```python -platform = user_info_dict.get("platform", "") # always "" due to above gap -``` - -**Consequence:** PFC-internal identity checks that operate on these serialized dicts cannot use `is_bot_self(platform, user_id)` until this gap is closed. Those sites are excluded from this plan entirely. - -### WebUI Compatibility Reality - -- Local WebUI bot messages are stored with `platform="webui"` and `user_id=str(global_config.bot.qq_account)`. -- `is_bot_self` already treats `platform == "webui"` as comparing against `qq_account`. -- `support.py` contains additional heuristic fallbacks built around this sender shape. - -This plan preserves this behavior. Introducing a new WebUI bot ID would require transition logic in both Python and SQL and is deferred. - -### Sender Construction Sites - -Outbound bot sender IDs are hard-coded to `global_config.bot.qq_account` in: -- `src/services/send_service.py:91` (has `target_stream.platform`) -- `src/chat/replyer/group_generator.py:1125` (has `self.chat_stream.platform`) -- `src/chat/replyer/private_generator.py:965` (has `self.chat_stream.platform`) -- `src/chat/brain_chat/PFC/message_sender.py:44` (has chat stream context) - -All have platform available at runtime. - -## Design Rules - -1. Prefer compatibility over cleanliness. -2. Fix runtime identity and sender correctness only at sites where `platform` is already available. -3. Do not thread platform through PFC serialization or prompt construction in this pass. -4. Do not introduce new user-visible formats unless required for runtime correctness. -5. Do not key implementation on `ChatObserver.process_chat_history` (no in-repo call sites). -6. Preserve `bot.qq_account` config — no schema migration. - -## Identity Matrix (This Plan) - -| Runtime context | Canonical bot user ID | -|-----------------|-----------------------| -| `platform == "qq"` and `qq_account` configured | `str(global_config.bot.qq_account)` | -| `platform == "telegram"` | `platforms["tg"]` or `platforms["telegram"]` | -| `platform == "webui"` | `str(global_config.bot.qq_account)` (current storage reality) | -| Other configured adapter platforms | `platforms[platform]` | -| Unknown / unconfigured platform | no account; `is_bot_self` returns `False` + warning | - -`qq_account in {None, "", 0, "0"}` means QQ bot identity is **not configured**. `get_bot_account("qq")` returns `""` and `is_bot_self("qq", any_id)` returns `False` in that case. - ---- - -## Phase 0: Unify `is_bot_self` - -### Objective - -Make `src/chat/utils/utils.py::is_bot_self(platform, user_id)` the only real implementation, and remove the argument-order trap. - -### Allowed Files - -- `src/common/utils/system_utils.py` -- `src/chat/utils/utils.py` -- `src/person_info/person_info.py` -- `src/learners/expression_learner.py` -- `src/common/utils/utils_message.py` -- `src/webui/routers/chat/support.py` -- tests - -### Required Changes - -> **ATOMICITY CONSTRAINT:** Steps 1 and 2 MUST be committed together. Do NOT commit step 2 without all step 1 fixes. A partial commit creates a silent semantic inversion: callers passing `(platform, user_id)` to a function that still interprets them as `(user_id, platform)`. The stub always returns `False` in production so the bug would be silent — no crash, just wrong results. - -1. Convert `src/common/utils/system_utils.py::is_bot_self` into a thin wrapper with signature `(platform, user_id)` that delegates to `src.chat.utils.utils.is_bot_self`. **Use a method-local import** to avoid circular dependency (`system_utils -> chat.utils.utils -> message -> mai_message_data_model -> utils_message -> system_utils`). -2. Fix all 8 wrong-order call sites — swap arguments to `(platform, user_id)`: - - | File | Line | Current Call | Required Fix | - |------|------|--------------|--------------| - | `expression_learner.py` | 158 | `is_bot_self(msg...user_id, msg.platform)` | swap to `(msg.platform, msg...user_id)` | - | `expression_learner.py` | 241 | `is_bot_self(target_msg...user_id, target_msg.platform)` | swap | - | `expression_learner.py` | 301 | `is_bot_self(current_msg...user_id, current_msg.platform)` | swap | - | `utils_message.py` | 370 | `is_bot_self(user_id, platform)` | swap | - | `utils_message.py` | 440 | `is_bot_self(user_id, platform)` | swap | - | `utils_message.py` | 476 | `is_bot_self(user_id, platform)` | swap | - | `utils_message.py` | 515 | `is_bot_self(user_id, platform)` | swap | - | `support.py` | 65 | `is_bot_self(user_id, msg.platform)` | swap + change import source to `src.chat.utils.utils` | - -3. Replace `Person._is_bot_self` body with delegation to the canonical function. Use a **method-local import** inside `person_info.py` to avoid a new import cycle. -4. Do not change identity semantics yet (QQ fallback deletion is Phase 1). -5. Update existing test mocks: `pytests/utils_test/message_utils_test.py:203` defines `dummy_is_bot_self(user_id, platform)` with the **old** argument order. After swapping call sites, this mock must be updated to `dummy_is_bot_self(platform, user_id)` and the injection at line 235 must match. - -### Acceptance Criteria - -- Only one real implementation remains: `src/chat/utils/utils.py::is_bot_self`. -- No runtime caller passes `(user_id, platform)`. -- `system_utils.is_bot_self` is a compatibility wrapper only, using a **local import** (not top-level). -- `Person._is_bot_self` delegates instead of duplicating logic. -- Tests cover QQ, Telegram, WebUI, and unknown platform cases. -- Existing test mocks updated to match new argument order. - ---- - -## Phase 1: Identity Resolution + Sender Construction + Trivial Prompt Cleanup - -> **REVIEW NOTE (Claude + Codex joint review, 2026-03-15):** Original plan had Phase 1 (identity resolution) and Phase 2 (sender construction) as separate phases. Joint review identified a **critical regression window**: Phase 1 deletes the QQ fallback (lines 112-113), but sender construction still hard-codes `qq_account` until Phase 2. Between the two phases, `is_bot_self("telegram", qq_account)` returns `False` for existing stored bot messages, breaking `filter_bot`, stats, and identity checks on non-QQ platforms. **Resolution: merge into a single atomic phase.** - -### Objective - -Create one canonical place to resolve bot accounts for any runtime platform, delete the dangerous unknown-platform QQ fallback, make `filter_bot` platform-aware, fix sender construction, and clean up trivial prompt wording. Only touch sites where `platform` is already available at runtime — do **not** touch PFC internals. - -> **ATOMICITY CONSTRAINT:** The QQ fallback deletion (lines 112-113) and sender construction fixes (4 sites) MUST be in the same commit. Deleting the fallback without fixing senders creates a regression for any non-QQ platform where bot messages are stored with `qq_account`. - -### Allowed Files - -- `src/chat/utils/utils.py` -- `src/chat/planner_actions/planner.py` -- `src/chat/utils/statistic.py` -- `src/common/message_repository.py` -- `src/webui/routers/chat/support.py` -- `src/services/send_service.py` -- `src/chat/replyer/group_generator.py` -- `src/chat/replyer/private_generator.py` -- `src/chat/brain_chat/PFC/message_sender.py` -- `src/person_info/person_info.py` -- tests - -### Required Helper Additions (in `src/chat/utils/utils.py`) - -```python -def get_bot_account(platform: str) -> str -def get_all_bot_accounts() -> dict[str, str] -``` - -- `get_bot_account` **replaces** `get_current_platform_account`. After Phase 1, `get_current_platform_account` must not exist. Update all its callers (including `is_mentioned_bot_in_message` at line 127). -- `get_all_bot_accounts()` returns only configured, non-empty runtime identities. When `qq_account` is configured and non-zero, include: - - `"qq": str(qq_account)` - - `"webui": str(qq_account)` (current storage reality) - - plus any configured external platform accounts from `bot.platforms` -- `parse_platform_accounts` remains as internal helper. -- Normalize platform strings: apply `.lower().strip()` in `is_bot_self`, `get_bot_account`, **and `parse_platform_accounts`** (on parsed keys). This ensures config values like `"TG:123"` are stored as key `"tg"` and matched correctly. - -No classes, registries, managers, or generalized identity frameworks. - -### Required Identity Semantics Update - -Update `is_bot_self` to follow the Identity Matrix. Key change: - -**Delete lines 112-113** (the `return user_id_str == qq_account` fallback for unknown platforms). Replace with `return False` + `logger.warning(...)`. This is the single most important line deletion in the entire plan. - -### Direct Comparison Replacements - -| File | Line | Current Code | Action | -|------|------|-------------|--------| -| `utils.py` | 88 | inside `is_bot_self` | **KEEP** — internal to canonical function | -| `utils.py` | 124 | inside `is_mentioned_bot_in_message` | **UPDATE** — use `get_bot_account(platform)` | -| `planner.py` | 125 | `platform = message.platform or "qq"` | **UPDATE** — see below | -| `planner.py` | 138 | `if user_id == global_config.bot.qq_account:` | **REPLACE** with `is_bot_self(platform, str(user_id))` | -| `statistic.py` | 2111 | `str(global_config.bot.qq_account)` | **REPLACE** — see below | -| `message_repository.py` | 166 | `Messages.user_id != global_config.bot.qq_account` | **UPDATE** — see below | -| `support.py` | 68 | `user_id == str(global_config.bot.qq_account)` | **REPLACE** with `is_bot_self(msg.platform, user_id)` | - -### Excluded from This Phase (PFC-Internal, Platform Unavailable) - -| File | Line | Current Code | Why excluded | -|------|------|-------------|-------------| -| `chat_observer.py` | 337 | `user_info.user_id == global_config.bot.qq_account` | `process_chat_history` has no call sites; PFC dict lacks platform | -| `reply_checker.py` | 61 | `str(user_info.user_id) == str(global_config.bot.qq_account)` | Operates on PFC dicts without platform | -| `action_planner.py` | 157 | `bot_id = str(global_config.bot.qq_account)` | PFC-internal; platform not propagated | - -### Sender Construction Sites (Now Part of This Phase) - -> These were originally deferred to a separate Phase 2. After joint review, they are merged into Phase 1 to prevent a regression window. See Sender Construction Changes section below for the required fixes. - -| File | Line | Current Code | Action | -|------|------|-------------|--------| -| `send_service.py` | 91 | `user_id=str(global_config.bot.qq_account)` | **REPLACE** with `get_bot_account(target_stream.platform)` | -| `group_generator.py` | 1125 | `user_id=str(global_config.bot.qq_account)` | **REPLACE** with `get_bot_account(self.chat_stream.platform)` | -| `private_generator.py` | 965 | `user_id=str(global_config.bot.qq_account)` | **REPLACE** with `get_bot_account(self.chat_stream.platform)` | -| `message_sender.py` | 44 | `user_id=global_config.bot.qq_account` | **REPLACE** with `get_bot_account(platform)` from chat stream context | - -### Detailed Instructions - -#### `planner.py:125` — Empty Platform Handling - -```python -# BEFORE: -platform = message.platform or "qq" - -# AFTER: -platform = message.platform or "" -if not platform: - logger.warning("planner: message has no platform set, bot-self detection will be skipped") -``` - -Instead of falsely assuming QQ, an empty platform means `is_bot_self("", user_id)` returns `False`. This is safer than falsely matching a QQ account that may belong to a real user on another platform. - -#### `statistic.py:2109-2151` - -```python -# BEFORE (lines 2109-2114): -bot_qq_account = ( - str(global_config.bot.qq_account) - if hasattr(global_config, "bot") and hasattr(global_config.bot, "qq_account") - else "" -) - -# AFTER: -from src.chat.utils.utils import is_bot_self -``` - -```python -# BEFORE (line 2151): -if bot_qq_account and message.user_id == bot_qq_account: - total_replies[interval_index] += 1 - -# AFTER: -if is_bot_self(message.platform or "", message.user_id or ""): - total_replies[interval_index] += 1 -``` - -Place the import locally to avoid circular import risk. `Messages` records have a `.platform` field so this handles all platforms. - -#### `support.py:62-70` — WebUI Bot Detection - -```python -# CURRENT (after Phase 0 arg-order fix): -is_bot = is_bot_self(msg.platform, user_id) - -if not is_bot and group_id and group_id.startswith(VIRTUAL_GROUP_ID_PREFIX): - is_bot = user_id == str(global_config.bot.qq_account) # QQ-only fallback -elif not is_bot: - is_bot = not user_id.startswith(WEBUI_USER_ID_PREFIX) # reverse heuristic -``` - -After Phase 1, `is_bot_self` correctly handles all current platform cases: -- WebUI local chat: `is_bot_self("webui", str(qq_account))` returns `True`. -- Virtual-group mode: `msg.platform` carries the simulated platform (e.g., `"qq"`), so `is_bot_self` uses the correct platform account. - -The entire if/elif block becomes dead code. - -**Target code:** -```python -is_bot = is_bot_self(msg.platform, user_id) -``` - -Delete the if/elif fallback block. After Phase 1, `is_bot_self` handles all current platform cases correctly, making this block dead code. If a smoke test disproves this assumption, stop and report instead of keeping the fallback. - -#### `filter_bot` in `message_repository.py:166` - -Current code has a type mismatch: `Messages.user_id` is string, `global_config.bot.qq_account` is `int`. - -**NOTE:** `message_repository.py` does not currently import `or_`, `and_`, or `not_` from SQLAlchemy (only `func` at line 7). These imports must be added. - -```python -# AFTER: -if filter_bot: - from src.chat.utils.utils import get_all_bot_accounts - bot_accounts = get_all_bot_accounts() - if bot_accounts: - from sqlalchemy import or_, and_, not_ - bot_identity_predicate = or_( - *[ - and_(Messages.platform == plat, Messages.user_id == acct) - for plat, acct in bot_accounts.items() - ] - ) - conditions.append(not_(bot_identity_predicate)) - # If no bot accounts configured, skip bot filtering (no-op). -``` - -> **EDGE CASE:** `or_()` with zero arguments raises `TypeError` in SQLAlchemy. The `if bot_accounts` guard is mandatory. - -**WebUI compatibility:** `get_all_bot_accounts()` includes `{"webui": str(qq_account), ...}`. Since WebUI bot messages are stored with `platform="webui"` and `user_id=str(qq_account)`, the pair correctly matches them. No regression. - -### Acceptance Criteria - -- `is_bot_self` no longer falls back to QQ for unknown platforms (lines 112-113 deleted). -- `get_bot_account(platform)` exists and resolves configured platform accounts. -- `get_current_platform_account` is deleted — no callers remain. -- `get_bot_account("qq") == ""` when `qq_account` is unconfigured. -- `is_bot_self("webui", str(qq_account))` returns `True` (preserves current behavior). -- `filter_bot=True` excludes configured bot identities by `(platform, user_id)` pairs. -- `statistic.py` uses `is_bot_self` for reply counting. -- `support.py` uses `is_bot_self` without reverse heuristic fallbacks. -- PFC-internal sites are **not changed**. - -### Sender Construction Changes (formerly Phase 2) - -Replace hard-coded `global_config.bot.qq_account` sender IDs with `get_bot_account(platform)`: - -| File | Line | Platform Source | -|------|------|----------------| -| `send_service.py` | 91 | `target_stream.platform` | -| `group_generator.py` | 1125 | `self.chat_stream.platform` | -| `private_generator.py` | 965 | `self.chat_stream.platform` | -| `message_sender.py` | 44 | available from chat stream context | - -For this pass: -- Local WebUI resolves to `str(qq_account)` via `get_bot_account("webui")` — same value as today, no regression. -- WebUI virtual-group sessions carry the simulated platform, so `get_bot_account(self.chat_stream.platform)` produces the correct platform-specific account. -- Do **not** introduce `WEBUI_BOT_USER_ID`. - -### Trivial Prompt Cleanup - -These sites use QQ-specific wording but need only text replacement, no runtime platform info: - -| File | Line(s) | Current Text | Change | -|------|---------|-------------|--------| -| `person_info.py` | 734 | `用户的qq昵称是` | `用户的昵称是` | -| `person_info.py` | 735 | `用户的qq群昵称名是` | `用户的群昵称名是` | -| `person_info.py` | 737 | `用户的qq头像是` | `用户的头像是` | -| `person_info.py` | 742 | `qq昵称或群昵称` (multiple) | Remove `qq` prefix, keep `昵称` and `群昵称` | - -### Prompt Sites Deferred (Require Platform Threading) - -| File | Line(s) | Text | Why deferred | -|------|---------|------|-------------| -| `action_planner.py` | 22, 55, 89 | `QQ私聊` / `QQ 私聊` | Needs platform in PFC prompt context | -| `reply_generator.py` | 18, 43, 68 | `QQ私聊` | Same | -| `pfc.py` | 125, 254 | `QQ聊天` | Same | -| `emoji_plugin/plugin.py` | 66 | `你正在进行QQ聊天` | Needs plugin message context | - -### Known Behavior Changes - -| Site | Current Behavior | New Behavior | Risk | -|------|-----------------|--------------|------| -| `planner.py:138` | `user_id == global_config.bot.qq_account` compares string to int — always `False` (existing bug) | `is_bot_self(platform, str(user_id))` — correctly identifies bot | **Fixes existing bug**, but changes behavior. May cause bot name to render as `{nickname}(你)` where it previously showed the raw user reference. | - -### Acceptance Criteria - -- `is_bot_self` no longer falls back to QQ for unknown platforms (lines 112-113 deleted). -- `get_bot_account(platform)` exists and resolves configured platform accounts. -- `get_current_platform_account` is deleted — no callers remain. -- `get_bot_account("qq") == ""` when `qq_account` is unconfigured. -- `is_bot_self("webui", str(qq_account))` returns `True` (preserves current behavior). -- `filter_bot=True` excludes configured bot identities by `(platform, user_id)` pairs. -- `statistic.py` uses `is_bot_self` for reply counting. -- `support.py` uses `is_bot_self` without reverse heuristic fallbacks. -- PFC-internal sites are **not changed**. -- Outbound QQ sender IDs still use `qq_account`. -- Outbound Telegram and other configured platform sender IDs use that platform's configured account. -- Outbound local WebUI sender IDs remain `str(qq_account)` — compatible with current stored history. -- No sender construction path hard-codes `global_config.bot.qq_account`. -- `person_info.py` nickname/avatar prompts use neutral wording (`昵称`, not `qq昵称`). -- No PFC-internal prompt files are modified. - ---- - -## Implementation Order - -1. **Phase 0** — unify `is_bot_self` (signature + argument order only, no semantic changes) -2. **Phase 1** — identity resolution, QQ fallback deletion, `filter_bot`, direct comparison cleanup, sender construction, trivial prompt cleanup - -Do not start Phase 1 until Phase 0 is committed and verified. - ---- - -## Hard Execution Contract - -This section is written for code-generation agents. If any rule here conflicts with a generic agent preference, **this section wins**. - -### Required Workflow - -1. Read every file that will be modified in full before editing. Read at least 30 lines above and below any target line. -2. Check `git status --short` before the first edit of a phase. If a file **in the current phase allowlist** has unrelated user changes, stop and report. Unrelated changes in files outside the allowlist (e.g., `docs/`) do not block the phase. -3. One phase per commit or one clearly isolated change batch. -4. Run the search checklist after each phase. -5. Produce a short phase report before moving on (files changed, searches before/after, tests run, residual hits). - -### Allowed to Do - -- Read any file needed to confirm current behavior. -- Edit only files in the **current phase allowlist**, plus narrowly scoped tests. -- Add local imports or compatibility wrappers to avoid circular imports. -- Fix additional call sites of the **same semantic bug pattern** if within the phase allowlist. -- Stop after completing the current phase. - -### Forbidden to Do - -- Do not edit files outside the current phase allowlist. -- Do not bundle Phase 0 and Phase 1 into one commit. -- Do not touch PFC serialization files (`chat_observer.py._message_to_dict`, `chat_states.py`, `observation_info.py`, `conversation.py`). -- Do not introduce `WEBUI_BOT_USER_ID` or any new WebUI bot identity constant. -- Do not change capability query API defaults (`args.get("platform", "qq")`). -- Do not change MCP permission context ID format. -- Do not change data models, adapter protocol, or config schema. -- Do not perform repo-wide formatting, lint churn, comment churn, or opportunistic refactors. -- Do not use destructive git commands (`git checkout -- .`, `git reset --hard`, etc.). -- Do not guess platform values — if `platform` is empty, treat as unknown, never substitute `"qq"`. -- Do not treat `qq_account == 0` as a real QQ bot identity. -- Do not create `get_bot_account` alongside `get_current_platform_account` — the old function must be deleted. -- Do not implement `filter_bot` as `Messages.user_id NOT IN (...)` — use `(platform, user_id)` pair matching. - -### Must Stop and Report - -Stop immediately and report instead of improvising if: - -- a required fix needs a file outside the current phase allowlist -- a file **in the current phase allowlist** already contains unrelated user changes -- the checked-in code no longer matches the plan's assumed signatures or control flow -- a circular import cannot be avoided with a local import or wrapper -- a search reveals a new runtime platform-coupling category not covered by the current phase -- tests fail twice and the root cause is still unclear - -When stopping, name: the exact file(s), the blocking mismatch, why it is outside scope, and the smallest safe next step. - -### Per-Phase File Allowlist - -| Phase | Allowed files | -|-------|---------------| -| Phase 0 | `src/common/utils/system_utils.py`, `src/chat/utils/utils.py`, `src/person_info/person_info.py`, `src/learners/expression_learner.py`, `src/common/utils/utils_message.py`, `src/webui/routers/chat/support.py`, tests (including `pytests/utils_test/message_utils_test.py`) | -| Phase 1 | `src/chat/utils/utils.py`, `src/chat/planner_actions/planner.py`, `src/chat/utils/statistic.py`, `src/common/message_repository.py`, `src/webui/routers/chat/support.py`, `src/services/send_service.py`, `src/chat/replyer/group_generator.py`, `src/chat/replyer/private_generator.py`, `src/chat/brain_chat/PFC/message_sender.py`, `src/person_info/person_info.py`, tests | - -### INVALID OUTPUT EXAMPLES - -Any of the following means the implementation has drifted and must be rejected: - -- Editing PFC serialization files (`chat_observer.py._message_to_dict`, `observation_info.py`, `chat_states.py`) -- Introducing `WEBUI_BOT_USER_ID` constant -- Changing WebUI sender storage to anything other than `str(qq_account)` -- `get_current_platform_account` still existing after Phase 1 -- `is_bot_self` returning `True` for unknown platforms (lines 112-113 still present) -- Implementing `filter_bot` as `Messages.user_id.notin_(...)` -- Changing `args.get("platform", "qq")` in capability query APIs -- Modifying PFC prompt strings (`QQ私聊`, `QQ聊天`) in this plan -- Editing Phase 1 files while Phase 0 is incomplete -- Introducing `PlatformRegistry`, `BotIdentityManager`, or similar abstractions -- Deleting the QQ fallback (lines 112-113) without simultaneously fixing all 4 sender construction sites - ---- - -## Search Checklist - -Run before and after each phase: - -```bash -rg -n "def is_bot_self|_is_bot_self|is_bot_self\(" src -rg -n "global_config\.bot\.qq_account" src -rg -n "get_current_platform_account|get_bot_account|get_all_bot_accounts" src -rg -n 'filter_bot|filter_mai' src -rg -n 'qq昵称|qq群昵称|qq头像' src -``` - -### Expected Residual Hits After All Phases - -| Pattern | File | Why it remains | -|---------|------|---------------| -| `global_config.bot.qq_account` | `src/chat/utils/utils.py` | Internal to `is_bot_self` and `get_bot_account` | -| `global_config.bot.qq_account` | `src/chat/brain_chat/PFC/chat_observer.py` | PFC-internal, `process_chat_history` has no call sites — deferred | -| `global_config.bot.qq_account` | `src/chat/brain_chat/PFC/reply_checker.py` | PFC-internal, platform not available — deferred | -| `global_config.bot.qq_account` | `src/chat/brain_chat/PFC/action_planner.py` | PFC-internal, platform not available — deferred | -| `QQ私聊` / `QQ聊天` | PFC prompt files | Requires platform threading into PFC context — deferred | -| `进行QQ聊天` | `emoji_plugin/plugin.py` | Requires plugin context investigation — deferred | -| `platform or "qq"` | capability query APIs | Default semantics change — deferred | - ---- - -## Known Issues Identified During Review - -> Added during Claude + Codex joint review (2026-03-15). These are pre-existing issues or edge cases discovered during plan review that do not block this plan but must be tracked. - -### 1. WebUI Virtual-Group Session ID Mismatch (Pre-existing Bug) - -`ChatHistoryManager._resolve_session_id()` at `support.py:84` always hashes with `WEBUI_CHAT_PLATFORM` ("webui"), but virtual-identity messages are created with the simulated platform (e.g., "telegram") at `support.py:341` and stored under `SessionUtils.calculate_session_id(message.platform, ...)` at `bot.py:316-317`. These produce different session IDs. This means history retrieval for virtual-group sessions may not find the stored messages. **This is a pre-existing bug, not introduced by this plan.** The plan's `support.py` heuristic deletion should include a smoke test of virtual-group mode to confirm behavior. - -### 2. `filter_bot` Legacy Data Contingency - -The new `(platform, user_id)` pair matching in `filter_bot` will not catch historical rows where `platform` is empty or inconsistent. Current write paths reject empty platform at `chat_manager.py:128-131` before storage, so this is unlikely for recent data. However, if the database contains legacy rows from earlier versions with empty `platform`, those bot messages will no longer be filtered. **Recommendation:** Before deploying, run `SELECT DISTINCT platform FROM mai_messages WHERE user_id = '{qq_account}'` to verify data distribution. If empty-platform rows exist, consider a one-time data migration or add `("", str(qq_account))` to `get_all_bot_accounts()`. - -### 3. `parse_platform_accounts` Key Normalization (Fixed by This Plan) - -`parse_platform_accounts()` at `utils.py:31` historically called `.strip()` on keys but not `.lower()`. This plan adds `.lower().strip()` normalization in `parse_platform_accounts`, `is_bot_self`, and `get_bot_account` (see Phase 1 Required Helper Additions), resolving this gap. - ---- - -## Phase Gates - -Every phase must pass before the next starts: - -1. Read every file that will be modified in full. -2. Record the search checklist output before editing. -3. Make only the changes required for the current phase. -4. Record the search checklist output after editing. -5. Run tests or smoke checks. -6. Verify no unrelated file was modified. -7. **Phase 1 only:** Before committing, run `SELECT DISTINCT platform FROM mai_messages WHERE user_id = '{qq_account}'` (or equivalent) to verify stored platform distribution. If empty-platform bot rows exist, add `("", str(qq_account))` to `get_all_bot_accounts()` as a legacy compatibility entry and document this in the phase report. -8. Produce a short phase report. - ---- - -## Definition of Done - -This plan is complete when: - -- `is_bot_self(platform, user_id)` has one real implementation with no argument-order traps -- The unknown-platform QQ fallback (lines 112-113) is deleted -- `get_bot_account(platform)` exists; `get_current_platform_account` is deleted -- Sender construction uses `get_bot_account(platform)` at all 4 sites -- `filter_bot=True` uses platform-aware `(platform, user_id)` pair matching -- `person_info.py` prompts use neutral wording -- No regression in WebUI bot message storage or filtering -- User-visible format changes and PFC platform propagation remain deferred - ---- - -## Follow-Up Plan Topics - -These are explicitly deferred and should be addressed in subsequent plans: - -1. **PFC platform propagation** — Add `platform` to `chat_observer._message_to_dict()` serialization, update consumers in `chat_states.py`, `observation_info.py`, `conversation.py`. Prerequisite for migrating PFC-internal identity checks. -2. **PFC-internal identity migration** — After propagation is fixed, migrate `reply_checker.py:61`, `action_planner.py:157`, and other PFC dict-based bot checks to `is_bot_self`. -3. **PFC prompt platformization** — Replace `QQ私聊`/`QQ聊天` with platform-aware wording after platform is available in PFC prompt context. -4. **WebUI identity separation** — Introduce `WEBUI_BOT_USER_ID`, update sender construction, add dual-acceptance transition period. -5. **Capability query API defaults** — Change `args.get("platform", "qq")` to `args.get("platform", "all_platforms")`. -6. **MCP permission format** — Change context IDs from `qq:{id}:...` to `{platform}:{id}:...`. -7. **UI/config cleanup** — Dashboard labels, setup flow, adapter naming, config field renames. -8. **WebUI virtual-group session ID fix** — `ChatHistoryManager._resolve_session_id()` always uses `"webui"` platform, but virtual messages are stored with simulated platform. Session IDs mismatch — needs investigation and fix. diff --git a/plugin-templates/MaiBot-Napcat-Adapter/.devcontainer/devcontainer.json b/plugin-templates/MaiBot-Napcat-Adapter/.devcontainer/devcontainer.json new file mode 100644 index 00000000..dbd0445d --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "MaiBot-Napcat-Adapter-DevContainer", + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + "features": { + "ghcr.io/rocker-org/devcontainer-features/apt-packages:1": { + "packages": [ + "tmux" + ] + }, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + "forwardPorts": [ + "8095:8095" + ], + "postCreateCommand": "pip3 install --user -r requirements.txt", + "customizations" : { + "jetbrains" : { + "backend" : "PyCharm" + } + } +} diff --git a/plugin-templates/MaiBot-Napcat-Adapter/.github/workflows/docker-image.yml b/plugin-templates/MaiBot-Napcat-Adapter/.github/workflows/docker-image.yml new file mode 100644 index 00000000..f672672f --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/.github/workflows/docker-image.yml @@ -0,0 +1,54 @@ +name: Docker Image CI + +on: + push: + branches: [ "main", "dev" ] + workflow_dispatch: # 允许手动触发工作流 + +jobs: + + build: + + runs-on: ubuntu-latest + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} + DATE_TAG: $(date -u +'%Y-%m-%dT%H-%M-%S') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Clone maim_message + run: git clone https://github.com/MaiM-with-u/maim_message maim_message + + - name: Determine Image Tags + id: tags + run: | + if [ "${{ github.ref_name }}" == "main" ]; then + echo "tags=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:latest,${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:main-$(date -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT + elif [ "${{ github.ref_name }}" == "dev" ]; then + echo "tags=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:dev,${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:dev-$(date -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT + fi + + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.tags.outputs.tags }} + push: true + cache-from: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache-${{ github.ref_name }} + cache-to: type=registry,ref=${{ secrets.DOCKERHUB_USERNAME }}/maimbot-adapter:buildcache-${{ github.ref_name }},mode=max + labels: | + org.opencontainers.image.created=${{ steps.tags.outputs.date_tag }} + org.opencontainers.image.revision=${{ github.sha }} \ No newline at end of file diff --git a/plugin-templates/MaiBot-Napcat-Adapter/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/__init__.py new file mode 100644 index 00000000..fa82860f --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/__init__.py @@ -0,0 +1 @@ +"""NapCat 内置适配器插件包。""" diff --git a/plugin-templates/MaiBot-Napcat-Adapter/_manifest.json b/plugin-templates/MaiBot-Napcat-Adapter/_manifest.json new file mode 100644 index 00000000..b5b26c65 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/_manifest.json @@ -0,0 +1,35 @@ +{ + "manifest_version": 2, + "version": "1.0.1", + "name": "Napcat_Adapter", + "description": "Built-in NapCat adapter plugin for QQ / NapCat message gateway and platform actions.", + "author": { + "name": "MaiBot Team", + "url": "https://github.com/Mai-with-u" + }, + "license": "GPL-v3.0-or-later", + "urls": { + "repository": "https://github.com/Mai-with-u/MaiBot-Napcat-Adapter", + "homepage": "https://github.com/Mai-with-u/MaiBot-Napcat-Adapter", + "documentation": "https://github.com/Mai-with-u/MaiBot-Napcat-Adapter", + "issues": "https://github.com/Mai-with-u/MaiBot-Napcat-Adapter/issues" + }, + "host_application": { + "min_version": "1.0.0", + "max_version": "1.0.0" + }, + "sdk": { + "min_version": "2.0.0", + "max_version": "2.99.99" + }, + "dependencies": [], + "capabilities": [], + "i18n": { + "default_locale": "zh-CN", + "supported_locales": [ + "zh-CN", + "en-US" + ] + }, + "id": "maibot-team.napcat-adapter" +} diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/__init__.py new file mode 100644 index 00000000..54322cb4 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/__init__.py @@ -0,0 +1,18 @@ +"""NapCat API mixin 导出。""" + +from .account import NapCatAccountApiMixin +from .file import NapCatFileApiMixin +from .group import NapCatGroupApiMixin +from .message import NapCatMessageApiMixin +from . import message_tool_patch as _message_tool_patch +from .support import NapCatApiSupportMixin +from .system import NapCatSystemApiMixin + +__all__ = [ + "NapCatAccountApiMixin", + "NapCatApiSupportMixin", + "NapCatFileApiMixin", + "NapCatGroupApiMixin", + "NapCatMessageApiMixin", + "NapCatSystemApiMixin", +] diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/account.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/account.py new file mode 100644 index 00000000..30725950 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/account.py @@ -0,0 +1,366 @@ +"""NapCat 账号与用户侧 API 端点。""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from maibot_sdk import API + +from .support import NapCatApiIdInput, NapCatApiParamsInput, NapCatApiSupportMixin + + +class NapCatAccountApiMixin(NapCatApiSupportMixin): + """NapCat 账号、好友与资料相关 API。""" + + @API("adapter.napcat.account.set_qq_profile", description="设置 QQ 账号资料", version="1", public=True) + async def api_set_qq_profile( + self, + nickname: object, + personal_note: str = "", + sex: str = "", + ) -> Dict[str, Any]: + """设置 QQ 账号资料。 + + Args: + nickname: 新昵称。 + personal_note: 个性签名。 + sex: 性别,支持 ``male``、``female``、``unknown``。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + normalized_sex = str(sex or "").strip().lower() + if normalized_sex and normalized_sex not in {"male", "female", "unknown"}: + raise ValueError("sex 必须为 male、female 或 unknown") + return await self._require_query_service().set_qq_profile( + nickname=self._normalize_non_empty_string(nickname, "nickname"), + personal_note=str(personal_note or "").strip(), + sex=normalized_sex, + ) + + @API("adapter.napcat.account.get_stranger_info", description="获取陌生人信息", version="1", public=True) + async def api_get_stranger_info( + self, + user_id: NapCatApiIdInput, + no_cache: bool = False, + ) -> Optional[Dict[str, Any]]: + """获取陌生人信息。 + + Args: + user_id: 用户号。 + no_cache: 是否禁用缓存。 + + Returns: + Optional[Dict[str, Any]]: 陌生人信息字典;失败时返回 ``None``。 + """ + return await self._require_query_service().get_stranger_info( + str(self._normalize_positive_int(user_id, "user_id")), + no_cache=bool(no_cache), + ) + + @API("adapter.napcat.account.get_friend_list", description="获取好友列表", version="1", public=True) + async def api_get_friend_list(self, no_cache: bool = False) -> Optional[List[Dict[str, Any]]]: + """获取好友列表。 + + Args: + no_cache: 是否禁用缓存。 + + Returns: + Optional[List[Dict[str, Any]]]: 好友信息列表;失败时返回 ``None``。 + """ + return await self._require_query_service().get_friend_list(no_cache=bool(no_cache)) + + @API("adapter.napcat.account.create_collection", description="创建收藏", version="1", public=True) + async def api_action_create_collection(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``create_collection`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("create_collection", params) + + @API("adapter.napcat.account.delete_friend", description="删除好友", version="1", public=True) + async def api_action_delete_friend(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``delete_friend`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("delete_friend", params) + + @API("adapter.napcat.account.fetch_custom_face", description="获取自定义表情", version="1", public=True) + async def api_action_fetch_custom_face(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``fetch_custom_face`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("fetch_custom_face", params) + + @API("adapter.napcat.account.get_ai_characters", description="获取AI角色列表", version="1", public=True) + async def api_action_get_ai_characters(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_ai_characters`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_ai_characters", params) + + @API("adapter.napcat.account.get_clientkey", description="获取ClientKey", version="1", public=True) + async def api_action_get_clientkey(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_clientkey`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_clientkey", params) + + @API("adapter.napcat.account.get_collection_list", description="获取收藏列表", version="1", public=True) + async def api_action_get_collection_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_collection_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_collection_list", params) + + @API("adapter.napcat.account.get_cookies", description="获取 Cookies", version="1", public=True) + async def api_action_get_cookies(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_cookies`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_cookies", params) + + @API( + "adapter.napcat.account.get_friends_with_category", description="获取带分组的好友列表", version="1", public=True + ) + async def api_action_get_friends_with_category(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_friends_with_category`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_friends_with_category", params) + + @API("adapter.napcat.account.get_mini_app_ark", description="获取小程序 Ark", version="1", public=True) + async def api_action_get_mini_app_ark(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_mini_app_ark`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_mini_app_ark", params) + + @API("adapter.napcat.account.get_profile_like", description="获取资料点赞", version="1", public=True) + async def api_action_get_profile_like(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_profile_like`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_profile_like", params) + + @API("adapter.napcat.account.get_recent_contact", description="获取最近会话", version="1", public=True) + async def api_action_get_recent_contact(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_recent_contact`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_recent_contact", params) + + @API("adapter.napcat.account.get_rkey", description="获取扩展 RKey", version="1", public=True) + async def api_action_get_rkey(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_rkey`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_rkey", params) + + @API("adapter.napcat.account.get_rkey_server", description="获取 RKey 服务器", version="1", public=True) + async def api_action_get_rkey_server(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_rkey_server`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_rkey_server", params) + + @API( + "adapter.napcat.account.get_unidirectional_friend_list", + description="获取单向好友列表", + version="1", + public=True, + ) + async def api_action_get_unidirectional_friend_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_unidirectional_friend_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_unidirectional_friend_list", params) + + @API("adapter.napcat.account.internal_ocr_image", description="图片 OCR 识别 (内部)", version="1", public=True) + async def api_action_internal_ocr_image(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``.ocr_image`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action(".ocr_image", params) + + @API("adapter.napcat.account.nc_get_rkey", description="获取 RKey", version="1", public=True) + async def api_action_nc_get_rkey(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``nc_get_rkey`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("nc_get_rkey", params) + + @API("adapter.napcat.account.ocr_image", description="图片 OCR 识别", version="1", public=True) + async def api_action_ocr_image(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``ocr_image`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("ocr_image", params) + + @API("adapter.napcat.account.send_like", description="点赞", version="1", public=True) + async def api_action_send_like(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_like`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_like", params) + + @API("adapter.napcat.account.set_diy_online_status", description="设置自定义在线状态", version="1", public=True) + async def api_action_set_diy_online_status(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_diy_online_status`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_diy_online_status", params) + + @API("adapter.napcat.account.set_friend_add_request", description="处理加好友请求", version="1", public=True) + async def api_action_set_friend_add_request(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_friend_add_request`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_friend_add_request", params) + + @API("adapter.napcat.account.set_friend_remark", description="设置好友备注", version="1", public=True) + async def api_action_set_friend_remark(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_friend_remark`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_friend_remark", params) + + @API("adapter.napcat.account.set_qq_avatar", description="设置QQ头像", version="1", public=True) + async def api_action_set_qq_avatar(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_qq_avatar`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_qq_avatar", params) + + @API("adapter.napcat.account.set_self_longnick", description="设置个性签名", version="1", public=True) + async def api_action_set_self_longnick(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_self_longnick`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_self_longnick", params) + + @API("adapter.napcat.account.translate_en2zh", description="英文单词翻译", version="1", public=True) + async def api_action_translate_en2zh(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``translate_en2zh`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("translate_en2zh", params) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/file.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/file.py new file mode 100644 index 00000000..fa1784bc --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/file.py @@ -0,0 +1,535 @@ +"""NapCat 文件与流式 API 端点。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from maibot_sdk import API + +from .support import NapCatApiParamsInput, NapCatApiSupportMixin + + +class NapCatFileApiMixin(NapCatApiSupportMixin): + """NapCat 文件、媒体与流式相关 API。""" + + @API("adapter.napcat.file.get_record", description="获取语音文件详情", version="1", public=True) + async def api_get_record( + self, + file: object = "", + file_id: str = "", + out_format: str = "wav", + ) -> Optional[Dict[str, Any]]: + """获取语音文件详情。 + + Args: + file: 语音文件名。 + file_id: 可选文件 ID。 + out_format: 输出格式;默认保持兼容旧行为的 ``wav``。 + + Returns: + Optional[Dict[str, Any]]: 语音文件详情;失败时返回 ``None``。 + """ + normalized_file_name = str(file or "").strip() or None + normalized_file_id = str(file_id or "").strip() or None + normalized_out_format = str(out_format or "").strip() + + if normalized_file_name is None and normalized_file_id is None: + raise ValueError("file 或 file_id 至少提供一个") + + return await self._require_query_service().get_record_detail( + file_name=normalized_file_name, + file_id=normalized_file_id, + out_format=normalized_out_format, + ) + + @API("adapter.napcat.file.cancel_online_file", description="取消在线文件", version="1", public=True) + async def api_action_cancel_online_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``cancel_online_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("cancel_online_file", params) + + @API("adapter.napcat.file.clean_stream_temp_file", description="清理流式传输临时文件", version="1", public=True) + async def api_action_clean_stream_temp_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``clean_stream_temp_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("clean_stream_temp_file", params) + + @API("adapter.napcat.file.create_flash_task", description="创建闪传任务", version="1", public=True) + async def api_action_create_flash_task(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``create_flash_task`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("create_flash_task", params) + + @API("adapter.napcat.file.create_group_file_folder", description="创建群文件目录", version="1", public=True) + async def api_action_create_group_file_folder(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``create_group_file_folder`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("create_group_file_folder", params) + + @API("adapter.napcat.file.del_group_album_media", description="删除群相册媒体", version="1", public=True) + async def api_action_del_group_album_media(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``del_group_album_media`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("del_group_album_media", params) + + @API("adapter.napcat.file.delete_group_file", description="删除群文件", version="1", public=True) + async def api_action_delete_group_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``delete_group_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("delete_group_file", params) + + @API("adapter.napcat.file.delete_group_folder", description="删除群文件目录", version="1", public=True) + async def api_action_delete_group_folder(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``delete_group_folder`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("delete_group_folder", params) + + @API("adapter.napcat.file.do_group_album_comment", description="发表群相册评论", version="1", public=True) + async def api_action_do_group_album_comment(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``do_group_album_comment`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("do_group_album_comment", params) + + @API("adapter.napcat.file.download_file", description="下载文件", version="1", public=True) + async def api_action_download_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``download_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("download_file", params) + + @API("adapter.napcat.file.download_file_image_stream", description="下载图片文件流", version="1", public=True) + async def api_action_download_file_image_stream(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``download_file_image_stream`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("download_file_image_stream", params) + + @API("adapter.napcat.file.download_file_record_stream", description="下载语音文件流", version="1", public=True) + async def api_action_download_file_record_stream(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``download_file_record_stream`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("download_file_record_stream", params) + + @API("adapter.napcat.file.download_file_stream", description="下载文件流", version="1", public=True) + async def api_action_download_file_stream(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``download_file_stream`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("download_file_stream", params) + + @API("adapter.napcat.file.download_fileset", description="下载文件集", version="1", public=True) + async def api_action_download_fileset(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``download_fileset`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("download_fileset", params) + + @API("adapter.napcat.file.get_file", description="获取文件", version="1", public=True) + async def api_action_get_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_file", params) + + @API("adapter.napcat.file.get_fileset_id", description="获取文件集 ID", version="1", public=True) + async def api_action_get_fileset_id(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_fileset_id`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_fileset_id", params) + + @API("adapter.napcat.file.get_fileset_info", description="获取文件集信息", version="1", public=True) + async def api_action_get_fileset_info(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_fileset_info`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_fileset_info", params) + + @API("adapter.napcat.file.get_flash_file_list", description="获取闪传文件列表", version="1", public=True) + async def api_action_get_flash_file_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_flash_file_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_flash_file_list", params) + + @API("adapter.napcat.file.get_flash_file_url", description="获取闪传文件链接", version="1", public=True) + async def api_action_get_flash_file_url(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_flash_file_url`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_flash_file_url", params) + + @API("adapter.napcat.file.get_group_album_media_list", description="获取群相册媒体列表", version="1", public=True) + async def api_action_get_group_album_media_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_album_media_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_album_media_list", params) + + @API("adapter.napcat.file.get_group_file_system_info", description="获取群文件系统信息", version="1", public=True) + async def api_action_get_group_file_system_info(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_file_system_info`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_file_system_info", params) + + @API("adapter.napcat.file.get_group_file_url", description="获取群文件URL", version="1", public=True) + async def api_action_get_group_file_url(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_file_url`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_file_url", params) + + @API("adapter.napcat.file.get_group_files_by_folder", description="获取群文件夹文件列表", version="1", public=True) + async def api_action_get_group_files_by_folder(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_files_by_folder`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_files_by_folder", params) + + @API("adapter.napcat.file.get_group_root_files", description="获取群根目录文件列表", version="1", public=True) + async def api_action_get_group_root_files(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_root_files`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_root_files", params) + + @API("adapter.napcat.file.get_image", description="获取图片", version="1", public=True) + async def api_action_get_image(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_image`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_image", params) + + @API("adapter.napcat.file.get_online_file_msg", description="获取在线文件消息", version="1", public=True) + async def api_action_get_online_file_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_online_file_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_online_file_msg", params) + + @API("adapter.napcat.file.get_private_file_url", description="获取私聊文件URL", version="1", public=True) + async def api_action_get_private_file_url(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_private_file_url`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_private_file_url", params) + + @API("adapter.napcat.file.get_qun_album_list", description="获取群相册列表", version="1", public=True) + async def api_action_get_qun_album_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_qun_album_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_qun_album_list", params) + + @API("adapter.napcat.file.get_share_link", description="获取文件分享链接", version="1", public=True) + async def api_action_get_share_link(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_share_link`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_share_link", params) + + @API("adapter.napcat.file.move_group_file", description="移动群文件", version="1", public=True) + async def api_action_move_group_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``move_group_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("move_group_file", params) + + @API("adapter.napcat.file.receive_online_file", description="接收在线文件", version="1", public=True) + async def api_action_receive_online_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``receive_online_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("receive_online_file", params) + + @API("adapter.napcat.file.refuse_online_file", description="拒绝在线文件", version="1", public=True) + async def api_action_refuse_online_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``refuse_online_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("refuse_online_file", params) + + @API("adapter.napcat.file.rename_group_file", description="重命名群文件", version="1", public=True) + async def api_action_rename_group_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``rename_group_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("rename_group_file", params) + + @API("adapter.napcat.file.send_flash_msg", description="发送闪传消息", version="1", public=True) + async def api_action_send_flash_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_flash_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_flash_msg", params) + + @API("adapter.napcat.file.send_online_file", description="发送在线文件", version="1", public=True) + async def api_action_send_online_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_online_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_online_file", params) + + @API("adapter.napcat.file.send_online_folder", description="发送在线文件夹", version="1", public=True) + async def api_action_send_online_folder(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_online_folder`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_online_folder", params) + + @API("adapter.napcat.file.set_group_album_media_like", description="点赞群相册媒体", version="1", public=True) + async def api_action_set_group_album_media_like(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_album_media_like`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_album_media_like", params) + + @API("adapter.napcat.file.trans_group_file", description="传输群文件", version="1", public=True) + async def api_action_trans_group_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``trans_group_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("trans_group_file", params) + + @API("adapter.napcat.file.upload_file_stream", description="上传文件流", version="1", public=True) + async def api_action_upload_file_stream(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``upload_file_stream`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("upload_file_stream", params) + + @API("adapter.napcat.file.upload_group_file", description="上传群文件", version="1", public=True) + async def api_action_upload_group_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``upload_group_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("upload_group_file", params) + + @API("adapter.napcat.file.upload_image_to_qun_album", description="上传图片到群相册", version="1", public=True) + async def api_action_upload_image_to_qun_album(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``upload_image_to_qun_album`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("upload_image_to_qun_album", params) + + @API("adapter.napcat.file.upload_private_file", description="上传私聊文件", version="1", public=True) + async def api_action_upload_private_file(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``upload_private_file`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("upload_private_file", params) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/group.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/group.py new file mode 100644 index 00000000..429f76ef --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/group.py @@ -0,0 +1,593 @@ +"""NapCat 群组与频道 API 端点。""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from maibot_sdk import API + +from .support import NapCatApiIdInput, NapCatApiSupportMixin, NapCatApiParamsInput + + +class NapCatGroupApiMixin(NapCatApiSupportMixin): + """NapCat 群组、频道与群扩展相关 API。""" + + @API("adapter.napcat.group.set_group_ban", description="设置群成员禁言", version="1", public=True) + async def api_set_group_ban( + self, + group_id: NapCatApiIdInput, + user_id: NapCatApiIdInput, + duration: NapCatApiIdInput, + ) -> Dict[str, Any]: + """设置群成员禁言。 + + Args: + group_id: 群号。 + user_id: 用户号。 + duration: 禁言秒数。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + normalized_duration = self._normalize_non_negative_int(duration, "duration") + if normalized_duration > 2592000: + raise ValueError("duration 不能超过 2592000 秒") + return await self._require_query_service().set_group_ban( + group_id=self._normalize_positive_int(group_id, "group_id"), + user_id=self._normalize_positive_int(user_id, "user_id"), + duration=normalized_duration, + ) + + @API("adapter.napcat.group.set_group_whole_ban", description="设置群全体禁言", version="1", public=True) + async def api_set_group_whole_ban(self, group_id: NapCatApiIdInput, enable: bool) -> Dict[str, Any]: + """设置群全体禁言。 + + Args: + group_id: 群号。 + enable: 是否开启全体禁言。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().set_group_whole_ban( + group_id=self._normalize_positive_int(group_id, "group_id"), + enable=self._normalize_bool(enable, "enable"), + ) + + @API("adapter.napcat.group.set_group_kick", description="踢出单个群成员", version="1", public=True) + async def api_set_group_kick( + self, + group_id: NapCatApiIdInput, + user_id: NapCatApiIdInput, + reject_add_request: bool = False, + ) -> Dict[str, Any]: + """踢出单个群成员。 + + Args: + group_id: 群号。 + user_id: 用户号。 + reject_add_request: 是否拒绝再次加群。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().set_group_kick( + group_id=self._normalize_positive_int(group_id, "group_id"), + user_id=self._normalize_positive_int(user_id, "user_id"), + reject_add_request=bool(reject_add_request), + ) + + @API("adapter.napcat.group.set_group_kick_members", description="批量踢出群成员", version="1", public=True) + async def api_set_group_kick_members( + self, + group_id: NapCatApiIdInput, + user_id: object, + reject_add_request: bool = False, + ) -> Dict[str, Any]: + """批量踢出群成员。 + + Args: + group_id: 群号。 + user_id: 用户号数组。 + reject_add_request: 是否拒绝再次加群。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().set_group_kick_members( + group_id=self._normalize_positive_int(group_id, "group_id"), + user_ids=self._normalize_user_id_list(user_id, "user_id"), + reject_add_request=bool(reject_add_request), + ) + + @API("adapter.napcat.group.set_group_name", description="设置群名称", version="1", public=True) + async def api_set_group_name(self, group_id: NapCatApiIdInput, group_name: object) -> Dict[str, Any]: + """设置群名称。 + + Args: + group_id: 群号。 + group_name: 新群名称。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().set_group_name( + group_id=self._normalize_positive_int(group_id, "group_id"), + group_name=self._normalize_non_empty_string(group_name, "group_name"), + ) + + @API("adapter.napcat.group.get_group_info", description="获取群信息", version="1", public=True) + async def api_get_group_info(self, group_id: NapCatApiIdInput) -> Optional[Dict[str, Any]]: + """获取群信息。 + + Args: + group_id: 群号。 + + Returns: + Optional[Dict[str, Any]]: 群信息字典;失败时返回 ``None``。 + """ + return await self._require_query_service().get_group_info( + str(self._normalize_positive_int(group_id, "group_id")) + ) + + @API("adapter.napcat.group.get_group_detail_info", description="获取群详细信息", version="1", public=True) + async def api_get_group_detail_info(self, group_id: NapCatApiIdInput) -> Optional[Dict[str, Any]]: + """获取群详细信息。 + + Args: + group_id: 群号。 + + Returns: + Optional[Dict[str, Any]]: 群详细信息字典;失败时返回 ``None``。 + """ + return await self._require_query_service().get_group_detail_info( + str(self._normalize_positive_int(group_id, "group_id")) + ) + + @API("adapter.napcat.group.get_group_list", description="获取群列表", version="1", public=True) + async def api_get_group_list(self, no_cache: bool = False) -> Optional[List[Dict[str, Any]]]: + """获取群列表。 + + Args: + no_cache: 是否禁用缓存。 + + Returns: + Optional[List[Dict[str, Any]]]: 群信息列表;失败时返回 ``None``。 + """ + return await self._require_query_service().get_group_list(no_cache=bool(no_cache)) + + @API("adapter.napcat.group.get_group_at_all_remain", description="获取群 @ 全体剩余次数", version="1", public=True) + async def api_get_group_at_all_remain(self, group_id: NapCatApiIdInput) -> Optional[Dict[str, Any]]: + """获取群 @ 全体剩余次数。 + + Args: + group_id: 群号。 + + Returns: + Optional[Dict[str, Any]]: 剩余次数信息;失败时返回 ``None``。 + """ + return await self._require_query_service().get_group_at_all_remain( + str(self._normalize_positive_int(group_id, "group_id")) + ) + + @API("adapter.napcat.group.get_group_member_info", description="获取群成员信息", version="1", public=True) + async def api_get_group_member_info( + self, + group_id: NapCatApiIdInput, + user_id: NapCatApiIdInput, + no_cache: bool = True, + ) -> Optional[Dict[str, Any]]: + """获取群成员信息。 + + Args: + group_id: 群号。 + user_id: 用户号。 + no_cache: 是否禁用缓存。 + + Returns: + Optional[Dict[str, Any]]: 群成员信息字典;失败时返回 ``None``。 + """ + return await self._require_query_service().get_group_member_info( + group_id=str(self._normalize_positive_int(group_id, "group_id")), + user_id=str(self._normalize_positive_int(user_id, "user_id")), + no_cache=bool(no_cache), + ) + + @API("adapter.napcat.group.get_group_member_list", description="获取群成员列表", version="1", public=True) + async def api_get_group_member_list( + self, + group_id: NapCatApiIdInput, + no_cache: bool = False, + ) -> Optional[List[Dict[str, Any]]]: + """获取群成员列表。 + + Args: + group_id: 群号。 + no_cache: 是否禁用缓存。 + + Returns: + Optional[List[Dict[str, Any]]]: 群成员信息列表;失败时返回 ``None``。 + """ + return await self._require_query_service().get_group_member_list( + group_id=str(self._normalize_positive_int(group_id, "group_id")), + no_cache=bool(no_cache), + ) + + @API("adapter.napcat.group.delete_essence_msg", description="移出精华消息", version="1", public=True) + async def api_action_delete_essence_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``delete_essence_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("delete_essence_msg", params) + + @API("adapter.napcat.group.delete_group_notice", description="删除群公告", version="1", public=True) + async def api_action_delete_group_notice(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``_del_group_notice`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("_del_group_notice", params) + + @API("adapter.napcat.group.get_essence_msg_list", description="获取群精华消息", version="1", public=True) + async def api_action_get_essence_msg_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_essence_msg_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_essence_msg_list", params) + + @API("adapter.napcat.group.get_group_honor_info", description="获取群荣誉信息", version="1", public=True) + async def api_action_get_group_honor_info(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_honor_info`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_honor_info", params) + + @API( + "adapter.napcat.group.get_group_ignore_add_request", + description="获取群被忽略的加群请求", + version="1", + public=True, + ) + async def api_action_get_group_ignore_add_request(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_ignore_add_request`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_ignore_add_request", params) + + @API("adapter.napcat.group.get_group_ignored_notifies", description="获取群忽略通知", version="1", public=True) + async def api_action_get_group_ignored_notifies(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_ignored_notifies`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_ignored_notifies", params) + + @API("adapter.napcat.group.get_group_info_ex", description="获取群详细信息 (扩展)", version="1", public=True) + async def api_action_get_group_info_ex(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_info_ex`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_info_ex", params) + + @API("adapter.napcat.group.get_group_notice", description="获取群公告", version="1", public=True) + async def api_action_get_group_notice(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``_get_group_notice`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("_get_group_notice", params) + + @API("adapter.napcat.group.get_group_shut_list", description="获取群禁言列表", version="1", public=True) + async def api_action_get_group_shut_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_shut_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_shut_list", params) + + @API("adapter.napcat.group.get_group_system_msg", description="获取群系统消息", version="1", public=True) + async def api_action_get_group_system_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_system_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_system_msg", params) + + @API("adapter.napcat.group.get_guild_list", description="获取频道列表", version="1", public=True) + async def api_action_get_guild_list(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_guild_list`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_guild_list", params) + + @API("adapter.napcat.group.get_guild_service_profile", description="获取频道个人信息", version="1", public=True) + async def api_action_get_guild_service_profile(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_guild_service_profile`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_guild_service_profile", params) + + @API("adapter.napcat.group.group_poke", description="发送戳一戳", version="1", public=True) + async def api_action_group_poke(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``group_poke`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("group_poke", params) + + @API("adapter.napcat.group.handle_quick_operation_internal", description="处理快速操作", version="1", public=True) + async def api_action_handle_quick_operation_internal(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``.handle_quick_operation`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action(".handle_quick_operation", params) + + @API("adapter.napcat.group.send_group_msg", description="发送群消息", version="1", public=True) + async def api_action_send_group_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_group_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_group_msg", params) + + @API("adapter.napcat.group.send_group_notice", description="发送群公告", version="1", public=True) + async def api_action_send_group_notice(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``_send_group_notice`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("_send_group_notice", params) + + @API("adapter.napcat.group.send_group_sign", description="群打卡", version="1", public=True) + async def api_action_send_group_sign(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_group_sign`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_group_sign", params) + + @API("adapter.napcat.group.set_essence_msg", description="设置精华消息", version="1", public=True) + async def api_action_set_essence_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_essence_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_essence_msg", params) + + @API("adapter.napcat.group.set_group_add_option", description="设置群加群选项", version="1", public=True) + async def api_action_set_group_add_option(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_add_option`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_add_option", params) + + @API("adapter.napcat.group.set_group_add_request", description="处理加群请求", version="1", public=True) + async def api_action_set_group_add_request(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_add_request`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_add_request", params) + + @API("adapter.napcat.group.set_group_admin", description="设置群管理员", version="1", public=True) + async def api_action_set_group_admin(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_admin`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_admin", params) + + @API("adapter.napcat.group.set_group_card", description="设置群名片", version="1", public=True) + async def api_action_set_group_card(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_card`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_card", params) + + @API("adapter.napcat.group.set_group_leave", description="退出群组", version="1", public=True) + async def api_action_set_group_leave(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_leave`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_leave", params) + + @API("adapter.napcat.group.set_group_portrait", description="设置群头像", version="1", public=True) + async def api_action_set_group_portrait(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_portrait`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_portrait", params) + + @API("adapter.napcat.group.set_group_remark", description="设置群备注", version="1", public=True) + async def api_action_set_group_remark(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_remark`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_remark", params) + + @API( + "adapter.napcat.group.set_group_robot_add_option", description="设置群机器人加群选项", version="1", public=True + ) + async def api_action_set_group_robot_add_option(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_robot_add_option`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_robot_add_option", params) + + @API("adapter.napcat.group.set_group_search", description="设置群搜索选项", version="1", public=True) + async def api_action_set_group_search(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_search`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_search", params) + + @API("adapter.napcat.group.set_group_sign", description="群打卡", version="1", public=True) + async def api_action_set_group_sign(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_sign`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_sign", params) + + @API("adapter.napcat.group.set_group_special_title", description="设置专属头衔", version="1", public=True) + async def api_action_set_group_special_title(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_special_title`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_special_title", params) + + @API("adapter.napcat.group.set_group_todo", description="设置群待办", version="1", public=True) + async def api_action_set_group_todo(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_group_todo`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_group_todo", params) + + @API("adapter.napcat.file.test_download_stream", description="测试下载流", version="1", public=True) + async def api_action_test_download_stream(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``test_download_stream`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("test_download_stream", params) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/message.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/message.py new file mode 100644 index 00000000..b7e7ebc9 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/message.py @@ -0,0 +1,431 @@ +"""NapCat 消息与互动 API 端点。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from maibot_sdk import API + +from .support import NapCatApiIdInput, NapCatApiParamsInput, NapCatApiSupportMixin + + +class NapCatMessageApiMixin(NapCatApiSupportMixin): + """NapCat 消息、互动与 AI 相关 API。""" + + @API("adapter.napcat.message.send_poke", description="发送戳一戳", version="1", public=True) + async def api_send_poke( + self, + user_id: Optional[NapCatApiIdInput] = None, + group_id: Optional[NapCatApiIdInput] = None, + target_id: Optional[NapCatApiIdInput] = None, + qq_id: Optional[NapCatApiIdInput] = None, + ) -> Dict[str, Any]: + """发送戳一戳。 + + Args: + user_id: 目标用户号。 + group_id: 可选群号。 + target_id: 官方 ``send_poke`` 动作支持的目标 ID。 + qq_id: 兼容旧版调用方式的 ``user_id`` 别名。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + normalized_user_id_input = str(user_id).strip() if user_id is not None else "" + normalized_qq_id_input = str(qq_id).strip() if qq_id is not None else "" + + if normalized_user_id_input and normalized_qq_id_input: + resolved_user_id = self._normalize_positive_int(user_id, "user_id") + resolved_qq_id = self._normalize_positive_int(qq_id, "qq_id") + if resolved_user_id != resolved_qq_id: + raise ValueError("user_id 与 qq_id 不能同时传递不同的值") + elif normalized_user_id_input: + resolved_user_id = self._normalize_positive_int(user_id, "user_id") + elif normalized_qq_id_input: + resolved_user_id = self._normalize_positive_int(qq_id, "qq_id") + else: + raise ValueError("user_id 不能为空") + + normalized_group_id: Optional[int] = None + if group_id is not None and str(group_id).strip(): + normalized_group_id = self._normalize_positive_int(group_id, "group_id") + + normalized_target_id: Optional[int] = None + if target_id is not None and str(target_id).strip(): + normalized_target_id = self._normalize_positive_int(target_id, "target_id") + + return await self._require_query_service().send_poke( + user_id=resolved_user_id, + group_id=normalized_group_id, + target_id=normalized_target_id, + ) + + @API("adapter.napcat.message.delete_msg", description="撤回消息", version="1", public=True) + async def api_delete_msg(self, message_id: NapCatApiIdInput) -> Dict[str, Any]: + """撤回消息。 + + Args: + message_id: 消息 ID。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().delete_message( + message_id=self._normalize_positive_int(message_id, "message_id") + ) + + @API("adapter.napcat.message.send_group_ai_record", description="发送群 AI 语音", version="1", public=True) + async def api_send_group_ai_record( + self, + group_id: NapCatApiIdInput, + character: object, + text: object, + ) -> Dict[str, Any]: + """发送群 AI 语音。 + + Args: + group_id: 群号。 + character: 角色标识。 + text: 语音文本。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().send_group_ai_record( + group_id=self._normalize_positive_int(group_id, "group_id"), + character=self._normalize_non_empty_string(character, "character"), + text=self._normalize_non_empty_string(text, "text"), + ) + + @API("adapter.napcat.message.set_msg_emoji_like", description="给消息贴表情", version="1", public=True) + async def api_set_msg_emoji_like( + self, + message_id: NapCatApiIdInput, + emoji_id: NapCatApiIdInput, + set: bool = True, + ) -> Dict[str, Any]: + """给消息贴表情或取消表情。 + + Args: + message_id: 消息 ID。 + emoji_id: 表情 ID。 + set: 是否设置为已贴表情。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._require_query_service().set_message_emoji_like( + message_id=self._normalize_positive_int(message_id, "message_id"), + emoji_id=self._normalize_positive_int(emoji_id, "emoji_id"), + set_like=bool(set), + ) + + @API("adapter.napcat.message.get_msg", description="获取消息详情", version="1", public=True) + async def api_get_msg(self, message_id: NapCatApiIdInput) -> Optional[Dict[str, Any]]: + """获取消息详情。 + + Args: + message_id: 消息 ID。 + + Returns: + Optional[Dict[str, Any]]: 消息详情字典;失败时返回 ``None``。 + """ + return await self._require_query_service().get_message_detail( + str(self._normalize_positive_int(message_id, "message_id")) + ) + + @API("adapter.napcat.message.get_forward_msg", description="获取合并转发消息", version="1", public=True) + async def api_get_forward_msg( + self, + message_id: object = "", + id: object = "", + ) -> Optional[Dict[str, Any]]: + """获取合并转发消息详情。 + + Args: + message_id: 合并转发消息 ID。 + id: NapCat 官方文档中的兼容字段。 + + Returns: + Optional[Dict[str, Any]]: 合并转发消息详情;失败时返回 ``None``。 + """ + normalized_message_id = str(message_id or "").strip() + normalized_forward_id = str(id or "").strip() + + if normalized_message_id and normalized_forward_id and normalized_message_id != normalized_forward_id: + raise ValueError("message_id 与 id 不能同时传递不同的值") + if not normalized_message_id and not normalized_forward_id: + raise ValueError("message_id 或 id 至少提供一个") + + return await self._require_query_service().get_forward_message( + message_id=normalized_message_id or None, + forward_id=normalized_forward_id or None, + ) + + @API("adapter.napcat.message.ark_share_group", description="分享群 (Ark)", version="1", public=True) + async def api_action_ark_share_group(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``ArkShareGroup`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("ArkShareGroup", params) + + @API("adapter.napcat.message.ark_share_peer", description="分享用户 (Ark)", version="1", public=True) + async def api_action_ark_share_peer(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``ArkSharePeer`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("ArkSharePeer", params) + + @API( + "adapter.napcat.message.click_inline_keyboard_button", description="点击内联键盘按钮", version="1", public=True + ) + async def api_action_click_inline_keyboard_button(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``click_inline_keyboard_button`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("click_inline_keyboard_button", params) + + @API("adapter.napcat.message.fetch_emoji_like", description="获取表情点赞详情", version="1", public=True) + async def api_action_fetch_emoji_like(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``fetch_emoji_like`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("fetch_emoji_like", params) + + @API("adapter.napcat.message.forward_friend_single_msg", description="转发单条消息", version="1", public=True) + async def api_action_forward_friend_single_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``forward_friend_single_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("forward_friend_single_msg", params) + + @API("adapter.napcat.message.forward_group_single_msg", description="转发单条消息", version="1", public=True) + async def api_action_forward_group_single_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``forward_group_single_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("forward_group_single_msg", params) + + @API("adapter.napcat.message.friend_poke", description="发送戳一戳", version="1", public=True) + async def api_action_friend_poke(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``friend_poke`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("friend_poke", params) + + @API("adapter.napcat.message.get_ai_record", description="获取 AI 语音", version="1", public=True) + async def api_action_get_ai_record(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_ai_record`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_ai_record", params) + + @API("adapter.napcat.message.get_emoji_likes", description="获取消息表情点赞列表", version="1", public=True) + async def api_action_get_emoji_likes(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_emoji_likes`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_emoji_likes", params) + + @API("adapter.napcat.message.get_friend_msg_history", description="获取好友历史消息", version="1", public=True) + async def api_action_get_friend_msg_history(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_friend_msg_history`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_friend_msg_history", params) + + @API("adapter.napcat.message.get_group_msg_history", description="获取群历史消息", version="1", public=True) + async def api_action_get_group_msg_history(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_group_msg_history`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_group_msg_history", params) + + @API("adapter.napcat.message.mark_all_as_read", description="标记所有消息已读", version="1", public=True) + async def api_action_mark_all_as_read(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``_mark_all_as_read`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("_mark_all_as_read", params) + + @API("adapter.napcat.message.mark_group_msg_as_read", description="标记群聊已读", version="1", public=True) + async def api_action_mark_group_msg_as_read(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``mark_group_msg_as_read`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("mark_group_msg_as_read", params) + + @API("adapter.napcat.message.mark_msg_as_read", description="标记消息已读 (Go-CQHTTP)", version="1", public=True) + async def api_action_mark_msg_as_read(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``mark_msg_as_read`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("mark_msg_as_read", params) + + @API("adapter.napcat.message.mark_private_msg_as_read", description="标记私聊已读", version="1", public=True) + async def api_action_mark_private_msg_as_read(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``mark_private_msg_as_read`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("mark_private_msg_as_read", params) + + @API("adapter.napcat.message.send_ark_share", description="分享用户 (Ark)", version="1", public=True) + async def api_action_send_ark_share(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_ark_share`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_ark_share", params) + + @API("adapter.napcat.message.send_forward_msg", description="发送合并转发消息", version="1", public=True) + async def api_action_send_forward_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_forward_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_forward_msg", params) + + @API("adapter.napcat.message.send_group_ark_share", description="分享群 (Ark)", version="1", public=True) + async def api_action_send_group_ark_share(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_group_ark_share`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_group_ark_share", params) + + @API("adapter.napcat.message.send_group_forward_msg", description="发送群合并转发消息", version="1", public=True) + async def api_action_send_group_forward_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_group_forward_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_group_forward_msg", params) + + @API("adapter.napcat.message.send_msg", description="发送消息", version="1", public=True) + async def api_action_send_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_msg", params) + + @API( + "adapter.napcat.message.send_private_forward_msg", description="发送私聊合并转发消息", version="1", public=True + ) + async def api_action_send_private_forward_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_private_forward_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_private_forward_msg", params) + + @API("adapter.napcat.message.send_private_msg", description="发送私聊消息", version="1", public=True) + async def api_action_send_private_msg(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_private_msg`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_private_msg", params) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/message_tool_patch.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/message_tool_patch.py new file mode 100644 index 00000000..fcd5522b --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/message_tool_patch.py @@ -0,0 +1,69 @@ +"""NapCat 消息工具扩展。""" + +from __future__ import annotations + +from typing import Any, Dict + +from maibot_sdk import Tool +from maibot_sdk.types import ToolParameterInfo, ToolParamType + +from .message import NapCatMessageApiMixin + + +def _tool_param(name: str, param_type: ToolParamType, description: str, required: bool) -> ToolParameterInfo: + """构造工具参数声明。""" + + return ToolParameterInfo(name=name, param_type=param_type, description=description, required=required) + + +@Tool( + "find_user_qq_id", + description="根据指定消息的 msg_id 查询该条消息发送者的 QQ 号信息", + parameters=[ + _tool_param("msg_id", ToolParamType.STRING, "要查询的消息 ID", True), + ], +) +async def handle_find_user_qq_id(self: NapCatMessageApiMixin, msg_id: str = "", **kwargs: Any) -> Dict[str, Any]: + """根据消息 ID 查询发送者的 QQ 号信息。""" + + del kwargs + + normalized_msg_id = str(self._normalize_positive_int(msg_id, "msg_id")) + message_detail = await self._require_query_service().get_message_detail(normalized_msg_id) + if not isinstance(message_detail, dict): + return { + "success": False, + "content": f"未找到 msg_id={normalized_msg_id} 对应的消息记录", + "msg_id": normalized_msg_id, + } + + sender = message_detail.get("sender", {}) + if not isinstance(sender, dict): + sender = {} + + user_id = str(message_detail.get("user_id") or sender.get("user_id") or sender.get("uin") or "").strip() + if not user_id: + return { + "success": False, + "content": f"已获取消息详情,但未解析出 msg_id={normalized_msg_id} 的发送者 QQ 号", + "msg_id": normalized_msg_id, + "message_detail": message_detail, + } + + nickname = str(sender.get("nickname") or sender.get("name") or "").strip() + cardname = str(sender.get("card") or "").strip() + sender_info = { + "user_id": user_id, + "nickname": nickname or None, + "cardname": cardname or None, + } + display_name = cardname or nickname or user_id + return { + "success": True, + "content": f"msg_id={normalized_msg_id} 的发送者 QQ 号是 {user_id}(显示名:{display_name})", + "msg_id": normalized_msg_id, + "sender": sender_info, + } + + +NapCatMessageApiMixin.handle_find_user_qq_id = handle_find_user_qq_id diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/support.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/support.py new file mode 100644 index 00000000..d0d07a2b --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/support.py @@ -0,0 +1,275 @@ +"""NapCat API 端点的公共辅助能力。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, TypeAlias + +from maibot_sdk import API + +from ..types import NapCatActionParamsInput, NapCatActionResponse, NapCatIdInput + +if TYPE_CHECKING: + from ..services import NapCatActionService, NapCatQueryService + + +NapCatApiIdInput: TypeAlias = NapCatIdInput +NapCatApiParamsInput: TypeAlias = NapCatActionParamsInput + + +class NapCatApiSupportMixin: + """NapCat API 端点共享辅助逻辑。""" + + _action_service: Optional["NapCatActionService"] + _query_service: Optional["NapCatQueryService"] + + def _ensure_runtime_components(self) -> None: + """确保运行时组件已经初始化。""" + raise NotImplementedError + + @staticmethod + def _coerce_int(value: object, field_name: str, expectation: str) -> int: + """将受支持的输入值转换为整数。 + + Args: + value: 待转换的值。 + field_name: 字段名,用于错误提示。 + expectation: 期望的取值描述,例如“正整数”。 + + Returns: + int: 转换后的整数值。 + + Raises: + ValueError: 当值无法转换为整数时抛出。 + """ + if isinstance(value, bool): + raise ValueError(f"{field_name} 必须是{expectation}") + if isinstance(value, int): + return value + if isinstance(value, float): + try: + return int(value) + except (OverflowError, ValueError) as exc: + raise ValueError(f"{field_name} 必须是{expectation}") from exc + if isinstance(value, str): + normalized_value = value.strip() + if not normalized_value: + raise ValueError(f"{field_name} 必须是{expectation}") + try: + return int(normalized_value) + except ValueError as exc: + raise ValueError(f"{field_name} 必须是{expectation}") from exc + raise ValueError(f"{field_name} 必须是{expectation}") + + def _require_query_service(self) -> "NapCatQueryService": + """返回当前可用的 NapCat 查询服务。 + + Returns: + NapCatQueryService: 已初始化的查询服务。 + + Raises: + RuntimeError: 当查询服务尚未初始化时抛出。 + """ + self._ensure_runtime_components() + query_service = self._query_service + if query_service is None: + raise RuntimeError("NapCat 查询服务尚未初始化") + return query_service + + def _require_action_service(self) -> "NapCatActionService": + """返回当前可用的 NapCat 动作服务。 + + Returns: + NapCatActionService: 已初始化的动作服务。 + + Raises: + RuntimeError: 当动作服务尚未初始化时抛出。 + """ + self._ensure_runtime_components() + action_service = self._action_service + if action_service is None: + raise RuntimeError("NapCat 动作服务尚未初始化") + return action_service + + @staticmethod + def _normalize_positive_int(value: object, field_name: str) -> int: + """将任意值规范化为正整数。 + + Args: + value: 待规范化的值。 + field_name: 字段名,用于错误提示。 + + Returns: + int: 规范化后的正整数。 + + Raises: + ValueError: 当值无法转换为正整数时抛出。 + """ + normalized_value = NapCatApiSupportMixin._coerce_int(value, field_name, "正整数") + if normalized_value <= 0: + raise ValueError(f"{field_name} 必须是正整数") + return normalized_value + + @staticmethod + def _normalize_non_negative_int(value: object, field_name: str) -> int: + """将任意值规范化为非负整数。 + + Args: + value: 待规范化的值。 + field_name: 字段名,用于错误提示。 + + Returns: + int: 规范化后的非负整数。 + + Raises: + ValueError: 当值无法转换为非负整数时抛出。 + """ + normalized_value = NapCatApiSupportMixin._coerce_int(value, field_name, "非负整数") + if normalized_value < 0: + raise ValueError(f"{field_name} 必须是非负整数") + return normalized_value + + @staticmethod + def _normalize_bool(value: object, field_name: str) -> bool: + """将任意值规范化为布尔值。 + + Args: + value: 待规范化的值。 + field_name: 字段名,用于错误提示。 + + Returns: + bool: 规范化后的布尔值。 + + Raises: + ValueError: 当值不是布尔值时抛出。 + """ + if not isinstance(value, bool): + raise ValueError(f"{field_name} 必须是布尔值") + return value + + @staticmethod + def _normalize_non_empty_string(value: object, field_name: str) -> str: + """将任意值规范化为非空字符串。 + + Args: + value: 待规范化的值。 + field_name: 字段名,用于错误提示。 + + Returns: + str: 规范化后的字符串。 + + Raises: + ValueError: 当值为空时抛出。 + """ + normalized_value = str(value or "").strip() + if not normalized_value: + raise ValueError(f"{field_name} 不能为空") + return normalized_value + + @classmethod + def _normalize_user_id_list(cls, values: object, field_name: str) -> List[int]: + """将任意值规范化为用户号列表。 + + Args: + values: 待规范化的值。 + field_name: 字段名,用于错误提示。 + + Returns: + List[int]: 规范化后的用户号列表。 + + Raises: + ValueError: 当值不是非空数组时抛出。 + """ + if not isinstance(values, list) or not values: + raise ValueError(f"{field_name} 必须是非空数组") + return [cls._normalize_positive_int(value, field_name) for value in values] + + @staticmethod + def _normalize_params(params: NapCatApiParamsInput) -> Dict[str, Any]: + """将动作参数规范化为可变字典。 + + Args: + params: 调用方提供的参数对象。 + + Returns: + Dict[str, Any]: 规范化后的参数字典。 + + Raises: + ValueError: 当 ``params`` 不是映射对象时抛出。 + """ + if params is None: + return {} + if not isinstance(params, Mapping): + raise ValueError("params 必须是对象") + return {str(key): value for key, value in params.items()} + + async def _call_napcat_action( + self, + action_name: str, + params: NapCatApiParamsInput = None, + ) -> NapCatActionResponse: + """调用 NapCat 动作并返回原始响应。 + + Args: + action_name: NapCat 动作名称。 + params: 传递给 NapCat 的动作参数。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + normalized_action_name = self._normalize_non_empty_string(action_name, "action_name") + normalized_params = self._normalize_params(params) + return await self._require_action_service().call_action(normalized_action_name, normalized_params) + + async def _call_napcat_action_data( + self, + action_name: str, + params: NapCatApiParamsInput = None, + ) -> Any: + """调用 NapCat 动作并返回 ``data`` 字段。 + + Args: + action_name: NapCat 动作名称。 + params: 传递给 NapCat 的动作参数。 + + Returns: + Any: NapCat 响应中的 ``data`` 字段。 + """ + normalized_action_name = self._normalize_non_empty_string(action_name, "action_name") + normalized_params = self._normalize_params(params) + return await self._require_action_service().call_action_data(normalized_action_name, normalized_params) + + @API("adapter.napcat.action.call", description="调用任意 OneBot 动作", version="1", public=True) + async def api_call_action( + self, + action_name: str = "", + params: NapCatApiParamsInput = None, + ) -> NapCatActionResponse: + """调用任意 OneBot 动作。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action(action_name, params) + + @API( + "adapter.napcat.action.call_data", description="调用任意 OneBot 动作并返回 data 字段", version="1", public=True + ) + async def api_call_action_data( + self, + action_name: str = "", + params: NapCatApiParamsInput = None, + ) -> Any: + """调用任意 OneBot 动作并返回 ``data`` 字段。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Any: NapCat 响应中的 ``data`` 字段。 + """ + return await self._call_napcat_action_data(action_name, params) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/apis/system.py b/plugin-templates/MaiBot-Napcat-Adapter/apis/system.py new file mode 100644 index 00000000..df9601b6 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/apis/system.py @@ -0,0 +1,290 @@ +"""NapCat 系统与运行时 API 端点。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from maibot_sdk import API + +from .support import NapCatApiParamsInput, NapCatApiSupportMixin + + +class NapCatSystemApiMixin(NapCatApiSupportMixin): + """NapCat 系统状态、凭证与运行控制相关 API。""" + + @API("adapter.napcat.system.get_login_info", description="获取当前登录账号信息", version="1", public=True) + async def api_get_login_info(self) -> Optional[Dict[str, Any]]: + """获取当前登录账号信息。 + + Returns: + Optional[Dict[str, Any]]: 登录信息字典;失败时返回 ``None``。 + """ + return await self._require_query_service().get_login_info() + + @API("adapter.napcat.system.bot_exit", description="退出登录", version="1", public=True) + async def api_action_bot_exit(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``bot_exit`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("bot_exit", params) + + @API("adapter.napcat.system.can_send_image", description="是否可以发送图片", version="1", public=True) + async def api_action_can_send_image(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``can_send_image`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("can_send_image", params) + + @API("adapter.napcat.system.can_send_record", description="是否可以发送语音", version="1", public=True) + async def api_action_can_send_record(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``can_send_record`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("can_send_record", params) + + @API("adapter.napcat.system.check_url_safely", description="检查URL安全性", version="1", public=True) + async def api_action_check_url_safely(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``check_url_safely`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("check_url_safely", params) + + @API("adapter.napcat.system.clean_cache", description="清理缓存", version="1", public=True) + async def api_action_clean_cache(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``clean_cache`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("clean_cache", params) + + @API("adapter.napcat.system.get_credentials", description="获取登录凭证", version="1", public=True) + async def api_action_get_credentials(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_credentials`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_credentials", params) + + @API("adapter.napcat.system.get_csrf_token", description="获取 CSRF Token", version="1", public=True) + async def api_action_get_csrf_token(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_csrf_token`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_csrf_token", params) + + @API( + "adapter.napcat.system.get_doubt_friends_add_request", description="获取可疑好友申请", version="1", public=True + ) + async def api_action_get_doubt_friends_add_request(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_doubt_friends_add_request`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_doubt_friends_add_request", params) + + @API("adapter.napcat.system.get_model_show", description="获取机型显示", version="1", public=True) + async def api_action_get_model_show(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``_get_model_show`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("_get_model_show", params) + + @API("adapter.napcat.system.get_online_clients", description="获取在线客户端", version="1", public=True) + async def api_action_get_online_clients(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_online_clients`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_online_clients", params) + + @API("adapter.napcat.system.get_robot_uin_range", description="获取机器人 UIN 范围", version="1", public=True) + async def api_action_get_robot_uin_range(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_robot_uin_range`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_robot_uin_range", params) + + @API("adapter.napcat.system.get_status", description="获取运行状态", version="1", public=True) + async def api_action_get_status(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_status`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_status", params) + + @API("adapter.napcat.system.get_version_info", description="获取版本信息", version="1", public=True) + async def api_action_get_version_info(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``get_version_info`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("get_version_info", params) + + @API("adapter.napcat.system.nc_get_packet_status", description="获取Packet状态", version="1", public=True) + async def api_action_nc_get_packet_status(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``nc_get_packet_status`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("nc_get_packet_status", params) + + @API("adapter.napcat.system.nc_get_user_status", description="获取用户在线状态", version="1", public=True) + async def api_action_nc_get_user_status(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``nc_get_user_status`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("nc_get_user_status", params) + + @API("adapter.napcat.system.send_packet", description="发送原始数据包", version="1", public=True) + async def api_action_send_packet(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``send_packet`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("send_packet", params) + + @API( + "adapter.napcat.system.set_doubt_friends_add_request", description="处理可疑好友申请", version="1", public=True + ) + async def api_action_set_doubt_friends_add_request(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_doubt_friends_add_request`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_doubt_friends_add_request", params) + + @API("adapter.napcat.system.set_input_status", description="设置输入状态", version="1", public=True) + async def api_action_set_input_status(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_input_status`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_input_status", params) + + @API("adapter.napcat.system.set_model_show", description="设置机型", version="1", public=True) + async def api_action_set_model_show(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``_set_model_show`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("_set_model_show", params) + + @API("adapter.napcat.system.set_online_status", description="设置在线状态", version="1", public=True) + async def api_action_set_online_status(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_online_status`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_online_status", params) + + @API("adapter.napcat.system.set_restart", description="重启服务", version="1", public=True) + async def api_action_set_restart(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``set_restart`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("set_restart", params) + + @API("adapter.napcat.system.unknown_action", description="unknown", version="1", public=True) + async def api_action_unknown_action(self, params: NapCatApiParamsInput = None) -> Dict[str, Any]: + """调用 NapCat 的 ``unknown`` 动作。 + + Args: + params: 传递给 NapCat 的动作参数字典;具体字段请参考 NapCat 官方文档。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + """ + return await self._call_napcat_action("unknown", params) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/__init__.py new file mode 100644 index 00000000..3acf779f --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/__init__.py @@ -0,0 +1 @@ +"""NapCat 编解码组件导出。""" diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/__init__.py new file mode 100644 index 00000000..1af8b577 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/__init__.py @@ -0,0 +1,5 @@ +"""NapCat 入站编解码导出。""" + +from .message_codec import NapCatInboundCodec + +__all__ = ["NapCatInboundCodec"] diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py new file mode 100644 index 00000000..3c7131d9 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/cards.py @@ -0,0 +1,545 @@ +"""NapCat 入站 JSON 卡片解析辅助。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Mapping, Optional + +import hashlib +import json +import re + +from ...qq_emoji_list import QQ_FACE +from ...types import NapCatSegment, NapCatSegments + +if TYPE_CHECKING: + from ...services import NapCatQueryService + + +class NapCatInboundCardMixin: + """封装入站 JSON 卡片与预览内容转换逻辑。""" + + if TYPE_CHECKING: + _query_service: NapCatQueryService + + @staticmethod + def _build_text_segment(text: str) -> NapCatSegment: ... + + @staticmethod + def _encode_binary(binary_data: bytes) -> str: ... + + async def _build_json_segments(self, segment_data: Mapping[str, Any]) -> NapCatSegments: + """将 JSON 卡片最佳努力转换为消息段列表。 + + Args: + segment_data: OneBot ``json`` 段的 ``data`` 字典。 + + Returns: + NapCatSegments: 转换后的消息段列表。 + """ + json_data = str(segment_data.get("data") or "").strip() + if not json_data: + return [self._build_text_segment("[json]")] + + try: + parsed_json = json.loads(json_data) + except Exception: + return [self._build_text_segment("[json]")] + + if not isinstance(parsed_json, Mapping): + return [self._build_text_segment("[json]")] + + app_name = str(parsed_json.get("app") or "").strip() + meta = parsed_json.get("meta", {}) + if not isinstance(meta, Mapping): + meta = {} + + if app_name == "com.tencent.mannounce": + return [self._build_mannounce_segment(meta)] + + if app_name in {"com.tencent.music.lua", "com.tencent.structmsg"}: + music_segments = self._build_music_card_segments(meta) + if music_segments: + return music_segments + + if app_name == "com.tencent.miniapp_01": + return await self._build_preview_text_segments( + self._build_miniapp_text(meta), + self._extract_preview_url(meta, "detail_1"), + ) + + if app_name == "com.tencent.giftmall.giftark": + gift_text = self._build_gift_text(meta) + if gift_text: + return [self._build_text_segment(gift_text)] + + if app_name == "com.tencent.contact.lua": + return [self._build_text_segment(self._build_contact_text(meta, "推荐联系人"))] + + if app_name == "com.tencent.troopsharecard": + return [self._build_text_segment(self._build_contact_text(meta, "推荐群聊"))] + + if app_name == "com.tencent.tuwen.lua": + return await self._build_preview_text_segments( + self._build_news_text(meta, default_tag="图文分享"), + self._extract_preview_url(meta, "news"), + ) + + if app_name == "com.tencent.feed.lua": + return await self._build_preview_text_segments( + self._build_feed_text(meta), + self._extract_preview_url(meta, "feed", field_name="cover"), + ) + + if app_name == "com.tencent.template.qqfavorite.share": + return await self._build_preview_text_segments( + self._build_favorite_text(meta), + self._extract_preview_url(meta, "news"), + ) + + if app_name == "com.tencent.miniapp.lua": + return await self._build_preview_text_segments( + self._build_simple_title_text(meta, "miniapp", "QQ空间"), + self._extract_preview_url(meta, "miniapp"), + ) + + if app_name == "com.tencent.forum": + forum_segments = await self._build_forum_segments(meta) + if forum_segments: + return forum_segments + + if app_name == "com.tencent.map": + location_text = self._build_location_text(meta) + if location_text: + return [self._build_text_segment(location_text)] + + if app_name == "com.tencent.together": + together_text = self._build_together_text(meta) + if together_text: + return [self._build_text_segment(together_text)] + + prompt = str(parsed_json.get("prompt") or "").strip() + if not prompt and isinstance(meta, Mapping): + prompt = str(meta.get("prompt") or "").strip() + text = prompt or app_name or "json" + return [self._build_text_segment(f"[json:{text}]")] + + def _build_mannounce_segment(self, meta: Mapping[str, Any]) -> NapCatSegment: + """构造群公告文本段。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + NapCatSegment: 群公告文本段。 + """ + mannounce = meta.get("mannounce", {}) + if not isinstance(mannounce, Mapping): + mannounce = {} + + title = str(mannounce.get("title") or "").strip() + text = str(mannounce.get("text") or "").strip() + encode_flag = mannounce.get("encode") + if encode_flag == 1: + title = self._safe_base64_decode(title) + text = self._safe_base64_decode(text) + + if title and text: + content = f"[{title}]:{text}" + elif title: + content = f"[{title}]" + elif text: + content = text + else: + content = "[群公告]" + return self._build_text_segment(content) + + def _build_music_card_segments(self, meta: Mapping[str, Any]) -> NapCatSegments: + """构造音乐卡片文本段。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + NapCatSegments: 音乐卡片转换后的消息段列表。 + """ + music = meta.get("music", {}) + if not isinstance(music, Mapping): + return [] + + title = str(music.get("title") or "").strip() + singer = str(music.get("desc") or music.get("singer") or "").strip() + tag = str(music.get("tag") or "音乐分享").strip() + text_parts: List[str] = [f"[{tag}]"] + if title: + text_parts.append(title) + if singer: + text_parts.append(f"- {singer}") + content = " ".join(text_parts).strip() or "[音乐分享]" + return [self._build_text_segment(content)] + + async def _build_preview_text_segments( + self, + text: str, + preview_url: str, + ) -> NapCatSegments: + """构造“文本 + 预览图”消息段列表。 + + Args: + text: 主文本内容。 + preview_url: 预览图地址。 + + Returns: + NapCatSegments: 转换后的消息段列表。 + """ + segments: NapCatSegments = [self._build_text_segment(text or "[卡片消息]")] + image_segment = await self._build_remote_image_segment(preview_url) + if image_segment is not None: + segments.append(image_segment) + return segments + + async def _build_remote_image_segment(self, image_url: str) -> Optional[NapCatSegment]: + """从远端图片地址构造图片消息段。 + + Args: + image_url: 图片地址。 + + Returns: + Optional[NapCatSegment]: 成功时返回图片消息段,否则返回 ``None``。 + """ + normalized_url = str(image_url or "").strip() + if not normalized_url: + return None + + binary_data = await self._query_service.download_binary(normalized_url) + if not binary_data: + return None + + return { + "type": "image", + "data": "", + "hash": hashlib.sha256(binary_data).hexdigest(), + "binary_data_base64": self._encode_binary(binary_data), + } + + def _build_miniapp_text(self, meta: Mapping[str, Any]) -> str: + """构造小程序分享文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + str: 小程序分享文本。 + """ + detail = meta.get("detail_1", {}) + if not isinstance(detail, Mapping): + return "[小程序]" + title = str(detail.get("title") or "").strip() + desc = str(detail.get("desc") or "").strip() + if title and desc: + return f"[小程序] {title}:{desc}" + if title: + return f"[小程序] {title}" + if desc: + return f"[小程序] {desc}" + return "[小程序]" + + def _build_gift_text(self, meta: Mapping[str, Any]) -> str: + """构造礼物卡片文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + str: 礼物卡片文本。 + """ + giftark = meta.get("giftark", {}) + if not isinstance(giftark, Mapping): + return "[赠送礼物]" + gift_name = str(giftark.get("title") or "礼物").strip() + desc = str(giftark.get("desc") or "").strip() + if desc: + return f"[赠送礼物: {gift_name}] {desc}" + return f"[赠送礼物: {gift_name}]" + + def _build_contact_text(self, meta: Mapping[str, Any], default_tag: str) -> str: + """构造推荐联系人或群聊文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + default_tag: 默认标签文本。 + + Returns: + str: 推荐对象文本。 + """ + contact = meta.get("contact", {}) + if not isinstance(contact, Mapping): + return f"[{default_tag}]" + name = str(contact.get("nickname") or "未知对象").strip() + tag = str(contact.get("tag") or default_tag).strip() or default_tag + return f"[{tag}] {name}" + + def _build_news_text(self, meta: Mapping[str, Any], default_tag: str) -> str: + """构造图文分享文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + default_tag: 默认标签文本。 + + Returns: + str: 图文分享文本。 + """ + news = meta.get("news", {}) + if not isinstance(news, Mapping): + return f"[{default_tag}]" + title = str(news.get("title") or "未知标题").strip() + desc = str(news.get("desc") or "").replace("[图片]", "").strip() + tag = str(news.get("tag") or default_tag).strip() or default_tag + if tag and title and tag in title: + title = self._trim_card_title(title.replace(tag, "", 1)) + if desc: + return f"[{tag}] {title}:{desc}" + return f"[{tag}] {title}".strip() + + def _build_feed_text(self, meta: Mapping[str, Any]) -> str: + """构造群相册分享文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + str: 群相册分享文本。 + """ + feed = meta.get("feed", {}) + if not isinstance(feed, Mapping): + return "[群相册]" + title = str(feed.get("title") or "群相册").strip() + tag = str(feed.get("tagName") or "群相册").strip() or "群相册" + desc = str(feed.get("forwardMessage") or "").strip() + if tag and title and tag in title: + title = self._trim_card_title(title.replace(tag, "", 1)) + if desc: + return f"[{tag}] {title}:{desc}" + return f"[{tag}] {title}".strip() + + def _build_favorite_text(self, meta: Mapping[str, Any]) -> str: + """构造 QQ 收藏分享文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + str: QQ 收藏分享文本。 + """ + news = meta.get("news", {}) + if not isinstance(news, Mapping): + return "[QQ收藏]" + desc = str(news.get("desc") or "").replace("[图片]", "").strip() + tag = str(news.get("tag") or "QQ收藏").strip() or "QQ收藏" + if desc: + return f"[{tag}] {desc}" + return f"[{tag}]" + + def _build_simple_title_text( + self, + meta: Mapping[str, Any], + key: str, + default_tag: str, + ) -> str: + """构造简单标题类卡片文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + key: 子对象键名。 + default_tag: 默认标签文本。 + + Returns: + str: 简单标题文本。 + """ + nested_payload = meta.get(key, {}) + if not isinstance(nested_payload, Mapping): + return f"[{default_tag}]" + title = str(nested_payload.get("title") or "未知标题").strip() + tag = str(nested_payload.get("tag") or default_tag).strip() or default_tag + return f"[{tag}] {title}".strip() + + async def _build_forum_segments(self, meta: Mapping[str, Any]) -> NapCatSegments: + """构造 QQ 频道帖子消息段。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + NapCatSegments: 频道帖子转换后的消息段列表。 + """ + detail = meta.get("detail", {}) + if not isinstance(detail, Mapping): + return [] + + feed = detail.get("feed", {}) + poster = detail.get("poster", {}) + channel_info = detail.get("channel_info", {}) + if not isinstance(feed, Mapping) or not isinstance(poster, Mapping) or not isinstance(channel_info, Mapping): + return [] + + guild_name = str(channel_info.get("guild_name") or "").strip() + nick = str(poster.get("nick") or "QQ用户").strip() or "QQ用户" + title = self._extract_forum_title(feed) + face_content = self._extract_forum_face_text(feed) + + text_prefix = "[频道帖子]" + if guild_name: + text_prefix = f"{text_prefix} [{guild_name}]" + text_content = f"{text_prefix}{nick}:{title}{face_content}" + segments: NapCatSegments = [self._build_text_segment(text_content)] + + images = feed.get("images", []) + if not isinstance(images, list): + return segments + + for image_item in images: + if not isinstance(image_item, Mapping): + continue + image_segment = await self._build_remote_image_segment(str(image_item.get("pic_url") or "").strip()) + if image_segment is not None: + segments.append(image_segment) + return segments + + def _extract_forum_title(self, feed: Mapping[str, Any]) -> str: + """提取 QQ 频道帖子标题。 + + Args: + feed: 频道帖子 ``feed`` 数据。 + + Returns: + str: 帖子标题。 + """ + title_payload = feed.get("title", {}) + if not isinstance(title_payload, Mapping): + return "帖子" + contents = title_payload.get("contents", []) + if not isinstance(contents, list) or not contents: + return "帖子" + first_content = contents[0] + if not isinstance(first_content, Mapping): + return "帖子" + text_content = first_content.get("text_content", {}) + if not isinstance(text_content, Mapping): + return "帖子" + return str(text_content.get("text") or "帖子").strip() or "帖子" + + def _extract_forum_face_text(self, feed: Mapping[str, Any]) -> str: + """提取 QQ 频道帖子中的表情文本。 + + Args: + feed: 频道帖子 ``feed`` 数据。 + + Returns: + str: 合并后的表情文本。 + """ + contents_payload = feed.get("contents", {}) + if not isinstance(contents_payload, Mapping): + return "" + contents = contents_payload.get("contents", []) + if not isinstance(contents, list): + return "" + + face_text_parts: List[str] = [] + for item in contents: + if not isinstance(item, Mapping): + continue + emoji_content = item.get("emoji_content", {}) + if not isinstance(emoji_content, Mapping): + continue + emoji_id = str(emoji_content.get("id") or "").strip() + if emoji_id in QQ_FACE: + face_text_parts.append(QQ_FACE[emoji_id]) + return "".join(face_text_parts) + + def _build_location_text(self, meta: Mapping[str, Any]) -> str: + """构造位置分享文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + str: 位置分享文本。 + """ + location = meta.get("Location.Search", {}) + if not isinstance(location, Mapping): + return "[位置]" + name = str(location.get("name") or "未知地点").strip() + address = str(location.get("address") or "").strip() + if address: + return f"[位置] {address} · {name}" + return f"[位置] {name}" + + def _build_together_text(self, meta: Mapping[str, Any]) -> str: + """构造“一起听歌”文本。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + + Returns: + str: 一起听歌文本。 + """ + invite = meta.get("invite", {}) + if not isinstance(invite, Mapping): + return "[一起听歌]" + title = str(invite.get("title") or "一起听歌").strip() or "一起听歌" + summary = str(invite.get("summary") or "").strip() + if summary: + return f"[{title}] {summary}" + return f"[{title}]" + + def _extract_preview_url( + self, + meta: Mapping[str, Any], + key: str, + field_name: str = "preview", + ) -> str: + """从卡片元数据中提取预览图地址。 + + Args: + meta: JSON 卡片 ``meta`` 数据。 + key: 子对象键名。 + field_name: 预览图字段名。 + + Returns: + str: 预览图地址;不存在时返回空字符串。 + """ + nested_payload = meta.get(key, {}) + if not isinstance(nested_payload, Mapping): + return "" + return str(nested_payload.get(field_name) or "").strip() + + @staticmethod + def _trim_card_title(title: str) -> str: + """清理卡片标题两侧的常见分隔符。 + + Args: + title: 原始标题文本。 + + Returns: + str: 清理后的标题文本。 + """ + return re.sub(r"^[::\s\-—]+|[::\s\-—]+$", "", str(title or "").strip()) + + @staticmethod + def _safe_base64_decode(encoded_text: str) -> str: + """安全地解码 Base64 文本。 + + Args: + encoded_text: 待解码的 Base64 文本。 + + Returns: + str: 解码结果;失败时返回原始文本。 + """ + normalized_text = str(encoded_text or "").strip() + if not normalized_text: + return "" + try: + import base64 + + return base64.b64decode(normalized_text).decode("utf-8", errors="ignore") + except Exception: + return normalized_text diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/message_codec.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/message_codec.py new file mode 100644 index 00000000..daf22424 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/message_codec.py @@ -0,0 +1,661 @@ +"""NapCat 入站消息编解码。""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Tuple +from uuid import uuid4 + +import hashlib +import time + +from ...qq_emoji_list import QQ_FACE +from ...services import NapCatQueryService +from ...types import NapCatIncomingSegment, NapCatIncomingSegments, NapCatPayload, NapCatSegment, NapCatSegments +from ..notice.helpers import normalize_optional_string +from .cards import NapCatInboundCardMixin +from .text import NapCatInboundTextMixin + + +class NapCatInboundCodec(NapCatInboundCardMixin, NapCatInboundTextMixin): + """NapCat 入站消息编码器。""" + + def __init__(self, logger: Any, query_service: NapCatQueryService) -> None: + """初始化入站消息编码器。 + + Args: + logger: 插件日志对象。 + query_service: QQ 查询服务。 + """ + self._logger = logger + self._query_service = query_service + + async def build_message_dict( + self, + payload: NapCatPayload, + self_id: str, + sender_user_id: str, + sender: Mapping[str, Any], + ) -> Dict[str, Any]: + """构造 Host 侧可接受的 ``MessageDict``。 + + Args: + payload: NapCat 原始消息事件。 + self_id: 当前机器人账号 ID。 + sender_user_id: 发送者用户 ID。 + sender: 发送者信息字典。 + + Returns: + Dict[str, Any]: 规范化后的 ``MessageDict``。 + """ + message_type = str(payload.get("message_type") or "").strip() or "private" + group_id = str(payload.get("group_id") or "").strip() + group_name = str(payload.get("group_name") or "").strip() or (f"group_{group_id}" if group_id else "") + user_nickname = str(sender.get("nickname") or sender.get("card") or sender_user_id).strip() or sender_user_id + user_cardname = str(sender.get("card") or "").strip() or None + + raw_message, is_at = await self.convert_segments(payload, self_id) + if not raw_message: + raw_message = [self._build_text_segment("[unsupported]")] + + plain_text = self.build_plain_text(raw_message) + timestamp_seconds = payload.get("time") + if not isinstance(timestamp_seconds, (int, float)): + timestamp_seconds = time.time() + + additional_config: Dict[str, Any] = {"self_id": self_id, "napcat_message_type": message_type} + if group_id: + additional_config["platform_io_target_group_id"] = group_id + else: + additional_config["platform_io_target_user_id"] = sender_user_id + + message_info: Dict[str, Any] = { + "user_info": { + "user_id": sender_user_id, + "user_nickname": user_nickname, + "user_cardname": user_cardname, + }, + "additional_config": additional_config, + } + if group_id: + message_info["group_info"] = {"group_id": group_id, "group_name": group_name} + + message_id = str(payload.get("message_id") or f"napcat-{uuid4().hex}").strip() + return { + "message_id": message_id, + "timestamp": str(float(timestamp_seconds)), + "platform": "qq", + "message_info": message_info, + "raw_message": raw_message, + "is_mentioned": is_at, + "is_at": is_at, + "is_emoji": False, + "is_picture": False, + "is_command": plain_text.startswith("/"), + "is_notify": False, + "session_id": "", + "processed_plain_text": plain_text, + "display_message": plain_text, + } + + async def convert_segments(self, payload: NapCatPayload, self_id: str) -> Tuple[NapCatSegments, bool]: + """将 OneBot 消息段转换为 Host 消息段结构。 + + Args: + payload: OneBot 原始消息事件。 + self_id: 当前机器人账号 ID。 + + Returns: + Tuple[NapCatSegments, bool]: 转换后的消息段列表,以及是否 @ 到当前机器人。 + + Raises: + ValueError: 当载荷缺少结构化 ``message`` 段列表时抛出。 + """ + message_payload = self._require_message_segments(payload) + group_id = str(payload.get("group_id") or "").strip() + return await self._convert_incoming_segments(message_payload, self_id, group_id) + + def _require_message_segments(self, payload: NapCatPayload) -> NapCatIncomingSegments: + """从 NapCat 载荷中提取结构化消息段列表。 + + Args: + payload: NapCat / OneBot 原始载荷。 + + Returns: + NapCatIncomingSegments: 规范化后的结构化消息段列表。 + + Raises: + ValueError: 当 ``message`` 字段不是结构化段列表时抛出。 + """ + message_payload = payload.get("message") + if not isinstance(message_payload, list): + raise ValueError("NapCat 入站消息缺少结构化 message 段列表") + + normalized_segments = self._normalize_incoming_segments(message_payload) + if not normalized_segments: + raise ValueError("NapCat 入站消息未包含可识别的结构化消息段") + return normalized_segments + + def _normalize_incoming_segments(self, message_payload: List[Any]) -> NapCatIncomingSegments: + """规范化 NapCat / OneBot 原始消息段列表。 + + Args: + message_payload: 原始 ``message`` 字段值。 + + Returns: + NapCatIncomingSegments: 过滤并标准化后的消息段列表。 + """ + normalized_segments: NapCatIncomingSegments = [] + for segment in message_payload: + if not isinstance(segment, Mapping): + continue + segment_type = str(segment.get("type") or "").strip() + segment_data = segment.get("data", {}) + if not segment_type or not isinstance(segment_data, Mapping): + continue + normalized_segments.append( + NapCatIncomingSegment( + type=segment_type, + data=dict(segment_data), + ) + ) + return normalized_segments + + async def _convert_incoming_segments( + self, + message_payload: NapCatIncomingSegments, + self_id: str, + group_id: str, + ) -> Tuple[NapCatSegments, bool]: + """将结构化 OneBot 消息段转换为 Host 消息段结构。 + + Args: + message_payload: NapCat / OneBot 结构化消息段列表。 + self_id: 当前机器人账号 ID。 + group_id: 当前消息所在群号;私聊消息为空字符串。 + + Returns: + Tuple[NapCatSegments, bool]: 转换后的消息段列表,以及是否 @ 到当前机器人。 + """ + converted_segments: NapCatSegments = [] + at_target_cache: Dict[str, Tuple[Optional[str], Optional[str]]] = {} + is_at = False + for segment in message_payload: + segment_type = str(segment.get("type") or "").strip() + segment_data = segment.get("data", {}) + if not isinstance(segment_data, Mapping): + segment_data = {} + + if segment_type == "text": + if text_value := str(segment_data.get("text") or ""): + converted_segments.append(self._build_text_segment(text_value)) + continue + + if segment_type == "at": + if target_user_id := str(segment_data.get("qq") or "").strip(): + if target_user_id in at_target_cache: + target_user_nickname, target_user_cardname = at_target_cache[target_user_id] + else: + target_user_nickname, target_user_cardname = await self._resolve_at_target_info( + group_id=group_id, + target_user_id=target_user_id, + ) + at_target_cache[target_user_id] = (target_user_nickname, target_user_cardname) + + converted_segments.append( + { + "type": "at", + "data": { + "target_user_id": target_user_id, + "target_user_nickname": target_user_nickname, + "target_user_cardname": target_user_cardname, + }, + } + ) + if self_id and target_user_id == self_id: + is_at = True + continue + + if segment_type == "reply": + if reply_segment := await self._build_reply_segment(segment_data): + converted_segments.append(reply_segment) + continue + + if segment_type == "face": + converted_segments.append(self._build_face_text_segment(segment_data)) + continue + + if segment_type == "image": + converted_segments.append(await self._build_image_like_segment(segment_data, is_emoji=False)) + continue + + if segment_type == "record": + converted_segments.append(await self._build_record_segment(segment_data)) + continue + + if segment_type == "video": + converted_segments.append(self._build_video_text_segment(segment_data)) + continue + + if segment_type == "file": + converted_segments.append(self._build_file_text_segment(segment_data)) + continue + + if segment_type == "json": + converted_segments.extend(await self._build_json_segments(segment_data)) + continue + + if segment_type == "forward": + if forward_segment := await self._build_forward_segment(segment_data): + converted_segments.append(forward_segment) + continue + + if segment_type in {"xml", "share"}: + converted_segments.append(self._build_text_segment(f"[{segment_type}]")) + + return converted_segments, is_at + + async def _resolve_at_target_info( + self, + group_id: str, + target_user_id: str, + ) -> Tuple[Optional[str], Optional[str]]: + """解析 ``at`` 目标的展示信息。 + + Args: + group_id: 当前消息所在群号;私聊消息为空字符串。 + target_user_id: 被 ``at`` 的用户号。 + + Returns: + Tuple[Optional[str], Optional[str]]: 依次返回 QQ 昵称和群昵称。 + """ + if not target_user_id or target_user_id == "all": + return None, None + + target_user_nickname: Optional[str] = None + target_user_cardname: Optional[str] = None + + if group_id: + member_info = await self._query_service.get_group_member_info(group_id, target_user_id, no_cache=True) + if member_info is not None: + target_user_nickname = normalize_optional_string(member_info.get("nickname")) + target_user_cardname = normalize_optional_string(member_info.get("card")) + + if target_user_nickname or target_user_cardname: + return target_user_nickname, target_user_cardname + + stranger_info = await self._query_service.get_stranger_info(target_user_id) + if stranger_info is None: + return None, None + + return normalize_optional_string(stranger_info.get("nickname")), target_user_cardname + + @staticmethod + def _build_text_segment(text: str) -> NapCatSegment: + """构造一条纯文本 Host 消息段。 + + Args: + text: 文本内容。 + + Returns: + NapCatSegment: Host 侧纯文本消息段。 + """ + return {"type": "text", "data": text} + + async def _build_reply_segment(self, segment_data: Mapping[str, Any]) -> Optional[NapCatSegment]: + """构造回复消息段。 + + Args: + segment_data: OneBot ``reply`` 段的 ``data`` 字典。 + + Returns: + Optional[NapCatSegment]: 转换后的回复消息段;缺少消息 ID 时返回 ``None``。 + """ + target_message_id = str(segment_data.get("id") or "").strip() + if not target_message_id: + return None + + message_detail = await self._query_service.get_message_detail(target_message_id) + reply_payload: Dict[str, Any] = {"target_message_id": target_message_id} + if message_detail is not None: + sender = message_detail.get("sender", {}) + if not isinstance(sender, Mapping): + sender = {} + reply_payload["target_message_content"] = await self._build_reply_preview_text(message_detail) + reply_payload["target_message_sender_id"] = ( + str(message_detail.get("user_id") or sender.get("user_id") or "").strip() or None + ) + reply_payload["target_message_sender_nickname"] = str(sender.get("nickname") or "").strip() or None + reply_payload["target_message_sender_cardname"] = str(sender.get("card") or "").strip() or None + + return {"type": "reply", "data": reply_payload} + + async def _build_reply_preview_text(self, message_detail: NapCatPayload) -> Optional[str]: + """为回复引用构造结构化消息预览文本。 + + Args: + message_detail: ``get_msg`` 返回的消息详情。 + + Returns: + Optional[str]: 基于结构化消息段生成的预览文本;无法生成时返回 ``None``。 + """ + try: + reply_segments, _ = await self.convert_segments(message_detail, "") + except ValueError: + return None + + if not reply_segments: + return None + return self.build_plain_text(reply_segments) + + async def _build_image_like_segment( + self, + segment_data: Mapping[str, Any], + is_emoji: bool, + ) -> NapCatSegment: + """构造图片或表情消息段。 + + Args: + segment_data: OneBot ``image`` 段的 ``data`` 字典。 + is_emoji: 是否按表情组件处理。 + + Returns: + NapCatSegment: 转换后的图片或表情消息段。 + """ + subtype = self._normalize_numeric_segment_value(segment_data.get("sub_type")) + actual_is_emoji = is_emoji or (subtype is not None and subtype not in {0, 4, 9}) + + image_url = str(segment_data.get("url") or "").strip() + binary_data = await self._query_service.download_binary(image_url) + if not binary_data: + return self._build_text_segment("[emoji]" if actual_is_emoji else "[image]") + + return { + "type": "emoji" if actual_is_emoji else "image", + "data": "", + "hash": hashlib.sha256(binary_data).hexdigest(), + "binary_data_base64": self._encode_binary(binary_data), + } + + async def _build_record_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: + """构造语音消息段。 + + Args: + segment_data: OneBot ``record`` 段的 ``data`` 字典。 + + Returns: + NapCatSegment: 转换后的语音或占位文本消息段。 + """ + file_name = str(segment_data.get("file") or "").strip() + file_id = str(segment_data.get("file_id") or "").strip() or None + if not file_name: + return self._build_text_segment("[voice]") + + record_detail = await self._query_service.get_record_detail(file_name=file_name, file_id=file_id) + if record_detail is None: + return self._build_text_segment("[voice]") + + record_base64 = str(record_detail.get("base64") or "").strip() + if not record_base64: + return self._build_text_segment("[voice]") + + try: + binary_data = self._decode_binary(record_base64) + except Exception: + return self._build_text_segment("[voice]") + + return { + "type": "voice", + "data": "", + "hash": hashlib.sha256(binary_data).hexdigest(), + "binary_data_base64": self._encode_binary(binary_data), + } + + def _build_face_text_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: + """构造 QQ 原生表情文本段。 + + Args: + segment_data: OneBot ``face`` 段的 ``data`` 字典。 + + Returns: + NapCatSegment: 转换后的文本消息段。 + """ + face_id = str(segment_data.get("id") or "").strip() + face_text = QQ_FACE.get(face_id, "[表情]") + return self._build_text_segment(face_text) + + def _build_video_text_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: + """构造视频消息的可读文本段。 + + Args: + segment_data: OneBot ``video`` 段的 ``data`` 字典。 + + Returns: + NapCatSegment: 转换后的文本消息段。 + """ + file_name = str(segment_data.get("file") or "").strip() + file_size = str(segment_data.get("file_size") or "").strip() + parts: List[str] = [] + if file_name: + parts.append(f"文件: {file_name}") + if file_size: + parts.append(f"大小: {file_size}") + if parts: + return self._build_text_segment(f"[视频] {','.join(parts)}") + return self._build_text_segment("[视频]") + + def _build_file_text_segment(self, segment_data: Mapping[str, Any]) -> NapCatSegment: + """构造文件消息的可读文本段。 + + Args: + segment_data: OneBot ``file`` 段的 ``data`` 字典。 + + Returns: + NapCatSegment: 转换后的文本消息段。 + """ + file_name = str(segment_data.get("file") or segment_data.get("name") or "").strip() + file_size = str(segment_data.get("file_size") or "").strip() + file_url = str(segment_data.get("url") or "").strip() + text_parts: List[str] = [] + if file_name: + text_parts.append(file_name) + if file_size: + text_parts.append(f"大小: {file_size}") + file_text = "[文件]" + if text_parts: + file_text = f"[文件] {','.join(text_parts)}" + if file_url: + file_text = f"{file_text},链接: {file_url}" + return self._build_text_segment(file_text) + + async def _build_forward_segment(self, segment_data: Mapping[str, Any]) -> Optional[NapCatSegment]: + """构造合并转发消息段。 + + Args: + segment_data: OneBot ``forward`` 段的 ``data`` 字典。 + + Returns: + Optional[NapCatSegment]: 转换后的合并转发消息段;失败时返回 ``None``。 + """ + inline_messages = self._extract_forward_messages(segment_data) + messages = inline_messages + + if messages is None: + message_id = str(segment_data.get("id") or "").strip() + if not message_id: + return None + + forward_detail = await self._query_service.get_forward_message(message_id) + if forward_detail is None: + return self._build_text_segment("[forward]") + + messages = self._extract_forward_messages(forward_detail) + + if not isinstance(messages, list): + return self._build_text_segment("[forward]") + + forward_nodes = await self._build_forward_nodes(messages) + if not forward_nodes: + return self._build_text_segment("[forward]") + return {"type": "forward", "data": forward_nodes} + + def _extract_forward_messages(self, payload: Mapping[str, Any]) -> Optional[List[Any]]: + """从转发载荷中提取节点列表。 + + Args: + payload: 转发段 ``data`` 或 ``get_forward_msg`` 返回的载荷。 + + Returns: + Optional[List[Any]]: 提取到的节点列表;当载荷中不存在节点列表时返回 ``None``。 + """ + direct_messages = payload.get("messages") + if isinstance(direct_messages, list): + return direct_messages + + direct_content = payload.get("content") + if isinstance(direct_content, list): + return direct_content + + nested_data = payload.get("data") + if isinstance(nested_data, Mapping): + nested_messages = nested_data.get("messages") + if isinstance(nested_messages, list): + return nested_messages + + nested_content = nested_data.get("content") + if isinstance(nested_content, list): + return nested_content + + return None + + async def _build_forward_nodes(self, messages: List[Any]) -> List[Dict[str, Any]]: + """将 NapCat 转发节点列表转换为 Host 转发节点列表。 + + Args: + messages: NapCat 返回的转发节点列表。 + + Returns: + List[Dict[str, Any]]: Host 侧可识别的转发节点列表。 + """ + forward_nodes: List[Dict[str, Any]] = [] + for forward_message in messages: + if not isinstance(forward_message, Mapping): + continue + + raw_content = self._extract_forward_node_content(forward_message) + content_segments = await self._convert_forward_content(raw_content, "") + sender = self._extract_forward_node_sender(forward_message) + + node_data = forward_message.get("data", {}) + if not isinstance(node_data, Mapping): + node_data = {} + + forward_nodes.append( + { + "user_id": str( + sender.get("user_id") + or sender.get("uin") + or node_data.get("user_id") + or node_data.get("uin") + or "" + ).strip() + or None, + "user_nickname": str( + sender.get("nickname") + or sender.get("name") + or node_data.get("nickname") + or node_data.get("name") + or "未知用户" + ), + "user_cardname": str(sender.get("card") or node_data.get("card") or "").strip() or None, + "message_id": str( + forward_message.get("message_id") + or forward_message.get("id") + or node_data.get("id") + or uuid4().hex + ), + "content": content_segments or [self._build_text_segment("[empty]")], + } + ) + return forward_nodes + + def _extract_forward_node_content(self, forward_message: Mapping[str, Any]) -> Any: + """提取单个转发节点中的消息段列表。 + + Args: + forward_message: NapCat 返回的单个转发节点。 + + Returns: + Any: 原始消息段列表;不存在时返回空列表。 + """ + direct_content = forward_message.get("content") + if isinstance(direct_content, list): + return direct_content + + direct_message = forward_message.get("message") + if isinstance(direct_message, list): + return direct_message + + node_data = forward_message.get("data", {}) + if not isinstance(node_data, Mapping): + return [] + + nested_content = node_data.get("content") + if isinstance(nested_content, list): + return nested_content + + nested_message = node_data.get("message") + if isinstance(nested_message, list): + return nested_message + + return [] + + def _extract_forward_node_sender(self, forward_message: Mapping[str, Any]) -> Mapping[str, Any]: + """提取单个转发节点的发送者信息。 + + Args: + forward_message: NapCat 返回的单个转发节点。 + + Returns: + Mapping[str, Any]: 归一化后的发送者信息映射。 + """ + sender = forward_message.get("sender", {}) + if isinstance(sender, Mapping): + return sender + + node_data = forward_message.get("data", {}) + if not isinstance(node_data, Mapping): + return {} + + normalized_sender: Dict[str, Any] = {} + user_id = str(node_data.get("user_id") or node_data.get("uin") or "").strip() + nickname = str(node_data.get("nickname") or node_data.get("name") or "").strip() + cardname = str(node_data.get("card") or "").strip() + if user_id: + normalized_sender["user_id"] = user_id + normalized_sender["uin"] = user_id + if nickname: + normalized_sender["nickname"] = nickname + normalized_sender["name"] = nickname + if cardname: + normalized_sender["card"] = cardname + return normalized_sender + + async def _convert_forward_content(self, raw_content: Any, self_id: str) -> NapCatSegments: + """转换转发节点内部的消息段列表。 + + Args: + raw_content: 转发节点原始内容。 + self_id: 当前机器人账号 ID。 + + Returns: + NapCatSegments: 转换后的消息段列表。 + """ + if not isinstance(raw_content, list): + return [] + + normalized_segments = self._normalize_incoming_segments(raw_content) + if not normalized_segments: + return [] + + segments, _ = await self._convert_incoming_segments(normalized_segments, self_id, "") + return segments diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/text.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/text.py new file mode 100644 index 00000000..4daffc54 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/inbound/text.py @@ -0,0 +1,90 @@ +"""NapCat 入站纯文本与二进制辅助。""" + +from __future__ import annotations + +from typing import Any, Mapping + +import base64 + +from ...types import NapCatSegments + + +class NapCatInboundTextMixin: + """封装入站纯文本与二进制辅助逻辑。""" + + def build_plain_text(self, raw_message: NapCatSegments) -> str: + """从标准消息段中提取可展示的纯文本。 + + Args: + raw_message: 标准化后的消息段列表。 + + Returns: + str: 用于 Host 展示和命令判断的纯文本内容。 + """ + plain_text_parts: list[str] = [] + for item in raw_message: + if not isinstance(item, Mapping): + continue + item_type = str(item.get("type") or "").strip() + item_data = item.get("data") + if item_type == "text": + plain_text_parts.append(str(item_data or "")) + elif item_type == "at" and isinstance(item_data, Mapping): + at_target_name = str( + item_data.get("target_user_cardname") + or item_data.get("target_user_nickname") + or item_data.get("target_user_id") + or "" + ).strip() + if at_target_name: + plain_text_parts.append(f"@{at_target_name}") + elif item_type == "reply": + plain_text_parts.append("[reply]") + elif item_type == "forward": + plain_text_parts.append("[forward]") + elif item_type in {"image", "emoji", "voice"}: + plain_text_parts.append(f"[{item_type}]") + + plain_text = "".join(part for part in plain_text_parts if part).strip() + return plain_text or "[unsupported]" + + @staticmethod + def _encode_binary(binary_data: bytes) -> str: + """将二进制内容编码为 Base64 字符串。 + + Args: + binary_data: 待编码的二进制内容。 + + Returns: + str: Base64 编码字符串。 + """ + return base64.b64encode(binary_data).decode("utf-8") + + @staticmethod + def _decode_binary(binary_base64: str) -> bytes: + """将 Base64 字符串解码为二进制内容。 + + Args: + binary_base64: Base64 字符串。 + + Returns: + bytes: 解码后的二进制内容。 + """ + return base64.b64decode(binary_base64) + + @staticmethod + def _normalize_numeric_segment_value(value: Any) -> Any: + """将可安全识别的数字字符串转为整数。 + + Args: + value: 原始字段值。 + + Returns: + Any: 规范化后的字段值。 + """ + if isinstance(value, str): + stripped_value = value.strip() + if stripped_value.isdigit(): + return int(stripped_value) + return stripped_value + return value diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/__init__.py new file mode 100644 index 00000000..f04f9199 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/__init__.py @@ -0,0 +1,5 @@ +"""NapCat 通知编解码导出。""" + +from .message_codec import NapCatNoticeCodec + +__all__ = ["NapCatNoticeCodec"] diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/enricher.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/enricher.py new file mode 100644 index 00000000..709cf227 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/enricher.py @@ -0,0 +1,72 @@ +"""NapCat 通知事件资料补全器。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +from ...services import NapCatQueryService +from .helpers import normalize_optional_string + + +class NapCatNoticeEntityResolver: + """为通知事件补全用户和群资料。""" + + def __init__(self, query_service: NapCatQueryService) -> None: + """初始化实体补全器。 + + Args: + query_service: NapCat 查询服务。 + """ + self._query_service = query_service + + async def build_user_info(self, group_id: str, user_id: str) -> Dict[str, Optional[str]]: + """构造通知消息的用户信息。 + + Args: + group_id: 群号;私聊或系统通知时为空字符串。 + user_id: 事件关联用户号。 + + Returns: + Dict[str, Optional[str]]: 规范化后的用户信息字典。 + """ + if not user_id: + return { + "user_id": "notice", + "user_nickname": "系统通知", + "user_cardname": None, + } + + member_info: Optional[Dict[str, Any]] + if group_id: + member_info = await self._query_service.get_group_member_info(group_id, user_id) + else: + member_info = await self._query_service.get_stranger_info(user_id) + + if member_info is None: + return { + "user_id": user_id, + "user_nickname": user_id, + "user_cardname": None, + } + + return { + "user_id": user_id, + "user_nickname": str(member_info.get("nickname") or user_id), + "user_cardname": normalize_optional_string(member_info.get("card")), + } + + async def build_group_info(self, group_id: str) -> Optional[Dict[str, str]]: + """构造通知消息的群信息。 + + Args: + group_id: 群号。 + + Returns: + Optional[Dict[str, str]]: 群信息字典;若不是群通知则返回 ``None``。 + """ + if not group_id: + return None + + group_info = await self._query_service.get_group_info(group_id) + group_name = str(group_info.get("group_name") or f"group_{group_id}") if group_info else f"group_{group_id}" + return {"group_id": group_id, "group_name": group_name} diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/helpers.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/helpers.py new file mode 100644 index 00000000..c05299c2 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/helpers.py @@ -0,0 +1,83 @@ +"""NapCat 通知编解码公共辅助函数。""" + +from __future__ import annotations + +from hashlib import sha1 +from typing import Any, Mapping, Optional + +import json + + +def build_payload_digest(payload: Mapping[str, Any]) -> str: + """对通知载荷生成稳定哈希。 + + Args: + payload: 原始通知载荷。 + + Returns: + str: 基于规范化 JSON 文本生成的 SHA-1 十六进制摘要。 + """ + normalized_payload = normalize_payload_value(payload) + serialized_payload = json.dumps( + normalized_payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + return sha1(serialized_payload.encode("utf-8")).hexdigest() + + +def normalize_optional_string(value: Any) -> Optional[str]: + """将任意值规范化为可选字符串。 + + Args: + value: 待规范化的值。 + + Returns: + Optional[str]: 规范化后的字符串;若值为空则返回 ``None``。 + """ + if value is None: + return None + normalized_value = str(value).strip() + return normalized_value if normalized_value else None + + +def normalize_payload_value(value: Any) -> Any: + """将通知载荷递归规范化为稳定 JSON 结构。 + + Args: + value: 待规范化的任意值。 + + Returns: + Any: 仅包含 JSON 基础类型的稳定结构。 + """ + if isinstance(value, Mapping): + return { + str(key): normalize_payload_value(child_value) + for key, child_value in sorted(value.items(), key=lambda item: str(item[0])) + } + if isinstance(value, (list, tuple)): + return [normalize_payload_value(item) for item in value] + if isinstance(value, set): + normalized_items = [normalize_payload_value(item) for item in value] + return sorted(normalized_items, key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True)) + if value is None or isinstance(value, (bool, int, float, str)): + return value + return str(value) + + +def resolve_actor_user_id(payload: Mapping[str, Any]) -> str: + """解析通知事件中的操作者用户号。 + + Args: + payload: 原始通知事件。 + + Returns: + str: 规范化后的操作者用户号;无法确定时返回空字符串。 + """ + if bool(payload.get("is_natural_lift", False)): + return "" + actor_user_id = str(payload.get("operator_id") or payload.get("user_id") or "").strip() + if actor_user_id == "0": + return "" + return actor_user_id diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/message_codec.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/message_codec.py new file mode 100644 index 00000000..35844398 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/message_codec.py @@ -0,0 +1,120 @@ +"""NapCat 通知事件编解码器。""" + +from __future__ import annotations + +from typing import Any, Dict, Optional +from uuid import uuid4 + +import time + +from ...services import NapCatQueryService +from ...types import NapCatPayload, NapCatPayloadDict +from .enricher import NapCatNoticeEntityResolver +from .helpers import build_payload_digest, resolve_actor_user_id +from .meta_event_logger import NapCatMetaEventObserver +from .renderer import NapCatNoticeTextRenderer + + +class NapCatNoticeCodec: + """NapCat QQ 通知事件编码器。""" + + def __init__(self, logger: Any, query_service: NapCatQueryService) -> None: + """初始化通知事件编码器。 + + Args: + logger: 插件日志对象。 + query_service: QQ 查询服务。 + """ + self._entity_resolver = NapCatNoticeEntityResolver(query_service) + self._meta_event_observer = NapCatMetaEventObserver(logger) + self._renderer = NapCatNoticeTextRenderer() + + async def build_notice_message_dict(self, payload: NapCatPayload) -> Optional[NapCatPayloadDict]: + """将 NapCat ``notice`` 事件转换为 Host 可接受的消息字典。 + + Args: + payload: NapCat 推送的原始通知事件。 + + Returns: + Optional[NapCatPayloadDict]: 成功时返回标准 ``MessageDict``;无法识别时返回 ``None``。 + """ + notice_type = str(payload.get("notice_type") or "").strip() + if not notice_type: + return None + + group_id = str(payload.get("group_id") or "").strip() + user_id = resolve_actor_user_id(payload) + self_id = str(payload.get("self_id") or "").strip() + + user_info = await self._entity_resolver.build_user_info(group_id=group_id, user_id=user_id) + group_info = await self._entity_resolver.build_group_info(group_id) + actor_name = user_info.get("user_nickname") or user_id or "系统" + notice_text = self._renderer.build_notice_text(payload, actor_name) + if not notice_text: + return None + + additional_config: Dict[str, Any] = { + "self_id": self_id, + "napcat_notice_type": notice_type, + "napcat_notice_sub_type": str(payload.get("sub_type") or "").strip(), + "napcat_notice_payload": dict(payload), + } + if group_id: + additional_config["platform_io_target_group_id"] = group_id + elif user_id: + additional_config["platform_io_target_user_id"] = user_id + + message_info: Dict[str, Any] = {"user_info": user_info, "additional_config": additional_config} + if group_info is not None: + message_info["group_info"] = group_info + + timestamp_seconds = payload.get("time") + if not isinstance(timestamp_seconds, (int, float)): + timestamp_seconds = time.time() + + return { + "message_id": f"napcat-notice-{uuid4().hex}", + "timestamp": str(float(timestamp_seconds)), + "platform": "qq", + "message_info": message_info, + "raw_message": [{"type": "text", "data": notice_text}], + "is_mentioned": False, + "is_at": False, + "is_emoji": False, + "is_picture": False, + "is_command": False, + "is_notify": True, + "session_id": "", + "processed_plain_text": notice_text, + "display_message": notice_text, + } + + def build_notice_dedupe_key(self, payload: NapCatPayload) -> Optional[str]: + """为 NapCat ``notice`` 事件构造稳定的技术性去重键。 + + Args: + payload: NapCat 推送的原始通知事件。 + + Returns: + Optional[str]: 若可以构造稳定去重键则返回该键,否则返回 ``None``。 + """ + external_message_id = str(payload.get("message_id") or "").strip() + if external_message_id: + return external_message_id + + notice_type = str(payload.get("notice_type") or "").strip() + if not notice_type: + return None + + sub_type = str(payload.get("sub_type") or "").strip() + payload_digest = build_payload_digest(payload) + suffix = f":{sub_type}" if sub_type else "" + return f"notice:{notice_type}{suffix}:{payload_digest}" + + async def handle_meta_event(self, payload: NapCatPayload) -> None: + """处理 ``meta_event`` 事件的日志与状态观测。 + + Args: + payload: NapCat 推送的原始元事件。 + """ + await self._meta_event_observer.handle_meta_event(payload) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/meta_event_logger.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/meta_event_logger.py new file mode 100644 index 00000000..05808bb4 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/meta_event_logger.py @@ -0,0 +1,49 @@ +"""NapCat 元事件日志处理器。""" + +from __future__ import annotations + +from typing import Any, Mapping + + +class NapCatMetaEventObserver: + """处理 NapCat 元事件的日志输出。""" + + def __init__(self, logger: Any) -> None: + """初始化元事件观察器。 + + Args: + logger: 插件日志对象。 + """ + self._logger = logger + + async def handle_meta_event(self, payload: Mapping[str, Any]) -> None: + """处理 ``meta_event`` 事件的日志与状态观测。 + + Args: + payload: NapCat 推送的原始元事件。 + """ + meta_event_type = str(payload.get("meta_event_type") or "").strip() + self_id = str(payload.get("self_id") or "").strip() or "unknown" + + if meta_event_type == "lifecycle": + sub_type = str(payload.get("sub_type") or "").strip() + if sub_type == "connect": + self._logger.info(f"NapCat 元事件:Bot {self_id} 已建立连接") + else: + self._logger.debug(f"NapCat 生命周期事件: self_id={self_id} sub_type={sub_type}") + return + + if meta_event_type == "heartbeat": + status = payload.get("status", {}) + if not isinstance(status, Mapping): + status = {} + is_online = bool(status.get("online", False)) + is_good = bool(status.get("good", False)) + interval_ms = payload.get("interval") + self._logger.debug( + f"NapCat 心跳事件: self_id={self_id} online={is_online} good={is_good} interval={interval_ms}" + ) + if not is_online: + self._logger.warning(f"NapCat 心跳显示 Bot {self_id} 已离线") + elif not is_good: + self._logger.warning(f"NapCat 心跳显示 Bot {self_id} 状态异常") diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/renderer.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/renderer.py new file mode 100644 index 00000000..1f5c4529 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/notice/renderer.py @@ -0,0 +1,63 @@ +"""NapCat 通知文本渲染器。""" + +from __future__ import annotations + +from typing import Any, Mapping + + +class NapCatNoticeTextRenderer: + """根据通知载荷生成可读文本。""" + + def build_notice_text(self, payload: Mapping[str, Any], actor_name: str) -> str: + """根据 NapCat 通知事件生成可读文本。 + + Args: + payload: 原始通知事件。 + actor_name: 事件操作者显示名。 + + Returns: + str: 生成的可读通知文本。 + """ + notice_type = str(payload.get("notice_type") or "").strip() + sub_type = str(payload.get("sub_type") or "").strip() + target_id = str(payload.get("target_id") or "").strip() + target_user_id = str(payload.get("user_id") or "").strip() + is_natural_lift = bool(payload.get("is_natural_lift", False)) + + if notice_type in {"group_recall", "friend_recall"}: + return f"{actor_name} 撤回了一条消息" + if notice_type == "notify" and sub_type == "poke": + target_text = f" -> {target_id}" if target_id else "" + return f"{actor_name} 发起了戳一戳{target_text}" + if notice_type == "notify" and sub_type == "group_name": + return f"{actor_name} 修改了群名称" + if notice_type == "group_ban" and sub_type == "ban": + duration = payload.get("duration") + if target_user_id in {"", "0"}: + return f"{actor_name} 开启了全体禁言" + return f"{actor_name} 禁言了用户 {target_user_id},时长 {duration} 秒" + if notice_type == "group_ban" and sub_type == "whole_lift_ban": + if is_natural_lift: + return "群全体禁言已自然解除" + return f"{actor_name} 解除了全体禁言" + if notice_type == "group_ban" and sub_type == "lift_ban": + if is_natural_lift: + return f"用户 {target_user_id} 的禁言已自然解除" + return f"{actor_name} 解除了用户 {target_user_id} 的禁言" + if notice_type == "group_upload": + file_info = payload.get("file", {}) + file_name = "" + if isinstance(file_info, Mapping): + file_name = str(file_info.get("name") or "").strip() + return f"{actor_name} 上传了文件{f':{file_name}' if file_name else ''}" + if notice_type == "group_increase": + return f"{actor_name} 加入了群聊" + if notice_type == "group_decrease": + return f"{actor_name} 离开了群聊" + if notice_type == "group_admin": + return f"{actor_name} 的群管理员状态发生变化" + if notice_type == "essence": + return f"{actor_name} 触发了精华消息事件" + if notice_type == "group_msg_emoji_like": + return f"{actor_name} 给一条消息添加了表情回应" + return f"[notice] {notice_type}.{sub_type}".strip(".") diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/__init__.py new file mode 100644 index 00000000..c091a4df --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/__init__.py @@ -0,0 +1,5 @@ +"""NapCat 出站编解码导出。""" + +from .message_codec import NapCatOutboundCodec + +__all__ = ["NapCatOutboundCodec"] diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/message_codec.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/message_codec.py new file mode 100644 index 00000000..b8983c30 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/message_codec.py @@ -0,0 +1,63 @@ +"""NapCat 出站消息编解码。""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Tuple + +from .segment_encoder import NapCatOutboundSegmentEncoder + + +class NapCatOutboundCodec: + """NapCat 出站消息编码器。""" + + def __init__(self) -> None: + """初始化出站消息编码器。""" + self._segment_encoder = NapCatOutboundSegmentEncoder() + + def build_outbound_action( + self, + message: Mapping[str, Any], + route: Mapping[str, Any], + ) -> Tuple[str, Dict[str, Any]]: + """为 Host 出站消息构造 OneBot 动作。 + + Args: + message: Host 侧标准 ``MessageDict``。 + route: Platform IO 路由信息。 + + Returns: + Tuple[str, Dict[str, Any]]: 动作名称与参数字典。 + + Raises: + ValueError: 当私聊出站缺少目标用户 ID 时抛出。 + """ + message_info = message.get("message_info", {}) + if not isinstance(message_info, Mapping): + message_info = {} + + group_info = message_info.get("group_info", {}) + if not isinstance(group_info, Mapping): + group_info = {} + + additional_config = message_info.get("additional_config", {}) + if not isinstance(additional_config, Mapping): + additional_config = {} + + raw_message = message.get("raw_message", []) + segments = self._segment_encoder.convert_segments(raw_message) + + if target_group_id := str( + group_info.get("group_id") or additional_config.get("platform_io_target_group_id") or "" + ).strip(): + return "send_group_msg", {"group_id": target_group_id, "message": segments} + + target_user_id = str( + additional_config.get("platform_io_target_user_id") + or additional_config.get("target_user_id") + or route.get("target_user_id") + or "" + ).strip() + if not target_user_id: + raise ValueError("Outbound private message is missing target_user_id") + + return "send_private_msg", {"message": segments, "user_id": target_user_id} diff --git a/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/segment_encoder.py b/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/segment_encoder.py new file mode 100644 index 00000000..b2c92ae6 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/codecs/outbound/segment_encoder.py @@ -0,0 +1,500 @@ +"""NapCat 出站消息段编码器。""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Mapping + + +class NapCatOutboundSegmentEncoder: + """将 Host 消息段转换为 NapCat 消息段。""" + + def __init__(self) -> None: + """初始化出站消息段编码器。""" + self._segment_builders: Dict[str, Callable[[Mapping[str, Any]], List[Dict[str, Any]]]] = { + "at": self._build_at_segments, + "dict": self._build_dict_segments, + "emoji": self._build_emoji_segments, + "face": self._build_face_segments, + "file": self._build_file_segments, + "forward": self._build_forward_segments, + "image": self._build_image_segments, + "imageurl": self._build_imageurl_segments, + "music": self._build_music_segments, + "reply": self._build_reply_segments, + "text": self._build_text_segments, + "video": self._build_video_segments, + "videourl": self._build_videourl_segments, + "voice": self._build_voice_segments, + "voiceurl": self._build_voiceurl_segments, + } + + def convert_segments(self, raw_message: Any) -> List[Dict[str, Any]]: + """将 Host 消息段转换为 OneBot 消息段。 + + Args: + raw_message: Host 侧 ``raw_message`` 字段。 + + Returns: + List[Dict[str, Any]]: OneBot 消息段列表。 + """ + if not isinstance(raw_message, list): + return [{"type": "text", "data": {"text": ""}}] + + outbound_segments: List[Dict[str, Any]] = [] + for item in raw_message: + if not isinstance(item, Mapping): + continue + + item_type = str(item.get("type") or "").strip() + segment_builder = self._segment_builders.get(item_type) + if segment_builder is None: + fallback_text = f"[unsupported:{item_type or 'unknown'}]" + outbound_segments.append({"type": "text", "data": {"text": fallback_text}}) + continue + + built_segments = segment_builder(item) + if built_segments: + outbound_segments.extend(built_segments) + continue + + fallback_text = self._build_empty_segment_fallback(item_type) + outbound_segments.append({"type": "text", "data": {"text": fallback_text}}) + + if not outbound_segments: + outbound_segments.append({"type": "text", "data": {"text": ""}}) + return outbound_segments + + @staticmethod + def _build_empty_segment_fallback(item_type: str) -> str: + """为缺少有效数据的消息段生成占位文本。 + + Args: + item_type: 原始消息段类型。 + + Returns: + str: 用于降级展示的占位文本。 + """ + normalized_type = item_type or "unknown" + fallback_map = { + "emoji": "[emoji]", + "face": "[face]", + "file": "[file]", + "image": "[image]", + "imageurl": "[image]", + "music": "[music]", + "video": "[video]", + "videourl": "[video]", + "voice": "[voice]", + "voiceurl": "[voice]", + } + return fallback_map.get(normalized_type, f"[unsupported:{normalized_type}]") + + def _build_text_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造文本消息段。 + + Args: + item: Host 侧文本消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 文本消息段列表。 + """ + text_value = str(item.get("data") or "") + return [{"type": "text", "data": {"text": text_value}}] + + def _build_at_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造 @ 消息段。 + + Args: + item: Host 侧 @ 消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat @ 消息段列表。 + """ + item_data = item.get("data") + if not isinstance(item_data, Mapping): + return [] + target_user_id = str(item_data.get("target_user_id") or "").strip() + if not target_user_id: + return [] + return [{"type": "at", "data": {"qq": target_user_id}}] + + def _build_reply_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造回复消息段。 + + Args: + item: Host 侧回复消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 回复消息段列表。 + """ + item_data = item.get("data") + if isinstance(item_data, Mapping): + target_message_id = str(item_data.get("target_message_id") or "").strip() + else: + target_message_id = str(item_data or "").strip() + if not target_message_id: + return [] + return [{"type": "reply", "data": {"id": target_message_id}}] + + def _build_image_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造图片消息段。 + + Args: + item: Host 侧图片消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 图片消息段列表。 + """ + binary_base64 = str(item.get("binary_data_base64") or "").strip() + if not binary_base64: + return [] + return [{"type": "image", "data": {"file": f"base64://{binary_base64}", "sub_type": 0}}] + + def _build_emoji_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造动画表情消息段。 + + Args: + item: Host 侧表情消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 表情消息段列表。 + """ + binary_base64 = str(item.get("binary_data_base64") or "").strip() + if not binary_base64: + return [] + return [ + { + "type": "image", + "data": { + "file": f"base64://{binary_base64}", + "sub_type": 1, + "summary": "[动画表情]", + }, + } + ] + + def _build_voice_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造语音消息段。 + + Args: + item: Host 侧语音消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 语音消息段列表。 + """ + return [self._build_voice_segment(item)] + + def _build_voiceurl_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造基于 URL 的语音消息段。 + + Args: + item: Host 侧语音 URL 消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 语音消息段列表。 + """ + voice_url_segment = self._build_url_media_segment("record", item.get("data")) + return [voice_url_segment] if voice_url_segment else [] + + def _build_face_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造 QQ 原生表情消息段。 + + Args: + item: Host 侧表情消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 表情消息段列表。 + """ + face_segment = self._build_face_segment(item.get("data")) + return [face_segment] if face_segment else [] + + def _build_imageurl_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造基于 URL 的图片消息段。 + + Args: + item: Host 侧图片 URL 消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 图片消息段列表。 + """ + image_segment = self._build_url_media_segment("image", item.get("data")) + return [image_segment] if image_segment else [] + + def _build_videourl_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造基于 URL 的视频消息段。 + + Args: + item: Host 侧视频 URL 消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 视频消息段列表。 + """ + video_segment = self._build_url_media_segment("video", item.get("data")) + return [video_segment] if video_segment else [] + + def _build_video_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造视频消息段。 + + Args: + item: Host 侧视频消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 视频消息段列表。 + """ + video_segment = self._build_video_segment(item) + return [video_segment] if video_segment else [] + + def _build_file_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造文件消息段。 + + Args: + item: Host 侧文件消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 文件消息段列表。 + """ + file_segment = self._build_file_segment(item.get("data")) + return [file_segment] if file_segment else [] + + def _build_music_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造音乐卡片消息段。 + + Args: + item: Host 侧音乐消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 音乐消息段列表。 + """ + music_segment = self._build_music_segment(item.get("data")) + return [music_segment] if music_segment else [] + + def _build_forward_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造合并转发消息段。 + + Args: + item: Host 侧转发消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 转发节点列表。 + """ + item_data = item.get("data") + if not isinstance(item_data, list): + return [] + return self._build_forward_nodes(item_data) + + def _build_dict_segments(self, item: Mapping[str, Any]) -> List[Dict[str, Any]]: + """构造 ``DictComponent`` 消息段。 + + Args: + item: Host 侧 ``DictComponent`` 消息段。 + + Returns: + List[Dict[str, Any]]: 构造后的 NapCat 消息段列表。 + """ + item_data = item.get("data") + if not isinstance(item_data, Mapping): + return [] + dict_segment = self._build_dict_component_segment(item_data) + return [dict_segment] if dict_segment else [] + + def _build_voice_segment(self, item: Mapping[str, Any]) -> Dict[str, Any]: + """构造语音消息段。 + + Args: + item: Host 侧语音消息段。 + + Returns: + Dict[str, Any]: NapCat ``record`` 消息段;缺少有效数据时返回占位文本段。 + """ + binary_base64 = str(item.get("binary_data_base64") or "").strip() + if binary_base64: + return {"type": "record", "data": {"file": f"base64://{binary_base64}"}} + + item_data = item.get("data") + if url_media_segment := self._build_url_media_segment("record", item_data): + return url_media_segment + return {"type": "text", "data": {"text": "[voice]"}} + + def _build_face_segment(self, item_data: Any) -> Dict[str, Any]: + """构造 QQ 原生表情消息段。 + + Args: + item_data: Host 侧表情段数据。 + + Returns: + Dict[str, Any]: NapCat ``face`` 段;缺少有效表情 ID 时返回空字典。 + """ + face_id = "" + if isinstance(item_data, Mapping): + face_id = str(item_data.get("id") or "").strip() + else: + face_id = str(item_data or "").strip() + if not face_id: + return {} + return {"type": "face", "data": {"id": face_id}} + + def _build_video_segment(self, item: Mapping[str, Any]) -> Dict[str, Any]: + """构造视频消息段。 + + Args: + item: Host 侧视频消息段。 + + Returns: + Dict[str, Any]: NapCat ``video`` 消息段;缺少有效数据时返回空字典。 + """ + binary_base64 = str(item.get("binary_data_base64") or "").strip() + if binary_base64: + return {"type": "video", "data": {"file": f"base64://{binary_base64}"}} + return self._build_url_media_segment("video", item.get("data")) + + def _build_file_segment(self, item_data: Any) -> Dict[str, Any]: + """构造文件消息段。 + + Args: + item_data: Host 侧文件段数据。 + + Returns: + Dict[str, Any]: NapCat ``file`` 段;缺少有效数据时返回空字典。 + """ + if isinstance(item_data, str): + normalized_file = item_data.strip() + if not normalized_file: + return {} + return {"type": "file", "data": {"file": self._normalize_file_reference(normalized_file)}} + + if not isinstance(item_data, Mapping): + return {} + + raw_file = str(item_data.get("file") or "").strip() + raw_path = str(item_data.get("path") or "").strip() + raw_url = str(item_data.get("url") or "").strip() + file_ref = raw_file or raw_path or raw_url + if not file_ref: + return {} + + data: Dict[str, Any] = {"file": self._normalize_file_reference(file_ref)} + for optional_field in ("name", "thumb"): + optional_value = str(item_data.get(optional_field) or "").strip() + if optional_value: + data[optional_field] = optional_value + return {"type": "file", "data": data} + + def _build_music_segment(self, item_data: Any) -> Dict[str, Any]: + """构造音乐卡片消息段。 + + Args: + item_data: Host 侧音乐段数据。 + + Returns: + Dict[str, Any]: NapCat ``music`` 段;缺少有效数据时返回空字典。 + """ + if isinstance(item_data, str): + normalized_song_id = item_data.strip() + if not normalized_song_id: + return {} + return {"type": "music", "data": {"type": "163", "id": normalized_song_id}} + + if not isinstance(item_data, Mapping): + return {} + + platform = str(item_data.get("type") or "163").strip() or "163" + if platform not in {"163", "qq"}: + platform = "163" + song_id = str(item_data.get("id") or "").strip() + if not song_id: + return {} + return {"type": "music", "data": {"type": platform, "id": song_id}} + + def _build_url_media_segment(self, segment_type: str, item_data: Any) -> Dict[str, Any]: + """构造基于 URL 或文件引用的媒体消息段。 + + Args: + segment_type: 目标消息段类型。 + item_data: Host 侧消息段数据。 + + Returns: + Dict[str, Any]: NapCat 媒体消息段;缺少有效引用时返回空字典。 + """ + if isinstance(item_data, Mapping): + file_reference = str(item_data.get("file") or item_data.get("url") or "").strip() + else: + file_reference = str(item_data or "").strip() + if not file_reference: + return {} + return {"type": segment_type, "data": {"file": self._normalize_file_reference(file_reference)}} + + @staticmethod + def _normalize_file_reference(file_reference: str) -> str: + """规范化文件引用字符串。 + + Args: + file_reference: 原始文件引用。 + + Returns: + str: 可供 NapCat 使用的文件引用。 + """ + if file_reference.startswith(("base64://", "file://", "http://", "https://")): + return file_reference + return f"file://{file_reference}" + + def _build_forward_nodes(self, forward_nodes: List[Any]) -> List[Dict[str, Any]]: + """构造 NapCat 转发节点列表。 + + Args: + forward_nodes: 内部转发节点列表。 + + Returns: + List[Dict[str, Any]]: NapCat 转发节点列表。 + """ + built_nodes: List[Dict[str, Any]] = [] + for node in forward_nodes: + if not isinstance(node, Mapping): + continue + raw_content = node.get("content", []) + node_segments = self.convert_segments(raw_content) + built_nodes.append( + { + "type": "node", + "data": { + "name": str(node.get("user_nickname") or node.get("user_cardname") or "QQ用户"), + "uin": str(node.get("user_id") or ""), + "content": node_segments, + }, + } + ) + return built_nodes + + def _build_dict_component_segment(self, item_data: Mapping[str, Any]) -> Dict[str, Any]: + """尽力将 ``DictComponent`` 转换为 NapCat 消息段。 + + Args: + item_data: ``DictComponent`` 原始数据。 + + Returns: + Dict[str, Any]: NapCat 消息段;不支持时返回占位文本段。 + """ + raw_type = str(item_data.get("type") or "").strip() + raw_payload = item_data.get("data", item_data) + if raw_type == "file": + return self._build_file_segment(raw_payload) + if raw_type == "music": + return self._build_music_segment(raw_payload) + if raw_type == "video": + if isinstance(raw_payload, Mapping): + pseudo_item: Dict[str, Any] = { + "binary_data_base64": raw_payload.get("binary_data_base64"), + "data": raw_payload, + } + return self._build_video_segment(pseudo_item) + return self._build_video_segment({"data": raw_payload}) + if raw_type == "face": + return self._build_face_segment(raw_payload) + if raw_type == "voiceurl": + return self._build_url_media_segment("record", raw_payload) + if raw_type == "imageurl": + return self._build_url_media_segment("image", raw_payload) + if raw_type == "videourl": + return self._build_url_media_segment("video", raw_payload) + if raw_type in {"image", "record", "reply", "at"} and isinstance(raw_payload, Mapping): + return {"type": raw_type, "data": dict(raw_payload)} + return {"type": "text", "data": {"text": f"[unsupported:{raw_type or 'dict'}]"}} diff --git a/plugin-templates/MaiBot-Napcat-Adapter/config.py b/plugin-templates/MaiBot-Napcat-Adapter/config.py new file mode 100644 index 00000000..86342dc2 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/config.py @@ -0,0 +1,631 @@ +"""NapCat 内置适配器配置模型。""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple +from urllib.parse import urlparse + +import logging + +from maibot_sdk import Field, PluginConfigBase +from pydantic import ValidationInfo, field_validator, model_validator + +from .constants import ( + DEFAULT_ACTION_TIMEOUT_SEC, + DEFAULT_CHAT_LIST_TYPE, + DEFAULT_HEARTBEAT_INTERVAL_SEC, + DEFAULT_NAPCAT_HOST, + DEFAULT_NAPCAT_PORT, + DEFAULT_RECONNECT_DELAY_SEC, + SUPPORTED_CONFIG_VERSION, +) + +LOGGER = logging.getLogger("napcat_adapter.config") + + +class NapCatPluginOptions(PluginConfigBase): + """插件级配置。""" + + __ui_label__: ClassVar[str] = "插件设置" + __ui_order__: ClassVar[int] = 0 + + enabled: bool = Field( + default=False, + description="是否启用 NapCat 适配器。", + json_schema_extra={ + "hint": "关闭后插件会保持空闲,不会主动建立 NapCat WebSocket 连接。", + "label": "启用适配器", + "order": 0, + }, + ) + config_version: str = Field( + default=SUPPORTED_CONFIG_VERSION, + description="当前配置结构版本。", + json_schema_extra={ + "disabled": True, + "hidden": True, + "label": "配置版本", + "order": 99, + }, + ) + + def should_connect(self) -> bool: + """判断当前配置下是否应当启动连接。 + + Returns: + bool: 若插件连接已启用,则返回 ``True``。 + """ + + return self.enabled + + @field_validator("config_version", mode="before") + @classmethod + def _normalize_config_version(cls, value: Any) -> str: + """规范化配置版本字段。 + + Args: + value: 原始配置值。 + + Returns: + str: 去除首尾空白后的配置版本;若为空则回退到当前支持版本。 + """ + + normalized_value = _normalize_string(value) + return normalized_value or SUPPORTED_CONFIG_VERSION + + +class NapCatServerConfig(PluginConfigBase): + """NapCat 正向 WebSocket 连接配置。""" + + __ui_label__: ClassVar[str] = "NapCat 连接" + __ui_order__: ClassVar[int] = 1 + + host: str = Field( + default=DEFAULT_NAPCAT_HOST, + description="NapCat WebSocket 服务主机地址。", + json_schema_extra={ + "hint": "通常为运行 NapCat 的宿主机地址,默认使用本机回环地址。", + "label": "主机地址", + "order": 0, + "placeholder": "127.0.0.1", + }, + ) + port: int = Field( + default=DEFAULT_NAPCAT_PORT, + description="NapCat WebSocket 服务端口。", + json_schema_extra={ + "hint": "与 NapCat 正向 WebSocket 服务监听端口保持一致。", + "label": "端口", + "order": 1, + }, + ) + token: str = Field( + default="", + description="NapCat 访问令牌,未启用鉴权时可留空。", + json_schema_extra={ + "hint": "若 NapCat 开启了访问令牌校验,请在这里填写相同的 token。", + "input_type": "password", + "label": "访问令牌", + "order": 2, + "placeholder": "可留空", + }, + ) + heartbeat_interval: float = Field( + default=DEFAULT_HEARTBEAT_INTERVAL_SEC, + description="心跳超时判定间隔,单位为秒。", + json_schema_extra={ + "hint": "用于判断 NapCat 连接是否失活,必须大于 0。", + "label": "心跳间隔(秒)", + "order": 3, + "step": 1, + }, + ) + reconnect_delay_sec: float = Field( + default=DEFAULT_RECONNECT_DELAY_SEC, + description="连接断开后的重连等待时间,单位为秒。", + json_schema_extra={ + "hint": "连接断开后会等待该时长再尝试重新连接。", + "label": "重连等待(秒)", + "order": 4, + "step": 1, + }, + ) + action_timeout_sec: float = Field( + default=DEFAULT_ACTION_TIMEOUT_SEC, + description="调用 NapCat 动作接口的超时时间,单位为秒。", + json_schema_extra={ + "hint": "发送消息、查询信息等动作会在超时后报错。", + "label": "动作超时(秒)", + "order": 5, + "step": 1, + }, + ) + connection_id: str = Field( + default="", + description="可选连接标识,用于区分多条 NapCat 链路。", + json_schema_extra={ + "hint": "当存在多条 NapCat 连接时,可用它作为路由作用域标识。", + "label": "连接标识", + "order": 6, + "placeholder": "例如:primary", + }, + ) + + def build_ws_url(self) -> str: + """构造正向 WebSocket 地址。 + + Returns: + str: 供适配器作为客户端连接的 NapCat WebSocket 地址。 + """ + + return f"ws://{self.host}:{self.port}" + + @field_validator("host", mode="before") + @classmethod + def _normalize_host(cls, value: Any) -> str: + """规范化主机地址字段。 + + Args: + value: 原始配置值。 + + Returns: + str: 去除首尾空白后的主机地址;若为空则回退到默认主机。 + """ + + normalized_value = _normalize_string(value) + return normalized_value or DEFAULT_NAPCAT_HOST + + @field_validator("port", mode="before") + @classmethod + def _normalize_port(cls, value: Any) -> int: + """规范化端口字段。 + + Args: + value: 原始配置值。 + + Returns: + int: 合法的正整数端口;非法时回退到默认端口。 + """ + + return _normalize_positive_int(value, DEFAULT_NAPCAT_PORT) + + @field_validator("token", "connection_id", mode="before") + @classmethod + def _normalize_text_fields(cls, value: Any) -> str: + """规范化文本字段。 + + Args: + value: 原始配置值。 + + Returns: + str: 去除首尾空白后的字符串值。 + """ + + return _normalize_string(value) + + @field_validator( + "heartbeat_interval", + "reconnect_delay_sec", + "action_timeout_sec", + mode="before", + ) + @classmethod + def _normalize_positive_float_fields(cls, value: Any, info: ValidationInfo) -> float: + """规范化正浮点数字段。 + + Args: + value: 原始配置值。 + info: Pydantic 字段校验上下文。 + + Returns: + float: 合法的正浮点数;非法时回退到对应默认值。 + """ + + default_values: Dict[str, float] = { + "action_timeout_sec": DEFAULT_ACTION_TIMEOUT_SEC, + "heartbeat_interval": DEFAULT_HEARTBEAT_INTERVAL_SEC, + "reconnect_delay_sec": DEFAULT_RECONNECT_DELAY_SEC, + } + return _normalize_positive_float(value, default_values[str(info.field_name)]) + + +class NapCatChatConfig(PluginConfigBase): + """聊天名单配置。""" + + __ui_label__: ClassVar[str] = "聊天过滤" + __ui_order__: ClassVar[int] = 2 + + enable_chat_list_filter: bool = Field( + default=True, + description="是否启用群聊与私聊名单过滤。", + json_schema_extra={ + "hint": "关闭后将忽略群聊名单和私聊名单,仅保留全局屏蔽用户与官方机器人屏蔽规则。", + "label": "启用聊天名单过滤", + "order": 0, + }, + ) + show_dropped_chat_list_messages: bool = Field( + default=False, + description="是否显示未通过聊天名单过滤而被丢弃的消息日志。", + json_schema_extra={ + "hint": "关闭后不会记录群聊/私聊因未通过聊天名单过滤而被丢弃的日志,默认关闭以减少刷屏。", + "label": "显示聊天名单丢弃日志", + "order": 1, + }, + ) + group_list_type: Literal["whitelist", "blacklist"] = Field( + default=DEFAULT_CHAT_LIST_TYPE, + description="群聊名单模式。", + json_schema_extra={ + "hint": "白名单模式只接收列表内群聊,黑名单模式则忽略列表内群聊。", + "label": "群聊名单模式", + "order": 2, + }, + ) + group_list: List[str] = Field( + default_factory=list, + description="群聊名单中的群号列表。", + json_schema_extra={ + "hint": "群号会被统一转换为字符串并自动去重。", + "label": "群聊名单", + "order": 3, + "placeholder": "请输入群号", + }, + ) + private_list_type: Literal["whitelist", "blacklist"] = Field( + default=DEFAULT_CHAT_LIST_TYPE, + description="私聊名单模式。", + json_schema_extra={ + "hint": "白名单模式只接收列表内私聊,黑名单模式则忽略列表内私聊。", + "label": "私聊名单模式", + "order": 4, + }, + ) + private_list: List[str] = Field( + default_factory=list, + description="私聊名单中的用户 ID 列表。", + json_schema_extra={ + "hint": "用户 ID 会被统一转换为字符串并自动去重。", + "label": "私聊名单", + "order": 5, + "placeholder": "请输入用户 ID", + }, + ) + ban_user_id: List[str] = Field( + default_factory=list, + description="全局屏蔽的用户 ID 列表。", + json_schema_extra={ + "hint": "这些用户的消息会在进入 Host 之前被直接丢弃。", + "label": "全局屏蔽用户", + "order": 6, + "placeholder": "请输入用户 ID", + }, + ) + ban_qq_bot: bool = Field( + default=False, + description="是否屏蔽 QQ 官方机器人消息。", + json_schema_extra={ + "hint": "开启后会忽略来自 QQ 官方机器人或频道机器人的消息。", + "label": "屏蔽官方机器人", + "order": 7, + }, + ) + + @field_validator("group_list_type", "private_list_type", mode="before") + @classmethod + def _normalize_list_types(cls, value: Any) -> Literal["whitelist", "blacklist"]: + """规范化名单模式字段。 + + Args: + value: 原始配置值。 + + Returns: + Literal["whitelist", "blacklist"]: 合法的名单模式;非法时回退到默认值。 + """ + + return _normalize_list_mode(value) + + @field_validator("group_list", "private_list", "ban_user_id", mode="before") + @classmethod + def _normalize_id_lists(cls, value: Any) -> List[str]: + """规范化 ID 列表字段。 + + Args: + value: 原始配置值。 + + Returns: + List[str]: 规范化后的字符串列表,已去除空白与重复项。 + """ + + return _normalize_string_list(value) + + +class NapCatFilterConfig(PluginConfigBase): + """消息过滤配置。""" + + __ui_label__: ClassVar[str] = "消息过滤" + __ui_order__: ClassVar[int] = 3 + + ignore_self_message: bool = Field( + default=True, + description="是否忽略机器人自身发送的消息。", + json_schema_extra={ + "hint": "建议保持开启,避免机器人处理自己刚刚发出的消息。", + "label": "忽略自身消息", + "order": 0, + }, + ) + + +class NapCatPluginSettings(PluginConfigBase): + """NapCat 插件完整配置。""" + + plugin: NapCatPluginOptions = Field(default_factory=NapCatPluginOptions) + napcat_server: NapCatServerConfig = Field(default_factory=NapCatServerConfig) + chat: NapCatChatConfig = Field(default_factory=NapCatChatConfig) + filters: NapCatFilterConfig = Field(default_factory=NapCatFilterConfig) + + @model_validator(mode="before") + @classmethod + def _upgrade_legacy_config(cls, raw_config: Any) -> Dict[str, Any]: + """将旧版配置结构迁移为当前配置模型。 + + Args: + raw_config: Runner 注入的原始配置内容。 + + Returns: + Dict[str, Any]: 适配到当前配置模型后的字典结构。 + """ + + raw_mapping = _as_mapping(raw_config) + plugin_section = _as_mapping(raw_mapping.get("plugin")) + server_section = _as_mapping(raw_mapping.get("napcat_server")) + legacy_connection_section = _as_mapping(raw_mapping.get("connection")) + chat_section = _as_mapping(raw_mapping.get("chat")) + filters_section = _as_mapping(raw_mapping.get("filters")) + + if legacy_connection_section: + LOGGER.warning("NapCat 适配器检测到旧版 [connection] 配置段,已自动迁移到 [napcat_server]") + + if not server_section and legacy_connection_section: + server_section = dict(legacy_connection_section) + + normalized_server_section = dict(server_section) + legacy_host, legacy_port = _read_legacy_host_port(normalized_server_section, legacy_connection_section) + current_host = _normalize_string(normalized_server_section.get("host")) + if legacy_host and current_host in {"", DEFAULT_NAPCAT_HOST}: + normalized_server_section["host"] = legacy_host + + current_port = _normalize_positive_int(normalized_server_section.get("port"), DEFAULT_NAPCAT_PORT) + if legacy_port is not None and current_port == DEFAULT_NAPCAT_PORT: + normalized_server_section["port"] = legacy_port + + legacy_access_token = _normalize_string(normalized_server_section.get("access_token")) or _normalize_string( + legacy_connection_section.get("access_token") + ) + if legacy_access_token and not _normalize_string(normalized_server_section.get("token")): + LOGGER.warning("NapCat 适配器检测到旧版 access_token 配置,已自动迁移到 napcat_server.token") + normalized_server_section["token"] = legacy_access_token + + legacy_heartbeat_value = normalized_server_section.get("heartbeat_sec", legacy_connection_section.get("heartbeat_sec")) + current_heartbeat = _normalize_positive_float( + normalized_server_section.get("heartbeat_interval"), + DEFAULT_HEARTBEAT_INTERVAL_SEC, + ) + legacy_heartbeat = _normalize_positive_float(legacy_heartbeat_value, DEFAULT_HEARTBEAT_INTERVAL_SEC) + if legacy_heartbeat_value is not None and current_heartbeat == DEFAULT_HEARTBEAT_INTERVAL_SEC: + LOGGER.warning( + "NapCat 适配器检测到旧版 heartbeat_sec 配置,已自动迁移到 napcat_server.heartbeat_interval" + ) + normalized_server_section["heartbeat_interval"] = legacy_heartbeat + + return { + "chat": chat_section, + "filters": filters_section, + "napcat_server": normalized_server_section, + "plugin": plugin_section, + } + + @classmethod + def from_mapping(cls, raw_config: Mapping[str, Any], logger: Any) -> "NapCatPluginSettings": + """从 Runner 注入的原始配置字典解析插件配置。 + + Args: + raw_config: Runner 注入的原始配置内容。 + logger: 兼容旧调用签名保留的日志对象,当前不直接使用。 + + Returns: + NapCatPluginSettings: 规范化后的插件配置模型。 + """ + + del logger + return cls.model_validate(dict(raw_config)) + + def should_connect(self) -> bool: + """判断当前配置下是否应当启动连接。 + + Returns: + bool: 若插件连接已启用,则返回 ``True``。 + """ + + return self.plugin.should_connect() + + def validate_runtime_config(self, logger: Any) -> bool: + """校验当前配置是否满足启动连接的前提条件。 + + Args: + logger: 插件日志对象。 + + Returns: + bool: 若配置满足启动连接的前提条件,则返回 ``True``。 + """ + + config_version = self.plugin.config_version + if not config_version: + logger.error(f"NapCat 适配器配置缺少 plugin.config_version,当前插件要求版本 {SUPPORTED_CONFIG_VERSION}") + return False + + if config_version != SUPPORTED_CONFIG_VERSION: + logger.error( + f"NapCat 适配器配置版本不兼容: 当前为 {config_version},当前插件要求 {SUPPORTED_CONFIG_VERSION}" + ) + return False + + if not self.napcat_server.host: + logger.warning("NapCat 适配器已启用,但 napcat_server.host 为空") + return False + + if self.napcat_server.port <= 0: + logger.warning("NapCat 适配器已启用,但 napcat_server.port 不是正整数") + return False + + return True + + +def _as_mapping(value: Any) -> Dict[str, Any]: + """将任意值安全转换为字典。 + + Args: + value: 待转换的值。 + + Returns: + Dict[str, Any]: 若原值是映射,则返回普通字典;否则返回空字典。 + """ + + return dict(value) if isinstance(value, Mapping) else {} + + +def _normalize_list_mode(value: Any) -> Literal["whitelist", "blacklist"]: + """规范化名单模式字符串。 + + Args: + value: 原始配置值。 + + Returns: + Literal["whitelist", "blacklist"]: 合法的名单模式;非法时回退到默认值。 + """ + + normalized_value = _normalize_string(value) + if normalized_value == "whitelist": + return "whitelist" + if normalized_value == "blacklist": + return "blacklist" + return DEFAULT_CHAT_LIST_TYPE + + +def _normalize_positive_float(value: Any, default: float) -> float: + """规范化正浮点数配置值。 + + Args: + value: 原始配置值。 + default: 非法取值时使用的默认值。 + + Returns: + float: 合法的正浮点数;非法时回退到默认值。 + """ + + if isinstance(value, (int, float)) and float(value) > 0: + return float(value) + + if isinstance(value, str): + try: + parsed_value = float(value.strip()) + except ValueError: + return default + if parsed_value > 0: + return parsed_value + + return default + + +def _normalize_positive_int(value: Any, default: int) -> int: + """规范化正整数配置值。 + + Args: + value: 原始配置值。 + default: 非法取值时使用的默认值。 + + Returns: + int: 合法的正整数;非法时回退到默认值。 + """ + + if isinstance(value, int) and value > 0: + return value + + if isinstance(value, str): + normalized_value = value.strip() + if normalized_value.isdigit(): + parsed_value = int(normalized_value) + if parsed_value > 0: + return parsed_value + + return default + + +def _normalize_string(value: Any) -> str: + """规范化字符串配置值。 + + Args: + value: 原始配置值。 + + Returns: + str: 去除首尾空白后的字符串;若值为空则返回空字符串。 + """ + + return "" if value is None else str(value).strip() + + +def _normalize_string_list(value: Any) -> List[str]: + """规范化字符串列表配置值。 + + Args: + value: 原始配置值。 + + Returns: + List[str]: 去除空白与重复项后的字符串列表。 + """ + + if not isinstance(value, list): + return [] + + normalized_values: List[str] = [] + seen_values = set() + for item in value: + item_text = _normalize_string(item) + if not item_text or item_text in seen_values: + continue + seen_values.add(item_text) + normalized_values.append(item_text) + return normalized_values + + +def _read_legacy_host_port( + server_section: Mapping[str, Any], + legacy_connection_section: Mapping[str, Any], +) -> Tuple[str, Optional[int]]: + """从旧版 ``ws_url`` 配置中提取主机与端口。 + + Args: + server_section: 新版 ``napcat_server`` 配置段。 + legacy_connection_section: 旧版 ``connection`` 配置段。 + + Returns: + Tuple[str, Optional[int]]: 解析到的主机与端口;若未找到,则返回空主机与 ``None``。 + """ + + legacy_ws_url = _normalize_string(server_section.get("ws_url")) or _normalize_string( + legacy_connection_section.get("ws_url") + ) + if not legacy_ws_url: + return "", None + + parsed_url = urlparse(legacy_ws_url) + parsed_host = parsed_url.hostname or "" + parsed_port = parsed_url.port + + LOGGER.warning("NapCat 适配器检测到旧版 ws_url 配置,已自动迁移到 napcat_server.host/port") + if parsed_url.path not in {"", "/"}: + LOGGER.warning("NapCat 适配器旧版 ws_url 包含路径,新的 napcat_server 配置不会保留该路径") + + return parsed_host, parsed_port diff --git a/plugin-templates/MaiBot-Napcat-Adapter/constants.py b/plugin-templates/MaiBot-Napcat-Adapter/constants.py new file mode 100644 index 00000000..95b8bcdc --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/constants.py @@ -0,0 +1,13 @@ +"""NapCat 内置适配器共享常量。""" + +NAPCAT_GATEWAY_NAME = "napcat_gateway" +SUPPORTED_CONFIG_VERSION = "0.1.0" +DEFAULT_NAPCAT_HOST = "127.0.0.1" +DEFAULT_NAPCAT_PORT = 3001 +DEFAULT_RECONNECT_DELAY_SEC = 5.0 +DEFAULT_HEARTBEAT_INTERVAL_SEC = 30.0 +DEFAULT_ACTION_TIMEOUT_SEC = 15.0 +DEFAULT_CHAT_LIST_TYPE = "whitelist" +DEFAULT_HISTORY_RECOVERY_BATCH_SIZE = 20 +DEFAULT_HISTORY_RECOVERY_CHECKPOINT_LIMIT = 50 +DEFAULT_HISTORY_RECOVERY_SEEN_TTL_SEC = 86400.0 * 7 diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/README.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/README.md new file mode 100644 index 00000000..94eadb26 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/README.md @@ -0,0 +1,90 @@ +# MaiBot NapCat Adapter API 文档 + +当前统计: + +- 公开 API 总数:`164` +- 强类型封装 API:`24` +- 透传 NapCat action API:`140` +- 对照到 NapCat 官方文档的底层 action:`162 / 162` + +## 文档索引 + +- [强类型封装 API](./typed-api.md) +- [System 透传 API](./system-api.md) +- [Account 透传 API](./account-api.md) +- [Group 透传 API](./group-api.md) +- [Message 透传 API](./message-api.md) +- [File 透传 API](./file-api.md) +- [核验与兼容性说明](./verification.md) + +## 先看调用方式 + +### 强类型封装 API + +这类 API 直接展开参数,不要再套一层 `params`。 + +```python +response = await self.ctx.api.call( + "adapter.napcat.group.get_group_member_info", + group_id=123456789, + user_id=987654321, + no_cache=True, +) +``` + +### 透传 NapCat action API + +这类 API 统一只收 `params` 对象。 + +```python +response = await self.ctx.api.call( + "adapter.napcat.group.set_group_admin", + params={ + "group_id": 123456789, + "user_id": 987654321, + "enable": True, + }, +) +``` + +### 宿主统一返回结构 + +`self.ctx.api.call(...)` 返回的是宿主包装结构: + +```python +{ + "success": True, + "result": ..., +} +``` + +失败时通常为: + +```python +{ + "success": False, + "error": "...", +} +``` + +## 这次文档采用的对齐口径 + +- 透传 API 的“官方请求字段”优先看 NapCat 官方页面的“请求参数”结构。 +- 如果官方页面左侧 Schema 没展开字段,改用同页 `curl --data-raw` 示例补齐。 +- 如果官方页面 Schema 和 `curl` 示例同时给出、但字段不一致,文档会把冲突显式写出来,不会替官方文档做静默裁剪。 +- 强类型封装 API 额外写清“适配器直接参数”和“实际下发给 NapCat 的 body”。 + +详细例外见 [核验与兼容性说明](./verification.md)。 + +- NapCat 官方文档地址:[https://napcat.apifox.cn/](https://napcat.apifox.cn/) + +## 命名空间数量 + +| 命名空间 | 数量 | 说明 | +| --- | ---: | --- | +| `adapter.napcat.action` | 2 | 适配器提供的通用动作入口。 | +| `adapter.napcat.system` | 23 | 登录、状态、凭证、系统控制。 | +| `adapter.napcat.account` | 27 | 资料、好友、收藏、OCR、账号能力。 | +| `adapter.napcat.group` | 41 | 群、频道、公告、群管理。 | +| `adapter.napcat.message` | 28 | 消息、互动、转发、AI 语音。 | +| `adapter.napcat.file` | 43 | 文件、群文件、在线文件、相册、流式传输。 | diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/account-api.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/account-api.md new file mode 100644 index 00000000..55d5d3ec --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/account-api.md @@ -0,0 +1,59 @@ +# Account 透传 API + +这一页覆盖 `adapter.napcat.account.*` 下除强类型封装 API 外的透传 API。 + +统一调用方式: + +```python +response = await self.ctx.api.call( + "adapter.napcat.account.send_like", + params={ + "user_id": 123456789, + "times": 10, + }, +) +``` + +字段来源说明: + +- `无参`:官方页面当前无请求字段。 +- `Schema`:直接来自官方“请求参数”结构。 +- `示例`:官方页面 Schema 没展开出具体字段,参数来自同页 `curl --data-raw` 示例。 + +## API 列表 + +| API | 底层 action | 官方请求字段 | 来源 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.account.create_collection` | `create_collection` | `rawData`、`brief` | `Schema` | [官方](https://napcat.apifox.cn/226659178e0) | 创建收藏。 | +| `adapter.napcat.account.delete_friend` | `delete_friend` | `friend_id`、`user_id`、`temp_block`、`temp_both_del` | `Schema` | [官方](https://napcat.apifox.cn/227237873e0) | 删除好友。 | +| `adapter.napcat.account.fetch_custom_face` | `fetch_custom_face` | `count` | `Schema` | [官方](https://napcat.apifox.cn/226659210e0) | 获取自定义表情。 | +| `adapter.napcat.account.get_ai_characters` | `get_ai_characters` | `group_id`、`chat_type` | `Schema` | [官方](https://napcat.apifox.cn/229485683e0) | 获取 AI 角色列表。 | +| `adapter.napcat.account.get_clientkey` | `get_clientkey` | 无 | `无参` | [官方](https://napcat.apifox.cn/250286915e0) | 获取 ClientKey。 | +| `adapter.napcat.account.get_collection_list` | `get_collection_list` | `category`、`count` | `Schema` | [官方](https://napcat.apifox.cn/226659182e0) | 获取收藏列表。 | +| `adapter.napcat.account.get_cookies` | `get_cookies` | `domain` | `Schema` | [官方](https://napcat.apifox.cn/226657041e0) | 获取 Cookies。 | +| `adapter.napcat.account.get_friends_with_category` | `get_friends_with_category` | 无 | `无参` | [官方](https://napcat.apifox.cn/226658978e0) | 获取带分组的好友列表。 | +| `adapter.napcat.account.get_mini_app_ark` | `get_mini_app_ark` | `type`、`title`、`desc`、`picUrl`、`jumpUrl` | `示例` | [官方](https://napcat.apifox.cn/227738594e0) | 官方页当前为 `Any Of` 结构,但左侧 Schema 未展开具体顶层字段,这里按同页示例请求体记录。 | +| `adapter.napcat.account.get_profile_like` | `get_profile_like` | `user_id`、`start`、`count` | `Schema` | [官方](https://napcat.apifox.cn/226659197e0) | 获取资料点赞。 | +| `adapter.napcat.account.get_recent_contact` | `get_recent_contact` | `count` | `Schema` | [官方](https://napcat.apifox.cn/226659190e0) | 获取最近会话。 | +| `adapter.napcat.account.get_rkey` | `get_rkey` | 无 | `无参` | [官方](https://napcat.apifox.cn/283136230e0) | 获取扩展 RKey。 | +| `adapter.napcat.account.get_rkey_server` | `get_rkey_server` | 无 | `无参` | [官方](https://napcat.apifox.cn/283136236e0) | 获取 RKey 服务器。 | +| `adapter.napcat.account.get_unidirectional_friend_list` | `get_unidirectional_friend_list` | 无 | `无参` | [官方](https://napcat.apifox.cn/266151878e0) | 获取单向好友列表。 | +| `adapter.napcat.account.internal_ocr_image` | `.ocr_image` | `image` | `Schema` | [官方](https://napcat.apifox.cn/226658234e0) | 内部 OCR 动作。 | +| `adapter.napcat.account.nc_get_rkey` | `nc_get_rkey` | 无 | `无参` | [官方](https://napcat.apifox.cn/226659297e0) | 获取 RKey。 | +| `adapter.napcat.account.ocr_image` | `ocr_image` | `image` | `Schema` | [官方](https://napcat.apifox.cn/226658231e0) | 图片 OCR 识别。 | +| `adapter.napcat.account.send_like` | `send_like` | `user_id`、`times` | `Schema` | [官方](https://napcat.apifox.cn/226656717e0) | 点赞。 | +| `adapter.napcat.account.set_diy_online_status` | `set_diy_online_status` | `face_id`、`face_type`、`wording` | `Schema` | [官方](https://napcat.apifox.cn/266151905e0) | 设置自定义在线状态。 | +| `adapter.napcat.account.set_friend_add_request` | `set_friend_add_request` | `flag`、`approve`、`remark` | `Schema` | [官方](https://napcat.apifox.cn/226656932e0) | 处理加好友请求。 | +| `adapter.napcat.account.set_friend_remark` | `set_friend_remark` | `user_id`、`remark` | `Schema` | [官方](https://napcat.apifox.cn/298305173e0) | 设置好友备注。 | +| `adapter.napcat.account.set_qq_avatar` | `set_qq_avatar` | `file` | `Schema` | [官方](https://napcat.apifox.cn/226658980e0) | 设置 QQ 头像。 | +| `adapter.napcat.account.set_self_longnick` | `set_self_longnick` | `longNick` | `Schema` | [官方](https://napcat.apifox.cn/226659186e0) | 设置个性签名。 | +| `adapter.napcat.account.translate_en2zh` | `translate_en2zh` | `words` | `Schema` | [官方](https://napcat.apifox.cn/226659102e0) | 英文单词翻译。 | + +## 典型示例 + +```python +response = await self.ctx.api.call( + "adapter.napcat.account.ocr_image", + params={"image": "https://example.com/demo.png"}, +) +``` diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/file-api.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/file-api.md new file mode 100644 index 00000000..8224abe6 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/file-api.md @@ -0,0 +1,83 @@ +# File 透传 API + +这一页覆盖 `adapter.napcat.file.*` 下除 `get_record` 外的透传 API。 + +统一调用方式: + +```python +response = await self.ctx.api.call( + "adapter.napcat.file.upload_group_file", + params={ + "group_id": 123456789, + "file": "/tmp/demo.txt", + "name": "demo.txt", + }, +) +``` + +字段来源说明: + +- `无参`:官方页面当前无请求字段。 +- `Schema`:直接来自官方“请求参数”结构。 +- `Schema + 示例`:官方页左侧 Schema 和同页 `curl --data-raw` 示例合并后得到。 +- `冲突`:官方页同页 Schema / 示例字段互相冲突,文档显式列出。 + +## API 列表 + +| API | 底层 action | 官方请求字段 | 来源 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.file.cancel_online_file` | `cancel_online_file` | `user_id`、`msg_id` | `Schema` | [官方](https://napcat.apifox.cn/410334677e0) | 取消在线文件。 | +| `adapter.napcat.file.clean_stream_temp_file` | `clean_stream_temp_file` | 无 | `无参` | [官方](https://napcat.apifox.cn/395354124e0) | 清理流式传输临时文件。 | +| `adapter.napcat.file.create_flash_task` | `create_flash_task` | `files`、`name`、`thumb_path` | `Schema` | [官方](https://napcat.apifox.cn/410334666e0) | 创建闪传任务。 | +| `adapter.napcat.file.create_group_file_folder` | `create_group_file_folder` | `group_id`、`folder_name`、`name` | `Schema` | [官方](https://napcat.apifox.cn/226658773e0) | 创建群文件目录。 | +| `adapter.napcat.file.del_group_album_media` | `del_group_album_media` | `group_id`、`album_id`、`lloc` | `Schema` | [官方](https://napcat.apifox.cn/395455119e0) | 删除群相册媒体。 | +| `adapter.napcat.file.delete_group_file` | `delete_group_file` | `group_id`、`file_id` | `Schema` | [官方](https://napcat.apifox.cn/226658755e0) | 删除群文件。 | +| `adapter.napcat.file.delete_group_folder` | `delete_group_folder` | `group_id`、`folder_id`、`folder` | `Schema` | [官方](https://napcat.apifox.cn/226658779e0) | 删除群文件目录。 | +| `adapter.napcat.file.do_group_album_comment` | `do_group_album_comment` | `group_id`、`album_id`、`lloc`、`content` | `Schema` | [官方](https://napcat.apifox.cn/395458911e0) | 发表群相册评论。 | +| `adapter.napcat.file.download_file` | `download_file` | `url`、`base64`、`name`、`headers` | `Schema` | [官方](https://napcat.apifox.cn/226658887e0) | 下载文件。 | +| `adapter.napcat.file.download_file_image_stream` | `download_file_image_stream` | `file`、`file_id`、`chunk_size` | `Schema` | [官方](https://napcat.apifox.cn/395419462e0) | 下载图片文件流。 | +| `adapter.napcat.file.download_file_record_stream` | `download_file_record_stream` | `file`、`file_id`、`chunk_size`、`out_format` | `Schema` | [官方](https://napcat.apifox.cn/395417040e0) | 下载语音文件流。 | +| `adapter.napcat.file.download_file_stream` | `download_file_stream` | `file`、`file_id`、`chunk_size` | `Schema` | [官方](https://napcat.apifox.cn/395413859e0) | 下载文件流。 | +| `adapter.napcat.file.download_fileset` | `download_fileset` | `fileset_id` | `Schema` | [官方](https://napcat.apifox.cn/410334678e0) | 下载文件集。 | +| `adapter.napcat.file.get_file` | `get_file` | `file`、`file_id` | `Schema` | [官方](https://napcat.apifox.cn/226658985e0) | 获取文件。 | +| `adapter.napcat.file.get_fileset_id` | `get_fileset_id` | `share_code` | `Schema` | [官方](https://napcat.apifox.cn/410334679e0) | 获取文件集 ID。 | +| `adapter.napcat.file.get_fileset_info` | `get_fileset_info` | `fileset_id` | `Schema` | [官方](https://napcat.apifox.cn/410334671e0) | 获取文件集信息。 | +| `adapter.napcat.file.get_flash_file_list` | `get_flash_file_list` | `fileset_id` | `Schema` | [官方](https://napcat.apifox.cn/410334667e0) | 获取闪传文件列表。 | +| `adapter.napcat.file.get_flash_file_url` | `get_flash_file_url` | `fileset_id`、`file_name`、`file_index` | `Schema` | [官方](https://napcat.apifox.cn/410334668e0) | 获取闪传文件链接。 | +| `adapter.napcat.file.get_group_album_media_list` | `get_group_album_media_list` | `group_id`、`album_id`、`attach_info` | `Schema` | [官方](https://napcat.apifox.cn/395459066e0) | 获取群相册媒体列表。 | +| `adapter.napcat.file.get_group_file_system_info` | `get_group_file_system_info` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226658789e0) | 获取群文件系统信息。 | +| `adapter.napcat.file.get_group_file_url` | `get_group_file_url` | `group_id`、`file_id`、`busid` | `Schema + 示例` | [官方](https://napcat.apifox.cn/226658867e0) | 官方页左侧 Schema 当前只列 `group_id` / `file_id`,同页示例请求体额外给出 `busid`。 | +| `adapter.napcat.file.get_group_files_by_folder` | `get_group_files_by_folder` | `group_id`、`folder_id`、`folder`、`file_count` | `Schema` | [官方](https://napcat.apifox.cn/226658865e0) | 获取群文件夹文件列表。 | +| `adapter.napcat.file.get_group_root_files` | `get_group_root_files` | `group_id`、`file_count` | `Schema` | [官方](https://napcat.apifox.cn/226658823e0) | 获取群根目录文件列表。 | +| `adapter.napcat.file.get_image` | `get_image` | `file`、`file_id` | `Schema` | [官方](https://napcat.apifox.cn/226657066e0) | 获取图片。 | +| `adapter.napcat.file.get_online_file_msg` | `get_online_file_msg` | `user_id` | `Schema` | [官方](https://napcat.apifox.cn/410334672e0) | 获取在线文件消息。 | +| `adapter.napcat.file.get_private_file_url` | `get_private_file_url` | `user_id`、`file_id` | `Schema + 示例` | [官方](https://napcat.apifox.cn/266151849e0) | 官方页左侧 Schema 当前只列 `file_id`,同页示例请求体额外给出 `user_id`。 | +| `adapter.napcat.file.get_qun_album_list` | `get_qun_album_list` | `group_id`、`attach_info` | `Schema` | [官方](https://napcat.apifox.cn/395460287e0) | 获取群相册列表。 | +| `adapter.napcat.file.get_share_link` | `get_share_link` | `fileset_id` | `Schema` | [官方](https://napcat.apifox.cn/410334670e0) | 获取文件分享链接。 | +| `adapter.napcat.file.move_group_file` | `move_group_file` | `group_id`、`file_id`、`current_parent_directory`、`target_parent_directory` | `Schema` | [官方](https://napcat.apifox.cn/283136359e0) | 移动群文件。 | +| `adapter.napcat.file.receive_online_file` | `receive_online_file` | `user_id`、`msg_id`、`element_id` | `Schema` | [官方](https://napcat.apifox.cn/410334675e0) | 接收在线文件。 | +| `adapter.napcat.file.refuse_online_file` | `refuse_online_file` | `user_id`、`msg_id`、`element_id` | `Schema` | [官方](https://napcat.apifox.cn/410334676e0) | 拒绝在线文件。 | +| `adapter.napcat.file.rename_group_file` | `rename_group_file` | `group_id`、`file_id`、`current_parent_directory`、`new_name` | `Schema` | [官方](https://napcat.apifox.cn/283136375e0) | 重命名群文件。 | +| `adapter.napcat.file.send_flash_msg` | `send_flash_msg` | `fileset_id`、`user_id`、`group_id` | `Schema` | [官方](https://napcat.apifox.cn/410334669e0) | 发送闪传消息。 | +| `adapter.napcat.file.send_online_file` | `send_online_file` | `user_id`、`file_path`、`file_name` | `Schema` | [官方](https://napcat.apifox.cn/410334673e0) | 发送在线文件。 | +| `adapter.napcat.file.send_online_folder` | `send_online_folder` | `user_id`、`folder_path`、`folder_name` | `Schema` | [官方](https://napcat.apifox.cn/410334674e0) | 发送在线文件夹。 | +| `adapter.napcat.file.set_group_album_media_like` | `set_group_album_media_like` | `group_id`、`album_id`、`lloc`、`id`、`set` | `Schema` | [官方](https://napcat.apifox.cn/395457331e0) | 点赞群相册媒体。 | +| `adapter.napcat.file.trans_group_file` | `trans_group_file` | `group_id`、`file_id` | `Schema` | [官方](https://napcat.apifox.cn/283136366e0) | 传输群文件。 | +| `adapter.napcat.file.upload_file_stream` | `upload_file_stream` | `stream_id`、`chunk_data`、`chunk_index`、`total_chunks`、`file_size`、`expected_sha256`、`is_complete`、`filename`、`reset`、`verify_only`、`file_retention` | `Schema` | [官方](https://napcat.apifox.cn/395363988e0) | 上传文件流。 | +| `adapter.napcat.file.upload_group_file` | `upload_group_file` | `group_id`、`file`、`name`、`folder`、`folder_id`、`upload_file` | `Schema` | [官方](https://napcat.apifox.cn/226658753e0) | 上传群文件。 | +| `adapter.napcat.file.upload_image_to_qun_album` | `upload_image_to_qun_album` | `group_id`、`album_id`、`album_name`、`file` | `Schema` | [官方](https://napcat.apifox.cn/395459739e0) | 上传图片到群相册。 | +| `adapter.napcat.file.upload_private_file` | `upload_private_file` | `user_id`、`file`、`name`、`upload_file` | `Schema` | [官方](https://napcat.apifox.cn/226658883e0) | 上传私聊文件。 | +| `adapter.napcat.file.test_download_stream` | `test_download_stream` | `error`、`url` | `冲突` | [官方](https://napcat.apifox.cn/395355338e0) | 官方页左侧 Schema 当前字段为 `error`,同页示例请求体却使用 `url`,两者互相冲突。 | + +## 典型示例 + +```python +response = await self.ctx.api.call( + "adapter.napcat.file.upload_group_file", + params={ + "group_id": 123456789, + "file": "/tmp/demo.txt", + "name": "demo.txt", + }, +) +``` diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/group-api.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/group-api.md new file mode 100644 index 00000000..585d05ef --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/group-api.md @@ -0,0 +1,70 @@ +# Group 透传 API + +这一页覆盖 `adapter.napcat.group.*` 下除强类型封装 API 外的透传 API。 + +统一调用方式: + +```python +response = await self.ctx.api.call( + "adapter.napcat.group.set_group_admin", + params={ + "group_id": 123456789, + "user_id": 987654321, + "enable": True, + }, +) +``` + +字段来源说明: + +- `无参`:官方页面当前无请求字段。 +- `Schema`:直接来自官方“请求参数”结构。 +- `示例`:官方页面 Schema 未展开字段,参数来自同页 `curl --data-raw` 示例。 + +## API 列表 + +| API | 底层 action | 官方请求字段 | 来源 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.group.delete_essence_msg` | `delete_essence_msg` | `message_id`、`msg_seq`、`msg_random`、`group_id` | `Schema` | [官方](https://napcat.apifox.cn/226658678e0) | 官方页当前除了 `message_id` 还列出兼容字段;适配器这里完全透传。 | +| `adapter.napcat.group.delete_group_notice` | `_del_group_notice` | `group_id`、`notice_id` | `Schema` | [官方](https://napcat.apifox.cn/226659240e0) | 删除群公告。 | +| `adapter.napcat.group.get_essence_msg_list` | `get_essence_msg_list` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226658664e0) | 获取群精华消息。 | +| `adapter.napcat.group.get_group_honor_info` | `get_group_honor_info` | `group_id`、`type` | `Schema` | [官方](https://napcat.apifox.cn/226657036e0) | 获取群荣誉信息。 | +| `adapter.napcat.group.get_group_ignore_add_request` | `get_group_ignore_add_request` | 无 | `无参` | [官方](https://napcat.apifox.cn/226659234e0) | 获取群被忽略的加群请求。 | +| `adapter.napcat.group.get_group_ignored_notifies` | `get_group_ignored_notifies` | 无 | `无参` | [官方](https://napcat.apifox.cn/226659323e0) | 获取群忽略通知。 | +| `adapter.napcat.group.get_group_info_ex` | `get_group_info_ex` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226659229e0) | 获取群详细信息(扩展)。 | +| `adapter.napcat.group.get_group_notice` | `_get_group_notice` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226658742e0) | 获取群公告。 | +| `adapter.napcat.group.get_group_shut_list` | `get_group_shut_list` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226659300e0) | 获取群禁言列表。 | +| `adapter.napcat.group.get_group_system_msg` | `get_group_system_msg` | `count` | `Schema` | [官方](https://napcat.apifox.cn/226658660e0) | 获取群系统消息。 | +| `adapter.napcat.group.get_guild_list` | `get_guild_list` | 无 | `无参` | [官方](https://napcat.apifox.cn/226659311e0) | 获取频道列表。 | +| `adapter.napcat.group.get_guild_service_profile` | `get_guild_service_profile` | `guild_id` | `示例` | [官方](https://napcat.apifox.cn/226659317e0) | 官方页左侧 Schema 当前只显示 `object`,同页示例请求体给出 `guild_id`。 | +| `adapter.napcat.group.group_poke` | `group_poke` | `group_id`、`user_id`、`target_id` | `Schema` | [官方](https://napcat.apifox.cn/226659265e0) | 发送群聊戳一戳。 | +| `adapter.napcat.group.handle_quick_operation_internal` | `.handle_quick_operation` | `context`、`operation` | `Schema` | [官方](https://napcat.apifox.cn/226658889e0) | 处理快速操作。 | +| `adapter.napcat.group.send_group_msg` | `send_group_msg` | `message_type`、`user_id`、`group_id`、`message`、`auto_escape`、`source`、`news`、`summary`、`prompt`、`timeout` | `Schema` | [官方](https://napcat.apifox.cn/226656598e0) | 官方页当前顶层请求字段就是这一组;真正的消息段细节放在 `message` 内。 | +| `adapter.napcat.group.send_group_notice` | `_send_group_notice` | `group_id`、`content`、`image`、`pinned`、`type`、`confirm_required`、`is_show_edit_card`、`tip_window_type` | `Schema` | [官方](https://napcat.apifox.cn/226658740e0) | 发送群公告。 | +| `adapter.napcat.group.send_group_sign` | `send_group_sign` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/230897177e0) | NapCat 另外提供的“群打卡”动作。 | +| `adapter.napcat.group.set_essence_msg` | `set_essence_msg` | `message_id` | `Schema` | [官方](https://napcat.apifox.cn/226658674e0) | 设置精华消息。 | +| `adapter.napcat.group.set_group_add_option` | `set_group_add_option` | `group_id`、`add_type`、`group_question`、`group_answer` | `Schema` | [官方](https://napcat.apifox.cn/301542178e0) | 设置群加群选项。 | +| `adapter.napcat.group.set_group_add_request` | `set_group_add_request` | `flag`、`approve`、`reason`、`count` | `Schema` | [官方](https://napcat.apifox.cn/226656947e0) | 官方页当前字段与旧版常见的 `sub_type` 方案不同;文档按当前官方页记录。 | +| `adapter.napcat.group.set_group_admin` | `set_group_admin` | `group_id`、`user_id`、`enable` | `Schema` | [官方](https://napcat.apifox.cn/226656815e0) | 设置群管理员。 | +| `adapter.napcat.group.set_group_card` | `set_group_card` | `group_id`、`user_id`、`card` | `Schema` | [官方](https://napcat.apifox.cn/226656913e0) | 设置群名片。 | +| `adapter.napcat.group.set_group_leave` | `set_group_leave` | `group_id`、`is_dismiss` | `Schema` | [官方](https://napcat.apifox.cn/226656926e0) | 退出群组。 | +| `adapter.napcat.group.set_group_portrait` | `set_group_portrait` | `file`、`group_id` | `Schema` | [官方](https://napcat.apifox.cn/226658669e0) | 设置群头像。 | +| `adapter.napcat.group.set_group_remark` | `set_group_remark` | `group_id`、`remark` | `Schema` | [官方](https://napcat.apifox.cn/283136268e0) | 设置群备注。 | +| `adapter.napcat.group.set_group_robot_add_option` | `set_group_robot_add_option` | `group_id`、`robot_member_switch`、`robot_member_examine` | `Schema` | [官方](https://napcat.apifox.cn/301542198e0) | 设置群机器人加群选项。 | +| `adapter.napcat.group.set_group_search` | `set_group_search` | `group_id`、`no_code_finger_open`、`no_finger_open` | `Schema` | [官方](https://napcat.apifox.cn/301542170e0) | 设置群搜索选项。 | +| `adapter.napcat.group.set_group_sign` | `set_group_sign` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226659329e0) | 群打卡。 | +| `adapter.napcat.group.set_group_special_title` | `set_group_special_title` | `group_id`、`user_id`、`special_title` | `Schema` | [官方](https://napcat.apifox.cn/226656931e0) | 设置专属头衔。 | +| `adapter.napcat.group.set_group_todo` | `set_group_todo` | `group_id`、`message_id`、`message_seq` | `Schema` | [官方](https://napcat.apifox.cn/395460568e0) | 设置群待办。 | + +## 典型示例 + +```python +response = await self.ctx.api.call( + "adapter.napcat.group.send_group_msg", + params={ + "message_type": "group", + "group_id": 123456789, + "message": [{"type": "text", "data": {"text": "你好"}}], + }, +) +``` diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/message-api.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/message-api.md new file mode 100644 index 00000000..ae935218 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/message-api.md @@ -0,0 +1,61 @@ +# Message 透传 API + +这一页覆盖 `adapter.napcat.message.*` 下除强类型封装 API 外的透传 API。 + +统一调用方式: + +```python +response = await self.ctx.api.call( + "adapter.napcat.message.friend_poke", + params={ + "group_id": 123456789, + "user_id": 987654321, + "target_id": 987654321, + }, +) +``` + +字段来源说明: + +- `无参`:官方页面当前无请求字段。 +- `Schema`:直接来自官方“请求参数”结构。 + +## API 列表 + +| API | 底层 action | 官方请求字段 | 来源 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.message.ark_share_group` | `ArkShareGroup` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/226658971e0) | 分享群(Ark)。 | +| `adapter.napcat.message.ark_share_peer` | `ArkSharePeer` | `user_id`、`group_id`、`phone_number` | `Schema` | [官方](https://napcat.apifox.cn/226658965e0) | 分享用户(Ark)。 | +| `adapter.napcat.message.click_inline_keyboard_button` | `click_inline_keyboard_button` | `group_id`、`bot_appid`、`button_id`、`callback_data`、`msg_seq` | `Schema` | [官方](https://napcat.apifox.cn/266151864e0) | 点击内联键盘按钮。 | +| `adapter.napcat.message.fetch_emoji_like` | `fetch_emoji_like` | `message_id`、`emojiId`、`emojiType`、`count`、`cookie` | `Schema` | [官方](https://napcat.apifox.cn/226659219e0) | 获取表情点赞详情。 | +| `adapter.napcat.message.forward_friend_single_msg` | `forward_friend_single_msg` | `message_id`、`group_id`、`user_id` | `Schema` | [官方](https://napcat.apifox.cn/226659051e0) | 转发单条消息。 | +| `adapter.napcat.message.forward_group_single_msg` | `forward_group_single_msg` | `message_id`、`group_id`、`user_id` | `Schema` | [官方](https://napcat.apifox.cn/226659074e0) | 转发单条消息。 | +| `adapter.napcat.message.friend_poke` | `friend_poke` | `group_id`、`user_id`、`target_id` | `Schema` | [官方](https://napcat.apifox.cn/226659255e0) | 发送私聊戳一戳。 | +| `adapter.napcat.message.get_ai_record` | `get_ai_record` | `character`、`group_id`、`text` | `Schema` | [官方](https://napcat.apifox.cn/229486818e0) | 获取 AI 语音。 | +| `adapter.napcat.message.get_emoji_likes` | `get_emoji_likes` | `group_id`、`message_id`、`emoji_id`、`emoji_type`、`count` | `Schema` | [官方](https://napcat.apifox.cn/410334663e0) | 获取消息表情点赞列表。 | +| `adapter.napcat.message.get_friend_msg_history` | `get_friend_msg_history` | `user_id`、`message_seq`、`count`、`reverse_order`、`disable_get_url`、`parse_mult_msg`、`quick_reply`、`reverseOrder` | `Schema` | [官方](https://napcat.apifox.cn/226659174e0) | 官方页当前同时列出 `reverse_order` 和 `reverseOrder` 两种写法。 | +| `adapter.napcat.message.get_group_msg_history` | `get_group_msg_history` | `group_id`、`message_seq`、`count`、`reverse_order`、`disable_get_url`、`parse_mult_msg`、`quick_reply`、`reverseOrder` | `Schema` | [官方](https://napcat.apifox.cn/226657401e0) | 官方页当前同时列出 `reverse_order` 和 `reverseOrder` 两种写法。 | +| `adapter.napcat.message.mark_all_as_read` | `_mark_all_as_read` | 无 | `无参` | [官方](https://napcat.apifox.cn/226659194e0) | 标记所有消息已读。 | +| `adapter.napcat.message.mark_group_msg_as_read` | `mark_group_msg_as_read` | `user_id`、`group_id`、`message_id` | `Schema` | [官方](https://napcat.apifox.cn/226659167e0) | 标记群聊已读。 | +| `adapter.napcat.message.mark_msg_as_read` | `mark_msg_as_read` | `user_id`、`group_id`、`message_id` | `Schema` | [官方](https://napcat.apifox.cn/226657389e0) | 标记消息已读(Go-CQHTTP 兼容)。 | +| `adapter.napcat.message.mark_private_msg_as_read` | `mark_private_msg_as_read` | `user_id`、`group_id`、`message_id` | `Schema` | [官方](https://napcat.apifox.cn/226659165e0) | 标记私聊已读。 | +| `adapter.napcat.message.send_ark_share` | `send_ark_share` | `user_id`、`group_id`、`phone_number` | `Schema` | [官方](https://napcat.apifox.cn/410334665e0) | 分享用户(Ark)。 | +| `adapter.napcat.message.send_forward_msg` | `send_forward_msg` | `message_type`、`user_id`、`group_id`、`message`、`auto_escape`、`source`、`news`、`summary`、`prompt`、`timeout` | `Schema` | [官方](https://napcat.apifox.cn/226659136e0) | 官方页当前顶层请求字段就是这一组;真正的转发节点细节放在 `message` 内。 | +| `adapter.napcat.message.send_group_ark_share` | `send_group_ark_share` | `group_id` | `Schema` | [官方](https://napcat.apifox.cn/410334664e0) | 分享群(Ark)。 | +| `adapter.napcat.message.send_group_forward_msg` | `send_group_forward_msg` | `message_type`、`user_id`、`group_id`、`message`、`auto_escape`、`source`、`news`、`summary`、`prompt`、`timeout` | `Schema` | [官方](https://napcat.apifox.cn/226657396e0) | 发送群合并转发消息。 | +| `adapter.napcat.message.send_msg` | `send_msg` | `message_type`、`user_id`、`group_id`、`message`、`auto_escape`、`source`、`news`、`summary`、`prompt`、`timeout` | `Schema` | [官方](https://napcat.apifox.cn/226656652e0) | 通用发送消息。 | +| `adapter.napcat.message.send_private_forward_msg` | `send_private_forward_msg` | `message_type`、`user_id`、`group_id`、`message`、`auto_escape`、`source`、`news`、`summary`、`prompt`、`timeout` | `Schema` | [官方](https://napcat.apifox.cn/226657399e0) | 发送私聊合并转发消息。 | +| `adapter.napcat.message.send_private_msg` | `send_private_msg` | `message_type`、`user_id`、`group_id`、`message`、`auto_escape`、`source`、`news`、`summary`、`prompt`、`timeout` | `Schema` | [官方](https://napcat.apifox.cn/226656553e0) | 发送私聊消息。 | + +## 典型示例 + +```python +response = await self.ctx.api.call( + "adapter.napcat.message.send_msg", + params={ + "message_type": "group", + "group_id": 123456789, + "message": [{"type": "text", "data": {"text": "你好,MaiBot"}}], + }, +) +``` diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/system-api.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/system-api.md new file mode 100644 index 00000000..22e9481a --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/system-api.md @@ -0,0 +1,54 @@ +# System 透传 API + +这一页覆盖 `adapter.napcat.system.*` 下除 `get_login_info` 外的透传 API。 + +统一调用方式: + +```python +response = await self.ctx.api.call( + "adapter.napcat.system.check_url_safely", + params={"url": "https://example.com"}, +) +``` + +字段来源说明: + +- `无参`:官方页面当前无请求字段。 +- `Schema`:直接来自官方“请求参数”结构。 +- `示例`:官方页面 Schema 只显示泛型 `object`,字段来自同页 `curl --data-raw` 示例。 + +## API 列表 + +| API | 底层 action | 官方请求字段 | 来源 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.system.bot_exit` | `bot_exit` | 无 | `无参` | [官方](https://napcat.apifox.cn/283136399e0) | 退出登录。 | +| `adapter.napcat.system.can_send_image` | `can_send_image` | 无 | `无参` | [官方](https://napcat.apifox.cn/226657071e0) | 是否可以发送图片。 | +| `adapter.napcat.system.can_send_record` | `can_send_record` | 无 | `无参` | [官方](https://napcat.apifox.cn/226657080e0) | 是否可以发送语音。 | +| `adapter.napcat.system.check_url_safely` | `check_url_safely` | `url` | `Schema` | [官方](https://napcat.apifox.cn/228534361e0) | 检查 URL 安全性。 | +| `adapter.napcat.system.clean_cache` | `clean_cache` | 无 | `无参` | [官方](https://napcat.apifox.cn/298305106e0) | 清理缓存。 | +| `adapter.napcat.system.get_credentials` | `get_credentials` | `domain` | `Schema` | [官方](https://napcat.apifox.cn/226657054e0) | 获取登录凭证。 | +| `adapter.napcat.system.get_csrf_token` | `get_csrf_token` | 无 | `无参` | [官方](https://napcat.apifox.cn/226657044e0) | 获取 CSRF Token。 | +| `adapter.napcat.system.get_doubt_friends_add_request` | `get_doubt_friends_add_request` | `count` | `Schema` | [官方](https://napcat.apifox.cn/289565516e0) | 获取可疑好友申请。 | +| `adapter.napcat.system.get_model_show` | `_get_model_show` | `model` | `Schema` | [官方](https://napcat.apifox.cn/227233981e0) | 获取机型显示。 | +| `adapter.napcat.system.get_online_clients` | `get_online_clients` | `no_cache` | `示例` | [官方](https://napcat.apifox.cn/226657379e0) | 官方页左侧 Schema 当前只显示 `object`,同页示例请求体给出 `no_cache`。 | +| `adapter.napcat.system.get_robot_uin_range` | `get_robot_uin_range` | 无 | `无参` | [官方](https://napcat.apifox.cn/226658975e0) | 获取机器人 UIN 范围。 | +| `adapter.napcat.system.get_status` | `get_status` | 无 | `无参` | [官方](https://napcat.apifox.cn/226657083e0) | 获取运行状态。 | +| `adapter.napcat.system.get_version_info` | `get_version_info` | 无 | `无参` | [官方](https://napcat.apifox.cn/226657087e0) | 获取版本信息。 | +| `adapter.napcat.system.nc_get_packet_status` | `nc_get_packet_status` | 无 | `无参` | [官方](https://napcat.apifox.cn/226659280e0) | 获取 Packet 状态。 | +| `adapter.napcat.system.nc_get_user_status` | `nc_get_user_status` | `user_id` | `Schema` | [官方](https://napcat.apifox.cn/226659292e0) | 获取用户在线状态。 | +| `adapter.napcat.system.send_packet` | `send_packet` | `cmd`、`data`、`rsp` | `Schema` | [官方](https://napcat.apifox.cn/250286903e0) | 发送原始数据包。 | +| `adapter.napcat.system.set_doubt_friends_add_request` | `set_doubt_friends_add_request` | `flag`、`approve` | `Schema` | [官方](https://napcat.apifox.cn/289565525e0) | 处理可疑好友申请。 | +| `adapter.napcat.system.set_input_status` | `set_input_status` | `user_id`、`event_type` | `Schema` | [官方](https://napcat.apifox.cn/226659225e0) | 设置输入状态。 | +| `adapter.napcat.system.set_model_show` | `_set_model_show` | `model`、`model_show` | `示例` | [官方](https://napcat.apifox.cn/227233993e0) | 官方页左侧 Schema 当前只显示 `object`,同页示例请求体给出 `model` / `model_show`。 | +| `adapter.napcat.system.set_online_status` | `set_online_status` | `status`、`ext_status`、`battery_status` | `Schema` | [官方](https://napcat.apifox.cn/226658977e0) | 设置在线状态。 | +| `adapter.napcat.system.set_restart` | `set_restart` | 无 | `无参` | [官方](https://napcat.apifox.cn/410334662e0) | 重启服务。 | +| `adapter.napcat.system.unknown_action` | `unknown` | 无 | `无参` | [官方](https://napcat.apifox.cn/411631224e0) | 透传调用名为 `unknown` 的底层动作。 | + +## 典型示例 + +```python +response = await self.ctx.api.call( + "adapter.napcat.system.get_online_clients", + params={"no_cache": False}, +) +``` diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/typed-api.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/typed-api.md new file mode 100644 index 00000000..cd8e6b44 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/typed-api.md @@ -0,0 +1,83 @@ +# 强类型封装 API + +这一页只写“强类型封装 API”。 + +调用规则: + +- 直接使用 `self.ctx.api.call("完整 API 名", **kwargs)`。 +- 不要把参数再包进 `params`。 +- 如果 `response["success"]` 为真,真正的业务结果在 `response["result"]`。 + +## 通用入口 + +| API | 适配器直接参数 | 官方 action | 官方请求字段 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.action.call` | `action_name`、`params=None` | 任意 action | 由 `action_name` 决定 | 无 | 适配器通用入口;`result` 为 NapCat 原始响应字典。 | +| `adapter.napcat.action.call_data` | `action_name`、`params=None` | 任意 action | 由 `action_name` 决定 | 无 | 适配器通用入口;`result` 直接返回 NapCat 响应里的 `data`。 | + +## System + +| API | 适配器直接参数 | 官方 action | 官方请求字段 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.system.get_login_info` | 无 | `get_login_info` | 无 | [官方](https://napcat.apifox.cn/226656952e0) | `result` 为 `dict \| None`;失败返回 `None`。 | + +## Account + +| API | 适配器直接参数 | 官方 action | 官方请求字段 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.account.set_qq_profile` | `nickname`、`personal_note=""`、`sex=""` | `set_qq_profile` | `nickname`、`personal_note`、`sex` | [官方](https://napcat.apifox.cn/226657374e0) | `nickname` 必填;`sex` 仅允许 `male` / `female` / `unknown`;空 `personal_note` 和空 `sex` 不会下发。 | +| `adapter.napcat.account.get_stranger_info` | `user_id`、`no_cache=False` | `get_stranger_info` | `user_id`、`no_cache` | [官方](https://napcat.apifox.cn/226656970e0) | 适配器已对齐官方隐藏 schema;官方默认示例只展示 `user_id`,但页面内嵌 schema 还定义了 `no_cache`;`result` 为 `dict \| None`。 | +| `adapter.napcat.account.get_friend_list` | `no_cache=False` | `get_friend_list` | `no_cache` | [官方](https://napcat.apifox.cn/226656976e0) | `result` 为归一化后的好友列表;NapCat 不同版本下若把列表包在 `friend_list` / `data` 里,适配器会自动展开。 | + +## Group + +| API | 适配器直接参数 | 官方 action | 官方请求字段 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.group.set_group_ban` | `group_id`、`user_id`、`duration` | `set_group_ban` | `group_id`、`user_id`、`duration` | [官方](https://napcat.apifox.cn/226656791e0) | `duration` 必须是 `0` 到 `2592000` 之间的非负整数。 | +| `adapter.napcat.group.set_group_whole_ban` | `group_id`、`enable` | `set_group_whole_ban` | `group_id`、`enable` | [官方](https://napcat.apifox.cn/226656802e0) | `enable` 会被规范成布尔值。 | +| `adapter.napcat.group.set_group_kick` | `group_id`、`user_id`、`reject_add_request=False` | `set_group_kick` | `group_id`、`user_id`、`reject_add_request` | [官方](https://napcat.apifox.cn/226656748e0) | 单个踢人封装。 | +| `adapter.napcat.group.set_group_kick_members` | `group_id`、`user_id`、`reject_add_request=False` | `set_group_kick_members` | `group_id`、`user_id`、`reject_add_request` | [官方](https://napcat.apifox.cn/301542209e0) | 适配器要求 `user_id` 传数组,并实际下发 `user_id: [ ... ]`。 | +| `adapter.napcat.group.set_group_name` | `group_id`、`group_name` | `set_group_name` | `group_id`、`group_name` | [官方](https://napcat.apifox.cn/226656919e0) | `group_name` 会被规范成非空字符串。 | +| `adapter.napcat.group.get_group_info` | `group_id` | `get_group_info` | `group_id` | [官方](https://napcat.apifox.cn/226656979e0) | `result` 为 `dict \| None`。 | +| `adapter.napcat.group.get_group_detail_info` | `group_id` | `get_group_detail_info` | `group_id` | [官方](https://napcat.apifox.cn/307180859e0) | `result` 为 `dict \| None`。 | +| `adapter.napcat.group.get_group_list` | `no_cache=False` | `get_group_list` | `no_cache` | [官方](https://napcat.apifox.cn/226656992e0) | `result` 为归一化后的群列表。 | +| `adapter.napcat.group.get_group_at_all_remain` | `group_id` | `get_group_at_all_remain` | `group_id` | [官方](https://napcat.apifox.cn/227245941e0) | `result` 为 `dict \| None`;不同 NapCat 版本下返回字段名可能不同。 | +| `adapter.napcat.group.get_group_member_info` | `group_id`、`user_id`、`no_cache=True` | `get_group_member_info` | `group_id`、`user_id`、`no_cache` | [官方](https://napcat.apifox.cn/226657019e0) | `group_id` / `user_id` 会先规范化为正整数,再转字符串下发。 | +| `adapter.napcat.group.get_group_member_list` | `group_id`、`no_cache=False` | `get_group_member_list` | `group_id`、`no_cache` | [官方](https://napcat.apifox.cn/226657034e0) | `result` 为归一化后的成员列表。 | + +## Message + +| API | 适配器直接参数 | 官方 action | 官方请求字段 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.message.send_poke` | `user_id=None`、`group_id=None`、`target_id=None`、`qq_id=None` | `send_poke` | `group_id`、`user_id`、`target_id` | [官方](https://napcat.apifox.cn/250286923e0) | 优先使用官方字段 `user_id` / `group_id` / `target_id`;`qq_id` 仅作为旧版兼容别名,会映射成 `user_id`。 | +| `adapter.napcat.message.delete_msg` | `message_id` | `delete_msg` | `message_id` | [官方](https://napcat.apifox.cn/226919954e0) | `message_id` 必须是正整数。 | +| `adapter.napcat.message.send_group_ai_record` | `group_id`、`character`、`text` | `send_group_ai_record` | `character`、`group_id`、`text` | [官方](https://napcat.apifox.cn/229486774e0) | `character` 和 `text` 都会被规范成非空字符串。 | +| `adapter.napcat.message.set_msg_emoji_like` | `message_id`、`emoji_id`、`set=True` | `set_msg_emoji_like` | `message_id`、`emoji_id`、`set` | [官方](https://napcat.apifox.cn/226659104e0) | 适配器把 `set` 下发为官方字段 `set`。 | +| `adapter.napcat.message.get_msg` | `message_id` | `get_msg` | `message_id` | [官方](https://napcat.apifox.cn/226656707e0) | `result` 为 `dict \| None`。 | +| `adapter.napcat.message.get_forward_msg` | `message_id=""`、`id=""` | `get_forward_msg` | `message_id`、`id` | [官方](https://napcat.apifox.cn/226656712e0) | 适配器已对齐官方隐藏 schema;至少提供一个字段;若两个字段同时传入则要求值一致;`result` 会统一整理成 `{\"messages\": [...]}`。 | + +## File + +| API | 适配器直接参数 | 官方 action | 官方请求字段 | 官方文档 | 说明 | +| --- | --- | --- | --- | --- | --- | +| `adapter.napcat.file.get_record` | `file=""`、`file_id=""`、`out_format="wav"` | `get_record` | `file`、`file_id`、`out_format` | [官方](https://napcat.apifox.cn/226657058e0) | 适配器已对齐官方隐藏 schema;`file` / `file_id` 至少提供一个;`out_format` 默认仍为 `wav`,以兼容旧行为。 | + +## 典型示例 + +```python +response = await self.ctx.api.call( + "adapter.napcat.message.send_poke", + user_id=987654321, + group_id=123456789, + target_id=123456789, +) +``` + +```python +response = await self.ctx.api.call( + "adapter.napcat.group.get_group_member_info", + group_id=123456789, + user_id=987654321, + no_cache=True, +) +``` diff --git a/plugin-templates/MaiBot-Napcat-Adapter/docs/verification.md b/plugin-templates/MaiBot-Napcat-Adapter/docs/verification.md new file mode 100644 index 00000000..815235c3 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/docs/verification.md @@ -0,0 +1,55 @@ +# 核验与兼容性说明 + +## 1. 这次核验做了什么 + +- 扫描 `plugins/MaiBot-Napcat-Adapter/apis/*.py` 中全部 `@API(..., public=True)` 公开接口。 +- 对每个透传 API 反查其底层 NapCat action。 +- 用 NapCat 官方文档 [https://napcat.apifox.cn/](https://napcat.apifox.cn/) 逐项确认底层 action 页面是否存在。 +- 用浏览器逐页读取官方页面的“请求参数”结构;遇到官方页左侧只显示泛型 `object` 时,再补读同页 `curl --data-raw` 示例。 +- 对请求示例少于隐藏 schema 的页面,额外抓取官方页原始 HTML,核对 Apifox 内嵌的 request body schema。 +- 对强类型封装 API,额外对照 `plugins/MaiBot-Napcat-Adapter/services/query_service.py` 确认适配器实际下发的 body。 + +## 2. 覆盖范围 + +- 适配器公开 API 总数:`164` +- 其中适配器自带通用入口:`2` + - `adapter.napcat.action.call` + - `adapter.napcat.action.call_data` +- 其中可映射到底层 NapCat action 的 API:`162` +- 这 `162` 个底层 action 的官方文档页面:`162 / 162` 都已找到并写入 docs + +## 3. 参数对齐口径 + +- 普通透传 API:文档写“官方请求字段”,适配器自己不裁剪,只要求调用方传 `params={...}`。 +- 强类型封装 API:文档写“适配器直接参数”和“官方请求字段”两列,明确哪里是适配器收敛过的用法。 +- 如果官方页 `Schema` 和同页 `curl` 示例不一致,文档不会替官方做静默判断,而是显式标成 `Schema + 示例` 或 `冲突`。 + +## 4. 已确认的官方页例外 + +| action / API | 官方文档 | 现象 | 文档处理方式 | +| --- | --- | --- | --- | +| `get_online_clients` | [官方](https://napcat.apifox.cn/226657379e0) | 左侧 Schema 只显示 `object`,同页示例请求体给出 `no_cache` | 在文档中按 `示例` 记录 `no_cache` | +| `_set_model_show` | [官方](https://napcat.apifox.cn/227233993e0) | 左侧 Schema 只显示 `object`,同页示例请求体给出 `model`、`model_show` | 在文档中按 `示例` 记录 | +| `get_mini_app_ark` | [官方](https://napcat.apifox.cn/227738594e0) | 官方页为 `Any Of` 结构,但左侧 Schema 未展开可直接抄用的顶层字段 | 在文档中按同页示例请求体记录当前可见字段 | +| `get_guild_service_profile` | [官方](https://napcat.apifox.cn/226659317e0) | 左侧 Schema 只显示 `object`,同页示例请求体给出 `guild_id` | 在文档中按 `示例` 记录 `guild_id` | +| `get_group_file_url` | [官方](https://napcat.apifox.cn/226658867e0) | 左侧 Schema 只列 `group_id`、`file_id`,同页示例请求体额外给出 `busid` | 在文档中按 `Schema + 示例` 合并记录 | +| `get_private_file_url` | [官方](https://napcat.apifox.cn/266151849e0) | 左侧 Schema 只列 `file_id`,同页示例请求体额外给出 `user_id` | 在文档中按 `Schema + 示例` 合并记录 | +| `test_download_stream` | [官方](https://napcat.apifox.cn/395355338e0) | 左侧 Schema 当前字段为 `error`,同页示例请求体却使用 `url` | 在文档中标记为 `冲突`,两组字段都写出 | +| `get_stranger_info` | [官方](https://napcat.apifox.cn/226656970e0) | 页面默认示例只写 `user_id`,但原始 HTML 的隐藏 schema 还定义了 `no_cache` | 在文档和实现中按隐藏 schema 记录 `user_id`、`no_cache` | +| `get_forward_msg` | [官方](https://napcat.apifox.cn/226656712e0) | 页面默认示例只写 `message_id`,但原始 HTML 的隐藏 schema 还定义了 `id` | 在文档和实现中按隐藏 schema 同时支持 `message_id`、`id` | +| `get_record` | [官方](https://napcat.apifox.cn/226657058e0) | 页面默认示例只写 `file`、`out_format`,但原始 HTML 的隐藏 schema 还定义了 `file_id` | 在文档和实现中按隐藏 schema 记录 `file`、`file_id`、`out_format` | +| `send_poke` | [官方](https://napcat.apifox.cn/250286923e0) | 页面默认示例只写 `user_id`,但原始 HTML 的隐藏 schema 还定义了 `group_id`、`target_id` | 在文档和实现中按隐藏 schema 记录 `user_id`、`group_id`、`target_id` | +| `send_group_sign` | [官方](https://napcat.apifox.cn/230897177e0) | 官方页面存在,但侧边序列化索引缺少常规标题字段 | 文档直接写死官方链接,不依赖侧边索引标题 | +| `send_poke` / `friend_poke` / `forward_group_single_msg` / `send_ark_share` / `send_group_ark_share` / `clean_stream_temp_file` | 对应各自官方页 | 官方侧边序列化索引缺少常规标题字段,但页面本身存在 | 文档直接写死官方链接,不依赖侧边索引标题 | + +## 5. 适配器实现侧的对齐与兼容策略 + +| API | 实际下发 | 与官方字段的关系 | +| --- | --- | --- | +| `adapter.napcat.message.send_poke` | `{"user_id": ..., "group_id"?: ..., "target_id"?: ...}` | 已对齐官方隐藏 schema;公开 API 额外保留 `qq_id` 作为旧版兼容别名 | +| `adapter.napcat.message.get_forward_msg` | 调 `get_forward_msg({"message_id"?: ..., "id"?: ...})` | 已对齐官方隐藏 schema;至少提供一个字段,双字段同时传入时要求一致 | +| `adapter.napcat.file.get_record` | 调 `get_record({"file"?: ..., "file_id"?: ..., "out_format"?: ...})` | 已对齐官方隐藏 schema;默认 `out_format="wav"` 仅用于兼容旧行为 | +| `adapter.napcat.account.get_stranger_info` | `{"user_id": ..., "no_cache": ...}` | 已对齐官方隐藏 schema;`no_cache` 默认值为 `False` | + +- 当前强类型封装 API 已无“缺少官方字段”的已知冲突项。 +- 当前仍保留的兼容策略只有两类:`send_poke` 的 `qq_id` 旧版别名,以及 `get_record` 的 `out_format="wav"` 默认值。 diff --git a/plugin-templates/MaiBot-Napcat-Adapter/filters.py b/plugin-templates/MaiBot-Napcat-Adapter/filters.py new file mode 100644 index 00000000..21c30dd6 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/filters.py @@ -0,0 +1,82 @@ +"""NapCat 入站消息过滤。""" + +from typing import Any, Collection + +from .config import NapCatChatConfig + + +class NapCatChatFilter: + """NapCat 聊天名单过滤器。""" + + def __init__(self, logger: Any) -> None: + """初始化聊天名单过滤器。 + + Args: + logger: 插件日志对象。 + """ + self._logger = logger + + def is_inbound_chat_allowed( + self, + sender_user_id: str, + group_id: str, + chat_config: NapCatChatConfig, + ) -> bool: + """检查入站消息是否通过聊天名单过滤。 + + Args: + sender_user_id: 发送者用户 ID。 + group_id: 群聊 ID;私聊时为空字符串。 + chat_config: 当前生效的聊天配置。 + + Returns: + bool: 若消息允许继续进入 Host,则返回 ``True``。 + """ + if sender_user_id in chat_config.ban_user_id: + self._logger.warning(f"NapCat 用户 {sender_user_id} 在全局禁止名单中,消息被丢弃") + return False + + if not chat_config.enable_chat_list_filter: + return True + + if group_id: + if not self._is_id_allowed_by_list_policy(group_id, chat_config.group_list_type, chat_config.group_list): + self._log_chat_list_rejection( + chat_config.show_dropped_chat_list_messages, + f"NapCat 群聊 {group_id} 未通过聊天名单过滤,消息被丢弃", + ) + return False + return True + + if not self._is_id_allowed_by_list_policy( + sender_user_id, + chat_config.private_list_type, + chat_config.private_list, + ): + self._log_chat_list_rejection( + chat_config.show_dropped_chat_list_messages, + f"NapCat 私聊用户 {sender_user_id} 未通过聊天名单过滤,消息被丢弃", + ) + return False + return True + + def _log_chat_list_rejection(self, enabled: bool, message: str) -> None: + """按配置决定是否记录聊天名单过滤丢弃日志。""" + if enabled: + self._logger.warning(message) + + @staticmethod + def _is_id_allowed_by_list_policy(target_id: str, list_type: str, configured_ids: Collection[str]) -> bool: + """根据白名单或黑名单规则判断目标 ID 是否允许通过。 + + Args: + target_id: 待检查的目标 ID。 + list_type: 名单模式,仅支持 ``whitelist`` 或 ``blacklist``。 + configured_ids: 配置中的 ID 集合或列表。 + + Returns: + bool: 若目标 ID 允许通过,则返回 ``True``。 + """ + if list_type == "whitelist": + return target_id in configured_ids + return target_id not in configured_ids diff --git a/plugin-templates/MaiBot-Napcat-Adapter/heartbeat_monitor.py b/plugin-templates/MaiBot-Napcat-Adapter/heartbeat_monitor.py new file mode 100644 index 00000000..37e61bb6 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/heartbeat_monitor.py @@ -0,0 +1,148 @@ +"""NapCat 心跳监测。""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Mapping, Optional + +import asyncio +import time + + +class NapCatHeartbeatMonitor: + """NapCat 心跳状态监测器。""" + + def __init__( + self, + logger: Any, + on_timeout: Callable[[str], Awaitable[None]], + ) -> None: + """初始化心跳监测器。 + + Args: + logger: 插件日志对象。 + on_timeout: 当心跳长时间未更新时触发的异步回调。 + """ + self._logger = logger + self._on_timeout = on_timeout + self._last_heartbeat_at: float = 0.0 + self._interval_sec: float = 30.0 + self._self_id: str = "" + self._check_task: Optional[asyncio.Task[None]] = None + self._timeout_reported: bool = False + + async def start(self, self_id: str, default_interval_sec: float) -> None: + """启动或刷新心跳监测。 + + Args: + self_id: 当前机器人账号 ID。 + default_interval_sec: 默认心跳间隔秒数。 + """ + normalized_self_id = str(self_id or "").strip() + if normalized_self_id: + self._self_id = normalized_self_id + self._interval_sec = max(float(default_interval_sec or 30.0), 1.0) + self._touch() + if self._check_task is None or self._check_task.done(): + self._check_task = asyncio.create_task( + self._check_loop(), + name="napcat_adapter.heartbeat_monitor", + ) + + async def stop(self) -> None: + """停止当前心跳监测循环。""" + check_task = self._check_task + self._check_task = None + self._timeout_reported = False + self._last_heartbeat_at = 0.0 + if check_task is not None: + check_task.cancel() + try: + await check_task + except asyncio.CancelledError: + pass + + async def observe_meta_event( + self, + payload: Mapping[str, Any], + default_interval_sec: float, + ) -> None: + """根据 NapCat ``meta_event`` 更新心跳监测状态。 + + Args: + payload: NapCat 推送的元事件载荷。 + default_interval_sec: 当前配置中的默认心跳间隔秒数。 + """ + meta_event_type = str(payload.get("meta_event_type") or "").strip() + if meta_event_type == "lifecycle": + sub_type = str(payload.get("sub_type") or "").strip() + if sub_type == "connect": + await self.start(str(payload.get("self_id") or "").strip(), default_interval_sec) + return + + if meta_event_type != "heartbeat": + return + + self_id = str(payload.get("self_id") or "").strip() + interval_sec = self._resolve_interval_sec(payload, default_interval_sec) + status = payload.get("status", {}) + if not isinstance(status, Mapping): + status = {} + + is_online = bool(status.get("online", False)) + is_good = bool(status.get("good", False)) + + await self.start(self_id, interval_sec) + self._interval_sec = interval_sec + + if is_online and is_good: + self._touch() + return + + if not is_online: + self._logger.error(f"NapCat 心跳显示 Bot {self._self_id or self_id or 'unknown'} 已离线") + elif not is_good: + self._logger.warning(f"NapCat 心跳显示 Bot {self._self_id or self_id or 'unknown'} 状态异常") + + @staticmethod + def _resolve_interval_sec(payload: Mapping[str, Any], default_interval_sec: float) -> float: + """解析心跳间隔秒数。 + + Args: + payload: NapCat 推送的元事件载荷。 + default_interval_sec: 配置中的默认心跳间隔秒数。 + + Returns: + float: 规范化后的心跳间隔秒数。 + """ + interval_ms = payload.get("interval") + if isinstance(interval_ms, (int, float)) and interval_ms > 0: + return max(float(interval_ms) / 1000.0, 1.0) + return max(float(default_interval_sec or 30.0), 1.0) + + def _touch(self) -> None: + """刷新最近一次心跳时间戳。""" + self._last_heartbeat_at = time.time() + self._timeout_reported = False + + async def _check_loop(self) -> None: + """持续检查心跳是否超时。""" + while True: + await asyncio.sleep(max(self._interval_sec, 1.0)) + if self._last_heartbeat_at <= 0: + continue + + elapsed_sec = time.time() - self._last_heartbeat_at + if elapsed_sec <= self._interval_sec * 2: + continue + + if self._timeout_reported: + continue + + self._timeout_reported = True + self._logger.error(f"Bot {self._self_id or 'unknown'} 可能发生了连接断开、被下线,或者 NapCat 心跳卡死") + try: + await self._on_timeout(self._self_id) + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.warning(f"NapCat 心跳超时回调执行失败: {exc}") diff --git a/plugin-templates/MaiBot-Napcat-Adapter/plugin.py b/plugin-templates/MaiBot-Napcat-Adapter/plugin.py new file mode 100644 index 00000000..d965a3dc --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/plugin.py @@ -0,0 +1,244 @@ +"""内置 NapCat 适配器插件。 + +当前实现承担完整的 QQ / NapCat 消息网关职责: +1. 作为客户端连接 NapCat / OneBot v11 WebSocket 服务。 +2. 将入站消息、通知事件与元事件转换为 Host 侧结构。 +3. 将 Host 出站消息转换为 OneBot 动作并发送。 +4. 通过公开 API 暴露 QQ 平台专属查询与管理动作。 +""" + +from __future__ import annotations + +from typing import Any, ClassVar, Dict, Mapping, Optional, cast + +from maibot_sdk import MaiBotPlugin, MessageGateway, PluginConfigBase + +from .apis import ( + NapCatAccountApiMixin, + NapCatFileApiMixin, + NapCatGroupApiMixin, + NapCatMessageApiMixin, + NapCatSystemApiMixin, +) +from .config import NapCatPluginSettings +from .constants import NAPCAT_GATEWAY_NAME +from .runtime import NapCatEventRouter, NapCatRuntimeBuilder, NapCatRuntimeBundle +from .services import NapCatActionService, NapCatQueryService + + +class NapCatAdapterPlugin( + NapCatAccountApiMixin, + NapCatFileApiMixin, + NapCatGroupApiMixin, + NapCatMessageApiMixin, + NapCatSystemApiMixin, + MaiBotPlugin, +): + """NapCat 消息网关与 QQ 能力插件。""" + + config_model: ClassVar[type[PluginConfigBase] | None] = NapCatPluginSettings + + def __init__(self) -> None: + """初始化 NapCat 适配器插件实例。""" + super().__init__() + self._action_service: Optional[NapCatActionService] = None + self._query_service: Optional[NapCatQueryService] = None + self._event_router: Optional[NapCatEventRouter] = None + self._runtime_bundle: Optional[NapCatRuntimeBundle] = None + + async def on_load(self) -> None: + """在插件加载时根据配置决定是否启动连接。""" + await self._restart_connection_if_needed() + + async def on_unload(self) -> None: + """在插件卸载时关闭连接。""" + await self._stop_connection() + + async def on_config_update(self, scope: str, config_data: Dict[str, Any], version: str) -> None: + """在配置更新后重载连接状态。 + + Args: + scope: 配置变更范围。 + config_data: 最新的配置数据。 + version: 配置版本号。 + """ + if scope != "self": + return + + self.set_plugin_config(config_data) + if version: + self.ctx.logger.debug(f"NapCat 适配器收到配置更新通知: {version}") + await self._restart_connection_if_needed() + + @MessageGateway( + name=NAPCAT_GATEWAY_NAME, + route_type="duplex", + platform="qq", + protocol="napcat", + description="NapCat 正向 WebSocket 双工消息网关", + ) + async def handle_napcat_gateway( + self, + message: Dict[str, Any], + route: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """处理 Host 出站消息并发送到 NapCat。 + + Args: + message: Host 侧标准 ``MessageDict``。 + route: Platform IO 生成的路由信息。 + metadata: Platform IO 附带的投递元数据。 + **kwargs: 预留扩展参数。 + + Returns: + Dict[str, Any]: 标准化后的发送结果。 + """ + del metadata + del kwargs + + runtime_bundle = self._require_runtime_bundle() + try: + action_name, params = runtime_bundle.outbound_codec.build_outbound_action(message, route or {}) + response = await runtime_bundle.transport.call_action(action_name, params) + except Exception as exc: + return {"success": False, "error": str(exc)} + + if str(response.get("status", "")).lower() != "ok": + return { + "success": False, + "error": str(response.get("wording") or response.get("message") or "NapCat send failed"), + "metadata": {"retcode": response.get("retcode")}, + } + + response_data = response.get("data", {}) + internal_message_id = str(message.get("message_id") or "").strip() + external_message_id = "" + if isinstance(response_data, Mapping): + external_message_id = str(response_data.get("message_id") or "") + + adapter_callbacks = [] + if internal_message_id and external_message_id and internal_message_id != external_message_id: + adapter_callbacks.append( + { + "name": "message_id_echo", + "payload": { + "content": { + "type": "echo", + "echo": internal_message_id, + "actual_id": external_message_id, + } + }, + } + ) + + return { + "success": True, + "external_message_id": external_message_id or None, + "metadata": { + "action": action_name, + "adapter_callbacks": adapter_callbacks, + }, + } + + def _ensure_runtime_components(self) -> None: + """确保运行时依赖对象已经完成初始化。""" + if self._event_router is None: + self._event_router = NapCatEventRouter( + gateway_capability=self.ctx.gateway, + logger=self.ctx.logger, + gateway_name=NAPCAT_GATEWAY_NAME, + load_settings=self._load_settings, + ) + + if self._runtime_bundle is None: + runtime_builder = NapCatRuntimeBuilder( + gateway_capability=self.ctx.gateway, + logger=self.ctx.logger, + gateway_name=NAPCAT_GATEWAY_NAME, + ) + self._runtime_bundle = runtime_builder.build( + on_connection_opened=self._event_router.bootstrap_adapter_runtime_state, + on_connection_closed=self._event_router.handle_transport_disconnected, + on_payload=self._event_router.handle_transport_payload, + on_natural_lift=self._event_router.emit_natural_lift_notice, + on_heartbeat_timeout=self._event_router.handle_heartbeat_timeout, + ) + self._event_router.bind_runtime(self._runtime_bundle) + self._bind_runtime_aliases(self._runtime_bundle) + + def _bind_runtime_aliases(self, runtime_bundle: NapCatRuntimeBundle) -> None: + """同步运行时组件到插件级别的快捷引用。 + + Args: + runtime_bundle: 已初始化的运行时组件集合。 + """ + self._action_service = runtime_bundle.action_service + self._query_service = runtime_bundle.query_service + + def _load_settings(self) -> NapCatPluginSettings: + """返回当前生效的插件配置。 + + Returns: + NapCatPluginSettings: 当前生效的插件配置。 + """ + return cast(NapCatPluginSettings, self.config) + + async def _restart_connection_if_needed(self) -> None: + """根据当前配置重启连接循环。""" + self._ensure_runtime_components() + runtime_bundle = self._require_runtime_bundle() + settings = self._load_settings() + + await self._stop_connection() + if not settings.should_connect(): + self.ctx.logger.info("NapCat 适配器保持空闲状态,因为插件或配置未启用") + return + if not settings.validate_runtime_config(self.ctx.logger): + return + if not runtime_bundle.transport.is_available(): + self.ctx.logger.error("NapCat 适配器依赖 aiohttp,但当前环境未安装该依赖") + return + + if not settings.chat.enable_chat_list_filter: + self.ctx.logger.info( + "NapCat 聊天名单过滤已关闭:将忽略 group_list 与 private_list,仅保留 ban_user_id 和官方机器人屏蔽规则" + ) + + runtime_bundle.transport.configure(settings.napcat_server) + await runtime_bundle.transport.start() + + async def _stop_connection(self) -> None: + """停止当前连接并清理运行时缓存。""" + runtime_bundle = self._runtime_bundle + if runtime_bundle is None: + return + + await runtime_bundle.transport.stop() + if self._event_router is not None: + self._event_router.reset_caches() + + def _require_runtime_bundle(self) -> NapCatRuntimeBundle: + """返回当前已初始化的运行时组件集合。 + + Returns: + NapCatRuntimeBundle: 当前运行时组件集合。 + + Raises: + RuntimeError: 当运行时尚未初始化时抛出。 + """ + self._ensure_runtime_components() + runtime_bundle = self._runtime_bundle + if runtime_bundle is None: + raise RuntimeError("NapCat 运行时尚未初始化") + return runtime_bundle + + +def create_plugin() -> NapCatAdapterPlugin: + """创建插件实例。 + + Returns: + NapCatAdapterPlugin: NapCat 内置适配器插件实例。 + """ + return NapCatAdapterPlugin() diff --git a/plugin-templates/MaiBot-Napcat-Adapter/qq_emoji_list.py b/plugin-templates/MaiBot-Napcat-Adapter/qq_emoji_list.py new file mode 100644 index 00000000..1466ac24 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/qq_emoji_list.py @@ -0,0 +1,226 @@ +"""QQ 原生表情映射表。""" + +from typing import Dict + + +QQ_FACE: Dict[str, str] = { + "0": "[表情:惊讶]", + "1": "[表情:撇嘴]", + "2": "[表情:色]", + "3": "[表情:发呆]", + "4": "[表情:得意]", + "5": "[表情:流泪]", + "6": "[表情:害羞]", + "7": "[表情:闭嘴]", + "8": "[表情:睡]", + "9": "[表情:大哭]", + "10": "[表情:尴尬]", + "11": "[表情:发怒]", + "12": "[表情:调皮]", + "13": "[表情:呲牙]", + "14": "[表情:微笑]", + "15": "[表情:难过]", + "16": "[表情:酷]", + "18": "[表情:抓狂]", + "19": "[表情:吐]", + "20": "[表情:偷笑]", + "21": "[表情:可爱]", + "22": "[表情:白眼]", + "23": "[表情:傲慢]", + "24": "[表情:饥饿]", + "25": "[表情:困]", + "26": "[表情:惊恐]", + "27": "[表情:流汗]", + "28": "[表情:憨笑]", + "29": "[表情:悠闲]", + "30": "[表情:奋斗]", + "31": "[表情:咒骂]", + "32": "[表情:疑问]", + "33": "[表情:嘘]", + "34": "[表情:晕]", + "35": "[表情:折磨]", + "36": "[表情:衰]", + "37": "[表情:骷髅]", + "38": "[表情:敲打]", + "39": "[表情:再见]", + "41": "[表情:发抖]", + "42": "[表情:爱情]", + "43": "[表情:跳跳]", + "46": "[表情:猪头]", + "49": "[表情:拥抱]", + "53": "[表情:蛋糕]", + "56": "[表情:刀]", + "59": "[表情:便便]", + "60": "[表情:咖啡]", + "63": "[表情:玫瑰]", + "64": "[表情:凋谢]", + "66": "[表情:爱心]", + "67": "[表情:心碎]", + "74": "[表情:太阳]", + "75": "[表情:月亮]", + "76": "[表情:赞]", + "77": "[表情:踩]", + "78": "[表情:握手]", + "79": "[表情:胜利]", + "85": "[表情:飞吻]", + "86": "[表情:怄火]", + "89": "[表情:西瓜]", + "96": "[表情:冷汗]", + "97": "[表情:擦汗]", + "98": "[表情:抠鼻]", + "99": "[表情:鼓掌]", + "100": "[表情:糗大了]", + "101": "[表情:坏笑]", + "102": "[表情:左哼哼]", + "103": "[表情:右哼哼]", + "104": "[表情:哈欠]", + "105": "[表情:鄙视]", + "106": "[表情:委屈]", + "107": "[表情:快哭了]", + "108": "[表情:阴险]", + "109": "[表情:左亲亲]", + "110": "[表情:吓]", + "111": "[表情:可怜]", + "112": "[表情:菜刀]", + "114": "[表情:篮球]", + "116": "[表情:示爱]", + "118": "[表情:抱拳]", + "119": "[表情:勾引]", + "120": "[表情:拳头]", + "121": "[表情:差劲]", + "123": "[表情:NO]", + "124": "[表情:OK]", + "125": "[表情:转圈]", + "129": "[表情:挥手]", + "137": "[表情:鞭炮]", + "144": "[表情:喝彩]", + "146": "[表情:爆筋]", + "147": "[表情:棒棒糖]", + "169": "[表情:手枪]", + "171": "[表情:茶]", + "172": "[表情:眨眼睛]", + "173": "[表情:泪奔]", + "174": "[表情:无奈]", + "175": "[表情:卖萌]", + "176": "[表情:小纠结]", + "177": "[表情:喷血]", + "178": "[表情:斜眼笑]", + "179": "[表情:doge]", + "181": "[表情:戳一戳]", + "182": "[表情:笑哭]", + "183": "[表情:我最美]", + "185": "[表情:羊驼]", + "187": "[表情:幽灵]", + "201": "[表情:点赞]", + "212": "[表情:托腮]", + "262": "[表情:脑阔疼]", + "263": "[表情:沧桑]", + "264": "[表情:捂脸]", + "265": "[表情:辣眼睛]", + "266": "[表情:哦哟]", + "267": "[表情:头秃]", + "268": "[表情:问号脸]", + "269": "[表情:暗中观察]", + "270": "[表情:emm]", + "271": "[表情:吃瓜]", + "272": "[表情:呵呵哒]", + "273": "[表情:我酸了]", + "277": "[表情:汪汪]", + "281": "[表情:无眼笑]", + "282": "[表情:敬礼]", + "283": "[表情:狂笑]", + "284": "[表情:面无表情]", + "285": "[表情:摸鱼]", + "286": "[表情:魔鬼笑]", + "287": "[表情:哦]", + "289": "[表情:睁眼]", + "293": "[表情:摸锦鲤]", + "294": "[表情:期待]", + "295": "[表情:拿到红包]", + "297": "[表情:拜谢]", + "298": "[表情:元宝]", + "299": "[表情:牛啊]", + "300": "[表情:胖三斤]", + "302": "[表情:左拜年]", + "303": "[表情:右拜年]", + "305": "[表情:右亲亲]", + "306": "[表情:牛气冲天]", + "307": "[表情:喵喵]", + "311": "[表情:打call]", + "312": "[表情:变形]", + "314": "[表情:仔细分析]", + "317": "[表情:菜汪]", + "318": "[表情:崇拜]", + "319": "[表情:比心]", + "320": "[表情:庆祝]", + "323": "[表情:嫌弃]", + "324": "[表情:吃糖]", + "325": "[表情:惊吓]", + "326": "[表情:生气]", + "332": "[表情:举牌牌]", + "333": "[表情:烟花]", + "334": "[表情:虎虎生威]", + "336": "[表情:豹富]", + "337": "[表情:花朵脸]", + "338": "[表情:我想开了]", + "339": "[表情:舔屏]", + "341": "[表情:打招呼]", + "342": "[表情:酸Q]", + "343": "[表情:我方了]", + "344": "[表情:大怨种]", + "345": "[表情:红包多多]", + "346": "[表情:你真棒棒]", + "347": "[表情:大展宏兔]", + "349": "[表情:坚强]", + "350": "[表情:贴贴]", + "351": "[表情:敲敲]", + "352": "[表情:咦]", + "353": "[表情:拜托]", + "354": "[表情:尊嘟假嘟]", + "355": "[表情:耶]", + "356": "[表情:666]", + "357": "[表情:裂开]", + "392": "[表情:龙年快乐]", + "393": "[表情:新年中龙]", + "394": "[表情:新年大龙]", + "395": "[表情:略略略]", + "9786": "[表情:可爱]", + "10024": "[表情:闪光]", + "9749": "[表情:咖啡]", + "127801": "[表情:玫瑰]", + "127817": "[表情:西瓜]", + "127822": "[表情:苹果]", + "127827": "[表情:草莓]", + "127836": "[表情:拉面]", + "127838": "[表情:面包]", + "127847": "[表情:刨冰]", + "127866": "[表情:啤酒]", + "127867": "[表情:干杯]", + "127881": "[表情:庆祝]", + "128074": "[表情:拳头]", + "128076": "[表情:好的]", + "128077": "[表情:厉害]", + "128079": "[表情:鼓掌]", + "128157": "[表情:礼物]", + "128164": "[表情:睡觉]", + "128166": "[表情:水]", + "128168": "[表情:吹气]", + "128170": "[表情:肌肉]", + "128293": "[表情:火]", + "128513": "[表情:呲牙]", + "128514": "[表情:激动]", + "128516": "[表情:高兴]", + "128522": "[表情:嘿嘿]", + "128524": "[表情:羞涩]", + "128527": "[表情:哼哼]", + "128530": "[表情:不屑]", + "128531": "[表情:汗]", + "128532": "[表情:失落]", + "128536": "[表情:飞吻]", + "128538": "[表情:亲亲]", + "128540": "[表情:淘气]", + "128541": "[表情:吐舌]", + "128557": "[表情:大哭]", + "128560": "[表情:紧张]", + "128563": "[表情:瞪眼]", +} diff --git a/plugin-templates/MaiBot-Napcat-Adapter/runtime/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/runtime/__init__.py new file mode 100644 index 00000000..01b6050f --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/runtime/__init__.py @@ -0,0 +1,7 @@ +"""NapCat 运行时组件导出。""" + +from .builder import NapCatRuntimeBuilder +from .bundle import NapCatRuntimeBundle +from .router import NapCatEventRouter + +__all__ = ["NapCatEventRouter", "NapCatRuntimeBuilder", "NapCatRuntimeBundle"] diff --git a/plugin-templates/MaiBot-Napcat-Adapter/runtime/builder.py b/plugin-templates/MaiBot-Napcat-Adapter/runtime/builder.py new file mode 100644 index 00000000..6720d882 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/runtime/builder.py @@ -0,0 +1,105 @@ +"""NapCat 运行时组件构建器。""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Coroutine + +from ..codecs.inbound import NapCatInboundCodec +from ..codecs.notice import NapCatNoticeCodec +from ..codecs.outbound import NapCatOutboundCodec +from ..filters import NapCatChatFilter +from ..heartbeat_monitor import NapCatHeartbeatMonitor +from ..runtime_state import NapCatRuntimeStateManager +from ..services import ( + NapCatActionService, + NapCatBanStateStore, + NapCatBanTracker, + NapCatHistoryRecoveryStore, + NapCatOfficialBotGuard, + NapCatQueryService, +) +from ..transport import NapCatTransportClient +from .bundle import NapCatRuntimeBundle + + +class NapCatRuntimeBuilder: + """按固定依赖图构建 NapCat 运行时组件。""" + + def __init__(self, gateway_capability: Any, logger: Any, gateway_name: str) -> None: + """初始化运行时构建器。 + + Args: + gateway_capability: SDK 提供的消息网关能力对象。 + logger: 插件日志对象。 + gateway_name: 当前消息网关名称。 + """ + self._gateway_capability = gateway_capability + self._logger = logger + self._gateway_name = gateway_name + + def build( + self, + on_connection_opened: Callable[[], Coroutine[Any, Any, None]], + on_connection_closed: Callable[[], Coroutine[Any, Any, None]], + on_payload: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + on_natural_lift: Callable[[dict[str, Any]], Awaitable[None]], + on_heartbeat_timeout: Callable[[str], Awaitable[None]], + ) -> NapCatRuntimeBundle: + """创建一套完整的运行时组件。 + + Args: + on_connection_opened: 连接建立回调。 + on_connection_closed: 连接断开回调。 + on_payload: 非 echo 载荷回调。 + on_natural_lift: 自然解除禁言回调。 + on_heartbeat_timeout: 心跳超时回调。 + + Returns: + NapCatRuntimeBundle: 已完成依赖注入的运行时组件集合。 + """ + chat_filter = NapCatChatFilter(self._logger) + transport = NapCatTransportClient( + logger=self._logger, + on_connection_opened=on_connection_opened, + on_connection_closed=on_connection_closed, + on_payload=on_payload, + ) + action_service = NapCatActionService(self._logger, transport) + query_service = NapCatQueryService(action_service, self._logger) + ban_state_store = NapCatBanStateStore(self._logger) + history_recovery_store = NapCatHistoryRecoveryStore(self._logger) + inbound_codec = NapCatInboundCodec(self._logger, query_service) + notice_codec = NapCatNoticeCodec(self._logger, query_service) + runtime_state = NapCatRuntimeStateManager( + gateway_capability=self._gateway_capability, + logger=self._logger, + gateway_name=self._gateway_name, + ) + ban_tracker = NapCatBanTracker( + logger=self._logger, + query_service=query_service, + on_natural_lift=on_natural_lift, + state_store=ban_state_store, + ) + heartbeat_monitor = NapCatHeartbeatMonitor( + logger=self._logger, + on_timeout=on_heartbeat_timeout, + ) + official_bot_guard = NapCatOfficialBotGuard(self._logger, query_service) + outbound_codec = NapCatOutboundCodec() + + return NapCatRuntimeBundle( + action_service=action_service, + ban_state_store=ban_state_store, + ban_tracker=ban_tracker, + chat_filter=chat_filter, + heartbeat_monitor=heartbeat_monitor, + history_recovery_store=history_recovery_store, + inbound_codec=inbound_codec, + notice_codec=notice_codec, + official_bot_guard=official_bot_guard, + outbound_codec=outbound_codec, + query_service=query_service, + runtime_state=runtime_state, + transport=transport, + ) diff --git a/plugin-templates/MaiBot-Napcat-Adapter/runtime/bundle.py b/plugin-templates/MaiBot-Napcat-Adapter/runtime/bundle.py new file mode 100644 index 00000000..3045bfb1 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/runtime/bundle.py @@ -0,0 +1,40 @@ +"""NapCat 运行时组件容器。""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..codecs.inbound import NapCatInboundCodec +from ..codecs.notice import NapCatNoticeCodec +from ..codecs.outbound import NapCatOutboundCodec +from ..filters import NapCatChatFilter +from ..heartbeat_monitor import NapCatHeartbeatMonitor +from ..runtime_state import NapCatRuntimeStateManager +from ..services import ( + NapCatActionService, + NapCatBanStateStore, + NapCatBanTracker, + NapCatHistoryRecoveryStore, + NapCatOfficialBotGuard, + NapCatQueryService, +) +from ..transport import NapCatTransportClient + + +@dataclass +class NapCatRuntimeBundle: + """NapCat 运行时依赖集合。""" + + action_service: NapCatActionService + ban_state_store: NapCatBanStateStore + ban_tracker: NapCatBanTracker + chat_filter: NapCatChatFilter + heartbeat_monitor: NapCatHeartbeatMonitor + history_recovery_store: NapCatHistoryRecoveryStore + inbound_codec: NapCatInboundCodec + notice_codec: NapCatNoticeCodec + official_bot_guard: NapCatOfficialBotGuard + outbound_codec: NapCatOutboundCodec + query_service: NapCatQueryService + runtime_state: NapCatRuntimeStateManager + transport: NapCatTransportClient diff --git a/plugin-templates/MaiBot-Napcat-Adapter/runtime/router.py b/plugin-templates/MaiBot-Napcat-Adapter/runtime/router.py new file mode 100644 index 00000000..cf0a8536 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/runtime/router.py @@ -0,0 +1,611 @@ +"""NapCat 事件路由协调器。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, Mapping, Optional, Protocol + +import asyncio + +from ..config import NapCatPluginSettings +from ..constants import DEFAULT_HISTORY_RECOVERY_BATCH_SIZE, DEFAULT_HISTORY_RECOVERY_CHECKPOINT_LIMIT +from ..services import NapCatChatCheckpoint +from ..types import NapCatPayloadDict +from .bundle import NapCatRuntimeBundle + + +class _GatewayCapabilityProtocol(Protocol): + """插件网关能力协议。""" + + async def route_message( + self, + gateway_name: str, + message: Dict[str, Any], + *, + route_metadata: Optional[Dict[str, Any]] = None, + external_message_id: str = "", + dedupe_key: str = "", + ) -> bool: + """向 Host 注入一条消息。""" + ... + + +@dataclass(frozen=True) +class _NapCatChatIdentity: + """描述一条 NapCat 消息所属的会话身份。""" + + chat_type: str + chat_id: str + + +class NapCatEventRouter: + """协调 NapCat 运行时组件处理各类平台事件。""" + + def __init__( + self, + gateway_capability: _GatewayCapabilityProtocol, + logger: Any, + gateway_name: str, + load_settings: Callable[[], NapCatPluginSettings], + ) -> None: + """初始化事件路由器。 + + Args: + gateway_capability: SDK 提供的消息网关能力对象。 + logger: 插件日志对象。 + gateway_name: 当前消息网关名称。 + load_settings: 返回当前生效插件配置的回调。 + """ + self._gateway_capability = gateway_capability + self._logger = logger + self._gateway_name = gateway_name + self._load_settings = load_settings + self._runtime: Optional[NapCatRuntimeBundle] = None + self._recovery_task: Optional[asyncio.Task[None]] = None + + def bind_runtime(self, runtime: NapCatRuntimeBundle) -> None: + """绑定当前路由器使用的运行时依赖。 + + Args: + runtime: 已初始化的运行时组件集合。 + """ + self._runtime = runtime + + def reset_caches(self) -> None: + """重置与路由相关的短期缓存。""" + runtime = self._runtime + if runtime is None: + return + self._cancel_recovery_task() + runtime.official_bot_guard.clear_cache() + + async def handle_transport_payload(self, payload: NapCatPayloadDict) -> None: + """处理来自传输层的非 echo 载荷。 + + Args: + payload: NapCat 推送的原始事件数据。 + """ + post_type = str(payload.get("post_type") or "").strip() + if post_type == "message": + await self.handle_inbound_message(payload) + return + if post_type == "notice": + await self.handle_notice_event(payload) + return + if post_type == "meta_event": + await self.handle_meta_event(payload) + + async def handle_inbound_message(self, payload: NapCatPayloadDict) -> bool: + """处理单条 NapCat 入站消息并注入 Host。 + + Args: + payload: NapCat / OneBot 推送的原始消息事件。 + """ + runtime = self._require_runtime() + settings = self._load_settings() + + self_id = str(payload.get("self_id") or "").strip() + if self_id: + await runtime.runtime_state.report_connected(self_id, settings.napcat_server) + + sender = payload.get("sender", {}) + if not isinstance(sender, Mapping): + sender = {} + + sender_user_id = str(payload.get("user_id") or sender.get("user_id") or "").strip() + if not sender_user_id: + return False + + group_id = str(payload.get("group_id") or "").strip() + if self_id and sender_user_id == self_id and settings.filters.ignore_self_message: + return False + if not runtime.chat_filter.is_inbound_chat_allowed(sender_user_id, group_id, settings.chat): + return False + if await runtime.official_bot_guard.should_reject( + sender_user_id=sender_user_id, + group_id=group_id, + ban_qq_bot=settings.chat.ban_qq_bot, + ): + return False + + try: + message_dict = await runtime.inbound_codec.build_message_dict(payload, self_id, sender_user_id, sender) + except ValueError as exc: + self._logger.warning(f"NapCat 入站消息格式不受支持,已丢弃: {exc}") + return False + + route_metadata = self._build_route_metadata(self_id, settings.napcat_server.connection_id) + external_message_id = str(payload.get("message_id") or "").strip() + accepted = await self._gateway_capability.route_message( + gateway_name=self._gateway_name, + message=message_dict, + route_metadata=route_metadata, + external_message_id=external_message_id, + dedupe_key=external_message_id, + ) + if not accepted: + self._logger.debug(f"Host 丢弃了 NapCat 入站消息: {external_message_id or '无消息 ID'}") + return False + + await self._record_inbound_checkpoint( + payload=payload, + self_id=self_id, + external_message_id=external_message_id or str(message_dict.get("message_id") or "").strip(), + scope=settings.napcat_server.connection_id, + ) + return True + + async def handle_notice_event(self, payload: NapCatPayloadDict) -> None: + """处理 NapCat ``notice`` 事件并注入 Host。 + + Args: + payload: NapCat 推送的通知事件。 + """ + runtime = self._require_runtime() + settings = self._load_settings() + + self_id = str(payload.get("self_id") or "").strip() + if self_id: + await runtime.runtime_state.report_connected(self_id, settings.napcat_server) + + await runtime.ban_tracker.record_notice(payload) + await self.route_notice_payload(payload, self_id, settings.napcat_server.connection_id) + + async def route_notice_payload( + self, + payload: NapCatPayloadDict, + self_id: str, + connection_id: str, + ) -> None: + """将单条通知载荷转换并注入 Host。 + + Args: + payload: NapCat 通知载荷。 + self_id: 当前机器人账号 ID。 + connection_id: 当前连接标识。 + """ + runtime = self._require_runtime() + message_dict = await runtime.notice_codec.build_notice_message_dict(payload) + if message_dict is None: + return + + route_metadata = self._build_route_metadata(self_id, connection_id) + external_message_id = str(payload.get("message_id") or "").strip() + dedupe_key = runtime.notice_codec.build_notice_dedupe_key(payload) or "" + accepted = await self._gateway_capability.route_message( + gateway_name=self._gateway_name, + message=message_dict, + route_metadata=route_metadata, + external_message_id=external_message_id, + dedupe_key=dedupe_key, + ) + if not accepted: + self._logger.debug(f"Host 丢弃了 NapCat 通知事件: {external_message_id or dedupe_key or '无消息 ID'}") + + async def emit_natural_lift_notice(self, payload: NapCatPayloadDict) -> None: + """注入一条由适配器合成的自然解除禁言通知。 + + Args: + payload: 合成后的 NapCat 通知载荷。 + """ + settings = self._load_settings() + self_id = str(payload.get("self_id") or "").strip() + await self.route_notice_payload(payload, self_id, settings.napcat_server.connection_id) + + async def handle_meta_event(self, payload: NapCatPayloadDict) -> None: + """处理 NapCat ``meta_event`` 事件。 + + Args: + payload: NapCat 推送的元事件。 + """ + runtime = self._require_runtime() + settings = self._load_settings() + + meta_event_type = str(payload.get("meta_event_type") or "").strip() + self_id = str(payload.get("self_id") or "").strip() + should_report_connected = False + if meta_event_type == "lifecycle": + should_report_connected = str(payload.get("sub_type") or "").strip() == "connect" + elif meta_event_type == "heartbeat": + status = payload.get("status", {}) + if not isinstance(status, Mapping): + status = {} + should_report_connected = bool(status.get("online", False)) and bool(status.get("good", False)) + + if self_id and should_report_connected: + await runtime.runtime_state.report_connected(self_id, settings.napcat_server) + elif meta_event_type == "heartbeat" and not should_report_connected: + await runtime.runtime_state.report_disconnected() + + await runtime.heartbeat_monitor.observe_meta_event(payload, settings.napcat_server.heartbeat_interval) + await runtime.notice_codec.handle_meta_event(payload) + + async def bootstrap_adapter_runtime_state(self) -> None: + """在连接建立后主动获取账号信息并激活消息网关路由。""" + runtime = self._require_runtime() + settings = self._load_settings() + + max_attempts = 3 + last_error: Optional[Exception] = None + for attempt in range(1, max_attempts + 1): + try: + login_info = await runtime.query_service.get_login_info() + self_id = self._extract_self_id_from_login_response(login_info) + await runtime.runtime_state.report_connected(self_id, settings.napcat_server) + await runtime.heartbeat_monitor.start(self_id, settings.napcat_server.heartbeat_interval) + await runtime.ban_tracker.start() + await runtime.history_recovery_store.load() + self._schedule_history_recovery(self_id=self_id, scope=settings.napcat_server.connection_id) + return + except asyncio.CancelledError: + raise + except Exception as exc: + last_error = exc + self._logger.warning(f"NapCat 消息网关获取登录信息失败,第 {attempt}/{max_attempts} 次重试: {exc}") + if attempt < max_attempts: + await asyncio.sleep(1.0) + + if last_error is not None: + self._logger.error(f"NapCat 消息网关未能完成路由激活,连接将保持只接收状态: {last_error}") + + async def handle_transport_disconnected(self) -> None: + """处理传输层断开事件。""" + runtime = self._require_runtime() + await runtime.heartbeat_monitor.stop() + await runtime.ban_tracker.stop() + self.reset_caches() + await runtime.runtime_state.report_disconnected() + + async def handle_heartbeat_timeout(self, self_id: str) -> None: + """处理 NapCat 心跳长时间未更新的情况。 + + Args: + self_id: 当前机器人账号 ID。 + """ + runtime = self._require_runtime() + if self_id: + self._logger.warning(f"NapCat Bot {self_id} 心跳超时,暂时将消息网关标记为未就绪") + else: + self._logger.warning("NapCat 心跳超时,暂时将消息网关标记为未就绪") + await runtime.runtime_state.report_disconnected() + + def _require_runtime(self) -> NapCatRuntimeBundle: + """返回当前已绑定的运行时依赖。 + + Returns: + NapCatRuntimeBundle: 已初始化的运行时依赖。 + + Raises: + RuntimeError: 当运行时尚未绑定时抛出。 + """ + runtime = self._runtime + if runtime is None: + raise RuntimeError("NapCat 运行时尚未初始化") + return runtime + + def _schedule_history_recovery(self, self_id: str, scope: str) -> None: + """在连接恢复后调度一次历史补拉任务。""" + + self._cancel_recovery_task() + runtime = self._runtime + if runtime is None: + return + + self._recovery_task = asyncio.create_task( + self._recover_recent_history(self_id=self_id, scope=scope), + name="napcat_adapter.history_recovery", + ) + + def _cancel_recovery_task(self) -> None: + """取消当前仍在运行的历史补拉任务。""" + + recovery_task = self._recovery_task + self._recovery_task = None + if recovery_task is not None and not recovery_task.done(): + recovery_task.cancel() + + async def _recover_recent_history(self, *, self_id: str, scope: str) -> None: + """按 checkpoint 列表逐个尝试补拉断线期间遗漏的消息。""" + + runtime = self._require_runtime() + checkpoints = await runtime.history_recovery_store.list_checkpoints( + self_id, + scope=scope, + limit=DEFAULT_HISTORY_RECOVERY_CHECKPOINT_LIMIT, + ) + if not checkpoints: + return + + recovered_count = 0 + for checkpoint in checkpoints: + recovered_count += await self._recover_chat_history_from_checkpoint( + self_id=self_id, + scope=scope, + checkpoint=checkpoint, + ) + + if recovered_count > 0: + self._logger.info(f"NapCat 历史补拉完成,共补回 {recovered_count} 条消息") + + async def _recover_chat_history_from_checkpoint( + self, + *, + self_id: str, + scope: str, + checkpoint: NapCatChatCheckpoint, + ) -> int: + """针对单个会话执行一次小批量历史补拉。""" + + runtime = self._require_runtime() + history_messages = await self._query_history_messages(checkpoint, limit=DEFAULT_HISTORY_RECOVERY_BATCH_SIZE) + if not history_messages: + return 0 + + ordered_messages = sorted( + history_messages, + key=lambda item: ( + self._extract_message_timestamp(item), + self._extract_message_seq(item), + str(item.get("message_id") or "").strip(), + ), + ) + + recovered_count = 0 + for history_payload in ordered_messages: + external_message_id = str(history_payload.get("message_id") or "").strip() + if not external_message_id: + continue + if external_message_id == checkpoint.last_message_id: + continue + if await runtime.history_recovery_store.has_recovered_message_seen( + account_id=self_id, + scope=scope, + chat_type=checkpoint.chat_type, + chat_id=checkpoint.chat_id, + external_message_id=external_message_id, + ): + continue + if not self._is_message_after_checkpoint(history_payload, checkpoint): + continue + accepted = await self._reinject_history_payload(history_payload, self_id=self_id) + if not accepted: + continue + await runtime.history_recovery_store.mark_recovered_message_seen( + account_id=self_id, + scope=scope, + chat_type=checkpoint.chat_type, + chat_id=checkpoint.chat_id, + external_message_id=external_message_id, + ) + recovered_count += 1 + + return recovered_count + + async def _query_history_messages( + self, + checkpoint: NapCatChatCheckpoint, + *, + limit: int, + ) -> list[NapCatPayloadDict]: + """查询某个会话在 checkpoint 之后的一小批历史消息。""" + + runtime = self._require_runtime() + payload_collections: list[list[NapCatPayloadDict]] = [] + if checkpoint.last_message_seq is not None: + payload_collections.append( + await self._fetch_history_messages( + chat_type=checkpoint.chat_type, + chat_id=checkpoint.chat_id, + message_seq=checkpoint.last_message_seq, + limit=limit, + ) + ) + payload_collections.append( + await self._fetch_history_messages( + chat_type=checkpoint.chat_type, + chat_id=checkpoint.chat_id, + message_seq=None, + limit=limit, + ) + ) + + merged_payloads: list[NapCatPayloadDict] = [] + seen_message_ids: set[str] = set() + for payloads in payload_collections: + for payload in payloads: + external_message_id = str(payload.get("message_id") or "").strip() + dedupe_key = external_message_id or repr(sorted(payload.items())) + if dedupe_key in seen_message_ids: + continue + seen_message_ids.add(dedupe_key) + merged_payloads.append(payload) + return merged_payloads + + async def _fetch_history_messages( + self, + *, + chat_type: str, + chat_id: str, + message_seq: int | None, + limit: int, + ) -> list[NapCatPayloadDict]: + """调用查询服务获取一批历史消息。""" + + runtime = self._require_runtime() + if chat_type == "group": + history_payloads = await runtime.query_service.get_group_message_history( + chat_id, + message_seq=message_seq, + count=limit, + reverse_order=False, + ) + elif chat_type == "private": + history_payloads = await runtime.query_service.get_friend_message_history( + chat_id, + message_seq=message_seq, + count=limit, + reverse_order=False, + ) + else: + return [] + + if history_payloads is None: + return [] + return [dict(payload) for payload in history_payloads if isinstance(payload, Mapping)] + + async def _reinject_history_payload(self, payload: NapCatPayloadDict, *, self_id: str) -> bool: + """将补拉到的历史消息重新送回实时入站路径。""" + + try: + normalized_payload = dict(payload) + if self_id and not str(normalized_payload.get("self_id") or "").strip(): + normalized_payload["self_id"] = self_id + return await self.handle_inbound_message(normalized_payload) + except asyncio.CancelledError: + raise + except Exception as exc: + external_message_id = str(payload.get("message_id") or "").strip() or "unknown" + self._logger.warning(f"NapCat 历史消息补拉注入失败: message_id={external_message_id} error={exc}") + return False + + async def _record_inbound_checkpoint( + self, + *, + payload: NapCatPayloadDict, + self_id: str, + external_message_id: str, + scope: str, + ) -> None: + """在消息被 Host 接受后更新该会话的最新 checkpoint。""" + + runtime = self._require_runtime() + chat_identity = self._extract_chat_identity(payload) + if chat_identity is None: + return + + await runtime.history_recovery_store.record_checkpoint( + account_id=self_id, + scope=scope, + chat_type=chat_identity.chat_type, + chat_id=chat_identity.chat_id, + message_id=external_message_id, + message_time=self._extract_message_timestamp(payload), + message_seq=self._extract_message_seq(payload), + ) + + @staticmethod + def _extract_chat_identity(payload: Mapping[str, Any]) -> _NapCatChatIdentity | None: + """从 NapCat 载荷中提取会话身份。""" + + group_id = str(payload.get("group_id") or "").strip() + user_id = str(payload.get("user_id") or "").strip() + + if group_id: + return _NapCatChatIdentity(chat_type="group", chat_id=group_id) + if user_id: + return _NapCatChatIdentity(chat_type="private", chat_id=user_id) + return None + + @staticmethod + def _extract_message_seq(payload: Mapping[str, Any]) -> int | None: + """从 NapCat 载荷中提取历史接口可复用的消息序号。""" + + for field_name in ("message_seq", "messageSeq", "msg_seq"): + raw_value = payload.get(field_name) + if raw_value is None or str(raw_value).strip() == "": + continue + try: + return int(raw_value) + except (TypeError, ValueError): + continue + return None + + @staticmethod + def _extract_message_timestamp(payload: Mapping[str, Any]) -> float: + """从 NapCat 载荷中提取消息时间戳。""" + + raw_timestamp = payload.get("time") + if isinstance(raw_timestamp, (int, float)): + return float(raw_timestamp) + return 0.0 + + @classmethod + def _is_message_after_checkpoint( + cls, + payload: Mapping[str, Any], + checkpoint: NapCatChatCheckpoint, + ) -> bool: + """判断历史消息是否位于 checkpoint 之后。""" + + payload_message_id = str(payload.get("message_id") or "").strip() + if payload_message_id == checkpoint.last_message_id: + return False + + payload_message_seq = cls._extract_message_seq(payload) + if payload_message_seq is not None and checkpoint.last_message_seq is not None: + return payload_message_seq > checkpoint.last_message_seq + + payload_timestamp = cls._extract_message_timestamp(payload) + if payload_timestamp != checkpoint.last_message_time: + return payload_timestamp > checkpoint.last_message_time + + return True + + @staticmethod + def _build_route_metadata(self_id: str, connection_id: str) -> Dict[str, Any]: + """构造注入 Host 时使用的路由元数据。 + + Args: + self_id: 当前机器人账号 ID。 + connection_id: 当前连接标识。 + + Returns: + Dict[str, Any]: 路由元数据字典。 + """ + route_metadata: Dict[str, Any] = {} + if self_id: + route_metadata["self_id"] = self_id + if connection_id: + route_metadata["connection_id"] = connection_id + return route_metadata + + @staticmethod + def _extract_self_id_from_login_response(response: Optional[Dict[str, Any]]) -> str: + """从 ``get_login_info`` 查询结果中提取当前账号 ID。 + + Args: + response: NapCat 返回的登录信息字典。 + + Returns: + str: 规范化后的账号 ID 字符串。 + + Raises: + ValueError: 当响应中缺少有效账号 ID 时抛出。 + """ + if not isinstance(response, Mapping): + raise ValueError("get_login_info 响应缺少 data 字段") + + self_id = str(response.get("user_id") or "").strip() + if not self_id: + raise ValueError("get_login_info 响应缺少有效的 user_id") + return self_id diff --git a/plugin-templates/MaiBot-Napcat-Adapter/runtime_state.py b/plugin-templates/MaiBot-Napcat-Adapter/runtime_state.py new file mode 100644 index 00000000..9190185d --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/runtime_state.py @@ -0,0 +1,118 @@ +"""NapCat 消息网关运行时状态管理。""" + +from typing import Any, Optional, Protocol + +from .config import NapCatServerConfig + + +class _GatewayCapabilityProtocol(Protocol): + """消息网关能力代理协议。""" + + async def update_state( + self, + gateway_name: str, + *, + ready: bool, + platform: str = "", + account_id: str = "", + scope: str = "", + metadata: dict[str, Any] | None = None, + ) -> bool: + """向 Host 上报消息网关运行时状态。""" + ... + + +class NapCatRuntimeStateManager: + """NapCat 消息网关路由状态上报器。""" + + def __init__( + self, + gateway_capability: _GatewayCapabilityProtocol, + logger: Any, + gateway_name: str, + ) -> None: + """初始化运行时状态管理器。 + + Args: + gateway_capability: SDK 提供的消息网关能力对象。 + logger: 插件日志对象。 + gateway_name: 当前 NapCat 消息网关组件名称。 + """ + + self._gateway_capability = gateway_capability + self._gateway_name = gateway_name + self._logger = logger + self._runtime_state_connected: bool = False + self._reported_account_id: Optional[str] = None + self._reported_scope: Optional[str] = None + + async def report_connected(self, account_id: str, server_config: NapCatServerConfig) -> bool: + """向 Host 上报当前消息网关连接已就绪。 + + Args: + account_id: 当前 NapCat 连接对应的机器人账号 ID。 + server_config: 当前生效的 NapCat 服务端配置。 + + Returns: + bool: 若 Host 接受了运行时状态更新,则返回 ``True``。 + """ + + normalized_account_id = str(account_id).strip() + if not normalized_account_id: + return False + + scope = server_config.connection_id or None + if ( + self._runtime_state_connected + and self._reported_account_id == normalized_account_id + and self._reported_scope == scope + ): + return True + + accepted = False + try: + accepted = await self._gateway_capability.update_state( + gateway_name=self._gateway_name, + ready=True, + platform="qq", + account_id=normalized_account_id, + scope=server_config.connection_id, + metadata={"ws_url": server_config.build_ws_url()}, + ) + except Exception as exc: + self._logger.warning(f"NapCat 消息网关上报连接就绪状态失败: {exc}") + return False + + if not accepted: + self._logger.warning("NapCat 消息网关连接已建立,但 Host 未接受运行时状态更新") + return False + + self._runtime_state_connected = True + self._reported_account_id = normalized_account_id + self._reported_scope = scope + self._logger.info( + f"NapCat 消息网关已激活路由: platform=qq account_id={normalized_account_id} " + f"scope={self._reported_scope or '*'}" + ) + return True + + async def report_disconnected(self) -> None: + """向 Host 上报当前连接已断开,并撤销消息网关路由。""" + + if not self._runtime_state_connected: + self._reported_account_id = None + self._reported_scope = None + return + + try: + await self._gateway_capability.update_state( + gateway_name=self._gateway_name, + ready=False, + platform="qq", + ) + except Exception as exc: + self._logger.warning(f"NapCat 消息网关上报断开状态失败: {exc}") + finally: + self._runtime_state_connected = False + self._reported_account_id = None + self._reported_scope = None diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py b/plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py new file mode 100644 index 00000000..cc10abc3 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/__init__.py @@ -0,0 +1,19 @@ +"""NapCat 内部服务导出。""" + +from .action_service import NapCatActionService +from .ban_tracker import NapCatBanTracker +from .ban_state_store import NapCatBanRecord, NapCatBanStateStore +from .history_recovery_store import NapCatChatCheckpoint, NapCatHistoryRecoveryStore +from .official_bot_guard import NapCatOfficialBotGuard +from .query_service import NapCatQueryService + +__all__ = [ + "NapCatActionService", + "NapCatBanRecord", + "NapCatBanStateStore", + "NapCatBanTracker", + "NapCatChatCheckpoint", + "NapCatHistoryRecoveryStore", + "NapCatOfficialBotGuard", + "NapCatQueryService", +] diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/action_service.py b/plugin-templates/MaiBot-Napcat-Adapter/services/action_service.py new file mode 100644 index 00000000..7ce064d7 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/action_service.py @@ -0,0 +1,119 @@ +"""NapCat 底层动作调用服务。""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional + +import asyncio + +try: + from aiohttp import ClientSession, ClientTimeout + + AIOHTTP_AVAILABLE = True +except ImportError: + ClientSession = None # type: ignore[assignment] + ClientTimeout = None # type: ignore[assignment] + AIOHTTP_AVAILABLE = False + +if TYPE_CHECKING: + from ..transport import NapCatTransportClient + + +class NapCatActionService: + """NapCat 底层动作与资源访问服务。""" + + def __init__(self, logger: Any, transport: "NapCatTransportClient") -> None: + """初始化底层动作服务。 + + Args: + logger: 插件日志对象。 + transport: NapCat 传输层客户端。 + """ + self._logger = logger + self._transport = transport + + async def call_action(self, action_name: str, params: Mapping[str, Any]) -> Dict[str, Any]: + """调用 OneBot 动作并要求返回成功结果。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + + Raises: + RuntimeError: 当动作执行失败或平台返回非成功状态时抛出。 + """ + normalized_params = {str(key): value for key, value in params.items()} + try: + response = await self._transport.call_action(action_name, normalized_params) + except asyncio.CancelledError: + raise + except Exception as exc: + raise RuntimeError(f"NapCat 动作执行失败: action={action_name} error={exc}") from exc + + if str(response.get("status") or "").lower() != "ok": + error_message = str(response.get("wording") or response.get("message") or "unknown") + raise RuntimeError(f"NapCat 动作返回失败: action={action_name} message={error_message}") + return response + + async def call_action_data(self, action_name: str, params: Mapping[str, Any]) -> Any: + """调用 OneBot 动作并返回 ``data`` 字段。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Any: NapCat 响应中的 ``data`` 字段。 + """ + response = await self.call_action(action_name, params) + return response.get("data") + + async def safe_call_action_data(self, action_name: str, params: Mapping[str, Any]) -> Any: + """安全调用 OneBot 动作并返回 ``data`` 字段。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Any: 响应中的 ``data`` 字段;失败时返回 ``None``。 + """ + try: + return await self.call_action_data(action_name, params) + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.warning(f"NapCat 查询动作执行失败: action={action_name} error={exc}") + return None + + async def download_binary(self, url: str) -> Optional[bytes]: + """下载远程二进制资源。 + + Args: + url: 资源 URL。 + + Returns: + Optional[bytes]: 下载到的二进制内容;失败时返回 ``None``。 + """ + if not url: + return None + if not AIOHTTP_AVAILABLE or ClientSession is None or ClientTimeout is None: + self._logger.warning("NapCat 查询层缺少 aiohttp,无法下载远程资源") + return None + + try: + timeout = ClientTimeout(total=15) + async with ClientSession(timeout=timeout) as session: + async with session.get(url) as response: + if response.status != 200: + self._logger.warning(f"NapCat 远程资源下载失败: status={response.status} url={url}") + return None + return await response.read() + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.warning(f"NapCat 远程资源下载失败: {exc}") + return None diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/ban_state_store.py b/plugin-templates/MaiBot-Napcat-Adapter/services/ban_state_store.py new file mode 100644 index 00000000..0d2cb26f --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/ban_state_store.py @@ -0,0 +1,168 @@ +"""NapCat 禁言状态存储。""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional + +import asyncio +import json + + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_DEFAULT_STORAGE_PATH = _PROJECT_ROOT / "data" / "napcat_adapter" / "ban_state.json" + + +@dataclass +class NapCatBanRecord: + """NapCat 禁言记录。""" + + group_id: str + user_id: str + lift_time: int + + @property + def record_key(self) -> str: + """返回当前记录的稳定键。 + + Returns: + str: 由群号和用户号拼接得到的稳定键。 + """ + return f"{self.group_id}:{self.user_id}" + + @classmethod + def from_mapping(cls, payload: Mapping[str, Any]) -> Optional["NapCatBanRecord"]: + """从字典构造禁言记录。 + + Args: + payload: 原始记录字典。 + + Returns: + Optional[NapCatBanRecord]: 构造成功时返回记录对象,否则返回 ``None``。 + """ + group_id = str(payload.get("group_id") or "").strip() + user_id = str(payload.get("user_id") or "").strip() + if not group_id or not user_id: + return None + + try: + lift_time = int(payload.get("lift_time", -1)) + except (TypeError, ValueError): + lift_time = -1 + return cls(group_id=group_id, user_id=user_id, lift_time=lift_time) + + def to_dict(self) -> Dict[str, Any]: + """将记录转换为可序列化字典。 + + Returns: + Dict[str, Any]: 可直接写入 JSON 的记录字典。 + """ + return asdict(self) + + +class NapCatBanStateStore: + """NapCat 禁言状态持久化仓库。""" + + def __init__(self, logger: Any, storage_path: Path = _DEFAULT_STORAGE_PATH) -> None: + """初始化禁言状态仓库。 + + Args: + logger: 插件日志对象。 + storage_path: 持久化文件路径。 + """ + self._logger = logger + self._storage_path = storage_path + self._records: Dict[str, NapCatBanRecord] = {} + self._records_lock = asyncio.Lock() + + async def load(self) -> None: + """从本地文件加载禁言记录。""" + if not self._storage_path.exists(): + return + + try: + raw_payload = json.loads(self._storage_path.read_text(encoding="utf-8")) + except Exception as exc: + self._logger.warning(f"NapCat 禁言状态文件读取失败,将忽略旧记录: {exc}") + return + + if not isinstance(raw_payload, list): + self._logger.warning("NapCat 禁言状态文件格式非法,将忽略旧记录") + return + + loaded_records: Dict[str, NapCatBanRecord] = {} + for item in raw_payload: + if not isinstance(item, Mapping): + continue + record = NapCatBanRecord.from_mapping(item) + if record is not None: + loaded_records[record.record_key] = record + + async with self._records_lock: + self._records = loaded_records + if loaded_records: + self._logger.info(f"NapCat 禁言状态已加载 {len(loaded_records)} 条记录") + + async def snapshot(self) -> List[NapCatBanRecord]: + """返回当前记录快照。 + + Returns: + List[NapCatBanRecord]: 当前内存中的记录列表副本。 + """ + async with self._records_lock: + return list(self._records.values()) + + async def upsert(self, record: NapCatBanRecord) -> None: + """新增或更新一条禁言记录。 + + Args: + record: 待写入的禁言记录。 + """ + async with self._records_lock: + self._records[record.record_key] = record + await self.persist() + + async def remove(self, group_id: str, user_id: str) -> Optional[NapCatBanRecord]: + """删除指定禁言记录。 + + Args: + group_id: 群号。 + user_id: 用户号。 + + Returns: + Optional[NapCatBanRecord]: 被移除的记录;不存在时返回 ``None``。 + """ + record_key = f"{group_id}:{user_id}" + return await self.pop(record_key) + + async def pop(self, record_key: str) -> Optional[NapCatBanRecord]: + """按稳定键移除一条记录。 + + Args: + record_key: 记录稳定键。 + + Returns: + Optional[NapCatBanRecord]: 被移除的记录;不存在时返回 ``None``。 + """ + async with self._records_lock: + removed_record = self._records.pop(record_key, None) + if removed_record is not None: + await self.persist() + return removed_record + + async def persist(self) -> None: + """将当前禁言记录持久化到本地文件。""" + async with self._records_lock: + serialized_records = [ + record.to_dict() for record in sorted(self._records.values(), key=lambda item: item.record_key) + ] + + try: + self._storage_path.parent.mkdir(parents=True, exist_ok=True) + self._storage_path.write_text( + json.dumps(serialized_records, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except Exception as exc: + self._logger.warning(f"NapCat 禁言状态持久化失败: {exc}") diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py b/plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py new file mode 100644 index 00000000..1c1686da --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/ban_tracker.py @@ -0,0 +1,176 @@ +"""NapCat 群禁言状态跟踪服务。""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Dict, Mapping, Optional + +import asyncio +import contextlib +import time + +from .ban_state_store import NapCatBanRecord, NapCatBanStateStore +from .query_service import NapCatQueryService + + +class NapCatBanTracker: + """NapCat 群禁言状态跟踪器。""" + + def __init__( + self, + logger: Any, + query_service: NapCatQueryService, + on_natural_lift: Callable[[Dict[str, Any]], Awaitable[None]], + state_store: NapCatBanStateStore, + ) -> None: + """初始化群禁言状态跟踪器。 + + Args: + logger: 插件日志对象。 + query_service: NapCat 查询服务。 + on_natural_lift: 检测到自然解除禁言后的回调。 + state_store: 禁言状态存储仓库。 + """ + self._logger = logger + self._query_service = query_service + self._on_natural_lift = on_natural_lift + self._state_store = state_store + self._poll_task: Optional[asyncio.Task[None]] = None + + async def start(self) -> None: + """启动禁言状态跟踪。""" + await self._state_store.load() + await self._refresh_records_from_remote() + if self._poll_task is None or self._poll_task.done(): + self._poll_task = asyncio.create_task(self._poll_loop(), name="napcat_adapter.ban_tracker") + + async def stop(self) -> None: + """停止禁言状态跟踪并落盘当前记录。""" + poll_task = self._poll_task + self._poll_task = None + if poll_task is not None: + poll_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await poll_task + await self._state_store.persist() + + async def record_notice(self, payload: Mapping[str, Any]) -> None: + """根据实际 notice 事件更新禁言状态。 + + Args: + payload: NapCat 推送的原始通知事件。 + """ + notice_type = str(payload.get("notice_type") or "").strip() + if notice_type != "group_ban": + return + + sub_type = str(payload.get("sub_type") or "").strip() + group_id = str(payload.get("group_id") or "").strip() + user_id = str(payload.get("user_id") or "0").strip() or "0" + if not group_id: + return + + if sub_type == "ban": + duration = self._normalize_int(payload.get("duration"), default=-1) + lift_time = -1 if user_id == "0" or duration <= 0 else int(time.time()) + duration + await self._state_store.upsert(NapCatBanRecord(group_id=group_id, user_id=user_id, lift_time=lift_time)) + return + + if sub_type in {"lift_ban", "whole_lift_ban"}: + await self._state_store.remove(group_id=group_id, user_id=user_id) + + async def _refresh_records_from_remote(self) -> None: + """基于当前 QQ 平台状态校正本地禁言记录。""" + for record in await self._state_store.snapshot(): + if record.user_id == "0": + await self._refresh_whole_ban_record(record) + continue + await self._refresh_member_ban_record(record) + + async def _refresh_whole_ban_record(self, record: NapCatBanRecord) -> None: + """刷新全体禁言记录。 + + Args: + record: 待刷新的禁言记录。 + """ + group_info = await self._query_service.get_group_info(record.group_id) + if group_info is None: + await self._emit_natural_lift(record) + return + + group_all_shut = self._normalize_int(group_info.get("group_all_shut"), default=0) + if group_all_shut == 0: + await self._emit_natural_lift(record) + + async def _refresh_member_ban_record(self, record: NapCatBanRecord) -> None: + """刷新成员禁言记录。 + + Args: + record: 待刷新的禁言记录。 + """ + member_info = await self._query_service.get_group_member_info(record.group_id, record.user_id, no_cache=True) + if member_info is None: + await self._emit_natural_lift(record) + return + + shut_up_timestamp = self._normalize_int(member_info.get("shut_up_timestamp"), default=0) + if shut_up_timestamp == 0: + await self._emit_natural_lift(record) + return + + if shut_up_timestamp != record.lift_time: + await self._state_store.upsert( + NapCatBanRecord(group_id=record.group_id, user_id=record.user_id, lift_time=shut_up_timestamp) + ) + + async def _poll_loop(self) -> None: + """后台轮询自然解除禁言。""" + while True: + await asyncio.sleep(5.0) + current_timestamp = int(time.time()) + for record in await self._state_store.snapshot(): + if record.user_id == "0": + await self._refresh_whole_ban_record(record) + continue + if record.lift_time != -1 and record.lift_time <= current_timestamp: + await self._emit_natural_lift(record) + + async def _emit_natural_lift(self, record: NapCatBanRecord) -> None: + """上报自然解除禁言事件。 + + Args: + record: 已解除的禁言记录。 + """ + removed_record = await self._state_store.pop(record.record_key) + if removed_record is None: + return + + payload: Dict[str, Any] = { + "post_type": "notice", + "notice_type": "group_ban", + "sub_type": "whole_lift_ban" if record.user_id == "0" else "lift_ban", + "group_id": record.group_id, + "user_id": record.user_id, + "operator_id": None, + "time": time.time(), + "is_natural_lift": True, + } + try: + await self._on_natural_lift(payload) + except Exception as exc: + self._logger.warning(f"NapCat 自然解除禁言回调失败: {exc}") + + @staticmethod + def _normalize_int(value: Any, default: int) -> int: + """将任意值规范化为整数。 + + Args: + value: 待规范化的值。 + default: 转换失败时的默认值。 + + Returns: + int: 规范化后的整数结果。 + """ + try: + return int(value) + except (TypeError, ValueError): + return default diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/history_recovery_store.py b/plugin-templates/MaiBot-Napcat-Adapter/services/history_recovery_store.py new file mode 100644 index 00000000..6dad2771 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/history_recovery_store.py @@ -0,0 +1,423 @@ +"""NapCat 历史补拉状态持久化仓库。""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, List, Optional, TypeVar + +import asyncio +import sqlite3 +import time + +from ..constants import DEFAULT_HISTORY_RECOVERY_SEEN_TTL_SEC + +_PROJECT_ROOT = Path(__file__).resolve().parents[3] +_DEFAULT_STORAGE_PATH = _PROJECT_ROOT / "data" / "napcat_adapter" / "history_recovery.sqlite3" + +_SCHEMA_STATEMENTS = ( + """ + CREATE TABLE IF NOT EXISTS napcat_chat_checkpoint ( + account_id TEXT NOT NULL, + scope TEXT NOT NULL, + chat_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + last_message_id TEXT NOT NULL, + last_message_time REAL NOT NULL, + last_message_seq INTEGER, + updated_at REAL NOT NULL, + PRIMARY KEY (account_id, scope, chat_type, chat_id) + ) + """, + """ + CREATE INDEX IF NOT EXISTS ix_napcat_chat_checkpoint_updated_at + ON napcat_chat_checkpoint (updated_at DESC) + """, + """ + CREATE TABLE IF NOT EXISTS napcat_recovery_seen ( + account_id TEXT NOT NULL, + scope TEXT NOT NULL, + chat_type TEXT NOT NULL, + chat_id TEXT NOT NULL, + external_message_id TEXT NOT NULL, + seen_at REAL NOT NULL, + PRIMARY KEY (account_id, scope, chat_type, chat_id, external_message_id) + ) + """, + """ + CREATE INDEX IF NOT EXISTS ix_napcat_recovery_seen_seen_at + ON napcat_recovery_seen (seen_at DESC) + """, +) + +T = TypeVar("T") + + +@dataclass(frozen=True) +class NapCatChatCheckpoint: + """描述一个会话的最近入站锚点。""" + + account_id: str + scope: str + chat_type: str + chat_id: str + last_message_id: str + last_message_time: float + last_message_seq: int | None + updated_at: float + + @classmethod + def from_row(cls, row: sqlite3.Row) -> "NapCatChatCheckpoint": + """从 SQLite 行对象恢复 checkpoint。""" + + last_message_seq = row["last_message_seq"] + normalized_seq = int(last_message_seq) if isinstance(last_message_seq, int) else None + return cls( + account_id=str(row["account_id"] or "").strip(), + scope=str(row["scope"] or "").strip(), + chat_type=str(row["chat_type"] or "").strip(), + chat_id=str(row["chat_id"] or "").strip(), + last_message_id=str(row["last_message_id"] or "").strip(), + last_message_time=float(row["last_message_time"] or 0.0), + last_message_seq=normalized_seq, + updated_at=float(row["updated_at"] or 0.0), + ) + + +class NapCatHistoryRecoveryStore: + """负责持久化历史补拉所需的会话状态与去重状态。""" + + def __init__(self, logger: Any, storage_path: Path = _DEFAULT_STORAGE_PATH) -> None: + """初始化历史补拉状态仓库。""" + + self._logger = logger + self._storage_path = storage_path + self._store_lock = asyncio.Lock() + self._schema_ready = False + + async def load(self) -> None: + """初始化 SQLite 文件并清理过期去重记录。""" + + await self._execute_locked(self._ensure_schema) + pruned_count = await self.prune_recovery_seen(DEFAULT_HISTORY_RECOVERY_SEEN_TTL_SEC) + if pruned_count > 0: + self._logger.debug(f"NapCat 历史补拉去重表已清理 {pruned_count} 条过期记录") + + async def list_checkpoints(self, account_id: str, scope: str = "", limit: int = 50) -> List[NapCatChatCheckpoint]: + """列出指定账号与作用域下的最近会话 checkpoint。""" + + normalized_account_id = str(account_id or "").strip() + if not normalized_account_id: + return [] + + normalized_scope = self._normalize_scope(scope) + normalized_limit = max(1, int(limit)) + + def _operation(conn: sqlite3.Connection) -> List[NapCatChatCheckpoint]: + cursor = conn.execute( + """ + SELECT + account_id, + scope, + chat_type, + chat_id, + last_message_id, + last_message_time, + last_message_seq, + updated_at + FROM napcat_chat_checkpoint + WHERE account_id = ? AND scope = ? + ORDER BY updated_at DESC + LIMIT ? + """, + (normalized_account_id, normalized_scope, normalized_limit), + ) + return [NapCatChatCheckpoint.from_row(row) for row in cursor.fetchall()] + + return await self._execute_locked(_operation) + + async def record_checkpoint( + self, + *, + account_id: str, + scope: str = "", + chat_type: str, + chat_id: str, + message_id: str, + message_time: float, + message_seq: int | None = None, + ) -> None: + """记录一条已被 Host 接受的最新入站消息锚点。""" + + normalized_account_id = str(account_id or "").strip() + normalized_scope = self._normalize_scope(scope) + normalized_chat_type = str(chat_type or "").strip() + normalized_chat_id = str(chat_id or "").strip() + normalized_message_id = str(message_id or "").strip() + + if not ( + normalized_account_id + and normalized_chat_type + and normalized_chat_id + and normalized_message_id + ): + return + + normalized_message_time = float(message_time or 0.0) + normalized_message_seq = self._normalize_message_seq(message_seq) + updated_at = time.time() + + def _operation(conn: sqlite3.Connection) -> None: + cursor = conn.execute( + """ + SELECT last_message_id, last_message_time, last_message_seq + FROM napcat_chat_checkpoint + WHERE account_id = ? AND scope = ? AND chat_type = ? AND chat_id = ? + """, + ( + normalized_account_id, + normalized_scope, + normalized_chat_type, + normalized_chat_id, + ), + ) + existing_row = cursor.fetchone() + if existing_row is not None and not self._should_advance_checkpoint( + existing_row=existing_row, + message_id=normalized_message_id, + message_time=normalized_message_time, + message_seq=normalized_message_seq, + ): + return + + conn.execute( + """ + INSERT INTO napcat_chat_checkpoint ( + account_id, + scope, + chat_type, + chat_id, + last_message_id, + last_message_time, + last_message_seq, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(account_id, scope, chat_type, chat_id) DO UPDATE SET + last_message_id = excluded.last_message_id, + last_message_time = excluded.last_message_time, + last_message_seq = excluded.last_message_seq, + updated_at = excluded.updated_at + """, + ( + normalized_account_id, + normalized_scope, + normalized_chat_type, + normalized_chat_id, + normalized_message_id, + normalized_message_time, + normalized_message_seq, + updated_at, + ), + ) + + await self._execute_locked(_operation) + + async def has_recovered_message_seen( + self, + *, + account_id: str, + scope: str = "", + chat_type: str, + chat_id: str, + external_message_id: str, + ) -> bool: + """判断某条历史补拉消息是否已经被当前仓库记录过。""" + + normalized_account_id = str(account_id or "").strip() + normalized_scope = self._normalize_scope(scope) + normalized_chat_type = str(chat_type or "").strip() + normalized_chat_id = str(chat_id or "").strip() + normalized_external_message_id = str(external_message_id or "").strip() + + if not ( + normalized_account_id + and normalized_chat_type + and normalized_chat_id + and normalized_external_message_id + ): + return False + + def _operation(conn: sqlite3.Connection) -> bool: + cursor = conn.execute( + """ + SELECT 1 + FROM napcat_recovery_seen + WHERE account_id = ? + AND scope = ? + AND chat_type = ? + AND chat_id = ? + AND external_message_id = ? + LIMIT 1 + """, + ( + normalized_account_id, + normalized_scope, + normalized_chat_type, + normalized_chat_id, + normalized_external_message_id, + ), + ) + return cursor.fetchone() is not None + + return await self._execute_locked(_operation) + + async def mark_recovered_message_seen( + self, + *, + account_id: str, + scope: str = "", + chat_type: str, + chat_id: str, + external_message_id: str, + ) -> None: + """将一条历史补拉消息标记为已尝试处理。""" + + normalized_account_id = str(account_id or "").strip() + normalized_scope = self._normalize_scope(scope) + normalized_chat_type = str(chat_type or "").strip() + normalized_chat_id = str(chat_id or "").strip() + normalized_external_message_id = str(external_message_id or "").strip() + + if not ( + normalized_account_id + and normalized_chat_type + and normalized_chat_id + and normalized_external_message_id + ): + return + + def _operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT OR REPLACE INTO napcat_recovery_seen ( + account_id, + scope, + chat_type, + chat_id, + external_message_id, + seen_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + normalized_account_id, + normalized_scope, + normalized_chat_type, + normalized_chat_id, + normalized_external_message_id, + time.time(), + ), + ) + + await self._execute_locked(_operation) + + async def prune_recovery_seen(self, ttl_seconds: float) -> int: + """删除超过保留期的历史补拉去重记录。""" + + normalized_ttl_seconds = max(0.0, float(ttl_seconds or 0.0)) + if normalized_ttl_seconds <= 0.0: + return 0 + + cutoff_timestamp = time.time() - normalized_ttl_seconds + + def _operation(conn: sqlite3.Connection) -> int: + cursor = conn.execute( + "DELETE FROM napcat_recovery_seen WHERE seen_at < ?", + (cutoff_timestamp,), + ) + return int(cursor.rowcount or 0) + + return await self._execute_locked(_operation) + + async def _execute_locked(self, operation: Callable[[sqlite3.Connection], T]) -> T: + """在锁保护下打开 SQLite 并执行一次原子操作。""" + + async with self._store_lock: + conn = self._open_connection() + try: + self._ensure_schema(conn) + result = operation(conn) + conn.commit() + return result + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def _open_connection(self) -> sqlite3.Connection: + """打开一个带 Row 工厂的 SQLite 连接。""" + + self._storage_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(self._storage_path)) + conn.row_factory = sqlite3.Row + return conn + + def _ensure_schema(self, conn: sqlite3.Connection) -> None: + """确保 SQLite 表结构已经准备完成。""" + + if self._schema_ready: + return + + for statement in _SCHEMA_STATEMENTS: + conn.execute(statement) + self._schema_ready = True + + @staticmethod + def _normalize_scope(scope: str | None) -> str: + """将空作用域统一折叠为空字符串。""" + + return str(scope or "").strip() + + @staticmethod + def _normalize_message_seq(message_seq: object) -> int | None: + """将消息序号规范化为可选整数。""" + + try: + if message_seq is None or str(message_seq).strip() == "": + return None + return int(message_seq) + except (TypeError, ValueError): + return None + + @classmethod + def _should_advance_checkpoint( + cls, + *, + existing_row: sqlite3.Row, + message_id: str, + message_time: float, + message_seq: int | None, + ) -> bool: + """判断新的锚点是否应覆盖旧锚点。""" + + existing_message_id = str(existing_row["last_message_id"] or "").strip() + existing_message_time = float(existing_row["last_message_time"] or 0.0) + existing_message_seq = cls._normalize_message_seq(existing_row["last_message_seq"]) + + if message_seq is not None and existing_message_seq is not None: + if message_seq != existing_message_seq: + return message_seq > existing_message_seq + if message_id == existing_message_id: + return False + return message_time >= existing_message_time + + if message_time != existing_message_time: + return message_time > existing_message_time + + if message_id == existing_message_id: + return False + + if message_seq is not None and existing_message_seq is None: + return True + + return True diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/official_bot_guard.py b/plugin-templates/MaiBot-Napcat-Adapter/services/official_bot_guard.py new file mode 100644 index 00000000..caa31065 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/official_bot_guard.py @@ -0,0 +1,59 @@ +"""NapCat 官方机器人消息拦截服务。""" + +from __future__ import annotations + +from typing import Any, Dict + +from .query_service import NapCatQueryService + + +class NapCatOfficialBotGuard: + """根据群成员资料判断是否应拦截 QQ 官方机器人消息。""" + + def __init__(self, logger: Any, query_service: NapCatQueryService) -> None: + """初始化官方机器人拦截服务。 + + Args: + logger: 插件日志对象。 + query_service: NapCat 查询服务。 + """ + self._logger = logger + self._query_service = query_service + self._cache: Dict[str, bool] = {} + + def clear_cache(self) -> None: + """清空机器人识别缓存。""" + self._cache.clear() + + async def should_reject(self, sender_user_id: str, group_id: str, ban_qq_bot: bool) -> bool: + """判断是否应拦截当前消息。 + + Args: + sender_user_id: 发送者用户号。 + group_id: 群号。 + ban_qq_bot: 是否启用官方机器人拦截。 + + Returns: + bool: 若应拦截,则返回 ``True``。 + """ + if not ban_qq_bot or not group_id: + return False + + cache_key = f"{group_id}:{sender_user_id}" + cached_result = self._cache.get(cache_key) + if cached_result is not None: + if cached_result: + self._logger.warning("QQ 官方机器人消息拦截已启用,消息被丢弃") + return cached_result + + member_info = await self._query_service.get_group_member_info(group_id, sender_user_id, no_cache=True) + if member_info is None: + self._logger.warning("无法获取用户是否为机器人,默认放行当前消息") + self._cache[cache_key] = False + return False + + should_reject = bool(member_info.get("is_robot")) + self._cache[cache_key] = should_reject + if should_reject: + self._logger.warning("QQ 官方机器人消息拦截已启用,消息被丢弃") + return should_reject diff --git a/plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py b/plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py new file mode 100644 index 00000000..6a45a9a2 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/services/query_service.py @@ -0,0 +1,585 @@ +"""NapCat QQ 平台查询服务。""" + +from __future__ import annotations + +from typing import Any, List, Mapping, Optional + +from ..types import NapCatActionParams, NapCatActionResponse, NapCatPayloadDict, NapCatPayloadList +from .action_service import NapCatActionService + + +class NapCatQueryService: + """NapCat QQ 平台查询与管理动作服务。""" + + def __init__(self, action_service: NapCatActionService, logger: Any) -> None: + """初始化查询服务。 + + Args: + action_service: NapCat 底层动作服务。 + logger: 插件日志对象。 + """ + self._action_service = action_service + self._logger = logger + + async def call_action(self, action_name: str, params: NapCatActionParams) -> NapCatActionResponse: + """调用 OneBot 动作并要求返回成功结果。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + NapCatActionResponse: NapCat 返回的原始响应字典。 + """ + return await self._action_service.call_action(action_name, params) + + async def call_action_data(self, action_name: str, params: NapCatActionParams) -> Any: + """调用 OneBot 动作并返回 ``data`` 字段。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Any: NapCat 响应中的 ``data`` 字段。 + """ + return await self._action_service.call_action_data(action_name, params) + + async def get_login_info(self) -> Optional[NapCatPayloadDict]: + """获取当前登录账号信息。 + + Returns: + Optional[NapCatPayloadDict]: 登录信息字典;返回值不是字典时为 ``None``。 + """ + response_data = await self._safe_call_action_data("get_login_info", {}) + return response_data if isinstance(response_data, dict) else None + + async def get_stranger_info(self, user_id: str, no_cache: bool = False) -> Optional[NapCatPayloadDict]: + """获取陌生人信息。 + + Args: + user_id: 用户号。 + no_cache: 是否禁用缓存。 + + Returns: + Optional[NapCatPayloadDict]: 陌生人信息字典;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data( + "get_stranger_info", + {"user_id": user_id, "no_cache": bool(no_cache)}, + ) + return response_data if isinstance(response_data, dict) else None + + async def get_friend_list(self, no_cache: bool = False) -> Optional[NapCatPayloadList]: + """获取好友列表。 + + Args: + no_cache: 是否禁用缓存。 + + Returns: + Optional[NapCatPayloadList]: 好友信息列表;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data("get_friend_list", {"no_cache": bool(no_cache)}) + return self._normalize_payload_list(response_data, action_name="get_friend_list") + + async def get_group_info(self, group_id: str) -> Optional[NapCatPayloadDict]: + """获取群信息。 + + Args: + group_id: 群号。 + + Returns: + Optional[NapCatPayloadDict]: 群信息字典;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data("get_group_info", {"group_id": group_id}) + return response_data if isinstance(response_data, dict) else None + + async def get_group_detail_info(self, group_id: str) -> Optional[NapCatPayloadDict]: + """获取群详细信息。 + + Args: + group_id: 群号。 + + Returns: + Optional[NapCatPayloadDict]: 群详细信息字典;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data("get_group_detail_info", {"group_id": group_id}) + return response_data if isinstance(response_data, dict) else None + + async def get_group_list(self, no_cache: bool = False) -> Optional[NapCatPayloadList]: + """获取群列表。 + + Args: + no_cache: 是否禁用缓存。 + + Returns: + Optional[NapCatPayloadList]: 群信息列表;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data("get_group_list", {"no_cache": bool(no_cache)}) + return self._normalize_payload_list(response_data, action_name="get_group_list") + + async def get_group_at_all_remain(self, group_id: str) -> Optional[NapCatPayloadDict]: + """获取群 @ 全体成员剩余次数。 + + Args: + group_id: 群号。 + + Returns: + Optional[NapCatPayloadDict]: 剩余次数信息;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data("get_group_at_all_remain", {"group_id": group_id}) + return response_data if isinstance(response_data, dict) else None + + async def get_group_member_info( + self, + group_id: str, + user_id: str, + no_cache: bool = True, + ) -> Optional[NapCatPayloadDict]: + """获取群成员信息。 + + Args: + group_id: 群号。 + user_id: 用户号。 + no_cache: 是否禁用缓存。 + + Returns: + Optional[NapCatPayloadDict]: 群成员信息字典;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data( + "get_group_member_info", + {"group_id": group_id, "user_id": user_id, "no_cache": bool(no_cache)}, + ) + return response_data if isinstance(response_data, dict) else None + + async def get_group_member_list(self, group_id: str, no_cache: bool = False) -> Optional[NapCatPayloadList]: + """获取群成员列表。 + + Args: + group_id: 群号。 + no_cache: 是否禁用缓存。 + + Returns: + Optional[NapCatPayloadList]: 群成员信息列表;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data( + "get_group_member_list", + {"group_id": group_id, "no_cache": bool(no_cache)}, + ) + return self._normalize_payload_list(response_data, action_name="get_group_member_list") + + async def get_message_detail(self, message_id: str) -> Optional[NapCatPayloadDict]: + """获取消息详情。 + + Args: + message_id: 消息 ID。 + + Returns: + Optional[NapCatPayloadDict]: 消息详情字典;失败时返回 ``None``。 + """ + response_data = await self._safe_call_action_data("get_msg", {"message_id": message_id}) + return response_data if isinstance(response_data, dict) else None + + async def get_friend_message_history( + self, + user_id: str, + *, + message_seq: int | None = None, + count: int = 20, + reverse_order: bool = False, + ) -> Optional[NapCatPayloadList]: + """获取私聊历史消息列表。""" + + params: NapCatActionResponse = { + "user_id": user_id, + "count": max(1, int(count)), + "reverse_order": bool(reverse_order), + } + if message_seq is not None: + params["message_seq"] = int(message_seq) + response_data = await self._safe_call_action_data("get_friend_msg_history", params) + return self._normalize_payload_list(response_data, action_name="get_friend_msg_history") + + async def get_group_message_history( + self, + group_id: str, + *, + message_seq: int | None = None, + count: int = 20, + reverse_order: bool = False, + ) -> Optional[NapCatPayloadList]: + """获取群聊历史消息列表。""" + + params: NapCatActionResponse = { + "group_id": group_id, + "count": max(1, int(count)), + "reverse_order": bool(reverse_order), + } + if message_seq is not None: + params["message_seq"] = int(message_seq) + response_data = await self._safe_call_action_data("get_group_msg_history", params) + return self._normalize_payload_list(response_data, action_name="get_group_msg_history") + + async def get_forward_message( + self, + message_id: Optional[str] = None, + forward_id: Optional[str] = None, + ) -> Optional[NapCatPayloadDict]: + """获取合并转发消息详情。 + + Args: + message_id: 转发消息 ID。 + forward_id: NapCat 官方文档中的兼容字段 ``id``。 + + Returns: + Optional[NapCatPayloadDict]: 合并转发消息详情;失败时返回 ``None``。 + """ + params: NapCatActionResponse = {} + if message_id: + params["message_id"] = message_id + if forward_id: + params["id"] = forward_id + if not params: + raise ValueError("message_id 或 id 至少提供一个") + + response_data = await self._safe_call_action_data("get_forward_msg", params) + return self._normalize_forward_payload(response_data) + + async def get_record_detail( + self, + file_name: Optional[str] = None, + file_id: Optional[str] = None, + out_format: str = "wav", + ) -> Optional[NapCatPayloadDict]: + """获取语音文件详情。 + + Args: + file_name: 语音文件名。 + file_id: 可选文件 ID。 + out_format: 输出格式。 + + Returns: + Optional[NapCatPayloadDict]: 语音详情字典;失败时返回 ``None``。 + """ + params: NapCatActionResponse = {} + if file_name: + params["file"] = file_name + if file_id: + params["file_id"] = file_id + if out_format: + params["out_format"] = out_format + if not params.get("file") and not params.get("file_id"): + raise ValueError("file 或 file_id 至少提供一个") + + response_data = await self._safe_call_action_data("get_record", params) + return response_data if isinstance(response_data, dict) else None + + async def set_group_ban(self, group_id: int, user_id: int, duration: int) -> NapCatActionResponse: + """设置群成员禁言。 + + Args: + group_id: 群号。 + user_id: 用户号。 + duration: 禁言秒数。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "set_group_ban", + {"group_id": group_id, "user_id": user_id, "duration": duration}, + ) + + async def set_group_whole_ban(self, group_id: int, enable: bool) -> NapCatActionResponse: + """设置群全体禁言。 + + Args: + group_id: 群号。 + enable: 是否开启全体禁言。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "set_group_whole_ban", + {"group_id": group_id, "enable": bool(enable)}, + ) + + async def set_group_kick( + self, + group_id: int, + user_id: int, + reject_add_request: bool = False, + ) -> NapCatActionResponse: + """踢出群成员。 + + Args: + group_id: 群号。 + user_id: 用户号。 + reject_add_request: 是否拒绝再次加群。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "set_group_kick", + { + "group_id": group_id, + "user_id": user_id, + "reject_add_request": bool(reject_add_request), + }, + ) + + async def set_group_kick_members( + self, + group_id: int, + user_ids: List[int], + reject_add_request: bool = False, + ) -> NapCatActionResponse: + """批量踢出群成员。 + + Args: + group_id: 群号。 + user_ids: 用户号列表。 + reject_add_request: 是否拒绝再次加群。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "set_group_kick_members", + { + "group_id": group_id, + "user_id": user_ids, + "reject_add_request": bool(reject_add_request), + }, + ) + + async def send_poke( + self, + user_id: int, + group_id: Optional[int] = None, + target_id: Optional[int] = None, + ) -> NapCatActionResponse: + """发送戳一戳。 + + Args: + user_id: 目标用户号。 + group_id: 可选群号;私聊时为空。 + target_id: NapCat 官方 ``send_poke`` 动作支持的目标 ID。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + params: NapCatActionResponse = {"user_id": user_id} + if group_id is not None: + params["group_id"] = group_id + if target_id is not None: + params["target_id"] = target_id + return await self.call_action("send_poke", params) + + async def delete_message(self, message_id: int) -> NapCatActionResponse: + """撤回消息。 + + Args: + message_id: 消息 ID。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action("delete_msg", {"message_id": message_id}) + + async def send_group_ai_record(self, group_id: int, character: str, text: str) -> NapCatActionResponse: + """发送群 AI 语音。 + + Args: + group_id: 群号。 + character: 角色标识。 + text: 语音文本。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "send_group_ai_record", + {"group_id": group_id, "character": character, "text": text}, + ) + + async def set_message_emoji_like( + self, + message_id: int, + emoji_id: int, + set_like: bool = True, + ) -> NapCatActionResponse: + """给消息贴表情或取消表情。 + + Args: + message_id: 消息 ID。 + emoji_id: 表情 ID。 + set_like: 是否设置为已贴表情。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "set_msg_emoji_like", + {"message_id": message_id, "emoji_id": emoji_id, "set": bool(set_like)}, + ) + + async def set_group_name(self, group_id: int, group_name: str) -> NapCatActionResponse: + """设置群名称。 + + Args: + group_id: 群号。 + group_name: 新群名称。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + return await self.call_action( + "set_group_name", + {"group_id": group_id, "group_name": group_name}, + ) + + async def set_qq_profile( + self, + nickname: str, + personal_note: str = "", + sex: str = "", + ) -> NapCatActionResponse: + """设置 QQ 账号资料。 + + Args: + nickname: 新昵称。 + personal_note: 个性签名。 + sex: 性别,支持 ``male``、``female``、``unknown``。 + + Returns: + NapCatActionResponse: NapCat 原始响应字典。 + """ + params: NapCatActionResponse = {"nickname": nickname} + if personal_note: + params["personal_note"] = personal_note + if sex: + params["sex"] = sex + return await self.call_action("set_qq_profile", params) + + async def download_binary(self, url: str) -> Optional[bytes]: + """下载远程二进制资源。 + + Args: + url: 资源 URL。 + + Returns: + Optional[bytes]: 下载到的二进制内容;失败时返回 ``None``。 + """ + return await self._action_service.download_binary(url) + + async def _safe_call_action_data(self, action_name: str, params: NapCatActionParams) -> Any: + """安全调用 OneBot 动作并返回 ``data`` 字段。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Any: 响应中的 ``data`` 字段;失败时返回 ``None``。 + """ + return await self._action_service.safe_call_action_data(action_name, params) + + def _normalize_payload_list(self, response_data: Any, action_name: str) -> Optional[NapCatPayloadList]: + """将列表类响应归一化为字典列表。 + + NapCat 在不同版本或不同动作下,``data`` 可能直接返回列表, + 也可能再包一层字典,例如 ``{\"members\": [...]}``。 + + Args: + response_data: 原始 ``data`` 字段。 + action_name: 当前动作名称。 + + Returns: + Optional[NapCatPayloadList]: 归一化后的列表;无法识别时返回 ``None``。 + """ + + if isinstance(response_data, list): + return [dict(item) for item in response_data if isinstance(item, Mapping)] + + if not isinstance(response_data, Mapping): + self._logger.warning( + "NapCat 列表接口返回了无法识别的数据类型: action=%s type=%s payload=%r", + action_name, + type(response_data).__name__, + response_data, + ) + return None + + for key in ( + "list", + "items", + "members", + "member_list", + "group_list", + "friend_list", + "friends", + "records", + "rows", + "data", + ): + candidate = response_data.get(key) + if isinstance(candidate, list): + return [dict(item) for item in candidate if isinstance(item, Mapping)] + + for candidate in response_data.values(): + if isinstance(candidate, list): + return [dict(item) for item in candidate if isinstance(item, Mapping)] + + self._logger.warning( + "NapCat 列表接口返回了无法归一化的字典结构: action=%s payload=%r", + action_name, + response_data, + ) + return None + + def _normalize_forward_payload(self, response_data: Any) -> Optional[NapCatPayloadDict]: + """将合并转发响应归一化为统一字典结构。 + + NapCat 的 ``get_forward_msg`` 在不同版本下,``data`` 可能直接返回节点列表, + 也可能返回 ``{\"messages\": [...]}``,甚至包在 ``content`` 字段中。 + + Args: + response_data: ``get_forward_msg`` 的原始 ``data`` 字段。 + + Returns: + Optional[NapCatPayloadDict]: 归一化后的转发消息详情;失败时返回 ``None``。 + """ + if isinstance(response_data, list): + return {"messages": [dict(item) for item in response_data if isinstance(item, Mapping)]} + + if not isinstance(response_data, Mapping): + self._logger.warning( + "NapCat 转发接口返回了无法识别的数据类型: type=%s payload=%r", + type(response_data).__name__, + response_data, + ) + return None + + direct_messages = response_data.get("messages") + if isinstance(direct_messages, list): + return dict(response_data) + + direct_content = response_data.get("content") + if isinstance(direct_content, list): + return {"messages": [dict(item) for item in direct_content if isinstance(item, Mapping)]} + + nested_data = response_data.get("data") + if isinstance(nested_data, Mapping): + nested_messages = nested_data.get("messages") + if isinstance(nested_messages, list): + return {"messages": [dict(item) for item in nested_messages if isinstance(item, Mapping)]} + + nested_content = nested_data.get("content") + if isinstance(nested_content, list): + return {"messages": [dict(item) for item in nested_content if isinstance(item, Mapping)]} + + self._logger.warning("NapCat 转发接口未返回可识别的转发节点列表: payload=%r", response_data) + return None diff --git a/plugin-templates/MaiBot-Napcat-Adapter/transport.py b/plugin-templates/MaiBot-Napcat-Adapter/transport.py new file mode 100644 index 00000000..b61075a2 --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/transport.py @@ -0,0 +1,449 @@ +"""NapCat 正向 WebSocket 传输层。""" + +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, Optional, Set, cast +from uuid import uuid4 + +import asyncio +import contextlib +import json + +from .config import NapCatServerConfig + +if TYPE_CHECKING: + from aiohttp import ClientWebSocketResponse as AiohttpClientWebSocketResponse + +try: + from aiohttp import ClientSession, ClientTimeout, WSMsgType + + AIOHTTP_AVAILABLE = True +except ImportError: + ClientSession = cast(Any, None) + ClientTimeout = cast(Any, None) + WSMsgType = cast(Any, None) + AIOHTTP_AVAILABLE = False + +if not TYPE_CHECKING: + AiohttpClientWebSocketResponse = Any + + +class NapCatTransportClient: + """NapCat 正向 WebSocket 客户端。""" + + def __init__( + self, + logger: Any, + on_connection_opened: Callable[[], Coroutine[Any, Any, None]], + on_connection_closed: Callable[[], Coroutine[Any, Any, None]], + on_payload: Callable[[Dict[str, Any]], Coroutine[Any, Any, None]], + ) -> None: + """初始化传输层客户端。 + + Args: + logger: 插件日志对象。 + on_connection_opened: 连接建立后的异步回调。 + on_connection_closed: 连接断开后的异步回调。 + on_payload: 收到非 echo 载荷后的异步回调。 + """ + self._logger = logger + self._on_connection_opened = on_connection_opened + self._on_connection_closed = on_connection_closed + self._on_payload = on_payload + self._server_config: Optional[NapCatServerConfig] = None + self._connection_task: Optional[asyncio.Task[None]] = None + self._pending_actions: Dict[str, asyncio.Future[Dict[str, Any]]] = {} + self._background_tasks: Set[asyncio.Task[Any]] = set() + self._send_lock = asyncio.Lock() + self._ws: Optional[AiohttpClientWebSocketResponse] = None + self._stop_requested: bool = False + self._connection_active: bool = False + self._warned_missing_token_for_ws_url: Optional[str] = None + + @classmethod + def is_available(cls) -> bool: + """判断当前环境是否安装了传输层依赖。 + + Returns: + bool: 若已安装 ``aiohttp``,则返回 ``True``。 + """ + return AIOHTTP_AVAILABLE + + def configure(self, server_config: NapCatServerConfig) -> None: + """更新当前传输层使用的 NapCat 服务端配置。 + + Args: + server_config: 最新生效的 NapCat 服务端配置。 + """ + self._server_config = server_config + self._warned_missing_token_for_ws_url = None + + async def start(self) -> None: + """启动 NapCat 正向 WebSocket 连接循环。 + + Raises: + RuntimeError: 当缺少配置或依赖时抛出。 + """ + if not self.is_available(): + raise RuntimeError("NapCat 适配器依赖 aiohttp,但当前环境未安装该依赖") + if self._server_config is None: + raise RuntimeError("NapCat 适配器尚未配置 napcat_server") + if self._connection_task is not None and not self._connection_task.done(): + return + + self._stop_requested = False + self._connection_task = asyncio.create_task(self._connection_loop(), name="napcat_adapter.connection") + + async def stop(self) -> None: + """停止当前连接并清理所有后台任务。""" + self._stop_requested = True + connection_task = self._connection_task + self._connection_task = None + + ws = self._ws + if ws is not None and not ws.closed: + with contextlib.suppress(Exception): + await ws.close() + self._ws = None + + if connection_task is not None: + connection_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await connection_task + + await self._cancel_background_tasks() + await self._notify_connection_closed() + self._fail_pending_actions("NapCat connection closed") + + async def call_action(self, action_name: str, params: Dict[str, Any]) -> Dict[str, Any]: + """发送 OneBot 动作并等待对应的 echo 响应。 + + Args: + action_name: OneBot 动作名称。 + params: 动作参数。 + + Returns: + Dict[str, Any]: NapCat 返回的原始响应字典。 + + Raises: + RuntimeError: 当连接不可用时抛出。 + """ + ws = self._ws + server_config = self._server_config + if ws is None or ws.closed or server_config is None: + raise RuntimeError("NapCat is not connected") + + echo_id = uuid4().hex + loop = asyncio.get_running_loop() + response_future: asyncio.Future[Dict[str, Any]] = loop.create_future() + self._pending_actions[echo_id] = response_future + + request_payload = {"action": action_name, "params": params, "echo": echo_id} + try: + async with self._send_lock: + await ws.send_str(json.dumps(request_payload, ensure_ascii=False)) + return await asyncio.wait_for(response_future, timeout=server_config.action_timeout_sec) + finally: + self._pending_actions.pop(echo_id, None) + + async def _connection_loop(self) -> None: + """维护单个 WebSocket 连接,并在断开后按配置重连。""" + assert ClientSession is not None + assert ClientTimeout is not None + + while not self._stop_requested: + server_config = self._server_config + if server_config is None: + return + + ws_url = server_config.build_ws_url() + timeout = ClientTimeout(total=None, connect=10) + self._log_connection_attempt(ws_url, server_config) + + try: + async with ClientSession(headers=self._build_headers(server_config), timeout=timeout) as session: + async with session.ws_connect(ws_url, heartbeat=server_config.heartbeat_interval or None) as ws: + self._ws = ws + self._logger.info(f"NapCat 适配器已连接: {ws_url}") + disconnect_reason = await self._receive_loop(ws) + self._log_connection_closed(ws_url, server_config, disconnect_reason) + except asyncio.CancelledError: + raise + except Exception as exc: + self._logger.warning( + f"NapCat 适配器连接失败: {exc}" + f"{self._build_missing_token_hint(server_config)}" + f"{self._build_reconnect_hint(server_config)}" + ) + finally: + self._ws = None + await self._notify_connection_closed() + self._fail_pending_actions("NapCat connection interrupted") + + if self._stop_requested: + break + + await asyncio.sleep(server_config.reconnect_delay_sec) + + async def _receive_loop(self, ws: AiohttpClientWebSocketResponse) -> str: + """持续消费 WebSocket 消息并分发处理。 + + Args: + ws: 当前活跃的 WebSocket 连接对象。 + + Returns: + str: 当前连接结束时的简要原因描述。 + """ + assert WSMsgType is not None + + disconnect_reason = "未收到更多 WebSocket 消息,连接已结束" + bootstrap_task = self._create_background_task( + self._notify_connection_opened(), + "napcat_adapter.bootstrap", + ) + try: + async for ws_message in ws: + if ws_message.type != WSMsgType.TEXT: + if ws_message.type == WSMsgType.CLOSE: + disconnect_reason = self._describe_terminal_ws_message( + ws=ws, + ws_message=ws_message, + message_label="收到服务端 CLOSE 帧", + ) + break + if ws_message.type == WSMsgType.CLOSED: + disconnect_reason = self._describe_terminal_ws_message( + ws=ws, + ws_message=ws_message, + message_label="WebSocket 已关闭", + ) + break + if ws_message.type == WSMsgType.ERROR: + disconnect_reason = self._describe_terminal_ws_message( + ws=ws, + ws_message=ws_message, + message_label="WebSocket 进入错误状态", + ) + break + continue + + payload = self._parse_json_message(ws_message.data) + if payload is None: + continue + + if echo_id := str(payload.get("echo") or "").strip(): + self._resolve_pending_action(echo_id, payload) + continue + + self._create_background_task(self._on_payload(payload), "napcat_adapter.payload") + finally: + if bootstrap_task is not None and not bootstrap_task.done(): + bootstrap_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await bootstrap_task + + return disconnect_reason + + def _create_background_task(self, coroutine: Coroutine[Any, Any, Any], name: str) -> asyncio.Task[Any]: + """创建并跟踪一个后台任务。 + + Args: + coroutine: 待执行的协程对象。 + name: 任务名。 + + Returns: + asyncio.Task[Any]: 已创建的后台任务。 + """ + task = asyncio.create_task(coroutine, name=name) + self._background_tasks.add(task) + task.add_done_callback(self._handle_background_task_completion) + return task + + def _handle_background_task_completion(self, task: asyncio.Task[Any]) -> None: + """处理后台任务结束后的清理与异常记录。 + + Args: + task: 已结束的后台任务。 + """ + self._background_tasks.discard(task) + if task.cancelled(): + return + + exception = task.exception() + if exception is not None: + self._logger.error(f"NapCat 适配器后台任务异常: {exception}", exc_info=True) + + async def _cancel_background_tasks(self) -> None: + """取消所有仍在运行的后台任务。""" + background_tasks = list(self._background_tasks) + for task in background_tasks: + task.cancel() + if background_tasks: + with contextlib.suppress(Exception): + await asyncio.gather(*background_tasks, return_exceptions=True) + self._background_tasks.clear() + + async def _notify_connection_opened(self) -> None: + """在连接建立后触发上层回调。""" + if self._connection_active: + return + + self._connection_active = True + try: + await self._on_connection_opened() + except Exception as exc: + self._logger.warning(f"NapCat 适配器连接建立回调失败: {exc}") + + async def _notify_connection_closed(self) -> None: + """在连接断开后触发上层回调。""" + if not self._connection_active: + return + + self._connection_active = False + try: + await self._on_connection_closed() + except Exception as exc: + self._logger.warning(f"NapCat 适配器断连回调失败: {exc}") + + def _resolve_pending_action(self, echo_id: str, payload: Dict[str, Any]) -> None: + """解析等待中的动作响应。 + + Args: + echo_id: 动作请求对应的 echo 标识。 + payload: NapCat 返回的响应载荷。 + """ + response_future = self._pending_actions.get(echo_id) + if response_future is None or response_future.done(): + return + response_future.set_result(payload) + + def _fail_pending_actions(self, error_message: str) -> None: + """让所有等待中的动作以异常方式结束。 + + Args: + error_message: 写入异常中的错误信息。 + """ + for response_future in self._pending_actions.values(): + if not response_future.done(): + response_future.set_exception(RuntimeError(error_message)) + self._pending_actions.clear() + + def _build_headers(self, server_config: NapCatServerConfig) -> Dict[str, str]: + """构造连接 NapCat 所需的请求头。 + + Args: + server_config: 当前生效的 NapCat 服务端配置。 + + Returns: + Dict[str, str]: WebSocket 握手请求头。 + """ + return {"Authorization": f"Bearer {server_config.token}"} if server_config.token else {} + + def _log_connection_attempt(self, ws_url: str, server_config: NapCatServerConfig) -> None: + """记录一次连接尝试的诊断信息。 + + Args: + ws_url: 即将连接的 WebSocket 地址。 + server_config: 当前生效的 NapCat 服务端配置。 + """ + auth_mode = "已配置 token" if server_config.token else "未配置 token" + self._logger.debug(f"NapCat 适配器开始连接: {ws_url}(鉴权: {auth_mode})") + + if not server_config.token and self._warned_missing_token_for_ws_url != ws_url: + self._logger.warning( + "NapCat 适配器当前未配置 napcat_server.token;" + "若 NapCat 开启了访问令牌校验,连接可能会被服务端立即断开" + ) + self._warned_missing_token_for_ws_url = ws_url + + def _log_connection_closed(self, ws_url: str, server_config: NapCatServerConfig, reason: str) -> None: + """记录连接结束与重连计划。 + + Args: + ws_url: 当前连接对应的 WebSocket 地址。 + server_config: 当前生效的 NapCat 服务端配置。 + reason: 当前连接结束原因。 + """ + self._logger.warning( + f"NapCat 适配器连接已断开: {ws_url},{reason}" + f"{self._build_missing_token_hint(server_config)}" + f"{self._build_reconnect_hint(server_config)}" + ) + + def _build_missing_token_hint(self, server_config: NapCatServerConfig) -> str: + """构造缺失 token 时的附加提示。 + + Args: + server_config: 当前生效的 NapCat 服务端配置。 + + Returns: + str: 缺失 token 时的提示文案;无需提示时返回空字符串。 + """ + if server_config.token: + return "" + return ";当前未配置 napcat_server.token,若服务端开启了访问令牌校验,请补全 token" + + def _build_reconnect_hint(self, server_config: NapCatServerConfig) -> str: + """构造连接结束后的重连提示。 + + Args: + server_config: 当前生效的 NapCat 服务端配置。 + + Returns: + str: 自动重连提示;当停止请求已发出时返回空字符串。 + """ + if self._stop_requested: + return "" + return f";将在 {server_config.reconnect_delay_sec:g} 秒后重连" + + def _describe_terminal_ws_message( + self, + ws: AiohttpClientWebSocketResponse, + ws_message: Any, + message_label: str, + ) -> str: + """描述导致连接结束的终止类 WebSocket 消息。 + + Args: + ws: 当前活跃的 WebSocket 连接对象。 + ws_message: aiohttp 返回的终止消息。 + message_label: 当前终止消息的人类可读标签。 + + Returns: + str: 汇总后的终止原因描述。 + """ + details = [] + close_code = getattr(ws, "close_code", None) + if close_code not in (None, 0): + details.append(f"close_code={close_code}") + + message_data = getattr(ws_message, "data", None) + if message_data not in (None, "", 0, close_code): + details.append(f"data={message_data}") + + message_extra = str(getattr(ws_message, "extra", "") or "").strip() + if message_extra: + details.append(f"extra={message_extra}") + + ws_exception = ws.exception() + if ws_exception is not None: + details.append(f"exception={ws_exception}") + + if not details: + return message_label + return f"{message_label}({', '.join(str(item) for item in details)})" + + def _parse_json_message(self, data: Any) -> Optional[Dict[str, Any]]: + """解析 WebSocket 文本消息中的 JSON 数据。 + + Args: + data: WebSocket 收到的原始文本数据。 + + Returns: + Optional[Dict[str, Any]]: 成功时返回字典,失败时返回 ``None``。 + """ + try: + payload = json.loads(str(data)) + except Exception as exc: + self._logger.warning(f"NapCat 适配器解析 JSON 载荷失败: {exc}") + return None + + return payload if isinstance(payload, dict) else None diff --git a/plugin-templates/MaiBot-Napcat-Adapter/types.py b/plugin-templates/MaiBot-Napcat-Adapter/types.py new file mode 100644 index 00000000..7faa797b --- /dev/null +++ b/plugin-templates/MaiBot-Napcat-Adapter/types.py @@ -0,0 +1,37 @@ +"""NapCat 适配器内部共享类型。""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, MutableMapping, Optional, TypeAlias + +from typing_extensions import NotRequired, TypedDict + + +class NapCatIncomingSegment(TypedDict): + """NapCat / OneBot 入站消息段结构。""" + + type: str + data: Mapping[str, Any] + + +class NapCatHostMessageSegment(TypedDict): + """适配器转换后写入 Host 的消息段结构。""" + + type: str + data: Any + hash: NotRequired[str] + binary_data_base64: NotRequired[str] + + +NapCatActionParams: TypeAlias = Mapping[str, Any] +NapCatActionParamsInput: TypeAlias = Optional[Mapping[str, Any]] +NapCatActionResponse: TypeAlias = Dict[str, Any] +NapCatIdInput: TypeAlias = int | str +NapCatMutablePayload: TypeAlias = MutableMapping[str, Any] +NapCatOptionalIdInput: TypeAlias = int | str | None +NapCatPayload: TypeAlias = Mapping[str, Any] +NapCatPayloadDict: TypeAlias = Dict[str, Any] +NapCatPayloadList: TypeAlias = List[Dict[str, Any]] +NapCatIncomingSegments: TypeAlias = List[NapCatIncomingSegment] +NapCatSegment: TypeAlias = NapCatHostMessageSegment +NapCatSegments: TypeAlias = List[NapCatHostMessageSegment] diff --git a/plugins/__init__.py b/plugins/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/plugins/hello_world_plugin/_manifest.json b/plugins/hello_world_plugin/_manifest.json deleted file mode 100644 index e2bc694d..00000000 --- a/plugins/hello_world_plugin/_manifest.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "manifest_version": 2, - "version": "2.0.0", - "name": "Hello World 示例插件 (Hello World Plugin)", - "description": "我的第一个 MaiCore 插件,包含问候功能和时间查询等基础示例", - "author": { - "name": "MaiBot开发团队", - "url": "https://github.com/MaiM-with-u" - }, - "license": "GPL-v3.0-or-later", - "urls": { - "repository": "https://github.com/MaiM-with-u/maibot", - "homepage": "https://github.com/MaiM-with-u/maibot", - "documentation": "https://github.com/MaiM-with-u/maibot", - "issues": "https://github.com/MaiM-with-u/maibot/issues" - }, - "host_application": { - "min_version": "1.0.0", - "max_version": "1.0.0" - }, - "sdk": { - "min_version": "2.0.0", - "max_version": "2.99.99" - }, - "dependencies": [], - "capabilities": [ - "send.text", - "send.forward", - "send.hybrid", - "emoji.get_random", - "config.get" - ], - "i18n": { - "default_locale": "zh-CN", - "locales_path": "_locales", - "supported_locales": [ - "zh-CN" - ] - }, - "id": "maibot-team.hello-world-plugin" -} diff --git a/plugins/hello_world_plugin/plugin.py b/plugins/hello_world_plugin/plugin.py deleted file mode 100644 index 3e58cb0d..00000000 --- a/plugins/hello_world_plugin/plugin.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Hello World 示例插件 — 新 SDK 版本 - -你的第一个 MaiCore 插件,包含问候功能、时间查询等基础示例。 -""" - -from datetime import datetime -from typing import Any - -import random - -from maibot_sdk import Action, Command, EventHandler, Field, MaiBotPlugin, PluginConfigBase, Tool -from maibot_sdk.types import ActivationType, EventType, ToolParameterInfo, ToolParamType - - -class PluginSectionConfig(PluginConfigBase): - """插件基础配置。""" - - __ui_label__ = "插件" - __ui_icon__ = "package" - __ui_order__ = 0 - - enabled: bool = Field(default=False, description="是否启用插件") - config_version: str = Field(default="2.0.0", description="配置版本") - - -class GreetingConfig(PluginConfigBase): - """问候配置。""" - - __ui_label__ = "问候" - __ui_icon__ = "message-circle" - __ui_order__ = 1 - - message: str = Field(default="嗨!很开心见到你!😊", description="默认问候消息") - - -class TimeConfig(PluginConfigBase): - """时间查询配置。""" - - __ui_label__ = "时间" - __ui_icon__ = "clock" - __ui_order__ = 2 - - format: str = Field(default="%Y-%m-%d %H:%M:%S", description="时间显示格式") - - -class PrintMessageConfig(PluginConfigBase): - """消息打印配置。""" - - __ui_label__ = "消息打印" - __ui_icon__ = "terminal" - __ui_order__ = 3 - - enabled: bool = Field(default=False, description="是否打印接收到的消息") - - -class HelloWorldPluginConfig(PluginConfigBase): - """Hello World 示例插件配置。""" - - plugin: PluginSectionConfig = Field(default_factory=PluginSectionConfig) - greeting: GreetingConfig = Field(default_factory=GreetingConfig) - time: TimeConfig = Field(default_factory=TimeConfig) - print_message: PrintMessageConfig = Field(default_factory=PrintMessageConfig) - - -class HelloWorldPlugin(MaiBotPlugin): - """Hello World 示例插件""" - - config_model = HelloWorldPluginConfig - - async def on_load(self) -> None: - """处理插件加载。""" - - async def on_unload(self) -> None: - """处理插件卸载。""" - - # ===== Tool 组件 ===== - - @Tool( - "compare_numbers", - description="使用工具比较两个数的大小,返回较大的数", - parameters=[ - ToolParameterInfo(name="num1", param_type=ToolParamType.FLOAT, description="第一个数字", required=True), - ToolParameterInfo(name="num2", param_type=ToolParamType.FLOAT, description="第二个数字", required=True), - ], - ) - async def handle_compare_numbers(self, num1: float = 0, num2: float = 0, **kwargs): - """比较两个数的大小""" - try: - if num1 > num2: - result = f"{num1} 大于 {num2}" - elif num1 < num2: - result = f"{num1} 小于 {num2}" - else: - result = f"{num1} 等于 {num2}" - return {"name": "compare_numbers", "content": result} - except Exception as e: - return {"name": "compare_numbers", "content": f"比较数字失败,炸了: {e}"} - - # ===== Action 组件 ===== - - @Action( - "hello_greeting", - description="向用户发送问候消息", - activation_type=ActivationType.ALWAYS, - action_parameters={"greeting_message": "要发送的问候消息"}, - action_require=["需要发送友好问候时使用", "当有人向你问好时使用", "当你遇见没有见过的人时使用"], - associated_types=["text"], - ) - async def handle_hello(self, stream_id: str = "", greeting_message: str = "", **kwargs): - """问候动作""" - del kwargs - - base_message = self.config.greeting.message - message = base_message + greeting_message - await self.ctx.send.text(message, stream_id) - return True, "发送了问候消息" - - @Action( - "bye_greeting", - description="向用户发送告别消息", - activation_type=ActivationType.KEYWORD, - activation_keywords=["再见", "bye", "88", "拜拜"], - action_parameters={"bye_message": "要发送的告别消息"}, - action_require=["用户要告别时使用", "当有人要离开时使用", "当有人和你说再见时使用"], - associated_types=["text"], - ) - async def handle_bye(self, stream_id: str = "", bye_message: str = "", **kwargs): - """告别动作""" - del kwargs - - message = f"再见!期待下次聊天!👋{bye_message}" - await self.ctx.send.text(message, stream_id) - return True, "发送了告别消息" - - # ===== Command 组件 ===== - - @Command("time", description="查询当前时间", pattern=r"^/time$") - async def handle_time(self, stream_id: str = "", **kwargs): - """时间查询命令""" - del kwargs - - time_format = self.config.time.format - now = datetime.now() - time_str = now.strftime(time_format) - await self.ctx.send.text(f"⏰ 当前时间:{time_str}", stream_id) - return True, f"显示了当前时间: {time_str}", True - - @Command("random_emojis", description="发送多张随机表情包", pattern=r"^/random_emojis$") - async def handle_random_emojis(self, stream_id: str = "", **kwargs): - """发送多张随机表情包""" - del kwargs - - emojis = await self.ctx.emoji.get_random(5) - if not emojis: - return False, "未找到表情包", False - # 用转发消息发送多张图片 - messages = [ - {"user_id": "0", "nickname": "神秘用户", "segments": [{"type": "image", "content": e.get("base64", "")}]} - for e in emojis - ] - await self.ctx.send.forward(messages, stream_id) - return True, "已发送随机表情包", True - - @Command("test", description="测试命令", pattern=r"^/test$") - async def handle_test(self, stream_id: str = "", **kwargs): - """测试命令 — 发送简单测试消息""" - del kwargs - - await self.ctx.send.text("测试正常!Bot 功能运行中 ✅", stream_id) - return True, "测试完成", True - - # ===== EventHandler 组件 ===== - - @EventHandler("print_message_handler", description="打印接收到的消息", event_type=EventType.ON_MESSAGE) - async def handle_print_message(self, message: Any = None, **kwargs: Any): - """打印消息事件""" - del kwargs - - if self.config.print_message.enabled and message: - raw = message.get("raw_message", "") if isinstance(message, dict) else str(message) - print(f"接收到消息: {raw}") - return True, True, "消息已打印", None, None - - @EventHandler( - "forward_messages_handler", description="把接收到的消息转发到指定聊天ID", event_type=EventType.ON_MESSAGE - ) - async def handle_forward_messages(self, message: Any = None, stream_id: str = "", **kwargs: Any): - """收集消息并定期转发""" - del kwargs - - if not message: - return True, True, None, None, None - plain_text = message.get("plain_text", "") if isinstance(message, dict) else "" - if not plain_text: - return True, True, None, None, None - - # 使用插件级状态收集消息 - if not hasattr(self, "_fwd_messages"): - self._fwd_messages: list[str] = [] - self._fwd_counter: int = 0 - - self._fwd_messages.append(plain_text) - self._fwd_counter += 1 - - if self._fwd_counter % 10 == 0 and stream_id: - if random.random() < 0.01: - segments = [{"type": "text", "content": msg} for msg in self._fwd_messages] - await self.ctx.send.hybrid(segments, stream_id) - else: - messages = [ - {"user_id": "0", "nickname": "转发", "segments": [{"type": "text", "content": msg}]} - for msg in self._fwd_messages - ] - await self.ctx.send.forward(messages, stream_id) - self._fwd_messages = [] - - return True, True, None, None, None - - async def on_config_update(self, scope: str, config_data: dict[str, object], version: str) -> None: - """处理配置热重载事件。 - - Args: - scope: 配置变更范围。 - config_data: 最新配置数据。 - version: 配置版本号。 - """ - - del scope - del config_data - del version - - -def create_plugin() -> HelloWorldPlugin: - """创建 Hello World 示例插件实例。 - - Returns: - HelloWorldPlugin: 新的示例插件实例。 - """ - - return HelloWorldPlugin() diff --git a/pytests/A_memorix_test/data/benchmarks/README.md b/pytests/A_memorix_test/data/benchmarks/README.md deleted file mode 100644 index 1ee363f9..00000000 --- a/pytests/A_memorix_test/data/benchmarks/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# Group Chat Stream Benchmark - -这套基准数据专门用于 A_memorix 当前“群聊聊天流”设计的量化评估。 - -设计对齐点: - -- 只把 Bot 参与过的话题段落纳入长期记忆总结。 -- 群聊内容先按话题批次收束,再写入 `chat_summary`。 -- 回复前通过 `search_long_term_memory` 做长期记忆检索增强。 -- 回复后只把“关于人物的稳定事实”写回 `person_fact`。 -- 检索需要覆盖 `search / time / episode / aggregate` 四种模式。 -- 需要有明确的负样本,验证“无 Bot 参与”的纯群友闲聊不会被误写入。 -- 当前 summarizer 的原生触发条件需要被显式覆盖: - `80` 条消息直接触发,或 `8` 小时后累计至少 `20` 条消息触发。 - -数据文件: - -- `group_chat_stream_memory_benchmark.json` -- `group_chat_stream_memory_benchmark_hard.json` - 第二套更长、更刁钻的压力数据,刻意加入跨话题重叠词、自然句 episode query、 - 以及更容易淹没人物事实的长聊天流,用于验证修复是否具有泛化效果。 - -推荐量化指标: - -- `search.accuracy_at_1` -- `search.recall_at_5` -- `search.keyword_recall_at_5` -- `knowledge_fetcher.success_rate` -- `profile.success_rate` -- `writeback.success_rate` -- `episode_generation.success_rate` -- `negative_control.zero_hit_rate` - -当前 fixture 结构: - -- `simulated_stream_batches` - 用于模拟话题级聊天窗口,适合检索、episode、画像、写回等离线量化评估。 -- `runtime_trigger_streams` - 用于模拟真正能触发当前 summarizer 阈值的原生聊天流。 - 这部分数据满足 `20 条 + 8 小时` 的时间触发条件,可直接用于验证 - “是否进入话题检查”与“无 Bot 发言是否被丢弃”。 -- `chat_history_records` - 用于模拟宿主将群聊话题总结后写入长期记忆的主路径。 -- `person_writebacks` - 用于模拟发送回复后的稳定人物事实写回。 -- `search_cases / time_cases / episode_cases / knowledge_fetcher_cases / profile_cases` - 用于直接驱动量化检索评估。 -- `negative_control_cases` - 用于验证“无 Bot 发言的群聊片段应被忽略”。 - -覆盖主题: - -- 值班柜第二层的备用物资与物资报备 -- 停电夜投影仪抢救与应急灯 / 橙色延长线盘 -- 风铃观测前的温湿度计校准与无糖姜茶 -- 东侧窗边狸花猫、绿色硬壳笔记本与黄铜回形针 -- 无 Bot 参与的零食闲聊负样本 - -使用建议: - -- 如果要验证“当前 summarizer 是否真的会被触发”,优先喂 `runtime_trigger_streams`。 -- 如果要验证“当前实现是否真正符合总结后写入和检索设计”,优先喂 `simulated_stream_batches` 与 `chat_history_records`。 -- 如果要快速跑检索、画像、episode、写回指标,直接使用 `chat_history_records + person_writebacks + cases`。 -- 如果要切换到第二套压力数据,可在运行 benchmark 前设置 - `A_MEMORIX_BENCHMARK_DATA_FILE=pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark_hard.json`。 diff --git a/pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark.json b/pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark.json deleted file mode 100644 index a7f4d15f..00000000 --- a/pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark.json +++ /dev/null @@ -1,728 +0,0 @@ -{ - "meta": { - "scenario_id": "group_chat_stream_memory_benchmark", - "description": "面向群聊聊天流的长期记忆量化评估数据集,覆盖 Bot 参与门槛、话题收束、检索增强、人物事实写回与负样本忽略。", - "designed_for": [ - "group_chat", - "topic_batching", - "bot_participation_gate", - "runtime_trigger", - "search_mode", - "time_mode", - "episode_mode", - "aggregate_mode", - "person_fact_writeback", - "negative_control" - ], - "quantitative_targets": { - "search": { - "accuracy_at_1": 0.4, - "recall_at_5": 0.75, - "keyword_recall_at_5": 0.85 - }, - "knowledge_fetcher": { - "success_rate": 0.75, - "keyword_recall": 0.8 - }, - "profile": { - "success_rate": 0.67, - "evidence_rate": 1.0 - }, - "writeback": { - "success_rate": 0.67, - "keyword_recall": 0.8 - }, - "episode": { - "success_rate": 0.75, - "keyword_recall": 0.8 - }, - "negative_control": { - "zero_hit_rate": 1.0 - }, - "runtime_trigger": { - "positive_trigger_rate": 1.0, - "negative_discard_rate": 1.0 - } - } - }, - "session": { - "session_id": "qq_group_424242", - "platform": "qq", - "user_id": "10086", - "group_id": "424242", - "display_name": "松烟阁夜谈群" - }, - "simulated_stream_batches": [ - { - "batch_id": "supply_round_march_03", - "topic": "值班柜第二层物资补位", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "林澈", - "周枝", - "秦昭", - "唐未" - ], - "start_time": 1772526180.0, - "end_time": 1772526960.0, - "messages": [ - "[2026-03-03 19:03] 林澈:空调又直吹值班桌,我先把风扇拨回二档。值班柜第二层是不是只剩一把备用钥匙了?我记得黑柄折叠伞和创可贴本来都在那层。", - "[2026-03-03 19:04] 周枝:昨天雨太大,我把黑柄折叠伞借给来送样本的人了,回来的时候顺手挂回柜里,但创可贴我没核对。", - "[2026-03-03 19:05] 秦昭:柜门里侧还夹着旧清单,最底下一行写着“备用钥匙、折叠伞、创可贴、充电头”,不过充电头早就改放一层了。", - "[2026-03-03 19:06] Mai:那我记一下,第二层以后固定只放备用钥匙、黑柄折叠伞和创可贴,谁临时拿走就在群里报备,不然下次值夜的人会抓瞎。", - "[2026-03-03 19:08] 唐未:我刚看了一眼,创可贴只剩两片,外包装已经翘边了,最好连同备用纱布一起补一盒新的。", - "[2026-03-03 19:10] 林澈:我晚上值夜的时候最怕找东西翻半天,尤其空调风一吹头就疼,所以物资位置还是固定最省心。", - "[2026-03-03 19:11] 周枝:我待会儿把创可贴和纱布一起补上,再把备用钥匙套个红绳,不然黑灯找起来太慢。", - "[2026-03-03 19:12] 秦昭:折叠伞我建议继续放第二层最左边,别再和记录本挤一起,上回纸边都被伞骨刮卷了。", - "[2026-03-03 19:14] Mai:行,我把“第二层左侧放折叠伞,中间放备用钥匙,右边放创可贴”记到今晚值班备注里。", - "[2026-03-03 19:15] 唐未:顺便提醒一下,如果谁把第二层的物资借出去,至少在群里发一句“已借出+归还时间”,别只口头说。", - "[2026-03-03 19:16] 周枝:收到,我今晚补完会拍一张柜内照片,省得之后又靠记忆猜。", - "[2026-03-03 19:16] 林澈:谢谢,等柜里补齐了我就把桂花乌龙和个人杯子继续放回上层,省得和公用物资混在一起。" - ], - "combined_text": "[2026-03-03 19:03] 林澈:空调又直吹值班桌,我先把风扇拨回二档。值班柜第二层是不是只剩一把备用钥匙了?我记得黑柄折叠伞和创可贴本来都在那层。\n[2026-03-03 19:04] 周枝:昨天雨太大,我把黑柄折叠伞借给来送样本的人了,回来的时候顺手挂回柜里,但创可贴我没核对。\n[2026-03-03 19:05] 秦昭:柜门里侧还夹着旧清单,最底下一行写着“备用钥匙、折叠伞、创可贴、充电头”,不过充电头早就改放一层了。\n[2026-03-03 19:06] Mai:那我记一下,第二层以后固定只放备用钥匙、黑柄折叠伞和创可贴,谁临时拿走就在群里报备,不然下次值夜的人会抓瞎。\n[2026-03-03 19:08] 唐未:我刚看了一眼,创可贴只剩两片,外包装已经翘边了,最好连同备用纱布一起补一盒新的。\n[2026-03-03 19:10] 林澈:我晚上值夜的时候最怕找东西翻半天,尤其空调风一吹头就疼,所以物资位置还是固定最省心。\n[2026-03-03 19:11] 周枝:我待会儿把创可贴和纱布一起补上,再把备用钥匙套个红绳,不然黑灯找起来太慢。\n[2026-03-03 19:12] 秦昭:折叠伞我建议继续放第二层最左边,别再和记录本挤一起,上回纸边都被伞骨刮卷了。\n[2026-03-03 19:14] Mai:行,我把“第二层左侧放折叠伞,中间放备用钥匙,右边放创可贴”记到今晚值班备注里。\n[2026-03-03 19:15] 唐未:顺便提醒一下,如果谁把第二层的物资借出去,至少在群里发一句“已借出+归还时间”,别只口头说。\n[2026-03-03 19:16] 周枝:收到,我今晚补完会拍一张柜内照片,省得之后又靠记忆猜。\n[2026-03-03 19:16] 林澈:谢谢,等柜里补齐了我就把桂花乌龙和个人杯子继续放回上层,省得和公用物资混在一起。", - "expected_memory_targets": [ - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴", - "报备" - ] - }, - { - "batch_id": "blackout_projection_march_06", - "topic": "停电夜投影仪抢救", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 11, - "participants": [ - "Mai", - "秦昭", - "唐未", - "林澈", - "周枝" - ], - "start_time": 1772791980.0, - "end_time": 1772792820.0, - "messages": [ - "[2026-03-06 21:33] 秦昭:北墙射灯刚关,投影仪才开到一半就跳闸了,整间资料室一下子全黑,镜头还没盖上。", - "[2026-03-06 21:34] 唐未:别先碰镜头,我去摸仪器桌下层的应急灯,上次检修后应该还塞在最里边。", - "[2026-03-06 21:35] 林澈:我在门边摸到橙色延长线盘了,先别急着全拉开,确认是不是排插过载再说。", - "[2026-03-06 21:36] Mai:先按顺序来,唐未开应急灯,秦昭别挪投影仪,林澈看一下是不是延长线和热风枪同时挂在同一路上。", - "[2026-03-06 21:38] 唐未:应急灯找到了,在仪器桌下层右手边的蓝色档案盒后面,亮度够,至少能先护住镜头和电源键。", - "[2026-03-06 21:39] 秦昭:问题找到了,热风枪和投影仪都接在橙色延长线盘上,刚才我又把扫描灯也插进去了,估计就是这个组合把闸打掉了。", - "[2026-03-06 21:40] 周枝:我把热风枪先拔掉,扫描灯改到东墙独立插口,橙色延长线盘只留投影仪和笔记本电源。", - "[2026-03-06 21:41] Mai:好,先恢复最少设备,镜头盖等重新上电稳定后再扣,别在黑里来回碰。", - "[2026-03-06 21:43] 林澈:现在电回来了,投影仪风扇声正常,橙色延长线盘没有再冒热,看来就是同路负载堆太多。", - "[2026-03-06 21:44] 秦昭:记一下,应急灯平时别乱挪,必须固定放在仪器桌下层,橙色延长线盘也只给投影相关设备用。", - "[2026-03-06 21:47] Mai:我已经记到故障备忘里了:停电夜先找仪器桌下层应急灯,再检查橙色延长线盘负载,不要让热风枪和投影仪挂同一路。" - ], - "combined_text": "[2026-03-06 21:33] 秦昭:北墙射灯刚关,投影仪才开到一半就跳闸了,整间资料室一下子全黑,镜头还没盖上。\n[2026-03-06 21:34] 唐未:别先碰镜头,我去摸仪器桌下层的应急灯,上次检修后应该还塞在最里边。\n[2026-03-06 21:35] 林澈:我在门边摸到橙色延长线盘了,先别急着全拉开,确认是不是排插过载再说。\n[2026-03-06 21:36] Mai:先按顺序来,唐未开应急灯,秦昭别挪投影仪,林澈看一下是不是延长线和热风枪同时挂在同一路上。\n[2026-03-06 21:38] 唐未:应急灯找到了,在仪器桌下层右手边的蓝色档案盒后面,亮度够,至少能先护住镜头和电源键。\n[2026-03-06 21:39] 秦昭:问题找到了,热风枪和投影仪都接在橙色延长线盘上,刚才我又把扫描灯也插进去了,估计就是这个组合把闸打掉了。\n[2026-03-06 21:40] 周枝:我把热风枪先拔掉,扫描灯改到东墙独立插口,橙色延长线盘只留投影仪和笔记本电源。\n[2026-03-06 21:41] Mai:好,先恢复最少设备,镜头盖等重新上电稳定后再扣,别在黑里来回碰。\n[2026-03-06 21:43] 林澈:现在电回来了,投影仪风扇声正常,橙色延长线盘没有再冒热,看来就是同路负载堆太多。\n[2026-03-06 21:44] 秦昭:记一下,应急灯平时别乱挪,必须固定放在仪器桌下层,橙色延长线盘也只给投影相关设备用。\n[2026-03-06 21:47] Mai:我已经记到故障备忘里了:停电夜先找仪器桌下层应急灯,再检查橙色延长线盘负载,不要让热风枪和投影仪挂同一路。", - "expected_memory_targets": [ - "停电夜", - "投影仪", - "应急灯", - "橙色延长线盘", - "仪器桌下层" - ] - }, - { - "batch_id": "wind_bell_observation_march_10", - "topic": "风铃观测与姜茶准备", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "唐未", - "林澈", - "周枝", - "许棠" - ], - "start_time": 1773136020.0, - "end_time": 1773136980.0, - "messages": [ - "[2026-03-10 20:27] 唐未:周六夜里要做风铃塔观测,我下午先去校准温湿度计,镜片也顺便擦一遍,不然露点记录会飘。", - "[2026-03-10 20:28] 林澈:我可以把南平台的小风扇带上去,但别让我坐空调口边上值守,上次吹得我偏头痛一整晚。", - "[2026-03-10 20:30] 周枝:观测箱里还剩两包姜片,我去值班室再补一盒无糖姜茶,甜的那种唐未不喝。", - "[2026-03-10 20:31] 许棠:屋顶东侧栏杆边那只风铃这两天响得特别密,最好把记录本提前夹在硬板夹上,别到时一手按纸一手扶灯。", - "[2026-03-10 20:32] Mai:那就按这个分工:唐未负责温湿度计和镜片,周枝准备无糖姜茶和记录夹,林澈盯现场风向和风扇位置。", - "[2026-03-10 20:35] 唐未:我会把校准后的时间写在第一页右上角,免得后面整理的时候又分不清哪组数据是校准前的。", - "[2026-03-10 20:37] 林澈:如果夜里温差太大,我会把风扇固定在二档,既能带走雾气,又不至于把纸吹跑。", - "[2026-03-10 20:39] 周枝:姜茶我放保温壶里,标签写“无糖”,免得有人顺手加糖包,唐未每次都喝不下去。", - "[2026-03-10 20:41] 许棠:记录本别用软封皮,上回屋顶起风,边角被栏杆刮得卷起来,还是硬板夹最稳。", - "[2026-03-10 20:43] Mai:再补一条,风铃塔观测前先在群里报“温湿度计已校准”,这样后面谁接手都知道状态。", - "[2026-03-10 20:45] 唐未:收到,我会先发校准完成,再把镜片状态和气温一起报出来。", - "[2026-03-10 20:46] 林澈:那我带桂花乌龙给自己,公用保温壶就只放无糖姜茶,别把味道混了。" - ], - "combined_text": "[2026-03-10 20:27] 唐未:周六夜里要做风铃塔观测,我下午先去校准温湿度计,镜片也顺便擦一遍,不然露点记录会飘。\n[2026-03-10 20:28] 林澈:我可以把南平台的小风扇带上去,但别让我坐空调口边上值守,上次吹得我偏头痛一整晚。\n[2026-03-10 20:30] 周枝:观测箱里还剩两包姜片,我去值班室再补一盒无糖姜茶,甜的那种唐未不喝。\n[2026-03-10 20:31] 许棠:屋顶东侧栏杆边那只风铃这两天响得特别密,最好把记录本提前夹在硬板夹上,别到时一手按纸一手扶灯。\n[2026-03-10 20:32] Mai:那就按这个分工:唐未负责温湿度计和镜片,周枝准备无糖姜茶和记录夹,林澈盯现场风向和风扇位置。\n[2026-03-10 20:35] 唐未:我会把校准后的时间写在第一页右上角,免得后面整理的时候又分不清哪组数据是校准前的。\n[2026-03-10 20:37] 林澈:如果夜里温差太大,我会把风扇固定在二档,既能带走雾气,又不至于把纸吹跑。\n[2026-03-10 20:39] 周枝:姜茶我放保温壶里,标签写“无糖”,免得有人顺手加糖包,唐未每次都喝不下去。\n[2026-03-10 20:41] 许棠:记录本别用软封皮,上回屋顶起风,边角被栏杆刮得卷起来,还是硬板夹最稳。\n[2026-03-10 20:43] Mai:再补一条,风铃塔观测前先在群里报“温湿度计已校准”,这样后面谁接手都知道状态。\n[2026-03-10 20:45] 唐未:收到,我会先发校准完成,再把镜片状态和气温一起报出来。\n[2026-03-10 20:46] 林澈:那我带桂花乌龙给自己,公用保温壶就只放无糖姜茶,别把味道混了。", - "expected_memory_targets": [ - "风铃塔观测", - "温湿度计", - "无糖姜茶", - "硬板夹", - "屋顶东侧栏杆" - ] - }, - { - "batch_id": "archive_cat_march_14", - "topic": "东侧窗边狸花猫与绿色笔记本", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 11, - "participants": [ - "Mai", - "许棠", - "周枝", - "秦昭", - "林澈" - ], - "start_time": 1773478440.0, - "end_time": 1773479280.0, - "messages": [ - "[2026-03-14 19:34] 许棠:东侧窗边那只狸花猫又钻进档案室了,刚才直接跳上长桌,把绿色硬壳笔记本踩得翻到中间页。", - "[2026-03-14 19:35] 周枝:长桌上不是还夹着黄铜回形针和旧雨量页吗?猫要是蹭一下,纸页顺着窗缝就容易飞。", - "[2026-03-14 19:36] 秦昭:我刚把窗缝先关小了,绿色硬壳笔记本已经挪到蓝色档案盒上面,黄铜回形针我也收回铁盘里了。", - "[2026-03-14 19:38] Mai:先别赶猫,确认一下它是不是又往暖气后面钻。长桌这边只保留绿色笔记本和今晚要抄的旧雨量页,其他散物都收走。", - "[2026-03-14 19:40] 林澈:狸花猫刚从暖气后面出来,蹭了一圈又去窗边晒了,至少没再踩记录纸。我顺手把长桌右侧的纸镇也压上了。", - "[2026-03-14 19:41] 许棠:绿色笔记本第七页记的是去年秋天的风速补注,别被猫爪勾破了,那页我明天还要录系统。", - "[2026-03-14 19:43] 周枝:以后东侧窗边长桌如果要摊资料,先把黄铜回形针和纸镇放好,不然有风再加猫,纸真的收不回来。", - "[2026-03-14 19:45] 秦昭:蓝色档案盒我也顺手换到桌角了,避免猫跳上来时把整盒推下去。", - "[2026-03-14 19:46] Mai:我记个规则:东侧窗边长桌只放绿色硬壳笔记本、当次要抄的纸页和纸镇;黄铜回形针统一回铁盘。", - "[2026-03-14 19:48] 林澈:那只狸花猫现在缩在窗台垫子上了,看样子只是找暖和,不是故意拆台。", - "[2026-03-14 19:48] 许棠:收到,明天我会先去看第七页,再决定要不要把整本绿色笔记本转移到里间。" - ], - "combined_text": "[2026-03-14 19:34] 许棠:东侧窗边那只狸花猫又钻进档案室了,刚才直接跳上长桌,把绿色硬壳笔记本踩得翻到中间页。\n[2026-03-14 19:35] 周枝:长桌上不是还夹着黄铜回形针和旧雨量页吗?猫要是蹭一下,纸页顺着窗缝就容易飞。\n[2026-03-14 19:36] 秦昭:我刚把窗缝先关小了,绿色硬壳笔记本已经挪到蓝色档案盒上面,黄铜回形针我也收回铁盘里了。\n[2026-03-14 19:38] Mai:先别赶猫,确认一下它是不是又往暖气后面钻。长桌这边只保留绿色笔记本和今晚要抄的旧雨量页,其他散物都收走。\n[2026-03-14 19:40] 林澈:狸花猫刚从暖气后面出来,蹭了一圈又去窗边晒了,至少没再踩记录纸。我顺手把长桌右侧的纸镇也压上了。\n[2026-03-14 19:41] 许棠:绿色笔记本第七页记的是去年秋天的风速补注,别被猫爪勾破了,那页我明天还要录系统。\n[2026-03-14 19:43] 周枝:以后东侧窗边长桌如果要摊资料,先把黄铜回形针和纸镇放好,不然有风再加猫,纸真的收不回来。\n[2026-03-14 19:45] 秦昭:蓝色档案盒我也顺手换到桌角了,避免猫跳上来时把整盒推下去。\n[2026-03-14 19:46] Mai:我记个规则:东侧窗边长桌只放绿色硬壳笔记本、当次要抄的纸页和纸镇;黄铜回形针统一回铁盘。\n[2026-03-14 19:48] 林澈:那只狸花猫现在缩在窗台垫子上了,看样子只是找暖和,不是故意拆台。\n[2026-03-14 19:48] 许棠:收到,明天我会先去看第七页,再决定要不要把整本绿色笔记本转移到里间。", - "expected_memory_targets": [ - "东侧窗边", - "狸花猫", - "绿色硬壳笔记本", - "黄铜回形针", - "蓝色档案盒" - ] - }, - { - "batch_id": "snack_gossip_march_15_negative", - "topic": "无 Bot 参与的零食闲聊负样本", - "bot_participated": false, - "expected_behavior": "ignored_by_summarizer_without_bot_message", - "message_count": 8, - "participants": [ - "许棠", - "周枝", - "秦昭", - "林澈" - ], - "start_time": 1773565320.0, - "end_time": 1773566160.0, - "messages": [ - "[2026-03-15 19:42] 许棠:我准备给周末值班买海盐柠檬饼干,你们有人忌口吗?", - "[2026-03-15 19:43] 周枝:我不吃太甜的,但海盐的可以,顺便来点原味苏打更稳。", - "[2026-03-15 19:45] 秦昭:我想要辣味海苔片,饼干别买太碎的,上次全压成粉了。", - "[2026-03-15 19:46] 林澈:如果有无糖薄荷糖也帮我带一盒,值夜后半段嘴里太淡容易犯困。", - "[2026-03-15 19:48] 许棠:那我就下单海盐柠檬饼干、原味苏打、辣味海苔和无糖薄荷糖。", - "[2026-03-15 19:50] 周枝:别忘了备注送到北门值班室,不然又会被前台放到快递架最里面。", - "[2026-03-15 19:54] 秦昭:海苔别买大片装,碎屑掉在键盘里太难清。", - "[2026-03-15 19:56] 林澈:收到,等到了我去北门拿。" - ], - "combined_text": "[2026-03-15 19:42] 许棠:我准备给周末值班买海盐柠檬饼干,你们有人忌口吗?\n[2026-03-15 19:43] 周枝:我不吃太甜的,但海盐的可以,顺便来点原味苏打更稳。\n[2026-03-15 19:45] 秦昭:我想要辣味海苔片,饼干别买太碎的,上次全压成粉了。\n[2026-03-15 19:46] 林澈:如果有无糖薄荷糖也帮我带一盒,值夜后半段嘴里太淡容易犯困。\n[2026-03-15 19:48] 许棠:那我就下单海盐柠檬饼干、原味苏打、辣味海苔和无糖薄荷糖。\n[2026-03-15 19:50] 周枝:别忘了备注送到北门值班室,不然又会被前台放到快递架最里面。\n[2026-03-15 19:54] 秦昭:海苔别买大片装,碎屑掉在键盘里太难清。\n[2026-03-15 19:56] 林澈:收到,等到了我去北门拿。", - "expected_memory_targets": [ - "海盐柠檬饼干", - "原味苏打", - "辣味海苔", - "无糖薄荷糖" - ] - } - ], - "runtime_trigger_streams": [ - { - "stream_id": "runtime_supply_trigger_march_18", - "topic": "值班柜第二层物资规则长流触发样本", - "trigger_mode": "time_threshold", - "elapsed_since_last_check_hours": 8.7, - "bot_participated": true, - "expected_check_outcome": "should_trigger_topic_check_and_pass_bot_gate", - "expected_next_stage": "topic_cache_should_update", - "message_count": 22, - "participants": [ - "Mai", - "林澈", - "周枝", - "秦昭", - "唐未" - ], - "start_time": 1773795720.0, - "end_time": 1773827160.0, - "messages": [ - "[2026-03-18 09:02] 林澈:我早上开柜门的时候又被空调风正面吹到,顺手看了一眼,值班柜第二层现在只有备用钥匙和半包创可贴了。", - "[2026-03-18 09:05] 周枝:黑柄折叠伞昨晚借给送样的人还没放回,我记得归还口头说的是今天中午前。", - "[2026-03-18 09:07] 秦昭:柜门里那张旧清单我还没撕,最下面还是写着备用钥匙、折叠伞、创可贴,不过字已经糊了。", - "[2026-03-18 09:09] 唐未:创可贴我刚翻过,只剩一条完整包装,纱布也只余两片,今天最好一起补齐。", - "[2026-03-18 09:12] Mai:先别各自乱挪,今天这轮就按第二层只放备用钥匙、黑柄折叠伞、创可贴和备用纱布来整理,借出统一在群里报备。", - "[2026-03-18 09:16] 林澈:收到,我主要担心晚上值夜的时候黑灯摸东西太慢,所以备用钥匙最好再挂个醒目的红绳。", - "[2026-03-18 09:24] 周枝:我中午前会带红绳过去,顺手把创可贴和纱布一起补上,再拍一张第二层的照片。", - "[2026-03-18 09:31] 秦昭:折叠伞还是建议固定在第二层最左边,钥匙放中间,创可贴和纱布靠右,别再和记录本混在一起。", - "[2026-03-18 10:02] 唐未:我去医务柜那边借到了一盒新创可贴和一包小纱布,午休回来就补进去。", - "[2026-03-18 10:28] Mai:补充一条,凡是从第二层拿走物资,都要发“已借出+用途+预计归还时间”,不要只在现场口头说。", - "[2026-03-18 11:14] 周枝:我先把备用钥匙套上红绳了,照片也拍了,不过黑柄折叠伞还在门口晾水,等干一点再放回柜里。", - "[2026-03-18 12:43] 林澈:我刚在值班桌后面找到那把黑柄折叠伞,伞骨没问题,就是伞套湿着,我先挂在柜侧透气。", - "[2026-03-18 13:26] 秦昭:旧清单我重新写了一份,第二层现在明确写成“左伞中钥匙右医用小物”,这样新来的人也看得懂。", - "[2026-03-18 14:18] 唐未:新的创可贴和纱布都补齐了,我另外塞了一卷医用胶带,但没和创可贴压在一起,怕拿的时候全带出来。", - "[2026-03-18 15:07] Mai:医用胶带别挤占创可贴的位置,优先保证备用钥匙、黑柄折叠伞、创可贴、纱布这四样一眼能看到。", - "[2026-03-18 15:52] 周枝:现在第二层左边是折叠伞,中间是带红绳的备用钥匙,右边是创可贴和纱布,拍照存档已经发群文件了。", - "[2026-03-18 16:21] 林澈:这样就清楚多了,我自己的桂花乌龙和杯子还是继续放上层,不和公用物资混放。", - "[2026-03-18 16:57] 秦昭:我把旧清单撕掉了,柜门里只留新版规则,写了借出后必须报备和归还时间。", - "[2026-03-18 17:08] 唐未:医用小物这边都齐了,晚上如果还有人借伞或拿创可贴,记得照新版格式报一下。", - "[2026-03-18 17:24] Mai:今晚值班备注我已经更新成固定摆放规则了,之后默认按这版执行,除非群里另行通知。", - "[2026-03-18 17:38] 周枝:我补一条,借出的东西最好二十四小时内归还,超过时间就在群里继续报状态。", - "[2026-03-18 17:46] 林澈:这个规则对夜班太友好了,至少以后不会一边被空调吹一边在柜里翻半天。" - ], - "expected_memory_targets": [ - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴", - "纱布", - "报备" - ] - }, - { - "stream_id": "runtime_snack_gossip_trigger_march_22_negative", - "topic": "无 Bot 参与的零食拼单长流负样本", - "trigger_mode": "time_threshold", - "elapsed_since_last_check_hours": 8.6, - "bot_participated": false, - "expected_check_outcome": "should_trigger_topic_check_but_be_discarded_without_bot_message", - "expected_next_stage": "topic_cache_should_not_update", - "message_count": 21, - "participants": [ - "许棠", - "周枝", - "秦昭", - "林澈" - ], - "start_time": 1774144860.0, - "end_time": 1774175700.0, - "messages": [ - "[2026-03-22 10:01] 许棠:周末值班太长了,我准备拼一单零食,先问下大家海盐柠檬饼干和原味苏打能不能接受。", - "[2026-03-22 10:12] 周枝:原味苏打可以,海盐柠檬也行,但别买太甜的夹心款,我夜里吃那个会腻。", - "[2026-03-22 10:20] 秦昭:我想要辣味海苔片,别选大片易掉渣的版本,上回碎屑全进键盘缝了。", - "[2026-03-22 10:33] 林澈:如果有无糖薄荷糖也帮我带一盒,后半夜嘴里太淡的时候容易犯困。", - "[2026-03-22 10:52] 许棠:那我先记海盐柠檬饼干、原味苏打、辣味海苔和无糖薄荷糖,坚果你们有人想加吗?", - "[2026-03-22 11:15] 周枝:坚果少一点吧,值班桌一忙起来很容易忘记封口,还是独立小包装更稳。", - "[2026-03-22 11:42] 秦昭:海苔一定别买油太大的,拿文件之前还得擦手太麻烦。", - "[2026-03-22 12:08] 林澈:如果可以的话顺便来两包原味小麻花,脆一点但别掉很多渣。", - "[2026-03-22 12:44] 许棠:行,我把小麻花也记上,不过总价快到免配送门槛了,再凑一点就够。", - "[2026-03-22 13:17] 周枝:可以补几包茶包,别太香精味重,清淡一点的红茶或者大麦茶都行。", - "[2026-03-22 13:58] 秦昭:辣条就别买了,油会蹭到键帽上,还是海苔和苏打这种最安全。", - "[2026-03-22 14:26] 林澈:如果店里有小盒装薄荷糖,比大袋装方便,值夜带着走也不会散一桌。", - "[2026-03-22 15:09] 许棠:配送时间我打算约到晚上六点半左右,正好你们晚饭后能一起收。", - "[2026-03-22 15:47] 周枝:每个人单独贴名字吧,不然海盐柠檬和原味苏打长得太像,容易拿混。", - "[2026-03-22 16:13] 秦昭:再加一包湿巾也行,吃完零食直接擦手,省得摸鼠标留油印。", - "[2026-03-22 16:42] 林澈:奶味太重的糖就不要了,我夜里喝水本来就少,吃那个更容易口干。", - "[2026-03-22 17:01] 许棠:配送地址我本来想填前台,后来还是觉得北门值班室最稳,省得被塞到最里面。", - "[2026-03-22 17:18] 周枝:对,直接送北门值班室,备注写清楚到门口电话联系,不然外卖员总找不到。", - "[2026-03-22 17:46] 秦昭:如果凑单还差一点,就补最普通的瓜子,别买调味太重的版本。", - "[2026-03-22 18:12] 林澈:到了之后我去门口拿,顺便把无糖薄荷糖先分出来,免得被当成普通糖。", - "[2026-03-22 18:35] 许棠:已经下单了,截图我发群里,内容就是海盐柠檬饼干、原味苏打、辣味海苔、无糖薄荷糖、小麻花和茶包。" - ], - "expected_memory_targets": [] - } - ], - "import_payload": { - "paragraphs": [ - { - "content": "东侧档案室窗边长桌通常用于整理当晚资料,绿色硬壳笔记本、黄铜回形针和纸镇经常在这里配套出现。", - "source": "fixture:group_chat_stream_memory_benchmark", - "knowledge_type": "narrative", - "entities": [ - "东侧档案室", - "长桌", - "绿色硬壳笔记本", - "黄铜回形针", - "纸镇" - ] - }, - { - "content": "停电或抢修投影设备时,仪器桌下层的应急灯和橙色延长线盘是最常被提到的两个关键物品。", - "source": "fixture:group_chat_stream_memory_benchmark", - "knowledge_type": "factual", - "entities": [ - "应急灯", - "橙色延长线盘", - "仪器桌下层", - "投影设备" - ] - }, - { - "content": "周枝平时看管值班柜第二层的备用钥匙、黑柄折叠伞和创可贴,借出时要求在群里报备。", - "source": "fixture:group_chat_stream_memory_benchmark", - "knowledge_type": "factual", - "entities": [ - "周枝", - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴" - ] - }, - { - "content": "唐未在观测前通常先校准温湿度计和擦镜片,并且只喝无糖姜茶。", - "source": "fixture:group_chat_stream_memory_benchmark", - "knowledge_type": "factual", - "entities": [ - "唐未", - "温湿度计", - "镜片", - "无糖姜茶" - ] - }, - { - "content": "林澈值夜时不喜欢空调直吹,常把风扇固定在二档,同时会自带桂花乌龙。", - "source": "fixture:group_chat_stream_memory_benchmark", - "knowledge_type": "factual", - "entities": [ - "林澈", - "空调直吹", - "风扇二档", - "桂花乌龙" - ] - }, - { - "content": "秦昭做投影演示前会先关北墙射灯,避免镜头反光,再检查投影设备是否与大功率工具共用同一路电源。", - "source": "fixture:group_chat_stream_memory_benchmark", - "knowledge_type": "factual", - "entities": [ - "秦昭", - "北墙射灯", - "镜头反光", - "投影设备" - ] - } - ], - "relations": [ - { - "subject": "周枝", - "predicate": "看管", - "object": "值班柜第二层" - }, - { - "subject": "值班柜第二层", - "predicate": "存放", - "object": "黑柄折叠伞" - }, - { - "subject": "唐未", - "predicate": "负责", - "object": "温湿度计校准" - }, - { - "subject": "唐未", - "predicate": "偏好", - "object": "无糖姜茶" - }, - { - "subject": "林澈", - "predicate": "不喜欢", - "object": "空调直吹" - }, - { - "subject": "秦昭", - "predicate": "会先关闭", - "object": "北墙射灯" - }, - { - "subject": "东侧档案室长桌", - "predicate": "摆放", - "object": "绿色硬壳笔记本" - } - ] - }, - "chat_history_records": [ - { - "record_id": 920001, - "theme": "值班柜第二层物资补位", - "summary": "群里确认值班柜第二层固定放备用钥匙、黑柄折叠伞和创可贴,借出后必须在群里报备;林澈再次提到自己值夜时怕空调直吹,会把风扇调到二档。", - "participants": [ - "Mai", - "林澈", - "周枝", - "秦昭", - "唐未" - ], - "start_time": 1772526180.0, - "end_time": 1772526960.0, - "original_text": "[2026-03-03 19:03] 林澈:空调又直吹值班桌,我先把风扇拨回二档。值班柜第二层是不是只剩一把备用钥匙了?我记得黑柄折叠伞和创可贴本来都在那层。\n[2026-03-03 19:04] 周枝:昨天雨太大,我把黑柄折叠伞借给来送样本的人了,回来的时候顺手挂回柜里,但创可贴我没核对。\n[2026-03-03 19:05] 秦昭:柜门里侧还夹着旧清单,最底下一行写着“备用钥匙、折叠伞、创可贴、充电头”,不过充电头早就改放一层了。\n[2026-03-03 19:06] Mai:那我记一下,第二层以后固定只放备用钥匙、黑柄折叠伞和创可贴,谁临时拿走就在群里报备,不然下次值夜的人会抓瞎。\n[2026-03-03 19:08] 唐未:我刚看了一眼,创可贴只剩两片,外包装已经翘边了,最好连同备用纱布一起补一盒新的。\n[2026-03-03 19:10] 林澈:我晚上值夜的时候最怕找东西翻半天,尤其空调风一吹头就疼,所以物资位置还是固定最省心。\n[2026-03-03 19:11] 周枝:我待会儿把创可贴和纱布一起补上,再把备用钥匙套个红绳,不然黑灯找起来太慢。\n[2026-03-03 19:12] 秦昭:折叠伞我建议继续放第二层最左边,别再和记录本挤一起,上回纸边都被伞骨刮卷了。\n[2026-03-03 19:14] Mai:行,我把“第二层左侧放折叠伞,中间放备用钥匙,右边放创可贴”记到今晚值班备注里。\n[2026-03-03 19:15] 唐未:顺便提醒一下,如果谁把第二层的物资借出去,至少在群里发一句“已借出+归还时间”,别只口头说。\n[2026-03-03 19:16] 周枝:收到,我今晚补完会拍一张柜内照片,省得之后又靠记忆猜。\n[2026-03-03 19:16] 林澈:谢谢,等柜里补齐了我就把桂花乌龙和个人杯子继续放回上层,省得和公用物资混在一起。" - }, - { - "record_id": 920002, - "theme": "停电夜投影仪抢救", - "summary": "资料室停电后,群里确认应急灯固定放在仪器桌下层,橙色延长线盘只给投影相关设备使用;问题由热风枪、扫描灯和投影仪共路导致。", - "participants": [ - "Mai", - "秦昭", - "唐未", - "林澈", - "周枝" - ], - "start_time": 1772791980.0, - "end_time": 1772792820.0, - "original_text": "[2026-03-06 21:33] 秦昭:北墙射灯刚关,投影仪才开到一半就跳闸了,整间资料室一下子全黑,镜头还没盖上。\n[2026-03-06 21:34] 唐未:别先碰镜头,我去摸仪器桌下层的应急灯,上次检修后应该还塞在最里边。\n[2026-03-06 21:35] 林澈:我在门边摸到橙色延长线盘了,先别急着全拉开,确认是不是排插过载再说。\n[2026-03-06 21:36] Mai:先按顺序来,唐未开应急灯,秦昭别挪投影仪,林澈看一下是不是延长线和热风枪同时挂在同一路上。\n[2026-03-06 21:38] 唐未:应急灯找到了,在仪器桌下层右手边的蓝色档案盒后面,亮度够,至少能先护住镜头和电源键。\n[2026-03-06 21:39] 秦昭:问题找到了,热风枪和投影仪都接在橙色延长线盘上,刚才我又把扫描灯也插进去了,估计就是这个组合把闸打掉了。\n[2026-03-06 21:40] 周枝:我把热风枪先拔掉,扫描灯改到东墙独立插口,橙色延长线盘只留投影仪和笔记本电源。\n[2026-03-06 21:41] Mai:好,先恢复最少设备,镜头盖等重新上电稳定后再扣,别在黑里来回碰。\n[2026-03-06 21:43] 林澈:现在电回来了,投影仪风扇声正常,橙色延长线盘没有再冒热,看来就是同路负载堆太多。\n[2026-03-06 21:44] 秦昭:记一下,应急灯平时别乱挪,必须固定放在仪器桌下层,橙色延长线盘也只给投影相关设备用。\n[2026-03-06 21:47] Mai:我已经记到故障备忘里了:停电夜先找仪器桌下层应急灯,再检查橙色延长线盘负载,不要让热风枪和投影仪挂同一路。" - }, - { - "record_id": 920003, - "theme": "风铃观测与姜茶准备", - "summary": "群里安排周六夜间风铃塔观测,唐未负责校准温湿度计和擦镜片,周枝准备无糖姜茶与记录夹,林澈负责风向与风扇位置,观测前需先报温湿度计已校准。", - "participants": [ - "Mai", - "唐未", - "林澈", - "周枝", - "许棠" - ], - "start_time": 1773136020.0, - "end_time": 1773136980.0, - "original_text": "[2026-03-10 20:27] 唐未:周六夜里要做风铃塔观测,我下午先去校准温湿度计,镜片也顺便擦一遍,不然露点记录会飘。\n[2026-03-10 20:28] 林澈:我可以把南平台的小风扇带上去,但别让我坐空调口边上值守,上次吹得我偏头痛一整晚。\n[2026-03-10 20:30] 周枝:观测箱里还剩两包姜片,我去值班室再补一盒无糖姜茶,甜的那种唐未不喝。\n[2026-03-10 20:31] 许棠:屋顶东侧栏杆边那只风铃这两天响得特别密,最好把记录本提前夹在硬板夹上,别到时一手按纸一手扶灯。\n[2026-03-10 20:32] Mai:那就按这个分工:唐未负责温湿度计和镜片,周枝准备无糖姜茶和记录夹,林澈盯现场风向和风扇位置。\n[2026-03-10 20:35] 唐未:我会把校准后的时间写在第一页右上角,免得后面整理的时候又分不清哪组数据是校准前的。\n[2026-03-10 20:37] 林澈:如果夜里温差太大,我会把风扇固定在二档,既能带走雾气,又不至于把纸吹跑。\n[2026-03-10 20:39] 周枝:姜茶我放保温壶里,标签写“无糖”,免得有人顺手加糖包,唐未每次都喝不下去。\n[2026-03-10 20:41] 许棠:记录本别用软封皮,上回屋顶起风,边角被栏杆刮得卷起来,还是硬板夹最稳。\n[2026-03-10 20:43] Mai:再补一条,风铃塔观测前先在群里报“温湿度计已校准”,这样后面谁接手都知道状态。\n[2026-03-10 20:45] 唐未:收到,我会先发校准完成,再把镜片状态和气温一起报出来。\n[2026-03-10 20:46] 林澈:那我带桂花乌龙给自己,公用保温壶就只放无糖姜茶,别把味道混了。" - }, - { - "record_id": 920004, - "theme": "东侧窗边狸花猫与绿色笔记本", - "summary": "档案室东侧窗边的狸花猫跳上长桌,群里因此整理了绿色硬壳笔记本、黄铜回形针、蓝色档案盒和纸镇的摆放规则,避免纸页被风或猫弄乱。", - "participants": [ - "Mai", - "许棠", - "周枝", - "秦昭", - "林澈" - ], - "start_time": 1773478440.0, - "end_time": 1773479280.0, - "original_text": "[2026-03-14 19:34] 许棠:东侧窗边那只狸花猫又钻进档案室了,刚才直接跳上长桌,把绿色硬壳笔记本踩得翻到中间页。\n[2026-03-14 19:35] 周枝:长桌上不是还夹着黄铜回形针和旧雨量页吗?猫要是蹭一下,纸页顺着窗缝就容易飞。\n[2026-03-14 19:36] 秦昭:我刚把窗缝先关小了,绿色硬壳笔记本已经挪到蓝色档案盒上面,黄铜回形针我也收回铁盘里了。\n[2026-03-14 19:38] Mai:先别赶猫,确认一下它是不是又往暖气后面钻。长桌这边只保留绿色笔记本和今晚要抄的旧雨量页,其他散物都收走。\n[2026-03-14 19:40] 林澈:狸花猫刚从暖气后面出来,蹭了一圈又去窗边晒了,至少没再踩记录纸。我顺手把长桌右侧的纸镇也压上了。\n[2026-03-14 19:41] 许棠:绿色笔记本第七页记的是去年秋天的风速补注,别被猫爪勾破了,那页我明天还要录系统。\n[2026-03-14 19:43] 周枝:以后东侧窗边长桌如果要摊资料,先把黄铜回形针和纸镇放好,不然有风再加猫,纸真的收不回来。\n[2026-03-14 19:45] 秦昭:蓝色档案盒我也顺手换到桌角了,避免猫跳上来时把整盒推下去。\n[2026-03-14 19:46] Mai:我记个规则:东侧窗边长桌只放绿色硬壳笔记本、当次要抄的纸页和纸镇;黄铜回形针统一回铁盘。\n[2026-03-14 19:48] 林澈:那只狸花猫现在缩在窗台垫子上了,看样子只是找暖和,不是故意拆台。\n[2026-03-14 19:48] 许棠:收到,明天我会先去看第七页,再决定要不要把整本绿色笔记本转移到里间。" - } - ], - "person_writebacks": [ - { - "person_id": "6c4a50b4f4f34bdbb1a7a1c1e56c9001", - "person_name": "林澈", - "memory_content": "林澈值夜时不喜欢空调直吹,通常把风扇固定在二档,并且会自带桂花乌龙。", - "expected_keywords": [ - "林澈", - "空调直吹", - "风扇", - "二档", - "桂花乌龙" - ] - }, - { - "person_id": "cbfd79d4680849b5ac7e23a3f6f09002", - "person_name": "唐未", - "memory_content": "唐未每次观测前都会先校准温湿度计和擦镜片,而且只喝无糖姜茶。", - "expected_keywords": [ - "唐未", - "温湿度计", - "校准", - "镜片", - "无糖姜茶" - ] - }, - { - "person_id": "a6eb73d41251472a8d229f1202df9003", - "person_name": "周枝", - "memory_content": "周枝负责补和值班柜第二层的备用钥匙、黑柄折叠伞和创可贴,借出后会在群里报备。", - "expected_keywords": [ - "周枝", - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴" - ] - } - ], - "search_cases": [ - { - "query": "值班柜第二层 备用钥匙 折叠伞 创可贴", - "expected_keywords": [ - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴" - ], - "minimum_keyword_hits": 2 - }, - { - "query": "停电夜 投影仪 橙色延长线盘 应急灯", - "expected_keywords": [ - "停电夜", - "投影仪", - "橙色延长线盘", - "应急灯" - ], - "minimum_keyword_hits": 2 - }, - { - "query": "风铃塔观测 温湿度计 无糖姜茶", - "expected_keywords": [ - "风铃塔观测", - "温湿度计", - "无糖姜茶", - "校准" - ], - "minimum_keyword_hits": 2 - }, - { - "query": "东侧窗边 狸花猫 绿色硬壳笔记本 黄铜回形针", - "expected_keywords": [ - "东侧窗边", - "狸花猫", - "绿色硬壳笔记本", - "黄铜回形针" - ], - "minimum_keyword_hits": 2 - } - ], - "time_cases": [ - { - "query": "投影仪 应急灯", - "time_expression": "2026/03/06", - "expected_keywords": [ - "投影仪", - "应急灯", - "橙色延长线盘" - ] - }, - { - "query": "温湿度计 姜茶", - "time_expression": "2026/03/10", - "expected_keywords": [ - "温湿度计", - "无糖姜茶", - "风铃塔观测" - ] - }, - { - "query": "狸花猫 绿色笔记本", - "time_expression": "2026/03/14", - "expected_keywords": [ - "狸花猫", - "绿色硬壳笔记本", - "黄铜回形针" - ] - } - ], - "episode_cases": [ - { - "query": "那次值班柜第二层重新补物资的经过", - "expected_keywords": [ - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "停电夜抢救投影仪的经过", - "expected_keywords": [ - "停电夜", - "投影仪", - "应急灯", - "橙色延长线盘" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "风铃塔观测前准备姜茶和温湿度计的那次安排", - "expected_keywords": [ - "风铃塔观测", - "温湿度计", - "无糖姜茶", - "硬板夹" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "东侧窗边那只狸花猫闯进档案室那次", - "expected_keywords": [ - "东侧窗边", - "狸花猫", - "绿色硬壳笔记本", - "黄铜回形针" - ], - "minimum_keyword_recall": 0.75 - } - ], - "knowledge_fetcher_cases": [ - { - "query": "群里后来把值班柜第二层固定放哪些东西?", - "expected_keywords": [ - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞", - "创可贴" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "停电那次救投影仪时先找的是什么,延长线后来怎么规定?", - "expected_keywords": [ - "应急灯", - "仪器桌下层", - "橙色延长线盘", - "投影相关设备" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "谁会在观测前校准温湿度计,还只喝无糖姜茶?", - "expected_keywords": [ - "唐未", - "温湿度计", - "校准", - "无糖姜茶" - ], - "minimum_keyword_recall": 0.75 - } - ], - "profile_cases": [ - { - "person_id": "6c4a50b4f4f34bdbb1a7a1c1e56c9001", - "expected_keywords": [ - "林澈", - "空调直吹", - "二档", - "桂花乌龙" - ], - "minimum_keyword_recall": 0.75 - }, - { - "person_id": "cbfd79d4680849b5ac7e23a3f6f09002", - "expected_keywords": [ - "唐未", - "温湿度计", - "校准", - "无糖姜茶" - ], - "minimum_keyword_recall": 0.75 - }, - { - "person_id": "a6eb73d41251472a8d229f1202df9003", - "expected_keywords": [ - "周枝", - "值班柜第二层", - "备用钥匙", - "黑柄折叠伞" - ], - "minimum_keyword_recall": 0.75 - } - ], - "negative_control_cases": [ - { - "query": "海盐柠檬饼干 原味苏打 辣味海苔 无糖薄荷糖", - "source_batch_id": "snack_gossip_march_15_negative", - "expected_behavior": "should_return_no_hits_if_only_positive_batches_are_ingested", - "reason": "当前设计要求没有 Bot 发言的群聊批次不应进入长期记忆总结主路径。" - } - ] -} diff --git a/pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark_hard.json b/pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark_hard.json deleted file mode 100644 index 10f13773..00000000 --- a/pytests/A_memorix_test/data/benchmarks/group_chat_stream_memory_benchmark_hard.json +++ /dev/null @@ -1,862 +0,0 @@ -{ - "meta": { - "scenario_id": "group_chat_stream_memory_benchmark_hard", - "description": "第二套更长、更刁钻的群聊聊天流长期记忆量化评估数据,刻意加入跨话题重叠词、自然句检索、长时段触发流以及更容易被群聊摘要淹没的人物事实。", - "designed_for": [ - "group_chat", - "topic_batching", - "bot_participation_gate", - "runtime_trigger", - "search_mode", - "time_mode", - "episode_mode", - "aggregate_mode", - "person_fact_writeback", - "negative_control", - "cross_topic_overlap", - "hard_mode" - ], - "quantitative_targets": { - "search": { - "accuracy_at_1": 0.4, - "recall_at_5": 0.75, - "keyword_recall_at_5": 0.85 - }, - "knowledge_fetcher": { - "success_rate": 0.75, - "keyword_recall": 0.8 - }, - "profile": { - "success_rate": 0.67, - "evidence_rate": 1.0 - }, - "writeback": { - "success_rate": 0.67, - "keyword_recall": 0.8 - }, - "episode": { - "success_rate": 0.75, - "keyword_recall": 0.8 - }, - "negative_control": { - "zero_hit_rate": 1.0 - }, - "runtime_trigger": { - "positive_trigger_rate": 1.0, - "negative_discard_rate": 1.0 - } - } - }, - "session": { - "session_id": "qq_group_535353", - "platform": "qq", - "user_id": "10010", - "group_id": "535353", - "display_name": "岚桥夜航协作群" - }, - "import_payload": { - "paragraphs": [ - { - "content": "北塔夹层药箱抽屉通常固定放铜牌备用钥匙、银色保温毯、薄荷膏和黑色丁腈手套,夜间临时取用后需要在群里补报用途与归还时间。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "factual", - "entities": [ - "北塔夹层药箱", - "铜牌备用钥匙", - "银色保温毯", - "薄荷膏", - "黑色丁腈手套" - ] - }, - { - "content": "雨棚工具架附近的短波电台返潮时,大家通常会先把机身擦干,再把黑色盘线和备用听筒重新卷回灰色防潮箱,最后更换新的硅胶包。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "narrative", - "entities": [ - "短波电台", - "黑色盘线", - "灰色防潮箱", - "硅胶包", - "备用听筒" - ] - }, - { - "content": "晨雾采样结束后,蓝盖保温箱里的冷敷袋、编号试剂架和玻璃记号笔需要按固定顺序归位,避免交接时把样本编号和温控记录混在一起。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "factual", - "entities": [ - "蓝盖保温箱", - "冷敷袋", - "编号试剂架", - "玻璃记号笔", - "样本编号" - ] - }, - { - "content": "西廊风口观测前,迟雨会先校准气压计并核对湿度夹板,桌边常备温梨汤和风向丝带,避免记录员在长风口里来回找物件。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "factual", - "entities": [ - "迟雨", - "气压计", - "湿度夹板", - "温梨汤", - "风向丝带" - ] - }, - { - "content": "沈砚值夜时不喜欢冷白荧光灯直照,常把小风扇固定在三档,并且会自带盐渍梅子气泡水放在个人层架上。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "factual", - "entities": [ - "沈砚", - "冷白荧光灯", - "小风扇三档", - "盐渍梅子气泡水" - ] - }, - { - "content": "贺岚平时看管北塔夹层药箱和铜牌备用钥匙,取用后会顺手补回黑色丁腈手套,并检查银色保温毯有没有被塞反。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "factual", - "entities": [ - "贺岚", - "北塔夹层药箱", - "铜牌备用钥匙", - "黑色丁腈手套", - "银色保温毯" - ] - }, - { - "content": "绘图室天窗边长桌常压着象牙描图纸、琥珀夹子和黄铜镇纸,白耳鸮闯入时最容易把这一角的纸页和夹具掀乱。", - "source": "fixture:group_chat_stream_memory_benchmark_hard", - "knowledge_type": "narrative", - "entities": [ - "绘图室天窗边", - "象牙描图纸", - "琥珀夹子", - "黄铜镇纸", - "白耳鸮" - ] - } - ], - "relations": [ - { - "subject": "贺岚", - "predicate": "看管", - "object": "北塔夹层药箱" - }, - { - "subject": "北塔夹层药箱", - "predicate": "存放", - "object": "铜牌备用钥匙" - }, - { - "subject": "北塔夹层药箱", - "predicate": "存放", - "object": "银色保温毯" - }, - { - "subject": "顾澄", - "predicate": "维护", - "object": "短波电台" - }, - { - "subject": "迟雨", - "predicate": "负责", - "object": "气压计校准" - }, - { - "subject": "迟雨", - "predicate": "偏好", - "object": "温梨汤" - }, - { - "subject": "沈砚", - "predicate": "不喜欢", - "object": "冷白荧光灯直照" - }, - { - "subject": "绘图室天窗边", - "predicate": "摆放", - "object": "象牙描图纸" - } - ] - }, - "simulated_stream_batches": [ - { - "batch_id": "mezzanine_first_aid_march_21", - "topic": "北塔夹层药箱补位", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "沈砚", - "贺岚", - "迟雨", - "顾澄" - ], - "start_time": 1774095120.0, - "end_time": 1774098180.0, - "messages": [ - "[2026-03-21 20:12] 沈砚:北塔值夜的冷白灯又直打眼睛,我把小风扇拨回三档时顺手翻了一下夹层药箱,铜牌备用钥匙还在,但银色保温毯和薄荷膏的位置都乱了。", - "[2026-03-21 20:14] 贺岚:黑色丁腈手套昨晚急救演练拿走了两副,我还没来得及补,药箱里左边那格现在确实空得很明显。", - "[2026-03-21 20:18] 顾澄:旧清单上写的是铜牌备用钥匙、银色保温毯、薄荷膏、黑色丁腈手套和哨卡,但哨卡早改到门侧挂袋了。", - "[2026-03-21 20:22] Mai:那今晚重新定一遍,夹层药箱只保留铜牌备用钥匙、银色保温毯、薄荷膏和黑色丁腈手套,谁临时拿走都在群里补报用途和预计归还时间。", - "[2026-03-21 20:27] 迟雨:薄荷膏只剩底,保温毯边角也卷了,最好一并补新,不然真遇到低温处理时手忙脚乱。", - "[2026-03-21 20:31] 沈砚:我半夜最怕一边被灯晃一边找物资,所以钥匙和保温毯的位置最好别再动来动去,我自己的盐渍梅子气泡水也不会放抽屉里。", - "[2026-03-21 20:36] 贺岚:我待会儿带一盒新薄荷膏和两包丁腈手套过去,顺便把保温毯重新折成外翻口,黑里摸会更快。", - "[2026-03-21 20:40] 顾澄:铜牌备用钥匙建议继续夹中间,别再和创伤贴挤在一起,上次取钥匙把整包小药片都带出来了。", - "[2026-03-21 20:45] Mai:我记成新版摆放顺序了,左保温毯,中央铜牌备用钥匙,右边薄荷膏和丁腈手套;个人饮料和私人物件一律不进抽屉。", - "[2026-03-21 20:49] 迟雨:如果临时取保温毯或手套,至少发“已取用+用途+归还时间”,不要只在走廊口头说一声。", - "[2026-03-21 20:56] 贺岚:补完我会拍张抽屉照片,连折法和钥匙朝向一起拍进去,之后新值夜的人直接看图就行。", - "[2026-03-21 21:03] Mai:就按这版执行,后面谁要改夹层药箱布局,先在群里说,不然下次夜班还是会翻半天。" - ], - "combined_text": "北塔夹层药箱这一轮群聊把铜牌备用钥匙、银色保温毯、薄荷膏、黑色丁腈手套的摆放与补位规则重新说得很细。大家先从沈砚值夜时被冷白荧光灯直照、顺手检查抽屉说起,接着确认旧清单、演练后没有补回的丁腈手套、卷边的银色保温毯和几乎见底的薄荷膏到底该放在哪一格。Mai 明确要求夹层药箱只保留四样公用物资,谁取用都要在群里补报用途与归还时间;贺岚负责补回手套和薄荷膏,并承诺拍清楚抽屉照片;顾澄补充钥匙不能再与小药片混放;沈砚再次提到自己值夜时会把小风扇固定在三档,个人带来的盐渍梅子气泡水不会放进公用抽屉里。后半段群聊又进一步确认了左保温毯、中间钥匙、右侧药品的最终布局,说明旧清单中的哨卡已经迁出,不应再占用抽屉空间,并强调夜里应急时谁先摸到物资、谁负责在群里补一句用途和归还时间。整段对话同时覆盖了物资补位、摆放顺序、夜间可见性、旧规则迁移和报备流程,是一段标准而冗长的群聊收束样本。", - "expected_memory_targets": [ - "北塔夹层药箱", - "铜牌备用钥匙", - "银色保温毯", - "薄荷膏", - "黑色丁腈手套", - "报备" - ] - }, - { - "batch_id": "rain_shed_radio_march_23", - "topic": "雨棚短波电台返潮排查", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "顾澄", - "贺岚", - "迟雨", - "沈砚" - ], - "start_time": 1774275240.0, - "end_time": 1774278360.0, - "messages": [ - "[2026-03-23 22:14] 顾澄:雨棚那台短波电台今晚一开机就有潮噪,灰色防潮箱外壳摸着也冷,黑色盘线有一截像是沾了雾水。", - "[2026-03-23 22:16] 贺岚:备用听筒没事,主要是箱里那包硅胶已经发软了,估计这两天夜里返潮比想的更重。", - "[2026-03-23 22:20] 沈砚:别直接通电硬顶,我先把台面上的小黄灯关掉再看接口,反光太重会误判,盘线最好先拆下来擦干。", - "[2026-03-23 22:26] Mai:按顺序处理,顾澄先把机身外壳擦干,贺岚把黑色盘线和备用听筒摊开,迟雨看一下灰色防潮箱底部的吸水毡有没有饱和。", - "[2026-03-23 22:31] 迟雨:吸水毡已经发黏,箱底还有一点冷凝水,硅胶包也该整包换新,不然把盘线卷回去还是会闷出潮气。", - "[2026-03-23 22:35] 顾澄:机身擦干后杂音小了一半,问题大概不是主板,是存放时盘线缠得太紧,潮气都闷在接头这圈。", - "[2026-03-23 22:40] 贺岚:我把黑色盘线全部放开了,备用听筒也擦过一遍,灰色防潮箱里原来的旧硅胶包先扔掉。", - "[2026-03-23 22:45] Mai:之后统一改成机身先晾五分钟、盘线松卷、听筒单独装袋、灰色防潮箱最后再关盖,别再图快一股脑塞进去。", - "[2026-03-23 22:50] 沈砚:雨棚边夜里冷风大,机身外壳要是还冒凉气就别急着合盖,明早又得重新返潮。", - "[2026-03-23 22:56] 迟雨:新硅胶包已经放进去两袋,吸水毡我改成倒放在最底层,至少不会直接顶着电台外壳。", - "[2026-03-23 23:01] 顾澄:我把黑色盘线重新分成两圈,接头朝上,短波电台和备用听筒都回箱了,现在基本没有潮噪。", - "[2026-03-23 23:06] Mai:结论记一下,雨棚短波电台返潮时先擦机身、松黑色盘线、换硅胶包,再按灰色防潮箱的新顺序归位。" - ], - "combined_text": "雨棚短波电台这段对话比一般设备检修更刁钻,因为大家反复讨论的是返潮噪声、灰色防潮箱底部冷凝水、黑色盘线缠绕方式和硅胶包失效这些容易被混成同类词的细节。顾澄先发现短波电台有潮噪,贺岚确认备用听筒没坏而是防潮箱和硅胶包出了问题,沈砚提醒不要在反光和冷风里急着通电,Mai 则把处理顺序拆成机身擦干、盘线摊开、吸水毡检查和硅胶包更换。迟雨进一步指出灰色防潮箱底部的吸水毡已经发黏,需要调整放置方式。后面的讨论还涉及盘线要不要分成两圈、接头是否朝上、机身晾多久再合盖、备用听筒需不需要单独装袋,以及第二天谁来复查灰色防潮箱里的湿气状态。最后大家把短波电台、黑色盘线、备用听筒和灰色防潮箱的归位规则重新说清楚,让这一段既像设备维护,又像存放流程重整,天然适合测试跨话题重叠词下的检索稳定性。", - "expected_memory_targets": [ - "短波电台", - "灰色防潮箱", - "黑色盘线", - "硅胶包", - "备用听筒" - ] - }, - { - "batch_id": "fog_transfer_march_26", - "topic": "晨雾采样蓝盖保温箱排序", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "迟雨", - "顾澄", - "贺岚", - "沈砚" - ], - "start_time": 1774475280.0, - "end_time": 1774478520.0, - "messages": [ - "[2026-03-26 05:48] 迟雨:晨雾采样刚收尾,蓝盖保温箱里冷敷袋、编号试剂架和玻璃记号笔被塞反了,后面交接的人可能一打开就拿错。", - "[2026-03-26 05:52] 顾澄:我刚把样本管临时放回去时太赶了,冷敷袋压在编号试剂架上面了,玻璃记号笔也滑到最底层去了。", - "[2026-03-26 05:57] 贺岚:吸水垫已经湿透一半,保温箱里要是再混着放,样本编号和温控记录很容易一起糊掉。", - "[2026-03-26 06:02] Mai:先别再往里塞东西,按新版顺序来,最下层冷敷袋,中层编号试剂架,右侧槽放玻璃记号笔和封口贴,样本管最后再扣。", - "[2026-03-26 06:08] 沈砚:我先把外侧工作灯压低一点,冷白灯映在箱盖上太亮,我看编号时总会反光,看错一位就麻烦。", - "[2026-03-26 06:13] 迟雨:我把蓝盖保温箱里的冷敷袋重新平码了,编号试剂架按奇偶分左右,这样交接时一眼就能扫清楚。", - "[2026-03-26 06:18] 顾澄:玻璃记号笔现在固定在右槽,不再横放,我顺手把备用封口贴也放在笔旁边了。", - "[2026-03-26 06:24] 贺岚:样本管外壁的雾水有点重,我加了一层薄吸水纸,不然冷敷袋融水会一路蹭到标签。", - "[2026-03-26 06:30] Mai:之后凌晨交接都照这个次序,谁要临时改动蓝盖保温箱内部顺序,也得在群里补一句,免得下一班以为还是旧布局。", - "[2026-03-26 06:35] 迟雨:我已经把编号试剂架和样本位次对应关系写到箱盖内侧了,等会儿拍照发群文件。", - "[2026-03-26 06:39] 沈砚:这样就算我半困着看,也不至于拿错玻璃记号笔或者把冷敷袋从最上层一把抽出来。", - "[2026-03-26 06:42] Mai:确认一下,本次晨雾采样交接按蓝盖保温箱新顺序执行,冷敷袋、编号试剂架、玻璃记号笔和样本管都不要再混层。" - ], - "combined_text": "晨雾采样这一段专门压在“凌晨交接、低温、反光、编号顺序”这些容易让检索混淆的词上。大家围绕蓝盖保温箱里冷敷袋、编号试剂架、玻璃记号笔和样本管的归位顺序聊得很细,从顾澄一开始匆忙把冷敷袋压在试剂架上,到贺岚担心吸水垫、标签和融水互相污染,再到 Mai 明确规定最下层冷敷袋、中层编号试剂架、右槽玻璃记号笔和封口贴、样本管最后扣紧。沈砚额外提到冷白灯反光会影响编号辨认,迟雨把架位关系写到箱盖内侧并承诺拍照存档。随后大家又补充了吸水纸、备用封口贴、样本管雾水、奇偶编号左右分层和凌晨换班时必须按箱盖顺序复核这些细节,让这一段不仅仅是物品清单,而是包含低温保存、交接动作、标签保护和视觉干扰的完整操作窗口。整段话题兼有物品清单、流程顺序、交接规则和环境噪声,非常适合验证更长文本下的 search、time 和 episode 区分能力。", - "expected_memory_targets": [ - "蓝盖保温箱", - "冷敷袋", - "编号试剂架", - "玻璃记号笔", - "样本管" - ] - }, - { - "batch_id": "west_corridor_wind_march_29", - "topic": "西廊风口观测准备", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "迟雨", - "沈砚", - "许窈", - "贺岚" - ], - "start_time": 1774779720.0, - "end_time": 1774783080.0, - "messages": [ - "[2026-03-29 18:22] 迟雨:今晚西廊风口比预想更急,我先把气压计拿出来校准,湿度夹板也得重新夹一张干纸,不然一小时后全卷边。", - "[2026-03-29 18:25] 沈砚:工作台这边冷白灯太刺,我先换成侧边黄灯,再把小风扇关小一点,不然气流会把风向丝带吹得乱抖。", - "[2026-03-29 18:29] 许窈:温梨汤我装到窄口保温壶里了,放在西廊门内侧靠墙的位置,别和公用记录板挤在一起。", - "[2026-03-29 18:34] 贺岚:风向丝带刚整理过一遍,我把备用夹子和细线都放到记录板后面的小袋里,等会儿就不用来回摸。", - "[2026-03-29 18:39] Mai:流程再说一遍,迟雨先报气压计校准完成,许窈确认温梨汤和备用夹子位置,沈砚负责灯光和风扇不要干扰风口读数。", - "[2026-03-29 18:44] 迟雨:气压计现在回到零点附近了,湿度夹板也夹好了新纸,等第一轮风向记录时我再复核一次。", - "[2026-03-29 18:48] 沈砚:黄灯角度调好了,这样不会直照读数盘面,我自己的盐渍梅子气泡水也还是放个人架,不会碰公用桌面。", - "[2026-03-29 18:53] 许窈:温梨汤和备用杯已经摆好,门口风太直,我给壶套了防滑圈,谁喝完别顺手放回记录夹旁边。", - "[2026-03-29 18:58] 贺岚:风向丝带试摆过了,夹点不动,细线也没打结,记录板后袋里还有两枚备用夹子。", - "[2026-03-29 19:04] Mai:今晚西廊风口观测前的关键点就三件,气压计校准、湿度夹板换纸、温梨汤和夹具别混放,之后都照这版执行。", - "[2026-03-29 19:11] 迟雨:收到,我把“先校准气压计再开记录”写到第一行了,后面换班的人也不用再问一次。", - "[2026-03-29 19:18] Mai:这段我会记进观测前准备说明里,尤其是气压计、湿度夹板、风向丝带和温梨汤的位置别再临时换。" - ], - "combined_text": "西廊风口观测准备这段特意把人物偏好和观测流程混在一起,模拟真实群聊里“事实很容易被话题背景淹没”的情况。迟雨一直在谈气压计校准和湿度夹板换纸,许窈负责温梨汤和备用杯摆位,贺岚补充风向丝带、备用夹子和细线的位置,沈砚则反复抱怨冷白灯太刺眼、改用侧边黄灯并控制小风扇不要干扰读数。Mai 把整套流程收束成观测前三步,并强调温梨汤、夹具和记录板不能混放。后续大家又把保温壶的防滑圈、备用夹子分配、风向丝带试摆、个人饮料不能挤进公用桌面、以及第一轮记录前必须先报校准完成这些动作重复确认了一遍。这里既有人物稳定偏好,又有西廊风口这一特定场景和大量易重叠的工具名,还包含多轮确认与换班提示,是验证 person_fact 是否能真正压过群聊背景噪声的好素材。", - "expected_memory_targets": [ - "西廊风口", - "气压计", - "湿度夹板", - "温梨汤", - "风向丝带" - ] - }, - { - "batch_id": "skylight_owl_april_02", - "topic": "绘图室天窗白耳鸮闯入", - "bot_participated": true, - "expected_behavior": "should_be_summarized", - "message_count": 12, - "participants": [ - "Mai", - "许窈", - "顾澄", - "贺岚", - "沈砚" - ], - "start_time": 1775133060.0, - "end_time": 1775136540.0, - "messages": [ - "[2026-04-02 20:31] 许窈:绘图室天窗缝刚被风顶开,一只白耳鸮直接扑到长桌上,把象牙描图纸掀起来一大片,琥珀夹子全滑到地上了。", - "[2026-04-02 20:34] 顾澄:别先追鸟,我先把黄铜镇纸按回纸角,不然描图纸卷得更厉害,边上的玻璃尺也快掉了。", - "[2026-04-02 20:38] 贺岚:折梯在门后,我去把天窗先拉上,琥珀夹子我捡回一半了,有两枚滑到东侧柜脚下。", - "[2026-04-02 20:42] 沈砚:先别开顶灯,那盏冷白灯一亮鸟会更乱撞,我把侧边小灯压低,别让它再扑向纸架。", - "[2026-04-02 20:47] Mai:处理顺序先固定,顾澄压纸,贺岚关天窗找夹子,许窈把卷起的象牙描图纸按编号重新叠回去,不要一边追鸟一边挪桌上工具。", - "[2026-04-02 20:51] 许窈:最上面的两张描图纸已经有折痕了,但编号还在,我先用黄铜镇纸压住,再找少掉的琥珀夹子。", - "[2026-04-02 20:56] 顾澄:白耳鸮现在站到东梁上了,暂时不碰桌子,玻璃尺和铅笔盒我已经移到靠墙这边,省得再被翅膀扫下去。", - "[2026-04-02 21:00] 贺岚:天窗锁好了,东侧柜脚下捡回三枚琥珀夹子,另外一枚卡在桌脚和纸箱中间,我伸手就能拿到。", - "[2026-04-02 21:05] 沈砚:侧灯角度够了,至少不会再直照到鸟和纸面,我这边风扇也先停了,免得象牙描图纸继续抖。", - "[2026-04-02 21:10] Mai:等鸟自己往高处退,我们先把桌面恢复,象牙描图纸按编号叠好,琥珀夹子回左盒,黄铜镇纸继续压角,别因为一阵慌把原来的顺序全打散。", - "[2026-04-02 21:18] 许窈:桌面基本恢复了,少掉的那枚夹子也找回来了,描图纸折痕部分我另放最上层,明天再单独压平。", - "[2026-04-02 21:29] Mai:今晚的结论我记一下,绘图室天窗边长桌继续固定放象牙描图纸、琥珀夹子和黄铜镇纸,白耳鸮闯入时先稳纸再关窗。" - ], - "combined_text": "白耳鸮闯进绘图室这一段专门拿来压 episode query 的自然语言理解,因为话题里既有事件性描述,又有大量静物摆放细节。大家先处理的是天窗缝被顶开后,象牙描图纸、琥珀夹子和黄铜镇纸被掀乱的问题;随后又讨论冷白灯会刺激白耳鸮、侧灯角度、风扇是否需要停、折梯和玻璃尺的位置、以及描图纸折痕如何单独压平。Mai 没有只说‘把鸟赶出去’,而是明确总结成‘先稳纸再关窗’,并恢复了绘图室天窗边长桌的固定摆放规则。后半段群聊还补了夹子掉落点、东侧柜脚、桌脚纸箱缝、顶灯和侧灯的切换、以及第二天如何单独压平最上层折痕纸这些容易被普通检索忽略的细枝末节。这类又长又绕、既有事件又有物件的群聊,非常适合验证我们刚修过的中文自然句 episode 检索是否真的泛化。", - "expected_memory_targets": [ - "白耳鸮", - "象牙描图纸", - "琥珀夹子", - "黄铜镇纸", - "天窗" - ] - }, - { - "batch_id": "dessert_idle_april_04_negative", - "topic": "夜宵甜点口味闲聊", - "bot_participated": false, - "expected_behavior": "ignored_by_summarizer_without_bot_message", - "message_count": 10, - "participants": [ - "许窈", - "顾澄", - "迟雨", - "沈砚" - ], - "start_time": 1775301000.0, - "end_time": 1775304120.0, - "messages": [ - "[2026-04-04 19:10] 许窈:我刚在街口买到荔枝冻,里面还有一点桂花糖浆,冰得很夸张。", - "[2026-04-04 19:14] 顾澄:我更想吃焦糖海盐脆片,那种一咬就掉屑的比糯口点心适合夜里配热水。", - "[2026-04-04 19:19] 迟雨:乌龙奶酥我也喜欢,但甜得太快,还是百香果软糖更耐嚼。", - "[2026-04-04 19:24] 沈砚:夜里我其实只想吃不黏手的,海盐脆片和柚子糖都比奶酥方便。", - "[2026-04-04 19:31] 许窈:那我下次把荔枝冻换成常温的,不然冰箱味太重。", - "[2026-04-04 19:37] 顾澄:要是有烤糯米团就好了,焦糖和海盐一起裹会很香。", - "[2026-04-04 19:44] 迟雨:百香果软糖一定要选酸一点的,不然吃两颗就发腻。", - "[2026-04-04 19:51] 沈砚:乌龙奶酥掉渣太厉害,我更想找一盒柠檬脆片。", - "[2026-04-04 19:58] 许窈:行,那我下次夜宵就别买太黏的,省得桌上全是糖粉。", - "[2026-04-04 20:02] 顾澄:决定了,下一轮就试荔枝冻、海盐脆片和百香果软糖三选二。" - ], - "combined_text": "这一批是刻意保留下来的负样本,整段只围绕荔枝冻、焦糖海盐脆片、乌龙奶酥、百香果软糖和柚子糖的口味闲聊,没有 Mai 参与,也没有任何需要被长期记忆化的群聊任务结论。它的设计目的不是构造一个完全短小的无聊片段,而是制造一段在词面上同样丰富、长度也足够的闲聊,以便验证 summarizer 的 Bot 参与门槛和检索层的误召回控制是否真的有效。", - "expected_memory_targets": [ - "荔枝冻", - "焦糖海盐脆片", - "乌龙奶酥", - "百香果软糖" - ] - } - ], - "runtime_trigger_streams": [ - { - "stream_id": "runtime_west_corridor_trigger_april_07", - "topic": "西廊风口准备长流触发样本", - "trigger_mode": "time_threshold", - "elapsed_since_last_check_hours": 8.8, - "bot_participated": true, - "expected_check_outcome": "should_trigger_topic_check_and_pass_bot_gate", - "expected_next_stage": "topic_cache_should_update", - "message_count": 22, - "participants": [ - "Mai", - "迟雨", - "沈砚", - "许窈", - "贺岚" - ], - "start_time": 1775523960.0, - "end_time": 1775555880.0, - "messages": [ - "[2026-04-07 09:06] 迟雨:今早西廊风口比昨晚更急,气压计零点又飘了一点,我先不急着开记录。", - "[2026-04-07 09:18] 沈砚:门边冷白灯太刺,我把黄灯转过去一点,不然表盘反光还是读不清。", - "[2026-04-07 09:27] 许窈:温梨汤已经装进新壶了,先放在门内侧,不和记录板摆一排。", - "[2026-04-07 09:41] 贺岚:备用夹子和风向丝带我都放进后袋了,细线也重新绕好了。", - "[2026-04-07 10:03] Mai:今天沿用西廊风口旧流程,先校准气压计,再换湿度夹板的纸,最后确认温梨汤和夹具位置。", - "[2026-04-07 10:17] 迟雨:气压计现在回稳了,但湿度夹板上的旧纸边角已经卷起来,最好直接重夹。", - "[2026-04-07 10:33] 沈砚:我把风扇关小了,别让风向丝带被室内气流带偏。", - "[2026-04-07 10:57] 许窈:保温壶外壁有点滑,我又套了防滑圈,省得一碰就滚到门口去。", - "[2026-04-07 11:15] 贺岚:记录板后袋里的夹子还够,两枚大的留给风向丝带,细夹子给湿度纸。", - "[2026-04-07 11:39] Mai:谁临时挪了气压计或湿度夹板,记得在群里说一声,别让下一班找不到。", - "[2026-04-07 12:08] 迟雨:第一轮记录结束,我把气压计位置和校准时间写到夹板右上角了。", - "[2026-04-07 12:44] 沈砚:黄灯角度再压低一点就够了,现在表盘基本没有白斑。", - "[2026-04-07 13:11] 许窈:温梨汤还热,我又加了一个空杯,下午那班不用再回去找。", - "[2026-04-07 13:43] 贺岚:风向丝带其中一根打了结,我已经拆开重绑,备用细线也补了一卷。", - "[2026-04-07 14:06] Mai:下午继续按这版,不要把温梨汤放到公用记录夹旁边,也别让个人饮料挤进工作台。", - "[2026-04-07 14:38] 迟雨:第二轮之前我再复核一次气压计,湿度夹板的纸目前还是干的。", - "[2026-04-07 15:09] 沈砚:我自己的盐渍梅子气泡水还是放个人架,公用桌上只留气压计和夹板。", - "[2026-04-07 15:37] 许窈:门口风更大了,保温壶我往里挪了半步,但还是在原来的内侧角落。", - "[2026-04-07 16:03] 贺岚:备用夹子数量没变,风向丝带和细线现在都在后袋左边。", - "[2026-04-07 16:41] Mai:我把“先校准气压计再开记录”的提示写到最上面了,换班的人照着做就行。", - "[2026-04-07 17:12] 迟雨:第三轮读数也稳定,湿度夹板今天的纸还能撑到收工。", - "[2026-04-07 17:58] Mai:今天西廊风口准备流程就按这版固化,气压计、湿度夹板、风向丝带和温梨汤都别再临时换位。" - ], - "expected_memory_targets": [ - "西廊风口", - "气压计", - "湿度夹板", - "温梨汤", - "风向丝带" - ] - }, - { - "stream_id": "runtime_skylight_trigger_april_09", - "topic": "白耳鸮闯入长流触发样本", - "trigger_mode": "time_threshold", - "elapsed_since_last_check_hours": 8.9, - "bot_participated": true, - "expected_check_outcome": "should_trigger_topic_check_and_pass_bot_gate", - "expected_next_stage": "topic_cache_should_update", - "message_count": 22, - "participants": [ - "Mai", - "许窈", - "顾澄", - "贺岚", - "沈砚" - ], - "start_time": 1775707860.0, - "end_time": 1775739780.0, - "messages": [ - "[2026-04-09 12:11] 许窈:中午检窗时发现绘图室天窗锁没扣紧,我先把象牙描图纸压回去,免得下午又起风。", - "[2026-04-09 12:25] 顾澄:琥珀夹子今天早上少了一枚,我怀疑还卡在桌脚边。", - "[2026-04-09 12:42] 贺岚:折梯已经挪回门后,等会儿再顺手看一眼天窗扣件。", - "[2026-04-09 13:06] 沈砚:侧灯现在正常,顶灯还是先别开,冷白光一照纸面就晃得厉害。", - "[2026-04-09 13:31] Mai:先把桌面顺序恢复,象牙描图纸、琥珀夹子和黄铜镇纸都照旧摆,天窗问题下午统一处理。", - "[2026-04-09 14:02] 许窈:刚刚那只白耳鸮又落回外沿了,好在没再扑进来,我先不惊它。", - "[2026-04-09 14:29] 顾澄:少的那枚夹子找到了,果然卡在桌脚和纸箱中间。", - "[2026-04-09 14:58] 贺岚:天窗扣件我试了一下,有一边发涩,合上时得多推半格。", - "[2026-04-09 15:21] 沈砚:风扇先停吧,纸边已经有点抖,别把描图纸又吹翘了。", - "[2026-04-09 15:46] Mai:如果白耳鸮再闯进来,还是按先稳纸、再关窗、最后收桌面工具的顺序来。", - "[2026-04-09 16:03] 许窈:我把折痕最明显的两张描图纸单独压在最上面,明天再慢慢压平。", - "[2026-04-09 16:24] 顾澄:黄铜镇纸这边够用,四角都压住了,玻璃尺也移到了靠墙那边。", - "[2026-04-09 16:52] 贺岚:门后的折梯没再动,天窗如果晚上还发涩,我明早再上去调。", - "[2026-04-09 17:15] 沈砚:侧灯角度现在刚好,不会直打鸟也不会晃到纸面。", - "[2026-04-09 17:41] Mai:晚班别改桌面布局,尤其别把琥珀夹子和黄铜镇纸分开放,不然一慌又找不到。", - "[2026-04-09 18:08] 许窈:描图纸编号已经重新核对完,缺页没有新增。", - "[2026-04-09 18:39] 顾澄:桌脚周围也清出来了,之后夹子掉落会更容易捡。", - "[2026-04-09 19:02] 贺岚:天窗锁现在能扣上,只是右边还是偏紧,先留个提醒。", - "[2026-04-09 19:34] 沈砚:今晚继续用侧灯,冷白顶灯别开,省得纸面和鸟都受刺激。", - "[2026-04-09 20:07] Mai:我把白耳鸮闯入时的桌面恢复顺序写进说明里了,谁值班都照着做。", - "[2026-04-09 20:36] 许窈:象牙描图纸、琥珀夹子、黄铜镇纸都在固定位置,没有再乱。", - "[2026-04-09 21:03] Mai:这波可以收尾了,绘图室天窗边长桌的固定摆放和应急顺序都不再改。" - ], - "expected_memory_targets": [ - "白耳鸮", - "象牙描图纸", - "琥珀夹子", - "黄铜镇纸", - "天窗" - ] - }, - { - "stream_id": "runtime_dessert_negative_april_11", - "topic": "甜点闲聊长流负样本", - "trigger_mode": "time_threshold", - "elapsed_since_last_check_hours": 8.6, - "bot_participated": false, - "expected_check_outcome": "should_trigger_topic_check_but_be_discarded_without_bot_message", - "expected_next_stage": "topic_cache_should_remain_empty", - "message_count": 21, - "participants": [ - "许窈", - "顾澄", - "迟雨", - "沈砚" - ], - "start_time": 1775884080.0, - "end_time": 1775915160.0, - "messages": [ - "[2026-04-11 13:08] 许窈:中午那盒荔枝冻居然还剩两块,桂花糖浆味道比昨天重,盒底那层糖水也比我记得更黏一些。", - "[2026-04-11 13:22] 顾澄:我还是更想吃焦糖海盐脆片,起码不会晃一下就碎成汤。", - "[2026-04-11 13:37] 迟雨:乌龙奶酥闻起来很香,但吃两口就太腻,百香果软糖反而更合适。", - "[2026-04-11 13:55] 沈砚:软糖太黏了,我宁愿吃柚子脆片或者原味苏打。", - "[2026-04-11 14:11] 许窈:荔枝冻如果不冰透其实不错,就是盒子一开就会流糖水。", - "[2026-04-11 14:28] 顾澄:焦糖海盐和烤糯米片如果放一起,应该会比奶酥耐吃,至少不会一拆包装就满桌掉屑。", - "[2026-04-11 14:46] 迟雨:百香果软糖最好选酸一点的,不然甜度太直白。", - "[2026-04-11 15:02] 沈砚:乌龙奶酥掉渣太夸张,晚上值班根本不适合拿着吃。", - "[2026-04-11 15:19] 许窈:那下次我换成荔枝冻和海盐脆片,至少不用到处拍碎屑。", - "[2026-04-11 15:37] 顾澄:再加一包柠檬薄片吧,甜口之间吃一点会清爽很多。", - "[2026-04-11 15:56] 迟雨:如果有酸梅软糖我也可以,和百香果轮着吃不会太腻。", - "[2026-04-11 16:14] 沈砚:苏打饼和软糖混着吃也行,主要是别太黏手,不然摸完包装还得再找纸擦。", - "[2026-04-11 16:33] 许窈:荔枝冻最好还是用浅盒,不然挖到后面全是糖浆。", - "[2026-04-11 16:57] 顾澄:海盐脆片如果换成细砂糖版应该会更稳一点。", - "[2026-04-11 17:21] 迟雨:百香果软糖配温水挺好,但跟奶酥一起就太重口了。", - "[2026-04-11 17:49] 沈砚:我还是投原味苏打一票,夜里拿着方便。", - "[2026-04-11 18:12] 许窈:那下轮夜宵我就不带太黏的点心了,省得桌上全是糖粉。", - "[2026-04-11 18:43] 顾澄:焦糖海盐脆片和柠檬薄片暂时领先,看起来大家还是更偏爱不容易腻口的那种。", - "[2026-04-11 19:18] 迟雨:百香果软糖至少还在前三,不算完全出局。", - "[2026-04-11 20:06] 沈砚:乌龙奶酥还是留到白天吧,晚上收拾起来太麻烦。", - "[2026-04-11 21:46] 许窈:行,那这轮夜宵就先定海盐脆片、柠檬薄片和百香果软糖,奶酥和荔枝冻留到白天再慢慢吃。" - ], - "expected_memory_targets": [ - "荔枝冻", - "焦糖海盐脆片", - "乌龙奶酥", - "百香果软糖" - ] - } - ], - "chat_history_records": [ - { - "record_id": 930001, - "theme": "北塔夹层药箱补位", - "summary": "群里重新确认北塔夹层药箱固定放铜牌备用钥匙、银色保温毯、薄荷膏和黑色丁腈手套,临时取用后必须在群里报备;沈砚再次提到值夜时怕冷白荧光灯直照,会把小风扇调到三档。", - "participants": [ - "Mai", - "沈砚", - "贺岚", - "迟雨", - "顾澄" - ], - "start_time": 1774095120.0, - "end_time": 1774098180.0, - "original_text": "这段北塔夹层药箱的原始聊天记录围绕夜班抽屉补位展开。沈砚先从冷白荧光灯晃眼、自己值夜时会把小风扇调到三档说起,顺手发现铜牌备用钥匙还在,但银色保温毯和薄荷膏的位置已经乱了。贺岚补充黑色丁腈手套在演练后没补齐,顾澄提到旧清单已经与现实摆放不完全一致。Mai 把公用物资重新收束为铜牌备用钥匙、银色保温毯、薄荷膏和黑色丁腈手套四样,要求任何临时取用都在群里补报用途与归还时间,并且明确个人饮料与私人物件不进入抽屉。大家随后又讨论保温毯折法、钥匙居中、手套补回、拍照留档和夜间可见性,让整段群聊既有稳定事实,也有具体流程决议。" - }, - { - "record_id": 930002, - "theme": "雨棚短波电台返潮排查", - "summary": "雨棚那台短波电台返潮后,群里确认灰色防潮箱、黑色盘线和硅胶包的处理顺序需要重排:先擦干机身、松开盘线、替换硅胶包,再按新顺序归箱。", - "participants": [ - "Mai", - "顾澄", - "贺岚", - "迟雨", - "沈砚" - ], - "start_time": 1774275240.0, - "end_time": 1774278360.0, - "original_text": "这段设备维护聊天从短波电台的潮噪开始,但真正讨论的核心很快扩展到灰色防潮箱、黑色盘线、备用听筒、吸水毡和硅胶包如何重新归位。顾澄一开始以为是电台本体故障,贺岚判断更像是防潮箱内环境失效,迟雨进一步指出箱底冷凝水和发软的硅胶包才是主因。Mai 将整个排查流程拆成擦干机身、摊开盘线、检查吸水毡、整包更换硅胶包、最后再按新顺序回箱几步。沈砚还补充了反光、冷风和合盖时机等环境细节。整段记录兼有设备名称、存放规则和操作顺序,能很好地区分“只是提到短波电台”和“真正形成可检索的返潮处理规则”。" - }, - { - "record_id": 930003, - "theme": "晨雾采样蓝盖保温箱排序", - "summary": "晨雾采样结束后,群里把蓝盖保温箱的新布局固定为冷敷袋最下层、编号试剂架居中、玻璃记号笔与封口贴放右槽,样本管最后扣入,避免交接时拿错顺序。", - "participants": [ - "Mai", - "迟雨", - "顾澄", - "贺岚", - "沈砚" - ], - "start_time": 1774475280.0, - "end_time": 1774478520.0, - "original_text": "这段凌晨交接聊天聚焦蓝盖保温箱的内部顺序。大家从冷敷袋压住编号试剂架、玻璃记号笔滑到底层、样本管外壁水汽太重这些小问题谈起,最后把冷敷袋、编号试剂架、玻璃记号笔、封口贴和样本管的排布重新钉死。Mai 明确禁止谁忙就谁随手塞回去的旧习惯,迟雨把位次关系写到箱盖内侧并准备拍照存档,顾澄和贺岚则分别补充了封口贴、吸水纸和标签保护的细节。沈砚提到冷白灯反光会影响编号辨认,这又为后续人物画像提供了背景噪声。整段记录很长,而且既像物资整理又像样本交接,适合检验系统是否能从高重叠词背景里抓住真正的主结论。" - }, - { - "record_id": 930004, - "theme": "西廊风口观测准备", - "summary": "群里把西廊风口观测前准备固定成三步:先校准气压计,再换湿度夹板新纸,最后确认风向丝带、备用夹子和温梨汤的位置;迟雨负责开场前复核气压计。", - "participants": [ - "Mai", - "迟雨", - "沈砚", - "许窈", - "贺岚" - ], - "start_time": 1774779720.0, - "end_time": 1774783080.0, - "original_text": "西廊风口这一段本身并不短,里面混杂着观测流程、环境噪声和人物习惯。迟雨一直在说气压计校准与湿度夹板换纸,许窈在安顿温梨汤和保温壶,贺岚在整理风向丝带、备用夹子和细线,而沈砚则不断从灯光太刺眼、小风扇可能扰动读数这些角度插话。Mai 最终把真正需要记住的主流程收束成三步,并提醒公用桌上不要混入私人物件。由于这一段既有流程结论,也有大量背景描述,它特别适合验证 profile 是否还能稳定抓住迟雨和沈砚各自的稳定事实,而不是被场景语句淹没。" - }, - { - "record_id": 930005, - "theme": "绘图室天窗白耳鸮闯入", - "summary": "白耳鸮闯进绘图室后,群里确认应急顺序是先稳住象牙描图纸,再关天窗,最后把琥珀夹子与黄铜镇纸按原位归回;绘图室天窗边长桌的固定摆放规则保持不变。", - "participants": [ - "Mai", - "许窈", - "顾澄", - "贺岚", - "沈砚" - ], - "start_time": 1775133060.0, - "end_time": 1775136540.0, - "original_text": "这段聊天从白耳鸮扑进绘图室开始,话题里先后出现天窗缝、折梯、象牙描图纸、琥珀夹子、黄铜镇纸、玻璃尺、灯光角度和风扇停开等多个细节。许窈和顾澄一开始更关心压纸和找夹子,贺岚负责天窗和折梯,沈砚提醒冷白灯会刺激鸟并且让纸面反光,Mai 则把整件事稳定成先稳纸、再关窗、最后恢复桌面固定摆放的规则。因为里面既有明显的事件经过,又有不少可能误导检索的场景词,所以很适合当作自然语言 episode query 的压力样本。" - } - ], - "person_writebacks": [ - { - "person_id": "71aa50b4f4f34bdbb1a7a1c1e56c9011", - "person_name": "沈砚", - "memory_content": "沈砚值夜时不喜欢冷白荧光灯直照,通常把小风扇固定在三档,并且会自带盐渍梅子气泡水。", - "expected_keywords": [ - "沈砚", - "冷白荧光灯", - "小风扇", - "三档", - "盐渍梅子气泡水" - ] - }, - { - "person_id": "82bb79d4680849b5ac7e23a3f6f09122", - "person_name": "迟雨", - "memory_content": "迟雨每次西廊观测前都会先校准气压计并核对湿度夹板,而且只喝温梨汤。", - "expected_keywords": [ - "迟雨", - "气压计", - "湿度夹板", - "校准", - "温梨汤" - ] - }, - { - "person_id": "93cb73d41251472a8d229f1202df9333", - "person_name": "贺岚", - "memory_content": "贺岚平时看管北塔夹层药箱和铜牌备用钥匙,取用后会把黑色丁腈手套和银色保温毯补回原位。", - "expected_keywords": [ - "贺岚", - "北塔夹层药箱", - "铜牌备用钥匙", - "黑色丁腈手套", - "银色保温毯" - ] - } - ], - "search_cases": [ - { - "query": "北塔夹层 铜牌备用钥匙 银色保温毯 薄荷膏", - "expected_keywords": [ - "北塔夹层", - "铜牌备用钥匙", - "银色保温毯", - "薄荷膏" - ], - "minimum_keyword_hits": 2 - }, - { - "query": "短波电台 灰色防潮箱 黑色盘线 硅胶包", - "expected_keywords": [ - "短波电台", - "灰色防潮箱", - "黑色盘线", - "硅胶包" - ], - "minimum_keyword_hits": 2 - }, - { - "query": "蓝盖保温箱 编号试剂架 冷敷袋 玻璃记号笔", - "expected_keywords": [ - "蓝盖保温箱", - "编号试剂架", - "冷敷袋", - "玻璃记号笔" - ], - "minimum_keyword_hits": 2 - }, - { - "query": "白耳鸮 琥珀夹子 象牙描图纸 黄铜镇纸", - "expected_keywords": [ - "白耳鸮", - "琥珀夹子", - "象牙描图纸", - "黄铜镇纸" - ], - "minimum_keyword_hits": 2 - } - ], - "time_cases": [ - { - "query": "铜牌钥匙 保温毯", - "time_expression": "2026/03/21", - "expected_keywords": [ - "铜牌备用钥匙", - "银色保温毯", - "薄荷膏" - ] - }, - { - "query": "气压计 梨汤", - "time_expression": "2026/03/29", - "expected_keywords": [ - "气压计", - "温梨汤", - "西廊风口" - ] - }, - { - "query": "白耳鸮 描图纸", - "time_expression": "2026/04/02", - "expected_keywords": [ - "白耳鸮", - "象牙描图纸", - "琥珀夹子" - ] - } - ], - "episode_cases": [ - { - "query": "那回半夜说北塔夹层抽屉里只剩铜牌钥匙,还得把银色保温毯和薄荷膏补回去的那整段经过", - "expected_keywords": [ - "北塔夹层", - "铜牌备用钥匙", - "银色保温毯", - "薄荷膏" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "雨棚底下那回先把电台擦干,又讨论灰色防潮箱和黑色盘线怎么归位的来龙去脉", - "expected_keywords": [ - "短波电台", - "灰色防潮箱", - "黑色盘线", - "硅胶包" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "黎明前重新排蓝盖保温箱、编号试剂架和冷敷袋顺序的那次交接", - "expected_keywords": [ - "蓝盖保温箱", - "编号试剂架", - "冷敷袋", - "玻璃记号笔" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "天窗边那只白耳鸮冲进来,把琥珀夹子和象牙描图纸搅乱之后大家怎么收拾的", - "expected_keywords": [ - "白耳鸮", - "琥珀夹子", - "象牙描图纸", - "黄铜镇纸" - ], - "minimum_keyword_recall": 0.75 - } - ], - "knowledge_fetcher_cases": [ - { - "query": "群里后来把北塔夹层药箱里固定放哪些东西?", - "expected_keywords": [ - "北塔夹层药箱", - "铜牌备用钥匙", - "银色保温毯", - "薄荷膏" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "谁每次观测前都会先校准气压计,而且只喝温梨汤?", - "expected_keywords": [ - "迟雨", - "气压计", - "校准", - "温梨汤" - ], - "minimum_keyword_recall": 0.75 - }, - { - "query": "那次白耳鸮闯进绘图室后,大家把哪些东西重新压好固定?", - "expected_keywords": [ - "白耳鸮", - "琥珀夹子", - "象牙描图纸", - "黄铜镇纸" - ], - "minimum_keyword_recall": 0.75 - } - ], - "profile_cases": [ - { - "person_id": "71aa50b4f4f34bdbb1a7a1c1e56c9011", - "expected_keywords": [ - "沈砚", - "冷白荧光灯", - "三档", - "盐渍梅子气泡水" - ], - "minimum_keyword_recall": 0.75 - }, - { - "person_id": "82bb79d4680849b5ac7e23a3f6f09122", - "expected_keywords": [ - "迟雨", - "气压计", - "湿度夹板", - "温梨汤" - ], - "minimum_keyword_recall": 0.75 - }, - { - "person_id": "93cb73d41251472a8d229f1202df9333", - "expected_keywords": [ - "贺岚", - "北塔夹层药箱", - "铜牌备用钥匙", - "黑色丁腈手套" - ], - "minimum_keyword_recall": 0.75 - } - ], - "negative_control_cases": [ - { - "query": "焦糖海盐脆片 荔枝冻 乌龙奶酥 百香果软糖", - "source_batch_id": "dessert_idle_april_04_negative", - "expected_behavior": "should_return_no_hits_if_only_positive_batches_are_ingested", - "reason": "当前设计要求没有 Bot 发言的群聊批次不应进入长期记忆总结主路径;这条负样本还故意拉长并保留丰富词面,避免仅靠“太短”通过。" - } - ] -} diff --git a/pytests/A_memorix_test/data/benchmarks/long_novel_memory_benchmark.json b/pytests/A_memorix_test/data/benchmarks/long_novel_memory_benchmark.json deleted file mode 100644 index 524ebf1c..00000000 --- a/pytests/A_memorix_test/data/benchmarks/long_novel_memory_benchmark.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "meta": { - "scenario_id": "long_novel_memory_benchmark_v1", - "description": "长篇叙事检索基准,围绕北塔木梯、蓝漆铁盒与海潮图展开,验证导入、摘要写回、人物写回、episode 与工具检索链路。", - "designed_for": "A_memorix long novel benchmark", - "quantitative_targets": { - "search_accuracy_at_1": 0.35, - "search_recall_at_5": 0.6, - "search_keyword_recall_at_5": 0.8, - "writeback_success_rate": 0.66, - "knowledge_fetcher_success_rate": 0.66, - "profile_success_rate": 0.66, - "tool_modes_success_rate": 0.75 - } - }, - "session": { - "session_id": "private:novel:beita", - "platform": "qq", - "user_id": "10001", - "group_id": "", - "display_name": "北塔潮痕" - }, - "import_payload": { - "paragraphs": [ - { - "content": "北塔木梯第四阶后面的夹层里藏着一个蓝漆铁盒,铁盒里压着卷起的海潮图,图角写着旧码头会在大潮夜开启暗门。", - "source": "fixture:long_novel_memory_benchmark", - "knowledge_type": "narrative", - "entities": ["北塔木梯", "蓝漆铁盒", "海潮图", "旧码头", "暗门"] - }, - { - "content": "沈砚秋每次登北塔前都会先用银壳指南针校正方向,再把海潮图折成三折塞回蓝漆铁盒;她提醒顾回,真正藏铁盒的位置不是第七阶,而是北塔木梯第四阶。", - "source": "fixture:long_novel_memory_benchmark", - "knowledge_type": "narrative", - "entities": ["沈砚秋", "银壳指南针", "海潮图", "蓝漆铁盒", "顾回", "北塔木梯第四阶"] - }, - { - "content": "顾回在北塔木梯旁的潮痕墙上看见一幅褪色海潮图摹本,他据此确认蓝漆铁盒里的红线标记指向旧码头东侧的石闸门。", - "source": "fixture:long_novel_memory_benchmark", - "knowledge_type": "narrative", - "entities": ["顾回", "北塔木梯", "海潮图", "蓝漆铁盒", "旧码头", "石闸门"] - }, - { - "content": "他们后来把蓝漆铁盒重新藏回北塔木梯第四阶后面,只留下海潮图的摹本带去旧码头,避免有人提前发现暗门的位置。", - "source": "fixture:long_novel_memory_benchmark", - "knowledge_type": "narrative", - "entities": ["蓝漆铁盒", "北塔木梯第四阶", "海潮图", "旧码头", "暗门"] - } - ], - "relations": [ - { - "subject": "蓝漆铁盒", - "predicate": "藏在", - "object": "北塔木梯第四阶后面" - }, - { - "subject": "蓝漆铁盒", - "predicate": "装有", - "object": "海潮图" - }, - { - "subject": "海潮图", - "predicate": "指向", - "object": "旧码头东侧石闸门" - }, - { - "subject": "沈砚秋", - "predicate": "使用", - "object": "银壳指南针" - } - ] - }, - "chat_history_records": [ - { - "record_id": 930001, - "theme": "北塔木梯与蓝漆铁盒", - "summary": "沈砚秋和顾回确认蓝漆铁盒一直藏在北塔木梯第四阶后面,铁盒里压着海潮图,海潮图上的红线指向旧码头东侧石闸门;他们最终把铁盒放回原位,只带走摹本继续查暗门。", - "participants": ["Mai", "沈砚秋", "顾回"], - "start_time": 1775041200.0, - "end_time": 1775043600.0, - "original_text": "[2026-04-01 18:00] 沈砚秋:我又去摸了一遍北塔木梯,蓝漆铁盒还在第四阶后面的夹层里。\n[2026-04-01 18:02] 顾回:里面那张海潮图的红线还是指着旧码头东侧石闸门,看来暗门位置没有变。\n[2026-04-01 18:04] Mai:你们把铁盒放回去了吗?\n[2026-04-01 18:05] 沈砚秋:放回去了,只带了海潮图摹本,免得别人发现第四阶的夹层。\n[2026-04-01 18:07] 顾回:银壳指南针也重新校过了,等夜潮退下去我们再去旧码头。 " - } - ], - "person_writebacks": [ - { - "person_id": "person-shen-yanqiu", - "person_name": "沈砚秋", - "memory_content": "沈砚秋习惯用银壳指南针校正方向,确认北塔木梯第四阶后的蓝漆铁盒和海潮图位置后才继续行动。", - "expected_keywords": ["沈砚秋", "银壳指南针", "蓝漆铁盒", "海潮图"] - } - ], - "search_cases": [ - { - "query": "蓝漆铁盒 北塔木梯 海潮图 在哪里", - "expected_keywords": ["蓝漆铁盒", "北塔木梯", "海潮图"], - "minimum_keyword_hits": 2 - } - ], - "knowledge_fetcher_cases": [ - { - "query": "他们后来是在什么位置找到蓝漆铁盒和海潮图的?", - "expected_keywords": ["蓝漆铁盒", "北塔木梯第四阶", "海潮图"], - "minimum_keyword_recall": 0.67 - } - ], - "profile_cases": [ - { - "person_id": "person-shen-yanqiu", - "expected_keywords": ["沈砚秋", "银壳指南针", "海潮图"], - "minimum_keyword_recall": 0.67 - } - ], - "episode_cases": [ - { - "query": "那次他们在北塔木梯寻找蓝漆铁盒和海潮图的经过", - "expected_keywords": ["蓝漆铁盒", "北塔木梯第四阶", "海潮图"], - "minimum_keyword_recall": 0.67 - } - ] -} diff --git a/pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json b/pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json deleted file mode 100644 index 9a396c8a..00000000 --- a/pytests/A_memorix_test/data/real_dialogues/private_alice_weekend.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "meta": { - "scenario_id": "private_alice_weekend", - "description": "私聊周末回忆场景,用于验证导入/检索/画像/episode 全链路" - }, - "session": { - "session_id": "qq_private_10001", - "platform": "qq", - "user_id": "10001", - "group_id": "", - "display_name": "小爱" - }, - "import_payload": { - "paragraphs": [ - { - "content": "周末我们在北塔木梯旁找到一个蓝漆铁盒,盒里夹着海潮图。", - "source": "fixture:private_alice_weekend", - "knowledge_type": "narrative", - "entities": [ - "小爱", - "蓝漆铁盒", - "北塔木梯", - "海潮图" - ] - }, - { - "content": "小爱说蓝漆铁盒是她外公留下的,她会把海潮图带去修复。", - "source": "fixture:private_alice_weekend", - "knowledge_type": "factual", - "entities": [ - "小爱", - "蓝漆铁盒", - "海潮图" - ] - } - ], - "relations": [ - { - "subject": "小爱", - "predicate": "发现", - "object": "蓝漆铁盒" - }, - { - "subject": "蓝漆铁盒", - "predicate": "内含", - "object": "海潮图" - }, - { - "subject": "小爱", - "predicate": "提到地点", - "object": "北塔木梯" - } - ] - }, - "chat_history_record": { - "record_id": 900001, - "theme": "周末北塔发现", - "summary": "小爱在北塔木梯旁找到蓝漆铁盒,铁盒里有海潮图,并计划修复。", - "participants": [ - "小爱", - "Mai" - ], - "start_time": 1732903200.0, - "end_time": 1732904100.0, - "original_text": "周六傍晚,小爱说她在北塔木梯边发现了一个蓝漆铁盒,里面夹着海潮图,想下周拿去修复。" - }, - "person": { - "person_id": "7ee9e14d602520af84e57b8665f8e14d", - "person_name": "小爱" - }, - "person_fact": { - "memory_content": "小爱熟悉北塔木梯路线,知道蓝漆铁盒里夹着海潮图。" - }, - "search_queries": { - "direct": "蓝漆铁盒 北塔木梯 海潮图", - "knowledge_fetcher": "小爱周末提到的铁盒和地图是什么" - }, - "expectations": { - "search_keywords": [ - "蓝漆铁盒", - "北塔木梯", - "海潮图" - ], - "profile_keywords": [ - "小爱", - "蓝漆铁盒" - ], - "episode_source": "chat_summary:qq_private_10001" - } -} diff --git a/pytests/test_napcat_adapter_sdk.py b/pytests/test_napcat_adapter_sdk.py index 40c9aa4b..f53408b9 100644 --- a/pytests/test_napcat_adapter_sdk.py +++ b/pytests/test_napcat_adapter_sdk.py @@ -12,8 +12,10 @@ import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] PLUGINS_ROOT = PROJECT_ROOT / "plugins" +PLUGIN_TEMPLATE_ROOT = PROJECT_ROOT / "plugin-templates" SDK_ROOT = PROJECT_ROOT / "packages" / "maibot-plugin-sdk" NAPCAT_PLUGIN_DIR = PLUGINS_ROOT / "MaiBot-Napcat-Adapter" +NAPCAT_TEMPLATE_DIR = PLUGIN_TEMPLATE_ROOT / "MaiBot-Napcat-Adapter" NAPCAT_TEST_MODULE = "_test_napcat_adapter" for import_path in (str(SDK_ROOT),): @@ -204,12 +206,14 @@ def _load_napcat_sdk_modules() -> Tuple[Any, Any, Any, Any]: 依次返回常量模块、配置模块、插件模块和运行时状态模块。 """ + plugin_dir = NAPCAT_PLUGIN_DIR if NAPCAT_PLUGIN_DIR.is_dir() else NAPCAT_TEMPLATE_DIR + if NAPCAT_TEST_MODULE not in sys.modules: - plugin_path = NAPCAT_PLUGIN_DIR / "plugin.py" + plugin_path = plugin_dir / "plugin.py" spec = util.spec_from_file_location( NAPCAT_TEST_MODULE, plugin_path, - submodule_search_locations=[str(NAPCAT_PLUGIN_DIR)], + submodule_search_locations=[str(plugin_dir)], ) if spec is None or spec.loader is None: raise ImportError(f"无法为 NapCat 插件创建模块规格: {plugin_path}") diff --git a/pytests/test_napcat_history_recovery.py b/pytests/test_napcat_history_recovery.py new file mode 100644 index 00000000..30bc58f5 --- /dev/null +++ b/pytests/test_napcat_history_recovery.py @@ -0,0 +1,376 @@ +"""NapCat 历史补拉与恢复状态测试。""" + +from __future__ import annotations + +from importlib import import_module, util +from pathlib import Path +from typing import Any, Dict, List + +import logging +import sys +from types import SimpleNamespace + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PLUGINS_ROOT = PROJECT_ROOT / "plugins" +PLUGIN_TEMPLATE_ROOT = PROJECT_ROOT / "plugin-templates" +SDK_ROOT = PROJECT_ROOT / "packages" / "maibot-plugin-sdk" +NAPCAT_PLUGIN_DIR = PLUGINS_ROOT / "MaiBot-Napcat-Adapter" +NAPCAT_TEMPLATE_DIR = PLUGIN_TEMPLATE_ROOT / "MaiBot-Napcat-Adapter" +NAPCAT_TEST_MODULE = "_test_napcat_adapter_history_recovery" + +for import_path in (str(SDK_ROOT),): + if import_path not in sys.path: + sys.path.insert(0, import_path) + + +class _FakeGatewayCapability: + """用于测试入站注入的网关替身。""" + + def __init__(self) -> None: + """初始化测试替身。""" + + self.calls: List[Dict[str, Any]] = [] + + async def route_message( + self, + gateway_name: str, + message: Dict[str, Any], + *, + route_metadata: Dict[str, Any] | None = None, + external_message_id: str = "", + dedupe_key: str = "", + ) -> bool: + """记录入站注入请求并始终模拟成功。""" + + self.calls.append( + { + "gateway_name": gateway_name, + "message": dict(message), + "route_metadata": dict(route_metadata or {}), + "external_message_id": external_message_id, + "dedupe_key": dedupe_key, + } + ) + return True + + +def _resolve_napcat_plugin_dir() -> Path: + """返回当前测试可用的 NapCat 插件目录。""" + + if NAPCAT_PLUGIN_DIR.is_dir(): + return NAPCAT_PLUGIN_DIR + return NAPCAT_TEMPLATE_DIR + + +def _load_napcat_module(module_suffix: str) -> Any: + """动态加载 NapCat 测试模块。""" + + plugin_dir = _resolve_napcat_plugin_dir() + if NAPCAT_TEST_MODULE not in sys.modules: + plugin_path = plugin_dir / "plugin.py" + spec = util.spec_from_file_location( + NAPCAT_TEST_MODULE, + plugin_path, + submodule_search_locations=[str(plugin_dir)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"无法为 NapCat 插件创建模块规格: {plugin_path}") + + module = util.module_from_spec(spec) + sys.modules[NAPCAT_TEST_MODULE] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(NAPCAT_TEST_MODULE, None) + raise + + return import_module(f"{NAPCAT_TEST_MODULE}.{module_suffix}") + + +def _load_history_recovery_store_cls() -> Any: + """动态加载历史恢复状态仓库类。""" + + return _load_napcat_module("services.history_recovery_store").NapCatHistoryRecoveryStore + + +def _load_query_service_cls() -> Any: + """动态加载查询服务类。""" + + return _load_napcat_module("services.query_service").NapCatQueryService + + +def _load_router_cls() -> Any: + """动态加载事件路由器类。""" + + return _load_napcat_module("runtime.router").NapCatEventRouter + + +class _FakeActionService: + """用于查询服务的动作服务替身。""" + + def __init__(self, response_data: Any) -> None: + """初始化动作服务替身。""" + + self._response_data = response_data + self.action_data_calls: List[Dict[str, Any]] = [] + + async def safe_call_action_data(self, action_name: str, params: Dict[str, Any]) -> Any: + """记录安全查询动作。""" + + self.action_data_calls.append({"action_name": action_name, "params": dict(params)}) + return self._response_data + + +@pytest.mark.asyncio +async def test_history_recovery_store_persists_checkpoint_and_seen_state(tmp_path: Path) -> None: + """历史恢复状态仓库应持久化 checkpoint 与已补拉标记。""" + + store_cls = _load_history_recovery_store_cls() + store = store_cls( + logger=logging.getLogger("test.napcat.history_store"), + storage_path=tmp_path / "history.sqlite3", + ) + + await store.load() + await store.record_checkpoint( + account_id="10001", + scope="primary", + chat_type="group", + chat_id="20001", + message_id="msg-2", + message_time=200.0, + message_seq=2, + ) + await store.record_checkpoint( + account_id="10001", + scope="primary", + chat_type="group", + chat_id="20001", + message_id="msg-1", + message_time=100.0, + message_seq=1, + ) + await store.mark_recovered_message_seen( + account_id="10001", + scope="primary", + chat_type="group", + chat_id="20001", + external_message_id="history-1", + ) + + checkpoints = await store.list_checkpoints("10001", scope="primary") + + assert len(checkpoints) == 1 + assert checkpoints[0].last_message_id == "msg-2" + assert checkpoints[0].last_message_seq == 2 + assert ( + await store.has_recovered_message_seen( + account_id="10001", + scope="primary", + chat_type="group", + chat_id="20001", + external_message_id="history-1", + ) + is True + ) + + +@pytest.mark.asyncio +async def test_query_service_wraps_group_and_friend_history_actions() -> None: + """查询服务应按官方动作名封装历史消息接口。""" + + query_service_cls = _load_query_service_cls() + action_service = _FakeActionService([{"message_id": "msg-1"}]) + query_service = query_service_cls( + action_service=action_service, + logger=logging.getLogger("test.napcat.history_query"), + ) + + group_payload = await query_service.get_group_message_history("20001", message_seq=123, count=10) + friend_payload = await query_service.get_friend_message_history("30001", count=5, reverse_order=True) + + assert group_payload == [{"message_id": "msg-1"}] + assert friend_payload == [{"message_id": "msg-1"}] + assert action_service.action_data_calls == [ + { + "action_name": "get_group_msg_history", + "params": {"group_id": "20001", "count": 10, "reverse_order": False, "message_seq": 123}, + }, + { + "action_name": "get_friend_msg_history", + "params": {"user_id": "30001", "count": 5, "reverse_order": True}, + }, + ] + + +@pytest.mark.asyncio +async def test_router_recover_recent_history_reinjects_messages_in_order(tmp_path: Path) -> None: + """重连补拉应按时间顺序将历史消息重新注入原入站路径。""" + + history_store_cls = _load_history_recovery_store_cls() + router_cls = _load_router_cls() + gateway_capability = _FakeGatewayCapability() + router = router_cls( + gateway_capability=gateway_capability, + logger=logging.getLogger("test.napcat.history_router"), + gateway_name="napcat_gateway", + load_settings=lambda: SimpleNamespace( + napcat_server=SimpleNamespace(connection_id="primary", heartbeat_interval=30.0), + filters=SimpleNamespace(ignore_self_message=True), + chat=SimpleNamespace(ban_qq_bot=False), + ), + ) + + history_store = history_store_cls( + logger=logging.getLogger("test.napcat.history_router.store"), + storage_path=tmp_path / "router.sqlite3", + ) + await history_store.load() + await history_store.record_checkpoint( + account_id="10001", + scope="primary", + chat_type="group", + chat_id="20001", + message_id="msg-1", + message_time=100.0, + message_seq=10, + ) + + history_calls: List[Dict[str, Any]] = [] + history_payloads = [ + { + "post_type": "message", + "message_type": "group", + "self_id": "10001", + "group_id": "20001", + "user_id": "30002", + "message_id": "msg-3", + "message_seq": 12, + "time": 102, + "message": [{"type": "text", "data": {"text": "第三条"}}], + "sender": {"user_id": "30002", "nickname": "用户二"}, + }, + { + "post_type": "message", + "message_type": "group", + "self_id": "10001", + "group_id": "20001", + "user_id": "30001", + "message_id": "msg-2", + "message_seq": 11, + "time": 101, + "message": [{"type": "text", "data": {"text": "第二条"}}], + "sender": {"user_id": "30001", "nickname": "用户一"}, + }, + ] + + class _FakeQueryService: + async def get_group_message_history( + self, + group_id: str, + *, + message_seq: int | None = None, + count: int = 20, + reverse_order: bool = False, + ) -> List[Dict[str, Any]]: + history_calls.append( + { + "group_id": group_id, + "message_seq": message_seq, + "count": count, + "reverse_order": reverse_order, + } + ) + return list(history_payloads) + + async def get_friend_message_history(self, user_id: str, **kwargs: Any) -> List[Dict[str, Any]]: + del user_id + del kwargs + return [] + + class _FakeInboundCodec: + @staticmethod + async def build_message_dict( + payload: Dict[str, Any], + self_id: str, + sender_user_id: str, + sender: Dict[str, Any], + ) -> Dict[str, Any]: + del self_id + del sender_user_id + del sender + return { + "message_id": str(payload["message_id"]), + "platform": "qq", + "timestamp": str(float(payload["time"])), + "message_info": { + "user_info": {"user_id": str(payload["user_id"]), "user_nickname": "测试用户"}, + "group_info": {"group_id": str(payload["group_id"]), "group_name": "测试群"}, + "additional_config": {}, + }, + "raw_message": [{"type": "text", "data": str(payload["message"][0]["data"]["text"])}], + "processed_plain_text": str(payload["message"][0]["data"]["text"]), + "display_message": str(payload["message"][0]["data"]["text"]), + "is_mentioned": False, + "is_at": False, + "is_emoji": False, + "is_picture": False, + "is_command": False, + "is_notify": False, + "session_id": "", + } + + router.bind_runtime( + SimpleNamespace( + runtime_state=SimpleNamespace(report_connected=lambda *args, **kwargs: _noop_async(), report_disconnected=_noop_async), + chat_filter=SimpleNamespace(is_inbound_chat_allowed=lambda *args, **kwargs: True), + official_bot_guard=SimpleNamespace( + should_reject=lambda *args, **kwargs: _return_false_async(), + clear_cache=lambda: None, + ), + inbound_codec=_FakeInboundCodec(), + history_recovery_store=history_store, + query_service=_FakeQueryService(), + heartbeat_monitor=SimpleNamespace(start=_noop_async, stop=_noop_async), + ban_tracker=SimpleNamespace(start=_noop_async, stop=_noop_async, record_notice=_noop_async), + notice_codec=SimpleNamespace(handle_meta_event=_noop_async, build_notice_message_dict=_return_none_async), + ) + ) + + await router._recover_recent_history(self_id="10001", scope="primary") + + assert history_calls == [ + { + "group_id": "20001", + "message_seq": 10, + "count": 20, + "reverse_order": False, + } + ] + assert [call["external_message_id"] for call in gateway_capability.calls] == ["msg-2", "msg-3"] + assert [call["message"]["message_id"] for call in gateway_capability.calls] == ["msg-2", "msg-3"] + + +async def _noop_async(*args: Any, **kwargs: Any) -> None: + """无操作异步函数。""" + + del args + del kwargs + + +async def _return_false_async(*args: Any, **kwargs: Any) -> bool: + """返回 ``False`` 的异步测试替身。""" + + del args + del kwargs + return False + + +async def _return_none_async(*args: Any, **kwargs: Any) -> None: + """返回 ``None`` 的异步测试替身。""" + + del args + del kwargs + return None diff --git a/src/A_memorix/.gitattributes b/src/A_memorix/.gitattributes deleted file mode 100644 index dfe07704..00000000 --- a/src/A_memorix/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Auto detect text files and perform LF normalization -* text=auto diff --git a/src/A_memorix/.gitignore b/src/A_memorix/.gitignore deleted file mode 100644 index bb349827..00000000 --- a/src/A_memorix/.gitignore +++ /dev/null @@ -1,245 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Python -__pycache__/ -*.pyc -*.pyo -*.pyd -*.egg-info/ - -# Data & Storage (Privacy & Runtime) -data/ -logs/ - -# Deprecated / Cleanup (Avoid uploading junk) -deprecated/ - -# OS / System -.DS_Store -Thumbs.db -ehthumbs.db - -# IDE settings -.idea/ -.vscode/ - -# Temporary Verification Scripts -verify_*.py -config.toml - -# Test Artifacts & Generated Files -MagicMock/ -benchmark_output.txt -e2e_debug.log -e2e_error.log -full_diff.txt - -# Large Test Data Files -机娘导论-openie.json -scripts/机娘导论-openie.json - -# A_memorix recall/tuning generated artifacts -artifacts/ -scripts/run_arc_light_recall_pipeline.py - -# Compressed Data Archives -data.zip -scripts/full_feature_smoke_test.py -ACL2026_DEMO_EVAL.md -.probe_write -tests/ -temp_verify_v5_data/metadata/metadata.db -sql2/t.db -sql2/t.db-journal -scripts/test.json -scripts/test1.json -scripts/test-sample.json -USAGE_ARCHITECTURE.md -scripts/test_conversion.py -scripts/debug_graph_vis.py -/.tmp_feature_e2e_real -/.tmp_sparse_tests -/.tmp_test_probe -/.tmp_test_sqlite -/.tmp_testdata -/scripts/tmp diff --git a/src/llm_models/model_client/openai_client.py b/src/llm_models/model_client/openai_client.py index db2313ef..a69c22c6 100644 --- a/src/llm_models/model_client/openai_client.py +++ b/src/llm_models/model_client/openai_client.py @@ -12,6 +12,7 @@ from urllib.parse import urlparse from uuid import uuid4 from json_repair import repair_json +import aiohttp from openai import APIConnectionError, APIStatusError, AsyncOpenAI, AsyncStream from openai._types import FileTypes, Omit, omit from openai.types.chat import ( @@ -115,6 +116,14 @@ CHAT_COMPLETIONS_RESERVED_EXTRA_BODY_KEYS = { } """由当前客户端显式承载、不应再落入 `extra_body` 的字段集合。""" +OPENAI_MULTIMODAL_EMBEDDING_MODELS = { + "doubao-embedding-vision-241215", + "doubao-embedding-vision-250328", + "doubao-embedding-vision-250615", + "doubao-embedding-vision-251215", +} +"""需要走多模态 embedding 端点的模型标识。""" + OpenAIStreamResponseHandler = Callable[ [AsyncStream[ChatCompletionChunk], asyncio.Event | None], Coroutine[Any, Any, Tuple[APIResponse, UsageTuple | None]], @@ -763,6 +772,13 @@ def _snapshot_openai_argument(value: Any | Omit) -> Any | None: return value +def _is_multimodal_embedding_model(model_identifier: str) -> bool: + """判断当前 embedding 模型是否需要走多模态端点。""" + + normalized = str(model_identifier or "").strip().lower() + return normalized in OPENAI_MULTIMODAL_EMBEDDING_MODELS + + def _build_api_status_message(error: APIStatusError) -> str: """构建更适合记录和展示的状态错误信息。 @@ -1110,6 +1126,80 @@ class OpenaiClient(AdapterClient[AsyncStream[ChatCompletionChunk], ChatCompletio default_headers=client_config.default_headers or None, default_query=client_config.default_query or None, ) + self._default_headers = dict(client_config.default_headers) + self._default_query = dict(client_config.default_query) + self._base_url = client_config.base_url + + @staticmethod + def _build_multimodal_embedding_input(embedding_input: str) -> List[Dict[str, str]]: + """将纯文本 embedding 输入包装为多模态端点要求的结构。""" + + return [ + { + "type": "text", + "text": str(embedding_input), + } + ] + + async def _execute_multimodal_embedding_request( + self, + *, + model_info: Any, + embedding_input: str, + request_overrides: Any, + snapshot_provider_request: Dict[str, Any], + ) -> Tuple[APIResponse, UsageTuple | None]: + """通过多模态 embedding 端点执行视觉 embedding 模型请求。""" + + payload: Dict[str, Any] = { + "model": model_info.model_identifier, + "input": self._build_multimodal_embedding_input(embedding_input), + } + if request_overrides.extra_body: + payload.update(request_overrides.extra_body) + + snapshot_provider_request["endpoint"] = "/embeddings/multimodal" + snapshot_provider_request["operation"] = "embeddings.multimodal.create" + snapshot_provider_request["request_kwargs"] = { + "extra_body": request_overrides.extra_body or None, + "extra_headers": request_overrides.extra_headers or None, + "extra_query": request_overrides.extra_query or None, + "input": payload["input"], + "model": model_info.model_identifier, + } + + headers = dict(self._default_headers) + headers.update(request_overrides.extra_headers) + if self.client.api_key and "Authorization" not in headers: + headers["Authorization"] = f"Bearer {self.client.api_key}" + headers["Content-Type"] = "application/json" + + params = dict(self._default_query) + params.update(request_overrides.extra_query) + + url = f"{self._base_url}/embeddings/multimodal" + timeout = aiohttp.ClientTimeout(total=self.api_provider.timeout) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, params=params or None, json=payload) as resp: + response_text = await resp.text() + if resp.status >= 400: + raise RespNotOkException(resp.status, response_text) + try: + raw_response = json.loads(response_text) + except json.JSONDecodeError as exc: + raise RespParseException(response_text, "嵌入响应解析失败,返回内容不是合法 JSON。") from exc + + data = raw_response.get("data") + if not isinstance(data, dict): + raise RespParseException(raw_response, "嵌入响应解析失败,缺少 data 字段。") + embedding = data.get("embedding") + if not isinstance(embedding, list): + raise RespParseException(raw_response, "嵌入响应解析失败,缺少 embedding 数据。") + + response = APIResponse() + response.embedding = embedding + usage_record = _extract_usage_record(raw_response.get("usage")) + return response, usage_record def _build_default_stream_response_handler( self, @@ -1364,6 +1454,14 @@ class OpenaiClient(AdapterClient[AsyncStream[ChatCompletionChunk], ChatCompletio try: request_overrides = split_openai_request_overrides(extra_params) + if _is_multimodal_embedding_model(model_info.model_identifier): + return await self._execute_multimodal_embedding_request( + model_info=model_info, + embedding_input=embedding_input, + request_overrides=request_overrides, + snapshot_provider_request=snapshot_provider_request, + ) + snapshot_provider_request["request_kwargs"] = { "extra_body": request_overrides.extra_body or None, "extra_headers": request_overrides.extra_headers or None, diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py deleted file mode 100644 index 0b0692d4..00000000 --- a/src/plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""插件系统包""" diff --git a/src/plugins/built_in/plugin_management/_manifest.json b/src/plugins/built_in/plugin_management/_manifest.json deleted file mode 100644 index a2bfa9ce..00000000 --- a/src/plugins/built_in/plugin_management/_manifest.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "manifest_version": 2, - "version": "2.0.0", - "name": "插件和组件管理 (Plugin and Component Management)", - "description": "通过系统 API 管理插件和组件的生命周期,包括加载、卸载、启用和禁用等操作。", - "author": { - "name": "MaiBot团队", - "url": "https://github.com/MaiM-with-u" - }, - "license": "GPL-v3.0-or-later", - "urls": { - "repository": "https://github.com/MaiM-with-u/maibot", - "homepage": "https://github.com/MaiM-with-u/maibot", - "documentation": "https://github.com/MaiM-with-u/maibot", - "issues": "https://github.com/MaiM-with-u/maibot/issues" - }, - "host_application": { - "min_version": "1.0.0", - "max_version": "1.0.0" - }, - "sdk": { - "min_version": "2.0.0", - "max_version": "2.99.99" - }, - "dependencies": [], - "capabilities": [ - "component.get_all_plugins", - "component.list_loaded_plugins", - "component.list_registered_plugins", - "component.enable", - "component.disable", - "component.load_plugin", - "component.unload_plugin", - "component.reload_plugin", - "send.text", - "config.get" - ], - "i18n": { - "default_locale": "zh-CN", - "locales_path": "_locales", - "supported_locales": [ - "zh-CN" - ] - }, - "id": "builtin.plugin-management" -} diff --git a/src/plugins/built_in/plugin_management/plugin.py b/src/plugins/built_in/plugin_management/plugin.py deleted file mode 100644 index 00e7578c..00000000 --- a/src/plugins/built_in/plugin_management/plugin.py +++ /dev/null @@ -1,298 +0,0 @@ -"""插件和组件管理 — 新 SDK 版本 - -通过 /pm 命令管理插件和组件的生命周期。 -""" - -from maibot_sdk import Command, MaiBotPlugin - - -_VALID_COMPONENT_TYPES = ("tool", "command", "event_handler") - -HELP_ALL = ( - "管理命令帮助\n" - "/pm help 管理命令提示\n" - "/pm plugin 插件管理命令\n" - "/pm component 组件管理命令\n" - "使用 /pm plugin help 或 /pm component help 获取具体帮助" -) -HELP_PLUGIN = ( - "插件管理命令帮助\n" - "/pm plugin help 插件管理命令提示\n" - "/pm plugin list 列出所有注册的插件\n" - "/pm plugin list_enabled 列出所有加载(启用)的插件\n" - "/pm plugin load 加载指定插件\n" - "/pm plugin unload 卸载指定插件\n" - "/pm plugin reload 重新加载指定插件\n" -) -HELP_COMPONENT = ( - "组件管理命令帮助\n" - "/pm component help 组件管理命令提示\n" - "/pm component list 列出所有注册的组件\n" - "/pm component list enabled <可选: type> 列出所有启用的组件\n" - "/pm component list disabled <可选: type> 列出所有禁用的组件\n" - " - 可选项: local,代表当前聊天中的;global,代表全局的\n" - " - 不填时为 global\n" - "/pm component list type 列出已经注册的指定类型的组件\n" - "/pm component enable global 全局启用组件\n" - "/pm component enable local 本聊天启用组件\n" - "/pm component disable global 全局禁用组件\n" - "/pm component disable local 本聊天禁用组件\n" - " - 可选项: tool, command, event_handler\n" -) - - -class PluginManagementPlugin(MaiBotPlugin): - """插件和组件管理插件""" - - async def on_load(self) -> None: - """处理插件加载。""" - - async def on_unload(self) -> None: - """处理插件卸载。""" - - @Command( - "management", - description="管理插件和组件的生命周期", - pattern=r"(?P^/pm(\s[a-zA-Z0-9_]+)*\s*$)", - ) - async def handle_management( - self, stream_id: str = "", user_id: str = "", matched_groups: dict | None = None, **kwargs - ): - """处理 /pm 命令""" - # 权限检查 - permission_result = await self.ctx.config.get("plugin.permission") - permission_list = permission_result if isinstance(permission_result, list) else [] - if str(user_id) not in permission_list: - await self.ctx.send.text("你没有权限使用插件管理命令", stream_id) - return False, "没有权限", True - - if not stream_id: - return False, "无法获取聊天流信息", True - - raw_command = (matched_groups or {}).get("manage_command", "").strip() - parts = raw_command.split(" ") if raw_command else ["/pm"] - n = len(parts) - - # /pm - if n == 1: - await self.ctx.send.text(HELP_ALL, stream_id) - return True, "帮助已发送", True - - # /pm - if n == 2: - sub = parts[1] - if sub == "plugin": - await self.ctx.send.text(HELP_PLUGIN, stream_id) - elif sub == "component": - await self.ctx.send.text(HELP_COMPONENT, stream_id) - elif sub == "help": - await self.ctx.send.text(HELP_ALL, stream_id) - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - return True, "帮助已发送", True - - # /pm plugin / /pm component - if n == 3: - if parts[1] == "plugin": - await self._handle_plugin_3(parts[2], stream_id) - elif parts[1] == "component": - if parts[2] == "list": - await self._list_all_components(stream_id) - elif parts[2] == "help": - await self.ctx.send.text(HELP_COMPONENT, stream_id) - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - return True, "命令执行完成", True - - if n == 4: - if parts[1] == "plugin": - await self._handle_plugin_4(parts[2], parts[3], stream_id) - elif parts[1] == "component": - if parts[2] == "list": - await self._handle_component_list_4(parts[3], stream_id) - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - return True, "命令执行完成", True - - if n == 5: - if parts[1] != "component" or parts[2] != "list": - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - await self._handle_component_list_5(parts[3], parts[4], stream_id) - return True, "命令执行完成", True - - if n == 6: - if parts[1] != "component": - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - await self._handle_component_toggle(parts[2], parts[3], parts[4], parts[5], stream_id) - return True, "命令执行完成", True - - await self.ctx.send.text("插件管理命令不合法", stream_id) - return False, "命令不合法", True - - # ------ plugin 子命令 ------ - - async def _handle_plugin_3(self, action: str, stream_id: str): - match action: - case "help": - await self.ctx.send.text(HELP_PLUGIN, stream_id) - case "list": - result = await self.ctx.component.list_registered_plugins() - plugins = result if isinstance(result, list) else [] - await self.ctx.send.text(f"已注册的插件: {', '.join(plugins) if plugins else '无'}", stream_id) - case "list_enabled": - result = await self.ctx.component.list_loaded_plugins() - plugins = result if isinstance(result, list) else [] - await self.ctx.send.text(f"已加载的插件: {', '.join(plugins) if plugins else '无'}", stream_id) - case _: - await self.ctx.send.text("插件管理命令不合法", stream_id) - - async def _handle_plugin_4(self, action: str, name: str, stream_id: str): - match action: - case "load": - result = await self.ctx.component.load_plugin(name) - ok = result.get("success", False) if isinstance(result, dict) else bool(result) - msg = f"插件加载成功: {name}" if ok else f"插件加载失败: {name}" - await self.ctx.send.text(msg, stream_id) - case "unload": - result = await self.ctx.component.unload_plugin(name) - ok = result.get("success", False) if isinstance(result, dict) else bool(result) - msg = f"插件卸载成功: {name}" if ok else f"插件卸载失败: {name}" - await self.ctx.send.text(msg, stream_id) - case "reload": - result = await self.ctx.component.reload_plugin(name) - ok = result.get("success", False) if isinstance(result, dict) else bool(result) - msg = f"插件重新加载成功: {name}" if ok else f"插件重新加载失败: {name}" - await self.ctx.send.text(msg, stream_id) - case _: - await self.ctx.send.text("插件管理命令不合法", stream_id) - - # ------ component 子命令 ------ - - async def _list_all_components(self, stream_id: str): - result = await self.ctx.component.get_all_plugins() - if not result: - await self.ctx.send.text("没有注册的组件", stream_id) - return - components = self._extract_components(result) - if not components: - await self.ctx.send.text("没有注册的组件", stream_id) - return - text = ", ".join(f"{c['name']} ({c['type']})" for c in components) - await self.ctx.send.text(f"已注册的组件: {text}", stream_id) - - async def _handle_component_list_4(self, sub: str, stream_id: str): - if sub == "enabled": - await self._list_filtered_components("enabled", "global", stream_id) - elif sub == "disabled": - await self._list_filtered_components("disabled", "global", stream_id) - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - - async def _handle_component_list_5(self, sub: str, arg: str, stream_id: str): - if sub in ("enabled", "disabled"): - await self._list_filtered_components(sub, arg, stream_id) - elif sub == "type": - if arg not in _VALID_COMPONENT_TYPES: - await self.ctx.send.text(f"未知组件类型: {arg}", stream_id) - return - result = await self.ctx.component.get_all_plugins() - components = [c for c in self._extract_components(result) if c.get("type") == arg] - if not components: - await self.ctx.send.text(f"没有注册的 {arg} 组件", stream_id) - return - text = ", ".join(f"{c['name']} ({c['type']})" for c in components) - await self.ctx.send.text(f"注册的 {arg} 组件: {text}", stream_id) - else: - await self.ctx.send.text("插件管理命令不合法", stream_id) - - async def _list_filtered_components(self, filter_mode: str, scope: str, stream_id: str): - result = await self.ctx.component.get_all_plugins() - all_components = self._extract_components(result) - if not all_components: - await self.ctx.send.text("没有注册的组件", stream_id) - return - - if filter_mode == "enabled": - filtered = [c for c in all_components if c.get("enabled", False)] - label = "已启用" - else: - filtered = [c for c in all_components if not c.get("enabled", False)] - label = "已禁用" - - scope_label = "全局" if scope == "global" else "本聊天" - if not filtered: - await self.ctx.send.text(f"没有满足条件的{label}{scope_label}组件", stream_id) - return - text = ", ".join(f"{c['name']} ({c['type']})" for c in filtered) - await self.ctx.send.text(f"满足条件的{label}{scope_label}组件: {text}", stream_id) - - async def _handle_component_toggle(self, action: str, scope: str, comp_name: str, comp_type: str, stream_id: str): - if action not in ("enable", "disable"): - await self.ctx.send.text("插件管理命令不合法", stream_id) - return - if scope not in ("global", "local"): - await self.ctx.send.text("插件管理命令不合法", stream_id) - return - if comp_type not in _VALID_COMPONENT_TYPES: - await self.ctx.send.text(f"未知组件类型: {comp_type}", stream_id) - return - - if action == "enable": - result = await self.ctx.component.enable_component(comp_name, comp_type, scope=scope, stream_id=stream_id) - else: - result = await self.ctx.component.disable_component(comp_name, comp_type, scope=scope, stream_id=stream_id) - - ok = result.get("success", False) if isinstance(result, dict) else bool(result) - scope_label = "全局" if scope == "global" else "本地" - action_label = "启用" if action == "enable" else "禁用" - status = "成功" if ok else "失败" - await self.ctx.send.text(f"{scope_label}{action_label}组件{status}: {comp_name}", stream_id) - - # ------ helpers ------ - - @staticmethod - def _extract_components(result) -> list[dict]: - """从 get_all_plugins 结果中提取所有组件列表""" - if not result: - return [] - if isinstance(result, dict): - components = [] - for plugin_info in result.values(): - if isinstance(plugin_info, dict): - components.extend(plugin_info.get("components", [])) - return components - return [] - - async def on_config_update(self, scope: str, config_data: dict[str, object], version: str) -> None: - """处理配置热重载事件。 - - Args: - scope: 配置变更范围。 - config_data: 最新配置数据。 - version: 配置版本号。 - """ - - del scope - del config_data - del version - - -def create_plugin() -> PluginManagementPlugin: - """创建插件管理插件实例。 - - Returns: - PluginManagementPlugin: 新的插件管理插件实例。 - """ - - return PluginManagementPlugin() diff --git a/计划.md b/计划.md new file mode 100644 index 00000000..08841863 --- /dev/null +++ b/计划.md @@ -0,0 +1,230 @@ +# 私聊定时消息 V1 计划 + +## 目标 +- 只支持私聊,不支持群聊。 +- 由 LLM 在当前轮直接写好未来要发送的文本。 +- 到点后不再唤醒 LLM,不再二次规划,直接发送。 +- 尽量复用现有消息发送、消息存储、Maisaka 上下文、tool call 记录链路。 + +## 明确不做 +- 不做群聊定时发言。 +- 不做“到点后再让 LLM 判断该不该发”。 +- 不做 `intent_key` 这类没有明确行为差异的分类字段。 +- 不做复杂任务编排,只做“一条任务,到点发一条文本消息”。 + +## 端到端流转链路 + +### 1. 用户触发 +- 用户在私聊中表达未来某个时间点需要 bot 主动发一条消息。 +- 当前消息照常进入现有接收链路: + - 消息接收 + - HeartFlow runtime + - Timing Gate + - Planner + +### 2. Planner 决策 +- Planner 在分析当前局势后,直接调用新的定时消息工具。 +- 工具由 Planner 直接可见,不走 deferred tools。 +- 理由: + - 这是明确的主流程动作,不是低频扩展工具。 + - 用户说“明天早上提醒我”时,Planner 应当能直接执行,不应先 `tool_search`。 + +### 3. 工具执行 +- 新工具暂定名:`schedule_private_message` +- 工具接收参数后,直接写入任务存储。 +- 工具返回成功结果给当前 Planner。 +- 该 tool result 和其他工具一样,走现有 tool call 记录与上下文写回链路。 +- Planner 下一轮可继续: + - 调用 `reply` + - 告知用户“已经帮你定好了” + - 或继续补充说明 + +### 4. 调度器轮询 +- 独立后台调度器定期扫描到期任务。 +- 扫描条件: + - `status = pending` + - `send_at <= now` + - `chat_type = private` +- 调度器取出任务后尝试执行发送。 + +### 5. 到点发送 +- 调度器直接调用现有 `send_service.text_to_stream_with_message(...)` +- 发送参数要求: + - `text = 任务里保存的 message_text` + - `stream_id = 任务对应的私聊 session_id` + - `storage_message = True` + - `sync_to_maisaka_history = True` + - `maisaka_source_kind = "scheduled_send"` +- 这样发送后会自动: + - 走现有平台发送链路 + - 写入现有消息存储 + - 触发现有 memory automation + - 在 runtime 仍存活时同步写入 Maisaka 上下文 + +### 6. 发送后的上下文表现 +- 如果该 session 的 runtime 当前仍在 `heartflow_manager.heartflow_chat_list` 中: + - 发送完成后,消息会立刻追加进 `_chat_history` +- 如果 runtime 当前不在内存中: + - 这条消息仍会进入现有消息存储 + - 之后会话重新活跃时,至少数据库层面仍保留这条 bot 已发送消息 +- V1 先不为“runtime 不在内存时立即补上下文”额外新造链路 +- 先复用现有发送与存储通道 + +## 工具设计 + +### 工具名 +- `schedule_private_message` + +### 工具职责 +- 为当前私聊创建一条未来发送任务 +- 可选择覆盖当前私聊中尚未执行的旧任务 + +### 工具参数 +- `send_at` + - 类型:`string` + - 含义:发送时间 + - 约定:先存标准化后的绝对时间字符串,内部落库再转时间戳 +- `message_text` + - 类型:`string` + - 含义:到点时直接发送的文本 +- `replace_existing` + - 类型:`boolean` + - 含义:是否覆盖当前私聊里尚未执行的旧任务 + - 默认建议:`false` + +### 工具隐含上下文 +- 不要求 LLM 传 `session_id` +- 工具从当前 `ToolExecutionContext` / runtime 中直接拿当前私聊 `session_id` +- 如果当前不是私聊,工具直接失败 + +### 工具返回 +- 成功时至少返回: + - `task_id` + - `session_id` + - `send_at` + - `message_text` + - `replace_existing` + - 若发生覆盖,返回被取消的旧任务 ID 列表 +- 失败时返回明确原因: + - 非私聊 + - 时间非法 + - 文本为空 + - 存储失败 + +## 任务数据模型 + +### 建议字段 +- `id` +- `session_id` +- `chat_type` +- `message_text` +- `send_at_ts` +- `status` +- `created_at_ts` +- `updated_at_ts` +- `created_by_tool_call_id` +- `cancelled_by_tool_call_id` +- `sent_message_id` +- `sent_at_ts` +- `last_error` +- `replace_existing` + +### 状态 +- `pending` +- `sent` +- `cancelled` +- `failed` + +### 状态语义 +- `pending`:已创建,等待发送 +- `sent`:已成功发出 +- `cancelled`:被显式取消或被新任务覆盖 +- `failed`:尝试发送失败,保留错误信息 + +## 覆盖规则 + +### V1 定义 +- `replace_existing = true` 时: + - 将当前 `session_id` 下所有 `pending` 任务置为 `cancelled` + - 再创建当前新任务 +- `replace_existing = false` 时: + - 不取消旧任务 + - 直接新增当前任务 + +### 这样做的原因 +- 不引入模糊分类 +- 不猜“哪个旧任务和哪个新任务算同类” +- 规则简单、确定、易解释 + +## 取消与改期 + +### V1 建议 +- 不单独做“改期”底层能力 +- 改期等价于: + - 取消旧任务 + - 新建新任务 +- 后续可再补专用工具: + - `cancel_scheduled_private_message` + - `list_scheduled_private_messages` + +### 当前阶段 +- 当前文档先只覆盖“创建并发送”的最小闭环 +- 取消工具可以作为下一步扩展 + +## 到点前的最小状态校验 +- 任务当前仍是 `pending` +- 任务所属 `chat_type = private` +- 当前时间已到 `send_at_ts` +- 任务尚未发送过 +- 任务未被取消 + +## 明确不作为校验项的内容 +- 不检查“用户后来是否又聊天” +- 不让 LLM 到点时重新读上下文判断 +- 不根据最近聊天内容自动反悔 + +## 与现有链路的复用点 + +### 发送 +- 复用 `send_service.text_to_stream_with_message(...)` + +### 存储 +- 复用现有消息发送成功后的消息存储链路 + +### 上下文 +- 复用 `sync_to_maisaka_history=True` +- 复用 runtime 的 `append_sent_message_to_chat_history(...)` + +### Tool call 记录 +- 创建任务时,复用现有 Planner tool 执行记录链路 +- 到点执行时,不强行伪造一条新的 LLM tool call +- 到点执行属于调度器行为,不属于当轮 Planner 推理 + +## 需要补的实现模块 + +### 1. 新内置工具 +- 新增 `schedule_private_message` builtin tool + +### 2. 任务存储 +- 新增一张定时消息任务表 +- 提供最小 CRUD: + - create + - cancel pending by session + - list due pending + - mark sent + - mark failed + +### 3. 后台调度器 +- 独立异步循环 +- 固定间隔扫描 due tasks +- 执行发送并更新状态 + +### 4. 主程序启动 +- 在现有后台任务体系中注册该调度器 + +## 当前建议的落地顺序 +- 第一步:先把工具接口和任务表定下来 +- 第二步:把任务创建打通 +- 第三步:把后台调度发送打通 +- 第四步:确认发送后消息能按现有链路进入上下文 +- 第五步:再考虑取消/查看任务工具