From aa0aaf55382774c3be32fb88dc33c0c31f9e3a2e Mon Sep 17 00:00:00 2001
From: qiye-yuri <200499966@qq.com~>
Date: Tue, 13 Oct 2026 21:26:12 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=9C=AC?=
=?UTF-8?q?=E5=9C=B0=E6=9C=BA=E5=99=A8=E4=BA=BA=E7=AE=A1=E7=90=86=E5=90=8E?=
=?UTF-8?q?=E5=8F=B0=E7=AC=AC=E4=B8=80=E7=89=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
实现本机认证、运行概览、连接、插件、AI、日志与配置备份恢复,补齐角色控制、Agent持久记录及观察工具。
公开源码和使用/开发文档,集中忽略本机中间记录与运行资料;保留依赖许可证并提供中文PR模板。
验证:独立Windows构建、相关Go检查、242项界面、210项工具、64项布局、隔离整机与实际恢复重启通过;未测真人范围按约定取消,不记实测通过。
---
.github/PULL_REQUEST_TEMPLATE.md | 15 +
.gitignore | 34 +-
AGENTS.md | 20 +
CHANGELOG.md | 22 +
README.md | 8 +
docs/local-admin/README.md | 28 +
.../admin-foundation-2026-10-01.md | 55 +
docs/local-admin/admin-password.md | 11 +
docs/local-admin/agent-history-2026-10-03.md | 29 +
docs/local-admin/ai-browser-acceptance.md | 28 +
docs/local-admin/ai-config-check.md | 17 +
docs/local-admin/ai-conversations.md | 35 +
docs/local-admin/ai-discard.md | 11 +
docs/local-admin/ai-draft-fields.md | 15 +
docs/local-admin/ai-drafts.md | 19 +
docs/local-admin/ai-persona.md | 32 +
docs/local-admin/ai-role-permissions.md | 43 +
docs/local-admin/ai-select-appearance.md | 11 +
docs/local-admin/ai-settings-2026-10-02.md | 57 +
docs/local-admin/appearance-2026-10-03.md | 42 +
docs/local-admin/backup-catalog-export.md | 19 +
docs/local-admin/backup-cleanup.md | 13 +
docs/local-admin/backup-date-filter.md | 15 +
docs/local-admin/backup-impact.md | 13 +
docs/local-admin/backup-pagination.md | 13 +
docs/local-admin/backup-preview-expiry.md | 13 +
docs/local-admin/backup-restore-2026-10-02.md | 55 +
docs/local-admin/backup-search.md | 23 +
docs/local-admin/backup-size-summary.md | 21 +
docs/local-admin/backup-sort.md | 23 +
docs/local-admin/basic-smoke-2026-10-01.md | 39 +
docs/local-admin/browser-performance.md | 51 +
docs/local-admin/combined-observation.md | 15 +
docs/local-admin/config.windows.example.json | 19 +
.../connection-add-confirmation.md | 21 +
docs/local-admin/connection-add.md | 19 +
.../connection-browser-acceptance.md | 30 +
docs/local-admin/connection-delete.md | 13 +
docs/local-admin/connection-discard.md | 11 +
.../connection-draft-confirmation.md | 23 +
docs/local-admin/connection-draft-fields.md | 17 +
docs/local-admin/connection-drafts.md | 17 +
.../connection-settings-2026-10-01.md | 57 +
.../control-policy-backups-2026-10-02.md | 9 +
docs/local-admin/deepseek-vision.md | 13 +
docs/local-admin/delivery-evidence-check.md | 31 +
docs/local-admin/github-delivery.md | 37 +
docs/local-admin/gpt-quoted-message.md | 9 +
docs/local-admin/isolated-admin-acceptance.md | 43 +
docs/local-admin/isolated-ai-load.md | 23 +
.../live-performance-observation.md | 50 +
docs/local-admin/log-clear-display.md | 13 +
docs/local-admin/log-copy-entry.md | 17 +
docs/local-admin/log-export-2026-10-02.md | 38 +
.../log-export-date-browser-acceptance.md | 27 +
docs/local-admin/log-issues.md | 19 +
docs/local-admin/log-quick-range.md | 17 +
docs/local-admin/log-read-once.md | 19 +
docs/local-admin/log-summary.md | 21 +
docs/local-admin/log-time-filter.md | 11 +
docs/local-admin/log-viewer-2026-10-02.md | 37 +
.../logs-appearance-browser-acceptance.md | 25 +
.../local-admin/model-observation-identity.md | 21 +
docs/local-admin/model-observation-report.md | 46 +
docs/local-admin/model-request-statistics.md | 34 +
.../local-admin/observation-evidence-check.md | 36 +
docs/local-admin/onebot-windows.md | 94 +
docs/local-admin/operations.md | 243 ++
docs/local-admin/overview-alerts.md | 26 +
docs/local-admin/plugin-discard-one.md | 15 +
docs/local-admin/plugin-drafts.md | 21 +
.../plugin-management-2026-10-02.md | 72 +
docs/local-admin/plugin-pagination.md | 13 +
docs/local-admin/plugin-pending.md | 11 +
docs/local-admin/plugin-save-all.md | 15 +
docs/local-admin/plugin-search.md | 25 +
docs/local-admin/plugin-sort.md | 20 +
docs/local-admin/plugin-state-catalog.md | 15 +
docs/local-admin/plugin-usage-export.md | 13 +
docs/local-admin/progress.md | 19 +
docs/local-admin/qq-ai-voice.md | 13 +
docs/local-admin/qq-reply-evidence.md | 19 +
docs/local-admin/qq-reply-latency.md | 26 +
docs/local-admin/qq-reply-snapshot.md | 15 +
docs/local-admin/real-agent-acceptance.md | 34 +
docs/local-admin/release-v0.1.md | 35 +
docs/local-admin/requirements.md | 133 +
.../response-command-fix-2026-10-02.md | 55 +
docs/local-admin/response-status.md | 15 +
docs/local-admin/restore-plugin-reload.md | 13 +
docs/local-admin/reuse-analysis.md | 45 +
docs/local-admin/reverse-connection.md | 17 +
docs/local-admin/self-test.md | 34 +
docs/local-admin/service-list.md | 17 +
docs/local-admin/startup-differences.md | 11 +
.../system-acceptance-2026-10-02.md | 47 +
docs/local-admin/todo.md | 12 +
docs/local-admin/user-guide.md | 237 ++
docs/local-admin/windows-paths.md | 11 +
docs/local-admin/windows-setup.md | 102 +
docs/local-admin/wordcount-startup.md | 13 +
go.mod | 13 +
internal/adminqa/harness_windows.go | 459 +++
internal/adminqa/harness_windows_test.go | 77 +
internal/basiccommands/help.go | 25 +
internal/basiccommands/help_test.go | 95 +
internal/localadmin/ai.go | 320 ++
internal/localadmin/ai_checks.go | 42 +
internal/localadmin/ai_checks_test.go | 136 +
internal/localadmin/ai_conversations_test.go | 111 +
internal/localadmin/ai_test.go | 295 ++
internal/localadmin/assets/ai.js | 237 ++
internal/localadmin/assets/app.js | 840 +++++
internal/localadmin/assets/appearance.css | 90 +
internal/localadmin/assets/backups.js | 435 +++
internal/localadmin/assets/connections-add.js | 121 +
.../localadmin/assets/connections-delete.js | 69 +
internal/localadmin/assets/index.html | 181 ++
internal/localadmin/assets/logs.js | 277 ++
.../localadmin/assets/model-observation.js | 122 +
internal/localadmin/assets/overview.js | 96 +
internal/localadmin/assets/reply-metrics.js | 79 +
internal/localadmin/assets/security.js | 63 +
internal/localadmin/assets/style.css | 77 +
internal/localadmin/assets/theme.js | 31 +
internal/localadmin/auth.go | 173 +
internal/localadmin/backup_cleanup.go | 304 ++
internal/localadmin/backup_cleanup_test.go | 305 ++
internal/localadmin/backup_impact.go | 137 +
internal/localadmin/backup_impact_test.go | 249 ++
internal/localadmin/backups.go | 862 +++++
internal/localadmin/backups_test.go | 438 +++
internal/localadmin/connection_add.go | 87 +
internal/localadmin/connection_add_test.go | 198 ++
internal/localadmin/connection_delete.go | 38 +
internal/localadmin/connection_delete_test.go | 187 ++
internal/localadmin/connection_reverse.go | 58 +
.../localadmin/connection_reverse_test.go | 153 +
internal/localadmin/connections.go | 514 +++
internal/localadmin/connections_test.go | 307 ++
internal/localadmin/database_backup.go | 74 +
internal/localadmin/log_issues_test.go | 95 +
internal/localadmin/log_summary_test.go | 229 ++
internal/localadmin/logs.go | 540 ++++
internal/localadmin/logs_test.go | 258 ++
internal/localadmin/logs_time_test.go | 133 +
internal/localadmin/model_statistics_test.go | 112 +
internal/localadmin/navigation_test.go | 81 +
internal/localadmin/overview.go | 73 +
internal/localadmin/overview_test.go | 87 +
internal/localadmin/password.go | 144 +
internal/localadmin/password_test.go | 152 +
internal/localadmin/permissions_other.go | 13 +
internal/localadmin/permissions_windows.go | 24 +
internal/localadmin/plugins.go | 126 +
internal/localadmin/plugins_test.go | 286 ++
internal/localadmin/policies_test.go | 202 ++
internal/localadmin/replace_other.go | 7 +
internal/localadmin/replace_windows.go | 15 +
internal/localadmin/reply_statistics_test.go | 40 +
internal/localadmin/response_state_test.go | 146 +
internal/localadmin/runtime.go | 46 +
internal/localadmin/server.go | 357 ++
internal/localadmin/server_test.go | 259 ++
internal/localadmin/startup_differences.go | 25 +
.../localadmin/startup_differences_test.go | 126 +
internal/modelstats/stats.go | 201 ++
internal/modelstats/stats_test.go | 283 ++
internal/processidentity/creation_other.go | 12 +
internal/processidentity/creation_windows.go | 17 +
internal/processidentity/identity.go | 56 +
internal/processidentity/identity_test.go | 69 +
internal/qqvoice/send.go | 32 +
internal/qqvoice/send_test.go | 44 +
internal/replystats/send.go | 132 +
internal/replystats/send_test.go | 98 +
internal/replystats/stats.go | 208 ++
internal/replystats/stats_test.go | 173 +
kanban/init.go | 15 +-
kanban/reader.go | 63 +
kanban/reader_test.go | 113 +
main.go | 76 +-
plugin/aichat/main.go | 124 +-
plugin/aichat/permissions.go | 18 +
plugin/aichat/permissions_actions_test.go | 72 +
plugin/aichat/permissions_test.go | 38 +
plugin/aichatcfg/main.go | 87 +-
plugin/airecord/models.go | 24 +
plugin/airecord/models_test.go | 17 +
plugin/airecord/record.go | 56 +-
plugin/llm/main.go | 140 +-
plugin/llm/request.go | 125 +
plugin/llm/request_test.go | 199 ++
plugin/tarot/tarot.go | 15 +-
plugin/wordcount/chart_test.go | 42 +
plugin/wordcount/main.go | 52 +-
plugin/wordcount/resources.go | 54 +
plugin/wordcount/resources_test.go | 97 +
scripts/backup-control.go | 34 +
scripts/build-windows.ps1 | 36 +
scripts/check-combined-observation.mjs | 47 +
scripts/check-deepseek-vision.go | 89 +
scripts/check-deepseek.go | 77 +
scripts/check-local-admin-delivery.mjs | 100 +
scripts/check-observation-evidence.mjs | 223 ++
scripts/check-reply-evidence.mjs | 136 +
scripts/configure-deepseek.go | 154 +
scripts/configure-local-admin.ps1 | 10 +
scripts/install-onebot.ps1 | 44 +
scripts/live-observation-core.ps1 | 74 +
scripts/observe-isolated-ai.ps1 | 142 +
scripts/observe-live-services.ps1 | 81 +
scripts/open-onebot-webui.ps1 | 10 +
scripts/run-windows.ps1 | 28 +
scripts/service-host.ps1 | 51 +
scripts/setup-onebot.ps1 | 78 +
scripts/smoke-local-admin.go | 2866 +++++++++++++++++
scripts/start-admin-acceptance.go | 450 +++
scripts/start-admin-browser-performance.go | 296 ++
scripts/start-admin-connection-acceptance.go | 326 ++
scripts/start-local-services.ps1 | 62 +
scripts/start-model-observation-acceptance.go | 292 ++
scripts/start-onebot.ps1 | 48 +
scripts/stop-local-services.ps1 | 40 +
scripts/stop-onebot.ps1 | 31 +
scripts/test-admin-system.go | 623 ++++
scripts/test-ai-check-ui.mjs | 36 +
scripts/test-ai-discard-ui.mjs | 115 +
scripts/test-ai-draft-fields-ui.mjs | 82 +
scripts/test-ai-draft-ui.mjs | 156 +
scripts/test-appearance-layout.mjs | 119 +
scripts/test-backup-catalog-ui.mjs | 44 +
scripts/test-backup-date-ui.mjs | 74 +
scripts/test-backup-expiry-ui.mjs | 38 +
scripts/test-backup-impact-ui.mjs | 46 +
scripts/test-backup-search-ui.mjs | 60 +
scripts/test-backup-size-ui.mjs | 56 +
scripts/test-backup-sort-ui.mjs | 61 +
scripts/test-backup-ui.mjs | 195 ++
scripts/test-combined-observation.mjs | 61 +
scripts/test-connection-add-ui.mjs | 68 +
scripts/test-connection-confirm-layout.mjs | 25 +
scripts/test-connection-delete-ui.mjs | 41 +
scripts/test-connection-discard-ui.mjs | 101 +
scripts/test-connection-draft-confirm-ui.mjs | 80 +
scripts/test-connection-draft-fields-ui.mjs | 98 +
scripts/test-connection-draft-ui.mjs | 118 +
scripts/test-deepseek-vision.go | 184 ++
scripts/test-deepseek.go | 262 ++
scripts/test-delivery-evidence.mjs | 97 +
scripts/test-isolated-admin-acceptance.mjs | 62 +
scripts/test-isolated-ai-observation.ps1 | 38 +
scripts/test-live-observation.ps1 | 126 +
scripts/test-log-clear-ui.mjs | 63 +
scripts/test-log-copy-ui.mjs | 60 +
scripts/test-log-issues-ui.mjs | 42 +
scripts/test-log-quick-range-ui.mjs | 75 +
scripts/test-log-read-once-ui.mjs | 49 +
scripts/test-log-summary-ui.mjs | 32 +
scripts/test-log-time-ui.mjs | 71 +
scripts/test-log-ui.mjs | 184 ++
scripts/test-model-observation-ui.mjs | 86 +
scripts/test-observation-evidence.mjs | 166 +
scripts/test-onebot.ps1 | 80 +
scripts/test-overview-ui.mjs | 140 +
scripts/test-plugin-catalog-ui.mjs | 75 +
scripts/test-plugin-discard-one-ui.mjs | 95 +
scripts/test-plugin-pagination-ui.mjs | 135 +
scripts/test-plugin-pending-ui.mjs | 121 +
scripts/test-plugin-save-all-ui.mjs | 58 +
scripts/test-plugin-search-ui.mjs | 225 ++
scripts/test-plugin-sort-ui.mjs | 100 +
scripts/test-plugin-usage-ui.mjs | 55 +
scripts/test-reply-evidence.mjs | 106 +
scripts/test-reply-latency-ui.mjs | 37 +
scripts/test-reply-snapshot-ui.mjs | 55 +
scripts/test-response-status-ui.mjs | 30 +
scripts/test-restore-plugin-ui.mjs | 85 +
scripts/test-reverse-connection-ui.mjs | 32 +
scripts/test-security-ui.mjs | 63 +
scripts/test-service-paths.ps1 | 55 +
scripts/test-startup-differences-ui.mjs | 45 +
scripts/test-theme-ui.mjs | 62 +
scripts/windows-service-identity.ps1 | 48 +
third_party/go-onebot-agent/LICENSE | 661 ++++
third_party/go-onebot-agent/LOCAL_PATCH.md | 14 +
third_party/go-onebot-agent/README.md | 429 +++
third_party/go-onebot-agent/actions.yaml | 159 +
third_party/go-onebot-agent/agent.go | 329 ++
third_party/go-onebot-agent/go.mod | 31 +
third_party/go-onebot-agent/go.sum | 59 +
third_party/go-onebot-agent/history.go | 31 +
third_party/go-onebot-agent/memory.go | 45 +
third_party/go-onebot-agent/perm.go | 126 +
third_party/go-onebot-agent/perm_test.go | 44 +
third_party/go-onebot-agent/prompt.go | 43 +
third_party/go-onebot-agent/prompt_test.go | 494 +++
third_party/go-onebot-agent/public.md | 120 +
third_party/go-onebot-agent/types.go | 69 +
third_party/go-onebot-agent/types_test.go | 18 +
third_party/go-registry/LICENSE | 674 ++++
third_party/go-registry/LOCAL_PATCH.md | 9 +
third_party/go-registry/cmd.go | 207 ++
third_party/go-registry/deadline.go | 20 +
third_party/go-registry/deadline_test.go | 53 +
third_party/go-registry/go.mod | 11 +
third_party/go-registry/go.sum | 48 +
third_party/go-registry/helper.go | 30 +
third_party/go-registry/pool.go | 11 +
third_party/go-registry/reg.go | 380 +++
third_party/go-registry/storage.go | 16 +
third_party/plugin-control-patches.md | 32 +
third_party/sqlite/.gitignore | 17 +
third_party/sqlite/LICENSE | 674 ++++
third_party/sqlite/README.md | 42 +
third_party/sqlite/go.mod | 24 +
third_party/sqlite/go.sum | 37 +
third_party/sqlite/sqlite.go | 900 ++++++
third_party/sqlite/sqlite_test.go | 269 ++
third_party/sqlite/transaction.go | 35 +
third_party/sqlite/utils.go | 24 +
.../zbpctrl/.github/workflows/pull.yml | 35 +
.../zbpctrl/.github/workflows/push.yml | 28 +
third_party/zbpctrl/.gitignore | 17 +
third_party/zbpctrl/.golangci.yml | 75 +
third_party/zbpctrl/LICENSE | 674 ++++
third_party/zbpctrl/README.md | 2 +
third_party/zbpctrl/ban.go | 89 +
third_party/zbpctrl/block.go | 68 +
third_party/zbpctrl/config_update.go | 132 +
third_party/zbpctrl/control.go | 89 +
third_party/zbpctrl/data.go | 41 +
third_party/zbpctrl/enable.go | 17 +
third_party/zbpctrl/flip.go | 23 +
third_party/zbpctrl/go.mod | 30 +
third_party/zbpctrl/go.sum | 51 +
third_party/zbpctrl/manager.go | 116 +
third_party/zbpctrl/model.go | 38 +
third_party/zbpctrl/response.go | 115 +
third_party/zbpctrl/response_state.go | 55 +
third_party/zbpctrl/restore.go | 15 +
third_party/zbpctrl/switch.go | 147 +
.../zbputils/.github/workflows/pull.yml | 37 +
.../zbputils/.github/workflows/push.yml | 30 +
third_party/zbputils/.gitignore | 16 +
third_party/zbputils/.golangci.yml | 75 +
third_party/zbputils/LICENSE | 674 ++++
third_party/zbputils/LOCAL_PATCH.md | 9 +
third_party/zbputils/README.md | 8 +
third_party/zbputils/chat/README.md | 9 +
third_party/zbputils/chat/agent.go | 387 +++
third_party/zbputils/chat/agent_run.go | 59 +
third_party/zbputils/chat/cfg.go | 364 +++
third_party/zbputils/chat/char.yaml | 33 +
third_party/zbputils/chat/chat.go | 95 +
third_party/zbputils/chat/deepseek.go | 67 +
third_party/zbputils/chat/deepseek_test.go | 67 +
third_party/zbputils/chat/history.go | 340 ++
.../chat/history_permissions_other.go | 13 +
.../chat/history_permissions_windows.go | 23 +
.../zbputils/chat/history_reload_test.go | 48 +
third_party/zbputils/chat/history_test.go | 260 ++
third_party/zbputils/chat/mem.go | 89 +
third_party/zbputils/chat/storage.go | 58 +
third_party/zbputils/chat/storage_test.go | 149 +
third_party/zbputils/chat/str.go | 60 +
third_party/zbputils/control/cd.go | 96 +
third_party/zbputils/control/cd_test.go | 17 +
third_party/zbputils/control/ctx.go | 19 +
third_party/zbputils/control/defaultengine.go | 20 +
third_party/zbputils/control/engine.go | 204 ++
third_party/zbputils/control/extra.go | 18 +
third_party/zbputils/control/lazy.go | 42 +
third_party/zbputils/control/managers.go | 30 +
third_party/zbputils/control/matcher.go | 19 +
third_party/zbputils/control/register.go | 78 +
third_party/zbputils/control/rule.go | 501 +++
third_party/zbputils/control/service_list.go | 40 +
.../zbputils/control/service_list_format.go | 84 +
.../control/service_list_format_test.go | 83 +
third_party/zbputils/control/servicepic.go | 76 +
third_party/zbputils/control/speed.go | 31 +
.../zbputils/control/web/controller/job.go | 116 +
.../zbputils/control/web/controller/manage.go | 846 +++++
third_party/zbputils/control/web/docs/docs.go | 1037 ++++++
.../zbputils/control/web/docs/swagger.json | 1015 ++++++
.../zbputils/control/web/docs/swagger.yaml | 660 ++++
third_party/zbputils/control/web/gui.go | 127 +
.../control/web/middleware/middleware.go | 84 +
.../zbputils/control/web/model/webui.go | 61 +
.../zbputils/control/web/router/router.go | 53 +
.../zbputils/control/web/types/types.go | 177 +
third_party/zbputils/ctxext/message.go | 86 +
third_party/zbputils/ctxext/speed.go | 118 +
third_party/zbputils/ctxext/storage.go | 154 +
third_party/zbputils/driver/funcall.go | 184 ++
third_party/zbputils/go.mod | 116 +
third_party/zbputils/go.sum | 273 ++
third_party/zbputils/img/pool/sender.go | 34 +
third_party/zbputils/img/text/font.go | 42 +
third_party/zbputils/img/text/render.go | 42 +
third_party/zbputils/job/main.go | 627 ++++
third_party/zbputils/job/model.go | 11 +
third_party/zbputils/job/regexqa.go | 388 +++
third_party/zbputils/job/web.go | 459 +++
third_party/zbputils/vevent/ctx.go | 28 +
third_party/zbputils/vevent/vcall.go | 30 +
407 files changed, 48155 insertions(+), 281 deletions(-)
create mode 100644 .github/PULL_REQUEST_TEMPLATE.md
create mode 100644 AGENTS.md
create mode 100644 CHANGELOG.md
create mode 100644 docs/local-admin/README.md
create mode 100644 docs/local-admin/admin-foundation-2026-10-01.md
create mode 100644 docs/local-admin/admin-password.md
create mode 100644 docs/local-admin/agent-history-2026-10-03.md
create mode 100644 docs/local-admin/ai-browser-acceptance.md
create mode 100644 docs/local-admin/ai-config-check.md
create mode 100644 docs/local-admin/ai-conversations.md
create mode 100644 docs/local-admin/ai-discard.md
create mode 100644 docs/local-admin/ai-draft-fields.md
create mode 100644 docs/local-admin/ai-drafts.md
create mode 100644 docs/local-admin/ai-persona.md
create mode 100644 docs/local-admin/ai-role-permissions.md
create mode 100644 docs/local-admin/ai-select-appearance.md
create mode 100644 docs/local-admin/ai-settings-2026-10-02.md
create mode 100644 docs/local-admin/appearance-2026-10-03.md
create mode 100644 docs/local-admin/backup-catalog-export.md
create mode 100644 docs/local-admin/backup-cleanup.md
create mode 100644 docs/local-admin/backup-date-filter.md
create mode 100644 docs/local-admin/backup-impact.md
create mode 100644 docs/local-admin/backup-pagination.md
create mode 100644 docs/local-admin/backup-preview-expiry.md
create mode 100644 docs/local-admin/backup-restore-2026-10-02.md
create mode 100644 docs/local-admin/backup-search.md
create mode 100644 docs/local-admin/backup-size-summary.md
create mode 100644 docs/local-admin/backup-sort.md
create mode 100644 docs/local-admin/basic-smoke-2026-10-01.md
create mode 100644 docs/local-admin/browser-performance.md
create mode 100644 docs/local-admin/combined-observation.md
create mode 100644 docs/local-admin/config.windows.example.json
create mode 100644 docs/local-admin/connection-add-confirmation.md
create mode 100644 docs/local-admin/connection-add.md
create mode 100644 docs/local-admin/connection-browser-acceptance.md
create mode 100644 docs/local-admin/connection-delete.md
create mode 100644 docs/local-admin/connection-discard.md
create mode 100644 docs/local-admin/connection-draft-confirmation.md
create mode 100644 docs/local-admin/connection-draft-fields.md
create mode 100644 docs/local-admin/connection-drafts.md
create mode 100644 docs/local-admin/connection-settings-2026-10-01.md
create mode 100644 docs/local-admin/control-policy-backups-2026-10-02.md
create mode 100644 docs/local-admin/deepseek-vision.md
create mode 100644 docs/local-admin/delivery-evidence-check.md
create mode 100644 docs/local-admin/github-delivery.md
create mode 100644 docs/local-admin/gpt-quoted-message.md
create mode 100644 docs/local-admin/isolated-admin-acceptance.md
create mode 100644 docs/local-admin/isolated-ai-load.md
create mode 100644 docs/local-admin/live-performance-observation.md
create mode 100644 docs/local-admin/log-clear-display.md
create mode 100644 docs/local-admin/log-copy-entry.md
create mode 100644 docs/local-admin/log-export-2026-10-02.md
create mode 100644 docs/local-admin/log-export-date-browser-acceptance.md
create mode 100644 docs/local-admin/log-issues.md
create mode 100644 docs/local-admin/log-quick-range.md
create mode 100644 docs/local-admin/log-read-once.md
create mode 100644 docs/local-admin/log-summary.md
create mode 100644 docs/local-admin/log-time-filter.md
create mode 100644 docs/local-admin/log-viewer-2026-10-02.md
create mode 100644 docs/local-admin/logs-appearance-browser-acceptance.md
create mode 100644 docs/local-admin/model-observation-identity.md
create mode 100644 docs/local-admin/model-observation-report.md
create mode 100644 docs/local-admin/model-request-statistics.md
create mode 100644 docs/local-admin/observation-evidence-check.md
create mode 100644 docs/local-admin/onebot-windows.md
create mode 100644 docs/local-admin/operations.md
create mode 100644 docs/local-admin/overview-alerts.md
create mode 100644 docs/local-admin/plugin-discard-one.md
create mode 100644 docs/local-admin/plugin-drafts.md
create mode 100644 docs/local-admin/plugin-management-2026-10-02.md
create mode 100644 docs/local-admin/plugin-pagination.md
create mode 100644 docs/local-admin/plugin-pending.md
create mode 100644 docs/local-admin/plugin-save-all.md
create mode 100644 docs/local-admin/plugin-search.md
create mode 100644 docs/local-admin/plugin-sort.md
create mode 100644 docs/local-admin/plugin-state-catalog.md
create mode 100644 docs/local-admin/plugin-usage-export.md
create mode 100644 docs/local-admin/progress.md
create mode 100644 docs/local-admin/qq-ai-voice.md
create mode 100644 docs/local-admin/qq-reply-evidence.md
create mode 100644 docs/local-admin/qq-reply-latency.md
create mode 100644 docs/local-admin/qq-reply-snapshot.md
create mode 100644 docs/local-admin/real-agent-acceptance.md
create mode 100644 docs/local-admin/release-v0.1.md
create mode 100644 docs/local-admin/requirements.md
create mode 100644 docs/local-admin/response-command-fix-2026-10-02.md
create mode 100644 docs/local-admin/response-status.md
create mode 100644 docs/local-admin/restore-plugin-reload.md
create mode 100644 docs/local-admin/reuse-analysis.md
create mode 100644 docs/local-admin/reverse-connection.md
create mode 100644 docs/local-admin/self-test.md
create mode 100644 docs/local-admin/service-list.md
create mode 100644 docs/local-admin/startup-differences.md
create mode 100644 docs/local-admin/system-acceptance-2026-10-02.md
create mode 100644 docs/local-admin/todo.md
create mode 100644 docs/local-admin/user-guide.md
create mode 100644 docs/local-admin/windows-paths.md
create mode 100644 docs/local-admin/windows-setup.md
create mode 100644 docs/local-admin/wordcount-startup.md
create mode 100644 internal/adminqa/harness_windows.go
create mode 100644 internal/adminqa/harness_windows_test.go
create mode 100644 internal/basiccommands/help.go
create mode 100644 internal/basiccommands/help_test.go
create mode 100644 internal/localadmin/ai.go
create mode 100644 internal/localadmin/ai_checks.go
create mode 100644 internal/localadmin/ai_checks_test.go
create mode 100644 internal/localadmin/ai_conversations_test.go
create mode 100644 internal/localadmin/ai_test.go
create mode 100644 internal/localadmin/assets/ai.js
create mode 100644 internal/localadmin/assets/app.js
create mode 100644 internal/localadmin/assets/appearance.css
create mode 100644 internal/localadmin/assets/backups.js
create mode 100644 internal/localadmin/assets/connections-add.js
create mode 100644 internal/localadmin/assets/connections-delete.js
create mode 100644 internal/localadmin/assets/index.html
create mode 100644 internal/localadmin/assets/logs.js
create mode 100644 internal/localadmin/assets/model-observation.js
create mode 100644 internal/localadmin/assets/overview.js
create mode 100644 internal/localadmin/assets/reply-metrics.js
create mode 100644 internal/localadmin/assets/security.js
create mode 100644 internal/localadmin/assets/style.css
create mode 100644 internal/localadmin/assets/theme.js
create mode 100644 internal/localadmin/auth.go
create mode 100644 internal/localadmin/backup_cleanup.go
create mode 100644 internal/localadmin/backup_cleanup_test.go
create mode 100644 internal/localadmin/backup_impact.go
create mode 100644 internal/localadmin/backup_impact_test.go
create mode 100644 internal/localadmin/backups.go
create mode 100644 internal/localadmin/backups_test.go
create mode 100644 internal/localadmin/connection_add.go
create mode 100644 internal/localadmin/connection_add_test.go
create mode 100644 internal/localadmin/connection_delete.go
create mode 100644 internal/localadmin/connection_delete_test.go
create mode 100644 internal/localadmin/connection_reverse.go
create mode 100644 internal/localadmin/connection_reverse_test.go
create mode 100644 internal/localadmin/connections.go
create mode 100644 internal/localadmin/connections_test.go
create mode 100644 internal/localadmin/database_backup.go
create mode 100644 internal/localadmin/log_issues_test.go
create mode 100644 internal/localadmin/log_summary_test.go
create mode 100644 internal/localadmin/logs.go
create mode 100644 internal/localadmin/logs_test.go
create mode 100644 internal/localadmin/logs_time_test.go
create mode 100644 internal/localadmin/model_statistics_test.go
create mode 100644 internal/localadmin/navigation_test.go
create mode 100644 internal/localadmin/overview.go
create mode 100644 internal/localadmin/overview_test.go
create mode 100644 internal/localadmin/password.go
create mode 100644 internal/localadmin/password_test.go
create mode 100644 internal/localadmin/permissions_other.go
create mode 100644 internal/localadmin/permissions_windows.go
create mode 100644 internal/localadmin/plugins.go
create mode 100644 internal/localadmin/plugins_test.go
create mode 100644 internal/localadmin/policies_test.go
create mode 100644 internal/localadmin/replace_other.go
create mode 100644 internal/localadmin/replace_windows.go
create mode 100644 internal/localadmin/reply_statistics_test.go
create mode 100644 internal/localadmin/response_state_test.go
create mode 100644 internal/localadmin/runtime.go
create mode 100644 internal/localadmin/server.go
create mode 100644 internal/localadmin/server_test.go
create mode 100644 internal/localadmin/startup_differences.go
create mode 100644 internal/localadmin/startup_differences_test.go
create mode 100644 internal/modelstats/stats.go
create mode 100644 internal/modelstats/stats_test.go
create mode 100644 internal/processidentity/creation_other.go
create mode 100644 internal/processidentity/creation_windows.go
create mode 100644 internal/processidentity/identity.go
create mode 100644 internal/processidentity/identity_test.go
create mode 100644 internal/qqvoice/send.go
create mode 100644 internal/qqvoice/send_test.go
create mode 100644 internal/replystats/send.go
create mode 100644 internal/replystats/send_test.go
create mode 100644 internal/replystats/stats.go
create mode 100644 internal/replystats/stats_test.go
create mode 100644 kanban/reader.go
create mode 100644 kanban/reader_test.go
create mode 100644 plugin/aichat/permissions.go
create mode 100644 plugin/aichat/permissions_actions_test.go
create mode 100644 plugin/aichat/permissions_test.go
create mode 100644 plugin/airecord/models.go
create mode 100644 plugin/airecord/models_test.go
create mode 100644 plugin/llm/request.go
create mode 100644 plugin/llm/request_test.go
create mode 100644 plugin/wordcount/chart_test.go
create mode 100644 plugin/wordcount/resources.go
create mode 100644 plugin/wordcount/resources_test.go
create mode 100644 scripts/backup-control.go
create mode 100644 scripts/build-windows.ps1
create mode 100644 scripts/check-combined-observation.mjs
create mode 100644 scripts/check-deepseek-vision.go
create mode 100644 scripts/check-deepseek.go
create mode 100644 scripts/check-local-admin-delivery.mjs
create mode 100644 scripts/check-observation-evidence.mjs
create mode 100644 scripts/check-reply-evidence.mjs
create mode 100644 scripts/configure-deepseek.go
create mode 100644 scripts/configure-local-admin.ps1
create mode 100644 scripts/install-onebot.ps1
create mode 100644 scripts/live-observation-core.ps1
create mode 100644 scripts/observe-isolated-ai.ps1
create mode 100644 scripts/observe-live-services.ps1
create mode 100644 scripts/open-onebot-webui.ps1
create mode 100644 scripts/run-windows.ps1
create mode 100644 scripts/service-host.ps1
create mode 100644 scripts/setup-onebot.ps1
create mode 100644 scripts/smoke-local-admin.go
create mode 100644 scripts/start-admin-acceptance.go
create mode 100644 scripts/start-admin-browser-performance.go
create mode 100644 scripts/start-admin-connection-acceptance.go
create mode 100644 scripts/start-local-services.ps1
create mode 100644 scripts/start-model-observation-acceptance.go
create mode 100644 scripts/start-onebot.ps1
create mode 100644 scripts/stop-local-services.ps1
create mode 100644 scripts/stop-onebot.ps1
create mode 100644 scripts/test-admin-system.go
create mode 100644 scripts/test-ai-check-ui.mjs
create mode 100644 scripts/test-ai-discard-ui.mjs
create mode 100644 scripts/test-ai-draft-fields-ui.mjs
create mode 100644 scripts/test-ai-draft-ui.mjs
create mode 100644 scripts/test-appearance-layout.mjs
create mode 100644 scripts/test-backup-catalog-ui.mjs
create mode 100644 scripts/test-backup-date-ui.mjs
create mode 100644 scripts/test-backup-expiry-ui.mjs
create mode 100644 scripts/test-backup-impact-ui.mjs
create mode 100644 scripts/test-backup-search-ui.mjs
create mode 100644 scripts/test-backup-size-ui.mjs
create mode 100644 scripts/test-backup-sort-ui.mjs
create mode 100644 scripts/test-backup-ui.mjs
create mode 100644 scripts/test-combined-observation.mjs
create mode 100644 scripts/test-connection-add-ui.mjs
create mode 100644 scripts/test-connection-confirm-layout.mjs
create mode 100644 scripts/test-connection-delete-ui.mjs
create mode 100644 scripts/test-connection-discard-ui.mjs
create mode 100644 scripts/test-connection-draft-confirm-ui.mjs
create mode 100644 scripts/test-connection-draft-fields-ui.mjs
create mode 100644 scripts/test-connection-draft-ui.mjs
create mode 100644 scripts/test-deepseek-vision.go
create mode 100644 scripts/test-deepseek.go
create mode 100644 scripts/test-delivery-evidence.mjs
create mode 100644 scripts/test-isolated-admin-acceptance.mjs
create mode 100644 scripts/test-isolated-ai-observation.ps1
create mode 100644 scripts/test-live-observation.ps1
create mode 100644 scripts/test-log-clear-ui.mjs
create mode 100644 scripts/test-log-copy-ui.mjs
create mode 100644 scripts/test-log-issues-ui.mjs
create mode 100644 scripts/test-log-quick-range-ui.mjs
create mode 100644 scripts/test-log-read-once-ui.mjs
create mode 100644 scripts/test-log-summary-ui.mjs
create mode 100644 scripts/test-log-time-ui.mjs
create mode 100644 scripts/test-log-ui.mjs
create mode 100644 scripts/test-model-observation-ui.mjs
create mode 100644 scripts/test-observation-evidence.mjs
create mode 100644 scripts/test-onebot.ps1
create mode 100644 scripts/test-overview-ui.mjs
create mode 100644 scripts/test-plugin-catalog-ui.mjs
create mode 100644 scripts/test-plugin-discard-one-ui.mjs
create mode 100644 scripts/test-plugin-pagination-ui.mjs
create mode 100644 scripts/test-plugin-pending-ui.mjs
create mode 100644 scripts/test-plugin-save-all-ui.mjs
create mode 100644 scripts/test-plugin-search-ui.mjs
create mode 100644 scripts/test-plugin-sort-ui.mjs
create mode 100644 scripts/test-plugin-usage-ui.mjs
create mode 100644 scripts/test-reply-evidence.mjs
create mode 100644 scripts/test-reply-latency-ui.mjs
create mode 100644 scripts/test-reply-snapshot-ui.mjs
create mode 100644 scripts/test-response-status-ui.mjs
create mode 100644 scripts/test-restore-plugin-ui.mjs
create mode 100644 scripts/test-reverse-connection-ui.mjs
create mode 100644 scripts/test-security-ui.mjs
create mode 100644 scripts/test-service-paths.ps1
create mode 100644 scripts/test-startup-differences-ui.mjs
create mode 100644 scripts/test-theme-ui.mjs
create mode 100644 scripts/windows-service-identity.ps1
create mode 100644 third_party/go-onebot-agent/LICENSE
create mode 100644 third_party/go-onebot-agent/LOCAL_PATCH.md
create mode 100644 third_party/go-onebot-agent/README.md
create mode 100644 third_party/go-onebot-agent/actions.yaml
create mode 100644 third_party/go-onebot-agent/agent.go
create mode 100644 third_party/go-onebot-agent/go.mod
create mode 100644 third_party/go-onebot-agent/go.sum
create mode 100644 third_party/go-onebot-agent/history.go
create mode 100644 third_party/go-onebot-agent/memory.go
create mode 100644 third_party/go-onebot-agent/perm.go
create mode 100644 third_party/go-onebot-agent/perm_test.go
create mode 100644 third_party/go-onebot-agent/prompt.go
create mode 100644 third_party/go-onebot-agent/prompt_test.go
create mode 100644 third_party/go-onebot-agent/public.md
create mode 100644 third_party/go-onebot-agent/types.go
create mode 100644 third_party/go-onebot-agent/types_test.go
create mode 100644 third_party/go-registry/LICENSE
create mode 100644 third_party/go-registry/LOCAL_PATCH.md
create mode 100644 third_party/go-registry/cmd.go
create mode 100644 third_party/go-registry/deadline.go
create mode 100644 third_party/go-registry/deadline_test.go
create mode 100644 third_party/go-registry/go.mod
create mode 100644 third_party/go-registry/go.sum
create mode 100644 third_party/go-registry/helper.go
create mode 100644 third_party/go-registry/pool.go
create mode 100644 third_party/go-registry/reg.go
create mode 100644 third_party/go-registry/storage.go
create mode 100644 third_party/plugin-control-patches.md
create mode 100644 third_party/sqlite/.gitignore
create mode 100644 third_party/sqlite/LICENSE
create mode 100644 third_party/sqlite/README.md
create mode 100644 third_party/sqlite/go.mod
create mode 100644 third_party/sqlite/go.sum
create mode 100644 third_party/sqlite/sqlite.go
create mode 100644 third_party/sqlite/sqlite_test.go
create mode 100644 third_party/sqlite/transaction.go
create mode 100644 third_party/sqlite/utils.go
create mode 100644 third_party/zbpctrl/.github/workflows/pull.yml
create mode 100644 third_party/zbpctrl/.github/workflows/push.yml
create mode 100644 third_party/zbpctrl/.gitignore
create mode 100644 third_party/zbpctrl/.golangci.yml
create mode 100644 third_party/zbpctrl/LICENSE
create mode 100644 third_party/zbpctrl/README.md
create mode 100644 third_party/zbpctrl/ban.go
create mode 100644 third_party/zbpctrl/block.go
create mode 100644 third_party/zbpctrl/config_update.go
create mode 100644 third_party/zbpctrl/control.go
create mode 100644 third_party/zbpctrl/data.go
create mode 100644 third_party/zbpctrl/enable.go
create mode 100644 third_party/zbpctrl/flip.go
create mode 100644 third_party/zbpctrl/go.mod
create mode 100644 third_party/zbpctrl/go.sum
create mode 100644 third_party/zbpctrl/manager.go
create mode 100644 third_party/zbpctrl/model.go
create mode 100644 third_party/zbpctrl/response.go
create mode 100644 third_party/zbpctrl/response_state.go
create mode 100644 third_party/zbpctrl/restore.go
create mode 100644 third_party/zbpctrl/switch.go
create mode 100644 third_party/zbputils/.github/workflows/pull.yml
create mode 100644 third_party/zbputils/.github/workflows/push.yml
create mode 100644 third_party/zbputils/.gitignore
create mode 100644 third_party/zbputils/.golangci.yml
create mode 100644 third_party/zbputils/LICENSE
create mode 100644 third_party/zbputils/LOCAL_PATCH.md
create mode 100644 third_party/zbputils/README.md
create mode 100644 third_party/zbputils/chat/README.md
create mode 100644 third_party/zbputils/chat/agent.go
create mode 100644 third_party/zbputils/chat/agent_run.go
create mode 100644 third_party/zbputils/chat/cfg.go
create mode 100644 third_party/zbputils/chat/char.yaml
create mode 100644 third_party/zbputils/chat/chat.go
create mode 100644 third_party/zbputils/chat/deepseek.go
create mode 100644 third_party/zbputils/chat/deepseek_test.go
create mode 100644 third_party/zbputils/chat/history.go
create mode 100644 third_party/zbputils/chat/history_permissions_other.go
create mode 100644 third_party/zbputils/chat/history_permissions_windows.go
create mode 100644 third_party/zbputils/chat/history_reload_test.go
create mode 100644 third_party/zbputils/chat/history_test.go
create mode 100644 third_party/zbputils/chat/mem.go
create mode 100644 third_party/zbputils/chat/storage.go
create mode 100644 third_party/zbputils/chat/storage_test.go
create mode 100644 third_party/zbputils/chat/str.go
create mode 100644 third_party/zbputils/control/cd.go
create mode 100644 third_party/zbputils/control/cd_test.go
create mode 100644 third_party/zbputils/control/ctx.go
create mode 100644 third_party/zbputils/control/defaultengine.go
create mode 100644 third_party/zbputils/control/engine.go
create mode 100644 third_party/zbputils/control/extra.go
create mode 100644 third_party/zbputils/control/lazy.go
create mode 100644 third_party/zbputils/control/managers.go
create mode 100644 third_party/zbputils/control/matcher.go
create mode 100644 third_party/zbputils/control/register.go
create mode 100644 third_party/zbputils/control/rule.go
create mode 100644 third_party/zbputils/control/service_list.go
create mode 100644 third_party/zbputils/control/service_list_format.go
create mode 100644 third_party/zbputils/control/service_list_format_test.go
create mode 100644 third_party/zbputils/control/servicepic.go
create mode 100644 third_party/zbputils/control/speed.go
create mode 100644 third_party/zbputils/control/web/controller/job.go
create mode 100644 third_party/zbputils/control/web/controller/manage.go
create mode 100644 third_party/zbputils/control/web/docs/docs.go
create mode 100644 third_party/zbputils/control/web/docs/swagger.json
create mode 100644 third_party/zbputils/control/web/docs/swagger.yaml
create mode 100644 third_party/zbputils/control/web/gui.go
create mode 100644 third_party/zbputils/control/web/middleware/middleware.go
create mode 100644 third_party/zbputils/control/web/model/webui.go
create mode 100644 third_party/zbputils/control/web/router/router.go
create mode 100644 third_party/zbputils/control/web/types/types.go
create mode 100644 third_party/zbputils/ctxext/message.go
create mode 100644 third_party/zbputils/ctxext/speed.go
create mode 100644 third_party/zbputils/ctxext/storage.go
create mode 100644 third_party/zbputils/driver/funcall.go
create mode 100644 third_party/zbputils/go.mod
create mode 100644 third_party/zbputils/go.sum
create mode 100644 third_party/zbputils/img/pool/sender.go
create mode 100644 third_party/zbputils/img/text/font.go
create mode 100644 third_party/zbputils/img/text/render.go
create mode 100644 third_party/zbputils/job/main.go
create mode 100644 third_party/zbputils/job/model.go
create mode 100644 third_party/zbputils/job/regexqa.go
create mode 100644 third_party/zbputils/job/web.go
create mode 100644 third_party/zbputils/vevent/ctx.go
create mode 100644 third_party/zbputils/vevent/vcall.go
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000000..d680bf380a
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,15 @@
+## 变更说明
+
+说明具体问题、修改后的行为及本次范围。
+
+## 验证结果
+
+列出实际完成的检查;区分隔离验证、真实QQ及浏览器验证,说明必要的未验收范围。
+
+## 使用与兼容性
+
+说明使用文档入口、配置影响、是否需要重启及升级方式。
+
+## 限制与待办
+
+列出本次仍未解决或未验证的事项;若没有,写“无”。
diff --git a/.gitignore b/.gitignore
index ffa2ed68ce..f08e80a37f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,22 @@
-plugins/*.so
-plugins/*.dll
-.idea/
-.DS_Store
-.vscode
-go-zero*
-nohup.out
-zerobot
-ZeroBot-Plugin*
-*.syso
-/.direnv
-/result
\ No newline at end of file
+plugins/*.so
+plugins/*.dll
+.idea/
+.DS_Store
+.vscode
+go-zero*
+nohup.out
+zerobot
+ZeroBot-Plugin*
+*.syso
+/.direnv
+/result
+
+# Local Windows builds, credentials, logs and isolated runtime data.
+/.local/
+/plugin/*/data/
+/third_party/**/data/
+/internal/**/data/
+/kanban/data/
+
+# Local conversation notes are not part of usage/development documentation.
+/docs/local-notes/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000..b9e88ee003
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,20 @@
+# 本项目协作约定
+
+用户要求:每个功能更新后同步更新使用文档;如果该功能没有使用文档,先创建。
+
+- 功能新增、行为修改和影响使用方式的修复,都应在本次交付中更新对应使用说明。
+- 通用使用入口为 `docs/local-admin/user-guide.md`,文档索引为 `docs/local-admin/README.md`;运行维护说明为 `docs/local-admin/operations.md`。
+- 新功能没有对应说明时,先创建使用文档,并在通用指南或索引中加入入口。日期化开发/测试记录不能替代使用步骤。
+- 使用说明应写清入口或命令、必要权限、作用范围、生效时机和实际限制;只描述已实现行为。
+- 更新开发进度、相关测试结果与未验收事项,区分自动化验证和真实 QQ 验收。仅修改文档时无需重启服务或重跑机器人测试。
+
+## 第一版 GitHub 交付约定
+
+- 用户已要求:第一版完成后更新到自己的 GitHub 仓库 `qiye-yuri/ZeroBot-Plugin`;第一版尚未完成时不提前发布,也不把模拟或部分验收写成完整验收。
+- 提交、PR及版本说明采用中文;提交按 `feat: 中文说明`、`fix: 中文说明`、`docs: 中文说明`、`test: 中文说明` 等类型组织,PR说明具体变化、验证结果与仍存在的限制。
+- 上传实现所需源码、构建/测试配置、依赖及使用和开发文档。对话逐字记录、聊天问答/会话摘要、临时调试资料和私有运行证据不上传;来源于对话但已整理为可复用使用/开发说明的内容可以保留。
+- 发布前逐项检查文件清单并显式暂存;不要用整个目录暂存把本机配置、密钥、QQ档案、截图或运行数据带入提交。公开文档使用示例身份和路径,保留必要的测试摘要与范围,真实QQ明细留在本机。
+- 不因文件按日期命名就自动排除使用或开发文档,也不删除本机对话资料来达到不上载的目的。发布前检查保留文档的链接与被排除文件之间的依赖。
+- 默认目标为用户自己的 `origin`,不向上游 `FloatTech/ZeroBot-Plugin` 推送或发PR。具体版本号和标签先与仓库既有版本核对,不能将“后台第一版”直接当作整个项目版本降为v0.1。
+- 中间交流、本机调查和原始开发记录统一保存在 `docs/local-notes/`,该目录整体忽略;公开文档只放 `docs/local-admin/`。
+- 完整步骤见 `docs/local-admin/github-delivery.md`。
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000000..f79ae3978d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,22 @@
+# 变更记录
+
+## 本地管理后台 v0.1 — 2026-10-13
+
+此版本号描述后台功能范围,项目主体仍沿用原有版本体系。
+
+### 新增
+
+- 本机管理后台:认证、运行概览、连接、插件、AI、日志与配置备份恢复。
+- 多连接及表单草稿保护、中文操作提示、深浅主题、小屏适配与文件导出。
+- Agent持久档案、模型/回复快照与资源观察及离线核对工具。
+- Windows服务管理、隔离回归入口和完整使用/开发文档。
+
+### 修复
+
+- AI配置与指令共享、保存冲突和失败保护、GPT引用及中文长回复处理。
+- 非主人Agent动作的当前群与权限边界,群语音重复发送及私聊向群生成的问题。
+- 热词等频绘图、启动资源请求阻塞、恢复后插件列表首次读取和本机首页导航。
+
+### 验证与限制
+
+自动化、实际浏览器与真实QQ验证按[第一版说明](docs/local-admin/release-v0.1.md)记录。部分真人Agent、客户端体验和真实持续负载验收已取消,未测项目不记作通过。运行配置和中间交流不随源码公开。
diff --git a/README.md b/README.md
index 5b0df356ef..072971a7e5 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,11 @@
+## 本地机器人管理后台 v0.1
+
+本分支增加本机管理后台,支持连接、插件、AI、日志与配置备份。仅监听本机回环地址,默认端口为3010。
+
+[使用指南](docs/local-admin/user-guide.md) · [第一版说明](docs/local-admin/release-v0.1.md) · [开发与验证](docs/local-admin/self-test.md)
+
+---
+

diff --git a/docs/local-admin/README.md b/docs/local-admin/README.md
new file mode 100644
index 0000000000..3e743418f1
--- /dev/null
+++ b/docs/local-admin/README.md
@@ -0,0 +1,28 @@
+# 本地机器人管理后台:使用与开发文档
+
+本地管理后台v0.1,Windows为首要验证环境;管理自己的单个ZeroBot-Plugin进程及已有多条OneBot连接。
+
+| 入口 | 用途 |
+| --- | --- |
+| [使用指南](user-guide.md) | 安装与启动、各后台页面、QQ命令、Agent和排障 |
+| [第一版说明](release-v0.1.md) | 功能、验证、限制及升级方式 |
+| [运行维护](operations.md) | 服务启停、配置生效、恢复与数据边界 |
+| [Windows OneBot](onebot-windows.md) | 本机NapCat安装、账号、连接与密钥配对 |
+| [AI配置检查](ai-config-check.md) | 接口类型/地址/密钥/模型与实际业务检查 |
+| [AI角色权限](ai-role-permissions.md) | 主人、群主、管理员和普通用户权限 |
+| [Agent验证](real-agent-acceptance.md) | 实际动作、回执、持久记忆与重启验证 |
+| [Agent档案](agent-history-2026-10-03.md) | 对话留存、重置、备份及模型请求容量 |
+| [群语音](qq-ai-voice.md) | QQ内置群AI语音与私聊限制 |
+| [识图](deepseek-vision.md) | 现有Agent图片描述流程与模型配置 |
+| [GPT引用](gpt-quoted-message.md) | 引用文字与本次要求分别处理 |
+| [响应与插件](response-status.md) | 全局/群响应规则与插件开关区别 |
+| [配置备份](backup-restore-2026-10-02.md) | 预览、确认、覆盖范围与恢复后重启 |
+| [统计与观察](combined-observation.md) | 模型、回复和资源资料独立与联合核对 |
+| [需求](requirements.md) | 范围、验收标准与设计 |
+| [开发进度](progress.md) | 第一版实现及已验证范围 |
+| [自测](self-test.md) | 复验入口与真实/隔离边界 |
+| [GitHub交付](github-delivery.md) | 中文提交/PR、文件筛选与中间记录排除 |
+
+功能细节由使用指南链接到对应说明。每次功能更新同步维护使用文档;没有对应说明时先创建。根目录[开发约定](../../AGENTS.md)同时适用。
+
+公开文档不包含本机QQ身份、凭据、截图、聊天明细或私有验收文件;中间交流与原始开发记录集中在被忽略的docs/local-notes,运行产物继续保存在.local。
diff --git a/docs/local-admin/admin-foundation-2026-10-01.md b/docs/local-admin/admin-foundation-2026-10-01.md
new file mode 100644
index 0000000000..eed8078191
--- /dev/null
+++ b/docs/local-admin/admin-foundation-2026-10-01.md
@@ -0,0 +1,55 @@
+# 本地后台基础:启动与验证
+
+## 本机使用
+
+后台地址默认是 `http://127.0.0.1:3010/`。首次启动生成 `.local/admin/setup-code.txt`,在页面选择该文件,设置至少 12 个字符的后台密码即可进入。设置码成功使用后失效并删除;此密码与 QQ 登录分开,不能用 OneBot Token 登录。
+
+后台凭据仅保存随机盐与 PBKDF2-HMAC-SHA256 派生值(600,000 次),保存在 `.local/admin/credential.json`。会话存在内存中,有效期 8 小时;退出或重启后原会话失效。Windows 数据目录及凭据文件使用受保护的 DACL,仅当前用户与 SYSTEM 拥有访问权限。
+
+用独立计划任务宿主运行时,在项目根目录执行:
+
+```powershell
+# 开启本机后台;本操作不改机器人账号、昵称或 OneBot 配置
+.\scripts\configure-local-admin.ps1
+.\scripts\stop-local-services.ps1 -Service ZeroBot
+.\scripts\start-local-services.ps1 -Service ZeroBot
+
+# 关闭后台,保留原机器人启动方式;同样需重启 ZeroBot
+.\scripts\configure-local-admin.ps1 -Disable
+.\scripts\stop-local-services.ps1 -Service ZeroBot
+.\scripts\start-local-services.ps1 -Service ZeroBot
+```
+
+手动启动也可使用:
+
+```powershell
+.\.local\bin\zerobot.exe -c .local/config.json -admin -admin-addr 127.0.0.1:3010 -admin-data .local/admin
+```
+
+不传 `-admin` 时不会创建后台监听或读取后台凭据。地址只接受字面 IPv4 `127.0.0.1`;端口占用时明确报错并停止启动,不静默换端口。机器人无法连接 OneBot 时后台仍可加载并显示等待连接;概览标记的是 OneBot 传输连接,不替代 QQ 登录状态检查。
+
+## 接口约定
+
+| 接口 | 权限/作用 |
+| --- | --- |
+| `GET /api/session` | 只返回首次设置/登录状态;已登录时提供当前会话 CSRF 校验值 |
+| `POST /api/setup` | 本地设置码与新密码;只能在尚未初始化时使用 |
+| `POST /api/login` | 后台密码登录;每分钟最多五次失败尝试 |
+| `POST /api/logout` | 已登录并持有 CSRF 校验值,注销当前会话 |
+| `GET /api/overview` | 已登录;运行时间、构建信息、当前 OneBot 账号、连接及插件数量 |
+| `GET /api/plugins` | 已登录;已编译插件元数据及启动时默认状态 |
+
+全部接口仅接受回环来源及正确 Host,跨站 Origin/Fetch Metadata 被拒绝;写请求必须为同源 JSON 并带 `X-Local-Request: 1`,已登录写操作另需 `X-CSRF-Token`。Cookie 为 HttpOnly、SameSite=Strict;服务仅本机 HTTP,故不设置要求 HTTPS 的 Secure 标志。响应不缓存,不记录请求体或凭据。网页不把会话放入 URL/localStorage,不加载外部字体、头像、图标或脚本。
+
+静态服务只暴露内嵌的三个页面文件,不映射本地数据目录或备份。插件用法和名称作为文本节点展示。
+
+## 可重复执行的自测
+
+在项目根目录执行;运行中的程序需使用另一个输出文件名,避免覆盖被 Windows 锁定的可执行文件:
+
+```powershell
+.\scripts\build-windows.ps1 -RunTests -OutputName zerobot-next.exe
+go run scripts/smoke-local-admin.go -binary-name zerobot-next.exe
+```
+
+集成脚本创建独立 `.local/smoke-admin/run-*` 目录,只复制必要启动素材,使用假账号、本地模拟 OneBot 和测试密码;不读取实际机器人配置、控制数据库或后台凭据,不向真实 QQ 发消息。运行结果写入该目录的 `result.json` 和 `.local/smoke-admin/latest.json`,退出时停止自己的测试进程。未指定 `-binary-name` 时测试 `.local/bin/zerobot.exe`。
diff --git a/docs/local-admin/admin-password.md b/docs/local-admin/admin-password.md
new file mode 100644
index 0000000000..28f3eb81b4
--- /dev/null
+++ b/docs/local-admin/admin-password.md
@@ -0,0 +1,11 @@
+# 修改后台密码
+
+登录本机后台进入“后台安全”。填写当前后台密码、新密码及新密码确认,点击“修改密码并退出”后确认。新密码至少12个字符、不超过512字节,需与旧密码不同;不自动去掉首尾空格。这里仅管理后台密码,与QQ登录、OneBot Token和模型密钥分别设置。
+
+成功保存后所有后台会话失效,当前页面返回登录页,用新密码重新登录;其他标签页或浏览器在下次请求时要求重新登录。机器人和NapCat无需重启,消息处理保持运行。已开始的其他后台请求不追溯撤回,因此提交前需等待其他设置操作结束,并确认未保存编辑可以丢弃。
+
+密码输入不保留到浏览器存储或文件。离开安全页、退出、过期或成功后清空三项输入;请求中阻止切页/退出和重复提交。取消确认保留输入,服务端拒绝或网络异常后清空输入。若请求中断且不确定结果,请重新登录确认,优先使用新密码;不要根据页面未收到成功反馈推断保存失败。
+
+当前密码错误不会立即退出当前会话;登录与修改共享每分钟5次错误尝试限制。成功修改原子替换本机凭据,仅保存加盐派生值,不创建可恢复的旧密码备份;现有配置备份/恢复也不修改后台密码。写入失败保留旧凭据与会话;凭据被外部修改或文件异常时拒绝覆盖,按维护说明检查后重新启动。
+
+验收:在页面查看入口和输入校验;只有准备好记录新密码时才实际提交修改,再验证新密码登录及旧会话失效。自动测试使用临时后台与合成密码,不修改日常密码。不在聊天中发送密码。
diff --git a/docs/local-admin/agent-history-2026-10-03.md b/docs/local-admin/agent-history-2026-10-03.md
new file mode 100644
index 0000000000..7a54e11391
--- /dev/null
+++ b/docs/local-admin/agent-history-2026-10-03.md
@@ -0,0 +1,29 @@
+# Agent 连续对话与人物设定
+
+## 行为与数据
+
+| 项目 | 当前行为 |
+| --- | --- |
+| 行动轮数 | 移除单次任务最多 8 轮的循环;正常任务可继续执行,直到 `end_action`、没有动作或发生错误 |
+| 重复操作 | 同一任务重复生成相同动作批次,包括 A/B/A 循环时停止;变化的 Echo 不视为进展,新消息可开始新任务 |
+| 对话留存 | SQLite 持久保存已记录的事件、动作尝试、实际响应与任务结束标记,没有按轮数、条数或时间自动删除的规则 |
+| 会话隔离 | 每个机器人账号独立数据库;正数会话编号为群,负数为私聊用户;群内保留发言者身份 |
+| 修改人物设定 | 更新运行实例,保留历史;正在执行的旧设定任务停止,后续消息使用新设定 |
+| 重启 | 下一条事件创建 Agent 时重新加载当前会话记录;升级前只在旧进程内存中的记录无法补回 |
+| 手动重置 | 主人指令 `重置AI聊天Agent` 为已知会话写入重置标记,从新上下文开始;旧记录继续留在数据库 |
+| 请求容量 | 官方 DeepSeek 每次加载近期历史最多约 900,000 UTF-8 字节,给系统提示、记忆与输出留空间;超出的早期内容只从本次请求省略,不删除磁盘记录 |
+| 其他限制 | 单次模型 HTTP 请求仍有 30 秒超时,输出长度参数沿用原设置;取消轮数限制不等于提供方上下文和单次输出无限 |
+
+记录位于仓库内 `data/aichat/history/<机器人QQ>.sqlite`,当前账号对应 `data/aichat/history/87654321.sqlite`。首次收到可记录的事件时创建,并不是程序启动时立即生成。原消息钩子记录消息、相关通知/请求以及正常插件的消息动作;记录机制本身不主动向 QQ 发消息。事件和回复不再按原来的 1024/512 字节截短;已配置 AI 密钥和密钥设置命令内容会隐藏。这里保留的是 OneBot 事件/动作数据,图片等资源不会自动下载为离线附件,也不保存模型内部思考过程。
+
+Windows 对目录和数据库设置私有 ACL,仅当前运行用户与 SYSTEM 可访问;记录写入或配置读取失败会停止 Agent 动作,避免悄悄漏记后继续执行。现有 `data/aichat/agent/<会话编号>.txt` 长期记忆文件仍保留,记忆与事件档案分开使用;过长记忆只截取近期内容进入提示,不截断原文件。既有聊天角色权限、群开关与 @ 规则继续适用。
+
+后台现有三类配置备份不包含对话数据库或长期记忆文件。需要另行备份对话时先停止 ZeroBot,再复制 `data/aichat/history/` 整个目录(包括可能存在的 WAL/SHM 文件)和 `data/aichat/agent/`;恢复前也先停止服务。当前未新增网页记录查看、编辑或导出入口。档案会随使用增长,应按实际磁盘容量安排备份和整理。
+
+## 人物设定
+
+[默认人物设定](../../third_party/zbputils/chat/char.yaml) 已同时写入本机共享 AI 设置的 Agent 人物设定和性别字段。其他 AI 字段、密钥和启动 JSON 保持原值。
+
+保留 19 岁虚拟女生、粉蓝单马尾和校园风形象,把设定自然融入语气。通常简短接话,认真问题可以展开;少用固定口头禅和表情,傲娇以轻微俏皮为主,不挖苦用户。不编造经历或记忆,区分群内不同用户,只根据最新意图行动,成功后不重复发送,完成后结束当前任务。
+
+后续在后台修改 Agent 人物设定即可应用,并保留记录。磁盘档案完整保留不代表模型每次都能读取全部旧记录;超出请求容量的内容暂时不会提供给模型,当前也没有新增历史检索工具。
diff --git a/docs/local-admin/ai-browser-acceptance.md b/docs/local-admin/ai-browser-acceptance.md
new file mode 100644
index 0000000000..98d6a17cad
--- /dev/null
+++ b/docs/local-admin/ai-browser-acceptance.md
@@ -0,0 +1,28 @@
+# AI 设置真实浏览器自动验收
+
+## 使用范围
+
+日常使用入口为已登录后台的“AI 设置”。全局模型、聊天会话参数、GPT 温度分别保存;保存后后续请求生效,无需重启。密钥有保留、替换和明确确认清除三种操作,当前值不显示。切换后台页面保留未保存草稿;版本冲突保留编辑,重新加载核对后再保存。完整字段与权限见 [AI 设置](ai-settings-2026-10-02.md)、[草稿保护](ai-drafts.md) 和 [私聊参数](ai-conversations.md)。
+
+自动验收使用 [隔离后台](isolated-admin-acceptance.md) 创建的独立程序、随机密码、控制库和模拟 OneBot。程序副本取自当前已部署版本,测试密钥和账号均为合成值;不读取日常密钥,不向 QQ 或真实模型发送请求。实例仅在本机随机端口开放,由已授权的 Chrome 操作。日常 3010 后台不用于保存或清除演练。
+
+## 复验步骤
+
+1. 从仓库执行 `go run scripts/start-admin-acceptance.go -binary-name zerobot.exe -lifetime 30m`,取得本次 `session.json` 的 URL 和 `login.txt` 的随机测试密码,登录独立后台。
+2. 进入 AI 设置,核对初始模型 `isolated-edited-model` 与三个未配置密钥。将聊天、Agent、识图地址设为合成回环地址,例如 `http://127.0.0.1:1/chat`;填写合成模型和三种测试密钥后保存,核对三种密钥显示已配置、输入框恢复隐藏,基础检查不声称已调用模型。
+3. 修改模型名后切换插件页再返回,核对草稿和字段摘要保留。改回已保存值,读取群 `101`;将聊天温度/概率和 GPT 温度设为不同值,先保存聊天,核对 GPT 草稿仍保留,再单独保存 GPT。
+4. 切换到同号私聊 `101`,核对它不继承群的已保存参数,分别保存不同温度,再返回群确认未覆盖。这里的 `101` 只用于独立配置目标,不向该 QQ 或群发送消息。
+5. 在第二个 Chrome 标签页读取相同配置并修改草稿,先在第一页保存新模型,再在旧页保存,核对冲突提示、草稿保留及按钮恢复可用。聊天群参数用同样方式核对。修正或恢复未保存字段后再关闭旧页。
+6. 选择清除聊天密钥但不确认,点击保存应提示先确认且不新增备份;确认后保存,只清除聊天密钥。再确认清除识图和 Agent 密钥,核对字段和其他会话参数保持。无改动保存不新增备份;带查询参数的无效接口地址被拒绝,错误不回显合成认证文字。
+7. 保存后等待草稿消失、控件恢复可用,再读取完整页面核对;连续保存会保留同样的成功提示,不能只凭该旧提示判断请求完成。重新加载完成后再切换群/私聊范围。
+8. 在启动器输入 `restart`,仅重启本次实例。从更新后的 `session.json` 取得新 URL,确认旧会话失效,使用原测试密码重新登录;核对模型、三种密钥清除结果、群与私聊参数分别持久保存。输入 `stop` 停止实例。
+
+独立后台与日常后台在同一主机名使用同一会话 Cookie,回到日常后台可能需要正常重新登录;日常密码文件保持。测试失败、早读状态或截图超时应单独记录,不能作为已通过。
+
+## 本轮证据
+
+当前程序 SHA-256:`D9241B7A7D491897979B908454C9F8273FD0073D26CC5A851B581EE47DCF9986`。独立实例 `.local/admin-acceptance/run-1172851902`,两次启动、已正常停止;初始端口 63313,重启后 63316。21 组核对覆盖全局及三密钥保存/清除、草稿、群与同号私聊隔离、全局及聊天群参数冲突、校验、无改动保存、旧会话/原密码与重启持久状态。
+
+真实页面结果为该目录 `browser-ai-result.json`,截图 `browser-ai-verified.png`,相关完整页面状态为 `browser-ai-*.txt`;汇总 `.local/ai-workflow-browser-result.json`。启动器的 `manualAcceptancePassed=false` 不作为浏览器结论,其 `result.json` 只证明独立程序与退出状态;实际页面验收记录单独保存。
+
+`.local/ai-workflow-browser-before.json` 与 `-after.json` 核对日常程序、启动配置、控制库、后台凭据/地址、语音配置摘要及 ZeroBot/NapCat 进程保持。本轮无产品代码修改、无新构建或日常服务重启,不重跑已通过的 201 项界面与 64 项布局测试;本次真实页面流程补充 DEV-64 后当前程序的验收证据,不代替真实模型或其他 QQ 角色联调。
diff --git a/docs/local-admin/ai-config-check.md b/docs/local-admin/ai-config-check.md
new file mode 100644
index 0000000000..f98ed2150f
--- /dev/null
+++ b/docs/local-admin/ai-config-check.md
@@ -0,0 +1,17 @@
+# AI 基础配置检查
+
+登录本机后台,进入“AI 设置”,在表单上方查看“AI 配置检查”。分别显示聊天/GPT、Agent、识图的接口类型、模型名、接口地址和密钥是否填写;读取的全局配置包含程序默认值。聊天/GPT共享普通聊天接口,Agent使用自己的接口和密钥;识图是可选配置。
+
+检查接口类型是否支持,以及地址是否满足当前保存规则(完整HTTP/HTTPS、有效端口、不含URL认证参数);地址值、模型名、密钥内容不进入摘要。遗留地址含隐藏认证参数时提示需检查,原配置仍保留、不自动修复。OLLaMA接口允许无密钥,OpenAI/GenAI需要独立密钥;识图模型名可留空,需按接口要求核对。
+
+“基础字段检查通过”仅表示以上字段满足本机检查条件,不验证密钥有效性、模型是否存在、余额、网络连接、工具调用或图片能力。识图未配置不阻止文字聊天。Agent还取决于已读取会话的模式、插件开关、响应规则、@和权限;/gpt仍走聊天接口。真实模型和QQ业务联调见原待办。
+
+摘要在成功读取AI设置或保存全局模型后更新。未保存字段/密钥动作、群号或私聊QQ输入、会话参数草稿和放弃操作不会改变检查结果;会话参数保存也不覆盖全局草稿或摘要。读取/保存失败保留最近成功结果并显示错误,重新加载需按原规则确认放弃草稿;退出或会话过期清空,旧请求不能回填。未接入配置插件或旧版接口无检查结果时提示不可读取,不自行推断“通过”。
+
+本页检查只读,不另发模型请求,不花费模型调用额度、不创建配置备份、不改变原聊天/Agent/识图行为。编辑和保存仍走原确认、版本冲突与写前备份流程。
+
+验收:刷新AI页核对已保存的聊天/GPT与Agent基础状态,以及未配置的可选识图。编辑模型名或密钥动作但不保存,摘要应保持;放弃草稿后仍显示原结果。无需为验收清除日常密钥;缺项、地址和失败组合使用隔离配置验证。
+
+自动化入口:internal/localadmin/ai_checks_test.go 新增4项Go用例,覆盖三类类型/密钥/必需及可选模型、地址保存规则、原始快照与脱敏、只读无备份、认证/保存/冲突和插件不可用。scripts/test-ai-check-ui.mjs 新增4项离线Chrome用例,覆盖缺项/OLLaMA/可选识图、草稿及会话参数保存、失败/旧接口/字段内容隐藏和退出/过期/迟到请求。专项目标测试、121项完整界面回归、64项页面布局与深浅/窄屏截图、相关模块测试和静态检查通过。20:00:52完整Windows隔离回归通过,aiConfigChecksPassed=true,默认读取/网页保存/模拟QQ修改后的摘要与同一返回快照一致,只读不创建备份,原功能回归通过。20:02:06部署后后台HTTP 200、正确账号OneBot正常,NapCat进程及登录保持,ZeroBot身份单实例正常,启动日志无错误;原AI配置、控制库、启动配置及后台凭据不变。未发真实QQ消息或调用真实模型,真实业务待办继续保留。证据 .local/smoke-admin/run-999635738/result.json、.local/ai-check-deployment.json,详见 [自测记录](self-test.md)。
+
+AI设置页会按表单汇总未保存字段名称和数量,密钥动作/输入/确认合并计数,不显示新旧值;范围以该表单成功加载目标为准,详见 [AI未保存字段摘要](ai-draft-fields.md)。
diff --git a/docs/local-admin/ai-conversations.md b/docs/local-admin/ai-conversations.md
new file mode 100644
index 0000000000..c0b1423860
--- /dev/null
+++ b/docs/local-admin/ai-conversations.md
@@ -0,0 +1,35 @@
+# AI 群聊与私聊参数
+
+登录本机后台进入“AI 设置”,在会话范围中选择“指定群”或“指定私聊”,填写对应群号或与机器人聊天的用户 QQ,点击“查看会话参数”。私聊填写用户账号,不是机器人账号。输入编号必须是正整数,不能填写内部负号。
+
+聊天参数可分别修改触发概率、温度、Agent、语音和响应 @;GPT 的温度单独保存。后台和聊天配置指令共用同一会话数据:群号与相同数字的私聊 QQ 属于两个不同会话,互不覆盖。保存前备份,成功后供后续消息使用,无需重启;不会修改其他会话、模型密钥或人物设定。
+
+切换会话前会提示未保存更改,取消则保留编辑;读取失败保留原表单和已加载范围。每个表单标题标明当前实际群号或私聊 QQ。下拉框和输入框用于选择下一次读取的范围,仅改变它们不会改变已加载表单的保存目标。
+
+私聊设置不会放宽 Agent 动作权限,也不会自动开启插件或会话响应,不会发送测试消息。`/gpt` 始终使用聊天接口,私聊不存在群总结;部分开关只在原聊天逻辑适用时产生效果。普通成员能执行的 Agent 动作仍受原权限表约束。
+
+## 自测范围
+
+新增两个后端用例验证私聊和同号群/其他私聊隔离、共享聊天控制数据、未编辑全局与开关/未知位保留、写前备份与失败保护、版本冲突、控制库关闭重开、认证/CSRF及非法或双目标。最初重开夹具漏注册 AI 扩展选项,按实际插件注册方式补齐后两个用例通过。原后台/基础指令/LLM 模块和静态检查通过。
+
+33 项离线界面测试通过,新增私聊读取/保存/刷新、未读取选择不改变保存目标及取消/失败保护;其中七项使用真实 Chrome 的合成 AI 页面,全部阻止网络请求,不读取实际密钥或登录会话。56 项深浅主题与 320~1280px 布局通过,并核对小屏 AI 页面截图。证据 .local/appearance-layout/result.json;测试入口 internal/localadmin/ai_conversations_test.go、scripts/test-ai-draft-ui.mjs。
+
+## 暂停记录
+
+测试构建保留在 .local/bin/zerobot.exe,SHA256:7F8D6844276A8F1BBCE1607EB12AE97A8FB2693803BE5841266B485B730EDC1F。部署脚本 .local/deploy-ai-conversations.ps1 已准备且语法检查通过,但未执行。日常启动配置、控制库、模型密钥与人物设定未修改,当前正式二进制仍为上一版本。NapCat 已启动,最近检测为已扫码但未确认;ZeroBot 未恢复启动。按用户要求停止后续登录处理、服务操作与部署。
+
+恢复开发后先核实机器人账号登录与 OneBot 状态,再备份并部署测试构建;部署后才进行实际页面与私聊业务验收。
+
+## 2026-10-08 恢复与目录核对
+
+用户要求继续开发;已核实正确账号 ready 与 OneBot API 正常。首次部署在停止服务前发现运行记录指向 C 盘另一份项目,身份保护拒绝停止,正式程序未替换。C 盘实例占用 3010,当前 D 盘开发任务 Ready,两份目录不是联接;已请求确定正式运行目录,现有实例保持运行。
+
+近期人物默认 YAML 已更新,本轮保留旧测试构建后重新构建当前源码。后台/基础/LLM/chat 模块通过;03:32:08 完整 Windows 隔离回归通过,全部标志为 true,未发送真实 QQ 消息。证据 .local/smoke-admin/run-3224432393/result.json。最新测试构建 SHA256:2E49D69D6163C59E9944F528774A87AD13E88EEE57BFFEB8E7E331E4AC16CE9C。旧 2026-10-07 测试摘要仅作历史记录,实际部署必须使用此次重新测试版本。
+
+下一步:用户确定目录后,备份目标目录的程序与控制库,仅切换明确对应的 ZeroBot 实例,保留 NapCat 登录;验证新控件、后台认证、配置摘要及实际账号/OneBot 握手。尚未完成部署或人工私聊验收。
+
+## 2026-10-08 路径判断与部署
+
+此前只检查末级目录,遗漏 C 父目录联接,关于两份项目的初步判断不准确,已更正:<项目目录> 联接到 <项目目录>,两条项目路径实际相同,无需选择或迁移。标准路径为 D 盘;旧 C 盘入口兼容,详见 [Windows 服务路径](windows-paths.md)。
+
+AI设置页会按表单汇总未保存字段名称和数量,密钥动作/输入/确认合并计数,不显示新旧值;范围以该表单成功加载目标为准,详见 [AI未保存字段摘要](ai-draft-fields.md)。
diff --git a/docs/local-admin/ai-discard.md b/docs/local-admin/ai-discard.md
new file mode 100644
index 0000000000..2082e5f384
--- /dev/null
+++ b/docs/local-admin/ai-discard.md
@@ -0,0 +1,11 @@
+# AI 按表单放弃更改
+
+登录本机后台进入“AI 设置”,读取配置后,每个表单的保存按钮下方提供“放弃此表单更改”:全局模型与人物设定、聊天会话参数、GPT温度分别操作。只有该表单存在未保存更改时可点击。先确认表单名称或已读取的群/私聊标题,取消则保持全部编辑;确认后只恢复该表单最近读取或保存成功时的页面值,其他表单的草稿、选择及保存目标保持。
+
+放弃全局表单会清空未保存的新密钥输入,恢复“保留当前密钥”、隐藏清除确认,并恢复该表单的模型、地址与人物设定等字段;不会读取或显示已有密钥。放弃聊天参数或GPT温度只恢复对应表单,不修改全局模型或其他会话表单。范围输入尚未读取时,仍以已加载表单的标题为准。
+
+操作只恢复页面草稿,不发送请求、不创建备份、不撤销已保存的配置,不影响对话记录或正在执行的机器人任务。出现版本冲突后,放弃只恢复上次成功读取的页面值;需要重新加载设置以查看最新保存值。原保存、写前备份、后续请求生效、密钥替换/清除确认及版本保护保持。
+
+读取或保存期间禁止放弃,退出或会话过期清空草稿;旧确认和旧请求不能恢复新会话的输入。没有读取配置或没有更改时按钮禁用,AI插件未编译时不能操作。草稿与基线只在页面内存,不写浏览器存储或文件。
+
+验收:分别修改人物设定、聊天参数和GPT温度,不保存;放弃其中一项先取消再确认,核对其他两项仍有草稿。选择替换密钥时可用不含真实凭据的测试文字,放弃全局后确认新值清空、动作回到保留;无需保存或调用模型,忙时/冲突/过期场景用隔离测试覆盖。
diff --git a/docs/local-admin/ai-draft-fields.md b/docs/local-admin/ai-draft-fields.md
new file mode 100644
index 0000000000..d6d393a6c3
--- /dev/null
+++ b/docs/local-admin/ai-draft-fields.md
@@ -0,0 +1,15 @@
+# AI 未保存字段摘要
+
+登录本机后台,进入“AI 设置”,读取配置后修改表单。页面上方“未保存字段摘要”按全局模型/人物设定、聊天会话参数、GPT温度三个表单列出已改动字段名称和数量,输入或选择变化后立即更新,改回最近读取或保存成功的页面基线会从摘要中移除。
+
+摘要只显示固定字段名称,不显示新旧值、密钥输入、接口地址、提示词或人物设定正文。每个密钥的动作、输入和清除确认合并计为一个字段;列为“聊天密钥”等不代表已保存或密钥有效。未知表单字段统一显示“其他表单字段”,不展示其名称或内容。
+
+全局表单使用全局范围;聊天/GPT摘要明确采用该表单成功读取或保存时的群号或私聊QQ,未点击“查看会话参数”的范围输入不会改变摘要目标。参数与同号群/私聊仍按原规则隔离。仅比较页面基线与当前输入,不查询服务端,不表示已经生效、配置有效或与外部最新修改一致;数字等沿用原草稿检测的输入文字比较方式,输入形式改变也可能计为未保存。
+
+没有草稿时摘要隐藏。单独保存或放弃某一表单只更新该表单摘要,其他编辑保留;请求处理中保留摘要并提示等待,不自动保存。读取或保存失败/冲突保留原摘要,成功重新读取才替换;切换后台页面保留草稿与摘要,退出、会话过期或成功恢复配置后清空,迟到结果不能回填新会话。
+
+本功能只帮助保存前核对,不产生额外请求、备份或配置写入,不发送QQ消息或调用模型。仍须在对应表单点击保存;生效时机、校验、备份、版本冲突、密钥清除确认和原编辑保护保持。
+
+验收:分别修改人物设定、温度、密钥动作等,核对固定字段名称/合并数量/已加载范围;改回原值或确认局部放弃后摘要正确更新,无需保存日常配置或发送真实QQ消息。实际页面体验待验收。
+
+## 自动化验证与部署
diff --git a/docs/local-admin/ai-drafts.md b/docs/local-admin/ai-drafts.md
new file mode 100644
index 0000000000..dba59a602c
--- /dev/null
+++ b/docs/local-admin/ai-drafts.md
@@ -0,0 +1,19 @@
+# AI 设置草稿与编辑保护
+
+登录后台进入“AI 设置”,修改全局模型、人物设定、密钥操作或群参数后,页面提示哪些表单有未保存的更改。把字段改回读取时的值,会取消该表单的未保存标记。全局配置、聊天群参数和 GPT/群总结温度分别保存;成功保存只清除对应表单的标记,失败或版本冲突保留草稿。
+
+切换到其他后台页面再返回时,保留当前 AI 表单与草稿,不自动重新读取覆盖编辑。需要获取聊天指令或其他页面的新设置时,点击“重新加载设置”。重新加载、查看其他群参数或退出登录会提示是否放弃尚未保存的更改;取消则保持页面和草稿。读取失败时保留原编辑,成功读取后才替换表单。保存请求尚未结束时,退出登录提示等待保存结果。
+
+刷新或关闭浏览器标签页时,会在浏览器允许的条件下触发原生离开提醒;浏览器崩溃、强制关闭或未发生用户交互时可能不显示提醒。它不会自动保存,仍需点击相应的“备份并保存”按钮。
+
+草稿仅保留在当前页面内存,不写浏览器本地存储、不导出、不发送到模型接口。新密钥仍使用密码输入;退出登录或会话过期立即清空 AI 表单和草稿,重新登录后读取已保存配置。草稿不是备份,现有写前备份、生效规则、权限及版本冲突保护保持不变。
+
+## 自测
+
+五项真实 Chrome 离线合成页面测试通过,覆盖字段恢复、跨页保留、密钥动作/密码输入/不持久保存、取消重新加载/切群/退出、读取失败、全局与两类群表单分别保存、版本冲突/清除确认、会话过期清空、旧 401 不结束新会话、旧请求不解除新请求禁用、请求中退出阻止及 beforeunload 事件。入口 scripts/test-ai-draft-ui.mjs;不读取真实密钥或浏览器登录会话,不发送真实 QQ 消息。
+
+## 部署与人工体验
+
+刷新后台 AI 页面,临时修改人物设定,应出现未保存提示;切换插件管理再返回,编辑仍保留。取消重新加载应保留编辑,改回原值后提示消失。无需保存临时设定或改动密钥。人工体验见 TODO-AI-DRAFT-01,真实 QQ 的完整 AI 业务仍按原待办处理。
+
+AI设置页会按表单汇总未保存字段名称和数量,密钥动作/输入/确认合并计数,不显示新旧值;范围以该表单成功加载目标为准,详见 [AI未保存字段摘要](ai-draft-fields.md)。
diff --git a/docs/local-admin/ai-persona.md b/docs/local-admin/ai-persona.md
new file mode 100644
index 0000000000..0562a45ab3
--- /dev/null
+++ b/docs/local-admin/ai-persona.md
@@ -0,0 +1,32 @@
+# DeepSeek 风格小人人物设定
+
+## 当前人物
+
+Agent 使用 DeepSeek 社区「鲸鱼娘/蓝色大肥鱼」风格的 Q 版小人:蓝发蓝瞳、圆润大头、小巧身体,穿蓝白宽松连帽衫,带鲸鱼鳍发饰和小鲸尾装饰。称呼沿用机器人配置中的昵称;「小鲸」「蓝色大肥鱼」是可自然接住的昵称。该设定是参考社区形象的虚拟角色,不宣称是官方吉祥物或真实人物。
+
+性格聪明、亲切、好奇,带一点软萌和轻微傲娇;偶尔呆萌自嘲,喜欢美食、游戏和计算机。吃饭、摸鱼等梗只在合适的闲聊里出现,认真提问先解决问题,不把每句话都写成角色表演。
+
+## 查看和修改
+
+1. 启动机器人后,打开本机后台 `http://127.0.0.1:3010/`,用后台密码登录。
+2. 进入「AI 设置」,查看全局表单中的「Agent 人物设定」与「Agent 性别」。本次替换的是这两个字段,性别为「女」。
+3. 按需编辑后,点击全局表单的「备份并保存」。保存会创建控制数据库备份,后续 Agent 请求使用新设定;已有对话记录保留。
+4. 在已经开启 Agent 的会话中,与机器人聊天或询问「介绍一下你的形象」。群聊沿用原有 @、插件开关和响应条件;主人私聊可使用 Agent,普通成员私聊仍受原动作权限限制。
+
+主人也可在私聊发送 `查看AI聊天Agent性格` 查看已保存设定。人物设定是实例共享的,全局修改会影响该机器人接下来的各群和私聊 Agent 请求,不会自动启用某个会话的 Agent。
+
+本次更新的是文字人物设定,不会自动设置 QQ 头像、生成立绘或改变后台图标。普通聊天、`/gpt` 和群总结使用独立的「系统提示词」,本次保留其原值,因此不保证这些入口使用同一人物风格。
+
+## 源码默认值与恢复
+
+源码默认设定在 `third_party/zbputils/chat/char.yaml`。它在构建时嵌入程序,已保存的「Agent 人物设定」优先于源码默认值。只编辑 YAML 不会覆盖现有数据库;本次同时更新了数据库中的人物设定和性别,服务停止时写入的设置在下次启动后使用。
+
+主人私聊命令 `重置AI聊天Agent性格性别` 会恢复**当前运行程序构建时**的默认设定。旧程序仍包含旧默认值;在使用包含新 YAML 的构建前,请通过后台编辑或恢复备份来调整,避免用该命令恢复出旧形象。
+
+需要恢复之前的人物设定时,可在「备份恢复」预览本次控制数据库快照。恢复整个控制数据库也会恢复该快照中的其他控制设置,先核对预览;仅恢复人物时,可从保留的旧设定修改上述两个字段。不要清空对话档案来切换人物。
+
+## 本次应用与验证
+
+人物来源参考:[DeepSeek-chan 社区形象与表情包说明](https://github.com/the-beating-light-of-the-nail/deepseek-chan-meme-pack)。蓝白服装、鲸鳍发饰和 Q 版比例为本项目的具体设计选择。
+
+验证记录为本机 `.local/whale-persona-verification.json`。本次没有替换运行程序或启动服务;新默认 YAML 将在以后构建时嵌入,已保存的人物设定在现有程序下次启动时即可使用。本次不发送真实 QQ 消息、不调用模型;真实聊天语气由用户后续体验验收。
diff --git a/docs/local-admin/ai-role-permissions.md b/docs/local-admin/ai-role-permissions.md
new file mode 100644
index 0000000000..1802de4e84
--- /dev/null
+++ b/docs/local-admin/ai-role-permissions.md
@@ -0,0 +1,43 @@
+# AI 角色权限与隔离验证
+
+## 使用与权限
+
+在已响应且相关插件已启用的会话中使用`/gpt 问题`;群内普通聊天/Agent按既有@和触发概率规则处理。群总结仅群主、管理员或机器人主人可使用,GPT与总结共用每会话30秒额度。机器人主人由启动配置`zero.super_users`判定;群主/管理员身份来自当前群消息,机器人自身的管理员身份不会提高发言者权限。
+
+| 发言者 | Agent角色 | AI会话参数命令 | 全局AI设置与上下文重置 |
+| --- | --- | --- | --- |
+| 机器人主人 | owner | 当前群/私聊允许 | 允许;模型地址/密钥/人物等要求主人私聊 |
+| 非主人群主 | admin | 当前群温度、概率、语音输出允许 | 不允许 |
+| 非主人群管理员 | admin | 当前群温度、概率、语音输出允许 | 不允许 |
+| 群普通成员 | user | 不允许 | 不允许 |
+| 非主人私聊用户 | user | 不允许 | 不允许 |
+
+响应AT与使用Agent模式的聊天开关仍仅主人可修改;群主和管理员不会因此拥有主人权限。后台密码登录采用独立后台权限,不由QQ角色授予。
+
+## Agent动作
+
+角色动作白名单沿用程序内权限表:普通成员可发送当前群消息、查询当前群信息及使用已有读消息/表情/记忆动作,不能执行群管理或@全体成员;群主和管理员可执行当前群白名单内管理动作,不能设置管理员、退出群或修改全局AI;机器人主人沿用原owner白名单。
+
+非主人白名单中带群目标的动作,包括`get_group_info`,只能作用于当前群;不能利用私聊或另一个群调用。普通成员的@全体成员检查同时覆盖OneBot消息数组和CQ文字格式。经过群目标校验的编号必须为正整数。动作即使在模型提示中出现,也必须通过程序权限与参数检查才执行。机器人自身权限和QQ平台限制仍可能令合法请求失败。
+
+非主人私聊保留原规则:GPT可用;Agent的user白名单不包含`send_private_msg`,不会为本轮测试增开私聊动作,普通聊天回退仍受原配置影响。主人私聊已有真实文字验收。
+
+角色代码升级并重启ZeroBot后对后续请求生效,无需修改日常插件/角色/密钥。隔离测试覆盖角色选择、白名单、当前群隔离、两种@消息格式与命令拒绝后的实际状态。真实验收可使用另一个非主人账号,也可按下述方法临时移除现有账号的主人资格;仅修改QQ群头衔不会移除`zero.super_users`中的主人资格。真实Agent动作不能用模拟结果替代,不在真实群演练禁言、踢人或退群。
+
+## 使用现有账号验证真实角色
+
+主人资格由启动配置决定,临时移除后需重启ZeroBot才能对后续消息生效,并影响该账号的所有会话。操作前备份原配置、记录控制库一致快照和原群身份;准备独立于QQ权限的本机恢复途径及有明确期限的自动恢复。不能让主人列表为空,本轮用机器人自己的QQ87654321作临时保留项,没有授予其他真人主人权限。测试结束立即恢复原配置并重启ZeroBot;自动恢复是兜底,不能代替结束后的身份、程序、配置和QQ在线核对。
+
+在已响应且AI插件启用的测试群,普通成员发送`/gpt 请只回复‘普通成员测试成功’`,随后不加@发送`设置AI聊天温度51`:文字问答可用,温度不得改变。非主人私聊发送专用`/gpt`测试,再发送`查看AI聊天配置`:不得返回真实配置。无权执行的配置命令可能进入普通聊天并收到AI回复,应核对实际配置及回复内容,不能将“有回复”直接当作配置权限泄露。
+
+管理员测试由群主临时授予该账号管理员,机器人主人资格仍保持移除。发送前核对新鲜成员信息和消息上报使用的缓存身份;用`get_group_member_list`的`no_cache=true`请求刷新,再读取缓存列表确认角色。本机NapCat v4.18.28的刷新可能异步完成,应等待并再次核对,单次新鲜`get_group_member_info`查询不能证明消息缓存也已更新。捕获专用测试消息的实时`sender.role`,不根据事后历史查询倒推当时角色。
+
+当前群原温度位为0时,不加@发送`设置AI聊天温度0`应回复“成功”,0沿用默认温度0.7,因此这次验证不改原参数。随后发送`设置AI聊天不使用Agent模式`,非主人管理员不得修改此主人专属开关。其他群的原参数可能不同,测试前必须确定基线及恢复方式,不能直接照抄0作为通用不改数据的操作。结束后移除临时管理员并刷新、核对普通成员缓存,再恢复原主人配置。
+
+机器人在群内为群主,只说明它可执行授权的角色切换,不代表真人发言者是群主。验证真人群主时应选择测试账号本来就是群主的授权群,再临时移除该账号的机器人主人资格;不需要为此转让群所有权。上面的账号和群号仅为示例。
+
+## 第一版已验证范围
+
+普通成员群GPT与会话参数拒绝、非主人私聊GPT与配置未泄露、管理员和真人群主的温度命令允许/主人专属开关拒绝已验证,原身份与配置已恢复。主人真实Agent查询、保存记忆、群回复和单次重启后的延续也已通过。
+
+剩余非主人Agent真人动作测试已取消,不写成实测通过;保留隔离角色与动作边界证据。具体步骤见[Agent验证](real-agent-acceptance.md),验证摘要见[自测](self-test.md)。
diff --git a/docs/local-admin/ai-select-appearance.md b/docs/local-admin/ai-select-appearance.md
new file mode 100644
index 0000000000..8a2e3c5b81
--- /dev/null
+++ b/docs/local-admin/ai-select-appearance.md
@@ -0,0 +1,11 @@
+# AI 下拉框的主题与小屏显示
+
+聊天、识图、Agent 的接口类型,以及三种密钥操作下拉框统一使用页面的浅色/深色配色和全宽控件。点击高度至少 44px;640px 及以下字体为 16px,与其他表单保持一致。通过“外观”选择主题后即时显示;程序更新后刷新后台页面使用新样式。
+
+本次只调整显示和点击区域,不修改接口类型选项、密钥保留/替换/清除规则或保存目标。密钥当前值仍隐藏,清除仍须明确确认,群与私聊的参数保存方式保持。
+
+真实Chrome在375px窗口发现原6个下拉框为19px高、13.333px字体,并使用浏览器原生灰色背景;记录位于`.local/admin-acceptance/run-2489516078/ai-select-before.json`。修复版已通过离线和真实Chrome布局验证,19:10:50已部署;开发与自测记录为DEV-66 / REQ-65 / TC-86。
+
+## 验证与交付
+
+201项界面行为、离线64项布局、Go后台测试/vet/Windows构建通过,19:06:39完整独立Windows回归全部标志通过(.local/smoke-admin/run-1797868651/result.json)。修复版真实Chrome的320/375/640/1280px×深浅×登录/七后台页面64项矩阵通过;375px实际6个控件为51px高/16px字体/全宽/背景匹配,主题刷新与跨标签同步正确。run-4024527018已停止,视口及测试主题恢复。两类64项分别记录,不计作新增128项用例。原日志控件另完成12组核对,实际下载文件内容和原生日期输入明确保留待办;首轮下载等待超时/正文为空、日期工具报错、测试实例正常到期及视口定位/只读计算观察问题均保留,不改写为产品失败或全部通过。详见[日志与主题自动验收](logs-appearance-browser-acceptance.md)。
diff --git a/docs/local-admin/ai-settings-2026-10-02.md b/docs/local-admin/ai-settings-2026-10-02.md
new file mode 100644
index 0000000000..0e1d4857f0
--- /dev/null
+++ b/docs/local-admin/ai-settings-2026-10-02.md
@@ -0,0 +1,57 @@
+# AI 设置与基础缺陷修复
+
+## 实现范围
+
+后台新增“AI 设置”,管理聊天、识图与 Agent 的接口类型、地址、模型和独立密钥,以及提示词、最大输出长度、TopP、分隔符与推理努力度。指定正整数群号后,分别管理聊天群参数和 GPT/群总结温度。没有另建一份网页配置:与原聊天配置指令共用 `data/control/plugins.db` 的扩展数据和群参数。
+
+保存成功后,后续消息读取新配置,不需要重启;已经开始的请求继续使用其配置快照。全局配置变化会清理 Agent 实例,后续创建新实例。配置修改不会替用户切换插件启停或响应/沉默,也不会自动调用真实模型。
+
+| 设置 | 校验与含义 |
+| --- | --- |
+| 接口类型 | OpenAI、OLLaMA、GenAI;识图和 Agent 使用各自类型与密钥 |
+| 接口地址 | 完整 HTTP/HTTPS 请求地址;新地址不允许用户名、密码、查询参数或片段;端口 1~65535;认证放在独立密钥字段 |
+| 聊天模型名 | 非空;识图、Agent 模型名和地址可留空,保留原回退规则 |
+| 密钥 | 默认保留;替换需输入新值;清除需明确选择并确认;普通读接口只返回是否已配置 |
+| 最大输出长度 | 0~131072;0 使用默认 4096,实际支持范围仍取决于模型 |
+| TopP | 0~1;0 使用默认 0.9 |
+| 系统提示词、Agent 性格 | 最长 32768 个 UTF-8 字节;其他普通文本最长 256 字节 |
+| 聊天非 @ 触发概率 | 0~100 百分比;与响应 @ 开关分别设置 |
+| 群温度 | 0~100 百分比;0 保留原默认值 70,实际 0.7;聊天与 GPT/群总结分别保存 |
+| 其他聊天群参数 | Agent 模式、QQ AI 语音输出、响应 @;保存时保留其他位、默认标志和插件开关 |
+
+首次没有持久化 AI 配置时展示原默认配置;无密钥的部分设置不会被初始化逻辑清除。OLLaMA 可使用无密钥接口;OpenAI/GenAI 聊天需要配置独立密钥。
+
+## 持久化与凭据
+
+网页和聊天入口通过同一 Manager 锁执行读取、合并、备份和提交;群参数使用事务保留继承标记。提交成功后才更新缓存或清理 Agent;备份/SQL 失败保留原设置。网页旧版本提交返回冲突,不覆盖聊天或其他网页的新修改。全局版本使用进程内随机密钥的 HMAC,重启后旧页面需重新加载。
+
+实际修改前备份整个控制库至 `.local/admin/backups/*-control/`,包括当时的 AI 密钥,沿用本机私有权限;普通读接口与静态网页不公开这些备份。没有修改则不重复备份。该钩子也覆盖共用扩展写入口;群参数备份仅安装在 `aichatcfg` 和 `llm`,避免业务计数每次变化都产生备份。沉默、黑名单等其他写入以及完整恢复页面仍待后续阶段。
+
+未修改字段和未知扩展字段原样保留。含认证信息的旧 URL 只展示隐藏后的地址,不编辑就保留原值。配置展示隐藏已知密钥;模型请求失败不回显提供商响应正文、请求头或密钥。新设置不允许在 URL 中放认证,旧地址不会被自动改写。
+
+## GPT 与总结修复
+
+- `/gpt 问题` 只在文本开头匹配,并要求命令后有空白边界;支持首尾空白及回复消息。普通句子提及 `/gpt`、`/gptabc`、`/gpt问题` 不触发该处理器;空命令给出用法。
+- 被回复消息的文字与当前问题以换行拼接。长回复按最多 1000 个 Unicode 字符分段,保留全部换行,中文及 emoji 不截断 UTF-8。
+- GPT 和群总结共享每个群/每个私聊 30 秒一次的请求额度;不同群、不同私聊分别计算。失败尝试也占用本次窗口;并发请求受锁保护,额度表有容量上限。原有同时请求保护保留。
+- 协议构造失败返回非 nil 错误;模型 HTTP 请求最长 30 秒,网络或提供商错误返回不含凭据的提示。
+
+页面说明了模型接收的数据:聊天消息、上下文和系统提示词;回复消息附带被回复文字;群总结附带所选近期消息的昵称和文字,最多 1000 条。
+
+## 验证与限制
+
+自动化覆盖保存/读取一致性、未知字段保留、数据库重新打开后持久化、群隔离、密钥保留/替换/确认清除、URL 脱敏、非法输入、并发冲突、备份/SQL 故障与事务回滚。GPT 单元测试覆盖三种协议构造、非 nil 错误、超时、提供商错误脱敏、Unicode 分段与限速边界。
+
+完整 Windows 程序使用独立临时数据库、合成 QQ 事件和本地模拟 OpenAI 服务;只使用假密钥,不发送真实 QQ 消息或付费模型请求。真实模型兼容性、AI 页人工交互及 Agent 操作/识图/语音的全部业务组合尚未验收;不能据此宣布整个 P0 完成。
+
+原认证、插件注册表、网页/聊天开关、连接保存与备份、27 个群响应场景、5 个全局场景、帮助文字及服务列表图片同时通过。测试开始前仅在临时控制库禁用词典闲聊和概率回复,避免外部闲聊的随机延迟干扰命令断言;日常插件配置不受影响。
+
+## 本机部署
+
+后续用户确认指令可正常触发;尚未设置模型密钥,无法进行真实 AI 功能测试。已记录 [TODO-AI-01](todo.md),保留全部模拟验证证据,按用户要求先继续日志等其他功能开发。
+
+后台 HTTP 200,AI 导航及包含最后表单修正的 `/ai.js` 可访问;未认证 AI 读取、全局保存和群参数保存均为 401。实际程序与最终测试构建摘要一致;启动 JSON 和控制数据库文件摘要与升级前一致;正确账号的只读 OneBot API 与 ZeroBot 握手通过,没有启动 fatal/panic。证据 `.local/ai-settings-deployment.json`。本次没有填写真实模型密钥或更改日常 AI/插件设置。
+
+当前GPT引用与新要求的分隔方式已更新,2026-10-02的直接换行拼接是历史行为;现行规则和真实主人私聊修复见[GPT引用原文与本次要求](gpt-quoted-message.md)。
+
+程序SHA-256 D9241B7A7D491897979B908454C9F8273FD0073D26CC5A851B581EE47DCF9986,测试实例启动2次,已stop正常结束。结果.local/ai-workflow-browser-result.json及.local/admin-acceptance/run-1172851902/browser-ai-result.json,截图browser-ai-verified.png;启动器manualAcceptancePassed=false仅表示它未代替真实浏览器操作,浏览器结论单独记录。两次早读状态与一次完整截图超时保留于browser-initial-timing-note.txt,完成后重新核对及可视区域截图通过。17:33:01前后摘要/进程核对日常程序、启动配置、控制库、后台密码/地址、语音配置及ZeroBot/NapCat保持,未发送真实QQ消息或调用真实模型。本轮无产品代码/构建/日常重启,不重复201项界面与64项布局回归。关闭TODO-UI-AI-01,TODO-UI-01继续日志/主题/小屏等剩余范围,TODO-AI-01其他真实角色保持待验收。
diff --git a/docs/local-admin/appearance-2026-10-03.md b/docs/local-admin/appearance-2026-10-03.md
new file mode 100644
index 0000000000..33a9202869
--- /dev/null
+++ b/docs/local-admin/appearance-2026-10-03.md
@@ -0,0 +1,42 @@
+# 主题与小屏验证
+
+用户已确认日志可以正常下载,TC-28 的实际下载操作记为通过;其他导出人工矩阵保留原边界。本次继续实现外观和窄窗口布局。
+
+## 使用与行为
+
+登录页与后台顶栏新增“外观”,支持跟随系统、浅色、深色。默认跟随系统;明确选择浅色/深色后,系统变化不会覆盖选择。初始主题在页面内容加载前应用,两个选择器保持同步。
+
+只将选择写入当前浏览器、当前站点的 `zerobot.localadmin.theme.v1`,不写机器人配置或凭据。重开/退出后保留,同站点其他标签页通过存储事件同步。存储不可用仍可在当前页切换;刷新恢复跟随系统。未知存储值按跟随系统处理;不支持系统配色查询时回退浅色。
+
+所有页面的表单、插件卡片、日志、错误/状态徽标及用法弹窗使用同一套明暗配色。正文、辅助文字、按钮、错误和警告的配色对比度检查均达到 4.5:1。保留键盘焦点提示。
+
+640px 及以下,导航移动到顶部并保留名称,移除计数徽标以留出空间;表单、页标题和按钮按需换行。输入控件使用 16px 字体,常用按钮至少 44px 高;长插件名、日志和备份编号可折行。后台仍仅本机访问,不提供手机远程访问。
+
+## 验证
+
+| 项目 | 结果 |
+| --- | --- |
+| `CGO_ENABLED=0 go test ./internal/localadmin -count=1 -timeout=120s` | 通过,含新增离线资源及原后台接口回归 |
+| `go vet ./internal/localadmin`、`go vet scripts/smoke-local-admin.go` | 通过 |
+| `node --check internal/localadmin/assets/theme.js`、渲染脚本语法 | 通过 |
+| `node --test scripts/test-theme-ui.mjs scripts/test-log-ui.mjs scripts/test-backup-ui.mjs` | 13 项全部通过;新增 4 项验证初始化/系统切换、选择保留/跨标签、异常偏好/存储禁用及配色对比度 |
+| `node scripts/test-appearance-layout.mjs` | 56 组通过:320/375/640/1280px × 浅色/深色 × 登录及六个后台页面;没有横向溢出,主题控件与具名导航可见 |
+| 截图核对 | 保存 28 张 375/1280px 截图,核对明暗、登录、概览、连接和插件页面;发现并修正小屏计数徽标选择器优先级,再次渲染通过 |
+| `go build -trimpath -o .local/bin/zerobot.exe .` | Windows CGO=0 最终构建通过 |
+| `go run scripts/smoke-local-admin.go -binary-name zerobot.exe` | 00:34:12 最终整机回归通过,`appearanceAssetsPassed` 及全部原标志为 true;日志导出、原指令、插件开关及备份恢复正常 |
+
+独立渲染证据 `.local/appearance-layout/result.json` 及同目录 PNG。渲染脚本使用新的无登录 Chrome 上下文,将静态 HTML/CSS/主题脚本和合成数据直接载入,阻止全部网络请求;没有访问实际后台、实际账户或操作用户已登录的 Chrome。此项验证布局与静态外观,不代表日常数据或完整业务交互。
+
+整机证据 `.local/smoke-admin/run-670955380/result.json`。使用独立配置、模拟 OneBot 和本地模拟模型,不发送真实 QQ 消息或调用真实模型。早期构建结果不作为最终部署证据。
+
+## 本机更新
+
+更新前控制库快照 `20261002T163519.698259200Z-5fcc5e440b8b20b3-control`;旧程序 `.local/bin/zerobot-before-appearance-20261003-003513.exe`。仅重启 ZeroBot,NapCat 登录保留;预期账号、OneBot 只读 API 与握手通过。启动 JSON 与控制库摘要未变,启动 fatal/panic 未发现,所有原未认证接口拒绝检查通过。证据 `.local/appearance-before.json`、`.local/appearance-deployment.json`。
+
+既有外部 MD5 校验服务器不可达警告仍存在;本次没有将跳过远端校验记作资源完整性通过。主题不触发后台 API 写入,不改变日志导出范围、密钥及响应的 @ 规则。
+
+## 剩余验收
+
+刷新后台重新登录后可使用新控件。日常页面、真实数据和完整手工矩阵继续记为 TODO-UI-01;上述独立浏览器渲染不能代替业务验收。真实 AI 仍为 TODO-AI-01,备份清理策略未实施;没有重复上一构建的持续性能测量。
+
+201项界面行为、离线64项布局、Go后台测试/vet/Windows构建通过,19:06:39完整独立Windows回归全部标志通过(.local/smoke-admin/run-1797868651/result.json)。修复版真实Chrome的320/375/640/1280px×深浅×登录/七后台页面64项矩阵通过;375px实际6个控件为51px高/16px字体/全宽/背景匹配,主题刷新与跨标签同步正确。run-4024527018已停止,视口及测试主题恢复。两类64项分别记录,不计作新增128项用例。原日志控件另完成12组核对,实际下载文件内容和原生日期输入明确保留待办;首轮下载等待超时/正文为空、日期工具报错、测试实例正常到期及视口定位/只读计算观察问题均保留,不改写为产品失败或全部通过。详见[日志与主题自动验收](logs-appearance-browser-acceptance.md)。
diff --git a/docs/local-admin/backup-catalog-export.md b/docs/local-admin/backup-catalog-export.md
new file mode 100644
index 0000000000..ee74d66268
--- /dev/null
+++ b/docs/local-admin/backup-catalog-export.md
@@ -0,0 +1,19 @@
+# 下载备份清单
+
+登录本机后台,进入“备份恢复”,读取列表后按编号、日期、范围或校验状态筛选,点击“下载筛选清单”。生成 UTF-8 JSON 文件,收录所有已加载且符合当前筛选的记录,跨页保留;每页份数和当前页码不限制下载。零匹配也可下载空清单用于核对。生成后立即生效,不需重启机器人。
+
+清单包含备份编号、创建时间、范围、内容字节数、格式版本、是否含校验摘要及本次读取的可预览状态;附清单格式版本、下载时间、最近成功读取时间、加载和匹配数量、是否另有未加载记录、是否刷新失败。范围/状态筛选和是否使用关键词会记录,关键词原文不会写入文件。仅加载最新200份时明确标记有更早记录未列入。
+
+文件名以 zerobot-backup-catalog- 开头,可用文本编辑器打开。时间以UTC(末尾Z)保存,页面时间则按本机时区显示。scope字段中 startup-config 是启动配置,control-db 是控制数据库,admin-options 是后台启用与地址;unknown 表示无法确认的范围。source.loaded/matched 是已加载/匹配数量,more 为 true 表示还有更早未加载备份,lastReadFailed 为 true 表示最近列表刷新失败。无效编号、时间或数值使用 null,不猜测有效内容。
+
+这是当前页面缓存的目录快照。刷新失败后仍可下载上次结果,文件和提示标明失败;已加载记录不保证本机文件仍存在或可恢复,恢复时仍须原预览/校验/确认流程。清单不包含配置正文、密钥、数据库、聊天记录、恢复凭证或实际校验摘要,不是可用于恢复的备份文件,不提供清单导入。未知范围和无效元数据使用固定占位,不输出额外字段。
+
+加载、创建、恢复或清理进行中以及未登录、尚未加载列表时不能下载;切离本页禁止下载,退出或过期清除缓存与提示,旧请求不能回填到新会话。返回本页按原流程重新读取。下载在浏览器本地完成,不新增接口请求,不创建/删除备份,不修改机器人配置。文件按浏览器的下载设置保存;提示“已发起”不保证用户已保存文件。下载失败可检查浏览器设置后重试。
+
+验收:筛选控制库并切到第2页,下载后用文本编辑器查看,记录应包括该筛选下所有页;核对编号/数量、最近读取时间及部分加载提示。清除筛选后再次下载应恢复全部已加载记录。不要通过清单执行恢复,真实恢复继续使用原页面。
+
+自动化验证:新增4项离线Chrome下载与状态场景,备份相关19项/完整界面129项、64项页面布局及相关模块/静态检查通过;01:38:55完整Windows隔离回归与01:40:19本机部署通过,后台HTTP200、正确账号OneBot/握手正常,NapCat登录保持,原配置/控制库/后台凭据不变,当前启动无错误。合成页面截图已核对,下载行为实际Blob文件通过,用户已确认清单下载页面体验验收通过。证据 .local/smoke-admin/run-768448182/result.json、.local/backup-catalog-deployment.json;详见 [自测记录](self-test.md)。
+
+清单记录 `filters.sort`,备份条目顺序与所选列表排序一致,见 [备份列表排序](backup-sort.md)。
+
+备份列表可按本地开始/结束日期组合筛选,结束日期包含完整当天;列表、大小摘要与清单采用同一组已应用日期,清理策略和恢复目标保持,见 [备份日期筛选](backup-date-filter.md)。
diff --git a/docs/local-admin/backup-cleanup.md b/docs/local-admin/backup-cleanup.md
new file mode 100644
index 0000000000..278f4c8b53
--- /dev/null
+++ b/docs/local-admin/backup-cleanup.md
@@ -0,0 +1,13 @@
+# 备份清理使用说明
+
+入口为后台“备份恢复”的“清理旧备份”。填写每类保留数量和保留天数,先预览清单,再勾选永久删除确认并执行。仅由已登录的本机管理员操作,预览本身不删除数据。
+
+默认每种备份范围保护最近 10 份有效备份,同时保留近 7 天的备份。数量允许 3~200,天数允许 1~3650;只有同时超出数量保护且早于天数保护的有效备份才列入删除清单。按备份内记录的创建时间排序,不按文件修改时间。三类范围分别计数,每次最多处理 100 份,剩余部分需重新预览。参数仅用于本次手动预览,没有持久保存的定时清理策略。
+
+只处理本项目私有备份目录内、带校验摘要且结构符合预期的启动 JSON、控制数据库和后台选项备份。损坏、旧版无摘要、额外文件、链接或无法确认的内容保留,页面显示跳过数量。不会清理当前配置、后台凭据、QQ 登录、对话档案、长期记忆、日志或旧程序。
+
+预览有效期 10 分钟,与当前登录会话和可识别备份清单绑定;有效备份新增/修改/删除或条目可清理资格变化后,需要重新预览。修改清理参数、切换页面、重新登录或刷新也会丢弃页面预览。确认删除不能用配置恢复撤销,请先另存需要长期保留的备份。清理没有自动定时任务,无需重启。
+
+单次扫描最多 4096 个目录项、256 MiB 可识别备份文件,请求检查时限为 8 秒;扫描超限或失败时不会开始删除。清理请求执行前重新核对清单,每份删除前再次检查摘要和文件结构。删除只允许受控目录内的固定文件,不递归删除未知内容。
+
+出现请求中断或删除失败,刷新列表确认实际状态后再预览;已成功删除的备份不会自动恢复。多个备份的删除不是一个可回滚事务;控制库备份包含两个文件,中途失败可能留下不完整目录,页面会报告部分失败,下一次扫描跳过残留。异常文件或目录需本机人工检查,不能通过增加清理范围自动处理。
diff --git a/docs/local-admin/backup-date-filter.md b/docs/local-admin/backup-date-filter.md
new file mode 100644
index 0000000000..7798ff06aa
--- /dev/null
+++ b/docs/local-admin/backup-date-filter.md
@@ -0,0 +1,15 @@
+# 备份日期筛选
+
+登录本机后台,进入“备份恢复”,在列表上方填写“开始日期(本地)”和“结束日期(本地)”,点击“应用日期”。可只填写一端;两端都留空表示不限日期。结束日期包含当天完整一天,按浏览器本地时区计算,不是UTC日期,也不是最近24小时。
+
+日期输入尚未应用时,列表、分页、内容大小摘要和下载清单继续使用原已应用条件,并显示提示。无效日期或开始晚于结束不会替换原条件。应用后回到第一页,与原关键词、范围、校验状态组合筛选,保留排序和每页份数。“清除筛选”同时清除日期、关键词、范围与校验状态,保留排序。
+
+只筛选本次已加载的最新200份备份;更早未加载的记录不会因日期选择而被补充读取。有日期限制时,只纳入有效备份中可核对创建时间的记录;损坏或无法核对时间的备份不按编号猜测日期。不限日期时仍按原规则展示这些记录。筛选不代表可以恢复,恢复前仍需预览和服务端校验。
+
+筛选在浏览器本地完成,不新增接口,不创建、恢复或删除备份。日期筛选不会改变已选恢复目标、清理预览或确认;清理仍按原保留策略检查整个受控备份目录,不按列表筛选删除。分页和大小摘要基于全部已加载匹配项,下载清单沿用已应用日期,并在filters中附fromDate、untilDate和dateTimeZone(日期不限时不增加这些字段)。清单仍仅供核对,不能恢复配置。
+
+请求处理期间日期控件禁用;刷新、切换后台页面保留日期输入和已应用条件,刷新失败保留旧结果并标记。退出或会话过期清空日期,旧请求不能覆盖新会话。条件只存在当前页面内存,不写浏览器存储,刷新整个网页后重置。
+
+验收:选择已知备份日期,核对起止当天边界、单端筛选、条件组合及清除;确认大小摘要和清单只包含全部匹配项,原恢复/清理目标保持。自动化使用合成元数据,无需恢复日常配置、清理日常备份或发送真实QQ消息。实际页面体验待验收。
+
+## 自动化验证与部署
diff --git a/docs/local-admin/backup-impact.md b/docs/local-admin/backup-impact.md
new file mode 100644
index 0000000000..734816cefc
--- /dev/null
+++ b/docs/local-admin/backup-impact.md
@@ -0,0 +1,13 @@
+# 恢复预览的类别影响对比
+
+登录本机后台进入“备份恢复”,选择有效备份并点击“预览恢复”。“类别影响对比”显示当前保存状态与备份的类别差异,标签区分“有变化”和“一致”,有数量信息时显示当前与备份数量。预览只读,不自动恢复、备份、修改开关或重启。
+
+启动配置分为正向连接、反向连接、机器人主人、其他机器人参数、其他启动设置。连接或账号按条数对比,其他设置按所属对象字段数对比;同样数量可能有不同内容,需同时查看变化标签。字段名称、地址、Token、主人QQ和其他配置值不在该摘要中展示。仅字段名称大小写或其他表示有区别时可能另列“字段名称或表示差异”,沿用原JSON一致性判断。
+
+后台选项分别比较后台启用状态与访问地址,只显示是否有变化。控制库按响应规则与AI扩展、全局用户封禁、插件开关继承标记、插件与其他库内数据四类汇总记录数量与内容变化;不展示表名、用户/群号、模型密钥、提示词或原始行。只有当前库与备份结构一致且原完整性检查通过时才提供预览。
+
+摘要使用预览时的同一份当前状态及已校验备份生成;不是仅恢复变化类别的选择器,实际恢复仍覆盖整个所选范围。保留原范围警告、勾选确认、10分钟预览有效期、修改后重新预览、写前备份和冲突保护。配置与备份相同则恢复按钮仍不可用;预览取消/失败、刷新、切换后台页面、退出或操作时发现预览过期会清空旧摘要,迟到结果不能回填新会话。
+
+验收:预览不同范围,核对类别、数量和标签,再取消,不需使用日常配置演练恢复。真实恢复完整页面操作仍按TODO-BACKUP-01保留;差异、缺字段、密钥变化、数量相同内容不同与冲突保护由隔离测试覆盖。
+
+验证:4项新增Go、86项界面、64项布局和22:16:44完整Windows隔离回归通过。三种备份范围均覆盖相同数量但内容不同、完整性/认证与旧快照保护;无密钥或原始内容返回。取消、失败、刷新、导航、过期操作和迟到结果清理验证通过。本机新资源、原认证、正确账号与OneBot正常;启动配置、控制库和日常凭据保持,NapCat未重启。证据见 [自测记录](self-test.md)。
diff --git a/docs/local-admin/backup-pagination.md b/docs/local-admin/backup-pagination.md
new file mode 100644
index 0000000000..a301da1ea5
--- /dev/null
+++ b/docs/local-admin/backup-pagination.md
@@ -0,0 +1,13 @@
+# 备份列表分页
+
+登录本机后台进入“备份恢复”。筛选区域和恢复预览下方显示备份分页与列表,清理区域位于列表后。默认每页20份,可选择10、20或50份,再使用“上一页”“下一页”浏览;提示显示当前页、总页数及匹配结果中的条目范围。
+
+分页只调整本次已加载列表的显示,不发送请求,也不创建、恢复或删除备份。筛选在全部已加载记录上执行,再按所选排序排列后分页;默认保留服务器原顺序,详见 [备份列表排序](backup-sort.md)。后台一次仍最多加载最新200份,分页不下载更早记录;存在未加载记录时继续提示该限制。
+
+改变关键词、范围、校验状态、每页份数或清除筛选时回到第一页。点击页面内“刷新列表”或创建后的刷新保留当前页,结果数量减少时自动回到有效的最后一页。切换后台页面保留页码及每页份数;退出或会话过期恢复20份/第一页并清空缓存。浏览器重载页面重新从第一页/20份开始。没有结果时两边翻页按钮禁用。
+
+读取、预览、恢复和清理请求中禁用分页,旧请求不会覆盖新会话。翻页不更改已打开的恢复预览或清理确认目标;预览仍明确显示备份编号,恢复仍需原确认和后端重新校验。打开恢复预览后页面滚动到该预览,便于核对编号;减少动画模式下使用即时滚动。
+
+权限和保存范围不变:后台登录后使用,不读取备份文件内容或凭据,分页偏好只在当前页面内存,不写浏览器存储,不改变机器人配置、QQ登录、AI设定或历史。
+
+验收:足够多的备份下翻页,核对边界、显示范围与无重复/遗漏;切换份数或筛选回到首页;打开预览后翻页确认编号不变,点击“刷新列表”后确认当前页有效。无需创建大量真实备份或恢复/清理日常状态,满200份、缩页和旧会话通过隔离测试覆盖。
diff --git a/docs/local-admin/backup-preview-expiry.md b/docs/local-admin/backup-preview-expiry.md
new file mode 100644
index 0000000000..0fe21e81b6
--- /dev/null
+++ b/docs/local-admin/backup-preview-expiry.md
@@ -0,0 +1,13 @@
+# 备份预览有效期提示
+
+登录本机后台进入“备份恢复”,选择“预览恢复”,或在清理表单点击“预览清理清单”。预览下方显示截止时间和剩余分秒;时间仅按本机时钟估算,不表示当前配置或备份内容仍与预览一致。实际恢复/清理仍由服务端校验原票据、版本、范围和当前内容。
+
+到期自动取消原确认、禁用确认框与执行按钮,并提示重新预览。恢复可在备份卡片再次点“预览恢复”;清理可再点“预览清理清单”。新预览必须重新核对并勾选确认,已判定到期的预览不会因为本机时间回拨而重新可用。缺少或无效的有效期也会阻止执行,不猜测可用期限。
+
+仅当前已登录、可见的备份页面计时。页面隐藏时停止计时,返回可见时立即按当前时间重新核对;切页、取消、刷新或退出按原流程清掉对应预览和计时,迟到结果不能恢复旧会话。修改清理参数会取消旧清理预览。已提交的请求不会因页面倒计时到期而被中断或自动回滚,处理结果仍以原接口返回为准。
+
+这只增强提示和本机确认保护,不自动续期,不发送计时网络请求,不自动恢复/删除,不更改备份保留策略或机器人配置;截止时间仍以服务端为准。无变化的恢复预览和空清理清单继续不能执行,取消仍可使用。秒级倒计时不连续向读屏软件播报,到期状态有单独提示。
+
+验收:刷新后台打开恢复或清理预览,检查剩余时间逐秒减少,勾选确认后让预览自然到期,确认应被取消且按钮禁用;重新预览后需再次确认。无需为验收实际执行恢复或删除。
+
+自动化验证:新增4项离线Chrome可控时钟场景,备份相关26项/完整界面133项、64项页面布局/深浅窄屏截图、相关模块与静态检查通过;06:44:11完整Windows隔离回归及06:45:03本机部署通过。计时行为在隔离Chrome的真实DOM验证,整机回归确认新资源与原备份恢复/清理规则;未执行日常恢复/删除或发送真实QQ消息。后台HTTP200、正确账号OneBot/握手正常,NapCat进程与登录保持,配置/控制库/后台凭据不变,当前启动无错误。证据 .local/smoke-admin/run-1511812192/result.json、.local/backup-expiry-deployment.json;详见 [自测记录](self-test.md)。
diff --git a/docs/local-admin/backup-restore-2026-10-02.md b/docs/local-admin/backup-restore-2026-10-02.md
new file mode 100644
index 0000000000..545a338a6d
--- /dev/null
+++ b/docs/local-admin/backup-restore-2026-10-02.md
@@ -0,0 +1,55 @@
+# 备份恢复与验证
+
+## 本轮运行日志检查
+
+19:45 检查当前托管进程及其 stderr:进程运行、后台 HTTP 200、正确机器人账号握手成功;没有 error/fatal/panic 或连接断开记录。只读 OneBot 检查确认 QQ 登录 ready,配置配对和三个 API 正常。证据 `.local/pre-backups-log-check.json`。
+
+启动日志有 23 条警告:外部 MD5 校验服务器超时及已有素材跳过检查共 21 条,重定向终端不能读取控制台模式 1 条,已有共享 extra 注册提示 1 条。抽签插件另提示没有初始签文,并注明首次使用前为正常情况。它们没有阻塞本次后台与连接;不以此声明所有素材内容经过远程校验或全部插件可用。
+
+## 使用方法
+
+刷新并重新登录 [本机后台](http://127.0.0.1:3010/),进入“备份恢复”。可查看最近备份、选择范围手动创建、预览已有备份,再勾选覆盖确认并恢复。取消预览不改变配置;预览本身只读取目标和当前状态。
+
+一次操作一个范围,各范围分别恢复,不提供跨文件的一键整机回滚:
+
+| 范围 | 包含内容 | 生效方式 |
+| --- | --- | --- |
+| 启动配置 | 完整启动 JSON:所有 OneBot 连接、Token、主人、其他字段 | 保存文件后需重启;当前驱动与启动快照保留 |
+| 控制数据库 | 整个控制库:开关、默认翻转、继承、群参数、AI 扩展、响应、封禁及库内插件数据 | 控制层提交后立即更新;其他插件的业务缓存可能需要重启,页面统一提示重启 |
+| 后台启用与地址 | `.local/admin/options.json` 中 enabled/address | 托管宿主下次启动读取;恢复后可能关闭后台或改变访问端口 |
+
+后台密码、会话、设置码、NapCat/QQ 登录、聊天媒体、素材和其他业务数据库不在恢复范围内。后台设置文件缺失或不符合回环地址格式时,不提供对应手动备份选项。通过命令行直接启动时,后台选项文件不会覆盖已经给出的启动参数。
+
+控制库恢复覆盖所有库内数据,可能同时回退部分插件在该库保存的计数;不要把它理解成只恢复某一个插件的开关。恢复不会自动发送 QQ 消息,也不会自动重启进程。正在执行的 AI 请求仍可能继续使用它之前取得的配置快照;真实模型测试继续保留为 [TODO-AI-01](todo.md)。
+
+## 保护与恢复机制
+
+备份放在受限的本机目录,不提供明文配置读接口、下载接口或任意路径访问。列表只返回 ID、时间、格式版本、范围、大小和摘要是否存在;预览只返回覆盖说明、是否变化及表/记录数量,不返回凭据或原始数据库行。
+
+新 JSON 备份包含实际存储 JSON 内容的 SHA-256;保留未知字段、大整数及原字段语义,校验覆盖 HTML 和 Unicode 转义后的内容。已有控制库快照继续校验 manifest 的版本、固定文件名及摘要;预览时额外执行 SQLite quick_check,并要求源库和当前库结构一致。旧版无摘要 JSON 备份仍可读取,预览明确提醒不能证明它未被修改。摘要用于发现损坏,不是数字签名;本机目录仍需保持私有。
+
+只接受本程序生成的备份 ID,拒绝绝对路径、目录穿越、链接文件及链接祖先目录。单份启动配置限 1 MiB、控制库限 32 MiB;数据库解析限制 512 个 schema 项、10 万条记录和约 64 MiB 数据内存预算。列表最多展示最近 200 个备份,目录超过 4096 项时提示先在本机整理;不自动删除旧备份。
+
+恢复请求必须带已登录会话、CSRF、明确确认,以及与该会话、目标内容和当前状态绑定的十分钟预览凭据。进程重启、预览过期、退出登录、目标被修改或当前状态改变后,旧凭据不能继续恢复。无变化时不创建多余备份、不写文件。
+
+有变化时先保存当前状态,备份失败则拒绝覆盖。JSON 通过同目录私有临时文件、同步和替换写入;替换前再次检查外部编辑,失败保留原文件。控制库在与普通控制写入共用的 Manager 锁下,用一个 SQLite 事务复制匹配表的数据;不执行备份内存储的 SQL。写事务内再次比较当前版本,中途失败全部回滚。提交后才更新默认翻转及失效开关、响应、黑名单和封禁缓存。恢复前的备份可以再次预览恢复,以撤销上次成功恢复。
+
+恢复演练还发现并修复原封禁实现中 uint64 查询与 int64 存储不一致的问题;缓存未命中时也必须继续检查全局封禁。封禁/黑名单的读取与缓存写回现在持有同一锁,避免恢复后旧读者重新放回过期结果。相关写入仅在成功提交后改缓存,不增加恢复以外的业务操作。
+
+本记录首轮交付时,自动写前备份覆盖网页/聊天的插件开关、默认翻转、AI 扩展、AI 群参数和启动 JSON。后续已补齐响应/沉默、封禁/解封及插件禁止/允许,详见 [控制策略备份记录](control-policy-backups-2026-10-02.md)。业务计数及外部文件修改不逐次备份;可手动备份整个控制库。
+
+## 测试与待验收
+
+后台模块、基础指令和 LLM 回归、Go 静态检查及 Windows 编译通过。新增测试覆盖三类范围、恢复前快照、无变化处理、文件替换失败、控制库中途失败/回滚、成功恢复后的撤销恢复、默认/继承/参数/响应/封禁缓存、并发普通控制写入、过期/会话绑定/配置冲突、源文件修改、摘要/版本/结构损坏、SQLite 损坏、取消请求及路径拒绝。恢复和失败注入均使用临时配置和测试数据库。
+
+`node --test scripts/test-backup-ui.mjs scripts/test-log-ui.mjs` 六组离线页面行为测试通过。备份页验证纯文本显示、确认后才能提交、过期/冲突后重新预览、切页/退出清除或取消读取、迟到结果拒绝;保留日志页回归。这些测试不操作真实浏览器,不代替人工视觉验收。
+
+真实备份页人工操作、恢复后的实际重启及持续性能测量仍待验收;没有覆盖用户的日常配置进行破坏性恢复演练,没有调用真实模型。
+
+完整恢复页面人工操作可使用[本机隔离后台验收环境](isolated-admin-acceptance.md),在独立目录准备三类基线备份与不同的当前值,终端只重启本次测试实例;自动环境检查不替代人工验收,不影响日常机器人。
+
+测试观察脚本初检曾因时间戳小数位、AX默认差量文本及DOM换行产生误判,已以完整快照/DOM显示值校正,记录保留在observationCorrections。后台选项文本判断失败后启动器被提前请求一次独立重启,该次顺序偏差已记录earlyIsolatedRestart;随后重新预览并实际恢复后台选项,再执行最后重启完成全部核对,故测试程序共启动3次。临时实例已用stop停止,日常PID67560、NapCat PID56644及日常程序/启动配置/后台凭据/后台选项摘要均保持,见.local/admin-browser-current-before.json与-after.json。共用同一主机名的浏览器登录会话发生覆盖,测试结束后通过Chrome正常自动填充和登录按钮恢复日常登录,没有读取密码/浏览器存储。
+
+恢复控制库后插件页首次显示0/0和“当前程序未注册插件”,点击刷新后108项及chrev恢复状态正确,重启后也正确;此页面首次显示问题另记TODO-UI-RESTORE-CACHE-01,不把它当成已修复。TODO-BACKUP-01的创建/取消/三类恢复/重启范围关闭,TODO-UI-01其余矩阵保持待验收。仅进行真实浏览器验收和文档同步,本轮没有修改产品代码、构建/部署或重启日常服务。
+
+新增4项恢复/读取失败/迟到读取/迟到保存场景与完整201项界面测试通过;Go后台测试、vet、Windows构建、64项深浅主题和320~1280px布局检查通过。17:12:38完整独立Windows回归通过(.local/smoke-admin/run-847379204/result.json),不发送真实QQ消息。真实Chrome在独立run-1635245969恢复控制库后首次返回显示108项、群101的chrev禁用,群号和搜索保留,未点击手动刷新;切页返回正确,实例已停止。首次完整界面回归196/201通过,额外普通导航读取影响5项原测试;限定仅恢复后自动读取后201/201通过,初轮失败证据保留于.local/restore-plugin-reload-ui-tests-initial.txt。
diff --git a/docs/local-admin/backup-search.md b/docs/local-admin/backup-search.md
new file mode 100644
index 0000000000..f960705671
--- /dev/null
+++ b/docs/local-admin/backup-search.md
@@ -0,0 +1,23 @@
+# 备份列表检索与筛选
+
+登录本机后台进入“备份恢复”。列表上方可输入备份编号、范围名称或日期,并组合“备份范围”和“校验状态”筛选。日期支持列表显示的本地日期文字或元数据 ISO 日期;关键词不读取备份文件内容,也不搜索凭据。
+
+范围包括启动配置、控制数据库、后台启用与地址,以及异常/未知范围。校验状态区分可预览且含摘要、可预览但无摘要的旧版,以及不可恢复。含摘要不代表该备份适合当前业务;恢复仍需后端重新校验和明确确认。不可恢复的卡片不能预览。
+
+筛选即时调整本次已加载列表,不发送写请求、不创建或删除备份。“清除筛选”恢复所有已加载记录。计数显示匹配数/加载总数及状态分布;没有备份和没有匹配结果分别提示。后台每次最多返回最新 200 份,存在更早未加载记录时明确提醒:搜索只覆盖本次加载范围。
+
+搜索、范围与状态条件在切换后台页面、刷新列表及创建/恢复后的刷新中保留;退出或会话过期清空条件和缓存。读取失败保留上次已加载结果并标记读取失败,不能把旧结果称为最新状态;再次成功读取更新列表并重新应用条件。请求中暂时禁用筛选和列表操作,旧请求结果不会覆盖新会话。
+
+筛选不改变已打开的恢复预览或清理清单。恢复预览明确显示备份编号,即使该卡片被筛选隐藏,仍能核对预览的实际目标。恢复和永久清理保持原独立确认、版本/清单冲突、备份与回滚保护;本功能不自动执行这些操作。保存日常配置、后台密码、QQ 登录和 AI 人物设定不受筛选影响。
+
+验收:组合范围、校验状态与编号/日期,核对计数;清除后恢复列表;打开预览后调整筛选,核对预览编号和确认目标;切页/刷新核对条件保留。无需恢复或删除日常备份来测试检索;损坏、旧版和隐藏目标由隔离测试覆盖。
+
+自动验证:四项新增DOM用例覆盖范围/状态/编号/ISO日期/未知/空匹配、满载提示与文字安全、隐藏预览后的恢复ID、失败/刷新/跨页/创建条件保留、繁忙与会话重置;一项新增Chrome用例验证真实表单/导航、恢复编号和纯筛选无写请求。原47项加新增5项,共52项界面通过;56项深浅主题/320~1280px布局通过,核对桌面与窄屏截图(含预览编号)。Go模块/静态检查通过。
+
+未恢复或清理日常备份,未修改日常开关/地址/Token/AI密钥/人物设定,未发送真实QQ消息或调用模型。已有素材MD5服务器网络警告仍存在,缓存素材可用。实际页面待 TODO-BACKUP-SEARCH-01;原备份恢复完整人工验收仍按其独立待办处理。
+
+备份恢复页可按时间从旧到新、大小从大到小或从小到大排序全部已加载匹配项;分页和下载清单沿用所选顺序,见 [备份列表排序](backup-sort.md)。
+
+备份恢复页提供已加载/匹配和固定范围的备份内容大小摘要,异常大小单列;不代表目录实际占用,见 [备份内容大小](backup-size-summary.md)。
+
+备份列表可按本地开始/结束日期组合筛选,结束日期包含完整当天;列表、大小摘要与清单采用同一组已应用日期,清理策略和恢复目标保持,见 [备份日期筛选](backup-date-filter.md)。
diff --git a/docs/local-admin/backup-size-summary.md b/docs/local-admin/backup-size-summary.md
new file mode 100644
index 0000000000..43803ea27f
--- /dev/null
+++ b/docs/local-admin/backup-size-summary.md
@@ -0,0 +1,21 @@
+# 备份内容大小摘要
+
+登录本机后台,进入“备份恢复”。成功读取列表后,筛选区域下方显示“备份内容大小”:
+
+- 已加载记录:当前读取列表全部记录的份数、可核对内容大小、大小无法核对的份数。
+- 当前匹配记录:按关键词、范围与校验状态筛选后的全部结果,跨全部分页;显示同样的份数与内容大小。
+- 范围明细:启动配置、控制数据库、后台启用与地址、异常/未知范围各自的已加载和匹配摘要。
+
+仅汇总可预览记录中非负整数的大小。损坏、不可恢复或大小字段无效的记录计入份数和“大小无法核对”,不作为零大小相加。明确的0字节是可核对大小。总和按精确字节计算;较大值附KiB/MiB等换算,两位小数四舍五入,括号内为精确字节数。
+
+这里的大小来自最近读取的目录元数据:启动配置/后台选项统计JSON中的配置正文,控制库统计备份的SQLite文件。它不含JSON封装、manifest、目录或磁盘簇开销,不是磁盘实际占用。一次仍最多加载最近200份;存在更早未加载记录时会提示摘要不含这些记录,不能据此推断整个备份目录的总大小或可释放空间。
+
+改变筛选立即更新匹配及范围摘要,翻页、每页份数与排序不改变总和。刷新成功后按新目录重算;读取失败保留上次摘要并注明“刷新失败”,显示最近成功读取时间。切换页面保留最近目录,返回页面按原流程刷新;退出或会话过期清空,迟到结果不能覆盖新会话。
+
+摘要只读,不额外请求接口、读取备份正文或导出秘密,不创建/恢复/清理备份,也不改变已打开的恢复/清理预览和确认。下载清单仍沿用原固定元数据格式,摘要不加入下载文件。
+
+验收:刷新列表,核对全部及匹配份数;组合范围/状态/关键词,观察匹配大小更新;翻页或更换排序应保持总和。无需恢复或清理日常备份。实际页面体验待验收。
+
+自动化验证:新增4项离线Chrome场景与原152项界面共156项全部通过;64项深浅主题/窄屏布局及截图、相关Go模块/静态检查和Windows构建通过。合成200份验证全量/匹配/固定范围总和、0字节和无法核对大小、超过安全整数的精确总和及换算;排序分页不改大小、失败/刷新/预览确认保持、退出与迟到请求保护通过。未恢复或清理日常备份,实际页面体验待用户验收。
+
+备份列表可按本地开始/结束日期组合筛选,结束日期包含完整当天;列表、大小摘要与清单采用同一组已应用日期,清理策略和恢复目标保持,见 [备份日期筛选](backup-date-filter.md)。
diff --git a/docs/local-admin/backup-sort.md b/docs/local-admin/backup-sort.md
new file mode 100644
index 0000000000..2321f1c13a
--- /dev/null
+++ b/docs/local-admin/backup-sort.md
@@ -0,0 +1,23 @@
+# 备份列表排序
+
+登录本机后台,进入“备份恢复”,在筛选区域的“排序”下拉框选择:
+
+- 默认顺序:保留服务端返回的顺序,按备份编号从新到旧。
+- 时间从旧到新:按备份记录的创建时间排列。
+- 大小从大到小、大小从小到大:按记录的字节数排列,显示的 KiB 向上取整不影响排序。
+
+排序先覆盖全部已加载的匹配结果,再分页;相同时间或大小保持默认顺序。不可恢复的记录,以及缺少有效排序字段的记录,放在自定义排序的最后。不根据未知数据推断时间或大小,排序不使损坏备份变得可恢复。
+
+改变排序回到第一页。搜索、范围、校验状态与每页份数可组合使用;“清除筛选”保留排序,选择“默认顺序”恢复原顺序。刷新及切换后台页面保留排序;列表缩小后页码校正到有效页。退出、会话过期或浏览器重载恢复默认顺序。排序只保存在当前页面内存。
+
+“下载备份清单”导出按当前排序排列的全部匹配结果,跨全部已加载页,并在 `filters.sort` 中记录 `default`、`oldest`、`largest` 或 `smallest`。导出仍仅含固定目录元数据;已打开的恢复/清理预览和确认目标不会因排序改变,恢复时仍按原编号、期限和服务端校验执行。
+
+读取、预览、恢复、创建或清理请求中禁用排序。刷新失败保留上次列表与排序,页面和下载继续标明刷新失败;退出与迟到请求隔离沿用原流程。排序不发送请求、不创建/恢复/清理备份,也不读取配置正文或密钥。
+
+一次仍最多加载最近200份备份;时间从旧到新只重排这200份,不能用它查看更早未加载记录。清理策略和预览清单不受排序影响。
+
+验收:切换时间/大小排序,跨页核对记录顺序;组合筛选后下载清单,核对顺序与排序字段;打开预览后切换排序,确认备份编号与勾选保持。无需执行恢复或清理日常备份。实际页面体验待验收。
+
+自动化验证:新增5项离线Chrome场景与原147项界面共152项全部通过;64项深浅主题/窄屏布局与截图、相关Go模块/静态检查和Windows构建通过。合成200份备份验证全量排序、稳定同值、精确字节与异常置后;真实Blob下载验证全部匹配顺序和固定sort字段;预览/清理目标保持、刷新失败/忙时/会话隔离已覆盖。未恢复或清理日常备份,实际页面体验待用户验收。
+
+备份列表可按本地开始/结束日期组合筛选,结束日期包含完整当天;列表、大小摘要与清单采用同一组已应用日期,清理策略和恢复目标保持,见 [备份日期筛选](backup-date-filter.md)。
diff --git a/docs/local-admin/basic-smoke-2026-10-01.md b/docs/local-admin/basic-smoke-2026-10-01.md
new file mode 100644
index 0000000000..96a0956b3f
--- /dev/null
+++ b/docs/local-admin/basic-smoke-2026-10-01.md
@@ -0,0 +1,39 @@
+# Windows 基本功能验证:2026-10-01
+
+代码基线 `65619f08b09da44d57f44beacb3ce261130c5457`,包含尚未提交的本地修复。环境:Windows/amd64、Go 1.26.1、NapCat v4.18.28、OneBot 11 正向 WebSocket。本文验证已有机器人基本链路,不代替新后台 TC-01~25 验收。
+
+## 当前结果
+
+| 编号 | 检查 | 结果 | 证据与限制 |
+| --- | --- | --- | --- |
+| BASIC-01 | 实际 JSON、主人账号类型、URL/Token 配对 | 通过 | 主人已填写,正整数数组;Token 非空且匹配,不输出明文 |
+| BASIC-02 | NapCat 重新启动、再次扫码登录 | 通过 | 新进程及 WebUI HTTP 200;用户确认扫码,API 登录状态 ready |
+| BASIC-03 | 未认证管理请求拒绝、三个 OneBot 只读 API | 通过 | `test-onebot.ps1`;登录信息、在线状态及版本接口成功 |
+| BASIC-04 | WebUI/OneBot 实际回环监听 | 通过 | `127.0.0.1:6099`、`127.0.0.1:6700` |
+| BASIC-05 | 公告正常响应、错误状态及无响应读超时 | 通过 | `go test ./kanban -count=1 -timeout=30s` |
+| BASIC-06 | registry Get/Cat/MD5 比较无响应时结束 | 通过 | `third_party/go-registry` 模块内 `go test ./... -count=1 -timeout=30s` |
+| BASIC-07 | Windows 重新编译、帮助初始化 | 通过 | `CGO_ENABLED=0 go build -trimpath`;帮助日志完成 Usage 输出,未保存该次最终退出码 |
+| BASIC-08 | 实际配置启动 ZeroBot、WS 握手 | 通过 | `.local/zerobot/process.json` 和日志;进程存活,握手成功,无启动 panic;主人与机器人不是同一账号 |
+| BASIC-09 | 主人私聊 `/help` | 通过 | 原 `/help\n` 不匹配已修复;编译及重启后,用户确认正常显示,ZeroBot 新日志记录收到帮助命令并发送帮助文字 |
+| BASIC-10 | 主人私聊 `/服务列表` | 通过 | 用户确认有反应;ZeroBot 与 NapCat 日志均记录向主人发送图片,消息 ID 非零 |
+| BASIC-11 | chess/tarot 基线回归 | 通过 | 修改公共网络依赖后补跑,两包分别约 7.240 秒、7.227 秒 |
+| BASIC-12 | 隔离目录内完整程序处理帮助与服务列表 | 通过 | 新 Windows 程序处理普通帮助、末尾换行帮助、首尾空白帮助及服务列表均收到预期回复;`.local/smoke-basic/result.json`;未向真实 QQ 发消息 |
+| BASIC-13 | 独立宿主恢复、正确账号校验 | 通过 | 两个手动任务 Running,宿主由 Windows svchost 启动;配置指定账号登录及新 WS 握手通过;真实基础命令回复已确认 |
+| BASIC-14 | 帮助空白及消息路由回归 | 通过 | `go test ./internal/basiccommands -count=1 -timeout=20s`;11 个场景,原规则在换行与首尾空白场景失败,修复后全部通过;群聊仍需 @bot,较长命令及带参数命令不误匹配 |
+| BASIC-15 | ZeroBot 停止、替换程序、重启 | 通过 | 发现 Stop-ScheduledTask 只结束宿主而留下子进程;新增路径及启动时间校验后结束本项目进程树,旧 PID 已消失,新程序连接成功;OneBot 停止/移除未回归 |
+
+NapCat 停止及任务移除尚未单独实测;ZeroBot 已实际停止、替换及重启。当前两个服务保持运行,NapCat 登录会话持续保留。
+
+后续用户报告发送命令没有回复:复查时两个旧进程均已退出,旧 ZeroBot 日志没有收到用户测试命令。因此前面的“进程在线”是当次检查时的状态,不代表跨工具会话持续运行已通过。已新增独立计划任务宿主;用户手动登录正确账号后,OneBot 只读 API、账号校验与新 ZeroBot 握手均通过。两个任务都在运行,父进程核实为 Windows `svchost.exe`。修复帮助末尾换行问题后,用户与日志已确认帮助及服务列表真实回复通过;浏览器安全检查仍阻止自动页面操作。详见 [恢复与登录](operations.md)。
+
+隔离验证使用合成账号与独立配置/数据目录,不复用真实 Token。首次隔离启动缺少 `McFish/articlesInfo.json` 而退出,补齐已在本机取得的启动素材后通过。模拟进程已结束,真实 ZeroBot 保持运行。模拟验证能检查命令匹配、文字构造及图片生成;实际 QQ 投递仍以 BASIC-09、10 为准。
+
+## 修复与资源
+
+- 原公告请求没有连接及读写总时限;改为每次独立连接,整个请求最多五秒,失败只显示公告暂不可用。
+- 资源校验使用的锁定 registry 库只有连接超时,响应读取可能无限等待;首次实测控制层初始化约 106 秒。保留 v0.2.7 源码及 GPL-3.0 许可,通过本地 replace 为协议请求加五秒读写时限,连接也有限时。详情见 [补丁说明](../../third_party/go-registry/LOCAL_PATCH.md)。
+- 百人一首插件因镜像返回 HTTP 530 而 panic;从 [作者原仓库](https://github.com/fumiama/OguraHyakuninIsshu) 补齐 CSV。Git blob 为 `7fd14b91eb84c02ed00ad92b9e31b2a35a070f57`,SHA256 为 `6f48eabde117d41b05e1afadc68657f30467797903bccfd4aa38a1b956aeee09`,大小 19,579 字节。
+- 从锁定 data 子模块补齐 `Chat/dict.txt`,Git blob 为 `fc6075f64943e1861c420db4da38063de9d8afc5`。其他插件也可能按需下载素材;当前仍不是全部素材包。
+- 帮助命令原本要求正文完全相等;实际 QQ 事件中的 `/help\n` 没有匹配。将注册抽到 `internal/basiccommands`,只对帮助匹配去除首尾空白;保留原命令别名、优先级及私聊/群聊 @bot 限制。消息 `raw_message` 会省略换行,不能仅凭普通日志判定正文完全一致。
+
+日志位于 `.local/onebot/runtime/launcher-logs/`、`.local/smoke-restart/`、`.local/zerobot/logs/`,由 Git 忽略。日志可能含账号、聊天内容、WebUI 密钥或二维码链接,仅在本地保存。
diff --git a/docs/local-admin/browser-performance.md b/docs/local-admin/browser-performance.md
new file mode 100644
index 0000000000..1e68190632
--- /dev/null
+++ b/docs/local-admin/browser-performance.md
@@ -0,0 +1,51 @@
+# 真实浏览器持续性能核对
+
+当前程序的四组后台开关/接口轮询对照沿用[系统性能口径](system-acceptance-2026-10-02.md),每组预热60秒、采样300秒。浏览器补充使用独立测试页,先在“运行日志”暂停更新取得静态显示,再在持续更新状态采样;每组同样预热60秒、采样300秒。测试控制器按5秒间隔发送模拟“翻转 abc”并校验回复,按2秒间隔读取其自有程序的Windows资源指标。
+
+Chrome只读取受控测试标签页的CDP性能指标和页面已显示的统计。记录JS堆、主线程任务时间、布局/样式耗时、DOM/监听器数量与实际日志行数;不读取其他页面内容或浏览器账户数据。主线程任务时间占比与Windows机器人CPU口径不同,分别报告,不能合并称为整机或整个Chrome的CPU占用;共享渲染进程和测试工具采集可能影响该指标。
+
+浏览器正式样本窗口中,以首个样本为基线核对JS堆最大增长不超过64MiB、主线程任务时间占比不超过20%、页面日志不超过1000行、无页面错误;至少30个样本、实际首末跨度不小于300秒,且窗口位于控制器正式资源窗口内。约每5秒采集,记录最大采样间隔、页面可见性和模式,持续组还要证实日志增加。阈值是本轮检查口径,不是响应时间或长期无泄漏保证;采样间的瞬时尖峰可能未被捕获。隐藏/切页时不把暂停轮询的样本当成持续前台更新;样本不足或提前结束不能标正式通过。
+
+测试入口:
+
+```powershell
+Set-Location <项目目录>
+$env:CGO_ENABLED='0'
+go run scripts/start-admin-browser-performance.go -binary-name zerobot.exe -profile paused
+# 完成暂停组后另起一组;保持页面自动更新:
+go run scripts/start-admin-browser-performance.go -binary-name zerobot.exe -profile updating
+```
+
+启动后输出本次URL、登录文件和产物目录;Chrome使用该随机密码登录独立后台,打开日志并选择本组暂停/持续更新状态。终端输入`start`才开始该组预热和采样;期间可以读取进度文件,不能修改真实配置。控制器默认预热60秒、程序资源采样360秒,为Chrome独立300秒窗口保留协调余量;双方实际窗口分别记录,不称为相同采样窗口。`status`查看阶段,`stop`提前结束并停止本次程序;正式测量完成后也会自动停止。重复测量使用新的独立目录。
+
+Chrome使用连接的Browser运行时完成登录/进入日志/暂停或保持更新;通过该标签页的CDP `Performance.enable` / `Performance.getMetrics`读取指标,页面只读检查当前日志行数、状态及可见性。预热后保存每个原始样本和最终截图,结束时关闭性能采集并关闭自建测试标签页。长窗口分批调用时保存进度,不因工具调用超时丢弃已完成的独立采集,也不单凭超时前提示判通过;最终读取实际首末时间、样本量和控制器退出结果。
+
+完整资源样本、模拟消息结果、Chrome指标和页面截图保留在`.local/browser-performance/`,不进入Git。控制器只核对程序和模拟负载,`browserRendererMeasured=false`;Chrome结果另存,不能因控制器单独通过就关闭整个性能待办。
+
+## 2026-10-12 执行结果(DEV-68 / TC-88)
+
+测试当前已部署DEV-66程序,SHA-256 `D3A05799959EFE3BD14A40CF863B92A92A3D582181ABF37EEB746C0C5458C6A3`。没有产品代码修改、重新构建产品或日常重启。
+
+| 场景 | 消息p95(ms) | 登录p95(ms) | 最慢读取p95(ms) | 机器人CPU(全机容量%) | Private Bytes最大增长(MiB) |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| 关闭后台 | 193.79 | — | — | 0.0058 | 0.492 |
+| 后台空闲 | 193.89 | 84.28 | 21.40 | 0.0049 | 0.000 |
+| 概览每10秒轮询 | 193.68 | 83.88 | 26.77 | 0.0133 | 0.000 |
+| 日志每2秒轮询 | 192.20 | 83.16 | 19.99 | 0.0078 | 0.105 |
+
+最慢读取均为插件列表;读取/登录探测位于资源窗口外,轮询请求另计。相对关闭后台,消息p95最大增量0.103ms、CPU最大增量0.00752个百分点,均通过原阈值。证据`.local/admin-system/performance-636340632/result.json`、`.local/admin-system/performance-latest.json`、`.local/current-performance-tests.txt`;旧2026-10-02数据保留原构建与时间,不替换成此次结果。
+
+| 实际Chrome日志模式 | 实际采样时间(本机) | 样本/跨度 | JS堆最大增长(MiB) | 主线程任务占比 | 日志首末行数 | 最大样本间隔 |
+| --- | --- | --- | ---: | ---: | --- | ---: |
+| 暂停 | 20:12:42.343~20:17:42.531 | 62 / 300.188秒 | 3.282 | 0.0225% | 5→5 | 5.047秒 |
+| 自动更新 | 20:52:53.858~20:57:54.065 | 60 / 300.207秒 | 1.084 | 0.3710% | 29→149 | 16.293秒 |
+
+两组Chrome分别实际预热60.045/60.059秒,全程页面可见、模式正确、无页面错误、日志不超过1000行;更新组控制台无警告或错误。更新组最大间隔来自两次工具调用之间,原始时间完整保留,不宣称每个间隔均为5秒。更新布局/样式累计111.338/48.429ms;上述任务占比不代表整个Chrome或Windows CPU。
+
+暂停组独立目录`run-949628714`,更新组`run-257104903`;两组控制器分别预热60秒、程序采样360秒,各72条正式模拟消息全部正常,消息p95 222.29/222.34ms、私有内存最大增长0.219/0.180MiB,通过并自动停止。Chrome实际窗口均包含在各自控制器正式窗口内。完整证据为各目录`result.json`、`browser-samples.json`、`browser-result.json`、`browser-final.png`及汇总`.local/browser-performance-browser-result.json`。
+
+截图保存于正式采样结束后的页面核对阶段,更新组截图为20:58:28的165条;表中149条取自20:57:54.065的最后正式样本,不把后续截图数量计入采样窗口。格式与Go vet通过,记录`.local/browser-performance-tool-vet.txt`;最终跨文件一致性及676个相对文档链接核对通过,记录`.local/browser-performance-evidence-check.json`。
+
+控制器参数守卫、Go编译/vet、短窗口和开始前停止已检查;短测`run-653536827`、未开始即停止`run-2225139071`不算正式。首轮`run-1350068435`程序测量完成,但Chrome仅11个稀疏样本,已保留不通过记录并由62样本暂停组重测;一次采集调用300秒传输等待超时,后台采集继续完成,最终以保存的62样本/实际窗口和控制器退出结果复核,不用超时响应冒充证据。
+
+TODO-PERF-BROWSER-01关闭此独立日志页面持续窗口;TODO-PERF-01仍保留真实QQ/模型与所保留插件的实际负载及更长观察。此轮不发送真实QQ消息、不调用模型,不重复其他页面矩阵或宣称完整v0.1/P0全部验收。
diff --git a/docs/local-admin/combined-observation.md b/docs/local-admin/combined-observation.md
new file mode 100644
index 0000000000..44ab579f8b
--- /dev/null
+++ b/docs/local-admin/combined-observation.md
@@ -0,0 +1,15 @@
+# 模型回复与资源联合核对
+
+维护者运行`node scripts/check-combined-observation.mjs --resource 资源目录 --model 模型报告.json --before 起始回复快照.json --after 结束回复快照.json --output 尚不存在的结果.json`,一次检查已保存的模型、回复和资源资料。需要Node.js及输入读取权限,不需要后台密码或浏览器;无需部署或重启服务。
+
+工具复用[模型与资源核对](observation-evidence-check.md)和[回复窗口核对](qq-reply-evidence.md),验证原始资源样本、累计统计、时间和进程。除此之外,检查两次资源读取的原件摘要一致,并要求模型与回复窗口有正长度的交集。两个窗口分别有效、分别落在资源范围内,但互不重叠时,不能合并为同一段观察。
+
+模型HTTP次数和回复任务数分别列出,不要求相等:Agent可调用多次模型或识图接口,一次任务也可发送多条回复。两种累计平均/最长保持原口径,不转换为窗口耗时、同批成功率或QQ客户端收到时间。身份、资源通过、时间范围和业务验收分别判断。
+
+退出0表示三类资料内部有效、资源检查和精确进程关联通过,模型与回复观察窗口都有有效时间且重叠。退出2表示资料有效但条件不足,如短资源窗口、没有进程身份、窗口不重合或不同进程;退出1表示输入/配对/摘要无效、读取过程中资源原件改变、参数或输出失败。`businessAcceptancePassed=false`始终保留,不依据数字增长或联合核对通过关闭真实业务待办。
+
+输出仅复制各工具验证过的固定字段、各来源摘要和联合检查结果;不复制附加的正文、QQ账号、配置、密钥或程序路径。输入大小和样本量沿用原核对工具的限制。省略`--output`只打印结果,输出文件必须尚不存在;不会覆盖原件或已有结果,不访问网络、原始日志、凭据或运行服务。
+
+既有实际资料3组复核通过:DEV-81正式179资源样本/45模拟请求的模型、回复窗口交集为899.598066秒(899598066000纳秒),同进程、资源完整覆盖、联合退出0;10秒诊断资料有效、身份匹配/交集存在,但正式资源检查不足,退出2;将正式模型/资源与DEV-79另一实例的回复文件混用,内部资料均有效,却不同进程且窗口无交集,退出2。12份原输入摘要保持,没有重新下载、采样或发送消息。证据`.local/combined-observation-actual-result.json`及`-actual-{formal,short,different-fixture}-checked.json`;文件是已保存的合成实例资料,不扩大为真实业务验收。
+
+最终交付资料10个核对标记通过,覆盖26/210测试结果、3组实际复核/12份原输入保持、维护源码/两个共享工具/14份界面资源摘要与日常状态,832处文档链接存在。结果`.local/combined-observation-final-evidence.json`,源码摘要`.local/combined-observation-source-hashes.json`;交付核对只读已有资料,不再次测试或访问服务。
diff --git a/docs/local-admin/config.windows.example.json b/docs/local-admin/config.windows.example.json
new file mode 100644
index 0000000000..440ea8b1cb
--- /dev/null
+++ b/docs/local-admin/config.windows.example.json
@@ -0,0 +1,19 @@
+{
+ "zero": {
+ "nickname": ["机器人"],
+ "command_prefix": "/",
+ "super_users": [12345678],
+ "ring_len": 4096,
+ "latency": 233000000,
+ "max_process_time": 240000000000,
+ "mark_message": true
+ },
+ "ws": [
+ {
+ "Url": "ws://127.0.0.1:6700",
+ "AccessToken": ""
+ }
+ ],
+ "wss": null,
+ "force_base64_file": false
+}
diff --git a/docs/local-admin/connection-add-confirmation.md b/docs/local-admin/connection-add-confirmation.md
new file mode 100644
index 0000000000..09c6e65e53
--- /dev/null
+++ b/docs/local-admin/connection-add-confirmation.md
@@ -0,0 +1,21 @@
+# 新增连接的页面确认
+
+进入“连接设置”填写新增连接,点击“备份并新增连接”后,在页面弹窗中核对新增方向与保存说明,点击“确认新增”才提交;“取消”或Esc保留新增草稿且不提交请求。点击“放弃新增”同样先弹出页面确认,“确认放弃”只清空新增表单,取消保留原值。默认焦点放在取消按钮,弹窗期间不能操作背景表单。
+
+弹窗只显示固定操作说明,不显示Token、地址或主人字段值。确认前后核对原会话、配置版本、表单内容与生命周期;发生退出、过期或重置时关闭确认并取消旧操作,不能用旧确认提交新会话。保存仍按原接口执行版本检查、写前备份和待重启规则;不会自动连接、监听、重启或修改NapCat。
+
+本说明覆盖新增保存及放弃新增;2026-10-13已有连接/主人草稿的确认也已改为[页面弹窗](connection-draft-confirmation.md)。两类确认不会相互嵌套或替换。弹窗重置或会话过期会取消等待中的操作,后台请求完成前原忙时保护保持。以下2026-10-12记录保留当时验证范围。
+
+## 2026-10-12 验证与部署(DEV-70 / REQ-67 / TC-90)
+
+204项完整界面回归及其中21项相关流程通过,新增3项覆盖取消/Esc/默认焦点/不使用原生确认、旧草稿或设置替换、过期重置/重复提交与新会话隔离。既有64项布局及新增弹窗8项(1280/768/375/320px×深浅主题)通过;相关Go模块、vet、独立构建和22:18:36完整Windows回归通过。整机目录`.local/smoke-admin/run-3327827690/`;证据`.local/connection-confirm-{focused-ui,ui-tests,layout,dialog-layout,go-tests,vet,build,smoke}.txt`、`-layout-result.json`和`.local/connection-confirm-layout/result.json`。
+
+双模拟账号独立`run-1231404508`于22:14:58~22:29:12运行,当前程序SHA-256 `8C0204076E0E354758D4F9A0BC342BE6B864E399F6726CC3A3E924A7E8641288`。20项实际Chrome通过:页面取消与确认放弃、默认取消焦点/固定文本/无原生JS确认、重复地址拒绝并保留草稿、新增选择及其他连接/Token/未知大整数保持、删除预览确认/取消/换目标失效、删除未加载项恢复启动值、删除运行项仍保持运行通信、最后一条保护、重新新增/保存后实际重启与会话/持久状态。另8项实际弹窗主题/小屏布局通过,视口和原主题恢复;新旧测试标签均已关闭。
+
+控制器2次启动、4轮双账号往返共8条模拟回复通过,原测试密码及凭据保持、旧会话401、保存配置加载后提示一致,正常stop退出。记录各目录`result.json`、`browser-cases.json`、`browser-result.json`、`browser-dialog-layout.json`、`browser-confirmation-mobile.png`、`browser-after-restart.png`及汇总`.local/connection-confirm-browser-result.json`。控制器的`browserAcceptancePassed=false`仍只表达它本身不验证页面;独立浏览器结果按其已列范围判定。
+
+原始观察保留3条错误测试预期,不计通过数:旧夹具使用`Url`,重新新增采用规范`URL`,恢复相同URL/Token仍存在字段表示差异,页面正确报告同数量内容不同并待重启;另一次观察把重登录后保留的连接页当成概览页,不能据此判断账号离线。实际配置字段、页面结果与修正后的用例均保留,不隐藏原探测失败。实际Chrome的Esc输入尝试被原始CDP接口拒绝,该Chrome绑定没有可用的对应按键工具,因此未计实际Esc通过;离线真实Chrome键盘Esc用例通过,实际按钮取消/接受均成功。见`actual-escape-tool-limit.json`与`observer-expectation-correction.json`。
+
+既有连接/主人草稿的原生确认仍可能遇到浏览器工具焦点限制,单独保留未完成范围;此轮新增保存与放弃新增已可自动操作,不以本轮完成扩大到全部真实角色/业务/P0验收。
+
+日常后台正常自动填充登录、正确账号/108插件/无近期异常及未保存新增草稿的取消/确认放弃已自动核对,未提交真实连接写入;预览后配置摘要再次保持,见`.local/connection-confirm-daily-browser.json`与`-daily-preview-check.json`。最终日常弹窗截图`.local/connection-confirm-daily-confirmation.png`已视觉核对,截图草稿取消并清空、自建标签关闭。独立实例最初的手机截图未完整包含弹窗,不作为视觉通过证据;原图保留,另有8项离线弹窗PNG和8项实际几何/主题指标。最终跨文件与690个相对文档链接核对通过,见`.local/connection-confirm-evidence-check.json`。
diff --git a/docs/local-admin/connection-add.md b/docs/local-admin/connection-add.md
new file mode 100644
index 0000000000..92ebcc458e
--- /dev/null
+++ b/docs/local-admin/connection-add.md
@@ -0,0 +1,19 @@
+# 新增正向 OneBot 连接
+
+登录本机后台,进入“连接设置”,先读取启动配置。在“新增 OneBot 连接”中选择“正向连接”,输入接入程序提供的 ws:// 或 wss:// 地址,并输入对应 Token。接入程序确实不使用 Token 时,勾选“我确认此连接不使用 Token”;该选择会清空新 Token 输入。点击“备份并新增连接”,确认后保存。新增会附加到正向连接列表,自动选中新增项,显示旧配置备份与待重启提示。
+
+保存只修改当前实例的启动 JSON,不立即连接或重启,不改变 NapCat 的监听与账号;需要接入程序已配置相同地址/Token,下次重启 ZeroBot 后加载。此说明描述正向新增;选择“本机反向监听”时要求本机地址与 Token,见 [本机反向监听](reverse-connection.md)。另行删除已有连接见 [删除连接](connection-delete.md)。最多允许保存64条正向连接;已有超过上限的配置仍可通过原编辑功能管理。
+
+地址必须符合原 ws/wss 校验,认证信息须填在 Token 中,不能放到地址的账号、查询参数或片段中。同一正向列表中的等价地址拒绝重复(主机大小写、默认端口、空路径与根路径视为相同);不尝试网络连通性。Token最长2048字节,不能是控制字符或脱敏占位。新增项不复用任何旧 Token。
+
+新增与现有连接/主人编辑分别保存;现有连接或主人有未保存草稿时,先保存或放弃它们再新增。新增草稿切页保留;选择已有连接或单独撤销旧草稿不清空新增输入。重新加载配置或退出先确认,取消/读取失败/写入失败/冲突保留新增草稿;成功重新读取、放弃新增或退出/会话过期清空。新增请求中冻结操作,迟到结果不能回填新会话。新增 Token 只留在当前页面内存,不写浏览器存储,不通过普通接口或错误提示回显。
+
+保存采用原版本校验、受限写前备份与原子替换,保持其他正向/反向连接、主人、未知字段与大整数。冲突时原配置保留,请确认草稿后重新读取;备份失败不能继续保存。临时测试覆盖新增后的重新读取/启动快照以及写入失败,不用日常连接做测试样本。
+
+验收:检查新增表单、填写模拟地址后切页返回/放弃,核对未保存提示和原连接保持;实际新增请使用自己的已配置接入程序,保存后按需要重启,不以日常配置做破坏性演练。完整QQ业务待办保持原范围。
+
+自动化验证:新增4项Go覆盖旧字段/大整数/运行快照、备份与重新加载、地址/Token/上限/互斥校验、无连接与大小写字段、备份/写入失败、并发冲突及API认证/秘密隐藏;新增4项Chrome与原86项共90项界面,64项布局及桌面浅色/窄屏深色截图通过。02:10:56完整Windows隔离回归中新增/重复/旧版本、备份恢复与原有指令全部通过,未发送真实QQ消息或调用模型。网络失败未确认成功时先读取核对,避免重复尝试;不以隔离测试代替真实自有接入程序验收。
+
+本机部署:原启动配置、控制库、日常后台凭据保持,NapCat进程/登录保留、ZeroBot身份单实例正常,新脚本与原认证、正确账号/OneBot握手通过,无新增启动错误。证据见 [自测记录](self-test.md)。升级仅重启ZeroBot,后台可使用原密码重新登录。
+
+连接设置顶部现提供[未保存字段摘要](connection-draft-fields.md),分别核对实际加载连接、主人和新增表单的改动字段名称与数量;不显示字段值,保存与重启仍按原规则执行。
diff --git a/docs/local-admin/connection-browser-acceptance.md b/docs/local-admin/connection-browser-acceptance.md
new file mode 100644
index 0000000000..8e10e3e04c
--- /dev/null
+++ b/docs/local-admin/connection-browser-acceptance.md
@@ -0,0 +1,30 @@
+# 连接设置的真实浏览器隔离验收
+
+维护入口为 `go run scripts/start-admin-connection-acceptance.go`(仓库根目录,Windows,CGO_ENABLED=0)。临时程序与本机监听需要执行权限;页面自动化使用已连接Chrome。输出本次URL和私有`login.txt`路径;用本次随机密码登录并进入“连接设置”,分别检查两条正向连接、主人、Token保留/替换/清除、非法输入、新增/删除、确认与草稿保护。只有本次生成的回环模拟地址用于演练。
+
+`status`读取本次状态,`check`向两台模拟OneBot各发送一次翻转指令并校验回复;`restart`校验已保存连接仅指向本次两个端点且保留正确测试Token/主人后,只重启本次程序,核对两台均重新握手、原密码仍可用、旧会话失效。守卫不满足时拒绝重启并保留实例,先通过页面恢复测试连接。`stop`或期限到达停止自有实例。每次运行新建目录,不删除原测试证据。
+
+保存不立即改变运行连接;多连接编辑必须保留其他项及未知字段,重启提示与启动快照应一致。页面改动通过真实控件提交;控制器只准备合成环境、校验已保存状态及模拟通信,不用接口代替页面操作。模拟两账号不等于两个真实QQ或NapCat配置联动验收。
+
+## 2026-10-12 记录(DEV-69 / REQ-66 / TC-89)
+
+新辅助入口`adminqa.NewWithIdentity`为两个端点分别提供87654321/87654322的生命周期事件、登录身份和私聊事件;默认入口仍使用原87654321。`get_login_info`现返回相应模拟账号,不把模拟主人误作机器人身份。第二端点等候本次程序握手后独立核对回复,两个身份在实际页面同时显示在线。共享辅助模块已有测试及vet、新工具编译/vet通过,证据`.local/connection-browser-qa-tests.txt`、`-qa-vet.txt`、`-tool-vet.txt`。
+
+| 已完成页面范围 | 结果 |
+| --- | --- |
+| 两个不同账号在线、两条连接读取与Token不回显 | 通过 |
+| 选择第二项读取正确目标,修改地址并保留第一项 | 通过 |
+| 主人列表保存、写前备份、其他字段及9007199254740993精度保持 | 通过 |
+| 替换第二项Token,第一项不变 | 通过 |
+| 未确认清除拒绝、勾选后仅清除第二项 | 通过 |
+| 原测试Token未恢复时,重启守卫先拒绝且两条运行连接仍回复 | 通过 |
+| 重复主人、非法地址拒绝,保存文件不变 | 通过 |
+| 原生确认消失后原连接及草稿保留;恢复基线清除草稿 | 结果状态通过,确认框消失方式不确定 |
+| 重启后浏览器旧会话退出、原密码重新登录 | 通过 |
+| 两条保存配置、主人及第二项修改地址重启后保持,提示与启动一致 | 通过 |
+
+总计16项已完成用例,详情`browser-cases.json`。独立控制器共2次启动、4轮双账号检查(8条模拟翻转回复)通过;原密码/凭据摘要保持、旧会话401、保存配置加载后无需再次重启。测试实例正常`stop`退出,`result.json`中`passed=true`只表示控制器与通信范围;`browserAcceptancePassed=false`及`browser-result.json`中`fullConnectionMatrixPassed=false`保留完整页面未完成状态。
+
+原生“放弃草稿”以及“新增连接”确认框触发后,点击/选择出现CDP等待超时;受支持的原生取消、确认及标签关闭也在`Emulation.setFocusEmulationEnabled`处超时。第一确认框后续已消失,实际页面保留原目标和草稿,但不能确定由哪次操作解除;用户反馈当时没看到确认框,不能把这条反馈当作人工取消成功。第二次新增确认后工具再次阻塞,尚未证明重复新增保护、实际新增或删除页面通过。没有通过接口补写模拟UI操作,也没有更改扩展、浏览器权限或浏览器配置。
+
+工具异常与恢复后的页面状态见`native-dialog-tool-limitation.json`、`native-dialog-recovered-state.json`;新增场景的工具异常见`native-dialog-add-tool-limitation.json`。没有在阻塞状态声称截图成功。已请用户关闭本次自建59929测试标签,以解除确认阻碍;浏览器恢复后再继续新增/重复/删除及原生确认接受/取消验证。TODO-UI-CONNECTION-01与DEV-69保持进行中,不重新打开已通过的其他页面待办。
diff --git a/docs/local-admin/connection-delete.md b/docs/local-admin/connection-delete.md
new file mode 100644
index 0000000000..d90323e132
--- /dev/null
+++ b/docs/local-admin/connection-delete.md
@@ -0,0 +1,13 @@
+# 删除已保存的 OneBot 连接
+
+登录本机后台,进入“连接设置”,选择已保存的正向连接或反向监听,点击“预览删除当前连接”。核对类型、序号、已脱敏地址以及删除后的总连接数,勾选“我确认从已保存配置中删除这个连接”,点击“备份并删除连接”。预览与取消只操作本页,不发写请求;确认保存采用原版本校验、受限写前备份及原子替换。
+
+删除只移除所选启动JSON连接项,保留其他连接、主人、未知字段和大整数,不立即断开、重启或修改NapCat登录。当前运行仍需要改变时重启ZeroBot后才生效;若只是删除尚未加载的新连接且恢复到本次启动配置,则提示无需为本次删除重启;需要恢复时从删除前的启动配置备份预览恢复整个启动配置。正向与反向合计至少保留一条,不能删除最后一条;无连接或未读取配置时不能删除。删除后同类型序号会调整,本次启动信息仍按原列表序号展示,不能将原序号与保存后的序号视为同一连接身份。
+
+现有连接/主人或新增表单有未保存草稿时,先保存或放弃再预览删除。预览固定到当时读取的配置版本与所选项,换连接、编辑任何草稿、成功重读/保存、切换后台页面、取消、失败或退出会清空确认。请求中冻结页面切换、退出与其他连接操作;过期及迟到响应不能回填或解锁新会话。成功后重新读取后的相邻项被选中,展示写前备份和待重启提示。
+
+错误或冲突时不声称删除成功,配置保留并清空删除确认;网络中断时无法判断是否已保存,先重新读取核对,再决定是否操作,避免按变化后的序号误删。普通接口、确认提示和错误不展示Token;预览地址沿用原脱敏显示。接口要求明确的类型、序号、确认与版本,删除不能与新增/编辑/主人修改混合提交。
+
+验收可仅预览自己的连接再取消,检查目标和最后一条保护;隔离测试演练删除、备份恢复与启动快照加载,不使用日常连接执行破坏性测试。完整真实恢复及QQ业务仍按原待办范围记录。
+
+自动化验证:新增4项Go覆盖正向/反向选定项移除、其他字段与精度/运行快照保留、删除前备份恢复和重开快照、明确序号/确认/互斥/最后一条/别名、写入/备份失败、并发与API认证;新增4项Chrome与原90项共94项界面、64项深浅色/窄屏布局通过。页面校验目标固定、文本显示、草稿/导航/取消/重读失效、无确认不提交、错误与迟到会话隔离;另覆盖删除未加载项而恢复到运行配置时无需重启的提示。未删除日常连接,实际体验另留待办。
diff --git a/docs/local-admin/connection-discard.md b/docs/local-admin/connection-discard.md
new file mode 100644
index 0000000000..59f4d70bee
--- /dev/null
+++ b/docs/local-admin/connection-discard.md
@@ -0,0 +1,11 @@
+# 连接设置局部放弃更改
+
+登录本机后台进入“连接设置”,读取配置后,在保存按钮上方选择“放弃当前连接更改”或“放弃主人账号更改”。仅在相应部分存在未保存输入时可用。操作先确认,取消保留全部编辑;确认后只恢复这一部分最近读取或保存成功的值,另一部分草稿保持。
+
+放弃当前连接时,以当前实际加载的连接为准,恢复其地址,清空新Token输入,动作回到保留,隐藏并取消清除确认;连接选择与主人账号草稿保持。放弃主人账号时,恢复已读取的主人QQ列表,保留连接地址、Token新输入及替换/清除确认选择。有多条连接时不影响其他连接;没有可编辑连接时当前连接撤销禁用,主人账号仍可按可用配置独立撤销。
+
+操作仅恢复页面输入,不发送请求、不创建备份、不修改已保存配置、当前运行连接或QQ登录;已保存但待重启的提示继续显示。撤销结果另行提示,不会覆盖保存/待重启状态。已保存成功的更改不能通过此按钮回退。版本冲突后,撤销只恢复上次读取的值,需要重新加载配置获取最新规则。
+
+请求中、未读取配置、无相应草稿或无可用配置时禁止操作。退出/会话过期清空输入,旧确认不能恢复新登录或新加载目标;原切换连接/重新加载/退出确认、固定目标保存、写前备份与需重启生效规则不变。草稿只在页面内存,不写浏览器存储或文件,提示和确认不展示Token。
+
+验收:修改地址/Token动作与主人账号但不保存,分别先取消再确认放弃一部分,核对另一部分草稿保持;Token测试使用合成文字。若已保存配置待重启,确认局部放弃后提示仍在。不需为验收保存日常配置、改变主人或重启NapCat,保存/冲突/旧会话场景由隔离测试覆盖。
diff --git a/docs/local-admin/connection-draft-confirmation.md b/docs/local-admin/connection-draft-confirmation.md
new file mode 100644
index 0000000000..811d061a97
--- /dev/null
+++ b/docs/local-admin/connection-draft-confirmation.md
@@ -0,0 +1,23 @@
+# 连接与主人账号草稿的页面确认
+
+入口为本机后台登录后的“连接设置”。已有连接地址或 Token 有未保存更改时,切换连接先确认;主人账号草稿保留。点击“放弃当前连接更改”或“放弃主人账号更改”只撤销对应部分。重新加载配置和退出登录会先提醒丢弃连接设置草稿,包括尚未保存的新增连接。
+
+确认在页面内显示,默认焦点为“取消”。取消或按 Esc 保留草稿,不发送该操作的请求;确认后按原有规则切换、重新读取或退出。确认说明只显示操作与连接序号,不显示地址、Token 或主人账号值。登录失效、配置重新读取或待确认草稿变化后,旧确认不得继续操作新页面。
+
+局部放弃不修改已保存配置,也不影响运行中的连接。重新读取成功后采用已保存配置;失败保留原草稿。连接保存仍需备份与版本核对,保存后按提示重启生效。本轮不改变插件或 AI 表单的确认方式;真实 QQ 角色和持续业务负载按各自待办验证。
+
+## 自动验证(DEV-71 / REQ-68 / TC-91)
+
+212项完整界面回归、40项连接相关回归通过,其中新增8项覆盖默认取消焦点、Esc/外部关闭、三部分草稿保留、目标草稿变化与另一部分保留、旧会话/设置替换、并发与新增弹窗互斥、旧关闭事件及迟到退出响应。既有64项页面布局与两类弹窗16项离线布局通过;相关Go模块、vet、构建通过。00:25:42完整Windows隔离回归通过,目录`.local/smoke-admin/run-4265110890/`。证据为`.local/draft-confirm-{ui-tests,focused-ui,layout,dialog-layout,go-tests,vet,build,smoke}.txt`、`-layout-result.json`及`.local/draft-confirm-dialog-layout/result.json`。
+
+实际Chrome独立目录`.local/connection-browser-acceptance/run-2686500409/`的20项核对通过,涵盖已有连接/主人局部放弃、切换、重新加载、退出的取消和确认、三部分草稿范围、固定说明与默认焦点、原密码重新登录、保存备份与重启、旧登录拒绝及双模拟账号。8项实际弹窗几何/主题/焦点检查通过,视口与原主题恢复,自建测试标签关闭。控制器两次启动、三轮双账号检查共6条模拟回复通过,已停止;没有调用真实QQ或模型。控制器`result.json`与浏览器`browser-result.json`分别记录,互不代替。
+
+桌面`draft-confirmation.png`和小屏`draft-mobile-confirmation-verified.png`已查看且包含弹窗。循环截图保留但不作为视觉通过证据;实际Chrome Esc输入受工具能力限制,不计通过,离线Chrome键盘Esc已验证。首轮整机资源检查仍查找旧反向条件字符串,脚本标记已同步为当前会话/配置保护及新弹窗入口后整机通过;原失败保存在`draft-confirm-smoke-initial.txt`。首轮完整界面中概览测试仍按同步退出立即完成旧请求,更新为等待实际退出请求后212项通过;原211/212记录保存在`draft-confirm-ui-tests-initial.txt`,不计通过。
+
+新构建SHA-256:`0CF3DE9A2ACCE07772025577D0BBE59A76958443EECEBA9AB73B8CE57FC4E6F0`。本轮没有重复历史五分钟性能测量,不把上述模拟验证扩大到真实QQ其他角色或长期负载。
+
+## 部署与回退
+
+刷新后台重新登录即可使用。旧程序保存在`.local/bin/zerobot-before-draft-confirm-20261013-003334.exe`,SHA-256为`8C0204076E0E354758D4F9A0BC342BE6B864E399F6726CC3A3E924A7E8641288`;回退信息`.local/draft-confirm-rollback.json`,控制快照`20261012T163006.174452400Z-7c3d1f548e4aa48f-control`。只回退程序时不要恢复配置或停止NapCat,启停步骤见[运行维护](operations.md)。
+
+日常后台已用Chrome正常自动填充登录,核对账号87654321、108插件和近期警告/错误均为0。未保存的主人草稿页面确认已自动执行取消与局部放弃,草稿清空、自建标签关闭,没有提交日常配置写入;五项配置摘要再次保持。证据`.local/draft-confirm-daily-browser.json`、`-daily-preview-check.json`、`-daily-config-check.json`;实际弹窗截图`-daily-confirmation.png`已查看。最终文档相对链接、资源入口、构建/回归/部署证据核对通过,见`.local/draft-confirm-evidence-check.json`。
diff --git a/docs/local-admin/connection-draft-fields.md b/docs/local-admin/connection-draft-fields.md
new file mode 100644
index 0000000000..9bb24944e9
--- /dev/null
+++ b/docs/local-admin/connection-draft-fields.md
@@ -0,0 +1,17 @@
+# 连接设置未保存字段摘要
+
+使用后台密码登录本机管理页,进入“连接设置”。修改已加载连接的地址、Token 操作或主人账号,或者填写“新增 OneBot 连接”,顶部“未保存字段摘要”随输入自动更新,分别显示各部分的固定字段名称与数量。改回页面基线后该字段移除,没有草稿时摘要隐藏;不要求保存日常配置即可核对。
+
+当前连接最多显示“WebSocket 地址”和“Token”两项;Token 的操作、输入与清除确认合并计为一项。主人账号单列为启动配置范围。新增表单从默认正向、空地址、空 Token 和未勾选无 Token 的状态开始比较,分别显示“连接方向”“WebSocket 地址”和“Token”;Token 输入与无 Token 确认合并计数,改变方向也算草稿。
+
+摘要不显示地址、Token、主人 QQ、新旧值、版本或配置正文。已有连接的标题只使用实际加载连接的方向与原序号,未提交的选择控件不能更改摘要目标;无法核对元数据时只显示通用目标提示,不展示原值。新增表单固定标为“尚未保存的新连接”,其默认方向不代表新增请求已提交。
+
+摘要只比较当前页面输入与最近成功读取/保存后的页面基线,沿用原草稿文字比较;输入格式变化也可能计为未保存,不表示校验通过、服务端最新状态、已保存或正在运行。连接与主人属于启动配置,保存前按原规则备份,重启 ZeroBot 后生效;新增与现有连接/主人分别保存,不自动修改 NapCat、连接或重启。
+
+单独放弃当前连接、主人或新增草稿只清对应摘要;切换连接按原确认规则处理当前连接草稿,主人与新增草稿保留。保存现有配置只更新现有连接/主人基线,新增草稿保留;成功新增或重新读取才按原行为替换草稿。忙时保留摘要并提示等待,失败或冲突保留,切换后台页面保留;退出、会话过期或成功恢复配置后清空,旧请求不能回填新会话。未读取、未登录或启动配置不可管理时隐藏摘要。
+
+本功能只用于本地核对,不增加接口、配置写入、备份或浏览器存储。删除预览和已保存配置与本次启动的差异仍在原区域展示。
+
+验收:修改地址、Token 操作、主人或新增方向,核对名称、数量和实际加载目标;改回原值或分别放弃后对应摘要消失。无需保存、重启或修改日常接入地址。
+
+## 自动化验证与部署
diff --git a/docs/local-admin/connection-drafts.md b/docs/local-admin/connection-drafts.md
new file mode 100644
index 0000000000..161c027eb6
--- /dev/null
+++ b/docs/local-admin/connection-drafts.md
@@ -0,0 +1,17 @@
+# 连接设置编辑保护
+
+登录本机后台进入“连接设置”。编辑 WebSocket 地址、Token 操作或主人 QQ 后,页面提示未保存;恢复读取时的值会清除提示。草稿只保留在当前页面内存中,切换后台页面仍保留,不写浏览器存储。
+
+选择另一个连接时,如果当前连接的地址或 Token 有未保存修改,先确认是否丢弃;取消恢复原连接,继续则读取已加载的另一连接值。主人账号属于整个启动配置,切换连接不会丢弃主人草稿。保存目标始终是表单实际加载的连接。
+
+“重新加载配置”和退出登录会对所有未保存修改提示确认;取消不发请求。确认重新加载后,成功才替换表单,读取失败保留原值、选择和版本。保存失败或配置冲突保留草稿;成功后清除已保存草稿和新 Token 输入,显示“待重启生效”(如果保存值与启动值不同)。不会自动重启或修改 NapCat。
+
+保存/读取中禁用表单和重复请求,退出提示等待。关闭或刷新浏览器时,有草稿或请求未结束会请求原生离开提醒;实际是否显示取决于浏览器及用户交互状态。退出成功或会话过期清空地址、主人、Token 和草稿,迟到的旧会话读写结果不会回填、解锁或覆盖新页面。
+
+当前 Token 不通过读接口返回,替换值只输入在密码框,提示只描述字段类型,不显示敏感内容。保留、替换、显式清除及清除确认保持原语义,备份包含凭据且仅存本机。后台登录权限、写前备份与其他连接/未知字段保留规则保持不变。
+
+验收:仅修改表单,核对未保存提示;取消重新加载、退出及切换连接,确认输入保留;切换后台页面再回来确认草稿保留;改回原值确认提示取消。保存会影响下一次启动连接,实际日常地址无需故意改错。多连接、失败/冲突、过期与迟到响应由隔离测试覆盖。
+
+自动验证:六项新增 Chrome 场景与原 38 项界面行为,共 44 项通过;56 项深浅主题/320~1280px布局通过,核对桌面与窄屏连接截图。Go 后台/基础指令/LLM/chat 模块及静态检查通过。18:42:33 完整 Windows 程序回归通过,新资源和原功能全部标志通过,证据 .local/smoke-admin/run-2304155827/result.json;模拟配置与接口未涉及真实凭据或 QQ 消息。首次 fieldset 禁用断言因 Playwright 对非交互元素判断不适用,改为核对原生 disabled 属性及子控件的实际禁用后通过。
+
+连接设置顶部现提供[未保存字段摘要](connection-draft-fields.md),分别核对实际加载连接、主人和新增表单的改动字段名称与数量;不显示字段值,保存与重启仍按原规则执行。
diff --git a/docs/local-admin/connection-settings-2026-10-01.md b/docs/local-admin/connection-settings-2026-10-01.md
new file mode 100644
index 0000000000..eaeb4dc5e6
--- /dev/null
+++ b/docs/local-admin/connection-settings-2026-10-01.md
@@ -0,0 +1,57 @@
+# 连接设置与启动配置备份验证
+
+## 实现范围
+
+- 登录后读取、编辑 `-c` 指定文件中的已有 `ws`/`wss` 连接与 `zero.super_users`。不使用 `-c` 时显示不可编辑。
+- Token 只返回“已配置/未配置”;URL 的用户凭据、查询和片段脱敏。保留、替换和清除分别提交,清除页面有确认勾选;占位符不能替代真实 Token。
+- 校验 ws/wss 地址与端口、主人正整数/重复/数量、Token 长度/控制字符;主人 ID 以字符串在 API 中传递,避免 JavaScript 大整数精度丢失。读取与保存后的配置均限 1 MiB,格式化导致超限时拒绝保存并保留原文件。
+- 保留其他连接、其他字段和未知字段数值精度。重复 JSON 字段和字段别名歧义会拒绝;仅改动指定字段。
+- HMAC 版本标记阻止旧页面覆盖;服务内串行写入,写前重复检查外部变化。
+- 每次实际修改先生成私有旧配置备份,再写入同目录临时文件、同步并通过 Windows MoveFileEx 替换。备份失败不覆盖;替换失败保留原文件和已完成备份;无修改不写盘。
+- 保存值与本次启动快照分开,改变连接/主人配置后提示待重启。认证、来源与 CSRF 检查沿用已有后台;响应禁止缓存。
+
+备份封装版本为 1、范围为 `startup-config`,包含 `createdAt` 和旧配置 `config` 字段。本机保存到 `.local/admin/backups/`;含 Token,限制为当前 Windows 用户及 SYSTEM。用法见 [运行操作](operations.md)。
+
+## 可重复验证
+
+在项目根目录执行,环境为 Go 1.26.1、Windows amd64、CGO=0:
+
+```powershell
+$env:CGO_ENABLED = '0'
+go test ./internal/localadmin ./internal/basiccommands -count=1 -timeout=60s
+go vet ./internal/localadmin
+node --check internal/localadmin/assets/app.js
+go build -trimpath -o .local/bin/zerobot-next.exe .
+go run scripts/smoke-local-admin.go -binary-name zerobot-next.exe
+```
+
+完整程序隔离模拟结果,完成时间 22:56:05(Asia/Shanghai,包含格式化大小保护修复):
+
+| 检查 | 结果 |
+| --- | --- |
+| 后台认证与真实编译插件注册表 | 通过 |
+| 连接读取无 Token、保存前备份、非法 CSRF 和旧版本拒绝 | 通过 |
+| 保存后 help、带换行 help、服务列表图片回复 | 通过 |
+| 真实 QQ 消息发送 | 未发送;使用模拟 OneBot 与独立配置 |
+
+## 本机部署
+
+22:52 已备份旧程序为 `.local/bin/zerobot-before-connections-20261001-225200.exe`,停止本项目 ZeroBot 进程树、替换程序并通过已有 Windows 计划任务重新启动。NapCat 保持登录;部署前后日常配置 SHA256 一致,新程序与测试构建 SHA256 一致。本次没有通过管理接口修改日常机器人配置。
+
+| 部署检查 | 结果 |
+| --- | --- |
+| 后台 `/` | HTTP 200,包含连接设置入口 |
+| 内嵌 `app.js` | HTTP 200,包含连接 API |
+| 未认证 `/api/connections` | HTTP 401 |
+| ZeroBot 实际进程 | 正常运行,程序路径符合本项目 |
+| NapCat 登录与三个只读 API | 通过 |
+| ZeroBot 与配置指定机器人账号的实际握手 | 新日志确认成功,无启动 fatal/panic |
+
+重启使原后台会话失效;刷新页面并重新登录即可查看新入口。API/静态资源检查不代表浏览器交互或视觉验收。
+
+## 待完成与边界
+
+- DEV-05/06 仍标记进行中。页面校验、清除确认及配置修改后的真实连接重启联调待验收;本次只在模块测试中重新加载修改后的配置,完整程序隔离测试验证保存后继续处理消息。
+- 尚无连接新增/删除、NapCat 配置联动、插件/AI 写接口、SQLite 备份、手动备份、备份列表与恢复。JSON 写前备份不能替代完整恢复演练。
+- 当前原子替换及故障注入已验证;真实断电、磁盘硬件故障及多文件事务未验证。不承诺任意外部编辑器与后台共享锁,保存期间不要同时编辑配置。
+- 不把此阶段视为全部插件 Windows 兼容、完整资源包或全部 P0 验收通过。进度及用例分别见 `progress.md`、`self-test.md`。
diff --git a/docs/local-admin/control-policy-backups-2026-10-02.md b/docs/local-admin/control-policy-backups-2026-10-02.md
new file mode 100644
index 0000000000..47e648d102
--- /dev/null
+++ b/docs/local-admin/control-policy-backups-2026-10-02.md
@@ -0,0 +1,9 @@
+# 控制策略写前备份与验证
+
+启用本地后台后,响应/沉默、全局响应/沉默、用户封禁/解封、插件按群及全局禁止/允许,共用与开关和 AI 配置相同的私有控制库备份目录。后台关闭时不安装自动备份钩子。业务消息计数、其他数据库及外部编辑不属于本轮自动备份范围。
+
+在共享 Manager 锁内先检查持久化状态,区分未找到与读库失败。有变化时先建立含已提交 WAL 数据的完整 SQLite 快照,再执行原单条写入;备份或写入失败保留磁盘及缓存状态,SQL 写入失败前已成功建立的快照可用于恢复。重复操作不额外备份,重复响应/沉默提示已有状态。全局响应优先规则不变,无本地行时沉默不删除全局响应;按群允许不撤销全局禁止。
+
+禁止/允许原方法没有错误返回,聊天指令无论保存是否成功都可能报告成功。本轮改为检查 Ban/Permit 的 error 返回,封禁/解封也明确报告失败。错误回复不直接输出底层 SQL 或文件路径。批量操作按账号逐项执行和备份,允许部分成功,报告对应每一项结果;不是整个批次的原子事务。群内 @ 与原权限规则保留。
+
+模块测试验证八类写入的旧状态快照、WAL 内容、重复操作、重新打开数据库后的持久化、备份失败、SQL 触发器故障及读取失败;并验证全局回退规则和八个并发重复封禁只产生一个快照。后台、基础指令及 LLM 回归通过,相关 Go 静态检查和 Windows CGO=0 构建通过。
diff --git a/docs/local-admin/deepseek-vision.md b/docs/local-admin/deepseek-vision.md
new file mode 100644
index 0000000000..624007fb49
--- /dev/null
+++ b/docs/local-admin/deepseek-vision.md
@@ -0,0 +1,13 @@
+# DeepSeek 识图配置与使用
+
+识图配置为`ImageType=OpenAI`、`ImageAPI=https://api.deepseek.com/chat/completions`、`ImageModelName=deepseek-flash`,使用已经保存的同一DeepSeek密钥。密钥只在本机内部复制到独立识图字段,不输出到聊天或记录;以后修改聊天密钥不会自动同步识图密钥。保存前备份,其他全局AI字段、人物设定、群/私聊参数、插件及权限保持。
+
+## 使用
+
+在已启用Agent且有效响应的会话中,同时发送QQ图片与文字提问;群聊按已有@规则@机器人,主人私聊直接发送图片加提问。例如“请描述图片里有哪些形状和颜色”。图片须是QQ消息中的实际图片段,不是本机文件路径文字。
+
+现有流程先用识图模型生成简短中文图片描述,再将描述送入Agent上下文,由Agent回答提问;不是将所有历史图片直接交给最终动作模型。普通`/gpt`及纯文字聊天保持现有文字接口,本次不新增`/gpt`图片处理,也不改群开关、角色白名单或语音输出。
+
+QQ图片通过可读取的HTTP/HTTPS地址或消息base64数据加载;本机路径不读取。程序把图片缩放至最多1024×1024,再发送给模型,并使用相似图描述缓存。因此较小文字/图表细节可能丢失,不保证精确OCR;单独无文字图片可能被现有触发条件忽略。识图请求可能产生服务商图片与文本计费,图片内容会提交给DeepSeek。该流程只在Agent实际触发时运行,不批量导出聊天图库。
+
+在后台“AI设置”检查识图字段即可;基础检查通过只说明已配置,不能代替实际模型识图和QQ验收。首次配置采用停止ZeroBot、核对备份及只改四个识图字段、重新启动的流程;不重启NapCat或重置对话档案。以后后台保存后,后续请求使用新设置。
diff --git a/docs/local-admin/delivery-evidence-check.md b/docs/local-admin/delivery-evidence-check.md
new file mode 100644
index 0000000000..36c9e7270c
--- /dev/null
+++ b/docs/local-admin/delivery-evidence-check.md
@@ -0,0 +1,31 @@
+# 本机交付证据核对
+
+在仓库根目录的 PowerShell 执行:
+
+```powershell
+node scripts/check-local-admin-delivery.mjs --evidence-prefix log-clear-display
+```
+
+只有后端变化、界面资源保持原样时,可明确分开程序与界面证据:
+
+```powershell
+node scripts/check-local-admin-delivery.mjs --evidence-prefix wordcount-range --ui-evidence-prefix log-clear-display
+```
+
+`--evidence-prefix`始终对应当前测试程序、Go/vet/构建、整机和部署;可选的`--ui-evidence-prefix`仅指定原界面/新增界面/布局记录,默认与程序前缀相同。分开使用必须存在`.local/<界面前缀>-ui-baseline.json`,包含原界面证据文件与全部静态资源的SHA-256;文件摘要、完整资源文件集合和当前资源内容必须一致,否则退出1,不能仅凭修改时间复用。单一前缀核对成功会保存该界面基线;分开核对不会覆盖旧基线。该基线是本机证据索引,不是配置备份,不会产生新界面测试。
+
+原记录较早、尚无基线文件时,只能从此前已通过的核对结果与保留的原测试文件核实后导入;不能根据当前新程序替旧界面测试生成“通过”结果。此次21:50:21已通过的`log-clear-display`记录用于导入,原197项/64项保留实际执行时间;先核对全部12个界面资源摘要一致和原3份证据早于原部署,原记录另存`.local/log-clear-display-evidence-before-split.json`,导入不会运行新UI测试。热词程序采用22:18:20整机/22:22:03部署证据,分开核对通过。
+
+PowerShell的`Write-Host`就绪信息可能未被原管道捕获。若构建日志缺少该标记,必须有Go版本和模块校验成功记录,并用原升级前`.local/<程序前缀>-before.json`中的`stagedHash`核对测试程序,仍须满足实际整机、部署、时间顺序与磁盘程序一致检查;不能仅凭“all modules verified”判断构建成功。存在就绪标记但程序名错误时仍失败。
+
+`node --test scripts/test-delivery-evidence.mjs`的8项合成CLI回归通过,覆盖单前缀自动基线、分开来源及原时间、修改资源且保持mtime、增加/删除资源、修改仍通过的UI测试记录、基线缺失/重复/错误前缀/未来时间、非法参数无写入、缺失Write-Host标记的原快照摘要,以及新程序/Go回归失败。不访问服务或真实QQ。证据`.local/delivery-split-tool-tests.txt`。
+
+前缀对应 `.local/<前缀>-ui-tests.txt`、`-focused-ui.txt`、`-layout-result.json`、`-go-tests.txt`、`-vet.txt`、`-build.txt`、`-smoke.txt` 和 `-deployment.json`,以及 `.local/bin/zerobot-<前缀>-tested.exe`。分开核对时前三类文件取界面前缀,其他文件取程序前缀。必须先有真实回归和部署记录;脚本不运行测试,不构建或部署程序,不能用不存在的前缀创建“通过”证据。两种前缀均仅允许小写字母、数字和中间短横线。
+
+需要 Node.js 和读取本机忽略目录的文件权限,不需要后台密码。脚本读取既有结果,确认界面测试没有失败/取消/跳过、布局为离线通过、四个相关 Go 包有通过记录且 vet 无诊断、构建记录和 Windows 整机/部署检查通过;核对磁盘日常程序与测试构建的 SHA-256、资源修改时间及构建/整机/部署顺序。另检查 `docs/local-admin` 的本地 Markdown 链接和整机脚本声明的固定 HTML/JS/CSS 入口,记录当前静态资源摘要。
+
+通过时退出码为 0,标准输出简要结果;失败退出码为 1,并列出未满足的检查。参数不符合格式时退出码为 2。完整结果写入 `.local/current-delivery-evidence.json`,每次覆盖本机上一份核对结果;该文件不是配置备份,不用于恢复。输出不读取配置、凭据、QQ 正文或模型记录,程序和静态资源摘要只用于版本核对。
+
+已有测试记录和修改时间不能独立证明所有业务正确。磁盘程序一致不代表进程正在运行,历史部署的账号/连接正常不代表当前仍在线;脚本不访问服务、QQ 或模型接口。它不替代新代码的回归、全新机器构建、真实页面体验、实际 QQ 业务或长期性能验收。文档维护使用已有证据时,必须保留原测试和部署时间,并说明本轮没有重新执行。
+
+新功能开发仍先更新使用说明,执行相关回归后再核对新前缀;核对通过不能自动关闭其他人工待办。当前交付范围和执行记录见 [交付核对](release-v0.1.md)。
diff --git a/docs/local-admin/github-delivery.md b/docs/local-admin/github-delivery.md
new file mode 100644
index 0000000000..be12845245
--- /dev/null
+++ b/docs/local-admin/github-delivery.md
@@ -0,0 +1,37 @@
+# 第一版 GitHub 交付规则
+
+## 发布条件和目标
+
+先完成需求表与待办中第一版的验收范围,记录实际结果和仍存在的限制;用户明确取消的客户端体验事项保持关闭,不重新列为发布条件。已测真实角色与主人Agent按实际记录判断;其余真人Agent、客户端体验和真实持续负载已取消本轮要求,不记实测通过。
+
+目标是用户自己的`origin`:`https://github.com/qiye-yuri/ZeroBot-Plugin`,工作分支现为`codex/local-admin`。发布前重新检查远端、当前分支、已有提交与默认分支,将本轮实现推送到用户仓库;需要PR时指向用户仓库的目标分支。上游`FloatTech/ZeroBot-Plugin`不是默认发布目标。第一版完成并核对后更新该仓库;不自动向上游推送或合并。
+
+后台文档中的v0.1表示本地管理后台的第一版范围,项目自身另有版本号。正式标签及发布标题应与已有项目版本核对,不能覆盖已有标签或混用两个版本体系。
+
+## 文件范围
+
+| 文件类别 | 处理 |
+| --- | --- |
+| 产品源码、必要依赖与构建/测试配置 | 上传;本地替换的依赖实现须随源码完整提供 |
+| README、使用步骤、配置示例、运行维护 | 上传,配置示例不含真实凭据 |
+| 需求、设计、开发和必要测试说明 | 上传可复用内容及验证摘要,标明真实/隔离范围 |
+| 对话逐字记录、会话总结、临时问答草稿 | 保留本机,不上传 |
+| 本机调试截图、私有验收证据、日志、聊天档案、运行库及配置备份 | 保留本机,不上传 |
+| 非使用/开发用途的对话衍生文档 | 不上传;不能仅因位于docs目录就认为可公开 |
+
+`.local/`与插件内生成的运行数据已忽略;`docs/local-notes/`用于本机对话资料,也已忽略。忽略规则不会自动保护已跟踪文件,最终以拟提交清单及差异检查为准。不要删除本机资料,不要将已有数据子模块的运行变化作为源码更新一并提交。
+
+按内容用途筛选已有文档,日期化名称不是排除依据。将公开使用/开发说明中的真实账号、群号和本机路径改为示例;私人原始证据保留本机,公开文档只保留必要验证摘要。修正公开索引对不上传资料的链接,避免读者打开失效文件或被要求访问开发者的私有目录。
+
+## 中文格式和交付步骤
+
+1. 汇总第一版行为、配置迁移/生效方式、验收结果和限制,检查使用与开发文档是否同步。
+2. 按上述范围列出拟提交文件,核对差异、引用、生成文件、私有内容与依赖完整性;只暂存清单中的文件。
+3. 完成适合最终变更的检查,使用中文提交说明。类型使用`feat`、`fix`、`docs`、`test`、`chore`等,如`feat: 增加本地机器人管理后台`;正文说明必要影响和验证。
+4. 将提交推送到用户仓库。PR采用中文标题,按仓库模板写清“变更说明、验证结果、使用与兼容性、限制与待办”,不能写“全部通过”掩盖未验收范围。
+5. 如本次版本计划包含正式版本发布,核对版本和标签后整理中文版本说明,按“新增、修复、变更、升级说明、验证与限制”组织;只上传计划中的公开交付产物。
+6. 记录实际提交、分支、PR或版本链接及结果。未成功完成的环节保持待办,不能只准备了说明就记作已发布。
+
+权限及文件边界同样适用于后续版本。本说明不要求重复申请用户已授权的正常仓库更新,但发布必须满足“第一版完成后”的条件。
+
+公开交付文件按清单检查;中间交流、本机调查与原始开发记录统一放在docs/local-notes并整体忽略。该目录的资料不会因位于docs中而被提交。
diff --git a/docs/local-admin/gpt-quoted-message.md b/docs/local-admin/gpt-quoted-message.md
new file mode 100644
index 0000000000..4c111b18cc
--- /dev/null
+++ b/docs/local-admin/gpt-quoted-message.md
@@ -0,0 +1,9 @@
+# GPT 引用原文与本次要求
+
+私聊,或已启用`llm`且响应有效的群中,使用QQ的消息引用/回复功能选择有文字的消息,再输入`/gpt 本次要求`。确认输入框出现引用卡片,仅@发送者不代表有引用消息段。需满足原群响应、插件与角色规则;GPT与群总结在同一群或同一私聊仍共用30秒一次额度,正在处理的同会话并发请求仍可能被忽略。
+
+同时存在引用文字和新要求时,提交模型的提示会明确列出“引用原文”和“本次用户要求”,原文编码为JSON字符串以保留换行、引号、反斜线和中文/emoji,且明确新要求不属于原文。没有引用的普通`/gpt 问题`保持原输入;只有引用文字、发送`/gpt`而没有新要求时,仍按原方式提交引用文字;两者都没有则提示用法。
+
+例如引用“私聊调试成功”后发送`/gpt 请原样重复引用文字`,期望只回复被引用文字,不把新要求一起复述。其他要求如“解释这段代码”“给这段文字起标题”仍交给模型处理,不根据测试用语直接回显固定答案,不绕过模型调用。提示边界改善输入含义,不能保证所有模型都严格遵循输出格式。
+
+模型接收引用文字和本次要求;只读取当前指令引用的消息,不扩大为额外聊天历史。不改变密钥、模型、系统提示词、人物设定、群/私聊参数、温度、限速或回复长短的发送方式。程序升级并重启ZeroBot后供后续请求使用,已开始的请求不被改写。识图、语音以及其他权限角色另验收。
diff --git a/docs/local-admin/isolated-admin-acceptance.md b/docs/local-admin/isolated-admin-acceptance.md
new file mode 100644
index 0000000000..72ca117791
--- /dev/null
+++ b/docs/local-admin/isolated-admin-acceptance.md
@@ -0,0 +1,43 @@
+# 本机隔离后台验收环境
+
+用于 TODO-BACKUP-01 / TODO-UI-01 的真实页面操作。按用户2026-10-12的选择,由自动浏览器执行这些流程,无须逐页人工查看。它复制当前程序到新的忽略目录,创建独立配置、控制数据库、后台密码和模拟 OneBot;不读取日常启动配置、控制库、凭据或 NapCat 登录,不向真实 QQ 或模型发送测试请求。页面会显示合成账号和数据,不能据此判断日常机器人在线。
+
+从真实仓库目录执行:
+
+```powershell
+Set-Location <项目目录>
+$env:CGO_ENABLED = '0'
+go run scripts/start-admin-acceptance.go -binary-name zerobot.exe
+```
+
+需要 Go、可读取的 `.local/bin` 程序/固定启动素材,以及启动临时进程和本机回环监听的权限。终端保持打开,工具就绪后输出本次 URL、登录说明文件和产物目录。默认最多运行 30 分钟,可用 `-lifetime 10m` 调整(1~120 分钟)。后台为随机回环端口,密码每次随机生成,保存在该产物目录的 `login.txt`,不使用日常后台密码。
+
+仅在自己的浏览器打开本次 URL;自动浏览器的本机访问权限限制仍按原约定保留,本工具不改变权限。登录后使用页面显示的三个基线备份编号,分别预览和恢复启动配置、控制库、后台选项。当前测试值已与基线不同,可核对差异、确认、写前备份、重启提示和恢复后数值。合成主人为 `12345678`,测试群为 `101`,参数基线为 `isolated-baseline-model`,临时值为 `isolated-edited-model`。这些不是实际 QQ 目标。
+
+在启动器终端输入 `restart`,只重启本次测试程序并更新 `session.json` 中的 URL;浏览器重新登录,再核对恢复后的主人/插件/模型与重启提示。`status` 输出当前测试入口,`stop` 或 Ctrl+C 结束并停止测试程序。终端输入结束或运行期限届满也会停止。资料和日志保留在 `.local/admin-acceptance/run-*`,不会自动删除,且不进入 Git。
+
+如在测试页修改后台密码,后续重新登录使用你设定的新测试密码;`login.txt` 保留初始值,不自动更新。终端重启不代替浏览器登录,不会因初始密码已变更而拒绝启动。
+
+## 完整恢复页面验收步骤(支持自动浏览器执行)
+
+使用本次`session.json`中的URL和`login.txt`中的随机测试密码登录自己的浏览器,进入“备份恢复”。不要打开日常后台3010进行此演练。记录本次三个基线编号;同范围还有自动写前备份,按编号选基线,不能仅选最新一条。
+
+1. 手动创建一份“控制数据库”备份,确认列表出现新编号,再预览并取消。取消后AI模型名仍为`isolated-edited-model`,群101的chrev仍启用。
+2. 预览本次控制数据库基线,核对范围/编号/影响摘要。先不勾覆盖确认,恢复按钮应不可用;勾选后恢复。成功后AI模型名为`isolated-baseline-model`;进入插件页等待自动读取,核对群101的chrev停用;DEV-64已修复首次空列表,读取失败才需刷新重试;备份列表应保留恢复前状态的备份。
+3. 预览并确认恢复启动配置基线。“连接设置”主人应从两个合成账号恢复为仅`12345678`,页面提示需要重启。模拟OneBot的连接地址和Token保持,不改为真实连接。
+4. 预览并确认恢复后台选项基线,应成功并提示重启后生效。本次当前端口继续可用;恢复后的地址是准备时的原测试端口,重启后URL可能改变。
+5. 自动浏览器读取前三类恢复成功状态;调试者仅重启本次测试实例,并获取更新的URL。自动重新登录后核对主人、AI模型和chrev仍为基线,且原随机测试密码保持。测试完成后停止此实例。
+
+某一步失败时记录该范围、实际提示和是否改变了页面值,不继续其他范围来掩盖失败。真实页面操作、重启后只读核对和用户反馈分别记录;用户反馈不是本流程通过的前置条件;环境就绪、自动检查通过或单次恢复成功都不代表整个页面矩阵验收通过。
+
+重启前只接受本次模拟 OneBot 的原连接和 Token,以及本次准备的后台端口;连接被改为其他地址、增加真实连接或后台被禁用时拒绝重启,当前实例保留,先恢复基线再试。该限制用于保持验收范围,不是产品连接校验规则。关闭终端之外强制结束父进程可能跳过清理,应优先使用 `stop`;不要用日常服务停止脚本停止测试实例。
+
+自动检查环境准备和恢复重启链路时可执行:
+
+```powershell
+go run scripts/start-admin-acceptance.go -binary-name zerobot.exe -self-check
+```
+
+自动检查使用同样的新目录,确认样本差异、三类恢复、重启、旧会话拒绝、随机密码保持及地址守卫后退出,不留下运行实例。`result.json` 会明确标记自动环境检查和人工验收的区别;启动就绪、自动检查通过或正常停止都不会自动关闭真实页面待办,也不计作真实 QQ / 模型 / 长期性能验收。
+
+测试环境只准备本机合成配置与固定素材,不是完整数据副本。不要在测试页填入真实 Token、模型密钥、群号或公网接入地址。标准程序原有素材/公告初始化仍可能访问其既有外部资源;工具不提供通用网络隔离或完整插件素材。更多恢复限制见[备份恢复](backup-restore-2026-10-02.md)。
diff --git a/docs/local-admin/isolated-ai-load.md b/docs/local-admin/isolated-ai-load.md
new file mode 100644
index 0000000000..7e8313c88f
--- /dev/null
+++ b/docs/local-admin/isolated-ai-load.md
@@ -0,0 +1,23 @@
+# 独立AI持续负载观察
+
+从仓库运行`./scripts/observe-isolated-ai.ps1 -BinaryName zerobot.exe`,使用指定测试构建的独立副本、合成OneBot及本地模拟模型,持续观察15分钟,每分钟一批3次GPT回复。记录Windows资源样本、模型快照和回复快照,自动调用现有离线核对工具检查同一进程及完整时间覆盖。需要本机Go、Node.js、PowerShell 7.5或更新版本(保留JSON原始时间字符串)和查询测试进程/运行回环实例的权限,不需要日常后台密码或Chrome连接。
+
+工具只操作本次临时实例,不读取日常凭据、修改日常配置或启停日常ZeroBot/NapCat。模型默认人为等待150毫秒,用于验证计时包含等待;所有消息和模型请求均为本地模拟,不接入真实QQ或真实模型。结果不替代真实业务负载、其他权限角色、QQ客户端收到或端到端延迟验收。
+
+产物在`.local/ai-load-observation/`的独立目录,包含原始资源样本/最终结果、前后回复文件、模型观察文件和两种核对结果,以及模拟批次数/请求数和清理结果。累计平均/最长保持本次启动口径,不计算窗口最长;前后业务次数只能对应本次合成GPT任务,不扩大为所有插件或真实用户场景。
+
+快照由模拟驱动的认证接口生成,文件采用既有固定格式,不是浏览器下载原件。资源记录的零QQ/模型发送字段只说明被动采样部分没有发起这些动作;独立负载部分的合成请求数量在总结果另列,真实QQ和真实模型始终为0。此工具只测Windows程序及本地模型/发送确认链路,没有浏览器渲染或网络服务性能测量。
+
+默认正式窗口900秒、资源间隔5秒、批次间隔60秒;可将持续时长设为10~1200秒、资源间隔1~30秒、批次间隔60~120秒,模型等待设为0~2000毫秒。少于900秒仅诊断,即使回复正常也不能算正式观察通过。开始/结束快照留在实际资源首末样本内,单次输入最多20批,不使用重复会话绕过日常限速。
+
+采样前检查复制程序、进程编号/创建时间/摘要和本次模拟目录;每批沿用原模拟驱动对OneBot和模型地址的守卫。改变地址或目标、异常回执/统计、进程变化、采样节奏不足或资源检查不通过会记录失败,不拼接新进程。文件只保存固定统计字段和脱敏日志计数,不保存聊天正文、密钥或账号。
+
+需要提前结束时,仅在本次观察目录创建`stop.request`。下一次检查结束采样并停止本次模拟实例,标记未完成/未通过,日常服务保持。工具正常完成、失败或提前结束均清理自己的实例;若最终结果缺失,不能算通过。
+
+120秒诊断的提前停止演练`run-20261013-170959-6b184f15`记录`completed=false`/`passed=false`、`stopReason=stop-request`,已清理本实例。首轮30秒停止演练`run-20261013-170809-cf2af656`标记文件写入太晚,实例已正常完成短测,保留为迟到操作记录,不记作提前停止通过。首次两轮10秒诊断的离线阶段失败,是解释器发现多个路径后被当作单一命令调用;改取首个匹配应用路径。JSON时间读取同时保留原字符串,最终采样补在结束快照之后,最长间隔的短测已验证该边界。原失败目录`run-20261013-165944-755842e4`和`run-20261013-170201-217a8cc0`保持,均已清理模拟实例。
+
+正式目录`.local/ai-load-observation/run-20261013-170631-b147baae`使用日常相同2C3F30测试构建,资源采样17:06:37~17:21:37,179个样本跨度900.0739878秒、最大间隔6.8845599秒;15批/45次合成GPT的模型完成、任务完成及发送确认均45,失败/进行中/缺起点0。CPU平均占全机20逻辑处理器容量0.010849802%、相邻区间最大平均0.143285153%,私有内存最大增长3.44921875MiB,资源阈值与节奏通过。追加日志46行、警告/错误0,日志覆盖完整;不保存正文。
+
+模型快照窗口899.598秒、回复快照窗口899.598066秒,均完整位于资源首末样本内,两种核对都逐项匹配PID/操作系统创建时间/程序摘要,`compatible=true`/`sameProcessVerified=true`、退出0。三者实际窗口分别记录,不称完全相同的900秒跨度。模型传输累计平均151.068毫秒;首条及最后一条确认回复累计平均202.468毫秒、最长814.642毫秒,包含150毫秒模拟等待和冷启动后的处理,不是窗口最长或真实QQ客户端延迟。模拟每分钟3条,无并发饱和负载,也未测真实网络模型或浏览器渲染性能。
+
+最终交付资料14个核对标记通过,包含45请求、原始资源重算、两种精确关联、短窗/停止区别、原日常服务与14份界面资源/维护源码摘要保持,821处文档链接存在。结果`.local/isolated-ai-final-evidence.json`;资料核对只读取本轮产物,不重复启动或采样。
diff --git a/docs/local-admin/live-performance-observation.md b/docs/local-admin/live-performance-observation.md
new file mode 100644
index 0000000000..aa7612fb2b
--- /dev/null
+++ b/docs/local-admin/live-performance-observation.md
@@ -0,0 +1,50 @@
+# 日常服务只读性能观察
+
+后续DEV-73部署新增[模型接口请求统计](model-request-statistics.md)。以下180样本/15分钟属于原0CF3DE程序,不作为当前6109EA重复采样证明;此观察工具尚不登录后台或读取模型统计接口,真实数量/传输耗时和资源要分别记录,并核对窗口与统计起点。
+
+用于剩余性能待办:在正常使用机器人期间观察当前日常ZeroBot的Windows资源和新追加日志统计,不启动模拟机器人、不发送QQ消息、不调用模型、不登录或轮询后台,也不重启服务。结果只能说明记录窗口内的资源与异常情况,不能证明所有真实业务、回复延迟或长期无泄漏。
+
+入口为`scripts/observe-live-services.ps1`,从本项目托管进程记录核对程序路径、PID和启动时间,并兼容已有C/D目录联接。默认观察15分钟,每5秒采样CPU累计时间、私有内存、工作集、线程与句柄;从开始时的日志文件长度处读取新追加字节,输出换行数及警告/错误统计,不保存正文、账号、消息、地址或口令。进程重启或身份变化时结束本轮,不能把新进程拼接进原窗口。
+
+产物写入本项目`.local/live-observation/`独立目录。短窗口、提前停止、采样间隔异常或身份变化必须明确标记,不能算完整15分钟观察。资源阈值与业务验收分别记录;即使资源正常,没有对应业务证据也不能关闭真实QQ/模型负载待办。停止采样只影响观察工具,不停止ZeroBot或NapCat。
+
+## 使用步骤与结果
+
+在有权查询本机进程并读取本项目服务日志的PowerShell中,从仓库执行:
+
+```powershell
+Set-Location <项目目录>
+./scripts/observe-live-services.ps1
+# 可选:观察半小时,仍每5秒采样。
+./scripts/observe-live-services.ps1 -DurationSeconds 1800 -IntervalSeconds 5
+```
+
+工具只观察已按本项目启停脚本托管的日常ZeroBot;未运行、记录不匹配、路径或启动时间不可核验时拒绝采样,不自行启动或登录。输出`ARTIFACTS=...`给出本次独立目录。`samples.jsonl`每次追加一个资源样本,`progress.json`每分钟及完成时更新,`result.json`记录最终窗口、阈值、日志覆盖情况和停止原因。
+
+需要提前结束时,在另一PowerShell中对本次目录创建停止请求文件,使用输出中的实际路径:
+
+```powershell
+New-Item -ItemType File -Path '<项目目录>
+```
+
+下次采样前结束(默认不超过约5秒),最终`stopReason=stop-request`、`completed=false`、`passed=false`;ZeroBot和NapCat继续运行。不要创建到其他观察目录。直接关闭终端或强制结束观察进程可能只留下已写样本和进度文件,缺少最终结果,不能算完成。
+
+时长允许10秒至24小时,间隔允许1~30秒。小于900秒仅用于诊断,`fullTiming=false`;即使按请求采满10秒,也不算正式通过。正式窗口要求实际首末样本跨度至少达到请求时长、样本量达到预计数量的80%并含首样本、最大间隔不超过`2×间隔+0.5秒`;不足样本和异常间隔均如实记录。
+
+默认资源检查为CPU平均占全机逻辑处理器容量不超过20%、私有内存相对首样本的最大增长不超过64MiB;可用`-MaxCpuPercent`与`-MaxPrivateGrowthMiB`调整并在结果中留档。CPU峰值是相邻样本区间平均值的最大值,并非瞬时峰值。没有无后台对照或请求延迟样本,不能据此推断后台开销或回复延迟。
+
+`passed`只表示完整窗口、身份/程序保持、采样节奏、资源阈值及日志覆盖/新错误计数这些观察检查通过。`businessLoadMeasured=false`和`businessAcceptancePassed=false`始终保留,因为工具不统计真实QQ/模型请求数量或延迟。`realQQMessagesSent=false`和`modelCalls=0`仅指观察工具自身没有发起这些动作,不表示日常服务没有自然业务。
+
+日志每次最多读取256KiB,末尾再做有限补读;积压、未换行片段或日志截短会令覆盖不完整。行首logrus的`level=warning/warn/error/fatal/panic`及行首`panic:`用于级别计数,其余行只计“其他”;正文中的`level=error`不会误算错误。开始时已存在的内容不读取,因此原有未完行的后续片段可能无法识别级别;未知格式不推断错误。日志为零或没有对应业务证据时只报告静默/未分类资源观察。
+
+## 边界验证(DEV-72 / REQ-69 / TC-92)
+
+16项合成日志与资源回归通过:历史不计、分块和CRLF、正文伪级别/秘密不输出、长行内存有界、积压/截短、缺文件不创建、全机CPU口径、自定义阈值、最大内存增长和小数精度、短窗口/提前停止/稀疏采样、倒退计数/时钟及样本不足。三份脚本语法通过,观察入口不包含服务启停或网络API命令。既有8个任务/4个进程身份场景及5脚本语法通过。证据`.local/live-observation-tests.txt`、`-service-paths.txt`及`.local/live-observation-tests-734046385ba54956ada2257c4ac649e2/result.json`。
+
+10秒日常诊断目录`run-20261013-084939-45d4c387`正确标记`fullTiming=false`、`passed=false`;提前停止目录`run-20261013-085319-d1a3dd3c`32个样本/32.244秒,停止原因正确且不算完成。非法零时长在采样与目录创建前被拒绝,证据`.local/live-observation-args-result.json`。这些诊断和停止演练不计作完整15分钟观察。
+
+## 2026-10-13 日常观察结果
+
+最初汇总的内存增长因PowerShell调用`Math.Max`选择整数重载被取整为0,原始样本和最大/初始字节值正确。跨语言重算发现差异后保留原`result.json`及`.local/live-observation-formal-initial.json`,修正为显式浮点重载并新增8KiB及小数阈值回归。用原180个样本重新统计,`.local/live-observation-resource-resummary.json`才是本轮内存增长的正确汇总;其他指标与原结果一致,修正后检查仍通过。没有改写原始样本或声称重新采样15分钟。日志读取长度同时明确使用长整数重载,避免大文件剩余长度被转换为32位整数。
+
+最终脚本还执行了10秒诊断`run-20261013-090756-fdab3201`,完成且`fullTiming=false`、`passed=false`,不计正式窗口。16项回归、短测/提前停止/参数保护、源码摘要和180项实际样本独立重算通过,证据`.local/live-observation-evidence-check.json`、`-tool-hashes.json`、`-resource-resummary.json`及正式目录`samples.jsonl`;修正前源码摘要保留于`-tool-hashes-before-fix.json`。
diff --git a/docs/local-admin/log-clear-display.md b/docs/local-admin/log-clear-display.md
new file mode 100644
index 0000000000..4a49a085c9
--- /dev/null
+++ b/docs/local-admin/log-clear-display.md
@@ -0,0 +1,13 @@
+# 清空当前显示的日志
+
+使用后台密码登录本机管理页,进入“运行日志”。已有记录时点击“清空当前显示”,立即移除本页已显示的日志及复制提示,不删除服务端保留记录或日志文件,不发送请求。没有记录、读取处理中、未登录、切到其他页面或窗口隐藏时按钮不可用。
+
+清空后保留已应用的级别、关键词、时间范围及未应用输入,保留暂停/持续更新与自动滚动选择、当前实例和增量游标,不停止或增加轮询。持续更新时下次按原游标读取新记录;暂停时可用“读取一次”或“恢复更新”。清空时刻不等于新日志时间起点,先前尚未读到的记录仍可能随后出现,原断档/重启和容量限制保持。
+
+匹配统计仍是最近成功读取时服务保留范围的统计,不按清空后的页面条数归零;导出仍包含已应用条件下服务保留的日志,包括本页刚清空的记录。需要再次查看近期记录时点击“重新加载日志”;暂停状态下重新加载只重置读取位置,仍需读取一次或恢复更新。更早超出服务保留范围的记录不能恢复。
+
+清空操作没有撤销按钮,仅影响页面显示。页面会持续提示此范围,切换后台页面再回来保留;重新加载、应用筛选、成功识别新实例或退出/过期/恢复重置后清除提示。不会显示被清空的正文或字段值。日志正文的手动复制选区随清空移除,页面其他位置的选区保留;迟到的剪贴板结果不能重新选中或覆盖新记录。正在进行的导出按原规则完成,不受清空影响。
+
+验收:暂停并读取记录,点击清空,核对记录消失、匹配统计和筛选保持;读取一次可显示后续记录,重新加载后读取一次可重新查看近期保留日志。无须发送真实 QQ 消息、修改配置或清理备份。
+
+## 自动化验证与部署
diff --git a/docs/local-admin/log-copy-entry.md b/docs/local-admin/log-copy-entry.md
new file mode 100644
index 0000000000..64714ff432
--- /dev/null
+++ b/docs/local-admin/log-copy-entry.md
@@ -0,0 +1,17 @@
+# 复制本条日志
+
+登录本机后台,进入“运行日志”,在需要的记录下点击“复制本条”。复制文本为页面显示的本地时间、级别与日志正文,保留换行、Unicode文字及已有脱敏/截断标记。来源是该条已显示内容,不额外请求原始日志;页面未显示的字段、原始消息正文、配置、认证信息不会被附加到复制文本。
+
+成功后该条显示“已复制本条日志”。同一条正在复制时禁止重复点击;不同记录分别显示结果。运行中或暂停时都可复制,不改变筛选、增量游标、匹配统计、暂停、自动滚动或导出条件,不生成备份,不修改机器人配置,不发送QQ消息或调用模型。
+
+浏览器禁止或不支持剪贴板写入时,页面选中该条日志正文并提示按Ctrl+C手动复制;手动回退只选正文,时间与级别可另行选取。复制失败可重试。提示成功仅表示浏览器确认系统剪贴板写入完成。
+
+未登录、未进入日志页或页面隐藏时不能复制。切换筛选、重新加载、暂停/恢复、切页、隐藏页面、退出或会话过期清除旧复制提示,使未完成操作的界面结果失效;断档重置或容量淘汰后,已离开当前列表的记录也不会再显示迟到结果或选中文字。已交给浏览器的剪贴板写入无法撤回;页面失效后不会再显示成功或执行手动选择。
+
+页面仍只保留本次启动最近最多1000条,服务器容量上限与脱敏规则保持。复制的是最近成功读取后已显示的内容,不保证与此刻服务端保留范围完全一致。需要全部仍保留的筛选日志时使用原“导出筛选日志”。
+
+验收:读取日志后复制一条,核对时间、级别、换行、脱敏和暂停状态;剪贴板拒绝与迟到结果由隔离测试覆盖,无需发送真实QQ消息。实际页面体验待验收。
+
+## 自动化验证与部署
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
diff --git a/docs/local-admin/log-export-2026-10-02.md b/docs/local-admin/log-export-2026-10-02.md
new file mode 100644
index 0000000000..bd9090eaae
--- /dev/null
+++ b/docs/local-admin/log-export-2026-10-02.md
@@ -0,0 +1,38 @@
+# 日志导出与验证
+
+后台运行日志页新增“导出筛选日志”。使用已点击“应用筛选”的级别与关键词,从本次启动的内存缓冲区取得快照并下载 UTF-8 文本。导出最多 1000 条,按旧到新排列,不受页面首批 200 条限制,也不读取历史 stderr 文件。仍受原 1MiB 消息内容保留容量限制,因此长日志较多时可能不足 1000 条。
+
+文件开头说明保留与匹配条数、是否已淘汰旧记录,以及截断/重启边界。每条日志占一行,正文使用引号包围并转义换行、引号等字符;中文和 emoji 保留为 UTF-8。文件名只含固定前缀及 UTC 时间,不包含关键词、配置地址或用户输入。空筛选结果仍可下载有说明的文本。
+
+`GET /api/logs/export` 沿用后台的认证、回环 Host/Origin 检查和 `no-store`,返回 `text/plain; charset=utf-8`、安全附件名及 `nosniff`。当前接受单个 `level`、`q`、`from` 和 `until`(时间筛选于10月9日加入),拒绝路径、游标、条数及重复参数。导出前刷新已保存和运行配置的密钥,重新对旧记录脱敏;配置不可读返回 503,未接入日志返回 409,错误响应不会成为下载文件。
+
+页面使用已应用条件,导出期间禁止重复点击;暂停更新时可导出。切页、隐藏、重新加载、改筛选及退出取消请求,过期响应不能生成文件。下载完成释放临时 Blob URL;响应格式、文件名及 3MiB 接收上限校验失败时不下载。导出只读取日志与配置,不修改日常配置或新增服务器端导出文件。
+
+## 验证结果
+
+| 验证 | 结果 |
+| --- | --- |
+| `CGO_ENABLED=0 go test ./internal/localadmin ./internal/basiccommands ./plugin/llm -count=1 -timeout=120s` | 通过;新增两组导出测试覆盖 1100 条产生后的 1000 条快照、排序、级别/关键词、UTF-8/换行/截断/容量、安全文件名/响应头、旧密钥/正文脱敏及 400/401/409/503 |
+| `go vet ./internal/localadmin`、`go vet scripts/smoke-local-admin.go` | 通过 |
+| `node --check internal/localadmin/assets/logs.js` | 通过 |
+| `node --test scripts/test-log-ui.mjs scripts/test-backup-ui.mjs` | 9 项全部通过,其中新增 3 项下载行为测试覆盖已应用条件、暂停、重复点击、URL 释放、导航/隐藏/退出/改筛选取消、过期正文、401/503/异常格式与容量 |
+| `go build -trimpath -o .local/bin/zerobot.exe .` | Windows CGO=0 构建通过 |
+| `go run scripts/smoke-local-admin.go -binary-name zerobot.exe` | 23:37:11 完整 Windows 程序回归通过;`logExportPassed=true` 和全部旧标志为 true |
+
+完整程序证据:`.local/smoke-admin/run-2919836966/result.json`。脚本使用独立目录、模拟 OneBot 和本地模拟模型,验证下载响应、筛选、密钥/消息正文脱敏及未登录/退出拒绝;没有发送真实 QQ 消息或请求真实模型。
+
+## 本机更新
+
+更新前控制库快照:`20261002T153800.622624100Z-8279c4848fe8cb96-control`;旧程序 `.local/bin/zerobot-before-log-export-20261002-233753.exe`。仅重启 ZeroBot,NapCat 保持登录;预期机器人账号、OneBot 只读 API 与实际握手通过。启动 JSON 和控制库摘要未变,未检测到启动 fatal/panic。证据 `.local/log-export-before.json`、`.local/log-export-deployment.json`。
+
+原有外部 MD5 校验服务器不可达警告仍存在,已有资源跳过远端校验;本次不将其记作资源完整性验证通过。
+
+## 验收边界
+
+真实 Chrome 的下载交互及文本内容人工核对仍待 TODO-UI-01;离线 DOM 验证不等于真实浏览器验收。既有四组持续性能结果来自上一构建,本次完成有界导出与功能回归,没有重复持续性能测量。真实模型仍保留为 TODO-AI-01;DEV-13 的主题、小屏与备份清理未在本次实现。
+
+使用:刷新后台并重新登录,进入“运行日志”,应用需要的筛选后点击“导出筛选日志”。
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
+
+独立run-3788569462真实操作后,从Windows已知下载目录只读识别唯一新增的日志txt,逐份核对当前10份实际文件:完整/有界/倒置保持/仅结束/仅开始/相同边界空结果/级别关键词/未应用条件忽略/清空显示保留导出/组合无匹配;UTF-8字节、UTC时间说明、ID/级别/正文/顺序与页面一致,安全文件名和合成口令保护正确。原生分钟及非零秒输入通过,倒置范围不改变已应用条件;精确纳秒包含沿用既有Go测试,不扩大真实浏览器范围。找到了前轮run-2213640713已保存的3份文件(5/2/2条)并补证;当时下载等待超时记录保持,这不是文件未下载的证明。读文件作为已发起Chrome下载的产物核验,未更改浏览器下载设置。
diff --git a/docs/local-admin/log-export-date-browser-acceptance.md b/docs/local-admin/log-export-date-browser-acceptance.md
new file mode 100644
index 0000000000..fa3026ec69
--- /dev/null
+++ b/docs/local-admin/log-export-date-browser-acceptance.md
@@ -0,0 +1,27 @@
+# 日志文件与日期范围自动验收
+
+## 日常使用
+
+使用后台密码登录,进入“运行日志”。填写级别、关键词和本地开始/结束时间后点击“应用筛选”;暂停时再点击“读取一次”查看当前条件。日期可只填一端,开始不能晚于结束;倒置范围会提示错误,已应用条件和已有记录保持。
+
+点击“导出筛选日志”下载服务仍保留的匹配记录。未点击应用的级别、关键词或时间输入不影响导出;清空当前显示也不删除服务保留记录。空匹配仍下载有范围说明的文本。格式和保留限制见[日志导出](log-export-2026-10-02.md)、[日期筛选](log-time-filter.md)和[清空显示](log-clear-display.md)。
+
+原生日期控件会把零秒规范化为分钟格式,例如`2026-10-12T19:26`;非零秒可显示为`2026-10-12T19:26:56`。自动填写时使用控件的规范化格式,操作后读取实际值确认,再应用。之前带零秒的填写动作曾被工具报告失败而实际值已变为分钟格式,本轮分钟及非零秒输入均正常,没有修改产品的日期解析规则。
+
+## 自动核验方法
+
+使用[独立后台](isolated-admin-acceptance.md)产生的本次URL和随机测试密码,由已授权Chrome执行真实按钮和表单操作。日志仅来自独立程序及模拟OneBot,不向真实QQ或模型发送请求。暂停后记录页面的日志ID、时间、级别与正文,用于比较实际下载文件。
+
+本轮官方浏览器下载等待未返回路径,改为在UI发起下载后,只读定位Windows已知下载目录中的本次输出文件。系统提供目录,本次为`<项目目录>`;先记录匹配`zerobot-logs-
.txt`的已有文件名,再执行一次导出,等待唯一新增完整`.txt`文件。比较下载时段、文件名、字节数、SHA-256、范围说明及记录内容,把证据副本保留到忽略的本次验收目录,下载原件保留。此过程是对已下载文件的核验,不读取浏览器配置、密码、会话或下载历史。
+
+下载事件等待超时只说明该观察接口没有返回,不能仅凭它认定下载失败;页面“已发起下载”或HTTP200也不能单独代替文件证明。必须取得实际文件再确认内容。本轮没有更改下载权限、下载设置或程序导出实现。
+
+## 已验证范围
+
+13组核对覆盖:完整下载;分钟和秒级原生日期输入及有界读取;UTC时间说明与实际条数一致;倒置范围拒绝并保持原导出条件;仅结束/仅开始范围;相同时间边界的空结果;级别和关键词组合;三种未提交条件不改变导出;清空显示后仍导出保留匹配;无匹配文件的范围说明;实际UTF-8/日志内容与顺序逐行匹配;安全文件名及合成口令不出现。
+
+相同边界的空结果只核对本次记录与所选时刻,既有Go纳秒边界测试继续作为精确包含边界的程序级证据。这里没有扩展为所有浏览器、所有日期/时区或全部日志容量场景;已有离线和整机测试范围保持。
+
+当前程序为DEV-66,SHA-256 `D3A05799959EFE3BD14A40CF863B92A92A3D582181ABF37EEB746C0C5458C6A3`。独立`.local/admin-acceptance/run-3788569462`启动1次、已正常停止,当前实际10份下载文件逐份核对通过;此外找到并核实前轮`run-2213640713`产生的3份实际文件,条数依次5/2/2,补正了原“未取得文件”的证据缺口,保留当时等待超时记录。
+
+本次完成真实页面和实际文件验收、使用说明与待办同步,没有产品代码修改、构建、日常部署或重启,不重复已通过的201项界面、64项离线/64项实际布局或整机回归。日常会话受同主机名独立后台登录影响时,正常重新登录即可,原密码保持。
diff --git a/docs/local-admin/log-issues.md b/docs/local-admin/log-issues.md
new file mode 100644
index 0000000000..451447089d
--- /dev/null
+++ b/docs/local-admin/log-issues.md
@@ -0,0 +1,19 @@
+# 异常日志筛选与排障入口
+
+登录本机后台,在“运行概览”的近期异常区域点击“查看异常日志”,进入运行日志并应用“警告与错误”筛选,清空此前关键词。此组合包含 Warning、Error、Fatal 和 Panic,不包含 Info/Debug/Trace;只表示日志级别,不判断问题是否已解决。
+
+运行日志的级别列表也可选择“警告与错误”并点击“应用筛选”。“只看异常”立即切换到该组合,保留当前已应用的关键词;“清除筛选”立即恢复所有级别且清空关键词。搜索内容按脱敏后的日志匹配。
+
+页面标明当前已应用条件。输入框或级别下拉尚未提交的修改会提示“尚未应用”,查看和导出继续使用已应用条件;快速入口会把输入控件同步到实际条件。筛选切换从保留范围重新读取,旧筛选的迟到结果不会覆盖新列表。
+
+快速筛选不改变暂停状态或自动滚动偏好;暂停时改变条件先清空列表,恢复更新后读取对应条件,导出仍可使用新的已应用条件。切页/隐藏/退出取消请求,退出或过期清空条件。草稿和条件不写浏览器存储。
+
+权限和范围保持原规则:只供已登录本机后台使用;本次启动最多保留 1000 条日志,仍按容量/单条长度限制截断并脱敏。导出最多 1000 条仍保留记录,警告不等于业务错误;历史消息正文及凭据不展示,不读取任意日志文件或重启前日志。不修改机器人配置、不调用模型、不发送 QQ 消息。
+
+验收:从概览打开异常日志,核对组合级别及提示;填关键词但不提交,核对“尚未应用”及导出仍按原条件;应用、只看异常、清除筛选后核对条件;暂停后改变筛选再恢复更新。无需故意制造真实故障,四类级别和迟到请求由隔离测试覆盖。
+
+自动验证:两个后端用例覆盖四类异常/精确级别、增量分页/游标、读时脱敏、关键词、认证及查看/导出记录一致性;两个新增 DOM 和一个 Chrome 概览导航用例以及原44项,共47项界面测试通过。56项深浅主题/320~1280px布局通过,已核对桌面和窄屏日志截图,256字符连续关键词不造成横向溢出。Go模块/静态检查通过。
+
+日常配置、AI密钥与人物设定保留;未发送真实QQ消息或调用真实模型。日志中已有素材MD5服务器网络警告仍可能出现,缓存素材可用;组合筛选只是帮助查阅记录,不把历史警告当作新增故障或已解决判定。实际体验见 TODO-LOG-ISSUES-01。
+
+运行日志每条记录可点击“复制本条”,只复制页面已显示的时间、级别与脱敏正文,剪贴板受限时选中正文手动复制,详见 [复制本条日志](log-copy-entry.md)。
diff --git a/docs/local-admin/log-quick-range.md b/docs/local-admin/log-quick-range.md
new file mode 100644
index 0000000000..9838f6da21
--- /dev/null
+++ b/docs/local-admin/log-quick-range.md
@@ -0,0 +1,17 @@
+# 日志快捷时间范围
+
+在本机后台登录后,进入“运行日志”,点击“最近15分钟”“最近1小时”或“最近24小时”。按钮立即应用以点击时刻为结束、向前回溯指定时长的固定范围,精确到秒。开始和结束时间会回填表单,已应用条件列出实际本地时间。
+
+快捷按钮沿用最近已应用的级别和关键词;尚未点击“应用”的输入不会一并提交,表单会恢复为实际应用值。“不限时间”只移除起止时间,保留已应用的级别和关键词;“清除筛选”则移除全部条件。
+
+查看、匹配统计和下载日志使用同一起止时间。范围不会随轮询、重新加载、切页或下载自动向前移动;要查看更新的时间段,再次点击快捷按钮。暂停更新时只更换条件并清空旧结果,恢复更新后读取;仍可下载该范围的日志。切换条件会取消旧读取和下载,迟到结果不能覆盖新条件或重新登录的会话。
+
+使用浏览器本地时区与本机时钟;24小时表示实际经过的24小时。若系统时间无效,或夏令时回拨使起止时间无法由本地输入框准确表示,拒绝快捷应用并保留原条件和结果,页面提示手动选择可明确表示的时间。退出或会话过期清空筛选。
+
+此功能仅筛选本次启动仍保留的脱敏日志,不读取磁盘历史,不增加原有1000条保留量,不生成日志、修改配置或调用QQ/模型。选择24小时不保证存在24小时的历史记录。
+
+验收:先应用级别/关键词,点击不同快捷范围,核对回填值与已应用提示;暂停后换范围并下载,恢复更新后核对统计。“不限时间”应保留级别与关键词。实际页面体验待验收。
+
+自动化验证:新增4项离线Chrome测试通过;完整界面137项中132项在沙箱通过,5项旧下载用例因沙箱取消下载失败,对应两个测试文件9项在正常Windows权限下重跑全部通过。64项页面布局及深浅窄屏截图通过;相关Go模块与静态检查、Windows构建通过。首轮Go测试受沙箱ACL/回环网络限制,在正常权限下重跑通过。真实页面体验另待用户验收。
+
+暂停日志更新时可点击“读取一次”,按已应用条件读取新日志和匹配统计,完成后仍保持暂停,见 [暂停时读取一次日志](log-read-once.md)。
diff --git a/docs/local-admin/log-read-once.md b/docs/local-admin/log-read-once.md
new file mode 100644
index 0000000000..711b61925f
--- /dev/null
+++ b/docs/local-admin/log-read-once.md
@@ -0,0 +1,19 @@
+# 暂停时读取一次日志
+
+登录本机后台,进入“运行日志”,先点击“暂停更新”,再点击“读取一次”。页面按已应用的级别、关键词与起止时间读取一次新日志,并更新匹配统计;完成后继续保持暂停,不启动自动轮询。
+
+未点击“应用筛选”的输入不会随读取提交。已有记录按原游标增量追加,重复记录不重复展示;新应用条件或“重新加载日志”清空结果后,读取一次使用最新页,最多返回200条匹配记录。服务器仍只保留本次启动最多1000条/总容量1MiB,页面仍最多显示1000条。一次读取不保证补齐暂停期间的全部记录;有淘汰、断档或实例变化时沿用原提示与重置规则。
+
+匹配统计仍表示服务端保留快照中全部符合条件的记录,不等于此次返回条数;没有新增记录也可以更新统计。自动滚动沿用当前勾选状态,固定的快捷时间范围不会自动移动。导出继续使用同一组已应用筛选条件,范围为服务端仍保留的全部匹配记录。
+
+自动更新开启时“读取一次”禁用;请求中禁止重复点击。读取失败保留已有记录和最近成功统计,保持暂停,不自动重试,可再次点击读取。读取期间点击“恢复更新”会取消旧请求并按原流程启动自动更新。
+
+切换筛选、切页、隐藏页面、退出或会话过期取消旧读取;迟到结果不能覆盖新条件或重新登录的会话。切换页面保留原暂停选择,返回后可再读取一次。此操作只调用原认证、脱敏日志读接口,不写配置、不生成日志、不调用QQ或模型。
+
+验收:暂停后点击读取一次,核对新增日志/统计与暂停状态;重复点击、输入未应用、无新增记录、读取失败后重试等由隔离测试覆盖。无需发送真实QQ消息或调用模型。实际页面体验待验收。
+
+## 自动化验证与部署
+
+运行日志每条记录可点击“复制本条”,只复制页面已显示的时间、级别与脱敏正文,剪贴板受限时选中正文手动复制,详见 [复制本条日志](log-copy-entry.md)。
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
diff --git a/docs/local-admin/log-summary.md b/docs/local-admin/log-summary.md
new file mode 100644
index 0000000000..62a4c85fa0
--- /dev/null
+++ b/docs/local-admin/log-summary.md
@@ -0,0 +1,21 @@
+# 日志匹配统计
+
+登录本机后台,进入“运行日志”。在筛选条件下方查看“日志匹配统计”:显示读取完成的本地时间,以及最近一次成功读取时服务仍保留的日志里符合已应用条件的总数及Panic、Fatal、错误、警告、信息、调试、跟踪、其他级别数量。它使用和本次返回记录相同的保留快照,包含脱敏后关键词、级别和含边界的起止时间筛选。
+
+匹配统计不受本次最多200条返回、增量游标或浏览器已显示条数影响。页面可能只加载最近200条;旧显示记录也可能已在服务端淘汰。统计只针对本次启动仍保留的最多1000条/约1MiB日志,不读取历史日志文件,不代表所有历史故障或问题已解决。重启、容量淘汰、更新筛选或新增记录都会改变统计;需要完整当前保留记录时用原“导出筛选日志”。
+
+修改但未应用的筛选输入不影响当前统计。应用筛选或重新加载时清除旧统计,成功读取后再显示;暂停状态下更新筛选需恢复更新后读取。暂停、切页或页面隐藏停止刷新,保留上次成功统计;读取中断也保留旧统计并标明未刷新。恢复/重新进入后按原轮询更新。退出或会话过期清空,旧请求不能回填。旧版接口不提供或返回不一致计数时提示不可读取,不按页面条数猜测总匹配量。
+
+统计只返回固定类别与数量,不输出原始消息、关键词、地址或密钥;关键词匹配继续在脱敏后执行,不能用统计搜索原始密钥。本页只读,不改变日志内容、机器人配置或备份,不额外调用模型,也不清理服务日志。
+
+验收:刷新日志页查看匹配/保留及级别数量,再应用“仅看警告与错误”并核对统计范围。编辑关键词但未应用,统计应保持;暂停后不应自动改变,恢复后更新。数量是读取时的快照,有新日志时变化属正常;无需为验收在日常机器人制造异常。
+
+自动化入口:internal/localadmin/log_summary_test.go 新增5项Go场景,覆盖游标/分页独立、时间/级别/脱敏关键词、数量/字节淘汰、未知级别、认证/只读/导出一致以及密钥版本/关闭策略/淘汰的脱敏缓存失效。新增4项离线Chrome场景(scripts/test-log-summary-ui.mjs)覆盖完整匹配量、未提交筛选/清除/暂停/失败、旧接口/无效数据、会话及迟到结果保护。125项完整界面、64项页面布局、相关模块及静态检查通过;最终深浅色/窄屏合成截图已核对;00:03:59完整Windows隔离回归通过,logSummaryPassed=true,固定时间窗口在空增量及limit=1时仍显示全部匹配量,搜索合成原始密钥匹配为0,原功能回归通过。00:05:35本机部署后后台HTTP 200、正确账号OneBot正常,NapCat进程/登录保持,ZeroBot身份单实例正常,启动日志无错误;原启动配置、控制库和后台凭据不变。证据 .local/smoke-admin/run-1364614974/result.json、.local/log-summary-deployment.json,详见 [自测记录](self-test.md)。
+
+读取开销采用1000条约256字节合成日志,初次读取预热后测持续增量读取:无关键词约0.065毫秒、关键词约0.091毫秒,每次716字节/6次分配;证据 .local/log-summary-benchmark.txt。这里是保留缓冲区微基准,不是HTTP端到端或真实负载/长期内存验收。新密钥使脱敏缓存失效,记录淘汰清除对应缓存,缓存不修改日志正文;计数继续基于当前脱敏结果。
+
+暂停日志更新时可点击“读取一次”,按已应用条件读取新日志和匹配统计,完成后仍保持暂停,见 [暂停时读取一次日志](log-read-once.md)。
+
+运行日志每条记录可点击“复制本条”,只复制页面已显示的时间、级别与脱敏正文,剪贴板受限时选中正文手动复制,详见 [复制本条日志](log-copy-entry.md)。
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
diff --git a/docs/local-admin/log-time-filter.md b/docs/local-admin/log-time-filter.md
new file mode 100644
index 0000000000..5ea2b87310
--- /dev/null
+++ b/docs/local-admin/log-time-filter.md
@@ -0,0 +1,11 @@
+# 日志起止时间筛选
+
+登录本机后台进入“运行日志”,填写开始时间、结束时间并点击“应用筛选”。时间使用当前浏览器的本地时区,精确到秒;可只填写一端,留空不限制该端。边界包含所填时刻,开始不能晚于结束。时间与级别、关键词同时筛选脱敏后的保留日志。
+
+输入修改不会立即改变已应用条件;状态栏显示查看和导出的实际范围。无效或倒置范围不能应用,已有结果和已应用条件保留。“只看异常”保留已应用关键词和时间;“清除筛选”清空全部条件;概览“查看异常日志”进入全部时间的异常记录。
+
+暂停时可应用范围,恢复后读取;切换页面保留范围与未提交输入,退出或会话过期重置。重新加载沿用已应用条件,旧请求不能覆盖新范围。导出使用同一组已应用条件,文件列出实际UTC时间边界;没有匹配记录仍可下载说明文件。
+
+只查询本次启动仍保留的日志,最多1000条、总容量1MiB;首次页面查询显示最新最多200条匹配记录,导出包含保留范围内全部匹配记录。时间筛选不会恢复已淘汰或重启前的日志,也不会改变日志保留容量。填写固定结束时间后,超过结束时刻的新记录不会进入结果。无需保存配置、重启机器人或修改QQ登录。
+
+验收:在有日志的时段设置起止范围,应用后核对时间;修改时间但不应用,再下载核对仍是原范围;测试只填一端、开始晚于结束、暂停后应用及清除筛选。真实QQ与模型调用无需用于本项验收。
diff --git a/docs/local-admin/log-viewer-2026-10-02.md b/docs/local-admin/log-viewer-2026-10-02.md
new file mode 100644
index 0000000000..272f9e07d9
--- /dev/null
+++ b/docs/local-admin/log-viewer-2026-10-02.md
@@ -0,0 +1,37 @@
+# 日志查看与验证
+
+## 使用与范围
+
+刷新并重新登录 [本机后台](http://127.0.0.1:3010/),选择左侧“运行日志”。可按级别和关键词筛选,关键词搜索的是脱敏后的文字。点击“暂停更新”停止读取,点击“恢复更新”继续;取消“自动滚动”后仍更新记录,方便停留阅读。“重新加载日志”读取近期记录,保留当前已应用的筛选。
+
+每两秒进行一次有认证的短请求,仅当前日志页可见且未暂停时读取。切换页面、窗口隐藏、退出登录或关闭页面会停止轮询并取消当前请求;没有日志订阅或长连接。读取失败有提示并自动重试,退出后清空页面记录,不显示迟到的请求结果。
+
+保留本次启动后接入的标准 logrus 日志,最多 1000 条且消息总量不超过 1 MiB;每条文字约 2 KiB,超出显示截断提示。浏览器最多保留 1000 条记录;首次读取近期最多 200 条,之后按游标获取新记录。暂停期间旧记录过期会提示断档;进程重启后识别新实例并重新读取。不会提高原日志级别,因此未启用 debug/trace 时没有对应记录。
+
+日志只读,不更改启动配置、AI 参数或插件开关;不读取或公开历史 stderr 文件,不提供文件路径参数和导出接口。启用后台时安装日志钩子和脱敏格式包装,原输出目标及格式风格保留;未启用后台时不安装。
+
+## 凭据与并发
+
+入缓冲前隐藏认证头、Cookie、常见密钥/密码字段、URL 用户密码及全部查询/片段信息,隐藏收发消息正文和设置 AI 密钥的命令文本。启动时保护已有 OneBot 和 AI 凭据;读取时再次从当前保存与运行配置补充凭据,对保留记录重新脱敏。新网页提交的密钥和 Token 在尝试保存前加入保护集;已知密钥也匹配常见 URL/JSON 转义形式。标准 logrus 后续输出使用相同保护规则,既有历史文件不重写。
+
+凭据历史集合有上限;超过上限后隐藏全部日志文字直到重启,避免不断累积密钥版本导致内存增长。当前配置读取失败时日志接口返回安全的 503,拒绝输出未经当前凭据保护的记录。网页用 textContent 创建文字,不把日志当 HTML 执行。
+
+日志钩子与格式包装不访问数据库或文件,避免控制写入在持有 Manager 锁时日志回调再次争抢同一锁。配置刷新在日志缓冲锁之外完成;读写使用短临界区,缓冲有条数和字节上限。没有按客户端保存订阅状态。
+
+本阶段保护已配置凭据及常见认证结构,不声称识别所有自定义插件未知格式的秘密。其他独立 logger、fmt 输出与程序初始化阶段的旧日志不属于此钩子范围,网页不读取这些输出。
+
+## 自动化证据
+
+`go test ./internal/localadmin -count=1 -timeout=60s`、`go vet ./internal/localadmin`、两份修改脚本的 Node 语法检查和完整 Windows 编译通过。
+
+四组日志测试覆盖:认证头、Cookie、JSON/映射字段、URL、已知密钥、重叠密钥及消息正文脱敏;原格式输出与 Entry 不变;1000 条/1 MiB 上限、中文/emoji 截断、游标分页/断档/实例重置、筛选、凭据更新、并发读写、密钥历史有界;未登录/退出拒绝、非法参数与配置读取失败保护。
+
+`node --test scripts/test-log-ui.mjs` 三组离线 DOM 行为测试通过:日志中的脚本样本只作为文字、去重与页面上限、自动滚动开关;切页、暂停、隐藏和退出取消读取;迟到结果拒绝、筛选重置及失败重试。该测试不操作真实浏览器,不代替视觉与人工验收。
+
+## 本机部署
+
+升级前旧程序保存为 `.local/bin/zerobot-before-logs-20261002-152222.exe`,控制库一致性快照编号 `20261002T072229.835306700Z-ed58217dccad8146-control`。本次部署未触发回滚,也未修改日常 AI 设置或插件开关。
+
+AI 真实联调仍保留为 [TODO-AI-01](todo.md):用户已确认指令触发,但未设置模型密钥。真实日志页交互、持续五分钟 CPU/内存和响应性能测量待 DEV-11/TC-25;不把本次功能测试当作完整性能验收。
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
diff --git a/docs/local-admin/logs-appearance-browser-acceptance.md b/docs/local-admin/logs-appearance-browser-acceptance.md
new file mode 100644
index 0000000000..af0cb89d9d
--- /dev/null
+++ b/docs/local-admin/logs-appearance-browser-acceptance.md
@@ -0,0 +1,25 @@
+# 日志与主题页面自动验收
+
+## 已完成范围
+
+日志完成 12 组核对:读取/统计、暂停与单次读取、级别和关键词组合、导出请求使用已应用条件、清空显示保持统计/筛选/暂停、清空后增量读取、重新加载找回保留记录、异常空结果、清除筛选、固定最近15分钟、跨页暂停保留和恢复更新。对应 `.local/logs-controls-browser-result.json`,过程位于独立 `run-2213640713` 和 `run-2489516078`;这些实例使用修复前的 DEV-64 程序,均已停止。
+
+修复后的程序在真实 Chrome 完成 64 项布局核对:320、375、640、1280px × 浅色/深色 × 登录及七个后台页面。检查实际已加载控件、页面横向溢出、主题控件、具名导航和窄屏计数徽标;AI 的6个下拉框同时核对全宽、至少44px高、窄屏16px字体与面板一致的背景。主题刷新保留和同站点跨标签同步通过,测试后恢复“跟随系统”和浏览器默认视口。这里不把布局检查等同于每个页面全部保存/删除业务验收。
+
+实际矩阵结果为 `.local/ai-select-browser-layout-result.json` 和 `.local/admin-acceptance/run-4024527018/browser-layout-result.json`,该独立实例已停止。截图包含 `browser-ai-select-375-{light,dark}.png` 和 `browser-ai-select-1280-{light,dark}.png`。另有独立离线64项布局与201项界面行为回归;两类64项分别记录,不合并称为新增128个用例。控件修复见[AI下拉框显示](ai-select-appearance.md)。
+
+## DEV-66当时的未完成范围与观察限制(DEV-67已补齐)
+
+以下保留当时的观察记录。当前13组实际文件与日期核对已通过,见[DEV-67验收](log-export-date-browser-acceptance.md)。
+
+实际导出文件的获取和内容核验仍待完成。页面三次显示“已发起下载”,开发者观察确认 HTTP 200,关键词未应用时请求仍为原 `level=info/q=Websocket`;但官方 `waitForEvent("download")` 的30秒和8秒等待均未返回文件,网络正文读取返回空文本。未获得下载路径或文件内容,不能将页面提示或200响应当作文件验收通过。CDP下载设置方法被工具告知不支持,按提示改用官方下载接口,没有更改浏览器下载权限或使用其他下载机制绕过。当前程序的独立Windows回归 `logExportPassed=true` 只作为程序级证据。
+
+手动原生日期输入及倒置时间范围的真实浏览器核对未完成。工具填写带零秒日期时报告失败,页面实际显示归一化后的分钟值;未继续把未确认的日期值用于断言。快捷15分钟和不限时间已通过,既有日期输入的离线/程序级及用户确认记录保持,不能扩展为本轮全部真实页面日期场景通过。
+
+首个测试实例在等待后续工作期间正常到期停止,重新准备新实例继续;已完成的结果与过期状态单独保存,不用过期地址作新测试。视口调整后的首个主题定位曾遇到暂时未匹配,刷新完整页面状态后操作正常;只读页面执行范围不提供 `parseFloat`,改为读取字体字符串后在测试端计算。这些观察问题未作为产品错误,记录在过程文件和矩阵结果中。
+
+## 复验入口
+
+使用独立启动器产生的本次 URL 和随机密码登录;日志页按[日志读取一次](log-read-once.md)、[清空显示](log-clear-display.md)、[时间筛选](log-time-filter.md)及[导出](log-export-2026-10-02.md)操作。检查每次请求完成的状态后再核对。主题通过页面的“外观”切换,尺寸通过浏览器支持的视口能力临时设置;全部结束后恢复默认视口和原主题,输入 `stop` 停止本次实例。
+
+独立run-3788569462真实操作后,从Windows已知下载目录只读识别唯一新增的日志txt,逐份核对当前10份实际文件:完整/有界/倒置保持/仅结束/仅开始/相同边界空结果/级别关键词/未应用条件忽略/清空显示保留导出/组合无匹配;UTF-8字节、UTC时间说明、ID/级别/正文/顺序与页面一致,安全文件名和合成口令保护正确。原生分钟及非零秒输入通过,倒置范围不改变已应用条件;精确纳秒包含沿用既有Go测试,不扩大真实浏览器范围。找到了前轮run-2213640713已保存的3份文件(5/2/2条)并补证;当时下载等待超时记录保持,这不是文件未下载的证明。读文件作为已发起Chrome下载的产物核验,未更改浏览器下载设置。
diff --git a/docs/local-admin/model-observation-identity.md b/docs/local-admin/model-observation-identity.md
new file mode 100644
index 0000000000..a5d4493481
--- /dev/null
+++ b/docs/local-admin/model-observation-identity.md
@@ -0,0 +1,21 @@
+# 观察报告的进程关联
+
+登录本机后台,在“运行概览”按[模型观察报告](model-observation-report.md)记录起点并下载。新程序在Windows启动后台时读取自身PID、操作系统记录的进程创建时间及当前程序SHA-256,仅计算一次;成功后前后统计快照都包含相同的 `process` 信息,新下载文件使用版本2。没有程序路径、账号、聊天内容、模型地址或密钥。
+
+进程信息不新增请求、不修改配置;程序更新后重启ZeroBot生效,NapCat无需重启。统计计数及累计耗时口径保持。记录起点后,PID、精确启动时间或程序摘要变化会使原起点失效,即使统计起点时间碰巧相同,也不能混用前后资料。报告仍只保存在下载文件,页面起点在退出或刷新后清除。
+
+使用[离线核对工具](observation-evidence-check.md)将版本2报告与资源观察目录核对:
+
+```powershell
+node scripts/check-observation-evidence.mjs --resource .local/live-observation/本次目录 --model 本次模型报告.json --output .local/本次关联核对.json
+```
+
+工具核对前后快照的三项身份信息一致,并与资源目录的PID、启动时间、程序摘要逐项匹配。时间支持明确偏移,按UTC纳秒规范化后比较;不会用近似几秒或仅PID相同代替精确启动时间。资源记录必须保持进程身份和程序不变,才可得到 `sameProcessVerified=true`。资源短窗口、阈值或日志检查不通过时仍可说明身份匹配,但 `compatible=false`;真实业务验收字段始终为false。
+
+旧版本1报告继续按原格式核对次数、资源和时间范围,明确标记缺少进程身份,不能取得同进程证明。若新程序无法读取自身创建时间或程序摘要,概览仍提供统计,导出退回无身份的版本1,并在记录起点的说明中提示;不伪造创建时间、不把后台启动时刻当成操作系统进程启动时刻。非Windows平台暂不提供该关联信息,统计与旧格式仍可使用。
+
+输出的“验证”表示这些文件的身份字段吻合,不是文件来源签名;自行修改资料可能产生自洽数据。模型HTTP完成不等于QQ回复成功,累计平均/最长耗时仍不代表窗口耗时或端到端延迟。不要因此关闭真实负载或其他真实角色待办。
+
+实际Chrome5组流程、8种报告布局通过,桌面/手机截图已查看。下载初始零计数、3模拟请求增量及独立重启后零计数三份版本2文件,分别与Windows Get-Process记录和程序摘要核对;重启前PID109352/03:52:53.5609118Z,重启后PID105788/03:56:12.6128656Z,程序摘要相同。旧会话自动失效、重新登录后起点为空,随机凭据保持。独立实例`run-2829863660`两次启动/3模拟往返正常停止,自建页关闭,主题与视口恢复;证据`.local/model-identity-browser-result.json`、`-os-{first,second}.json`及`-downloads/`。浏览器操作初轮遇到视口能力尚未返回及重启后旧控件已消失,重新读取能力/页面状态后完成,没有用接口写入替代页面下载。
+
+日常Chrome沿用浏览器正常填充登录,没有读取密码值;核对QQ87654321/108插件,下载第四份版本2报告,13.036秒窗口内新增0模型传输。11:59:29开始的60秒/3秒资源诊断记录21样本、60.0284574秒(`run-20261013-115929-e4ae7220`);报告窗口完整位于实际样本之间,三项身份逐一吻合,离线结果`evidenceValid=true`、`sameProcessVerified=true`。由于`fullTiming=false`,资源检查/`compatible`仍为false,CLI退出2,业务验收始终false。这是短时身份关联验证,没有15分钟正式或真实负载验收;证据`.local/model-identity-resource-model-check.json`及`-daily-browser.json`。
diff --git a/docs/local-admin/model-observation-report.md b/docs/local-admin/model-observation-report.md
new file mode 100644
index 0000000000..548f06ad73
--- /dev/null
+++ b/docs/local-admin/model-observation-report.md
@@ -0,0 +1,46 @@
+# 模型请求观察报告
+
+登录本机后台,进入“运行概览”的“模型请求观察报告”。这项功能使用已有模型接口统计,不新增模型请求,不修改机器人配置。
+
+1. 等待概览成功读取后,点击“记录观察起点”。记录的是最近成功读取的统计快照,时间以报告里的`before.checkedAt`为准,不是点击瞬间。
+2. 正常使用机器人,保持这个页面打开;概览每10秒读取一次。也可点击“刷新状态”取得新的结束快照。
+3. 点击“下载观察报告”,保存JSON文件。报告含前后累计快照和总计/四类请求次数变化,不含账号、聊天内容、地址、模型名称、密钥或配置。
+4. “重新记录观察起点”立即替换本页起点;“清除观察起点”仅清除本页记录,机器人累计统计保持。
+
+仅已登录、概览可见、成功读取且当前没有读取/下载操作时可以记录或下载。读取失败或统计无效时禁用下载,保留起点等待重新读取;切到其他后台页面或隐藏标签保留起点,但清除当前快照,回来后重新读取。刷新整个网页、退出或会话失效后起点清除;不会写入浏览器持久存储,不能自动恢复上次观察。机器人重启或前后时间/累计计数无法核对时原起点自动失效,需要重新记录。
+
+文件名为`zerobot-model-observation-时间.json`。下载失败显示通用提示,等待按钮恢复后重试;临时下载链接自动清理。发起下载不代表已经保存文件,应以浏览器下载结果为准。按钮不要求确认框;修改本地观察起点不涉及后台保存。
+
+## 报告口径
+
+格式为`zerobot-model-observation`;Windows当前程序可读取[进程关联信息](model-observation-identity.md)时导出版本2,前后快照增加固定三项`process`字段。无关联信息时保留版本1,并在记录起点时提示。只导出固定字段,忽略接口的其他属性。时间保留UTC最多9位小数,窗口秒数按浏览器毫秒精度计算;即使间隔显示0秒也不认定观察有效或业务通过。
+
+| 字段 | 含义 |
+| --- | --- |
+| `before` / `after` | 同一次进程统计起点的前后完整快照,固定四类及累计次数、平均/最长传输耗时 |
+| `before.process` / `after.process` | 版本2:PID、操作系统创建时间、程序SHA-256;无路径或QQ账号,三项必须一致 |
+| `window.started` | 两次读取之间新发起的HTTP传输数,重定向逐跳计数 |
+| `window.completed` / `failed` / `incomplete` | 两次读取之间结束并归入接口完成、失败、提前关闭的数量 |
+| `window.activeAtStart` / `activeAtEnd` | 起点和结束快照里仍进行中的请求数量 |
+| `window.categories` | GPT/群总结、普通聊天、Agent、识图的同口径变化 |
+| `window.durationSeconds` | 结束读取时间与起点读取时间的间隔,不是每条QQ回复耗时 |
+| `scope.latencyScope` | 固定`process-cumulative`,平均/最长耗时属于各快照的本次启动累计值 |
+| `scope.businessAcceptancePassed` | 固定`false`;报告不会自动宣布真实业务通过 |
+
+窗口内结束的请求可能在起点之前已经发起。核对关系为“窗口新发起 + 起点进行中 = 窗口三类已结束 + 结束进行中”。开始次数和结束次数不要求相等,不能把窗口计数直接当成某一批QQ消息的成功率。报告不计算窗口平均或最长耗时;累计最长值不能通过两次快照相减得到窗口最长值。HTTP传输完成也不保证解析、Agent动作或QQ回复成功,完整口径见[模型请求统计](model-request-statistics.md)。
+
+模型请求报告与[资源观察](live-performance-observation.md)是两个独立来源。资源采样脚本不读取本报告;维护者可用[离线核对](observation-evidence-check.md)检查已保存文件、UTC范围及版本2进程身份。版本1仍不能证明同进程,两种版本都不测量QQ端到端延迟。不要把其他进程、重启前后的快照或历史资源结果拼接成当前构建的真实持续负载验收。
+
+## 独立自动化验收
+
+开发环境可从仓库根目录运行`go run scripts/start-model-observation-acceptance.go -binary-name zerobot.exe -lifetime 20m`。需要本机维护工具、回环监听/运行临时程序权限;随机密码仅在本次目录`login.txt`内,临时地址由输出给出。所有配置、账号、模型都是独立模拟数据,不读取日常密码或接入真实QQ/模型。
+
+命令`requests`每次只生成3条合成GPT往返,最多20批;每批新合成会话避开现有30秒限流。调用前核对原模拟OneBot地址与原本次模型地址,改成其他地址会拒绝;`status`读取统计,`restart`重启本次实例并核对随机凭据保持,`stop`停止本次实例。输入结束或限定时长到达也清理实例。本工具不修改Chrome下载设置,下载文件仍由正常浏览器按钮产生。
+
+实际Chrome9组流程与8项布局指标通过:下载零请求、0→3、重新记录3→6三份实际文件并核对前后快照/窗口次数;切页保留、局部清除、网页刷新清除、重启会话与起点清除、退出。报告实际时间/内容以各文件为准,零值短窗口不计性能验收。四种宽度320/375/640/1280px×深浅,按钮及摘要无横向溢出;桌面与手机深色截图已查看(`.local/model-report-browser-{desktop,mobile}.png`)。
+
+实际下载从Windows已知下载目录仅识别本次新增的报告文件,没有修改Chrome下载设置或读取其他下载。三份文件字节/来源/SHA-256及内容见`.local/model-report-browser-result.json`、副本`.local/model-report-downloads/`。独立实例`.local/model-observation-acceptance/run-1941381813`两次启动、两批6次模拟GPT、随机凭据保持和正常停止通过;自建页关闭,主题与视口恢复。报告文件无聊天、模型地址/名称/密钥、账号或配置,累计耗时未写入窗口平均/最长字段。
+
+本轮未发送真实QQ消息或调用真实模型,没有重做15分钟资源观察;真实角色和持续业务负载仍按[待办](todo.md)验收,报告导出不会自动关闭它们。
+
+10:57日常Chrome正常自动填充登录核对正确QQ87654321、108插件、累计0请求和报告入口;仅在本页记录/清除起点,未下载日常报告、未读取密码值或保存配置,自建页关闭(`.local/model-report-daily-browser.json`)。10:59:58最终只读检查程序/进程、五配置、QQ与NapCat保持,无启动错误/遗留模拟进程(`.local/model-report-daily-after.json`)。交付工具核对729处文档链接及135个资源入口通过;本轮汇总`.local/model-report-combined-evidence.json`核对三份实际文件原件/副本、程序与源码摘要及各证据范围,未重复执行测试或部署。
diff --git a/docs/local-admin/model-request-statistics.md b/docs/local-admin/model-request-statistics.md
new file mode 100644
index 0000000000..35fa3460d8
--- /dev/null
+++ b/docs/local-admin/model-request-statistics.md
@@ -0,0 +1,34 @@
+# 模型接口请求统计
+
+后续DEV-74已补齐[观察起点与报告下载](model-observation-report.md),可保存同一启动起点的前后累计快照与请求次数变化。以下DEV-73记录保留原构建/时间;当前窗口报告不计算窗口最长耗时、不等同QQ端到端延迟或业务验收。
+
+登录本机后台,在“运行概览”查看“模型接口请求”。页面沿用概览的10秒自动更新与“刷新状态”按钮;离开页面或隐藏标签暂停读取,重新进入重新读取。未登录不能读取统计;退出、切页、隐藏或读取失败会清除旧显示,避免把旧统计当成当前值。
+
+统计在运行新版机器人后开始,无需修改AI配置或发送测试消息。数据只在本次ZeroBot进程内累计,重启后重新开始,页面显示统计起点。NapCat重启、概览刷新或AI配置热更新不会主动清零;旧程序运行期间的数据不能补记。没有已结束请求时显示“耗时待统计”,不是耗时为零或业务已通过。
+
+四个固定类别:GPT/群总结、普通聊天、Agent、识图。Agent每轮实际接口请求单独计数,识图用单独配置的图片模型接口计数,Agent回退聊天会进入聊天类别。同一用户消息可能触发多次接口请求。不会统计QQ内置语音、图片下载、非模型插件、没有发出请求的参数校验/限流拒绝,或其他工具直接调用的接口。
+
+## 统计口径
+
+| 显示 | 含义 |
+| --- | --- |
+| 发起 | 已进入上述模型客户端的HTTP传输次数;重定向每一跳单独计数,不等于QQ消息数或模型任务数 |
+| 进行中 | 尚未结束传输,包括已收到响应头但仍读取响应的请求 |
+| 接口完成 | 2xx响应,读到结束或读完声明的响应长度;不证明解析成功、内容有效、Agent动作获准或QQ发送成功 |
+| 失败 | 网络/取消/超时/响应读取错误、非2xx响应;重定向3xx跳也在此类,后续跳可能完成 |
+| 提前关闭 | 2xx响应未核对完整就被调用方关闭;可能是解析错误或只读取部分内容,并不自动等同业务失败 |
+| 平均与最长耗时 | 所有已结束传输(包括失败与提前关闭)从进入HTTP传输到读取完成、报错或关闭的时间;页面以秒显示,接口以毫秒返回;不包括前置提示组装、排队或QQ发送时间 |
+
+响应正常传输和“用户收到正确回复”属于不同证据。未知长度响应必须读到结束;解析器若提前结束而没有再读取结束标记,将显示提前关闭。客户端的超时、重定向策略、Cookie处理和原传输保持,不增加请求、重试或主动读取剩余正文。
+
+统计只保存固定类别、次数、时间与耗时汇总,不保存逐条请求历史、QQ/群号、模型名称、接口地址、消息、图片、密钥或原始错误。不会写入配置或备份,不提供清零/修改接口。内存容量不随用户/请求历史增加;响应期间仅保留一次结束回调,结束重复通知不重复计数。
+
+维护工具可使用已登录的同源只读`GET /api/overview`中`modelRequests`对象取得累计快照。它沿用本机地址、来源和后台会话限制,响应禁止缓存;没有统计来源返回`null`,不能当作0。对比两个快照须核对统计起点一致,否则跨重启的差值无效。`observe-live-services.ps1`仍只采资源和日志,尚不读取这个接口,不能直接宣称同时取得请求数量或端到端回复延迟。
+
+## 自动验证与真实业务范围
+
+最终构建独立Windows整机09:56:44通过(`.local/smoke-admin/run-1893138544/result.json`),核对11次模拟模型传输及分类GPT4/普通聊天2/Agent5/识图0,数据不含接口/模型名/口令/提示/回复。此次整机未触发识图,识图计数由统计包HTTP用例覆盖,不称为真实识图重新验收。修正前的整机记录`.local/smoke-admin/run-2170727559`保留,不作为最终构建证据。
+
+实际Chrome9项流程与8项布局指标通过:零请求、11次及分类、自动/手动刷新、会话失效、切页、独立重启新起点、退出;四宽度320/375/640/1280px×深浅主题。桌面/小屏截图已查看,`.local/model-stats-browser-{desktop,mobile}.png`。两个独立后台共享回环Cookie名称,第二个登录使第一个会话失效;重新登录核对零值,初次记录保留`.local/model-stats-browser-initial-cases.json`。工具不支持的输入值方法及插件标题断言修正后使用实际新状态核对;未据此改动产品。独立run-848149287两次启动/4条翻转模拟回复、凭据保持、旧会话拒绝及停止通过,run-4152882730因输入结束正常停止,未算浏览器验收。最终浏览器记录`.local/model-stats-browser-result.json`,自建页面关闭、主题/视口恢复。
+
+回退元数据`.local/model-stats-rollback.json`,旧程序`.local/bin/zerobot-before-model-stats-20261013-100557.exe`,一致控制快照`20261013T020037.919356500Z-9c70cd9c3a5cb05a-control`。本轮未发送真实QQ消息或调用真实模型;真实QQ/模型持续负载与更长窗口仍按TODO-PERF-01验收,新增统计不会自动关闭该待办。
diff --git a/docs/local-admin/observation-evidence-check.md b/docs/local-admin/observation-evidence-check.md
new file mode 100644
index 0000000000..ab07ddb7c9
--- /dev/null
+++ b/docs/local-admin/observation-evidence-check.md
@@ -0,0 +1,36 @@
+# 模型与资源观察证据核对
+
+资源观察没有对应模型报告时,可仅运行`node scripts/check-observation-evidence.mjs --resource .local/live-observation/本次目录 --output .local/本次资源核对.json`。该模式只读取最终资源结果与原始样本,重新计算窗口、CPU/内存和阈值,沿用输入大小、秘密字段排除及独占输出保护;无需浏览器、后台密码或服务重启。不会读取日志正文、发送QQ消息或调用模型。
+
+仅资源输出标记`mode=resource-only`,`resourceObservationPassed`表示资料有效、资源检查通过且采样时钟一致。退出0对应此标记;退出2表示资料有效但正式时长、节奏、阈值、身份、日志覆盖或时钟检查未通过;退出1表示无效文件/样本/摘要或输出失败。`compatible=false`、`sameProcessVerified=false`及`temporalCoverage=unavailable`保持,因为没有模型窗口可关联;`businessAcceptancePassed=false`不会因为资源观察通过而改变。附带`--model`时仍按下面原双来源口径核对与退出,不改变历史报告行为。
+
+当前日常F86EA6构建15:31:38~15:46:38的180样本覆盖900.035秒,正式窗口/节奏/阈值/日志覆盖及原摘要独立重算通过;仅资源CLI退出0、`resourceObservationPassed=true`,关联与业务仍未证明。15:47:26只读核对ZeroBot PID110400/NapCat PID56644及原启动时间、五配置与QQ在线保持。14项界面资源摘要一致,本轮仅维护脚本和文档变化,无机器人构建、部署、重启或界面测试重跑。完整数值与原件见[日常资源记录](live-performance-observation.md),证据`.local/live-current-resource-check.json`、`-daily-after.json`及`-final-evidence.json`。
+
+维护者可在仓库运行下面的离线工具,核对已经保存的模型报告和资源观察目录。需要 Node.js 以及输入文件的读取权限;不需要后台密码、浏览器连接或管理员登录。工具不连接网络、不采样、不发送消息、不修改配置或控制服务,读取后立即生效,无需构建或重启机器人。
+
+```powershell
+node scripts/check-observation-evidence.mjs --resource .local/live-observation/本次目录 --model 本次模型报告.json --output .local/本次核对.json
+```
+
+请替换为实际路径。资源目录必须含最终 `result.json` 和原始 `samples.jsonl`;仅有进度文件不能使用。模型文件来自运行概览的[观察报告下载](model-observation-report.md)。输出文件必须尚不存在,工具不会覆盖输入或已有结果;省略 `--output` 时仅向终端输出结果。
+
+工具重新计算资源摘要(含小数内存增长)、窗口长度、节奏和阈值,并与原结果核对;不会修正或改写原文件。模型报告支持版本1/2,核对四类累计次数、跨窗口进行中守恒、精确时间与窗口变化;版本2另核对[进程身份](model-observation-identity.md)。不从累计平均/最长值推算窗口耗时。不认识的附加属性不会复制进输出;聊天内容、QQ账号、配置、路径和错误正文不会写入核对报告,输出包含来源摘要、资源及版本2模型的PID/启动时间/程序摘要与固定检查说明。
+
+结果区分以下内容:
+
+- `evidenceValid`:文件结构、原始样本与已保存摘要、模型报告内部关系均有效。
+- `resourceChecksPassed`:重新计算的正式窗口、资源阈值以及原结果的身份/程序/日志覆盖检查通过;日志计数不能从资源样本独立还原。
+- `temporalCoverage`:模型窗口是否完整位于资源首末样本之间;部分重叠、无重叠、零长度或时钟不一致均不能作为兼容窗口。
+- `compatible`:上述检查通过,模型统计起点在资源进程启动后且在模型首次读取前,两个窗口有完整覆盖。版本2还要求身份吻合;版本1只表示时间和数据兼容,仍缺少身份。
+- `sameProcessVerified`:版本2前后快照三项身份一致且与资源PID/精确创建时间/程序摘要吻合、资源身份与程序保持时为true;版本1始终false。资源短窗口或阈值不通过时,身份仍可能匹配,但`compatible=false`。
+- `businessAcceptancePassed=false`:始终保留;身份吻合或接口传输完成不能证明真实QQ回复成功,不关闭真实负载待办。
+
+双来源模式的退出码0表示资料兼容;版本1关联仍未证明,版本2身份核对另见`sameProcessVerified`及三项`processComparison`。退出2表示资料有效却不兼容(资源短窗口、停止、身份不合、资源/日志检查不通过或时间范围不合);退出1表示文件/计数/样本/摘要无效或输出失败。结果中的固定问题代码指出原因。判断结果要同时看模式、对应通过字段、身份及始终为false的业务验收字段,不能只凭退出码0认定业务通过。模型窗口可以较短,兼容不代表持续负载充分;按浏览器毫秒口径为0秒的窗口不能关联,完全位于资源窗口之外时优先标记无重合。
+
+输入限制为每份JSON不超过1MiB、样本文件不超过64MiB、最多100000条、每行不超过4096字节。时间支持UTC和明确的时区偏移,最多9位小数;采样墙钟与累计秒数偏差超过1秒会标记不可用于时间关联。原始日志正文不读取。人工更改文件仍可能生成自洽数据,摘要只用于辨认这次读取的文件,不是来源签名。
+
+历史DEV-72的资源结果曾把8192字节增长取整为0;用原目录核对会明确报告摘要不一致。保留原始文件和已有重算说明,不把修正结果冒充重新采样,也不把该旧构建与DEV-74模拟模型报告关联为当前日常负载。
+
+开发边界回归入口为 `node --test scripts/test-observation-evidence.mjs`。2026-10-13 DEV-75 / REQ-72 / TC-95:75项离线回归通过,覆盖跨起点2条进行中请求、四类重排、纳秒起点/倒序/回退、小数内存及原摘要不一致、停止/短资源窗口/稀疏采样/阈值/日志/身份、墙钟漂移、秘密字段排除、1MiB/64MiB/100000条/4096字节限制、BOM/末行、输入和已有输出保护及CLI退出码。首轮70项中部分重合测试的起点早于纳秒统计起点,修正合成时间后通过,原失败记录`.local/observation-check-tests-initial.txt`保留。
+
+三份DEV-74实际Chrome下载文件再次通过模型结构与窗口核对;原180项资源样本重算得到900.0403869秒及0.0078125MiB增长,明确报告原摘要不一致和时间无重合。另在标注的测试副本应用既有重算摘要,结构通过但仍因时间无重合退出2;没有改写原证据或重新采样。证据`.local/observation-check-actual-evidence-final.json`及三份`-actual-final-*.json`,历史摘要修正仅在`-resummary-fixture/`副本演练。
diff --git a/docs/local-admin/onebot-windows.md b/docs/local-admin/onebot-windows.md
new file mode 100644
index 0000000000..3df892940a
--- /dev/null
+++ b/docs/local-admin/onebot-windows.md
@@ -0,0 +1,94 @@
+# Windows OneBot 环境
+
+## 当前状态
+
+已安装并实际启动 NapCat,QQ 已完成登录,最近检查状态为 `ready`。WebUI 返回 HTTP 200,实际监听地址为 `127.0.0.1:6099`,未认证的管理请求被拒绝,本地凭据认证成功。
+
+OneBot 已实际监听 `127.0.0.1:6700`,仅本机访问。带 Token 的 WebSocket 连接及 `get_login_info`、`get_status`、`get_version_info` 均通过实测,在线状态为 true。2026-10-01 已重新启动并再次扫码登录,上述检查再次通过。ZeroBot 的 `.local/config.json` 已使用匹配的 URL 和 Token,主人账号已填写,且与机器人账号不同。
+
+ZeroBot 之前发现的公告及资源校验网络等待已增加超时,缺失启动素材已补齐;主程序已实际启动并与 NapCat 握手成功。私聊命令收发正在验证。NapCat 登录成功与 ZeroBot 插件程序正常启动是两个独立检查点。
+
+## 安装来源和依赖
+
+| 内容 | 版本或来源 | 验证 |
+| --- | --- | --- |
+| NapCat 官方包 | `NapCat.Shell.Windows.Node.zip`,v4.18.28 | SHA256 与官方 Release asset digest 一致 |
+| SHA256 | `fb64fa3b036ad2df1a5d7c204c482694c20e4b763978c8a4968fd3474c05b4a8` | 本机已核对 |
+| 包内 Node | v22.11.0 | 实际执行版本命令验证 |
+| 包内 QQ native | 9.9.31-49738 | 来自包内元数据 |
+| 本机已安装 QQ | 9.9.35-52892 | 从安装配置和 package.json 核对;未改动其文件或停止进程 |
+| 补充原生库 | 本机 QQ `resources/app/crypto.dll`、`ssl.dll` | 首次启动明确缺失;复制到独立运行目录后启动成功 |
+
+安装记录保存在 `.local/onebot/install-manifest.json`,包括官方包来源以及补充 DLL 的路径和 SHA256。不是修改 NapCat 业务代码,也不是给系统 QQ 安装注入插件。QQ native 的登录缓存可能仍由其自身写入用户数据目录;`NAPCAT_WORKDIR` 控制的是 NapCat 配置、日志、插件与缓存路径。
+
+官方参考:[发行包](https://github.com/NapNeko/NapCatQQ/releases/tag/v4.18.28)、[Windows 安装说明](https://napneko.github.io/guide/boot/Shell)、[OneBot 配置说明](https://napneko.github.io/config/basic)。
+
+## 文件与端口
+
+| 用途 | 位置/地址 |
+| --- | --- |
+| 独立程序 | `.local/onebot/napcat-v4.18.28/` |
+| NapCat 配置和缓存 | `.local/onebot/runtime/` |
+| WebUI 配置和登录密钥 | `.local/onebot/runtime/config/webui.json` |
+| 默认 OneBot 配置 | `.local/onebot/runtime/config/onebot11.json` |
+| QQ 登录后账户配置 | `config/onebot11_.json`,如果生成则以该文件为准 |
+| ZeroBot 连接配置 | `.local/config.json` |
+| 启动日志 | `.local/onebot/runtime/launcher-logs/` |
+| WebUI | `http://127.0.0.1:6099/webui/` |
+| OneBot 正向 WS | `ws://127.0.0.1:6700` |
+
+WebUI 密钥与 OneBot Token 分别生成,二者用途不同。配置和日志已由 `.local/` 忽略规则排除,不提交至 Git。NapCat 的启动日志可能含 WebUI 密钥和二维码链接,应仅在本机查看。
+
+## 常用操作
+
+在项目根目录执行:
+
+```powershell
+# 重复执行会验证安装和配对,保留已有配置及密钥
+.\scripts\install-onebot.ps1
+
+# 在当前终端运行;已有记录且进程仍运行时不重复启动
+.\scripts\start-onebot.ps1
+
+# 在默认浏览器中打开 WebUI,自动读取本地登录密钥
+.\scripts\open-onebot-webui.ps1
+
+# 检查页面、认证和 QQ 登录状态;登录后检查三个只读 OneBot API
+.\scripts\test-onebot.ps1
+
+# 从另一终端停止本项目记录的 NapCat 启动器及其 Node 子进程
+.\scripts\stop-onebot.ps1
+```
+
+首次安装需要 GitHub CLI 和下载网络。当前目录已有验证过的包;重装脚本不会再次下载。如果 QQ 安装在其他路径,可传入 `-QQAppPath` 指向含 `crypto.dll` 和 `ssl.dll` 的 `resources/app` 目录。
+
+当前实例已改为 Windows 计划任务的独立宿主运行,输出重定向到 `.local/services/`。之前临时工具启动的两个进程已退出,需要跨会话运行时使用 [持续运行入口](operations.md) 中的 `start-local-services.ps1`;前台 `start-onebot.ps1` 仍可用于手工调试,终端需保持打开。用户提供的杀毒记录确认:原隐藏启动脚本在 2026-09-30 两次触发 `HEUR:TrojanDownloader/PS.NetLoader.ae` 并被删除;当前没有修改杀毒软件配置或添加排除项。
+
+## 用户需要完成的登录步骤
+
+1. 打开 WebUI,选择二维码登录;使用准备作为机器人的 QQ 扫码,并在手机上确认。
+2. 若 WebUI 要求更改登录密码,按页面完成。该密码与 OneBot Token 无关;以后打开页面的脚本会读取更新后的配置。
+3. 确认 QQ 状态变为在线,网络配置中 `zerobot-local` 已启用,地址为 `127.0.0.1`,端口为 `6700`。
+4. 在 `.local/config.json` 的 `zero.super_users` 填入自己的主人 QQ 号。本机已由用户填写并通过格式检查;不要将机器人 QQ 号自动当作主人账号。
+5. 执行 `test-onebot.ps1`,应显示 `qqLoggedIn: true` 和 `onebotReadOnlyAPIsPassed: true`。
+
+登录二维码会过期,以 WebUI 中当前显示的二维码为准。无需把 QQ 密码发送到聊天中。
+
+当前 ZeroBot 主程序已通过独立宿主启动并成功连接。后台入口使用 `start-local-services.ps1 -Service ZeroBot`,会先校验 `.local/onebot/account.json` 指定的机器人账号;前台调试可用 `run-windows.ps1 -ConfigPath .local/config.json -Headless`。PID 和日志位置记录于 `.local/zerobot/process.json`,不要重复启动第二个实例。默认 WS 配置使用消息数组格式、30 秒心跳、关闭自身消息上报和调试事件输出;未启用 HTTP API 或反向连接。
+
+## 环境自测
+
+| 编号 | 检查 | 本次结果 |
+| --- | --- | --- |
+| OB-01 | 官方包 SHA256、包内 Node 版本 | 通过 |
+| OB-02 | 缺失 DLL 定位、补齐后启动 | 通过 |
+| OB-03 | WebUI HTTP 200 和实际回环监听 | 通过,`127.0.0.1:6099` |
+| OB-04 | 未认证请求拒绝、有效认证成功及会话退出 | 通过 |
+| OB-05 | ZeroBot 与 OneBot URL/Token 一致 | 通过 |
+| OB-06 | 重复安装/配置不修改既有密钥 | 通过,配置文件摘要保持一致 |
+| OB-07 | 已运行时重复启动 | 通过,复用记录的 PID |
+| OB-08 | QQ 登录并完成三个只读 API 检查 | 通过,`qqLoggedIn: true`、`loginPhase: ready`、`onebotReadOnlyAPIsPassed: true` |
+| OB-09 | 停止与重新启动回归 | 重新启动、扫码及接口回归通过;停止脚本尚未实测 |
+| OB-10 | ZeroBot 实际消息收发 | 通过:初始化、实际 WS 握手、隔离模拟及帮助/服务列表真实 QQ 回复均通过,用户及收发日志已确认 |
+
+健康检查不会发送消息、添加好友、处理群邀请或更改 QQ 群设置。环境就绪不等于所有业务插件通过验收。
diff --git a/docs/local-admin/operations.md b/docs/local-admin/operations.md
new file mode 100644
index 0000000000..f676c712ae
--- /dev/null
+++ b/docs/local-admin/operations.md
@@ -0,0 +1,243 @@
+# 本地服务运行与恢复
+
+机器人登录账号与机器人主人分别配置;后台采用独立密码。首次安装、编译与资源初始化见[Windows环境](onebot-windows.md)和[使用指南](user-guide.md)。
+
+## 持续运行方式
+
+本地服务使用 Windows 计划任务服务启动独立宿主,避免依附于临时开发工具进程。
+
+任务名称为 `ZeroBot-Local-<项目路径摘要>-OneBot/ZeroBot`,以当前已登录用户运行,权限为 Limited,手动触发,运行时限不限。没有添加开机或登录触发器。任务定义检查、PID、日志及清理入口都保留在项目中。
+
+在项目根目录执行:
+
+```powershell
+# 启动 NapCat,页面就绪后按 expectedQQ 登录
+.\scripts\start-local-services.ps1 -Service OneBot
+
+# 登录正确账号后:检查认证、账号及三个只读 API
+.\scripts\test-onebot.ps1
+
+# 验证正确账号后启动 ZeroBot 的独立宿主
+.\scripts\start-local-services.ps1 -Service ZeroBot
+
+# 停止本项目托管服务;All 先停 ZeroBot,再停 OneBot
+.\scripts\stop-local-services.ps1
+
+# 不再使用时停止并移除本项目任务,可用启动脚本重新创建
+.\scripts\stop-local-services.ps1 -Unregister
+```
+
+任务名称记录在 `.local/services/tasks.json`,宿主日志位于 `.local/services/`。真实进程信息仍分别写入 `.local/onebot/runtime/process.json` 和 `.local/zerobot/process.json`。停止脚本先校验任务定义、程序路径和进程启动时间,再结束对应进程树及任务,避免只停宿主而留下机器人。ZeroBot 停止、更新程序及重启已实际通过;OneBot 停止及任务移除尚未实测。
+
+## 浏览器恢复流程
+
+Chrome 自动打开后台时,可直接导航到 `http://127.0.0.1:3010/`。首页是公开的静态界面,允许本机顶层 GET 导航,即使浏览器将来源标记为 `cross-site` 或 `same-site`;iframe、跨站脚本请求、管理 API 与所有提交操作不在此范围,仍校验本机 Host、Origin、登录状态及提交凭据。导航兼容修复需要构建并重启实际运行的 ZeroBot 后生效,重启后重新登录;无需停用扩展或关闭来源校验。若页面仍显示“请求来源不受信任”,先核对地址是否为首页,再核对正在运行的程序是否已更新。
+
+本地管理后台已启用在 `http://127.0.0.1:3010/`,与 NapCat 登录页分开。服务宿主读取 `.local/admin/options.json`,ZeroBot 重启后仍按配置开启后台;重启会注销后台会话,需要重新输入后台密码。用户已确认首次设置、概览及插件列表正常。开启/关闭、构建及隔离自测入口见 [后台基础与验证](admin-foundation-2026-10-01.md)。后台密码、设置码与 OneBot Token 分开管理。
+
+先检查服务与端口;页面未就绪就启动 OneBot。用 Browser 技能操作 `http://127.0.0.1:6099/webui/`,读取可见登录状态,优先选择与 `expectedQQ` 一致的已缓存账号。扫码或手机确认需要用户完成。登录后通过 `test-onebot.ps1` 再核对账号,才启动 ZeroBot;最后检查真实连接和命令日志。
+
+本次浏览器导航被工具安全检查阻止:无法验证已保存的 localhost 访问权限。未绕过该限制;用户已手动登录配置指定的正确账号,三个 OneBot 只读 API 与账号校验通过。OneBot 和 ZeroBot 两个任务都在运行,ZeroBot 的新日志已确认实际握手成功,两者宿主父进程均为 Windows `svchost.exe`。
+
+用户重发后已确认 `/服务列表` 收到回复,收发日志也记录图片发送成功。`/help` 未回复的实际原因是正文末尾换行:只读消息历史确认 `message` 中的文本为 `/help\n`,而 `raw_message` 只显示 `/help`。原匹配规则要求完全相等。已修复帮助匹配的首尾空白处理,通过回归及完整程序隔离测试,2026-10-01 14:16 重启 ZeroBot 并重新连接正确账号。NapCat 保持登录;用户现已确认帮助正常显示,新日志也记录帮助接收及文字回复。基础消息链路验证完成。
+
+## 本地站点权限校验故障
+
+当前状态(2026-10-12 16:17):Chrome 新标签自动打开3010的问题已修复。实际抓取到首页请求 `Sec-Fetch-Site: cross-site`、`Sec-Fetch-Mode: navigate`、`Sec-Fetch-Dest: document`,旧后台先返回403,浏览器随后报 `ERR_BLOCKED_BY_CLIENT`。已仅允许公开首页的本机顶层 GET 导航,并部署、重启和通过Chrome新标签/刷新/读取/点击验证;管理接口和提交校验保持。下文保留早期诊断,不再代表当前导航状态。完整登录后页面流程仍按待办执行。
+
+官方 [Browser 说明](https://learn.chatgpt.com/docs/browser) 与 [设置说明](https://learn.chatgpt.com/docs/reference/settings) 指定在应用“设置 → Browser”管理允许和阻止的网站(Windows 可按 `Ctrl+,` 打开设置)。先检查 `127.0.0.1` 条目和设置页是否正常:若被阻止,通过正常界面移除阻止;若有允许选项,可仅允许本项目使用的本地站点。设置页没有该入口或自身报错时,记录实际提示,再按应用正常重启/更新流程恢复。恢复后由 Browser 重新导航并读取页面,成功才标记解除。
+
+没有使用改端口、替换域名、其他浏览器控制方式、修改权限存储或关闭安全检查来替代这次校验。无需为此修改 ZeroBot 的 Host/Origin 校验、OneBot Token 或后台认证。
+
+用户更新并重启后已再次复测:应用版本 26.928.31416(构建 12553,prod),当前更新渠道返回 `up_to_date`;使用对应新版 Browser 插件仍无法完成权限校验。详细复现及可提交的反馈摘要见 [浏览器权限故障记录](operations.md)。按用户要求现已暂缓此问题,继续开发。
+
+## 连接设置与保存
+
+刷新 `http://127.0.0.1:3010/` 并重新登录后,从左侧进入“连接设置”。现可编辑已有正向连接或反向监听,也可 [新增正向连接](connection-add.md)、[新增本机反向监听](reverse-connection.md) 或 [删除连接](connection-delete.md)。删除至少保留一条连接,须核对固定预览目标并确认;新增、修改、删除均在保存前备份,重启后加载。
+
+1. 选择连接,修改 WebSocket 地址(`ws://` 或 `wss://`)。不修改地址时完整保留原值;带认证参数的旧地址只展示脱敏值,替换地址时请使用独立 Token 字段。
+2. Token 默认为“保留当前 Token”,无需输入;替换时输入新值,清除时选择“清除 Token”并勾选确认。读接口不会返回当前 Token。
+3. 主人 QQ 账号用空格或逗号分隔,至少保留一个;这是管理者账号,与 NapCat 登录的机器人账号不同。
+4. 点击“备份并保存配置”。每次实际修改先备份旧启动配置,再替换文件;无修改时不写入、不生成重复备份。页面显示本次启动值和已保存值,修改后提示“待重启”。发生版本冲突时先重新加载,再核对更改,不直接覆盖。
+
+页面只写 ZeroBot 的 `-c` 配置文件。本机文件为 `.local/config.json`。NapCat 的地址/Token 不会自动同步,变更连接前须确保两端匹配;保存后需按上文脚本停止并重新启动 ZeroBot。现有启动脚本会检查 NapCat 登录账号及配置配对,配对不符时拒绝启动。
+
+启动配置备份位于 `.local/admin/backups/`,是含版本、时间和 `startup-config` 范围的 JSON 封装,旧启动配置在 `config` 字段,不能直接传给 `-c`。备份可能含 Token,使用当前 Windows 用户与 SYSTEM 的受限权限,不公开正文下载,也不进入 Git。“备份恢复”现有列表、手动创建、预览恢复和手动清理;另提供控制数据库及后台选项两个独立范围。启动配置备份不包含数据库,控制库备份也不等于全部插件数据库或聊天/媒体备份。详见下文恢复操作与 [备份范围说明](backup-restore-2026-10-02.md)。
+
+后台写入串行化并检查版本;也会在写前重新检查文件,但不能与任意外部编辑器建立共同锁。保存期间避免同时用其他工具编辑同一个配置文件。连接页面的运行值表示本次启动快照;插件开关即时更新见下节,AI 设置见末节。测试范围及限制见 [连接设置验证](connection-settings-2026-10-01.md)。
+
+## 插件管理
+
+1. 默认查看全局。每张卡片展示当前生效状态、来源和当前默认值;从下拉框选择“显式启用”“显式禁用”或“继承默认”,点击“保存开关”。显式全局设置覆盖所有群及私聊。
+2. 管理某群时,范围选择“指定群”,填写正整数群号,点击“查看此范围”。卡片下拉框展示此群保存的设置,状态徽标展示最终生效结果;全局覆盖存在时,群设置虽可保存,但不决定最终状态。
+3. 要让各群独立决定,先将全局设为“继承默认”;群的“继承全局或默认”会移除此群显式覆盖,同时保留插件已有参数。群号须自行核对,当前没有 QQ 群自动下拉列表;不修改群沉默、黑名单或个人封禁规则。
+4. 保存成功立即更新运行规则,无需重启;聊天命令修改后刷新状态也可看到结果。旧页面提交遇到冲突时重新加载后核对。刷新状态或切换范围先确认丢弃尚未保存的选择,取消或读取失败保留原选择,运行概览的十秒自动刷新不会覆盖插件选择。
+
+每次实际开关修改或默认翻转前,网页和聊天指令均创建控制数据库快照;无修改不重复创建。备份仍在 `.local/admin/backups/`,目录名以 `-control` 结尾,含 `plugins.sqlite` 与 `manifest.json`。使用 SQLite 一致性快照,涵盖当时控制库里的开关、群参数及 AI 扩展数据,可能含模型密钥;权限仅当前用户和 SYSTEM,不公开下载、不进入 Git。备份失败则拒绝该次开关修改。批量聊天开关是逐插件执行,有失败会明确报告未全部完成。
+
+控制库的手动快照可运行 `go run scripts/backup-control.go`(需要现有数据库),也可使用后台“备份恢复”的列表、手动备份、预览与确认恢复。AI 扩展/群参数、响应/沉默、用户封禁/解封及插件禁止/允许均已接入写前备份;业务计数不逐次备份。旧备份通过下述手动清理入口处理。网页不发送启停 QQ 通知,也不调用需要聊天上下文的启停回复回调。
+
+控制依赖补丁新增继承元数据,回退到旧二进制前需考虑数据库兼容;不能把仅替换旧程序当成完整配置恢复。具体改动及验证见 [插件管理验证](plugin-management-2026-10-02.md)。
+
+## 群响应与插件开关
+
+插件管理的搜索包含名称、简介和注册用法,例如 `/gpt`;“生效状态”按当前所选全局/群范围的实际启用、禁用或未知结果筛选。筛选和清除不会写配置或丢失开关草稿;保存后按新实际状态重算,卡片可能消失。刷新或切换范围先确认丢弃,成功读取才清除草稿,退出会重置检索条件,详见 [插件搜索与筛选](plugin-search.md)。
+
+AI 页面有独立编辑保护:全局与群参数分别标记未保存更改,切换后台页面保留草稿,重新加载/切群/退出先确认;取消、读取失败或保存冲突保留编辑,成功保存只清除对应表单。保存进行中先等待结果;会话过期立即清空表单,旧响应不覆盖新会话。草稿不写浏览器存储,刷新/关闭提醒受浏览器限制,详见 [AI 编辑保护](ai-drafts.md)。
+
+`/全局响应`、`/全局沉默` 仍仅限机器人主人:推荐私聊发送;群里使用时仍需 @。全局响应开启后覆盖各群响应门控。按群控制前,先私聊 `/全局沉默` 移除全局覆盖,再在目标群 @ 机器人并发送 `/响应`;其他已显式响应的群保持原值。
+
+插件开关和响应门控分别生效。`/响应` 不会重新启用被禁用的 sleepmanage;该插件“早安”仅在本机时间 6~12 点响应,“晚安”为 21 点至凌晨 3 点。验证经过与范围见 [群响应修复](response-command-fix-2026-10-02.md)。
+
+## AI 设置
+
+刷新 [本机后台](http://127.0.0.1:3010/),登录后选择左侧“AI 设置”。原有配置会直接读取,无需重新输入已保存密钥。
+
+1. 填写模型提供方要求的完整请求地址、接口类型和模型名。例如 OpenAI 兼容服务通常需要完整的聊天请求路径;填写提供方的实际地址。密钥保持默认“保留”,替换时在后台输入新值,清除时选择并勾选确认。密码与密钥无需发到聊天中。
+2. 根据需要修改系统提示词、TopP 和最大输出长度,点击“备份并保存全局配置”。后续请求生效;普通聊天中已发出的请求继续使用旧快照,Agent 旧任务在后续请求/动作前检查配置版本并停止,重新创建实例后保留历史。
+3. 选择“指定群”或“指定私聊”,填写群号或用户 QQ,点击“查看会话参数”。聊天触发概率、温度、Agent/语音/响应 @ 开关,与 GPT/群总结温度分别保存。群参数不改变插件开关或响应门控,仍须满足原权限和启用规则。
+4. 同时通过聊天指令修改设置后,重新加载即可看到最新值。遇到版本冲突先重新加载再核对;重新加载或切换群会提示未保存更改,确认后成功读取才替换表单;取消或读取失败保留编辑,详见 [AI 编辑保护](ai-drafts.md)。
+
+温度 0 使用原默认 70(实际 0.7);TopP 0 使用默认 0.9;最大长度 0 使用默认 4096。普通配置读取不返回密钥;旧地址中的认证信息隐藏显示,不修改就保留。每次实际修改先保存私有控制库快照,可能含模型密钥;备份失败会拒绝保存。
+
+`/gpt 问题` 可直接发送,或回复一条消息后发送 `/gpt 问题`。必须有空白边界;`/gptabc` 和普通句子提及不触发 GPT。GPT 与群总结共享每群/私聊 30 秒一次的额度,失败尝试也计入本次窗口。聊天、上下文、系统提示词和回复文字会发给所配置模型;群总结发送所选消息的昵称和文字(最多 1000 条)。参数和验证范围见 [AI 设置记录](ai-settings-2026-10-02.md)。
+
+主人可私聊发送 `/gpt 你好,请简短介绍自己` 验证实际回复;GPT/总结继续遵守 30 秒额度。Agent 需满足插件、响应及会话开关;普通成员的 Agent 权限表不允许发送私聊消息,主人可以,原群权限与 @ 条件未改变。
+
+## 运行日志
+
+刷新并重新登录后从左侧进入“运行日志”。选择日志级别、输入关键词并点击“应用筛选”;“暂停更新”停止读取,“恢复更新”继续。取消“自动滚动”可停留阅读,同时继续接收新记录。
+
+显示本次启动后接入的近期日志,最多 1000 条;重启后清空,长记录显示截断提示。暂停或断线期间超出保留范围会提示部分旧日志不可读。切换到其他页面或隐藏窗口后停止读取;不需要长期保持页面打开。
+
+认证信息、已配置密钥及收发消息正文隐藏显示;网页不读取历史 stderr 文件。日志中包含脚本或 HTML 时也只显示文字。不能读取配置时日志暂停提供并提示错误。完整范围及验证见 [日志查看记录](log-viewer-2026-10-02.md)。
+
+点击“导出筛选日志”下载 UTF-8 `.txt` 文件。先点击“应用筛选”,再导出;尚未应用的输入不会影响文件。暂停更新时也可导出。文件按时间顺序包含本次启动仍保留、符合条件的日志,最多 1000 条,不受页面首批 200 条限制。开头说明保留与匹配条数、旧记录是否已淘汰;正文里的换行写成 `\n`,长记录继续保留截断提示。
+
+导出时重新读取配置以保护最新密钥,配置读取失败则不提供文件。切页、隐藏窗口、退出、重新加载或改变已应用筛选会取消尚未完成的导出。实现与测试范围见 [日志导出记录](log-export-2026-10-02.md)。
+
+## 备份恢复操作补充(2026-10-02)
+
+后台“备份恢复”可分别保存和恢复完整启动 JSON、控制数据库、后台启用与地址设置。先预览范围,再勾选覆盖确认;恢复前自动保存当前状态。启动配置及后台地址需重启,控制层提交后立即刷新,但其他插件缓存可能也需要重启。网络中断时先检查当前配置和备份列表,不直接重复提交。
+
+控制库备份包含密钥及库内插件数据,恢复会覆盖整个库;QQ 登录、后台密码、媒体和其他数据库不包含在内。恢复后台选项后若启用状态或端口改变,下次启动应按恢复后的地址访问。详细限制和验证见 [备份恢复记录](backup-restore-2026-10-02.md),真实 AI 测试仍保留为 [待办](todo.md)。
+
+启用后台时,聊天管理指令的实际响应/沉默、封禁/解封及插件禁止/允许修改也会自动建立控制库快照;重复操作不创建新快照。群内响应/沉默仍需 @ 和原管理权限。若备份或保存失败,会回复“原状态保留”,修复本机目录或数据库问题后再操作;批量指令按账号分别保存,可能部分成功,应按报告核对。备份目录继续使用后台的私有目录,业务计数不逐次备份,详见 [控制策略备份记录](control-policy-backups-2026-10-02.md)。
+
+## 服务列表聊天记录
+
+`/服务列表` 改为发送合并聊天记录,每页最多 20 个已注册插件,包含名称、功能简介、当前群/私聊开关与注册用法。私聊直接发送,群内仍需 @ 机器人;英文 `/service_list` 保留。用 `/服务列表 2` 翻页,非法或越界页码有提示。每个节点最多 1200 个 Unicode 字符,过长用法保留开头并提示 `/用法插件名` 查看完整说明;原用法指令和后台“查看用法”保留。
+
+该命令只读,关闭的插件仍可查看;启用状态不代表所有成员都具有使用权限。记录发送者使用机器人账号,发送失败给出文字提示,不自动重发。用法来自注册说明,未提供时明确标注。用户已确认功能验收,说明见 [服务列表](service-list.md)。
+
+## 运行概览中的近期异常
+
+登录后台进入“运行概览”,查看当前连接与本次启动的近期异常。页面可见时每 10 秒刷新,也可点“刷新状态”;切页、隐藏或退出停止读取,回到页面重新获取。连接读取失败会清除旧在线账号显示,明确提示状态未取得。插件列表读取失败单独提示,不再使概览显示为连接异常。
+
+异常统计只涵盖有界日志缓冲中仍保留的 warning/error/fatal/panic;最新 5 条按记录顺序倒序显示,内容继续脱敏。历史警告可能已经解决,也可能只是素材摘要服务器不可达;结合记录时间和当前运行状态判断,不以数量当作未解决故障数。配置暂不可读时不展示异常正文,OneBot 状态仍独立显示。完整日志筛选与导出在“运行日志”,说明见 [运行概览与近期异常](overview-alerts.md)。
+
+## 清理旧备份
+
+在“备份恢复”填写每类保留数量和保留天数,点击“预览清理清单”。默认每类保留最近 10 份,同时保留近 7 天的所有有效备份;只有同时超出两项保护的备份才列入删除清单。核对范围、时间、数量和空间,另存需要长期保留的内容后,勾选永久删除确认并执行。清理无需重启,不改变当前配置和 AI 对话档案,不会定时自动执行。
+
+每批最多 100 份,预览 10 分钟有效;修改参数、切页、登录会话或有效备份清单变化后需要重新预览。损坏、无摘要、链接或多余文件的备份跳过。删除失败或请求中断时,先刷新检查实际状态;已删除的部分不能撤销,不要直接重试旧清单。范围和扫描限制见 [备份清理](backup-cleanup.md)。
+
+## 外观与小屏窗口
+
+登录页和后台顶栏均有“外观”选择,支持“跟随系统”“浅色”“深色”。默认跟随系统;明确选择浅色或深色后不再受系统变化影响。在相同站点的当前浏览器保存选择,重开页面或退出登录后仍保留,并同步到同站点的其他标签页。
+
+浏览器禁用本地存储时仍能切换,但刷新后恢复跟随系统。外观不写机器人配置,不影响密钥、后台密码或插件开关。同一台电脑的不同浏览器和不同后台端口各自保存。
+
+窄窗口下导航移到顶部并保留名称,表单与操作按钮按需换行;本机访问规则不变。测试范围和记录见 [主题与小屏验证](appearance-2026-10-03.md)。
+
+## Agent 连续对话
+
+Agent 开关按当前群或私聊分别保存。开启后,普通聊天触发优先使用 Agent;`/gpt` 和群聊总结继续走聊天接口,其他明确插件命令按原规则处理。Agent 没有产生可执行动作时可能回退普通聊天。模式切换、触发条件、权限与示例见 [使用指南](user-guide.md#普通聊天agent-与-gpt)。
+
+`/gpt` 与群聊总结的最终文字回复,200 个 Unicode 字符以内(含 200)直接发送普通消息,超过 200 字发送合并聊天记录。标题、标点、空格和换行计入长度,中文和 emoji 按 Unicode 字符计算;长回复保留全部内容,每个记录节点最多 1000 字。这是 QQ 显示方式的调整,不是对上下文记录或模型输出长度的限制。
+
+Agent 已取消固定对话/行动轮数限制,并将事件与动作结果保存至 `data/aichat/history/<机器人QQ>.sqlite`,首次事件时创建。修改人物设定和重启会保留历史;主人指令 `重置AI聊天Agent` 开启新上下文并保留旧档案。单次模型请求仍受上下文容量、输出设置和超时约束,重复动作会停止当前任务。对话档案不包含在后台配置备份中;文件备份和行为说明见 [Agent 连续对话记录](agent-history-2026-10-03.md)。
+
+## 隔离系统自测
+
+从仓库根目录执行 `CGO_ENABLED=0` 环境下的 `go run scripts/test-admin-system.go -mode restart`,可重复验证三类备份恢复后的实际 Windows 进程重启、后台端口/关闭、凭据保留和旧会话拒绝。脚本新建独立目录和模拟 OneBot,不把日常配置用于恢复演练,不要求 QQ 重新登录。
+
+`go run scripts/test-admin-system.go -mode performance` 顺序运行四组持续性能测量,每组默认预热一分钟、采样五分钟,总运行时间约 25 分钟。结果位于 `.local/admin-system/`,只测机器人进程和后台接口,不包含浏览器渲染、真实模型或 Windows 计划任务。执行方式、结果和限制见 [Windows 系统验收](system-acceptance-2026-10-02.md)。
+
+## AI 私聊参数
+
+后台 AI 设置支持指定群与私聊用户 QQ。同号群和私聊分别保存,与聊天配置指令共用控制数据;私聊无群总结,不改变 Agent 权限、插件开关或全局模型。填写的是与机器人聊天的用户账号;保存目标由已读取表单标题确定,改变选择后必须点击查看才能加载新目标。取消切换或读取失败保留原编辑和保存目标。完整步骤见 [AI 会话参数](ai-conversations.md)。
+
+## Windows 路径别名
+
+本机 C 盘 Documents 下 ChatGPT 父目录联接到 D 盘,项目两种路径实际相同。统一以 D 盘实际目录记录新服务,旧 C 盘入口和任务继续兼容;启停脚本解析父目录联接,再核对真实工作目录、脚本、程序和启动时间。启动复用同项目运行任务,停止只操作完整身份匹配的服务;不要删除配置或联接来消除路径差异。详见 [Windows 服务路径](windows-paths.md)。
+
+连接设置编辑保护:当前已加载连接决定保存目标,切换连接先确认未保存的地址/Token修改;主人账号属于整个配置,切换连接仍保留主人草稿。重新加载或退出确认所有编辑,成功读取才替换,失败保留原值与版本;保存/读取期间冻结表单。退出/会话过期/备份恢复成功清空编辑,旧请求不能回填新会话。Token 不显示在草稿提示,不写浏览器存储。步骤见 [连接编辑保护](connection-drafts.md)。
+
+异常日志入口:概览“查看异常日志”切到日志的警告与错误组合,清空旧关键词;组合含warning/error/fatal/panic。日志“只看异常”保留已应用关键词,清除筛选恢复全部;输入未提交时提示“尚未应用”,轮询和导出继续采用已应用条件。快速切换取消旧请求、重置游标,保留暂停/滚动设置;只是级别筛选,不推断问题已解决。见 [异常日志筛选](log-issues.md)。
+
+备份检索只查询已加载元数据,不读取或导出凭据内容。范围与校验状态可组合编号/日期关键词;旧版无摘要与损坏备份分别显示,后端恢复仍重新校验。过滤不更改当前恢复/清理确认目标,恢复预览编号可核对;条件跨页/刷新保留、退出清空,请求中禁用;刷新失败保留旧列表并标记。最新200份限制在计数说明中提示,见 [备份检索](backup-search.md)。
+
+备份恢复页默认每页20份,可选择10或50份,以“上一页”“下一页”浏览筛选后的记录;列表位于清理表单之前。改变筛选或份数回首页,刷新保留当前页并在结果减少时校正;切页保留,退出重置。分页仍只涵盖已加载的最新200份,不改变恢复/清理目标,打开恢复预览自动定位到编号与确认处,见 [备份分页](backup-pagination.md)。
+
+插件列表支持默认20个、可选10/50个分页;搜索和实际状态筛选覆盖全部已加载插件。翻页保留所有开关草稿,总数包含隐藏项;单项保存规则保持,读取成功换范围回首页,刷新/保存后校正页码。详见 [插件分页](plugin-pagination.md)。
+
+插件列表可勾选“只看未保存”,结合搜索/实际状态集中查看全部页草稿;“清除筛选”取消该限制且保留草稿。“放弃全部更改”确认后撤销当前范围全部未保存选择,包含隐藏项,只清页面内存,不发请求或改变已生效规则。详见 [待保存插件开关](plugin-pending.md)。
+
+AI设置各表单提供“放弃此表单更改”,确认后恢复该表单最近读取或保存成功的页面基线,保留其他表单草稿;全局恢复同时清空新密钥输入,动作回到保留。仅本页内存操作,不请求接口或回退已保存设置,见 [AI 按表单放弃更改](ai-discard.md)。
+
+连接设置可分别“放弃当前连接更改”和“放弃主人账号更改”,确认后恢复选中部分的读取/保存基线,保留另一部分草稿;连接撤销清空新Token并回到保留。仅页面操作,不写配置,待重启提示保持,见 [连接设置局部放弃](connection-discard.md)。
+
+运行日志支持按浏览器本地起止时间筛选,可留空一端;点击应用后查看与导出使用相同范围,倒置输入不能应用,见 [日志时间筛选](log-time-filter.md)。
+
+后台密码修改入口为“后台安全”,成功后所有后台会话需重新登录,不修改QQ/OneBot/模型密钥;完整步骤与异常处理见 [修改后台密码](admin-password.md)。
+
+恢复预览支持类别影响对比,显示当前/备份数量及是否变化,摘要不展示密钥或原始内容;实际恢复仍覆盖整个所选范围,见 [恢复预览影响对比](backup-impact.md)。
+
+连接设置新增正向OneBot连接的入口、Token与显式无Token、草稿/重复保护、备份和重启规则见 [新增正向连接](connection-add.md)。
+
+连接删除的目标预览、确认、备份与重启规则及最后一条保护见 [删除连接](connection-delete.md)。
+
+新增本机反向监听的方向、地址/Token要求与接入步骤见 [本机反向监听](reverse-connection.md)。
+
+连接设置的启动与已保存分类、数量和内容变化摘要见 [启动配置差异](startup-differences.md)。
+
+多项插件开关草稿的逐项保存、进度及停止规则见 [逐项保存全部插件草稿](plugin-save-all.md)。
+
+插件页全局与群响应状态、和插件开关的区别及原指令说明见 [响应状态说明](response-status.md)。
+
+插件注册用法复制/下载的范围、浏览器限制与会话清理见 [插件用法导出](plugin-usage-export.md)。
+
+AI全局配置的字段检查、遗留认证地址与未联调边界见 [AI 配置检查](ai-config-check.md)。
+
+日志保留快照与匹配/级别统计的边界见 [日志匹配统计](log-summary.md)。
+
+下载备份目录及当前读取快照边界见 [下载备份清单](backup-catalog-export.md)。
+
+预览截止时间、本机时钟估算及操作中等待原结果的边界见 [备份预览有效期](backup-preview-expiry.md)。
+
+## 日志快捷时间范围
+
+运行日志页可立即应用最近15分钟、1小时或24小时,或用“不限时间”移除起止限制。范围固定到本机点击时刻,沿用已应用级别/关键词;查看、统计与导出一致,暂停状态保持,仍受本次启动日志保留上限约束。详见 [日志快捷时间范围](log-quick-range.md)。
+
+插件列表支持默认顺序、名称、启用优先、禁用优先及未保存优先;排序覆盖全部匹配插件再分页,保留草稿,清除筛选保留排序,见 [插件列表排序](plugin-sort.md)。
+
+插件卡片支持“放弃此项更改”,确认后只恢复此插件的页面基线,保留其他页和隐藏草稿;不会撤销已保存的规则,见 [放弃单个插件草稿](plugin-discard-one.md)。
+
+备份恢复页可按时间从旧到新、大小从大到小或从小到大排序全部已加载匹配项;分页和下载清单沿用所选顺序,见 [备份列表排序](backup-sort.md)。
+
+备份恢复页提供已加载/匹配和固定范围的备份内容大小摘要,异常大小单列;不代表目录实际占用,见 [备份内容大小](backup-size-summary.md)。
+
+暂停日志更新时可点击“读取一次”,按已应用条件读取新日志和匹配统计,完成后仍保持暂停,见 [暂停时读取一次日志](log-read-once.md)。
+
+备份列表可按本地开始/结束日期组合筛选,结束日期包含完整当天;列表、大小摘要与清单采用同一组已应用日期,清理策略和恢复目标保持,见 [备份日期筛选](backup-date-filter.md)。
+
+插件列表可下载当前已加载全局/群范围的全部匹配状态清单,沿用筛选与排序,草稿只标记未保存;仅导出最近已知插件开关状态,不能导入或恢复,见 [下载插件状态清单](plugin-state-catalog.md)。
+
+运行日志每条记录可点击“复制本条”,只复制页面已显示的时间、级别与脱敏正文,剪贴板受限时选中正文手动复制,详见 [复制本条日志](log-copy-entry.md)。
+
+AI设置页会按表单汇总未保存字段名称和数量,密钥动作/输入/确认合并计数,不显示新旧值;范围以该表单成功加载目标为准,详见 [AI未保存字段摘要](ai-draft-fields.md)。
+
+连接设置顶部现提供[未保存字段摘要](connection-draft-fields.md),分别核对实际加载连接、主人和新增表单的改动字段名称与数量;不显示字段值,保存与重启仍按原规则执行。
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
diff --git a/docs/local-admin/overview-alerts.md b/docs/local-admin/overview-alerts.md
new file mode 100644
index 0000000000..3551f9b052
--- /dev/null
+++ b/docs/local-admin/overview-alerts.md
@@ -0,0 +1,26 @@
+# 运行概览与近期异常
+
+登录本机后台后进入“运行概览”,查看当前 OneBot 连接、账号、运行时间及近期异常。账号来自当前连接注册表,只表示 OneBot 已接入;QQ 登录由 NapCat 管理。
+
+概览显示本次启动仍保留日志中的警告和错误数量,并列出最新 5 条 warning/error/fatal/panic 记录,先显示最近一条。日志继续脱敏,按文字显示;不包括重启前或已被淘汰的记录。出现记录不代表故障仍未解决,应结合时间、当前连接和“运行日志”判断。
+
+概览可手动刷新,停留在可见概览页时每 10 秒更新;切页、隐藏或退出后取消读取,回来重新获取。读取失败会明确标记状态过期,账号区不继续显示为在线。异常日志读取失败不会把正常 OneBot 连接误报为断开。
+
+功能只读,不发送 QQ 消息、不调用模型、不修改配置,无需设置新的密钥。详细排查可进入“运行日志”筛选并导出;本机素材校验服务器不可达的警告可能继续存在,应以程序是否正常初始化为准。
+
+插件列表读取失败单独提示,不会改变概览的连接状态。概览刷新只更新概览;插件开关通过插件页的“重新加载状态”取得当前网页/聊天设置,不在概览轮询时重绘插件编辑表单。
+
+## 验证与部署
+
+关联 REQ-03/09/10/13、DEV-06/09/11、TC-33。
+
+- 后端两组新增测试及既有断线测试通过:等级统计、记录倒序、最新 5 条、旧记录淘汰、当前密钥再脱敏、认证/退出、不缓存,以及配置数据库关闭后拒绝异常正文而保持独立连接状态。
+- 五项概览离线 DOM 测试与既有 17 项备份/日志/主题测试通过;覆盖真实应用脚本登录、插件读取故障隔离、定时/手动请求不重叠、切页/隐藏/退出取消、旧响应拒绝和旧 401 正文不能影响新登录。
+- 56 项独立离线 Chrome 布局检查通过,已核对 375px 深浅主题截图;这属于合成数据布局检查,日常页面人工验收另记。
+- `CGO_ENABLED=0` 下 `go test ./internal/localadmin ./plugin/llm`、后台模块和整机脚本 `go vet`、最终 Windows 构建通过。
+- 12:07:12 完整 Windows 隔离程序回归通过,新概览摘要有界、等级/顺序正确、模型设置后的摘要继续脱敏,原后台/备份清理/群 @/插件开关/日志导出/GPT 200 字边界通过。证据 `.local/smoke-admin/run-1270077985/result.json`,`overviewAlertsPassed=true`;未发送真实 QQ 测试消息。
+- 12:08:21 升级前快照后本机部署通过:后台 HTTP 200,新卡片/脚本可用,未登录概览接口 401;正确账号登录、三个 OneBot 只读 API、实际握手正常,无启动 error/fatal/panic。启动 JSON 与控制库摘要未变,人物设定、密钥、群参数保留;仅重启 ZeroBot,NapCat 登录保留。本轮未执行备份清理。
+
+部署证据 `.local/overview-alerts-deployment.json`;升级前控制库快照 `20261003T040802.125032500Z-bf3d12bdfd6c3274-control`;旧程序 `.local/bin/zerobot-before-overview-alerts-20261003-120752.exe`。最终程序 SHA256:`C234119C6A74E8201A9CFF888B6BF6087E915B23FDA00A1920B4F8CD831E389A`。
+
+已有素材摘要服务器不可达警告仍存在,未影响本机程序初始化。用户在本轮服务列表修改时确认上一轮新增功能验收通过,概览增强不再单独列为待验收;其他页面人工与真实 QQ 的完整 AI 业务继续见 [待办](todo.md)。
diff --git a/docs/local-admin/plugin-discard-one.md b/docs/local-admin/plugin-discard-one.md
new file mode 100644
index 0000000000..56e7d4e930
--- /dev/null
+++ b/docs/local-admin/plugin-discard-one.md
@@ -0,0 +1,15 @@
+# 放弃单个插件的未保存更改
+
+登录本机后台进入“插件列表”。编辑某个插件开关后,其卡片底部的“放弃此项更改”可用;点击后核对确认框中的插件名称与已读取的全局/群范围,再确认放弃。
+
+确认后,只恢复该插件最近一次成功读取或保存的开关选择,清除该项“未保存”标记。其他插件的草稿(包括其他页和筛选隐藏项)、已保存的开关、实际状态和版本保持不变;不会发请求、写配置或创建备份。取消确认则保留原编辑。
+
+搜索、状态筛选、只看未保存、排序和每页个数保持。放弃后会重新筛选、排序及校正页码:只看未保存时该卡片消失,未保存优先排序时卡片可能移动,最后一页变空时回到有效页。用法窗口仍对应打开时的插件。
+
+无草稿时按钮禁用;当前插件没有可编辑状态时不显示此操作。读取/刷新、单项保存或逐项保存全部草稿期间不可放弃。确认期间若发生目标、范围、基线、草稿或登录会话变化,旧确认不再执行,需重新核对。退出或会话过期仍按原规则清空全部草稿。
+
+放弃恢复的是页面最近成功读取的值,不会重新查询机器人。如果保存曾提示版本冲突,放弃后仍需“刷新插件状态”核对最新值。已经保存成功的开关不能用此按钮撤销;需要重新选择并保存。界面只保留内存中的草稿,不写浏览器存储。
+
+验收:编辑两个插件但不保存,对其中一项先取消、再确认放弃,核对另一项草稿和实际启用状态保留;组合只看未保存与未保存优先排序,检查卡片位置、计数及分页。无需保存日常开关。实际页面体验待验收。
+
+自动化验证:新增5项离线Chrome场景通过;147项完整界面首轮146项通过,旧概览DOM模拟缺少querySelectorAll,补齐后该文件5项复测通过。最终目标名称保护另经5项专项复测通过。64项深浅主题/窄屏布局与截图、相关Go模块/静态检查及Windows构建通过;使用合成插件与模拟接口,未修改日常开关。实际页面体验另待用户验收。
diff --git a/docs/local-admin/plugin-drafts.md b/docs/local-admin/plugin-drafts.md
new file mode 100644
index 0000000000..c5d915c912
--- /dev/null
+++ b/docs/local-admin/plugin-drafts.md
@@ -0,0 +1,21 @@
+# 插件开关编辑保护
+
+登录本机后台,打开“插件列表”,读取全局或指定群的插件状态后,选择开关模式。尚未点击“保存开关”的选择属于草稿,不改变实际生效状态。
+
+页面显示当前范围和未保存插件数量,每张有改动的卡片显示“未保存”。改回读取时的模式会清除该插件的标记。搜索、状态筛选和切换后台页面保留草稿,包括筛选后暂时看不到的插件。
+
+“刷新插件状态”、读取其他群和退出登录会先提示丢弃未保存选择;取消不发出请求。确认刷新或切换后,只有成功读取才替换列表和清除草稿,失败保留原范围与选择。各插件可单独保存,也可 [逐项保存全部草稿](plugin-save-all.md),每项确认成功只清除对应草稿;冲突和写入失败保留选择,重新读取后再核对最新状态。
+
+保存或读取过程中禁用开关操作,退出提示等待。关闭或刷新浏览器时,请求未结束或有未保存选择会请求浏览器显示原生离开提醒;是否显示取决于浏览器和用户交互状态。
+
+草稿只存在页面内存中,不写浏览器存储。退出成功或会话过期后清空列表、群号及草稿;旧会话的迟到读取/保存结果不会影响新登录的页面。已提交的服务器写入不因客户端会话过期而撤销,应重新读取确认。
+
+权限、全局覆盖优先级、群内 @ 规则、生效方式和写前备份保持现有行为。使用后台需要本机后台登录;保存后立即影响后续消息。
+
+验收步骤:改动两个插件,筛选隐藏其中一个,再取消刷新/切群/退出,确认选择保留;改回原值确认标记减少;只保存一个确认另一个仍未保存;确认重新加载后核对实际状态。日常状态不用于故障演练,读取失败、冲突和旧会话竞态由隔离测试覆盖。
+
+自动验证:五项新增 Chrome 场景以及原 33 项界面行为通过;56 项深浅主题/320~1280px 布局通过,核对桌面与窄屏截图。Go 后台/基础指令/LLM/chat 模块与静态检查通过。测试仅使用合成数据及模拟接口,不算真实页面或 QQ 验收。首轮原断言定位修正、Node DOM 补充 window 事件;沙箱 ACL/回环限制导致的后端失败已在真实用户权限下复测通过,未放宽产品权限。
+
+Windows 完整程序回归在 18:28:16 通过,新资源和原有消息/控制/AI/日志/备份全部标志通过,结果 .local/smoke-admin/run-898629727/result.json。18:29:07 备份后正式部署通过:后台 200、新提示及控制器已加载、原接口认证/正确账号/OneBot/实际握手正常,程序与测试构建一致,无启动错误;启动 JSON 与控制库摘要不变、NapCat PID/启动时间保留,ZeroBot 单实例。部署证据 .local/plugin-drafts-deployment.json;升级前控制备份 20261008T102845.628521100Z-6e43267dfab65499-control,旧程序 <项目目录> 密钥/人物设定或发送真实 QQ 测试消息。已知素材 MD5 服务器网络警告仍存在,本地素材可用。
+
+插件卡片支持“放弃此项更改”,确认后只恢复此插件的页面基线,保留其他页和隐藏草稿;不会撤销已保存的规则,见 [放弃单个插件草稿](plugin-discard-one.md)。
diff --git a/docs/local-admin/plugin-management-2026-10-02.md b/docs/local-admin/plugin-management-2026-10-02.md
new file mode 100644
index 0000000000..69d4c3ad68
--- /dev/null
+++ b/docs/local-admin/plugin-management-2026-10-02.md
@@ -0,0 +1,72 @@
+# 插件管理与 SQLite 写前快照
+
+## 行为与数据来源
+
+页面使用实际编译注册的 108 个插件及同一个 `data/control/plugins.db`。查看全局或手工填写一个正整数群号;API 群号以字符串传递,避免 JavaScript 大整数精度问题。只接受注册插件,拒绝负群号、非法模式、缺失版本、未登录或无有效 CSRF 的写请求。
+
+最终规则为:显式全局覆盖 → 此群显式设置 → 当前默认值。全局覆盖也作用于私聊;群设置不会泄漏到其他群或私聊。卡片分别展示最终状态、来源、默认值及此范围保存的选择;存在全局覆盖时会说明群选择暂不决定生效结果。
+
+保存后立即更新运行规则并持久化;网页和既有聊天开关/还原指令进入同一控制写入口。恢复继承会保留插件原有 62 位群参数及默认翻转标志,不删整个数据行。旧行没有继承标记时仍按原有显式状态读取。新增 `__switch_inherit` 表记录显式继承,无需迁移所有旧数据。
+
+网页不构造聊天事件或发送 QQ 通知;需要聊天上下文的 OnEnable/OnDisable 回复回调仍由聊天指令触发。插件开关不是进程启停,也不能替代群沉默、黑名单及用户封禁规则。
+
+前端支持搜索与查看用法;自动刷新保留未保存选择,手工刷新状态或切换范围会放弃未保存选择。旧读版本提交返回 409,写入/备份失败返回 500 并保留原开关,不提示成功。异常提示不返回数据库路径或注入的底层错误。
+
+## 控制补丁与备份
+
+本地复制锁定版本 zbpctrl、sqlite、zbputils 源码,由根 go.mod replace 引入,保留许可证且不修改全局模块缓存。详见 `third_party/plugin-control-patches.md`。
+
+- 单个 Manager 锁覆盖读取、写前快照和事务写入。运行开关缓存只在成功提交后失效,解决旧读覆盖新缓存的窗口。
+- 控制写入与继承标记用一个 SQLite 事务提交;第二条语句失败时前一条也回滚。SetData 的读改写同样受锁保护,保留默认翻转标志。
+- 默认翻转正确保存高位标志,提交失败不改变运行默认值;数据库重开后仍可恢复。上游原有全局缺失缓存标记不再被当作禁用。
+- 后台启用时安装 BeforeSwitch,网页与聊天开关/默认翻转均先快照;后台关闭时不安装备份钩子。批量聊天命令逐插件执行,失败报告未全部完成,不声称整批原子化。
+
+备份目录 `.local/admin/backups/-<随机标识>-control/` 包含:
+
+| 文件 | 内容 |
+| --- | --- |
+| plugins.sqlite | SQLite VACUUM INTO 一致性快照,包含已提交 WAL 中的数据 |
+| manifest.json | 版本 1、时间、范围 control-db、文件名和 SHA256 |
+
+备份在当前 Windows 用户及 SYSTEM 受限目录中创建,目标空文件先设权限再让 SQLite 写入;完成后同步文件并写清单。快照语句有五秒期限。失败时开关不继续写入,不完整目录保留供诊断,没有恢复接口会读取它。无实际开关变化不重复创建备份。
+
+快照覆盖整个控制库,可能含模型密钥等扩展配置,不公开网页下载、不进入 Git。AI 扩展、黑名单、沉默等其他写入尚未接入自动备份;聊天记录、媒体及全部插件业务数据库不在此范围。无完整备份列表、恢复、清理或跨 JSON/SQLite 的恢复事务。
+
+## 验证
+
+环境:Windows amd64、Go 1.26.1、CGO=0。针对六组新用例运行根模块测试,避免单独子模块使用不同依赖版本:
+
+```powershell
+$env:CGO_ENABLED = '0'
+go test ./internal/localadmin ./internal/basiccommands -count=1 -timeout=60s
+go vet ./internal/localadmin github.com/FloatTech/zbpctrl github.com/FloatTech/sqlite github.com/FloatTech/zbputils/control
+node --check internal/localadmin/assets/app.js
+go build -trimpath -o .local/bin/zerobot-next.exe .
+go run scripts/smoke-local-admin.go -binary-name zerobot-next.exe
+```
+
+以上均通过。`internal/localadmin/plugins_test.go` 覆盖:
+
+| 检查 | 结果 |
+| --- | --- |
+| 两种默认值 × 三种全局 × 三种群设置;群/私聊隔离 | 通过 |
+| 继承保留参数、SetData 不破坏继承或默认翻转、数据库重开保留 | 通过 |
+| 旧数据行兼容;第二 SQL 语句故障后回滚,缓存不变 | 通过 |
+| 八个旧版本并发提交仅一成功;聊天更新使网页版本失效 | 通过 |
+| 快照包含 WAL 中修改前值、清单及无修改不重复备份;备份失败不改开关 | 通过 |
+| 认证、CSRF、插件/群号/版本校验、冲突、故障错误脱敏 | 通过 |
+
+完整 Windows 程序隔离模拟完成于 03:33:28(Asia/Shanghai),目录 `.local/smoke-admin/run-122562204/`,汇总 `.local/smoke-admin/latest.json`。使用独立配置、合成账号和模拟 OneBot,没有发送真实 QQ 消息:
+
+- 网页全局禁用 chrev 后,模拟“翻转 abc”不回复。
+- 非主人执行全局启用不改变状态;主人指令启用后,API 显示全局启用,并回复正确翻转结果。
+- 全局优先、恢复继承后群设置生效、其他群维持默认均通过。
+- 原有后台认证、真实注册列表、连接保存备份、help/换行 help 和服务图片回归通过。
+
+数据库重新打开后的持久化在模块测试中验证;尚未在完整程序中修改开关再重启,也未进行真实群联调、完整人工页面操作或全仓 race/持续性能测试。不能以 API 测试替代这些验收。
+
+## 后续人工验证与修复
+
+用户现已确认后台可以正常启停插件,真实日志记录测试消息及“早安”回复。用户同时发现群 `/响应` 无回复;原规则要求 @,随后私聊全局响应则可成功。已修复当前群响应/沉默定向条件,保留权限,并完成完整程序模拟与本机升级。以上正文中“人工操作待执行”描述的是首次部署时的状态;当前真实重发确认与完整验收边界见 [群响应修复](response-command-fix-2026-10-02.md)。
+
+后续用户要求恢复 @ 才触发,群响应/沉默现恢复原 OnlyToMe 条件;当前操作方法以 [运行说明](operations.md) 为准。此前免 @ 验证仅作历史记录。
diff --git a/docs/local-admin/plugin-pagination.md b/docs/local-admin/plugin-pagination.md
new file mode 100644
index 0000000000..cc7b0fe175
--- /dev/null
+++ b/docs/local-admin/plugin-pagination.md
@@ -0,0 +1,13 @@
+# 插件列表分页
+
+登录本机后台进入“插件列表”。搜索和生效状态筛选下方提供“上一页”“下一页”与“每页个数”,默认20个,可选10、20或50个;提示显示当前页、总页数与匹配记录范围。搜索名称/功能/用法、状态筛选先覆盖全部已加载插件,再按所选排序排列后分页;默认保持原注册列表顺序,其他方式见 [插件列表排序](plugin-sort.md)。
+
+改变搜索、状态筛选或每页个数、清除筛选时回到第一页。切换全局/群范围成功后回第一页,读取失败保留旧范围、页码与草稿。点击“刷新插件状态”保留当前页;刷新或保存导致匹配结果减少、当前页超出范围时,页码校正到有效的最后一页。切换后台页面保留页码和每页个数;浏览器重载、退出或会话过期恢复第一页/20个。
+
+翻页、筛选与切换每页个数只改变显示,不发送请求,不保存开关,也不丢弃草稿。顶部未保存数量涵盖全部页和隐藏的插件;返回原页仍可看到选择和“未保存”标记。每个插件仍单独点击“保存开关”,成功只清对应草稿,失败保留。保存后按实际生效状态重新筛选,草稿本身不会改变实际状态计数。
+
+读取范围、刷新或保存期间禁用分页;退出或过期清空草稿和已加载插件,旧请求不能回填或解锁新会话。刷新插件状态、切群、退出仍按原规则确认丢弃草稿。用法弹窗内容始终对应打开时的插件,关闭弹窗后可继续翻页。
+
+仅显示本程序真实注册、已编译的插件,权限与全局/群继承规则不变,不进行安装或批量启停。页码与每页个数只保存在当前页面内存,不写浏览器存储,不改变QQ、OneBot、AI设定或对话记录。
+
+验收:在108个插件中翻页并切换份数;先编辑一个开关,不保存,翻到其他页再返回,核对草稿仍在;输入搜索/状态条件确认回首页;打开用法核对标题与内容,关闭后继续翻页。不需要为验收修改日常开关,保存、失败和过期场景使用隔离测试覆盖。
diff --git a/docs/local-admin/plugin-pending.md b/docs/local-admin/plugin-pending.md
new file mode 100644
index 0000000000..b401b36b87
--- /dev/null
+++ b/docs/local-admin/plugin-pending.md
@@ -0,0 +1,11 @@
+# 待保存插件开关
+
+登录本机后台进入“插件列表”,在未保存提示下勾选“只看未保存”,集中查看当前全局或指定群范围的开关草稿。仍按搜索名称/功能/用法与实际状态筛选,再分页;条件变化回到第一页。“清除筛选”同时取消只看未保存,草稿仍保留。筛选不会发请求或保存开关,实际启用/禁用统计不把草稿视作已生效。
+
+顶部未保存数量包含所有页及当前条件隐藏的草稿,可继续单项保存,也可使用 [逐项保存全部插件草稿](plugin-save-all.md),确认后包含隐藏项逐个保存;遇错或手动停止时保留余下草稿。保存成功只清对应草稿并重新筛选;保存失败保留选择与版本。将开关改回读取时的值会取消该草稿,在只看未保存时该卡片消失;结果减少后页码自动校正。没有草稿与有草稿但条件无匹配分别提示。
+
+“放弃全部更改”撤销当前范围内所有未保存开关选择,包括隐藏的草稿。点击后先确认数量,取消则原样保留;确认后恢复已读取的选择,不发送接口、不修改已保存的实际规则,也不创建备份。没有草稿或请求中按钮禁用。若此前出现版本冲突,放弃只恢复最近读取的页面状态,需要“刷新插件状态”重新读取最新规则。已经保存成功的开关无法通过此按钮撤销;若需要修改已生效规则,请重新选择并保存。
+
+读取、刷新或保存期间禁用这两个新入口;原刷新/换群/退出的草稿确认不变。只看未保存跨后台页面保留,退出/会话过期或浏览器重载后取消,旧请求不能改变新会话;草稿只保留在页面内存。此功能不改变全局/群优先级、权限、QQ登录、模型设定或对话记录。
+
+验收:任选两项修改但不保存,勾选只看未保存核对数量与卡片;改变搜索确认草稿不丢失;改回一项原值确认该卡片消失;点击放弃全部更改先取消再确认,核对已生效标记保持。无需保存日常开关,成功保存、冲突、忙时与会话场景用隔离测试覆盖。
diff --git a/docs/local-admin/plugin-save-all.md b/docs/local-admin/plugin-save-all.md
new file mode 100644
index 0000000000..b0d1278bdf
--- /dev/null
+++ b/docs/local-admin/plugin-save-all.md
@@ -0,0 +1,15 @@
+# 逐项保存全部插件草稿
+
+登录本机后台,进入“插件列表”,先选择全局或指定群并读取。修改多个开关后,点击“逐项保存全部草稿”,确认当前已读取范围、总数量以及包含其他页和筛选隐藏项。按当前插件列表顺序提交全部待保存选择。显式全局设置覆盖所有群与私聊;继承模式沿用原默认/群级优先规则,群设置仍受显式全局优先级约束。成功后规则立即更新,不需要重启。
+
+此入口逐项调用原保存接口,每个实际变更沿用自己的版本检查和写前备份,不是一次事务,也不统一回滚。每项确认成功才清除对应草稿,并更新实际状态与进度;遇到首次冲突、接口/网络失败或无法核对的返回结果就停止,不发送剩余项。已确认成功的规则保持,失败/未尝试草稿保留。单项请求设定30秒超时,超时会中断等待并停止余下项。网络失败或超时不推断该项未写入,也不自动重试;先记录余下选择并刷新状态核对,版本冲突按原规则处理。
+
+保存中禁用范围切换、刷新、单项保存、草稿更改、分页与再次批量操作;搜索仍可查看,不改变已确认的保存目标。可切换后台页面,保存继续运行。点击“停止后续保存”后等待当前请求结束,停止尚未提交的项,不取消或撤销当前项;若当前项是最后一项并全部成功,显示全部完成。已保存的开关不能通过“放弃全部更改”撤销。
+
+没有草稿、未登录或控制不可用时不能启动。确认期间范围、草稿或会话变化不得提交旧确认;退出/会话过期清空批次和状态,迟到响应不能更新新会话或继续发请求。关闭浏览器或页面刷新不会自动续跑;离开提醒受浏览器限制,已提交项需要重新登录后核对。草稿和执行进度只在当前页面内存,不写浏览器存储。
+
+验收可先修改两项但不保存,核对按钮、确认范围/数量与隐藏项说明,然后取消并放弃草稿;无需改变日常开关。完整执行、失败、停止与会话场景由隔离 Chrome 和临时 Windows 程序验证。真实 QQ 完整业务保持原待办。
+
+自动化用例:scripts/test-plugin-save-all-ui.mjs 新增6项隔离 Chrome 场景,覆盖全局/群、隐藏项、逐项确认、失败、停止、超时和旧会话;全部界面回归共108项及64项布局通过,模块测试与静态检查通过。实际 Windows 接口逐项保存与备份、冲突后部分结果和无变化不备份由 scripts/smoke-local-admin.go 验证,15:24:25完整 Windows 回归通过,15:26:18本机部署通过,原配置/开关/后台凭据和 NapCat 登录保持。结果与部署证据见 [自测记录](self-test.md)。
+
+插件列表可下载当前已加载全局/群范围的全部匹配状态清单,沿用筛选与排序,草稿只标记未保存;仅导出最近已知插件开关状态,不能导入或恢复,见 [下载插件状态清单](plugin-state-catalog.md)。
diff --git a/docs/local-admin/plugin-search.md b/docs/local-admin/plugin-search.md
new file mode 100644
index 0000000000..8a5c9b58fe
--- /dev/null
+++ b/docs/local-admin/plugin-search.md
@@ -0,0 +1,25 @@
+# 插件搜索与状态筛选
+
+登录本机后台,在“插件管理”先选择全局或指定群范围。搜索框匹配插件名称、功能简介和注册用法,例如输入 `/gpt` 可查找包含该指令说明的插件;搜索忽略大小写与首尾空白,按连续文字匹配。
+
+“生效状态”可以选择全部、启用、禁用或状态未知,与搜索同时生效。状态取自当前范围最后一次成功读取或保存的实际结果,遵守全局覆盖、群设置、默认值顺序;未取得控制状态时归入未知,不把启动默认值当成实际状态。计数展示匹配数量、注册总数和当前范围的启用/禁用/未知数量。
+
+搜索和筛选只调整显示,不保存开关。切换筛选、清除筛选后,已选择但未保存的开关仍保留;只有点击该插件的“保存开关”才写入并备份。保存后以新实际状态重新筛选,因此卡片可能从当前列表消失。“清除筛选”只清空搜索并切回全部;刷新或切换范围先确认丢弃,取消/读取失败保留原选择;成功读取才清除,详见 [插件编辑保护](plugin-drafts.md)。
+
+无匹配时调整关键词或清除筛选。当前程序未注册插件时明确显示空列表。筛选不增加插件,不改变命令权限、群响应或 AI 模式;用法通过原“查看用法”入口完整查看。退出后台会清空搜索和筛选。
+
+## 自测结果
+
+四项真实 Chrome 离线行为测试通过:名称/简介/用法检索与组合筛选;草稿保留、实际状态计数和保存重筛;群范围状态与全局优先规则;未知/空列表、文字安全与退出重置。全部使用合成数据,阻止网络访问,不读取实际后台密码或登录会话。入口为 scripts/test-plugin-search-ui.mjs。
+
+既有概览、备份、日志和主题 22 项行为回归通过;56 项 320/375/640/1280 像素、深浅主题独立布局无横向溢出,并人工核对 375 深色与 1280 浅色插件页截图。布局证据为 .local/appearance-layout/result.json。Go 后台/基础指令/LLM 模块、静态检查和 Windows CGO=0 构建通过。
+
+测试程序 SHA256:BDEBEB88A4F0E9E53EAE137E12E422011C15B66397C3B80FB58A7AF34AE85591。
+
+## 本机部署
+
+刷新后台后,在插件管理搜索 /gpt,再组合启用/禁用条件,并核对当前范围的计数;切换筛选和清除后检查未保存开关选择仍保留。用户已确认该功能正常并验收。
+
+搜索结果支持按名称、实际状态或未保存优先排序后分页,详见 [插件列表排序](plugin-sort.md)。
+
+插件列表可下载当前已加载全局/群范围的全部匹配状态清单,沿用筛选与排序,草稿只标记未保存;仅导出最近已知插件开关状态,不能导入或恢复,见 [下载插件状态清单](plugin-state-catalog.md)。
diff --git a/docs/local-admin/plugin-sort.md b/docs/local-admin/plugin-sort.md
new file mode 100644
index 0000000000..2be4ee59e1
--- /dev/null
+++ b/docs/local-admin/plugin-sort.md
@@ -0,0 +1,20 @@
+# 插件列表排序
+
+登录本机后台,进入“插件列表”,在搜索栏的“排序”下拉框选择:
+
+- 默认顺序:最近读取的插件列表顺序。
+- 名称:按中文语言环境比较名称,数字按数值排列,例如 plugin-2 在 plugin-10 前;比较相同的名称保留默认顺序。
+- 启用优先、禁用优先:按最近读取或成功保存后的实际生效状态分组,状态未知始终在最后;同组保持默认顺序。
+- 未保存优先:有开关草稿的插件先显示,同组保持默认顺序。草稿不算已生效状态。
+
+排序作用于全部已加载的匹配插件,再按每页个数分页;不会只重排当前页。更换排序回到第一页,搜索、状态筛选和“只看未保存”可组合使用。“清除筛选”保留排序;选择“默认顺序”可恢复列表原顺序。
+
+排序仅改变显示,不发送请求、不保存或放弃开关、不创建备份。所有页与隐藏项的草稿保留,用法窗口仍显示打开时的插件。修改、取消或保存草稿后重新排列,卡片可能移动到其他位置或页;顶部草稿数量仍包含隐藏项。单项保存仍对应所点击插件;“逐项保存全部草稿”仍按默认列表顺序提交全部草稿,显示排序不改变提交顺序。
+
+排序在切换后台页面、刷新插件状态或成功切换全局/群范围时保留;切换范围后采用新范围的实际状态。请求失败保留最近成功列表和草稿。读取、单项保存及逐项保存期间禁用排序。退出、会话过期或浏览器重载后恢复默认顺序,不写浏览器存储;旧响应不能覆盖新登录的排序和列表。
+
+验收:在多个分页间切换排序,核对已启用/禁用/未知顺序;编辑两项但不保存,选择“未保存优先”并组合搜索,核对草稿未丢失;清除筛选应保留排序,默认顺序应恢复原列表。无需为验收保存日常开关。实际页面体验待验收。
+
+自动化验证:新增5项离线Chrome场景与原137项界面共142项全部通过;64项深浅主题/窄屏布局及截图、相关Go模块测试、静态检查和Windows构建通过。使用合成插件及模拟API,验证自然排序、稳定分组、草稿/分页/范围/用法、单项保存目标、逐项保存原顺序、忙时与新旧会话隔离。实际页面体验另待用户验收。
+
+插件列表可下载当前已加载全局/群范围的全部匹配状态清单,沿用筛选与排序,草稿只标记未保存;仅导出最近已知插件开关状态,不能导入或恢复,见 [下载插件状态清单](plugin-state-catalog.md)。
diff --git a/docs/local-admin/plugin-state-catalog.md b/docs/local-admin/plugin-state-catalog.md
new file mode 100644
index 0000000000..18eedca66f
--- /dev/null
+++ b/docs/local-admin/plugin-state-catalog.md
@@ -0,0 +1,15 @@
+# 下载插件状态清单
+
+登录本机后台,进入“插件列表”,成功读取全局或指定群范围后,点击“下载插件状态清单”。可先按关键词、生效状态、只看未保存筛选并选择排序。清单包含全部已加载匹配插件,不局限于当前分页;空结果也可下载,文件按浏览器下载设置保存。
+
+导出UTF-8 JSON,记录实际已加载的全局/群范围、最近成功读取时间、最近成功保存开关时间、导出时间及加载/匹配/未保存数量。群号取自成功加载的范围;未提交的范围输入不会改变清单目标。文件标记是否使用最近读取失败前的结果,采用固定的筛选与排序字段;关键词只记录是否应用,不写搜索文字。
+
+每项只列插件注册名称、启动默认值、最近成功读取或保存后的默认值/全局与群设置/生效状态/来源,以及是否存在本地未保存更改。状态未知明确列为unknown或null,不以启动默认推断;不导出草稿选择、开关版本、配置扩展、模型密钥、Token、简介或注册用法。草稿标记不表示开关已生效。“当前生效”仅指插件开关,不保证群响应、权限、限速及其他命令条件通过。
+
+清单来自浏览器已加载状态,可能已被QQ指令或其他页面修改,不能当作服务端实时或所有插件一致时刻的快照。保存单项后只有对应项更新,最近保存时间不表示其他项重新读取。核对最新状态请先“刷新插件状态”;取消或读取失败保留原范围与草稿,清单仍使用原范围并标记读取失败。
+
+未登录、未成功读取、读取或保存处理中、切离插件页或隐藏页面时禁止下载;退出、会话过期或恢复配置后清空清单来源,须重新读取,迟到请求不能回填新会话。下载不发额外接口请求、不保存或放弃草稿、不改变当前分页和用法窗口、不创建备份、不调整插件开关或调用QQ/模型。提示“已发起下载”表示浏览器开始处理,不保证文件已保存;失败可检查下载设置后重试。
+
+文件仅供核对,不能用来导入或恢复开关配置。实际页面体验待验收;自动化使用合成插件和模拟接口,无需改变日常群开关或发送真实QQ消息。
+
+## 自动化验证与部署
diff --git a/docs/local-admin/plugin-usage-export.md b/docs/local-admin/plugin-usage-export.md
new file mode 100644
index 0000000000..34d0f49751
--- /dev/null
+++ b/docs/local-admin/plugin-usage-export.md
@@ -0,0 +1,13 @@
+# 插件用法复制与下载
+
+登录本机后台,在“插件列表”找到插件并点击“查看用法”。窗口中的“复制用法”和“下载文本”使用打开时的快照:复制为注册用法原文,下载附插件名称、简介和注册用法;搜索、翻页或开关草稿不改变导出内容。注册用法为空时显示缺失提示,两个按钮禁用。
+
+复制由点击按钮触发,浏览器须支持剪贴板写入。复制失败或不可用时,页面选中用法文本,按 Ctrl+C 手动复制(浏览器可能省略末尾空换行),或者下载文本;不会显示系统错误细节或反复申请权限。复制执行中禁止重复复制,关闭窗口或退出后不显示迟到结果;已经发起的剪贴板写入无法撤回。
+
+下载生成 UTF-8 文本文件,保留中文、换行和 emoji;文件名以 zerobot-plugin- 开头,清理非法文件名字符并限制长度。下载内容只包括打开时的插件名称、简介和注册用法,不包括群号、生效状态、Token、模型密钥、后台配置或聊天历史。它是注册说明原文,不是从代码推导的完整手册;说明缺失或过时需维护插件自身文档。
+
+仅当前已登录后台可使用,不调用新的服务端接口、不修改机器人规则或创建配置备份。关闭窗口、Escape、退出或会话过期清空内容与提示,重新打开其他插件采用新快照。下载及复制不会执行说明中的 QQ 指令。
+
+验收:选择有中文/多行说明的插件,复制后粘贴到自己的文本编辑器,或下载后打开核对名称、简介和用法。无说明插件的按钮应禁用;关闭再打开另一插件不残留前一个的提示。浏览器下载目录及剪贴板由浏览器管理,已下载文件不会随退出后台被删除。
+
+自动化验证:scripts/test-plugin-usage-ui.mjs 新增5项离线Chrome用例,包含实际Blob下载、Unicode/长文件名、原文复制及模拟剪贴板失败/重复/迟到结果、缺失用法、关闭/Escape/会话清理。5项与原112项共117项界面测试通过;64项页面布局及新增8项用法窗口布局(4宽度×2主题)通过,1280px浅色/375px深色截图已核对,相关Go模块与静态检查通过。19:04:33完整Windows隔离回归验证新资源与原功能通过,pluginUsageExportAssetsPassed=true;19:05:20本机部署后后台HTTP 200、正确账号OneBot正常,NapCat进程/登录保持、ZeroBot单实例正常、启动日志无错误,原配置/控制库/后台凭据不变。程序标志为嵌入资源检查,剪贴板行为以隔离浏览器模拟验证,不代替实际浏览器验收。证据 .local/smoke-admin/run-1923525186/result.json、.local/plugin-usage-deployment.json;详见 [自测记录](self-test.md)。
diff --git a/docs/local-admin/progress.md b/docs/local-admin/progress.md
new file mode 100644
index 0000000000..47e15a0ba3
--- /dev/null
+++ b/docs/local-admin/progress.md
@@ -0,0 +1,19 @@
+# 第一版开发进度
+
+版本:本地管理后台v0.1。完成日期:2026-10-13。
+
+| 范围 | 状态 | 实现与验证 |
+| --- | --- | --- |
+| 本机认证与访问校验 | 已完成 | 回环监听、首次设置、密码登录/修改、来源与CSRF校验、退出/重启会话失效 |
+| 连接配置 | 已完成 | 多连接增删改、主人账号、草稿保护、局部撤销与保存后待重启状态 |
+| 插件管理 | 已完成 | 注册插件、全局/群继承、实际状态、搜索/排序/分页、草稿与用法下载 |
+| AI配置与业务 | 已完成(本轮范围) | 共享配置、群/私聊参数、密钥保护、文字/识图/群语音、真实角色会话命令 |
+| Agent上下文 | 已完成(主人群场景) | 持久档案、实际查询与记忆、群发送、单次重启后话题延续 |
+| 日志与外观 | 已完成 | 脱敏、级别/关键词/时间筛选、暂停/清空/导出、深浅主题与小屏 |
+| 配置备份与恢复 | 已完成 | 写前备份、三类预览/取消/确认/恢复、恢复后重启与凭据保持 |
+| 维护与开发工具 | 已完成 | 模型/回复快照、资源观察、离线联合核对、独立Windows回归与源码构建 |
+| 文档与GitHub交付 | 交付中 | 公开使用/开发文档与中文PR模板,显式文件清单,私有中间记录整体忽略 |
+
+剩余非主人Agent真人测试、客户端体验和真实持续负载验收按使用者决定取消,不作为本轮交付条件,也不记为实测通过。已有隔离与真实通过范围保留,后续出现问题再安排优化。
+
+详细范围见[第一版说明](release-v0.1.md)、[自测](self-test.md)及[交付规则](github-delivery.md)。中间交流和本机调查保存在被忽略的docs/local-notes,不随源码公开。
diff --git a/docs/local-admin/qq-ai-voice.md b/docs/local-admin/qq-ai-voice.md
new file mode 100644
index 0000000000..99a5d1d603
--- /dev/null
+++ b/docs/local-admin/qq-ai-voice.md
@@ -0,0 +1,13 @@
+# QQ AI 语音命令
+
+## 配置与使用
+
+语音配置保留共享声音名称/ID及旧生成用群号,存于`data/airecord/recordconfig.db`,不在现有控制库备份范围内;维护时另做一致备份。主人可私聊使用`设置AI语音群号123456789`、`设置AI语音模型`(选择返回列表的序号)、`查看AI语音配置`。更换声音不改模型人物、DeepSeek配置或Agent记录。发送始终使用当前群,旧生成群号不再参与路由。
+
+在有效响应的群中发送`@机器人 发送AI语音语音调试成功`。群主、管理员或机器人主人可使用,普通成员群内不具备该显式命令权限。需启用airecord插件,机器人在当前群内且QQ声聊能力可用。
+
+本机NapCat v4.18.28的`get_ai_record`会直接向参数指定的群发送语音,再返回资源地址;它不是无副作用的生成接口。为避免群重复语音及私聊内容发到群,修复后群内仅调用一次`send_group_ai_record`,目标始终为当前群,不读取共享生成群号,也不再获取资源、转发语音或兜底重试。该版本成功回执的message_id固定为0,按status/retcode判断接口是否成功,不能以0误判失败。失败或回执不明给出提示,不重试。
+
+QQ内置AI语音在当前适配器中不提供安全的私聊生成接口。私聊发送显式命令会给出仅支持群聊的提示,不调用任何语音接口、不发送群消息。共享生成群号暂保留以兼容原配置,不用于本修复后的发送。空文字、缺声音、无效群在调用前提示。
+
+后台会话的“以AI语音输出”仍只作用于原普通AI聊天回复:群内采用相同的直接发送接口;私聊保留文字回复,不向生成群发送。Agent成功的文字动作和`/gpt`保持现有发送方式。本轮不新增语音输入识别,不自动把Agent回复改为语音。QQ语音和模型文字输出由不同服务处理。
diff --git a/docs/local-admin/qq-reply-evidence.md b/docs/local-admin/qq-reply-evidence.md
new file mode 100644
index 0000000000..a1ab9ac714
--- /dev/null
+++ b/docs/local-admin/qq-reply-evidence.md
@@ -0,0 +1,19 @@
+# 回复快照离线核对
+
+维护者从仓库运行`node scripts/check-reply-evidence.mjs --before 起始回复快照.json --after 结束回复快照.json --output 尚不存在的结果.json`,自动核对已下载的[回复统计快照](qq-reply-snapshot.md)。需要Node.js和资料读取权限,不需要后台密码或浏览器连接;读取后立即生效,无需构建或重启机器人。
+
+两份文件必须来自同一次统计起点,读取时间递增,累计次数和首末最长值不能倒退。工具验证固定三类、计数守恒、平均分母、UTC纳秒时间及可读取的进程字段;按固定字段复制输出,附加的聊天、账号、密钥、路径等内容不会复制。窗口新任务和结束任务允许不同,起点进行中的任务可以在窗口内结束;窗口内新增确认回执也不保证对应窗口内新发起任务。
+
+`window.durationNanoseconds`保留精确间隔的十进制字符串,`durationSeconds`用于显示。`snapshotProcessMatches`表示两份快照都提供了相同的固定进程字段;没有资源资料时`sameProcessVerified=false`、`compatible=false`和`temporalCoverage=unavailable`保持,不把文件内部身份一致冒充资源关联。`replyObservationPassed`对应当前输入模式的观察条件,业务验收始终另列为false。
+
+输出保留两份累计快照及窗口任务、发送回执的次数变化,不计算窗口平均/最长耗时,不把累计最长值相减。部分回复后失败的任务仍按原失败和有回复分母记录;零长度窗口仅用于诊断,不能算有效观察。统计起点或前后进程字段改变拒绝合并;缺少进程字段时仅核对数字与时间,明确标记关联未证明。
+
+可附加`--resource .local/live-observation/本次目录`同时核对已完成的资源观察。工具复用资源重算检查,判断回复读取窗口是否完整位于资源首末样本内,核对PID、操作系统创建时间和程序摘要;资料有效、资源检查通过、时间覆盖、进程关联分别给出结果。短资源窗口、时钟漂移、不同进程或时间不重合不能判为资料兼容。不自动补齐缺少的业务证据。
+
+退出0表示回复窗口可用于观察;附加资源时还要求完整资源检查、时间覆盖和精确进程关联均通过。退出2表示资料内部有效但观察条件不足,如零长度、缺少身份、短资源窗口或配对不兼容;退出1表示输入/配对关系无效、原始样本或摘要损坏、参数/文件/输出失败。`businessAcceptancePassed=false`始终保留,数字增长、发送确认和资料兼容都不证明QQ客户端收到、已读或真实持续业务验收完成。
+
+每份快照JSON限制1MiB,原资源文件沿用[资源核对工具](observation-evidence-check.md)的大小和样本限制。输出文件必须尚不存在,省略`--output`仅打印结果;不会覆盖输入或已有输出。工具不读取原始日志正文、不访问网络、不采样或控制服务、不发送QQ消息或调用模型。摘要用于识别本次读取的资料,不是来源签名。
+
+使用DEV-79独立Chrome实际下载的三份原件完成4组复核:零任务→3回复窗口为49.8898898秒(49889889800纳秒),新增/完成/确认均3,前后进行中和失败0;CLI退出0。3回复→重启零值拒绝合并、退出1;同一零值快照退出2;与DEV-78旧F86EA6的180样本/15分钟资源配对,资源内部检查通过,但时间不重合且PID/创建时间/程序摘要均不匹配,退出2。原三份快照和两份资源文件摘要保持,不重新下载或采样,不推算窗口平均/最长,不扩大为真实QQ业务。证据`.local/reply-evidence-actual-final-result.json`及其4份`-final-actual-*.json`核对结果。
+
+最终交付资料的11个核对标记通过:72/184测试结果、4组实际复核、5份原件、维护源码摘要、原共享核对工具、14份界面资源和日常服务保持,809处文档链接存在。结果`.local/reply-evidence-final-evidence.json`,源码摘要`.local/reply-evidence-tool-hashes.json`;交付核对只读已有资料,不重新测试或访问服务。
diff --git a/docs/local-admin/qq-reply-latency.md b/docs/local-admin/qq-reply-latency.md
new file mode 100644
index 0000000000..baa368eff2
--- /dev/null
+++ b/docs/local-admin/qq-reply-latency.md
@@ -0,0 +1,26 @@
+# AI回复处理耗时
+
+登录本机管理后台,在“运行概览”的“AI回复处理耗时”查看本次启动的GPT/群总结、普通聊天、Agent/识图任务数量与首条/最后一条回复耗时。沿用概览自动读取与刷新按钮;等待刷新时保留上次成功读取的值,切页、隐藏、退出或读取失败会清除旧显示,重新读取后更新。无需额外QQ指令,修改程序后仅重启ZeroBot生效;不改变原触发、权限、限速、内容或语音回退规则。
+
+计时从消息进入ZeroBot匹配流程开始,到当前群或私聊的实际回复发送调用返回。包含匹配流程中此前经过的检查、引用/历史读取、模型处理和发送等待;不含QQ客户端到接入程序、事件解析或框架队列之前的时间,也不证明用户收到或已读。没有匹配流程起点时从AI处理器入口计时,并单独增加“缺少匹配起点”计数,不能将它冒充完整起点。
+
+仅实际进入AI处理的群或私聊计为任务。未触发、插件关闭、配置过滤、不符合权限、GPT空问题或限速拒绝不计;GPT与群总结归为一类,Agent图片描述和回退普通聊天仍归本次Agent任务,不重复计数。非AI插件、频道、单独“发送AI语音”命令暂不在本项范围。
+
+结束任务分为:
+
+- **取得回复回执**:至少一条当前会话回复取得确认,且本任务没有记录处理或发送失败。
+- **失败**:模型/历史/记录处理失败,或某次当前会话发送回执未确认;即使其他回复成功,也保持失败。
+- **没有回复**:没有确认回复,也没有记录失败,例如模型输出为空或Agent只执行查询动作。
+- **进行中**:处理尚未返回,不计入已结束任务的平均耗时。
+
+首条/最后一条耗时的平均和最长值只统计“已经结束且至少有一条确认回复”的任务;包含部分回复后失败的任务,不能据这些平均数推断成功率。最后一条指任务内最后一条确认回复,不是处理器退出时刻。多段文本、合并转发及普通聊天的群AI语音按原方式发送,不额外发送测试消息;“少女思考中”、限速和错误提示不算实质AI回复。
+
+普通文本/合并转发要求接口`status=ok`、`retcode=0`及可解析的非零消息编号;负整数编号仍可用。QQ原生群AI语音成功时可能返回0编号,沿用已验收的`status=ok`和`retcode=0`确认口径。仅记录当前会话的发送,Agent向其他目标发送不算当前提问的回复;非发送动作不计回执。没有确认不代表一定没发出,统计不会自动重试。
+
+统计只保留固定三类累计数字和时间,不保存账号、群号、消息编号、正文、模型地址、密钥、逐条历史或请求参数。重启清零,没有清零接口或持久化记录。认证概览中的`replyTasks`提供同口径固定字段及可读取的进程信息;现有模型观察报告仍只包含模型传输统计,不自动加入本项。真实持续负载与QQ客户端端到端体验仍须另行验收。
+
+实际Chrome独立实例`run-3438327210`使用150毫秒本地模型延迟,3次模拟GPT均完成并取得确认,进行中/失败/无回复/缺起点均为0;平均首条及最后一条193.787毫秒、最长206.510毫秒,模型传输平均151.326毫秒,页面分别显示0.194/0.207秒,与源数据舍入一致。初次发送完成通知后即时快照仍有1项进行中,待发送回执和处理器结束再读后3项全部结束,未将即时状态误判为失败。12个Chrome核对标记及8种320/375/640/1280px×深浅布局通过,桌面/小屏截图已检查;切页清除及返回读取、重启清零和原测试密码通过。两次启动、1批3次模拟请求、正常停止与凭据保持正确,自建页已关闭,主题/视口已恢复。程序启动器不代替Chrome验收,其`browserAcceptancePassed=false`与独立页面证据区分。
+
+维护者可运行`go run scripts/start-model-observation-acceptance.go -binary-name zerobot.exe -model-delay 150ms -lifetime 20m`建立独立实例。`-model-delay`只延迟本地合成模型,允许0~2秒、默认0,不修改日常模型或QQ连接。`requests`发送一批3次模拟GPT,待任务结束后`status`保存模型与回复统计;`restart`检查新起点及零值,`stop`关闭实例。随机测试密码仅在该目录`login.txt`;不可使用此模拟实例证明真实QQ延迟。
+
+交付资料只读核对通过:234/27/64结果、137静态入口、770文档链接以及测试构建/部署/日常摘要一致;专项证据核对的14个标记通过。结果`.local/reply-latency-delivery-evidence.json`及`-final-evidence.json`。初次资料核对指出截图为JPEG却使用PNG扩展名、需求表缺使用链接,已修正扩展名和链接,保留`.local/reply-latency-initial-evidence-check.json`;没有修改图片像素或产品行为。
diff --git a/docs/local-admin/qq-reply-snapshot.md b/docs/local-admin/qq-reply-snapshot.md
new file mode 100644
index 0000000000..56b695977d
--- /dev/null
+++ b/docs/local-admin/qq-reply-snapshot.md
@@ -0,0 +1,15 @@
+# AI回复统计快照下载
+
+登录本机后台,在“运行概览”的“AI回复处理耗时”点击“下载回复统计”。先等待概览读取成功;读取中、统计缺失或无效、切页、隐藏标签和退出后不能下载旧快照。零任务仍可下载,表示本次启动尚无任务,不是业务验收成功。
+
+按钮保存页面最近成功读取的累计快照,不重新请求接口,不保存配置,不发QQ消息或调用模型。正常使用前后可分别下载,并核对统计起点和可读取的进程编号、操作系统创建时间及程序摘要;重启前后的资料不得合并。刷新状态后再下载可取得新快照,文件内`checkedAt`标明读取时间。
+
+文件格式为`zerobot-reply-statistics`版本1,文件名为`zerobot-reply-statistics-时间.json`。仅包含导出时间、固定范围说明及三类累计任务/发送确认/首末回复耗时、UTC时间和可读取的固定进程信息;不含正文、QQ账号、群号、消息编号、配置、程序路径或密钥。下载失败显示通用提示,可重新尝试;临时链接自动释放,连续点击在本次下载清理前被禁止。发起下载不代表浏览器已经保存文件。
+
+快照没有观察起点或窗口统计。平均/最长值属于本次ZeroBot启动累计,不能将两份累计最长值相减得到窗口最长耗时,也不能把任务开始差值当作同一批任务成功率。进行中任务尚未计入首末回复平均;部分回复后失败的已结束任务可以进入平均分母,完整口径见[AI回复处理耗时](qq-reply-latency.md)。
+
+固定范围声明为机器人匹配入口到当前会话发送回执,本次启动累计;业务验收字段始终为false。不能证明QQ客户端收到、已读或真实持续负载通过;现有[模型观察报告](model-observation-report.md)继续只记录模型HTTP传输,两种文件分开保存。本功能随新程序部署并仅重启ZeroBot生效,不改原AI触发或回复内容。
+
+构建程序的独立回环实例`run-1702618060`由独立无界面Chrome执行,未使用用户日常Chrome连接、会话或密码。仅使用本次随机测试密码,实际下载零任务2431字节、3次模拟回复2527字节、独立重启后零任务2430字节的三份文件,内容/来源/SHA/字段及后台值一致;3任务完成/确认3、进行中/失败/缺起点0,首末平均196.356毫秒、最长211.217毫秒。重启后PID/统计起点改变且累计清零;8种320/375/640/1280px×深浅布局及桌面/小屏截图检查通过。两次启动、1批3次模拟模型/QQ往返、原随机凭据保持,测试实例和浏览器均已关闭。启动器自身的`browserAcceptancePassed=false`不代替单独浏览器结果。
+
+交付只读核对通过:242/35/64测试结果、139静态入口、796文档链接及测试构建/部署/日常程序摘要一致;专项证据的10个核对标记通过,三份实际文件来源/字节/SHA保持。结果`.local/reply-snapshot-delivery-evidence.json`及`-final-evidence.json`;这些核对不重新执行测试或访问服务。
diff --git a/docs/local-admin/real-agent-acceptance.md b/docs/local-admin/real-agent-acceptance.md
new file mode 100644
index 0000000000..6aa99e5215
--- /dev/null
+++ b/docs/local-admin/real-agent-acceptance.md
@@ -0,0 +1,34 @@
+# 真实 QQ Agent 验证步骤
+
+本轮主人群实际查询、保存记忆、连续询问及单次ZeroBot重启后代号延续已通过,4个任务、6次成功动作响应与4条QQ回执匹配,用户确认。剩余非主人群主/成员/管理员Agent真人测试由用户取消,未执行、不记通过,也不自动要求继续测试;这些步骤作为以后重新提出需求时的参考。实际证据与恢复情况见[角色记录](ai-role-permissions.md)。
+
+## 入口与前置条件
+
+在响应开启、aichat/aichatcfg插件启用且当前群允许Agent的会话中,用QQ的实际@功能发送`@机器人 自然语言要求`。`/gpt`验证文字接口,不能作为Agent动作执行的证据。模型、人物设定和Agent开关由机器人主人管理,不为测试公开或更换密钥。
+
+开始前记录当前程序及进程、启动配置、群级参数、发言者实际角色和档案序号;验证其他角色时临时移除发言者的机器人主人资格,并使用有期限的自动恢复。群身份来自实时消息,设置或撤销管理员后须核对缓存。群主/管理员使用Agent的admin权限表,只有配置中的机器人主人使用owner权限表。
+
+先测试查询、当前群回复与记忆;无需执行禁言、踢人、退群或@全体来证明基本流程。每步等收到回复并核对记录后再继续,异常时保留失败现场。
+
+## 允许动作与上下文
+
+| 步骤 | 发送内容 | 核对 |
+| --- | --- | --- |
+| 查询与真实回复 | `@机器人 请实际查询本群信息,再把群名和成员人数回复到本群;不要猜测。` | 本次档案存在get_group_info请求及成功响应、当前群send_group_msg成功;群名/人数与只读平台查询一致 |
+| 记忆 | `@机器人 本轮Agent测试代号是银色松鼠,请记住并只回复“已记住”。` | 事件和实际回复进入当前群的持久档案;这条回复本身不证明下轮记忆成功 |
+| 连续对话 | `@机器人 刚才的Agent测试代号是什么?只回复代号。` | 真实回复为“银色松鼠”,本轮确有Agent处理,任务正确结束且无重复发送 |
+| 重启后的延续 | 记录前三步后,仅重启ZeroBot,再发送上一条询问 | 程序与配置保持、进程确实更新;当前群档案重新加载并回复同一代号,NapCat保持登录 |
+
+测试代号仅用于该轮验收,不按固定短语直接回显。不能用预存答案、模拟模型或普通聊天回退代替实际Agent结果。查询若未产生实际动作,保留为“收到文字,动作未验证”,不要标记通过。
+
+## 角色与边界
+
+在主人、非主人群主/管理员及普通成员身份下,分别用查询和当前群回复验证合法动作。非主人私聊的user白名单不含send_private_msg,沿用现有GPT与普通聊天行为,不把该动作拒绝记作故障。
+
+非主人可另提出查询另一授权测试群的要求:该角色只能查询当前群,不应执行跨群动作。只读核对是否实际产生越界接口调用;模型自行拒绝只能证明本次没有执行,不能当作程序拦截分支已被触发。越群、非法编号和@全体两种格式的强制输出边界继续保留既有隔离回归证据,不为完成真实测试执行有影响的群管理动作。
+
+## 证据与恢复
+
+本机档案`data/aichat/history/<机器人QQ>.sqlite`记录event/request/response/end。只读核对本轮测试之后的序号、动作名、目标是否为当前群、成功状态和专用消息编号,不导出其他聊天正文。档案中的动作尝试还须结合实际成功响应与QQ历史回执判断,不能把模型输出当作平台执行成功。
+
+最后恢复原主人、群身份和测试前参数,核对单实例、QQ在线、程序与完整配置摘要。真实模型、动作、任务和发送计数的口径不同,不要求数字机械相等。重启前后观察须分别保存,不能跨进程合并累计统计。连续真实业务负载另按[资源观察](live-performance-observation.md)和[联合核对](combined-observation.md),本流程不重开用户已关闭的客户端体验待办。
diff --git a/docs/local-admin/release-v0.1.md b/docs/local-admin/release-v0.1.md
new file mode 100644
index 0000000000..b94cdc37bb
--- /dev/null
+++ b/docs/local-admin/release-v0.1.md
@@ -0,0 +1,35 @@
+# 本地管理后台 v0.1
+
+日期:2026-10-13。这是本地管理后台的第一版范围,沿用ZeroBot-Plugin现有项目版本体系,不覆盖上游标签。
+
+## 功能
+
+- 本机回环访问、首次设置、密码登录与修改、退出和请求来源校验。
+- 运行概览、多条OneBot连接增删改、主人账号与保存后待重启状态。
+- 全局/群级插件开关、继承和实际生效原因,检索/排序/分页、草稿保护及用法下载。
+- 共享AI配置、群/私聊参数、密钥保护、GPT引用处理、Agent持久上下文与角色动作边界。
+- 脱敏日志、级别/关键词/起止时间筛选、暂停/清空/导出、深浅主题与小屏布局。
+- 写前备份、手动备份、三类恢复预览/取消/确认、备份整理及恢复后重启检查。
+- QQ文字/识图/群AI语音与原消息行为修复,模型/回复快照和资源观察工具。
+
+使用步骤见[使用指南](user-guide.md),安装与服务管理见[Windows环境](onebot-windows.md)及[运行维护](operations.md)。后台默认不对局域网或公网开放。
+
+## 验证
+
+现有界面/布局、实际Chrome页面流程、Windows隔离恢复/重启、模型与资源工具、真实QQ文字/图片/语音及角色会话命令验证保留各自范围。主人真实Agent查询本群、保存记忆、四条群回执和单次重启后的话题延续已通过。
+
+最终交付核对已通过:242项界面、210项观察工具、64项离线布局、14个相关Go包(13包含测试)及vet、五个本地补丁模块的相关检查、Windows隔离整机与三类恢复/实际重启、14项观察参数守卫及路径身份夹具。五个补丁模块原始许可证完整保留,上游无断言的故意失败演示TestMaru明确跳过,不计为功能测试通过。
+
+依据显式公开文件清单生成的独立源码副本可完成Windows构建,SHA-256与当前已验收运行程序一致;仅测试使用data子模块资源及经典诗歌缓存,未复制日常控制库、模型配置或凭据。公开文档链接、私有身份/运行资料排除及当前密钥内容检查通过。沙箱文件权限、测试夹具缺少词典及依赖模块单独开发配置问题已记录并处理,未改写初次失败为通过。原始私有资料保留本机;复验入口见[自测](self-test.md)。
+
+## 限制与取消范围
+
+剩余非主人Agent真人动作、QQ客户端体验及真实持续业务负载验收按使用者决定取消,不作为本轮交付条件,也不写成实测通过。已有模拟负载和20分钟资源窗口不证明长期无泄漏、并发峰值或所有模型/插件均正常;后续出现问题再优化。
+
+QQ内置AI语音当前走群接口,私聊不得向其他群生成语音;Agent的非主人动作限于白名单和当前群。后台配置备份不包含聊天档案、QQ登录、媒体及全部插件数据库。其他操作系统没有本轮完整系统验收。
+
+## 升级与交付
+
+先备份本机配置、后台目录及需要保留的聊天档案,再从源码构建。仅更换ZeroBot时停止该服务并重启,保留NapCat登录;重新登录后台并核对连接、账号与已保存设置。程序更新不自动更改现有模型、角色或密钥。
+
+GitHub交付使用中文提交与PR说明,目标为使用者自己的仓库。中间交流与本机调查统一保存在docs/local-notes并整体忽略;源码只包含实现与使用/开发资料,运行数据库、配置、日志、截图和原始聊天内容不上传。规则见[GitHub交付](github-delivery.md)。
diff --git a/docs/local-admin/requirements.md b/docs/local-admin/requirements.md
new file mode 100644
index 0000000000..6da333c504
--- /dev/null
+++ b/docs/local-admin/requirements.md
@@ -0,0 +1,133 @@
+# 需求分析表
+
+版本:本地管理后台v0.1。功能实现完成;验证与限制见第一版说明。
+
+版本:v0.1;更新日期:2026-10-13;状态:开发基线;后台基础、连接编辑、全局/群级插件开关、AI 设置、运行日志/导出、主题、小屏布局及写前备份已实施,完整 P0 验收尚未完成。
+
+## 1. 目标与边界
+
+已确认的产品方向是本地管理后台,只管理用户自己的机器人。第一版以本机 Windows 为首要验收环境,管理一个 ZeroBot-Plugin 进程;已有配置允许多条 OneBot 连接,页面不能在保存时误删其余连接。
+
+使用者是机器人的本机所有者。浏览器负责展示和操作,后端执行配置校验与插件控制,ZeroBot 承担消息处理,外部 OneBot 实现承担 QQ 登录和平台接入。
+
+第一版包括状态、连接配置、插件管理、AI 设置、日志和配置备份。插件页现提供全局/指定群响应规则只读说明,区分响应检查与插件开关;网页修改响应、私聊响应管理和封禁编辑不在本次范围。以下内容暂不纳入:公网/局域网访问、多租户、用户注册、计费、多进程调度、扫码登录管理、动态安装 Go 插件、自动升级、后台进程托管。机器人仍按现有方式启动;需要重启的设置由页面明确提示。
+
+代码备份分支与运行配置备份用途不同:分支保存代码;后台备份保存配置。聊天历史、媒体资源及插件全部数据库的整机备份不在第一版内,恢复界面必须列明覆盖范围。
+
+## 2. 需求与验收标准
+
+P0:第一版必须具备;P1:核心流程完成后补充,不阻塞最小可用版本。
+
+| 编号 | 功能 | 优先级 | 需求说明 | 验收标准 | 自测 |
+| --- | --- | --- | --- | --- | --- |
+| REQ-79 | 三类观察联合核对 | P1 | 一次判断模型、回复与资源是否可用于共同观察 | 复用单项校验,资源两次读取摘要一致、精确身份/覆盖及正长度窗口交集;各自有效但不重叠/仅接触不能联合,模型请求/任务/发送不强求等量、不推窗口耗时;固定字段/安全诊断/独占输出及退出码,业务始终未验收,不控制服务,见[使用](combined-observation.md) | TC-102 |
+| REQ-78 | 独立AI连续负载观察 | P1 | 自动生成当前构建的连续合成负载与可匹配资源/模型/回复资料 | 本次程序副本/回环模型/合成OneBot、输入与批次上限/进程守卫、资源原件及前后统计,完整窗口/节奏/阈值/计数及精确身份自动核对;短窗/停止不算正式,失败清理本实例,不接入或修改日常服务,不冒充真实负载或客户端体验,见[使用](isolated-ai-load.md) | TC-101 |
+| REQ-77 | 回复快照窗口与资源离线核对 | P1 | 将已保存回复资料自动转为可核对观察结果 | 固定三类/守恒/分母/UTC纳秒/精确进程、累计平均/最长一致性和窗口增量;不推算窗口耗时/同批成功率,跨起点任务可结束,重启拒绝合并;可选资源重算/时间覆盖/身份逐项匹配;大小/独占输出/秘密排除和固定退出码,业务始终未验收、不控制服务,见[使用](qq-reply-evidence.md) | TC-100 |
+| REQ-76 | AI回复统计快照下载 | P1 | 将任务/发送回执和首末回复计时保存为可核对资料 | 导出最近成功读取的累计快照,固定三类/数字/UTC时间/可读进程/范围声明,不含正文/账号/路径/配置;读取中/失效/退出禁止旧下载,复制源数据、重复点击保护/通用错误/临时链接释放及旧会话隔离;不新增业务/API读取、窗口耗时或业务通过结论,模型报告保持原口径,见[使用](qq-reply-snapshot.md) | TC-99 |
+| REQ-75 | 仅资源观察资料离线核对 | P1 | 没有模型报告时仍能核对日常资源观察 | 省略模型输入可单独重算原始样本/时长/节奏/阈值并拒绝摘要不一致;时钟偏差不当作通过,独占输出与秘密排除保持;新模式及退出码明确,关联/业务始终未证明;原双来源行为兼容,不登录/控制服务 | TC-98 |
+| REQ-74 | AI回复处理耗时 | P1 | 补齐HTTP统计之外的任务处理与实际发送回执观察 | 群/私聊从匹配入口到当前会话首末确认回执;固定GPT/聊天/Agent三类及守恒计数,失败/无回复/部分回复准确区分,Agent识图/回退仅一个任务,进度/错误提示排除;发送参数与结果不改、不重试;认证固定数字/时间/可读进程,不保存内容/身份,重启清零和旧显示隔离;不冒充QQ客户端收到或端到端体验 | TC-97 |
+| REQ-73 | 观察报告进程身份关联 | P1 | 防止把时间重合的不同程序/重启窗口当同一实例 | 启动一次读取OS PID/精确创建时间/程序摘要,认证快照和版本2固定字段下载;前后身份变化清除起点,读取失败/非Windows保留版本1;离线三项逐一核对,身份/资源完整窗口/业务分别判断,旧报告兼容 | TC-96 |
+| REQ-72 | 离线模型与资源观察证据核对 | P1 | 自动发现独立观察资料的损坏、摘要不一致和时间错配 | 原样本重算资源/节奏/阈值、模型版本/分类/计数/时间/窗口核对;固定字段及来源摘要,拒绝覆盖/超限;窗口兼容不当作同进程证明或真实业务验收,不访问网络或控制服务 | TC-95 |
+| REQ-71 | 模型请求观察报告 | P1 | 保存持续性能观察的前后模型快照与次数变化 | 当前页面起点、四类次数及跨窗口进行中守恒,固定字段JSON下载无正文/身份/配置;读取中/失败/隐藏禁用,旧会话/重启/倒序/计数回退拒绝,退出清除;累计耗时不冒充窗口最长或QQ端到端耗时 | TC-94 |
+| REQ-69 | 日常服务只读性能观察 | P1 | 补充真实运行期间资源与异常记录工具 | 核对托管PID/路径/启动时间;只读采样与追加日志计数、正文不落盘;单独产物/完整窗口与节奏/资源阈值/提前停止;身份变化不续接,工具不发QQ/模型请求或启停服务;观察通过不代替真实业务负载验收 | TC-92 |
+| REQ-70 | 模型接口请求统计 | P1 | 为真实负载记录补充可核对的请求数量与传输耗时 | 固定4类别、次数与平均/最长耗时,无请求正文/身份/地址/密钥历史;完整响应和提前关闭区分,并发与取消收尾只计一次;认证概览、旧状态清除、重启清零;HTTP完成不当作QQ回复成功,既有客户端行为保持 | TC-93 |
+| REQ-68 | 连接与主人草稿页面确认 | P1 | 补齐已有连接操作的可重复自动验证 | 局部放弃、切换、重新加载和退出使用页面弹窗;取消/Esc保留草稿且不发送该请求;局部范围准确,固定说明不含值;会话/设置/目标/草稿变化拒绝旧确认,重置关闭及双类确认互斥,迟到退出不影响新会话;保存/备份/重启规则保持 | TC-91 |
+| REQ-67 | 新增连接页面内确认 | P1 | 解决原生确认使Chrome自动验收焦点超时的问题 | 新增保存/放弃新增使用页面弹窗,默认取消焦点,取消/Esc无提交并保留草稿;只显示固定说明,原会话/设置/草稿及代次变化拒绝旧确认;重置取消、并发不替换确认,原备份/版本/重启边界保持;实际Chrome与回归验证 | TC-90 |
+| REQ-66 | 双账号连接隔离验收环境 | P0 | 补齐多连接页面修改与独立重启证据 | 复制当前程序与合成配置,两个不同模拟身份;仅通过页面修改;重启前限制自有端点/测试Token/主人/后台地址,原密码与旧会话检查、双连接往返及退出;如实标记工具受限用例,保持日常配置/进程 | TC-89 |
+| REQ-65 | AI下拉框主题与小屏显示 | P1 | 修复实际6个原生小控件遗漏统一样式的问题 | 3接口类型及3密钥操作全宽、至少44px高,640px及以下16px字体,深浅配色与面板一致;保留原选项/确认/保存方式;实际页面与布局回归 | TC-86 |
+| REQ-64 | 恢复后插件页面自动读取 | P0 | 修复实际Chrome验收发现的首次空列表 | 成功恢复清除旧状态/草稿,首次返回自动读取原范围与响应;保留筛选;加载锁定、错误重试;迟到回调隔离;普通导航无额外读取 | TC-84 |
+| REQ-63 | QQ内置语音发送副作用与回执修复 | P0 | 根据真实群三条语音纠正接口语义及模拟缺口 | 群只调用一次直接发送并使用当前群;私聊不调用语音接口;成功零编号按status/retcode处理;无资源转发/兜底/重试;普通AI私聊保持文字;隔离原生副作用与真人复测分别记录 | TC-83 |
+| REQ-62 | QQ AI语音命令发送边界 | P0 | 修复群兜底成功仍发送空语音、私聊失败误走群发送,以及缺配置/声音选择问题 | 单条资源发送;群兜底成功立即结束;私聊失败不发群语音;空文字/缺声音/无效群先提示;发送失败反馈且不重试;声音按ID去重、序号绑定ID;隔离实际路由及用户试听 | TC-82 |
+| REQ-61 | DeepSeek识图配置与实际验证 | P1 | 按用户要求识图采用已用的DeepSeek服务/模型/密钥,沿用Agent图片描述流程 | 先补说明并查官方支持;合成图片真实API与Agent链路通过,备份后仅改ImageAPI/Type/ModelName/Key,其他AI字段/配置/程序/登录保持;真人QQ图片另验收 | TC-81 |
+| REQ-60 | AI其他角色权限与群目标检查 | P0 | 按原群主/管理员/普通成员/非主人私聊规则补齐验证,关闭跨群查询与CQ文字@全体成员缺口 | 仅配置主人获得owner角色;群主/管理员只获得admin,私聊不能沿用群身份;get_group_info检查当前群与正整数;普通成员两种消息格式禁止@全体;隔离命令/状态/实际程序回归,真实账号验收单独保留 | TC-80 |
+| REQ-59 | GPT引用原文与本次要求边界 | P0 | 修复真实主人私聊中同时复述原文和新要求的问题 | 两类输入明确分开,引用换行/引号/Unicode保留;无引用/无新要求/空输入规则不变;仍调用模型,完成模拟请求、整机、部署与主人私聊复测 | TC-79 |
+| REQ-58 | 热词小样本出图修复 | P0 | 修复真实调试群发现的单个或等频热词零范围错误 | 从0到最大正词频绘图,实际次数与前20名保持;空历史/资源/缓存规则保持;真实PNG、完整隔离回归、部署及指定群复测 | TC-77 |
+| REQ-57 | 清空当前显示日志 | P1 | 本地清空已显示日志,按原读取位置继续观察 | 不删服务记录、不发请求;保留筛选/草稿/暂停/游标/实例/统计/导出/轮询;读取中/无记录/可见会话保护、复制迟到隔离与重新加载说明 | TC-74 |
+| REQ-56 | 连接设置未保存字段摘要 | P1 | 按现有连接、主人和新增表单显示改动字段名称与数量 | 固定标签与实际加载目标,Token合并计数,无新旧值/秘密/额外请求;基线/忙时/失败/局部放弃与保存/会话保护,重启生效边界保持 | TC-73 |
+| REQ-55 | AI未保存字段摘要 | P1 | 按表单列出改动字段名称与数量,密钥动作/输入/确认合并 | 比较最新页面基线,不显示新旧值或未知字段名;范围取实际加载表单,局部保存/放弃与失败/忙时/导航/会话和迟到保护;无额外接口或写入,不代表校验/生效 | TC-72 |
+| REQ-54 | 复制单条日志 | P1 | 按条复制页面已显示的时间/级别/脱敏正文 | 保留Unicode/换行/截断标记,不读原始字段;同条忙时禁用、剪贴板拒绝手动选正文,生命周期/会话/淘汰与迟到保护,无额外接口/写入或更新状态变化 | TC-71 |
+| REQ-53 | 下载插件状态清单 | P1 | 已加载全局/群范围的全部匹配插件与最近已知开关状态 | 组合筛选/排序跨分页一致,草稿仅标记,未知不猜测;明确范围/时间/失败旧结果与非实时,仅固定字段不导出版本/配置/关键词;未读/忙时/隐藏/会话与恢复失效保护,无额外请求或写入 | TC-70 |
+| REQ-52 | 备份日期筛选 | P1 | 本地开始/结束日期及单端范围组合检索 | 结束完整当天,已应用/输入分离,无效保留;全部已加载匹配项分页/摘要/清单一致,时间未知排除,不增接口或改变恢复清理目标;刷新/忙时/会话与迟到保护 | TC-69 |
+| REQ-51 | 暂停时读取一次日志 | P1 | 保持暂停下手动读取新日志与匹配统计 | 原已应用条件/游标/200条限制,无轮询或自动重试;无新增也更新统计,失败保留,忙时防重复,恢复/筛选/导航/隐藏/会话和迟到保护,不改导出或保留范围 | TC-68 |
+| REQ-50 | 备份内容大小摘要 | P1 | 汇总已加载/匹配及固定范围的可核对内容大小 | 精确字节总和、无效大小单列不作0;跨全部匹配页,排序分页不改变大小;提示部分加载/失败与读取时间,明确非磁盘实际占用,无请求/秘密/预览变更,退出/旧会话隔离 | TC-67 |
+| REQ-49 | 备份列表排序 | P1 | 默认/时间旧到新/大小大到小和小到大 | 全部已加载匹配项稳定排序后分页,无效记录或排序字段最后;清单顺序及固定sort字段一致,清筛选保留,预览目标/忙时/刷新失败/会话隔离保持,不扩大200份限制 | TC-66 |
+| REQ-48 | 放弃单个插件草稿 | P1 | 卡片确认恢复此项页面基线,保留其他草稿 | 固定名称与已读范围,无请求/备份/实际状态变更;筛选排序分页保持并重算,成功保存基线/冲突不混淆,忙时/目标/草稿/会话及旧确认保护 | TC-65 |
+| REQ-47 | 插件列表排序 | P1 | 默认/名称/启用优先/禁用优先/未保存优先 | 全量筛选后排序再分页,未知状态最后,同组保留默认顺序;草稿/用法/保存目标与批量顺序不变,清筛选保留排序,忙时禁止,退出/过期重置及旧响应保护 | TC-64 |
+| REQ-46 | 日志快捷时间范围 | P1 | 最近15分钟/1小时/24小时和仅清除时间限制 | 固定点击时刻、真实经过时长、沿用已应用级别/关键词;查看/统计/导出一致,暂停/取消旧请求/会话隔离;无效或不可明确表示的本地时间拒绝,不扩大日志保留量 | TC-63 |
+| REQ-45 | 备份预览有效期提示 | P1 | 恢复/清理预览剩余时间和到期确认保护 | 本机秒级估算,到期/无效期取消确认并阻止执行,回拨不复活旧预览;隐藏/导航/取消/刷新/会话隔离,忙时不打断已提交请求;仍以服务端票据/版本校验为准,无自动操作 | TC-62 |
+| REQ-44 | 下载备份清单 | P1 | 导出当前筛选的所有已加载页目录,用于核对备份 | UTF-8 JSON仅固定元数据/读取时间与部分加载/失败标记;不导出关键词或正文,空匹配正确,忙时/导航/会话保护,无API写入和恢复票据 | TC-61 |
+| REQ-43 | 日志匹配统计 | P1 | 按已应用筛选统计当前保留快照的匹配总量与固定级别数量 | 和记录同锁快照,独立于游标/200条限制,脱敏先于关键词;淘汰/时间/重置正确,暂停/失败/换筛选/会话及旧接口保护,只读有界 | TC-60 |
+| REQ-42 | AI 基础配置检查 | P1 | 分聊天/GPT、Agent、可选识图展示本机字段检查 | 同一已读全局快照、无秘密值;OLLaMA无密钥和可选识图模型准确,地址按当前保存规则;未联调不保证实际调用,草稿/失败/会话保护,无网络检查或写入 | TC-59 |
+| REQ-41 | 插件用法复制与下载 | P1 | 用法窗口导出打开时名称/简介/注册说明快照 | 仅文本/UTF-8和安全文件名,不含配置/状态/聊天;缺失禁用,剪贴板失败可手动复制,关闭/过期/迟到保护,无API写入 | TC-58 |
+| REQ-40 | 插件页响应状态说明 | P1 | 只读区分全局/群单独/生效来源,附原指令权限与@说明 | 全局或群任一开启可响应,全局关闭不误报全群沉默;按成功读取范围更新,草稿/失败/会话保护,数据库错误不作关闭,多控制层不推断;秘密隐藏,无写操作 | TC-57 |
+| REQ-39 | 逐项保存全部插件草稿 | P1 | 确认当前范围全部含隐藏草稿,顺序提交,显示进度并可停止后续 | 原逐项备份/版本/即时生效,非事务;首错即停、仅成功清草稿,手动停止等待当前项;固定目标/忙时/会话隔离,失败不自动重试 | TC-56 |
+| REQ-38 | 启动配置差异摘要 | P1 | 按固定类别显示启动与保存数量/内容变化,解释待重启原因 | 同数量及Token变化可见,整配置比较一致;不含草稿/实时连接,秘密值/字段名/哈希隐藏;成功读写更新,失败保留与会话清理 | TC-55 |
+| REQ-37 | 新增本机反向监听 | P1 | 新增表单选择方向,追加反向监听,重启后接入 | ws/127.0.0.1/显式端口/根路径/Token强制,配置端口与后台端口冲突拒绝,64条上限;原备份版本/字段快照/草稿会话保护,旧正向API兼容;实际驱动认证和模拟命令通过 | TC-54 |
+| REQ-36 | 删除连接 | P1 | 固定目标预览后确认删除启动JSON中的正向/反向项 | 至少保留一条,类型/明确序号/确认/版本校验,独立保存/备份/原子写;保留其他字段/当前运行,不误认重排后的序号;草稿/取消/失败/会话与迟到保护 | TC-53 |
+| REQ-35 | 新增正向OneBot连接 | P1 | 在启动JSON附加正向连接,保存前备份,重启加载 | 地址/Token校验及显式无Token,重复地址/64条上限/互斥修改拒绝;保留其他字段/运行快照;原版本/原子写/受限备份,草稿/确认/失败/旧会话保护 | TC-52 |
+| REQ-34 | 备份恢复影响对比 | P1 | 按范围给出当前与备份的类别差异及数量 | 固定类别名称与计数,隐藏值/未知字段名称/表名;同数量内容变化准确、表示差异兜底;同一校验快照只读,无变化不可恢复,原票据/确认/冲突规则保持 | TC-51 |
+| REQ-33 | 修改后台密码 | P1 | 当前密码验证、确认新密码、失效全部后台会话 | 原子受限写入、旧密码不同、字数/长度限制、共享失败限速、写入/外部冲突保留;切页/过期清输入、忙时禁用、旧响应隔离;机器人/配置/登录不变 | TC-50 |
+| REQ-32 | 热词启动隔离 | P1 | 有效指令前按需加载字典/停用词 | 资源失败不终止机器人启动;不注册替代字典、不改开关;失败不进入绘图,下一次可重试 | TC-49 |
+| REQ-31 | 日志时间筛选 | P1 | 本地起止时间、单端/双端、查看与导出一致 | 秒级包含边界,时间与级别关键词组合;无效/倒置输入不改变已应用条件;暂停/切页/重载保持,旧请求隔离、退出重置;导出列出UTC边界,保留范围不变 | TC-48 |
+| REQ-01 | 本机访问 | P0 | 仅监听 IPv4 回环地址 `127.0.0.1`;端口可配置 | 非回环绑定被拒绝;端口占用有明确错误;网页及静态资源可离线加载 | TC-01、TC-02 |
+| REQ-02 | 身份与请求校验 | P0 | 首次使用建立本地凭据;未认证请求不得读取管理数据或改变状态;有退出能力 | 有效登录可操作,错误凭据失败;退出后会话失效;跨站写请求被拒绝;凭据不放 URL、不写日志 | TC-03、TC-04 |
+| REQ-03 | 运行概览 | P0 | 展示进程运行时间、版本、连接状态、已连接账号与近期异常 | 区分后台可访问与机器人已连接;当前注册表反映断线,读取失败清除旧在线显示;可见概览页每 10 秒刷新,切页/隐藏/退出取消读取且旧结果不覆盖新会话;最新 5 条脱敏警告/错误及统计仅来自本次启动保留记录,不推断问题是否解决;日志读取故障不误报连接断开 | TC-05、TC-33 |
+| REQ-04 | OneBot 配置 | P0 | 编辑地址、Token、主人账号;兼容读取已有多连接配置 | 地址、端口、账号格式校验;Token 默认脱敏;留空保留与显式清除语义区分;保存不丢失未编辑字段或其他连接 | TC-06、TC-07 |
+| REQ-05 | 配置生效状态 | P0 | 明确当前运行值与已保存值;区分热更新和需重启字段 | 插件开关使用运行时控制;无法热更新的设置显示“待重启生效”;保存失败不得提示成功或改变运行状态 | TC-08、TC-09 |
+| REQ-06 | 插件列表 | P0 | 展示已编译插件名称、说明、用法、默认状态;支持搜索 | 列表来自实际注册结果;不把未编译插件展示为可启用;空列表和读取失败有明确提示 | TC-10 |
+| REQ-07 | 插件开关 | P0 | 全局及按群开启/关闭,展示最终生效状态和原因 | 复用现有控制层语义;全局、默认与群级设置冲突时正确展示;重启后保留持久化开关 | TC-11 |
+| REQ-08 | AI 设置 | P0 | 管理聊天模型接口类型、地址、密钥、模型名、提示词及现有群级参数 | 复用现有配置结构;参数合法性校验;与聊天配置指令相互可见;说明生效时机;提示群总结会发送哪些数据至模型接口 | TC-12、TC-13、TC-85 |
+| REQ-09 | 密钥保护 | P0 | OneBot Token、模型密钥和管理凭据均不通过普通读接口返回明文 | 页面/API/日志/错误提示无明文密钥;显式清除需要确认;敏感响应禁止缓存;配置文件采用当前系统可用的受限权限 | TC-07、TC-14、TC-85、TC-87 |
+| REQ-10 | 日志查看 | P0 | 查看近期日志,按级别和关键词筛选,支持暂停滚动 | 有保留/条数上限;客户端断开释放资源;日志作为文本展示;HTTP 头和带凭据地址脱敏 | TC-14、TC-15、TC-87 |
+| REQ-11 | 自动配置备份 | P0 | 每次成功修改前保存可恢复的旧配置,并允许手动创建备份 | 备份有时间、版本、范围信息;备份失败时不继续覆盖配置;备份不位于静态目录、不进入 Git;界面说明含凭据 | TC-16 |
+| REQ-12 | 配置恢复 | P0 | 从本地备份列表选择,展示范围,确认后恢复 | 恢复前校验格式及版本并备份当前值;损坏文件不覆盖现状;失败可回滚;显示重启要求;只能访问受控备份目录 | TC-17、TC-18 |
+| REQ-13 | 机器人兼容性 | P0 | 启用后台后原有消息处理和管理指令继续工作 | 基础命令、管理员权限、插件开关和群消息发送通过联调;浏览器断开不影响机器人;后台与指令修改使用一致的数据源 | TC-11、TC-13、TC-19 |
+| REQ-14 | 可维护性 | P0 | 后台模块职责独立,错误可定位,前端产物可随程序部署;每次功能更新同步更新使用文档,没有对应说明时先创建 | 保留无后台启动方式;文档列出配置、构建流程和功能入口/权限/范围/生效方式;使用文档与实现一致并可从索引找到;测试用例可重复执行;不强制在线加载 CDN;持续性能工具隔离日常服务,记录实际窗口与采样不足/提前停止 | TC-01、TC-20、TC-75、TC-76、TC-88 |
+| REQ-15 | AI 基础缺陷修复 | P0 | 修复前次调研确认的错误吞掉、UTF-8 切分问题;收紧 `/gpt` 触发并补限速 | 协议错误返回真实错误;长中文/emoji 不被破坏;普通文本提及不误触发;限速有边界测试 | TC-21~TC-24 |
+| REQ-16 | 易用性增强 | P1 | 深浅主题、小屏适配、日志导出、手动备份清理 | 外观支持跟随系统/浅色/深色并只在当前浏览器保存偏好,存储不可用仍可切换;320~1280px 导航与表单无横向溢出。日志按已应用条件导出本次启动最多 1000 条,继续认证/脱敏并在配置不可读时拒绝文件。清理先预览再确认,只删除受控目录内有效旧备份;默认每类保护最近 10 份与近 7 天,最低 3 份/1 天;每批最多 100 份;过期/清单变更拒绝,部分失败明确反馈;不自动执行、不改变现行配置 | TC-28、TC-29、TC-32 |
+| REQ-17 | 服务列表聊天记录 | P1 | `/服务列表` 使用合并聊天记录附带插件使用方式,保留英文别名、定向规则及优先级 | 私聊与群聊调用对应转发接口,节点由机器人发送;每页最多 20 个已加载插件,实际会话状态、注册用法和缺失提示准确;页码非法/越界拒绝,1200 字 Unicode 上限有截断提示与完整用法入口;只读,发送失败明确反馈 | TC-34 |
+| REQ-18 | 插件检索增强 | P1 | 搜索插件名称、功能与注册用法,按当前范围实际状态筛选 | 搜索与启用/禁用/未知条件同时生效;总数和范围状态计数准确,未知不推断为默认状态;搜索/清除筛选保留开关草稿,不产生写请求;保存后重新筛选、切换群后采用实际状态,空列表/无匹配明确提示;深浅主题及窄窗口可用 | TC-35 |
+| REQ-19 | AI 编辑保护 | P1 | 显示未保存更改,防止重新加载/切换群/退出误丢失编辑 | 全局与群表单独立标记,恢复原值清除标记,保存仅清对应表单;取消和读写失败保留草稿,成功读取才替换;跨页保留、退出/过期清空,不写浏览器存储;请求中提示等待,旧请求不能清空或解锁新会话;原生离开提醒记录浏览器限制 | TC-36 |
+| REQ-20 | AI 私聊参数管理 | P1 | 在后台指定群或私聊 QQ,编辑对应聊天及 GPT 参数 | 明确正数群号/用户 QQ,拒绝双目标、负号和非法编号;同号群与私聊隔离,与聊天命令共用数据;写前备份、版本冲突及重开持久化有效;失败/取消保留原表单,保存目标不随未读取选择改变;不改变全局模型、插件开关或 Agent 权限 | TC-37 |
+| REQ-30 | 连接草稿局部撤销 | P1 | 分别确认撤销当前连接地址/Token或主人账号 | 只恢复选中部分最新读取/保存基线,另一部分草稿保持;Token清空/保留/隐藏确认正确;无请求、不改变已保存规则/运行连接,待重启和原保存状态保留;多连接固定目标、空连接、未读/忙时/退出/过期及旧确认保护 | TC-47 |
+| REQ-29 | AI 表单独立撤销 | P1 | 按全局/聊天/GPT表单确认放弃未保存更改 | 恢复该表单最近读取/保存成功的页面基线,其他草稿/已加载群私聊目标不变;新密钥清空,保留动作/确认/必填恢复;无请求和实际写入;已保存值不撤销;未读/无更改/忙时禁用,退出/过期和旧确认隔离 | TC-46 |
+| REQ-28 | 待保存开关集中管理 | P1 | 只看未保存与确认放弃全部当前范围草稿 | 与搜索/实际状态组合后分页;数量包含隐藏草稿;改回原值/单项保存后重筛与缩页;取消/失败保留;放弃含隐藏项但不发请求、不改变实际规则;请求中禁用、退出重置、旧会话隔离 | TC-45 |
+| REQ-27 | 插件列表分页 | P1 | 默认20,可选10/20/50;全量搜索/状态筛选后分页,保留所有页草稿 | 全量无重复/遗漏,边界/空结果正确;改变条件/份数与成功换范围回首页,刷新/保存缩页校正;隐藏草稿计数与单项保存不变;忙时禁用,退出/过期重置且旧请求隔离;用法目标保留,不产生分页API写入 | TC-44 |
+| REQ-26 | 备份列表分页 | P1 | 已加载记录筛选后分页,默认20份,可选10/20/50份;列表前置,恢复预览定位 | 满200份无重复/遗漏;筛选/份数变化回首页,刷新/切页保留并缩页校正;边界/无结果/请求中禁止翻页;退出/过期重置且拒绝旧结果;翻页不改变恢复/清理目标;减少动画使用即时滚动;不扩大最新200份加载限制 | TC-43 |
+| REQ-25 | 备份列表检索 | P1 | 范围、校验状态与编号/日期关键词组合筛选,加载/匹配计数及预览目标编号 | 含摘要、旧版无摘要和不可恢复区分;未知范围/空列表/无匹配提示;只搜索已加载最新200份并说明more;筛选不发写请求、不改恢复/清理目标;刷新/切页条件保留,读失败标记旧数据,退出缓存清空;旧结果拒绝,请求中禁止重复操作;原确认/完整性/恢复保护不变 | TC-42 |
+| REQ-24 | 异常日志排障入口 | P1 | 警告/错误/Fatal/Panic组合筛选、快速筛选/清除及概览入口 | 组合排除info/debug/trace且保留精确级别;关键词按脱敏后文本、分页/游标一致;查看与导出使用同条件;概览清关键词,快速异常保留已应用关键词;未提交输入提示未应用;切换取消旧请求并保留暂停/滚动,退出清空;原认证/脱敏/容量限制不变 | TC-41 |
+| REQ-23 | 连接设置编辑保护 | P1 | 地址、Token与主人账号草稿提示、取消/失败保留、会话隔离 | 改回原值清标记;切页保留;切换连接只丢其地址/Token,主人草稿保留;保存目标固定实际加载连接;重载/退出确认,失败保留原版本;成功清输入并保持重启提示;请求中冻结表单/阻止退出;过期清空,旧读写不回填或解锁新会话;恢复后清草稿,提示不含密钥 | TC-40 |
+| REQ-22 | 插件开关编辑保护 | P1 | 当前范围未保存数量和卡片标记;刷新/切群/退出先确认 | 改回读取模式取消草稿;搜索/筛选/切页保留,包括隐藏插件;成功只清对应草稿;读写失败保留原范围/版本/选择;成功读取才丢弃;请求中禁止退出与重复操作;退出/过期清空,旧读写及 finally 不覆盖或解锁新会话;浏览器离开提醒说明限制 | TC-39 |
+| REQ-21 | Windows 路径兼容 | P0 | 服务启停兼容父目录联接,不把同一项目的 C/D 别名识别为不同实例 | 比较实际脚本/工作目录/程序路径,保留 PID 与启动时间校验;识别同项目旧别名任务,启动复用运行任务,错误服务/外部脚本或程序拒绝;仅重启 ZeroBot 时保留 NapCat 登录 | TC-38 |
+
+## 3. 页面与状态设计
+
+| 页面 | 必要信息/操作 | 必须处理的状态 |
+| --- | --- | --- |
+| 登录 | 本地凭据输入、登录、退出 | 首次初始化、认证失败、会话过期 |
+| 概览 | 运行时间、版本、连接和账号 | 未配置、连接中、在线、断开、无法获取 |
+| 连接设置 | 地址、Token、主人账号 | 脱敏、校验失败、保存失败、待重启 |
+| 插件管理 | 列表、搜索、全局/群级开关、用法 | 未编译、生效来源、写入失败 |
+| AI 设置 | 接口、模型、密钥、提示词、群级参数 | 未配置、部分配置、保存成功、后续请求生效、版本冲突 |
+| 日志 | 近期记录、筛选、暂停 | 无记录、断流、重新连接、截断提示 |
+| 备份恢复 | 备份列表、覆盖范围、恢复确认 | 无备份、损坏、不兼容、恢复失败 |
+
+## 4. 技术调研与决策门槛
+
+| 事项 | 已知证据 | 下一步与决策标准 |
+| --- | --- | --- |
+| 旧 WebUI 复用 | `main.go` 中的 `zbputils/control/web` 与 `RunGui` 已注释 | 阅读锁定版本源码,核对认证、API、资源打包和维护情况;足够满足本需求则复用,否则保留适配层并新建轻量界面 |
+| 插件控制 | 插件使用 `control.AutoRegister`,存在全局及群级开关 | 确认可调用 API 和状态优先规则;页面不得另建一套开关数据库 |
+| AI 配置 | `aichatcfg`、`llm` 依赖 `zbputils/chat` | 明确读取、保存、并发访问及热更新边界;不复制独立配置副本 |
+| 持久化 | 入口 JSON 配置与插件控制数据分属不同路径 | 先列出配置清单与备份边界;多文件恢复应避免一半成功、一半失败 |
+| 启动方式 | 原程序可直接启动,后台入口默认关闭 | 优先评估内嵌 HTTP 服务;前端框架暂不锁定,选择简单、可离线打包的方案 |
+| 运行兼容 | Go 声明为 `1.26.1`;Windows 有控制台初始化 | 验证交互式终端及重定向日志场景,记录是否需要现有调试开关;不把调试行为当生产保证 |
+| 配置并发 | 网页与聊天指令均可能更新配置 | 统一写入入口/锁或版本校验;旧页面提交不得静默覆盖较新的修改 |
+
+## 5. 第一版完成标准
+
+全部P0需求实现,相关自测有记录;实际OneBot完成消息收发、角色会话命令与重启持久化,隔离配置恢复演练成功;使用文档、平台条件和未测范围公开说明。
+
+本轮已完成对应功能与约定验收。使用者取消剩余非主人Agent真人测试、客户端体验及真实持续业务负载,不记这些项目实测通过,后续出现问题再优化。四组Windows性能、浏览器窗口、模拟负载与20分钟资源结果保留各自范围,不宣称长期无泄漏。
+
+公开文件只包含实现及使用/开发所需资料,中间记录在docs/local-notes整体忽略。最终独立构建、交付核对与GitHub更新见[第一版说明](release-v0.1.md)及[交付规则](github-delivery.md)。
diff --git a/docs/local-admin/response-command-fix-2026-10-02.md b/docs/local-admin/response-command-fix-2026-10-02.md
new file mode 100644
index 0000000000..1aa1f20d23
--- /dev/null
+++ b/docs/local-admin/response-command-fix-2026-10-02.md
@@ -0,0 +1,55 @@
+# 群响应命令修复与验证
+
+当前规则:用户随后要求恢复原有 @ 条件,现已恢复 OnlyToMe。群内需 @ 机器人并具备主人/群主/管理员权限,私聊无需 @,原昵称唤醒兼容性保留。下文“免 @”部分是首次临时调整的历史记录,不是当前行为;恢复验证见末节。
+
+用户确认后台插件开关可以正常启停,截图显示 sleepmanage 全局显式禁用。真实日志记录群里 `/响应` 被接收但没有回复,随后私聊 `/全局响应` 回复成功,群里“早安”后来收到回复。原命令规则同时要求 UserOrGrpAdmin 和 OnlyToMe;群消息必须 @ 机器人或以机器人昵称开头,私聊自动符合 OnlyToMe。
+
+使用旧完整程序和模拟 OneBot 重现:主人作为普通群成员,不 @ 机器人发送 `/响应`,三秒内没有回复。失败记录目录 `.local/smoke-admin/run-2156660592/`。这与真实日志中群命令和私聊命令的差异一致。
+
+## 首次临时修改范围(后续已恢复 @ 要求)
+
+- 群内 `/响应`、`/沉默` 及英文别名 `/response`、`/silence` 不再要求 @。仍由机器人主人、群主或群管理员操作;普通成员即使 @ 机器人也不能操作。
+- 私聊 `/响应` 仍只控制当前私聊。群命令只控制当前群,不接受群号或其他参数。
+- 接受命令末尾空白、换行;拒绝 `/响应xxx`、`/响应 202` 等前缀误匹配。
+- 全局命令仍仅限机器人主人,私聊可直接发送,群里仍需要 @。同步拒绝全局命令的相似前缀和多余参数。
+- 未修改响应数据库、已有全局优先规则、其他插件指令的 @ 要求或权限。全局响应开启时仍覆盖群的响应门控。
+
+代码位于 `third_party/zbputils/control/rule.go`,依赖仍通过根 go.mod 的本地 replace 使用。测试入口为 `scripts/smoke-local-admin.go`。
+
+## 验证结果
+
+根模块后台和基础命令测试、相关 go vet、完整 Windows 构建通过。完整程序隔离模拟完成于 09:55:13(Asia/Shanghai),目录 `.local/smoke-admin/run-38404619/`,汇总 `.local/smoke-admin/latest.json`。
+
+| 检查 | 结果 |
+| --- | --- |
+| 主人、管理员不 @ 操作响应/沉默,管理员 @ 操作兼容 | 通过 |
+| 群主操作与中英文别名 | 通过 |
+| 普通成员有/无 @ 均不能切换,随后消息门控保持原值 | 通过 |
+| 换行、相似前缀、多余参数 | 通过 |
+| 当前群实际插件回复/沉默、其他群隔离 | 通过 |
+| 群管理员不能执行全局响应,主人私聊全局开关及实际消息门控 | 通过 |
+| 原后台认证、插件/聊天共享状态、连接备份、帮助及服务列表 | 通过 |
+
+新增 21 个群场景及 5 个全局响应场景;使用独立临时数据库和合成账号,没有发送真实 QQ 消息。后台插件启停已由用户人工确认;修复后的真实群命令仍待用户重发确认。完整业务验收未完成。
+
+## 本机部署
+
+升级前一致性快照 ID:`20261002T015549.694780700Z-40e41fe5a4fe4bac-control`。旧程序:`.local/bin/zerobot-before-response-fix-20261002-095549.exe`。
+
+只重启本项目 ZeroBot,NapCat 保持登录。09:56:39 的只读部署检查确认:后台 HTTP 200;未登录插件接口 HTTP 401;实际程序与测试构建摘要一致;启动 JSON 和控制数据库文件摘要与升级前一致;正确账号的 OneBot API 与 ZeroBot 握手通过,没有启动 fatal/panic。证据 `.local/response-fix-deployment.json`。
+
+## 实际使用
+
+要按群控制,先由主人私聊机器人发送 `/全局沉默`,移除全局响应覆盖,再在目标群 @ 机器人发送 `/响应` 或 `/沉默`。已有显式响应的其他群仍保持响应;这不是强制所有群沉默。
+
+响应门控和插件开关分别生效。若 sleepmanage 为显式禁用,`/响应` 不会启用它;需先在后台启用插件。“早安”还限制本机时间为 6~12 点,“晚安”为 21 点至凌晨 3 点。因此使用这些指令测试时须同时满足插件状态与时间条件。
+
+## 按用户要求恢复 @ 条件
+
+后续用户要求“恢复回需要 @ 才会触发”。已将当前会话响应/沉默的 zero.OnlyToMe 恢复,保留末尾换行容忍、无参数校验和既有权限;没有更改响应数据库和后台开关。
+
+go vet、Windows 构建和完整程序隔离模拟通过。最新模拟完成于 10:07:20,目录 `.local/smoke-admin/run-3793630746/`,27 个群场景加 5 个全局场景通过:主人/管理员不 @ 均不切换,随后插件门控保持原状态;具备权限且 @ 时可响应/沉默;普通成员即使 @ 也不能切换;跨群隔离、命令边界、私聊全局命令及原后台回归通过。汇总标志改为 groupResponseMentionRequiredPassed、groupSilenceMentionRequiredPassed;没有发送真实 QQ 消息。
+
+升级前快照 `20261002T020739.186869000Z-8fd766dc2e29c8f3-control`,旧程序 `.local/bin/zerobot-before-response-at-restore-20261002-100739.exe`。只重启 ZeroBot,10:08:05 部署检查确认后台 200、真实进程使用新程序、正确账号/API/握手通过;启动配置和控制库文件摘要不变。证据 `.local/response-at-restore-deployment.json`。NapCat 保持登录;恢复后的真实群重发待用户确认。
+
+后续用户反馈“经测试已经正常,继续开发其他功能”,确认恢复 @ 条件后的实际使用正常。该反馈关闭本次报告的群响应问题;完整普通成员/管理员/跨群矩阵仍以隔离模拟证据为主,不扩展成全部业务验收结论。
diff --git a/docs/local-admin/response-status.md b/docs/local-admin/response-status.md
new file mode 100644
index 0000000000..783f18a169
--- /dev/null
+++ b/docs/local-admin/response-status.md
@@ -0,0 +1,15 @@
+# 插件页响应状态说明
+
+登录本机后台进入“插件列表”,选择全局或填写群号并“查看此范围”。“响应状态说明”显示最近一次成功读取范围的全局响应、该群单独响应及响应来源。点击“刷新插件状态”重新读取;输入但未读取的群号、插件开关草稿和搜索筛选不改变说明目标,切后台页面保留。刷新/切范围失败保留上次成功结果,并按错误提示重新核对;退出或会话过期清空,旧请求不回填。
+
+现有规则是全局响应或该群单独响应任一开启即可通过响应检查。全局响应开启时,群单独响应关闭也不能阻止该群响应;全局沉默后,已单独开启的群仍可响应。全局视图只表示全局入口,不能据此判断所有群沉默,应读取具体群查看。卡片中的启用/禁用仍表示插件开关规则;回复还取决于响应状态、封禁、权限、@ 和具体指令,不以开启状态保证一定回复。
+
+本页只读,不自动发送 QQ 指令或更改响应。需要调整时沿用原指令:主人私聊发送 /全局响应 或 /全局沉默;群内由主人或群管理员 @机器人后发送 /响应 或 /沉默。群内管理仍要求 @,原权限、缓存与写前备份不变。全局开启覆盖单独沉默,全局关闭不清除各群已设置的响应。
+
+状态从当前实例统一控制层的持久化规则读取,不展示扩展内容、模型密钥或 Token,不把读取失败当作关闭。实例未接入控制层或插件分属多个控制管理器时提示不能读取统一状态,不推断响应。该入口只查看全局或正数群号,不提供私聊会话、封禁/解封或网页响应编辑。
+
+验收可刷新全局说明,再读取自己机器人的一个群,核对目标和三项状态;修改开关草稿或未读取群号不影响原说明,再放弃草稿即可。无需修改日常响应,状态组合、失败及消息回归采用隔离配置测试。真实 QQ 完整业务保持原待办。
+
+自动化检查入口:internal/localadmin/response_state_test.go 新增4项Go场景,覆盖响应组合、原规则一致性、扩展保留/秘密隐藏、读取不备份、数据库错误、API认证及多控制层不可用;scripts/test-response-status-ui.mjs 新增4项隔离Chrome场景。全部界面回归共112项和64项布局、相关模块测试及静态检查通过。完整Windows程序用模拟OneBot指令和临时控制库验证,18:46:38完整回归通过,pluginResponseStatusPassed=true,读取不创建备份,原功能回归通过;使用临时配置和模拟QQ消息,未发送真实QQ消息或调整日常响应。18:47:41部署后后台HTTP 200、正确账号OneBot正常,NapCat进程与登录保持,ZeroBot单实例与启动日志无错误,配置/控制库/后台凭据不变。证据见 .local/smoke-admin/run-780984701/result.json、.local/response-status-deployment.json 和 [自测记录](self-test.md)。
+
+插件列表可下载当前已加载全局/群范围的全部匹配状态清单,沿用筛选与排序,草稿只标记未保存;仅导出最近已知插件开关状态,不能导入或恢复,见 [下载插件状态清单](plugin-state-catalog.md)。
diff --git a/docs/local-admin/restore-plugin-reload.md b/docs/local-admin/restore-plugin-reload.md
new file mode 100644
index 0000000000..1f12952668
--- /dev/null
+++ b/docs/local-admin/restore-plugin-reload.md
@@ -0,0 +1,13 @@
+# 恢复后自动读取插件状态
+
+入口为本机管理后台的“备份恢复”和“插件列表”,需要登录后台。成功恢复后,页面丢弃旧插件状态和未保存草稿;首次返回插件列表时自动读取原全局或指定群范围,无需再点击刷新。搜索、筛选、排序和群号保留。
+
+读取期间显示加载状态并暂停开关操作;失败时显示错误和刷新重试提示,不把读取失败显示成程序未注册插件。读取成功后显示恢复后的实际开关及响应规则。真正没有注册插件的成功结果仍显示空列表。
+
+恢复前未完成的页面读取或保存响应不能回填新状态,也不会继续旧批量保存的后续项目;已经发往服务端的请求不能由页面撤销。切换到其他页面不改变恢复范围。此功能不发送 QQ 消息,不改变群权限或响应门控。
+
+更新程序后重启 ZeroBot,刷新浏览器页面生效。恢复预览、覆盖确认、写前备份和原密码保持规则沿用既有行为。开发与验证记录见开发进度 DEV-64、自测 TC-84;2026-10-12真实Chrome独立恢复与首次返回已自动验收,日常程序已更新。
+
+## 验证与交付
+
+新增4项恢复/读取失败/迟到读取/迟到保存场景与完整201项界面测试通过;Go后台测试、vet、Windows构建、64项深浅主题和320~1280px布局检查通过。17:12:38完整独立Windows回归通过(.local/smoke-admin/run-847379204/result.json),不发送真实QQ消息。真实Chrome在独立run-1635245969恢复控制库后首次返回显示108项、群101的chrev禁用,群号和搜索保留,未点击手动刷新;切页返回正确,实例已停止。首次完整界面回归196/201通过,额外普通导航读取影响5项原测试;限定仅恢复后自动读取后201/201通过,初轮失败证据保留于.local/restore-plugin-reload-ui-tests-initial.txt。
diff --git a/docs/local-admin/reuse-analysis.md b/docs/local-admin/reuse-analysis.md
new file mode 100644
index 0000000000..2f60b5c908
--- /dev/null
+++ b/docs/local-admin/reuse-analysis.md
@@ -0,0 +1,45 @@
+# 旧 WebUI 与数据接口复用结论
+
+## 结论
+
+新建独立、内嵌的本地 HTTP 服务与轻量网页;复用当前进程的 ZeroBot 账号注册表、插件注册列表和控制数据。旧 `control/web` 的认证、路由、日志与网页不直接启用。界面使用原生 HTML/CSS/JavaScript,由 `go:embed` 打包,不增加 Node 前端构建步骤或 CDN 依赖。
+
+## 版本与证据
+
+| 锁定模块 | 检查文件 | 观察与影响 |
+| --- | --- | --- |
+| `zbputils v1.7.2-0.20260510095247-3e9f8cf88d2d` | `control/web/router/router.go` | `/api/getLog`、`/api/data`、`/api/getBotList` 注册在不验证登录的路由组,不能直接用于新后台 |
+| 同上 | `control/web/middleware/middleware.go`、`controller/manage.go` | CORS 反射任意 Origin 并允许凭据,WebSocket `CheckOrigin` 恒为 true;新入口采用固定 Host/Origin、回环来源和会话校验 |
+| 同上 | `control/web/model/webui.go` | 明文密码保存在 `data/webui/user.db`,查询拼接用户名及密码;新认证不使用旧数据库 |
+| 同上 | `control/web/controller/manage.go`、`gui.go` | 全局单个日志/消息连接、直接向连接写入并替换日志输出;导入时安装消息监听器。新后台不导入此包,不改变现有日志去向 |
+| `ZeroBot-Plugin-Webui v1.0.1-0.20230412164529-cfe878675931` | `package.json`、嵌入产物 | 基于 Vben/Vue 的较大页面与旧接口绑定,头像/插件横幅取外部 URL;重建小型离线界面更适合当前范围 |
+| `ZeroBot v1.8.3-0.20260407110317-d207de3b6c79` | `callermap.go`、`driver/wsclient.go`、`driver/wsserver.go` | 连接时写入 `APICallers`,断线时删除;新概览每次读取该表,不把已有账号列表永久缓存为在线 |
+| `zbputils` | `control/managers.go`、`control/register.go` | `ForEachByPrio` 来自本次实际注册列表;可用于展示已编译插件及元数据,不扫描目录猜测可启用插件 |
+| `zbpctrl v1.7.1` | `control.go`、`data.go` | 开关、群配置与扩展配置使用同一 Manager/SQLite;网页不得建立另一套插件状态数据库 |
+
+## 状态与配置边界
+
+| 数据 | 来源 | 第一阶段实现/后续处理 |
+| --- | --- | --- |
+| 后台认证 | `.local/admin/credential.json`;首用设置码 | 新建独立认证,仅保存带随机盐的密码派生值;不复用 QQ/OneBot 密码或 Token |
+| 后台启用、地址 | `.local/admin/options.json` 或启动参数 | 默认不启用;仅 `127.0.0.1`;改端口/启用状态需要重启 ZeroBot |
+| 机器人账号 | 当前 `zero.APICallers` | 每次查询;只标明 OneBot 连接,不能据此宣称 QQ 平台在线 |
+| 插件元数据 | 当前 `control.ForEachByPrio` | 启动时快照;默认状态可能已包含已有持久化的默认翻转,页面标记“启动时默认状态” |
+| 插件开关/群参数/AI 扩展 | `data/control/plugins.db` | 复用同一 Manager;后续写入口必须先解决失败回报、并发及备份 |
+| OneBot 地址、Token、主人等 | 启动使用的 JSON,例如 `.local/config.json` | 需重启;DEV-05 以原始 JSON 字段树保存未知字段及未编辑连接,不能重新编码一个裁剪后的结构覆盖原文件 |
+| AI 运行配置 | 原 `chat.AC` 已移除,使用控制库与 `CurrentConfig/ConfigOf` 快照 | DEV-08 网页和聊天复用 ConfigRepository,原磁盘字段兼容 |
+| 日志 | 现有 logrus 输出 | DEV-09 已实现 1000 条/1 MiB 脱敏缓冲及有认证短请求读取;切页/暂停/退出取消轮询,日志钩子不访问控制库 |
+
+`zbpctrl.IsEnabledIn` 的实际实现为:存在显式全局开关时全局状态优先;没有显式全局开关时使用群级状态,最后使用默认值。源码旁边的部分文字说明与实际逻辑不一致,以锁定实现及测试矩阵为准。未配置全局值时直接调用 `IsEnabledIn(0)` 还会得到特殊缓存标记导致的禁用值;不能用这个调用表达“没有全局覆盖”。后续页面需明确“继承默认”与“显式全局开关”。
+
+上游锁定版本的 `Enable/Disable/Reset` 不返回写库错误,并在持久化完成前更新缓存;已在本地补丁中统一开关事务/锁、错误回报及提交后缓存失效。显式继承不删除插件参数;默认翻转标志正确持久化。DEV-08 已将 `chat.NewExtraSet*` 改为共享字段提交,取消可变 AC;扩展/群参数写入使用同一锁,提交成功后才影响后续读取。不认为整个依赖均已修复。
+
+## 备份与恢复方案
+
+JSON 使用版本校验、写前备份和同目录临时文件替换,已验证。插件开关和 AI 群参数采用共享 Manager 锁及 SQLite 事务;扩展更新在同一锁内合并并提交,写前以限时 VACUUM INTO 建立含 WAL 已提交数据的一致性快照;对应失败/并发测试已通过。DEV-10 实现三个独立范围的手动备份和恢复,逐项校验、绑定当前状态的预览与确认、恢复前备份、失败回滚及重启提示;不提供跨文件联合恢复。控制库使用同一锁和事务恢复匹配表,提交后清缓存,详见 [备份恢复记录](backup-restore-2026-10-02.md)。响应/沉默、用户封禁/解封及插件禁止/允许已接入同一写前备份,持久化查找失败不当成缺失状态,备份或写入失败不改缓存、不误报成功;验证见 [控制策略备份记录](control-policy-backups-2026-10-02.md)。业务计数及外部编辑不逐次备份。
+
+## 性能验收口径
+
+本机无后台基线采样(2026-10-01 16:36,现有 ZeroBot 进程):Working Set 49,209,344 字节,Private Bytes 130,613,248 字节;累计 CPU 4.59375 秒。该采样不是峰值或稳定区间统计,不能单独作为完整性能验收。
+
+概览不调用远程 OneBot API;插件列表现从同一控制数据库读取指定范围的实际状态。浏览器刷新及网页开关操作不会额外发送 QQ 消息,实测与限制见后台基础和插件管理记录。
diff --git a/docs/local-admin/reverse-connection.md b/docs/local-admin/reverse-connection.md
new file mode 100644
index 0000000000..21023117cf
--- /dev/null
+++ b/docs/local-admin/reverse-connection.md
@@ -0,0 +1,17 @@
+# 新增本机反向 OneBot 监听
+
+登录本机后台进入“连接设置”,在“新增 OneBot 连接”表单选择“本机反向监听”,填写 ws://127.0.0.1:端口(例如 ws://127.0.0.1:6702)和新的 Token。确认后备份并保存,自动选择新增项。保存只修改启动JSON,不立即监听或重启;重启ZeroBot后加载。随后在自己的接入程序中配置WebSocket客户端,使用相同地址与Token连接ZeroBot。此操作不自动修改NapCat或登录账号,当前正向连接保持。
+
+正向连接是ZeroBot主动连接接入程序的WebSocket服务器;反向监听是接入程序主动连接ZeroBot。连接列表中的“反向监听”不是TLS/wss加密连接,本项目锁定驱动使用普通HTTP WebSocket服务。新增入口因此仅接受ws、字面主机127.0.0.1、明确的1~65535十进制端口、空路径或根路径;不接受wss、外部主机、通配地址、查询参数、片段、地址中认证或自定义路径。原有非本机配置仍按原编辑入口读取和保存,不自动迁移。
+
+反向新增必须填写有效Token,不提供无Token选项;最长2048字节,不能包含控制字符或使用脱敏占位。检查同一启动配置中已保存反向监听端口以及本机正向连接目标端口冲突,后台API也拒绝与当前后台监听端口相同;最多64条反向项。保存时不探测其他程序是否占用端口,不保证启动时空闲;遇到占用应在启动日志核对后更换端口或处理实际占用。
+
+新增表单的类型、地址、Token和无Token选择一起作为草稿。切换方向保留地址/新Token,但切到反向会取消无Token选择并要求Token;只有正向可以明确无Token。选择方向本身记为草稿,重载或退出先确认,切后台页面保留。新增与已有连接/主人分别保存,已有草稿须先处理;放弃新增清空输入并回到正向。确认前后类型与内容变化不能提交旧确认,失败/冲突保留草稿,过期及旧响应不能回填新会话。预览删除与其他连接操作复用忙时保护,成功保存后Token输入清空。
+
+写前备份、配置版本检查、原子替换、字段精度/其他连接/主人保留及待重启提示沿用原新增规则。接口旧客户端不传kind时仍新增正向;指定反向只写反向列表。新连接需改变本次运行配置时显示待重启;若恢复到启动时相同配置,则无需重复重启。
+
+验收可只选择方向、填写模拟地址与Token后跨页返回/放弃,核对提示,不需改变日常NapCat配置。实际反向接入需按上述步骤设置自己的接入程序;隔离测试验证驱动监听、Token认证及模拟命令,不发送真实QQ消息。完整真实QQ业务仍保留原待办。
+
+验证记录:新增4项 Go 和4项隔离 Chrome 检查通过,界面回归共98项,布局检查共64项。10:39:42完整 Windows 隔离回归通过;另用实际 Windows 程序启动临时反向监听,缺少 Token 返回401、错误 Token 返回403,正确 Token 完成握手并收到模拟 /help 回复。未发送真实 QQ 消息或调用模型。10:40:34本机部署通过,原启动配置、控制库、后台凭据保持,NapCat 进程及登录保留,ZeroBot 身份和单实例检查正常。测试证据见 [自测记录](self-test.md)。
+
+连接设置顶部现提供[未保存字段摘要](connection-draft-fields.md),分别核对实际加载连接、主人和新增表单的改动字段名称与数量;不显示字段值,保存与重启仍按原规则执行。
diff --git a/docs/local-admin/self-test.md b/docs/local-admin/self-test.md
new file mode 100644
index 0000000000..47b46826ad
--- /dev/null
+++ b/docs/local-admin/self-test.md
@@ -0,0 +1,34 @@
+# 第一版验证摘要与复验入口
+
+更新日期:2026-10-13。公开文档记录可重复执行的方法与验证范围;真实QQ明细、私有日志、截图、凭据和原始结果不随源码上传。
+
+| 类别 | 已执行范围 | 边界 |
+| --- | --- | --- |
+| 后端与配置 | 认证/来源/CSRF、共享持久化、冲突/写失败保护、备份/恢复、统计及脱敏 | Go包测试与隔离程序验证;不改日常配置演练恢复 |
+| 页面与布局 | 现有242项界面回归、35项相关概览回归、64项离线布局;实际Chrome备份/AI/日志/连接/主题等流程 | 实际浏览器资料保留原执行范围,离线检查不替代所有浏览器行为 |
+| 观察工具 | 210项模型/回复/资源联合工具回归,45次模拟GPT与15分钟模拟资源观察 | 模型HTTP、任务和发送回执各有口径,不强求计数相等 |
+| 当前日常资源 | 20分钟、240样本、CPU平均约0.002018%、私有内存最大增长72KiB | 资源窗口,不是配套业务负载,也不能证明长期无泄漏 |
+| 真实QQ | 指定群/主人私聊文字、实际引用、识图、热词出图、群单条语音与私聊限制 | 仅记录已验证业务,不扩大到所有消息类型 |
+| 真实角色 | 普通成员/非主人私聊/管理员/真人群主的本轮会话参数允许与拒绝 | 临时身份已恢复;初次管理员无响应,刷新消息成员缓存后正向测试成功 |
+| 主人真实Agent | 查询本群、保存记忆、4条群回执、4个完成任务、6次成功动作响应,单次重启后代号延续 | 最初查询收到两条事件但只有一个完成任务;事件不能当作任务数 |
+| 取消项目 | 非主人Agent真人测试、客户端体验、真实持续业务负载 | 使用者取消,不记作实测通过,后续出现问题再优化 |
+
+最终交付已从显式文件清单生成独立源码副本并完成构建、相关Go检查、界面及工具回归和Windows隔离整机检查,文档链接和私有资料排除核对通过。结果汇总见[第一版说明](release-v0.1.md)。
+
+## 开发者复验
+
+在仓库根目录执行,先初始化data子模块并按[运行维护](operations.md)准备本地环境。测试使用合成账号与独立目录;不要把日常凭据传入夹具。
+
+```powershell
+.\scripts\build-windows.ps1 -OutputName zerobot.exe
+go test ./internal/... ./kanban ./plugin/llm ./plugin/aichat ./plugin/aichatcfg ./plugin/airecord ./plugin/wordcount ./plugin/tarot -count=1 -timeout=120s
+go run scripts/smoke-local-admin.go -binary-name zerobot.exe
+go run scripts/test-admin-system.go -mode restart -binary-name zerobot.exe
+.\scripts\test-service-paths.ps1
+```
+
+前端测试按实际文件列表执行scripts/test-*-ui.mjs;离线布局使用scripts/test-appearance-layout.mjs。模型/回复/资源工具回归入口为scripts/test-observation-evidence.mjs、scripts/test-reply-evidence.mjs与scripts/test-combined-observation.mjs,其他用例按scripts/test-*.mjs的用途运行。保留原始结果,失败不能改写成通过。
+
+本地依赖补丁可在各自目录单独开发:控制模块和工具模块的go.mod已显式引用相邻补丁,主程序根go.mod也保留同一组替换。隔离/观察启动器默认读取.local/bin/zerobot.exe,可通过binary-name或BinaryName参数指定其他已构建副本;不依赖开发者的内部构建文件名。
+
+依赖模块原有TestMaru只是无条件失败的演示,没有业务断言,现保留名称并明确跳过,不计功能测试通过。最终整机夹具使用data子模块资源及经典诗歌资源缓存,不复制日常控制库或私有模型配置。
diff --git a/docs/local-admin/service-list.md b/docs/local-admin/service-list.md
new file mode 100644
index 0000000000..f8af8a0acb
--- /dev/null
+++ b/docs/local-admin/service-list.md
@@ -0,0 +1,17 @@
+# 服务列表与使用方式
+
+私聊发送 `/服务列表`,群内 @ 机器人后发送同一命令。英文别名为 `/service_list`。命令只读,不要求后台登录,不修改插件或群响应配置;保留原有定向和指令优先级规则。
+
+回复改为合并聊天记录:首条说明总数量、页码、当前会话和翻页方式,其后每个插件独占一条,包含名称、功能说明、当前群/私聊启用状态,以及插件注册的使用说明。记录由机器人账号作为发送者,不冒充其他成员。
+
+每页最多 20 个已加载插件,按现有插件优先级排序;无参数时是第 1 页,发送 `/服务列表 2` 查看第 2 页。页码不合法或超出范围会提示,不会发送空记录。关闭的插件仍展示,未编译插件不会出现。
+
+单个节点最多 1200 个 Unicode 字符。用法过长时保留开头并明确提示发送 `/用法插件名` 查看完整说明,例如 `/用法llm`;原用法指令及后台“查看用法”保留。未提供用法的插件明确标注,不能把简介当成可执行指令。
+
+群响应门控与插件开关分别生效,“启用”不保证所有成员都有命令权限;仍需遵守每个插件的 @、角色、时段、密钥等条件。合并记录发送失败会明确提示,不自动刷屏重发;真实 QQ 的显示与风控以实际验收为准。
+
+## 验证与部署
+
+两个格式测试验证分页覆盖、机器人身份、中文/emoji 上限、缺失说明和文字中的 CQ 片段不转换为图片。基础指令、后台及 LLM 模块测试、Go 静态检查和 Windows CGO=0 构建通过。
+
+验收步骤:在私聊发送 `/服务列表`、`/服务列表 2`,在测试群 @ 机器人发送同一命令,核对聊天记录可展开、状态与后台一致、插件用法可读。用户现已确认功能验收通过。
diff --git a/docs/local-admin/startup-differences.md b/docs/local-admin/startup-differences.md
new file mode 100644
index 0000000000..747ecdbd1d
--- /dev/null
+++ b/docs/local-admin/startup-differences.md
@@ -0,0 +1,11 @@
+# 已保存配置与本次启动的差异摘要
+
+登录本机后台,进入“连接设置”,读取成功后查看“启动配置差异”。按正向连接、反向连接、机器人主人、其他机器人参数、其他启动设置分类显示本次启动数量与已保存数量,以及一致或有变化;数量相同仍可能内容不同。字段名称或表示差异无法归入上述类别时单独提示。整体待重启结论沿用原完整 JSON 比较。
+
+摘要比较最近一次成功读取或保存的配置文件与本次 ZeroBot 启动快照,不包含表单未保存草稿,不表示连接实时在线状态。编辑、切换连接或切页不会重新比较;成功读取、编辑保存、新增、删除或恢复后重新读取才更新。读取失败保留上次结果,需依据读取失败提示重新加载核对;退出或会话过期清空摘要,未使用启动配置文件时不展示。
+
+摘要只显示固定类别、数量和变化状态,不展示 Token、密钥、未知字段名称、配置值或摘要哈希。同数量下更换 Token 也计为内容变化;修改并保存回本次启动相同配置后显示一致。这里只读,不执行重启、备份、恢复或 NapCat 设置。原保存、备份、版本与草稿保护保持。
+
+验收:刷新后台进入连接设置,核对当前一致状态和数量;草稿编辑后摘要保持最近已保存状态。需要观察变化时在隔离配置测试,不必修改日常连接或 Token。用户已确认摘要体验验收,真实 QQ 业务保持原待办。
+
+自动化检查入口:4项新用例位于 internal/localadmin/startup_differences_test.go;4项隔离 Chrome 用例位于 scripts/test-startup-differences-ui.mjs。全部界面回归共102项及64项布局已通过,模块测试与静态检查通过。完整 Windows 检查由 scripts/smoke-local-admin.go 使用临时配置和模拟 OneBot/模型执行,不接入日常 QQ 或真实模型。完整 Windows 回归已于12:20:22通过;12:39:22本机部署通过,原配置、控制库、后台凭据和 NapCat 登录保持。执行结果与部署证据见 [自测记录](self-test.md)。
diff --git a/docs/local-admin/system-acceptance-2026-10-02.md b/docs/local-admin/system-acceptance-2026-10-02.md
new file mode 100644
index 0000000000..12a24e18fe
--- /dev/null
+++ b/docs/local-admin/system-acceptance-2026-10-02.md
@@ -0,0 +1,47 @@
+# Windows 恢复重启与性能验收
+
+## 隔离与执行
+
+从仓库根目录在 Windows PowerShell 中执行:
+
+```powershell
+$env:CGO_ENABLED='0'
+go run scripts/test-admin-system.go -mode restart
+go run scripts/test-admin-system.go -mode performance
+```
+
+每次运行创建 `.local/admin-system/` 下的新目录,复制必需启动素材,创建测试数据库、启动配置及后台凭据。机器人连接本地模拟 OneBot,不读取日常启动配置或控制库,不发送真实 QQ 消息或调用模型。测试子进程隐藏启动、退出时停止,仅保留结果与日志;目录不进入 Git。启动器按测试 `options.json` 生成与 `service-host.ps1` 相同的程序参数,不计作实际 Windows 计划任务的恢复演练。
+
+`-binary-name` 可指定 `.local/bin/` 中的待测 exe 文件名,默认为上述已部署构建。性能默认预热 60 秒、采样 300 秒;缩短参数仅用于脚本调试,结果中 `fullTiming=false` 不计作正式持续验收。
+
+## 恢复后重启
+
+1. 初始设置后台密码、群级开关、AI 模型名、全局插件禁止和用户封禁,保存三个范围的备份。
+2. 修改主人账号、开关、AI、响应与封禁规则后重启,确认已保存主人生效,待重启状态清除,旧会话拒绝。
+3. 恢复三个备份范围并重启,确认原主人、插件继承/群开关、AI 模型名及响应/封禁门控保留;后台使用恢复后的不同端口,旧端口不可访问,旧会话在新端口仍被拒绝,原密码和凭据文件摘要保留。
+4. 恢复关闭后台的选项并重启,确认后台不再监听,但机器人仍能正常响应测试命令。
+
+真实浏览器人工点击、日常 QQ 群完整联调仍另行验收;上述测试不使用日常配置进行破坏性演练。真实模型验证继续保留为 [TODO-AI-01](todo.md)。
+
+## 持续性能口径
+
+四组顺序测量:关闭后台、开启后台空闲、后台读接口每十秒刷新、日志每两秒带游标轮询。每组使用相同二进制、素材及合成配置,向同一模拟账号按约五秒间隔发送翻转指令。每两秒通过 Windows 原生进程 API 采集累计 CPU、Private Bytes 和 Working Set。CPU 百分比按本机 20 个逻辑 CPU 归一化,比较机器人进程所占全机容量;不测所有其他主机进程,也不测 Chrome 渲染。
+
+登录和六类读接口先分别执行 10 次、20 次探测,随后预热;这些探测不计入资源对比窗口。轮询组继续记录相应读接口时延。结果保存各接口 p95、消息 p95、请求次数及完整资源样本,内存增长取采样期最大 Private Bytes 减首个样本,保留末尾值和 Working Set 峰值。登录不反复创建配置备份,轮询不发送真实消息。
+
+目标沿用复用分析:管理读接口 p95 ≤ 500ms,登录 p95 ≤ 1s,Private Bytes 增长 ≤ 64MiB,相对关闭后台的 CPU 增量 ≤ 1 个百分点、消息 p95 增量 ≤ 100ms。固定窗口测量不能证明长期无泄漏或所有插件/流量下的表现;浏览器界面渲染和真实模型请求不在测量范围。
+
+| 场景 | 消息 p95(ms) | 登录 p95(ms) | 最慢读接口 p95(ms) | CPU(全机容量 %) | Private Bytes 最大增长(MiB) |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| 关闭后台 | 196.54 | 不适用 | 不适用 | 0.0071 | 0.207 |
+| 后台开启、空闲 | 197.20 | 81.30 | 19.20 | 0.0089 | 0.008 |
+| 读接口每十秒刷新 | 193.94 | 79.83 | 19.78 | 0.0188 | 0.223 |
+| 日志每两秒轮询 | 192.76 | 82.79 | 19.38 | 0.0091 | 0.730 |
+
+最慢读接口均为实际插件列表;日志轮询组日志接口 p95 为 1.40ms。相对关闭后台,消息 p95 最大增量 0.66ms,机器人 CPU 最大增量约 0.012 个百分点,所有已定义的进程/接口测量阈值通过。时延小幅下降属本次采样波动,不据此声称后台加速机器人。完整样本保存在结果文件,终端仅输出汇总。
+
+完整恢复页面人工操作可使用[本机隔离后台验收环境](isolated-admin-acceptance.md),在独立目录准备三类基线备份与不同的当前值,终端只重启本次测试实例;自动环境检查不替代人工验收,不影响日常机器人。
+
+## 当前构建复验:2026-10-12
+
+另完成真实Chrome日志暂停/自动更新各300秒,62/60样本、JS堆最大增长3.282/1.084MiB,页面持续可见且更新29→149行;窗口、采样间隔和任务时间口径详见[浏览器性能核对](browser-performance.md)。这些属于独立模拟负载与实际页面采集,真实QQ/模型负载仍待观察。日常配置、程序及ZeroBot/NapCat进程保持,本轮无需部署或重启。
diff --git a/docs/local-admin/todo.md b/docs/local-admin/todo.md
new file mode 100644
index 0000000000..e664587bdf
--- /dev/null
+++ b/docs/local-admin/todo.md
@@ -0,0 +1,12 @@
+# 第一版待办与后续范围
+
+本地管理后台v0.1的功能实现与本轮约定验收已经完成。第一版交付正在按[GitHub规则](github-delivery.md)整理并更新用户仓库。
+
+| 事项 | 当前状态 | 范围 |
+| --- | --- | --- |
+| GitHub第一版交付 | 交付中 | 完成独立源码构建及最终核对后,提交并更新用户仓库,使用中文说明 |
+| 非主人Agent真人动作测试 | 已关闭:取消本轮要求 | 不计为实测通过,不自动重开;保留隔离角色边界结果 |
+| QQ客户端体验验收 | 已关闭:取消本轮要求 | 后续有需求再提出;不宣称客户端收到/已读或端到端耗时已测 |
+| 真实持续业务负载验收 | 已关闭:暂时取消 | 模拟负载及20分钟资源观察保留原范围,不替代真实负载;后续出现问题再优化 |
+
+文字、识图、群单条语音及私聊语音限制、真实群角色会话命令、主人Agent动作和重启记忆均已取得本轮证据。具体已测与未测范围见[自测摘要](self-test.md)。取消验收不等于新增测试通过,也不代表所有插件、模型和长期业务场景均无问题。
diff --git a/docs/local-admin/user-guide.md b/docs/local-admin/user-guide.md
new file mode 100644
index 0000000000..5e8612e494
--- /dev/null
+++ b/docs/local-admin/user-guide.md
@@ -0,0 +1,237 @@
+# 本地机器人使用指南
+
+真实Agent的触发、动作/回执核对及重启记忆步骤见[Agent验证](real-agent-acceptance.md)。第一版完成后的GitHub更新与中文说明、对话资料排除规则见[交付规则](github-delivery.md)。
+
+## 启动与登录
+
+首次从源码安装:初始化data子模块,使用Go 1.26.1或满足go.mod要求的工具链编译,再按[Windows OneBot](onebot-windows.md)安装并配置本机QQ接入。下面的启动步骤适用于本机配置已经准备好的环境。
+
+```powershell
+git submodule update --init --recursive
+.\scripts\build-windows.ps1
+.\scripts\install-onebot.ps1
+```
+
+安装会在.local生成网络配置。启动前把.local/config.json中的示例主人QQ改为自己的账号,并创建.local/onebot/account.json,填写实际机器人账号,例如`{"expectedQQ":"87654321"}`;此处数字仅为格式示例,必须替换为自己的机器人QQ。首次扫码登录后运行test-onebot.ps1核对实际登录账号,再启动ZeroBot。后台默认启用情况由.local/admin/options.json和托管脚本控制;需要显式开启时运行configure-local-admin.ps1后重启ZeroBot。
+
+在仓库根目录的 PowerShell 中执行:
+
+```powershell
+.\scripts\start-local-services.ps1 -Service OneBot
+# 到 NapCat 登录机器人账号,首次登录可能需要扫码及手机确认
+.\scripts\test-onebot.ps1
+.\scripts\start-local-services.ps1 -Service ZeroBot
+```
+
+当前 NapCat 登录页为 ,管理后台为 。机器人账号按 `.local/onebot/account.json` 核对,主人账号按 `.local/config.json` 管理,两者用途不同。后台使用独立密码;重启 ZeroBot 后需要重新登录后台。首次未设置密码时按页面提示选择本机设置码文件并设置密码,密钥或密码不需要发到聊天中。
+
+Chrome 自动打开本机后台首页支持顶层页面导航;跨站读取接口、嵌入页面和提交配置仍受来源及登录校验保护。导航修复需更新并重启 ZeroBot 后生效,排查步骤见 [浏览器恢复流程](operations.md#浏览器恢复流程)。
+
+仅重启机器人时先执行 `stop-local-services.ps1 -Service ZeroBot`,再执行 `start-local-services.ps1 -Service ZeroBot`;NapCat 登录可以保留。完整启动、停止与恢复说明见 [运行维护](operations.md) 和 [Windows OneBot](onebot-windows.md)。
+
+## 服务列表与用法
+
+私聊发送 `/服务列表`,群内 @ 机器人后发送同一命令,即可收到带使用方式的合并聊天记录。每页最多 20 个已加载插件,包含名称、说明、当前会话启用状态及注册用法;关闭的插件仍列出。发送 `/服务列表 2` 翻页,英文别名 `/service_list` 同样可用。
+
+用法过长会明确提示 `/用法插件名` 查看完整说明,未提供用法的插件不会编造指令。插件启用后仍需满足自己的权限、时段和密钥等要求;服务列表不修改开关。步骤与限制见 [服务列表](service-list.md)。
+
+## 后台功能
+
+| 页面 | 使用方法与生效说明 |
+| --- | --- |
+| 运行概览 | 检查进程、连接及机器人账号;查看本次启动保留的警告/错误数量与最新 5 条异常;可见概览页每 10 秒更新,读取失败明确提示,页面可访问不等于机器人已在线 |
+| 连接设置 | 新增正向/反向连接、编辑/删除、局部放弃;顶部摘要核对未保存字段名称,启动差异比较已保存与本次启动;Token 默认保留,写前备份,保存后按提示重启 |
+| 插件列表 | 全局/群范围检索、状态筛选、排序/分页;单项或逐项保存全部草稿即时生效,可局部放弃;用法和最近已知状态清单可下载,清单不能导入/恢复 |
+| AI 设置 | 全局模型、密钥与人物设定;读取目标群/私聊会话后修改参数,各表单分别保存/放弃,未保存摘要只列字段名称;基础检查不调用模型,保存后供后续请求使用 |
+| 运行日志 | 级别/关键词/起止时间与快捷范围统一应用;可暂停、读取一次、复制本条、清空显示和导出;统计/导出采用服务仍保留的脱敏日志,清空显示不删日志,最多 1000 条 |
+| 备份恢复 | 按范围/状态/关键词/本地日期筛选,排序/分页、内容大小摘要与目录下载;三类范围独立备份/预览/确认恢复,到期须重读,启动地址等变更需重启;目录清单不能恢复 |
+| 清理旧备份 | 在“备份恢复”填写保留数量和天数,先预览清单,再勾选永久删除确认;默认每类保留最近 10 份和近 7 天全部备份,只手动执行 |
+| 外观 | 登录页或后台顶栏选择跟随系统、浅色或深色;浏览器本地保存偏好,小屏可用顶部导航 |
+| 后台安全 | 输入当前密码并确认新密码后修改后台密码;成功后全部后台会话失效,需要重新登录 |
+
+“继承”按原控制层规则生效;存在显式全局开关时,不能把群开关当作一定覆盖全局。插件开关与会话响应门控分别生效。遇到编辑冲突先重新加载并核对,插件与 AI 重新加载先确认,成功读取才替换未保存内容。完整步骤和限制见 [运行维护](operations.md),功能专项文档见 [文档索引](README.md)。
+
+插件搜索与状态条件同时生效,计数按当前范围最后读取或保存的实际结果计算,不把尚未保存的选择当成启用状态。筛选只影响显示;保存后卡片可能因状态变化而不再匹配。完整步骤见 [插件搜索与筛选](plugin-search.md)。
+
+AI 设置切换后台页面后保留当前草稿;重新加载、切换群和退出时提示未保存更改。取消会保留编辑,读取或保存失败也保留草稿;全局与各群表单分别保存,不自动保存。草稿仅在页面内存中,退出或会话过期清空,刷新/关闭时的原生提醒受浏览器限制。步骤见 [AI 编辑保护](ai-drafts.md)。
+
+私聊参数填写与机器人聊天的用户 QQ;同号群聊与私聊互不覆盖。保存目标由已经读取的表单决定,仅改变范围输入框不会改变保存目标。参数不会放宽 Agent 权限、改变插件开关或发送测试消息。步骤见 [AI 会话参数](ai-conversations.md)。
+
+清理只删除预览列出的旧备份,不修改当前配置或对话档案,无需重启。删除不能通过配置恢复撤销;先另存要长期保留的备份,再检查清单并确认。预览 10 分钟有效,修改参数、切页或备份清单变化后重新预览;每批最多 100 份。损坏、旧版无摘要或结构异常的内容跳过,失败时先刷新检查实际状态。完整步骤见 [备份清理](backup-cleanup.md)。
+
+概览“近期异常”复用脱敏日志,按最新在前显示;数量仅对应本次启动仍保留的记录,不是尚未解决的故障数量。切页、隐藏和退出停止概览读取;回来重新获取,失败会清除旧在线账号显示。异常暂不可读取时仍保留独立的连接状态。插件列表使用“重新加载状态”查看最新网页/聊天开关,概览自动刷新不会覆盖插件编辑表单。说明见 [运行概览与近期异常](overview-alerts.md)。
+
+## 普通聊天、Agent 与 GPT
+
+显式语音命令为`发送AI语音要朗读的文字`:有效响应的群内限群主/管理员或机器人主人使用。群内直接发送一次,只使用当前群;私聊给出仅支持群聊的提示,不生成群语音。普通AI聊天的语音输出也仅作用于当前群,私聊保留文字。Agent文字动作和`/gpt`方式保持;语音配置不在控制库备份范围内,详见[QQ AI语音](qq-ai-voice.md)。
+
+识图已配置为同一DeepSeek的`deepseek-flash`。在有效响应、已启用Agent的会话中同时发送QQ图片和文字提问,群内按规则@机器人,主人私聊直接发送。模型先描述图片,Agent再回答;纯图片可能不触发,`/gpt`仍是文字入口。使用与图片传输/缩放限制见[DeepSeek识图](deepseek-vision.md)。
+
+机器人主人、群主、管理员、成员和非主人私聊的AI命令与Agent动作各按发言者权限执行。群主/管理员不是机器人主人,不能改全局模型或重置上下文;非主人群查询/动作仅限当前群,普通成员不能让Agent@全体成员。权限表、生效方式和测试范围见[AI角色权限](ai-role-permissions.md)。
+
+可临时移除现有账号的主人资格来测试其他真实角色,须先备份、设置期限恢复并重启使身份生效。临时授予群管理员后,需同时核对消息成员缓存和实时`sender.role`;新鲜成员信息与事后历史查询不能替代实时身份。2026-10-13普通成员/非主人私聊GPT及管理员会话参数、主人专属开关拒绝已验证,原身份和配置已恢复;具体步骤和群主/Agent未验收范围见上述权限说明。
+
+GPT引用文字时,先用QQ消息引用/回复功能选中原消息,再发送`/gpt 本次要求`。程序将引用原文与新要求分开提交给模型;引用卡片需实际存在,仅@不代表引用。普通单问和纯引用用法保持,仍遵守原权限、响应及同会话30秒限速。完整规则见[GPT引用原文与本次要求](gpt-quoted-message.md),主人私聊测试步骤见[主人私聊联调](self-test.md)。
+
+Agent 人物已改为 DeepSeek「鲸鱼娘」风格的 Q 版小人,蓝发蓝瞳、蓝白连帽衫,软萌、聪明,偶尔呆萌自嘲。后台「AI 设置」可查看或修改人物设定;新设定保留对话档案,停止服务时应用的设置在下次启动后使用。普通聊天、GPT 与总结仍采用独立的系统提示词。详细说明见 [小人人物设定](ai-persona.md)。
+
+两种接口都已配置为 DeepSeek 的 `deepseek-flash`,区别是输入上下文、模型输出和后续执行方式。
+
+| 入口 | 行为 | 如何触发 |
+| --- | --- | --- |
+| 普通 AI 聊天 | 返回文字,使用近期内存上下文 | 当前会话关闭 Agent 后,@ 机器人发消息,或按配置概率响应非 @ 消息 |
+| Agent | 生成 OneBot 动作,检查权限并执行,把动作结果交回模型;历史持久保存 | 当前会话开启 Agent 后,同样 @ 机器人发消息,或按配置概率触发;没有单独的 `/agent` 入口 |
+| `/gpt 问题` | 使用聊天接口处理本次问题,不切换会话模式 | 直接发命令,或回复一条消息后发送 `/gpt 问题`;没有问题时可只发送 `/gpt` 使用被引用内容 |
+| `群聊总结 200` | 读取指定数量群消息并调用聊天接口总结 | 群内由主人、群主或管理员发送;省略数量默认 200,最多 1000 |
+
+**开启 Agent 后,普通聊天触发优先走 Agent,`/gpt` 和群聊总结仍使用聊天接口。** 其他插件的明确命令仍由原插件处理。Agent 未产生可执行动作时可能回退到普通聊天,所以“开启”不代表每次消息最终必定由 Agent 回复。
+
+用机器人主人账号,在目标群或私聊发送以下指令;不加 `/` 前缀:
+
+```text
+查看AI聊天配置
+设置AI聊天使用Agent模式
+设置AI聊天不使用Agent模式
+设置AI聊天响应AT
+设置AI聊天触发概率0
+```
+
+每次只发送一条所需指令。模式、响应 @ 与触发概率按当前群/私聊分别保存;在主人私聊里修改,不会自动应用到目标群。群内可以 @ 机器人后发送配置指令。开启“响应 @”后,@ 消息不受随机触发概率限制;概率 0 不主动响应非 @ 消息。模式和响应 @ 由主人管理,触发概率也允许群管理员管理。后台的“使用 Agent 模式”对应同一个会话设置。
+
+测试 Agent:在目标群开启模式后发送 `@机器人 你好,接着刚才的话题聊`。测试普通聊天:在同一会话关闭模式后再次 @ 发消息。测试单次 GPT:发送 `/gpt 请用一句话解释什么是 API`,不需要关闭 Agent。
+
+还须满足 aichat/aichatcfg/llm 对应插件启用与会话响应条件。群内开启响应由主人、群主或管理员发送 `@机器人 /响应`;`/沉默` 同样需要 @ 和管理权限。全局响应覆盖群门控,相关规则见 [运行维护](operations.md)。普通成员私聊不能使用 Agent 的私聊发送动作;主人可用,普通聊天与 GPT 不依赖这项 Agent 动作权限。Agent 不增加发起者或机器人原本没有的管理权限。
+
+GPT 与群总结共用每群/私聊 30 秒一次的额度,失败请求也计入;普通聊天与 Agent 不使用这项 GPT 限额。请求会把问题、所需上下文或群总结的昵称/文字发送到配置的模型接口。
+
+## 回复显示与记录
+
+`/gpt` 和群聊总结的最终文字,200 个 Unicode 字符以内(含 200)直接发送;超过 200 字以合并聊天记录发送,完整保留,单个节点最多 1000 字。总结标题、标点、空格、换行都计入长度。这项阈值当前只用于这两个入口;普通 AI 聊天和 Agent 沿用各自的发送路径。
+
+Agent 没有固定 8 轮行动上限;任务完成、错误或重复动作时结束。记录从新版本接收事件后保存到 `data/aichat/history/<机器人QQ>.sqlite`,群与私聊隔离。重启或修改人物设定后继续读取;单次模型请求只加载容量允许的近期记录,磁盘档案仍保留。升级前旧进程的内存历史无法补回。
+
+主人指令 `重置AI聊天Agent` 开启新的 Agent 上下文并保留旧档案;这是当前已加载机器人的会话重置操作,不应当作只清当前群。`重置AI聊天` 清空普通聊天的内存上下文。人物设定在后台修改后应用,历史继续保留。详细记录、文件备份与限制见 [Agent 连续对话](agent-history-2026-10-03.md)。后台配置备份不包含对话档案、长期记忆、QQ 登录或全部插件文件。
+
+## 没有回复时
+
+先检查概览中的连接和机器人账号,再检查插件开关、响应门控、当前会话 Agent/响应 @/触发概率。用主人私聊 `/gpt 你好` 验证聊天接口,并与上次 GPT/总结请求间隔至少 30 秒。Agent 无回复还需检查角色权限、模型动作解析和存储错误,不能仅凭收到文字就判断一定走了 Agent。
+
+到运行日志查看请求失败或 Agent 动作数/结束状态;密钥保持隐藏,接口、密钥及模型配置在后台核对。浏览器报错、保存冲突或备份失败时按页面提示处理,不反复提交不确定的修改。尚未完整验收的场景见 [待办](todo.md)。
+
+## Windows 路径说明
+
+项目可能通过目录联接使用不同路径入口。启停脚本以解析后的实际项目根和完整进程身份为准,识别同一项目的别名,避免重复启动;不要仅因路径显示不同就复制配置或删除联接。说明见 [Windows 服务路径](windows-paths.md)。
+
+## 页面操作细节
+
+插件列表显示当前范围未保存开关数量,卡片标记“未保存”;改回原模式清除标记。可单项保存或 [逐项保存全部草稿](plugin-save-all.md),逐项成功立即生效、遇错停止并保留余下选择;搜索、筛选、切页保留草稿。刷新/切群/退出先确认,取消及读写失败保留选择;请求中等待完成,退出或过期清空,旧请求不影响新会话。详见 [插件编辑保护](plugin-drafts.md)。
+
+连接设置会标记地址/Token与主人账号的未保存更改。重新加载/退出先确认,取消或读写失败保留草稿;切换连接只确认当前连接修改,主人草稿保留;切页不丢输入。请求中等待完成,退出或过期清空,旧响应不会回填或解锁新会话。保存后仍需重启机器人,不自动修改 NapCat。步骤见 [连接编辑保护](connection-drafts.md)。
+
+排障时,在概览“近期异常”点击“查看异常日志”,进入日志并应用警告/错误/Fatal/Panic组合,清空旧关键词;“只看异常”保留已应用关键词,“清除筛选”恢复全部。查看/导出使用页面标明的已应用条件,输入修改需提交才生效;快速筛选保留暂停/滚动偏好。见 [异常日志筛选](log-issues.md)。
+
+备份列表支持范围、校验状态和编号/日期组合检索,显示匹配数与已加载总数;有更早未加载记录时只搜索本次最新200份。筛选只调整显示,不影响已打开恢复/清理目标;恢复预览明确显示备份编号。条件跨页/刷新保留,读取失败标记上次结果,退出清空。见 [备份检索](backup-search.md)。
+
+备份恢复页默认每页20份,可选择10或50份,以“上一页”“下一页”浏览筛选后的记录;列表位于清理表单之前。改变筛选或份数回首页,刷新保留当前页并在结果减少时校正;切页保留,退出重置。分页仍只涵盖已加载的最新200份,不改变恢复/清理目标,打开恢复预览自动定位到编号与确认处,见 [备份分页](backup-pagination.md)。
+
+读取备份列表后可“下载筛选清单”,生成UTF-8 JSON,包含所有已加载匹配页的编号/时间/范围/状态等目录信息和最近读取时间。仅加载最近200份或刷新失败时会明确标记;不写关键词原文、配置正文或密钥,不能用来恢复。不新增接口请求,不修改备份,详见 [下载备份清单](backup-catalog-export.md)。
+
+恢复和清理预览显示截止时间与本机估算剩余分秒。到期后取消确认、禁用执行,须重新预览并再次确认;页面隐藏暂停计时,回来即核对。计时不发网络请求,不自动执行,也不中断已提交的请求;服务端仍校验当前状态。见 [备份预览有效期](backup-preview-expiry.md)。
+
+插件列表支持默认20个、可选10/50个分页;搜索和实际状态筛选覆盖全部已加载插件。翻页保留所有开关草稿,总数包含隐藏项;单项保存规则保持,读取成功换范围回首页,刷新/保存后校正页码。详见 [插件分页](plugin-pagination.md)。
+
+插件列表可勾选“只看未保存”,结合搜索/实际状态集中查看全部页草稿;“清除筛选”取消该限制且保留草稿。“放弃全部更改”确认后撤销当前范围全部未保存选择,包含隐藏项,只清页面内存,不发请求或改变已生效规则。详见 [待保存插件开关](plugin-pending.md)。
+
+AI设置各表单提供“放弃此表单更改”,确认后恢复该表单最近读取或保存成功的页面基线,保留其他表单草稿;全局恢复同时清空新密钥输入,动作回到保留。仅本页内存操作,不请求接口或回退已保存设置,见 [AI 按表单放弃更改](ai-discard.md)。
+
+连接设置可分别“放弃当前连接更改”和“放弃主人账号更改”,确认后恢复选中部分的读取/保存基线,保留另一部分草稿;连接撤销清空新Token并回到保留。仅页面操作,不写配置,待重启提示保持,见 [连接设置局部放弃](connection-discard.md)。
+
+运行日志支持按浏览器本地起止时间筛选,可留空一端;点击应用后查看与导出使用相同范围,倒置输入不能应用,见 [日志时间筛选](log-time-filter.md)。
+
+“热词”首次有效调用加载词典,失败提示稍后重试;不会再因为词典下载失败导致整个机器人启动中止,见 [热词启动保护](wordcount-startup.md)。
+
+后台密码修改入口为“后台安全”,成功后所有后台会话需重新登录,不修改QQ/OneBot/模型密钥;完整步骤与异常处理见 [修改后台密码](admin-password.md)。
+
+恢复预览支持类别影响对比,显示当前/备份数量及是否变化,摘要不展示密钥或原始内容;实际恢复仍覆盖整个所选范围,见 [恢复预览影响对比](backup-impact.md)。
+
+连接设置新增正向OneBot连接的入口、Token与显式无Token、草稿/重复保护、备份和重启规则见 [新增正向连接](connection-add.md)。
+
+连接删除的目标预览、确认、备份与重启规则及最后一条保护见 [删除连接](connection-delete.md)。
+
+新增本机反向监听的方向、地址/Token要求与接入步骤见 [本机反向监听](reverse-connection.md)。
+
+连接设置的启动与已保存分类、数量和内容变化摘要见 [启动配置差异](startup-differences.md)。
+
+多项插件开关草稿的逐项保存、进度及停止规则见 [逐项保存全部插件草稿](plugin-save-all.md)。
+
+刷新后台后进入“插件列表”,选择全局或读取指定群,在“响应状态说明”查看最近成功读取的全局/群单独状态和来源。全局或群单独开启均可通过响应检查;全局关闭不代表所有群沉默,插件开关还需分别核对。修改群号但未读取不会切换说明目标,刷新状态才能获得新的规则;本页只读。原群指令仍须主人或群管理员 @机器人,完整权限和步骤见 [响应状态说明](response-status.md)。
+
+插件列表点击“查看用法”,可“复制用法”获取注册说明原文,或“下载文本”保存名称、简介和说明的UTF-8文件。复制失败时按提示 Ctrl+C 手动复制;没有注册说明的插件不提供导出。导出不修改开关、不执行QQ指令,也不包含后台配置或聊天记录。详见 [插件用法导出](plugin-usage-export.md)。
+
+AI设置页上方的“AI配置检查”分别说明聊天/GPT、Agent和可选识图的类型、模型、地址和密钥配置。摘要采用最近成功读取/保存的全局设置,未保存草稿不影响结果;“基础字段检查通过”仍未验证实际调用,OLLaMA允许无密钥,识图未配置不阻止文字聊天。地址含旧认证参数需核对但不会自动修改。详见 [AI 配置检查](ai-config-check.md)。
+
+运行日志页的“日志匹配统计”显示最近成功读取时当前保留范围的全部匹配量和各级别数量,独立于页面已显示条数或本次最多200条返回;修改未应用的输入不改变统计,应用筛选/刷新先清除并等待新结果。暂停、隐藏或读取失败保留旧统计,恢复后更新;它不代表历史全部故障或当前问题已解决。详见 [日志匹配统计](log-summary.md)。
+
+日志页支持最近15分钟、1小时、24小时快捷筛选及“不限时间”;沿用已应用的级别与关键词,范围固定到点击时刻,查看/统计/下载一致,详见 [日志快捷时间范围](log-quick-range.md)。
+
+插件列表支持默认顺序、名称、启用优先、禁用优先及未保存优先;排序覆盖全部匹配插件再分页,保留草稿,清除筛选保留排序,见 [插件列表排序](plugin-sort.md)。
+
+插件卡片支持“放弃此项更改”,确认后只恢复此插件的页面基线,保留其他页和隐藏草稿;不会撤销已保存的规则,见 [放弃单个插件草稿](plugin-discard-one.md)。
+
+备份恢复页可按时间从旧到新、大小从大到小或从小到大排序全部已加载匹配项;分页和下载清单沿用所选顺序,见 [备份列表排序](backup-sort.md)。
+
+备份恢复页提供已加载/匹配和固定范围的备份内容大小摘要,异常大小单列;不代表目录实际占用,见 [备份内容大小](backup-size-summary.md)。
+
+暂停日志更新时可点击“读取一次”,按已应用条件读取新日志和匹配统计,完成后仍保持暂停,见 [暂停时读取一次日志](log-read-once.md)。
+
+备份列表可按本地开始/结束日期组合筛选,结束日期包含完整当天;列表、大小摘要与清单采用同一组已应用日期,清理策略和恢复目标保持,见 [备份日期筛选](backup-date-filter.md)。
+
+插件列表可下载当前已加载全局/群范围的全部匹配状态清单,沿用筛选与排序,草稿只标记未保存;仅导出最近已知插件开关状态,不能导入或恢复,见 [下载插件状态清单](plugin-state-catalog.md)。
+
+运行日志每条记录可点击“复制本条”,只复制页面已显示的时间、级别与脱敏正文,剪贴板受限时选中正文手动复制,详见 [复制本条日志](log-copy-entry.md)。
+
+AI设置页会按表单汇总未保存字段名称和数量,密钥动作/输入/确认合并计数,不显示新旧值;范围以该表单成功加载目标为准,详见 [AI未保存字段摘要](ai-draft-fields.md)。
+
+连接设置顶部现提供[未保存字段摘要](connection-draft-fields.md),分别核对实际加载连接、主人和新增表单的改动字段名称与数量;不显示字段值,保存与重启仍按原规则执行。
+
+日志页可[清空当前显示](log-clear-display.md):仅移除本页已显示记录,保留读取位置、筛选和更新状态,统计与导出仍使用服务保留记录;需要再次查看近期日志时重新加载后读取。
+
+维护已有交付记录可运行[本机交付证据核对](delivery-evidence-check.md),核对测试记录、磁盘程序和文档资源,不需要后台密码,也不会重启或访问机器人服务。
+
+完整恢复页面人工操作可使用[本机隔离后台验收环境](isolated-admin-acceptance.md),在独立目录准备三类基线备份与不同的当前值,终端只重启本次测试实例;自动环境检查不替代人工验收,不影响日常机器人。
+
+AI设置的保存、密钥清除、版本冲突、群/同号私聊隔离和独立重启持久状态已完成21组真实Chrome自动核对,无需逐页人工查看;使用范围与复验步骤见[AI页面自动验收](ai-browser-acceptance.md)。日常密钥不用于演练,页面基础检查仍不代表真实模型可调用。
+
+AI设置中的接口类型和密钥操作下拉框已统一为全宽主题控件,点击高度至少44px,640px及以下字体16px;19:10:50已部署,刷新后台即可使用。保存、密钥确认和会话范围保持,见[AI下拉框显示](ai-select-appearance.md)。实际64项主题/小屏布局与12组日志控件已自动核对;实际下载文件及原生日期输入的未完成范围见[页面验收记录](logs-appearance-browser-acceptance.md)。
+
+日志导出文件和原生日期范围已完成13组真实Chrome核对:当前10份实际文件及前轮3份补证通过,倒置/单端/空结果和未应用条件保留正确。分钟格式和非零秒格式原生日期输入正常,步骤与证据见[实际文件验收](log-export-date-browser-acceptance.md)。文档所列页面待办已关闭,真实QQ其他角色和持续性能仍按各自待办,不需要逐页人工查看。
+
+## 维护与验收
+
+已有模型报告、前后回复快照和资源目录时,可运行[模型回复与资源联合核对](combined-observation.md)一次检查原件一致性、精确进程、资源覆盖与两种窗口的交集。模型次数、回复任务和发送确认分别保留;联合通过也不表示真实业务通过。26项新增/210项工具回归和既有3组资料复核通过,无需日常密码或重启服务。
+
+维护者可用[独立AI持续负载观察](isolated-ai-load.md)自动运行本地模拟请求,采集资源及模型/回复快照并核对同进程和完整覆盖。当前2C3F30的15分钟/45次合成GPT及两种关联核对通过,资源私有内存最大增长约3.45MiB;不需日常密码或重启服务。它是每分钟3条的模拟负载,真实持续业务仍待验收;客户端体验已按用户决定关闭。
+
+已下载的起始/结束回复快照可用[回复快照离线核对](qq-reply-evidence.md)自动检查任务/回执增量、统计起点和精确进程;可附带资源目录核对覆盖范围与身份。无需后台密码或重启服务,工具会拒绝重启后的合并和错误配对,不计算窗口平均/最长,也不宣布真实业务通过。新增72项与原112项工具回归共184项通过,既有三份下载原件复核结果正确。
+
+在概览的AI回复处理耗时区域可[下载回复统计快照](qq-reply-snapshot.md)。等待读取成功后点击“下载回复统计”,保存最近读取的三类累计任务/确认次数、首末回复耗时、读取时间及可读取的进程信息,不增加接口读取或业务请求。正常使用前后分别保存,并核对同一进程和统计起点;文件没有窗口平均/最长或业务通过结论,模型观察报告仍单独下载。
+
+只有资源观察资料时,可用[仅资源离线核对](observation-evidence-check.md):省略维护命令的`--model`参数,工具从原始样本重算CPU/内存、时长和节奏,检查已保存摘要。该模式通过也不表示模型窗口已关联或真实业务验收完成;无需登录后台或重启服务。
+
+当前2C3F30的[20分钟日常资源观察](live-performance-observation.md)已完成:240样本/1200.026秒及重算通过,CPU平均约0.0020%、私有内存最大增长72KiB,追加非异常日志1行。原F86EA6的15分钟及2C3F30独立模拟窗口保留原范围;没有配套业务计数,不关闭真实持续负载待办。
+
+登录后的“运行概览”可查看[AI回复处理耗时](qq-reply-latency.md):按GPT/群总结、普通聊天、Agent/识图显示任务、发送确认与首条/最后一条平均及最长耗时。从机器人匹配入口计到当前会话发送回执,包含模型等待;重启清零,不保存正文或账号。部分回复后失败也可进入有回复任务的平均值,不能据平均值推断成功率。统计不覆盖QQ客户端到机器人之前的延迟,也不证明用户收到或已读;现有模型观察下载仍只包含模型传输统计。
+
+当前观察报告已补齐[进程关联](model-observation-identity.md):Windows正常读取身份时下载版本2,包含本次进程编号、操作系统创建时间和程序摘要。离线工具可将它与资源目录逐项匹配;旧格式继续支持。228项界面及96项工具回归通过,日常短诊断身份吻合;完整持续负载与QQ回复耗时仍需另行记录。
+
+维护者可用[模型与资源观察证据核对](observation-evidence-check.md)离线检查已下载的模型报告和资源目录。工具从原始样本重算指标,指出摘要不一致、资源窗口不足或时间不重合;无需重启机器人。版本2逐项比对进程身份,版本1仅时间兼容仍不能证明同一进程;两种结果都不等于真实QQ业务通过。
+
+“运行概览”可[记录模型请求观察起点并下载报告](model-observation-report.md)。先等待状态读取,记录最近快照,再正常使用机器人并刷新/等待自动刷新,下载前后累计值与窗口次数。起点仅保存在当前页面,退出或刷新网页清除;重启后重新记录。报告不会包含聊天、账号或配置,也不把累计最长耗时当成窗口最长值。
+
+“运行概览”新增[模型接口请求统计](model-request-statistics.md),按GPT/群总结、普通聊天、Agent及识图显示发起/进行中/接口完成/失败/提前关闭次数和已结束请求的平均、最长耗时。只记录本次启动,不记录聊天内容;重启后清零。接口完成不等于QQ回复成功,资源观察工具尚不直接读取这些统计。
+
+新增连接与放弃新增使用[新增确认弹窗](connection-add-confirmation.md);已有连接与主人账号草稿的局部放弃、切换连接、重新加载及退出登录也已补齐[页面确认](connection-draft-confirmation.md)。默认焦点在“取消”,取消或 Esc 保留草稿。局部放弃只影响对应部分,切换连接保留主人和新增草稿;重新加载成功或退出登录清空连接设置草稿。原保存备份及重启规则保持,当前212项界面回归与20项实际Chrome草稿确认流程通过,部署状态见专项说明。
+
+维护多连接配置可按[连接隔离验收说明](connection-browser-acceptance.md)启动双模拟账号后台。测试保存、校验和重启只影响独立目录;新增、重复、删除、已有连接/主人草稿的确认与取消、独立重启均已有实际Chrome证据。当前页面确认可正常自动操作;实际Chrome的 Esc 输入不在现有工具能力内,离线Chrome键盘回归已通过。测试无需修改日常连接。
+
+维护时可按[持续性能核对说明](browser-performance.md)启动独立测试实例,自动模拟消息并采集程序资源,再通过已连接的 Chrome 采集页面指标。2026-10-12 D3A057构建四组后台对照及日志暂停/更新两组五分钟浏览器测量通过,保留当时构建范围。当前日常服务可用[只读性能观察](live-performance-observation.md)在正常使用期间记录15分钟或更长的资源与新增日志计数,无需发送测试消息或重启服务;短窗口和提前停止不计完成。资源正常不代替真实QQ/模型业务负载验收。
diff --git a/docs/local-admin/windows-paths.md b/docs/local-admin/windows-paths.md
new file mode 100644
index 0000000000..907b03c01b
--- /dev/null
+++ b/docs/local-admin/windows-paths.md
@@ -0,0 +1,11 @@
+# Windows服务路径与目录联接
+
+同一项目可能通过目录联接使用多个路径入口。启停脚本先解析实际项目根,再核对服务脚本、工作目录、可执行文件、PID及进程启动时间;不能只凭文件名或程序摘要判断进程归属。
+
+启动会复用属于同一实际项目的托管任务,避免通过不同别名重复启动。停止仅处理身份明确匹配的本项目服务,其他目录的机器人仍是独立实例。路径显示不同本身不构成复制配置、删除联接或重置凭据的理由。
+
+## 开发者验证
+
+在仓库根目录执行scripts/test-service-paths.ps1。脚本在.local自建测试目录联接,检查8项任务夹具、4项进程夹具和5份脚本语法,结束时只删除已核实的测试联接,不递归操作其目标,也不启停真实服务。无需依赖开发者本机固定的C/D目录布局。
+
+相关使用入口见[运行维护](operations.md)。运行记录中的动态路径属于本机资料,不随源码上传。
diff --git a/docs/local-admin/windows-setup.md b/docs/local-admin/windows-setup.md
new file mode 100644
index 0000000000..3f45b712a3
--- /dev/null
+++ b/docs/local-admin/windows-setup.md
@@ -0,0 +1,102 @@
+# Windows 开发环境与实测记录
+
+## 结论
+
+本项目支持 Windows 原生开发,不需要 WSL。本机已完成 Windows/amd64 编译、选定测试和依赖校验。2026-10-01 增加公告请求总超时及资源校验协议读写超时,补齐缺失启动素材后,帮助命令完成初始化并输出用法,实际 ZeroBot 进程已启动并连接到本机 NapCat。
+
+当前具备本机编译、运行和真实 QQ 联调条件;帮助及服务列表私聊回复已由用户与日志确认通过。各业务插件及新管理后台尚未完整验收,见 [本次基本功能检查](basic-smoke-2026-10-01.md) 和 [OneBot 环境与自测](onebot-windows.md)。
+
+## 已拉取的内容
+
+| 内容 | 位置/版本 | 状态 |
+| --- | --- | --- |
+| 主项目完整 Git 历史 | 当前仓库;开发分支 `codex/local-admin` | 已拉取,非浅克隆 |
+| 个人 Fork | `origin` → `https://github.com/qiye-yuri/ZeroBot-Plugin.git` | 已拉取远端分支和备份分支 |
+| 上游 | `upstream` → `https://github.com/FloatTech/ZeroBot-Plugin.git` | 已拉取;本次上游与 Fork master 均为 `65619f0` |
+| Go 工具链 | Go `1.26.1 windows/amd64` | 已下载并用于编译 |
+| Go 模块 | `go.mod` / `go.sum` 锁定版本;本机 `<项目目录>` | 已下载,`go mod verify` 通过 |
+| 旧 WebUI 源码与 dist | `.local/references/ZeroBot-Plugin-Webui`,`v1.0.1-0.20230412164529-cfe878675931` | 已从锁定模块版本复制供参考;未安装前端 npm 依赖,未启用旧后台 |
+| data 资源子模块 | `6708169f6346d3b97998d4230e44b931617ac5fc` | 已接回子模块并核对版本;使用部分克隆和稀疏检出 |
+| Windows 程序 | `.local/bin/zerobot.exe` | 已生成,42,877,440 字节;后续构建体积可能变化 |
+| 启动词典 | `data/Chat/dict.txt` | 按资源子模块锁定提交补齐;约 5 MiB |
+| 百人一首启动 CSV | `data/hyaku/小倉百人一首.csv` | 从作者 GitHub 原仓库补齐;19,579 字节;来源摘要见本次检查记录 |
+| registry 超时补丁 | `third_party/go-registry` | 基于锁定 v0.2.7 源码,保留许可;通过 go.mod replace 使用 |
+
+资源稀疏检出目前包括 `Control/kanban.png`、`Control/zbpuwu.png`、四个启动字体 GlowSans、Impact、Torus、Yuruka 和 `Chat/dict.txt`,以及资源仓库说明/许可文件。它们用于插件控制与分词初始化;运行期间另有插件按需下载素材,不代表全部资源已下载。
+
+锁定资源快照包含约 733 MiB 文件,另外还有嵌套资源仓库;完整素材下载曾停滞。大型游戏、图片和其他插件素材尚未全部下载,后续根据保留插件逐项补齐。不要把部分克隆描述成完整素材包。若确实需要全部 data 素材,可在网络稳定时于项目根目录执行:
+
+```powershell
+git -C data -c http.sslBackend=openssl -c http.version=HTTP/1.1 sparse-checkout disable
+git -C data submodule update --init --recursive
+```
+
+这会下载更多素材,且懒加载资源仍可能来自其他服务。无需为本地后台第一版预先准备全部娱乐插件素材。
+
+## 修改后编译与测试
+
+在项目根目录执行:
+
+```powershell
+# 下载缺失依赖、校验、生成代码并编译
+.\scripts\build-windows.ps1
+
+# 同时执行已选定的基线测试
+.\scripts\build-windows.ps1 -RunTests
+```
+
+脚本不会调用 `go mod tidy` 或写入全局 Go 配置,避免仅为启动而改动依赖;`CGO_ENABLED=0` 只作用于本次脚本并在结束后恢复。
+
+当前选定测试为 `kanban`、`plugin/chess` 和 `plugin/tarot`,以及单独执行本地 `third_party/go-registry` 模块的超时测试。包初始化可能访问公告服务或下载字体。其他包中存在外部服务测试和显式 `t.Fail()` / `t.Fatal()` 的调试测试,尚未作为可靠的全仓验收门槛。
+
+## 真实机器人运行准备
+
+本机已部署 NapCat v4.18.28,QQ 已登录。登录页监听 `127.0.0.1:6099`,OneBot 正向 WebSocket 实际监听 `127.0.0.1:6700`,三个只读 API 已通过检查。安装脚本已生成本地 Token 并与 `.local/config.json` 配对,重复执行保留现有配置。详见 [OneBot 环境与自测](onebot-windows.md)。后台本身不负责 QQ 登录,也尚未实现。
+
+复制示例到已被 Git 忽略的位置;如果文件已存在,不覆盖原配置:
+
+```powershell
+New-Item -ItemType Directory -Path .local -Force | Out-Null
+if (-not (Test-Path .local/config.json)) {
+ Copy-Item docs/local-admin/config.windows.example.json .local/config.json
+}
+```
+
+编辑 `.local/config.json`,填入自己的主人 QQ 号到 `zero.super_users`。本机 `Url` 和 `AccessToken` 已配对;更改 OneBot 服务配置时需同步更新。示例不包含真实凭据;其中时长数字遵循上游 JSON 格式,单位为纳秒。
+
+正常手动启动使用以下命令。本次后台实例已运行,PID、配置和日志位置记录于 `.local/zerobot/process.json`;不要重复启动第二个实例。
+
+```powershell
+.\scripts\run-windows.ps1 -ConfigPath .local/config.json
+
+# 需要将输出重定向到文件时使用现有 Windows 兼容开关
+.\scripts\run-windows.ps1 -ConfigPath .local/config.json -Headless
+```
+
+`-Headless` 设置上游已有的 `DEBUG_MODE=1`,只用于容忍重定向时无有效控制台句柄,不代表后台网页已启用。脚本使用仓库根目录运行,插件数据写入 `data` 对应目录;修改资源或配置后提交前仍需检查主仓库和子模块状态。
+
+FFmpeg、TiMidity 尚未在当前命令搜索路径中发现,使用视频解析、猜歌、MIDI 转换等相关插件时需另行准备;它们不是本次 Go 编译的前置条件。QQ 登录及 OneBot 三个只读 API 已通过验证,完整消息联调仍待完成。
+
+## 实际验证结果
+
+| 编号 | 验证 | 结果 | 证据/限制 |
+| --- | --- | --- | --- |
+| WIN-01 | `go generate main.go` 与 `CGO_ENABLED=0 go build -trimpath` | 通过 | 本机生成 Windows `.exe` |
+| WIN-02 | `go test ./plugin/chess ./plugin/tarot -count=1 -timeout=60s` | 通过 | 两包分别约 18.1 秒、20.4 秒;不代表全仓测试通过 |
+| WIN-03 | `go mod verify` | 通过 | `all modules verified` |
+| WIN-04 | 新建构建脚本实际执行 | 通过 | 工具链、依赖校验、生成和编译全过程退出码为 0 |
+| WIN-05 | PowerShell 脚本语法及 JSON 示例解析 | 通过 | 两个脚本无语法错误;JSON 可解析 |
+| WIN-06 | 执行 `zerobot.exe -h`,日志重定向 | 新构建完成 | 旧构建超时;2026-10-01 补丁与素材就绪后输出 Usage,未单独保存该次最终退出码 |
+| WIN-07 | 初始化跟踪 | 已记录 | 旧记录 `.local/smoke-help/trace-stderr.log`;新记录 `.local/smoke-restart/help2-stderr.log` 完成全部初始化 |
+| WIN-08 | 真实 QQ 收发与新后台页面 | 进行中 | QQ/OneBot 已在线,ZeroBot 握手及帮助/服务列表真实回复通过;新后台尚未实现 |
+| WIN-09 | 实际配置启动 ZeroBot 并连接 NapCat | 通过 | `.local/zerobot/process.json` 及日志;进程存活、WS 握手成功,无启动 panic |
+| WIN-10 | 公告协议与 registry 无响应回归 | 通过 | `go test ./kanban` 及本地 registry 模块测试;正常公告、错误状态与超时均覆盖 |
+
+`.local/` 与选定测试生成的 `plugin/*/data/` 已加入忽略规则。代码及文档修改尚未提交或推送。
+
+## 后续必须处理的问题
+
+1. 继续排查其他插件外部服务与资源下载边界;已修复公告及 registry 读写等待,不代表所有第三方接口都具备超时及降级。
+2. 确定保留插件并补齐其资源;对其他外部接口逐项验证。
+3. 旧 WebUI 的 `model/webui.go` 将密码作为字符串保存并拼接查询;路由中日志等接口未挂认证中间件,CORS 反射请求来源。当前仅作源码参考,不直接启用;复用结论需结合现有控制层重新设计认证及接口边界。
+4. 完成基础私聊收发检查后,再逐项进行权限、保留插件和后台功能验收。
diff --git a/docs/local-admin/wordcount-startup.md b/docs/local-admin/wordcount-startup.md
new file mode 100644
index 0000000000..946ec1a9aa
--- /dev/null
+++ b/docs/local-admin/wordcount-startup.md
@@ -0,0 +1,13 @@
+# 热词资源加载与启动保护
+
+部署日志时间筛选时发现上游聊天热词插件在启动期间获取分词词典,下载遇到EOF会panic,旧版本也无法启动。修复将词典读取与解析移到首条有效热词指令执行前,与停用词一起按成功加载;启动不再依赖该词典下载。
+
+群中按原权限、响应及@规则发送“热词”或“热词 1000”(消息数最多10000)。首次有效调用加载词典和停用词,成功后本次进程复用;任一资源读取/解析失败,会提示资源暂不可用并允许下次重试,不执行后续绘图。并发调用串行初始化,只有词典与停用词均成功后才发布资源,不使用半成品。重启后首次调用重新加载。
+
+字典仍沿用原资源校验和下载规则,不自动关闭插件、修改开关或写入替代词典。字体、历史消息权限、停用词及绘图依赖保持原限制;本项不保证外部资源源站可用。后台、help及其他功能可独立启动。
+
+6组真实PNG回归通过,覆盖单个单次/多次、等频单次/多次/大次数和不同词频;四个Go包、静态检查、22:18:20完整Windows隔离回归与22:22:03部署检查通过。首轮完整Go回归受沙箱临时目录ACL与模拟HTTP回环限制失败,保留初始记录,宿主重跑通过,不声称首轮全通过。本轮未修改后台UI,197项离线界面和64项布局是原记录,未重跑。
+
+修复后真实指令 `1000801252` 收到图片消息 `808217455`,图为1024×500,四个热词各1次,0~1纵轴正常;本机图像视觉检查通过,已复制为`.local/qq-debug-wordcount.png`。用户确认输出正常并提供QQ客户端下载图片路径,打开文件与本机图相同,SHA-256均为`49E91C577A328165155D82C3151046B9ECB1A2DE0F7373FB81A1AB42DE82654A`,证据`.local/qq-debug-wordcount-verified.json`。DEV-57/TC-77完成,TODO-WORDCOUNT-01已关闭,不把本轮小样本当作全消息量性能验证。
+
+验收范围:本机启动与原OneBot连接;词典缺失/下载失败/格式错误及重试由合成资源隔离测试覆盖。真实QQ群热词完整出图仍需按实际资源与权限验证,不自动发送测试QQ消息。
diff --git a/go.mod b/go.mod
index 713a4087a4..14cffdbc69 100644
--- a/go.mod
+++ b/go.mod
@@ -105,4 +105,17 @@ require (
replace modernc.org/sqlite => github.com/fumiama/sqlite3 v1.29.10-simp
+// Bound registry I/O used during resource initialization; see the local patch notes.
+replace github.com/fumiama/go-registry => ./third_party/go-registry
+
+// Share checked plugin writes, inheritance and snapshots with chat commands.
+replace github.com/FloatTech/zbpctrl => ./third_party/zbpctrl
+
+replace github.com/FloatTech/sqlite => ./third_party/sqlite
+
+replace github.com/FloatTech/zbputils => ./third_party/zbputils
+
+// Persistent Agent history while keeping upstream action/permission parsing.
+replace github.com/fumiama/go-onebot-agent => ./third_party/go-onebot-agent
+
replace modernc.org/libc => github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5
diff --git a/internal/adminqa/harness_windows.go b/internal/adminqa/harness_windows.go
new file mode 100644
index 0000000000..90adc76069
--- /dev/null
+++ b/internal/adminqa/harness_windows.go
@@ -0,0 +1,459 @@
+// Package adminqa supplies isolated Windows processes for local admin acceptance checks.
+package adminqa
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/cookiejar"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+ "unsafe"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/RomiChan/websocket"
+ "golang.org/x/sys/windows"
+)
+
+const Owner = int64(12345678)
+const Password = "Isolated-admin-acceptance-only"
+
+type peer struct {
+ conn *websocket.Conn
+ mu sync.Mutex
+}
+type Harness struct {
+ Root, Binary, ConfigPath, AdminDir, Address string
+ server *http.Server
+ listener net.Listener
+ connections chan *peer
+ replies chan string
+ current *peer
+ messageID int
+ mockQQ int64
+}
+
+func WriteJSON(path string, value any) error {
+ b, err := json.MarshalIndent(value, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, b, 0600)
+}
+
+func FreeAddress() (string, error) {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return "", err
+ }
+ defer l.Close()
+ return l.Addr().String(), nil
+}
+
+func PrepareFixture(root, destination string) error {
+ for _, relative := range []string{"data/Control/kanban.png", "data/Control/zbpuwu.png", "data/Font/GlowSansSC-Normal-ExtraBold.ttf", "data/Font/Impact.ttf", "data/Font/Torus-Regular.otf", "data/Font/FOT-Yuruka Std.ttf", "data/Chat/dict.txt", "data/hyaku/小倉百人一首.csv", "data/Dish/dishes.db", "data/Pig/pig_data.json", "data/McFish/articlesInfo.json"} {
+ source, err := os.Open(filepath.Join(root, filepath.FromSlash(relative)))
+ if err != nil {
+ return err
+ }
+ path := filepath.Join(destination, filepath.FromSlash(relative))
+ if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ source.Close()
+ return err
+ }
+ target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ source.Close()
+ return err
+ }
+ _, err = io.Copy(target, source)
+ closeErr := target.Close()
+ source.Close()
+ if err != nil {
+ return err
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ }
+ return nil
+}
+
+func New(root, binary, destination string) (*Harness, error) {
+ return NewWithIdentity(root, binary, destination, 87654321)
+}
+
+// NewWithIdentity prepares a separate synthetic OneBot account for multi-connection checks.
+func NewWithIdentity(root, binary, destination string, mockQQ int64) (*Harness, error) {
+ if mockQQ <= 0 {
+ return nil, fmt.Errorf("synthetic account must be positive")
+ }
+ if err := PrepareFixture(root, destination); err != nil {
+ return nil, err
+ }
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return nil, err
+ }
+ address, err := FreeAddress()
+ if err != nil {
+ l.Close()
+ return nil, err
+ }
+ h := &Harness{Root: destination, Binary: binary, ConfigPath: filepath.Join(destination, "config.json"), AdminDir: filepath.Join(destination, "admin"), Address: address, listener: l, connections: make(chan *peer, 8), replies: make(chan string, 64), mockQQ: mockQQ}
+ if err = os.MkdirAll(h.AdminDir, 0700); err != nil {
+ l.Close()
+ return nil, err
+ }
+ config := map[string]any{"zero": map[string]any{"nickname": []string{"本机测试"}, "command_prefix": "/", "super_users": []int64{Owner}, "ring_len": 4096, "latency": 233000000, "max_process_time": 240000000000, "mark_message": true}, "ws": []map[string]any{{"Url": "ws://" + l.Addr().String(), "AccessToken": "isolated-lifecycle-token"}}, "wss": nil, "force_base64_file": false}
+ if err = WriteJSON(h.ConfigPath, config); err != nil {
+ l.Close()
+ return nil, err
+ }
+ if err = h.Options(true, address); err != nil {
+ l.Close()
+ return nil, err
+ }
+ // Only synthetic configuration tables are initialized. No live database is read.
+ m := ctrl.NewManager[any](filepath.ToSlash(filepath.Join(destination, "data/control/plugins.db")))
+ if err = m.Response(0); err == nil {
+ for _, name := range []string{"aichat", "chat", "atri", "thesaurus"} {
+ c := m.NewControl(name, &ctrl.Options[any]{})
+ if err = c.Disable(0); err != nil {
+ break
+ }
+ }
+ }
+ closeErr := m.D.Close()
+ if err != nil {
+ l.Close()
+ return nil, err
+ }
+ if closeErr != nil {
+ l.Close()
+ return nil, closeErr
+ }
+ upgrader := websocket.Upgrader{}
+ h.server = &http.Server{ReadHeaderTimeout: 5 * time.Second}
+ h.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer isolated-lifecycle-token" {
+ http.Error(w, "unauthorized", 401)
+ return
+ }
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ p := &peer{conn: conn}
+ p.mu.Lock()
+ err = conn.WriteJSON(map[string]any{"self_id": h.mockQQ, "post_type": "meta_event", "meta_event_type": "lifecycle", "sub_type": "connect", "time": time.Now().Unix()})
+ p.mu.Unlock()
+ if err != nil {
+ return
+ }
+ select {
+ case h.connections <- p:
+ default:
+ return
+ }
+ for {
+ var request struct {
+ Action string `json:"action"`
+ Echo json.RawMessage `json:"echo"`
+ Params struct {
+ Message json.RawMessage `json:"message"`
+ } `json:"params"`
+ }
+ if conn.ReadJSON(&request) != nil {
+ return
+ }
+ p.mu.Lock()
+ userID := Owner
+ if request.Action == "get_login_info" {
+ userID = h.mockQQ
+ }
+ err = conn.WriteJSON(map[string]any{"status": "ok", "retcode": 0, "data": map[string]any{"message_id": 999, "user_id": userID, "nickname": "Synthetic acceptance user"}, "echo": request.Echo})
+ p.mu.Unlock()
+ if err != nil {
+ return
+ }
+ if request.Action == "send_private_msg" || request.Action == "send_group_msg" {
+ var segments []struct {
+ Type string `json:"type"`
+ Data map[string]string `json:"data"`
+ }
+ _ = json.Unmarshal(request.Params.Message, &segments)
+ var text strings.Builder
+ for _, segment := range segments {
+ if segment.Type == "text" {
+ text.WriteString(segment.Data["text"])
+ }
+ }
+ select {
+ case h.replies <- text.String():
+ default:
+ }
+ }
+ }
+ })
+ go h.server.Serve(l)
+ return h, nil
+}
+
+func (h *Harness) Close() {
+ if h.current != nil {
+ h.current.conn.Close()
+ }
+ h.server.Close()
+}
+
+// MockURL identifies this fixture's synthetic OneBot endpoint.
+func (h *Harness) MockURL() string { return "ws://" + h.listener.Addr().String() }
+
+// WaitConnection attaches an extra fixture endpoint to the already started test process.
+func (h *Harness) WaitConnection(ctx context.Context) error {
+ select {
+ case h.current = <-h.connections:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+func (h *Harness) Options(enabled bool, address string) error {
+ return WriteJSON(filepath.Join(h.AdminDir, "options.json"), map[string]any{"enabled": enabled, "address": address})
+}
+
+type Bot struct {
+ cmd *exec.Cmd
+ done chan error
+ handle windows.Handle
+ logs []*os.File
+ stop sync.Once
+}
+
+// Running checks only this fixture's owned process handle.
+func (b *Bot) Running() bool {
+ if b == nil || b.handle == 0 {
+ return false
+ }
+ var code uint32
+ return windows.GetExitCodeProcess(b.handle, &code) == nil && code == 259
+}
+
+func (b *Bot) Stop() {
+ if b == nil {
+ return
+ }
+ b.stop.Do(func() {
+ _ = b.cmd.Process.Kill()
+ <-b.done
+ windows.CloseHandle(b.handle)
+ for _, f := range b.logs {
+ f.Close()
+ }
+ })
+}
+
+func (h *Harness) Start(ctx context.Context, label string) (*Bot, error) {
+ var options struct {
+ Enabled bool `json:"enabled"`
+ Address string `json:"address"`
+ }
+ raw, err := os.ReadFile(filepath.Join(h.AdminDir, "options.json"))
+ if err != nil {
+ return nil, err
+ }
+ if err = json.Unmarshal(raw, &options); err != nil {
+ return nil, err
+ }
+ args := []string{"-c", h.ConfigPath}
+ if options.Enabled {
+ args = append(args, "-admin", "-admin-addr", options.Address, "-admin-data", h.AdminDir)
+ }
+ cmd := exec.CommandContext(ctx, h.Binary, args...)
+ cmd.Dir = h.Root
+ cmd.Env = append(os.Environ(), "DEBUG_MODE=1")
+ cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x08000000}
+ b := &Bot{cmd: cmd, done: make(chan error, 1)}
+ for _, stream := range []string{"stdout", "stderr"} {
+ f, err := os.Create(filepath.Join(h.Root, label+"-"+stream+".log"))
+ if err != nil {
+ for _, old := range b.logs {
+ old.Close()
+ }
+ return nil, err
+ }
+ b.logs = append(b.logs, f)
+ }
+ cmd.Stdout = b.logs[0]
+ cmd.Stderr = b.logs[1]
+ if err = cmd.Start(); err != nil {
+ for _, f := range b.logs {
+ f.Close()
+ }
+ return nil, err
+ }
+ go func() { b.done <- cmd.Wait() }()
+ b.handle, err = windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, uint32(cmd.Process.Pid))
+ if err != nil {
+ _ = cmd.Process.Kill()
+ <-b.done
+ for _, f := range b.logs {
+ f.Close()
+ }
+ return nil, err
+ }
+ select {
+ case h.current = <-h.connections:
+ case <-ctx.Done():
+ b.Stop()
+ return nil, ctx.Err()
+ case <-time.After(45 * time.Second):
+ b.Stop()
+ return nil, fmt.Errorf("isolated bot handshake timed out")
+ }
+ if options.Enabled {
+ h.Address = options.Address
+ client := &http.Client{Timeout: time.Second}
+ ready := false
+ for i := 0; i < 50; i++ {
+ rsp, e := client.Get("http://" + h.Address + "/api/session")
+ if e == nil {
+ rsp.Body.Close()
+ if rsp.StatusCode == 200 {
+ ready = true
+ break
+ }
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if !ready {
+ b.Stop()
+ return nil, fmt.Errorf("isolated admin did not become ready")
+ }
+ }
+ time.Sleep(300 * time.Millisecond)
+ return b, nil
+}
+
+func (h *Harness) Send(uid int64, text string) (string, time.Duration, error) {
+ h.messageID++
+ start := time.Now()
+ p := h.current
+ p.mu.Lock()
+ err := p.conn.WriteJSON(map[string]any{"time": time.Now().Unix(), "self_id": h.mockQQ, "post_type": "message", "message_type": "private", "sub_type": "friend", "message_id": h.messageID, "user_id": uid, "raw_message": text, "message": []map[string]any{{"type": "text", "data": map[string]string{"text": text}}}, "sender": map[string]any{"user_id": uid, "nickname": "Synthetic acceptance user", "role": "member"}})
+ p.mu.Unlock()
+ if err != nil {
+ return "", time.Since(start), err
+ }
+ select {
+ case reply := <-h.replies:
+ return reply, time.Since(start), nil
+ case <-time.After(3 * time.Second):
+ return "", time.Since(start), nil
+ }
+}
+
+type Client struct {
+ Base string
+ HTTP *http.Client
+ CSRF string
+}
+
+func NewClient(address string) *Client {
+ jar, _ := cookiejar.New(nil)
+ return &Client{Base: "http://" + address, HTTP: &http.Client{Jar: jar, Timeout: 10 * time.Second}}
+}
+func (c *Client) Request(method, path string, body any) (int, map[string]any, time.Duration, error) {
+ var raw []byte
+ var err error
+ if body != nil {
+ raw, err = json.Marshal(body)
+ if err != nil {
+ return 0, nil, 0, err
+ }
+ }
+ req, err := http.NewRequest(method, c.Base+path, bytes.NewReader(raw))
+ if err != nil {
+ return 0, nil, 0, err
+ }
+ if method == "POST" {
+ req.Header.Set("Origin", c.Base)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Local-Request", "1")
+ req.Header.Set("X-CSRF-Token", c.CSRF)
+ }
+ start := time.Now()
+ rsp, err := c.HTTP.Do(req)
+ if err != nil {
+ return 0, nil, time.Since(start), err
+ }
+ defer rsp.Body.Close()
+ var value map[string]any
+ err = json.NewDecoder(io.LimitReader(rsp.Body, 2<<20)).Decode(&value)
+ return rsp.StatusCode, value, time.Since(start), err
+}
+func (c *Client) Auth(adminDir string) (time.Duration, error) {
+ return c.AuthWithPassword(adminDir, Password)
+}
+
+// AuthWithPassword keeps interactive fixture passwords separate from test defaults.
+func (c *Client) AuthWithPassword(adminDir, password string) (time.Duration, error) {
+ code, err := os.ReadFile(filepath.Join(adminDir, "setup-code.txt"))
+ path := "/api/login"
+ body := map[string]string{"password": password}
+ if err == nil {
+ path = "/api/setup"
+ body["setupCode"] = strings.TrimSpace(string(code))
+ } else if !os.IsNotExist(err) {
+ return 0, err
+ }
+ status, value, duration, err := c.Request("POST", path, body)
+ if err != nil || status != 200 {
+ return duration, fmt.Errorf("isolated admin authentication failed: status %d", status)
+ }
+ c.CSRF, _ = value["csrfToken"].(string)
+ if c.CSRF == "" {
+ return duration, fmt.Errorf("missing CSRF")
+ }
+ return duration, nil
+}
+func (c *Client) Checked(method, path string, body any) (map[string]any, error) {
+ status, value, _, err := c.Request(method, path, body)
+ if err != nil || status != 200 {
+ return nil, fmt.Errorf("isolated API %s failed: status %d", path, status)
+ }
+ return value, nil
+}
+
+type Memory struct {
+ Size uint32
+ PageFaultCount uint32
+ PeakWorkingSetSize, WorkingSetSize, QuotaPeakPagedPoolUsage, QuotaPagedPoolUsage, QuotaPeakNonPagedPoolUsage, QuotaNonPagedPoolUsage, PagefileUsage, PeakPagefileUsage, PrivateUsage uintptr
+}
+
+var memoryProc = windows.NewLazySystemDLL("psapi.dll").NewProc("GetProcessMemoryInfo")
+
+func (b *Bot) Metrics() (float64, uint64, uint64, error) {
+ var create, exit, kernel, user windows.Filetime
+ if err := windows.GetProcessTimes(b.handle, &create, &exit, &kernel, &user); err != nil {
+ return 0, 0, 0, err
+ }
+ m := Memory{Size: uint32(unsafe.Sizeof(Memory{}))}
+ ok, _, err := memoryProc.Call(uintptr(b.handle), uintptr(unsafe.Pointer(&m)), uintptr(m.Size))
+ if ok == 0 {
+ return 0, 0, 0, err
+ }
+ seconds := float64((uint64(kernel.HighDateTime)<<32|uint64(kernel.LowDateTime))+(uint64(user.HighDateTime)<<32|uint64(user.LowDateTime))) / 1e7
+ return seconds, uint64(m.PrivateUsage), uint64(m.WorkingSetSize), nil
+}
diff --git a/internal/adminqa/harness_windows_test.go b/internal/adminqa/harness_windows_test.go
new file mode 100644
index 0000000000..3776aaf84b
--- /dev/null
+++ b/internal/adminqa/harness_windows_test.go
@@ -0,0 +1,77 @@
+package adminqa
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestFixtureAuthenticationKeepsCustomAndDefaultPasswordsSeparate(t *testing.T) {
+ for _, setup := range []bool{true, false} {
+ for _, custom := range []bool{true, false} {
+ dir := t.TempDir()
+ if setup {
+ if err := os.WriteFile(filepath.Join(dir, "setup-code.txt"), []byte(" synthetic-code\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ wantPassword := Password
+ if custom {
+ wantPassword = "QA-synthetic-random-password-only"
+ }
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ wantPath := "/api/login"
+ if setup {
+ wantPath = "/api/setup"
+ }
+ var body map[string]string
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ if r.Method != "POST" || r.URL.Path != wantPath || body["password"] != wantPassword {
+ t.Error("incorrect fixture authentication request")
+ }
+ if setup && body["setupCode"] != "synthetic-code" {
+ t.Error("setup code not trimmed")
+ }
+ if r.Header.Get("Origin") != "http://"+r.Host || r.Header.Get("X-Local-Request") != "1" {
+ t.Error("local authentication headers missing")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"csrfToken":"synthetic-session"}`))
+ }))
+ client := NewClient(strings.TrimPrefix(server.URL, "http://"))
+ var err error
+ if custom {
+ _, err = client.AuthWithPassword(dir, wantPassword)
+ } else {
+ _, err = client.Auth(dir)
+ }
+ server.Close()
+ if err != nil || !called || client.CSRF != "synthetic-session" {
+ t.Fatalf("fixture authentication did not establish a session: %v", err)
+ }
+ }
+ }
+}
+
+func TestFixtureAuthenticationRejectsUnconfirmedSessions(t *testing.T) {
+ for _, status := range []int{http.StatusOK, http.StatusUnauthorized} {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(`{"error":"synthetic rejection"}`))
+ }))
+ client := NewClient(strings.TrimPrefix(server.URL, "http://"))
+ _, err := client.AuthWithPassword(t.TempDir(), "QA-custom-password-only")
+ server.Close()
+ if err == nil || client.CSRF != "" {
+ t.Fatal("unconfirmed fixture session was accepted")
+ }
+ }
+}
diff --git a/internal/basiccommands/help.go b/internal/basiccommands/help.go
new file mode 100644
index 0000000000..319e02ef22
--- /dev/null
+++ b/internal/basiccommands/help.go
@@ -0,0 +1,25 @@
+// Package basiccommands registers built-in commands independently of plugins.
+package basiccommands
+
+import (
+ "strings"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+// RegisterHelp accepts surrounding whitespace from QQ clients, while preserving
+// the existing aliases, exact command names, priority and private/at-me behavior.
+func RegisterHelp(banner string) {
+ zero.OnMessage(func(ctx *zero.Ctx) bool {
+ switch strings.TrimSpace(ctx.MessageString()) {
+ case "help", "/help", ".help", "菜单":
+ return true
+ default:
+ return false
+ }
+ }, zero.OnlyToMe).
+ SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ ctx.SendChain(message.Text(banner, "\n发送\"/服务列表\"查看功能与用法(聊天记录)\n发送\"/服务列表 2\"翻页;\"/用法name\"查看完整用法\n群内请 @ 机器人"))
+ })
+}
diff --git a/internal/basiccommands/help_test.go b/internal/basiccommands/help_test.go
new file mode 100644
index 0000000000..229584211c
--- /dev/null
+++ b/internal/basiccommands/help_test.go
@@ -0,0 +1,95 @@
+package basiccommands
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/tidwall/gjson"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+type localDriver struct {
+ ready chan func([]byte, zero.APICaller)
+ stop chan struct{}
+ sent chan zero.APIRequest
+}
+
+func (*localDriver) Connect() {}
+func (d *localDriver) Listen(receive func([]byte, zero.APICaller)) {
+ d.ready <- receive
+ <-d.stop
+}
+func (d *localDriver) CallAPI(_ context.Context, req zero.APIRequest) (zero.APIResponse, error) {
+ d.sent <- req
+ return zero.APIResponse{Status: "ok", Data: gjson.Parse(`{"message_id":99}`)}, nil
+}
+
+// A blocking chat fallback registered before help must not intercept help.
+// Ordinary chat and group messages without @bot must still reach the fallback.
+func TestHelpRouting(t *testing.T) {
+ zero.OnMessage().SetBlock(true).ThirdPriority().Handle(func(ctx *zero.Ctx) {
+ ctx.SendChain(message.Text("chat fallback"))
+ })
+ RegisterHelp("local help banner")
+ driver := &localDriver{ready: make(chan func([]byte, zero.APICaller), 1), stop: make(chan struct{}), sent: make(chan zero.APIRequest, 16)}
+ defer close(driver.stop)
+ zero.Run(&zero.Config{CommandPrefix: "/", MaxProcessTime: time.Second, Driver: []zero.Driver{driver}})
+ var receive func([]byte, zero.APICaller)
+ select {
+ case receive = <-driver.ready:
+ case <-time.After(time.Second):
+ t.Fatal("local driver did not start")
+ }
+ for i, tc := range []struct {
+ name string
+ text string
+ group bool
+ atBot bool
+ want string
+ }{
+ {"private slash", "/help", false, false, "local help banner"},
+ {"trailing newline from QQ", "/help\n", false, false, "local help banner"},
+ {"surrounding whitespace", "\r\n\t/help \t", false, false, "local help banner"},
+ {"private plain", "help", false, false, "local help banner"},
+ {"private dot", ".help", false, false, "local help banner"},
+ {"private menu", "菜单", false, false, "local help banner"},
+ {"ordinary chat", "hello", false, false, "chat fallback"},
+ {"help with arguments", "/help extra", false, false, "chat fallback"},
+ {"longer command", "/helper", false, false, "chat fallback"},
+ {"group no at", "/help", true, false, "chat fallback"},
+ {"group at bot", "/help", true, true, "local help banner"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ segments := message.Message{message.Text(tc.text)}
+ if tc.atBot {
+ segments = append(message.Message{message.At(87654321)}, segments...)
+ }
+ event := map[string]any{"time": time.Now().Unix(), "self_id": 87654321, "post_type": "message", "message_type": "private", "sub_type": "friend", "message_id": i + 1, "user_id": 12345678, "raw_message": tc.text, "message": segments, "sender": map[string]any{"user_id": 12345678, "nickname": "Local test"}}
+ if tc.group {
+ event["message_type"] = "group"
+ event["group_id"] = 23456789
+ }
+ payload, err := json.Marshal(event)
+ if err != nil {
+ t.Fatal(err)
+ }
+ receive(payload, driver)
+ select {
+ case request := <-driver.sent:
+ encoded, err := json.Marshal(request.Params["message"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(encoded), tc.want) {
+ t.Fatalf("reply = %s, want %q", encoded, tc.want)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("no reply to local message")
+ }
+ })
+ }
+}
diff --git a/internal/localadmin/ai.go b/internal/localadmin/ai.go
new file mode 100644
index 0000000000..85fd69f892
--- /dev/null
+++ b/internal/localadmin/ai.go
@@ -0,0 +1,320 @@
+package localadmin
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/FloatTech/zbputils/chat"
+ "github.com/FloatTech/zbputils/ctxext"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+type AIStore struct {
+ repository *chat.ConfigRepository
+ groups map[string]*ctrl.Control[*zero.Ctx]
+}
+
+var errAIValidation = errors.New("AI 设置无效")
+
+func invalidAI(text string) error { return fmt.Errorf("%w:%s", errAIValidation, text) }
+func NewAIStore(controls []*ctrl.Control[*zero.Ctx]) *AIStore {
+ s := &AIStore{groups: map[string]*ctrl.Control[*zero.Ctx]{}}
+ for _, c := range controls {
+ if c.Service == "aichatcfg" {
+ s.repository = chat.NewConfigRepository(c)
+ }
+ if c.Service == "aichatcfg" || c.Service == "llm" {
+ s.groups[c.Service] = c
+ c.Manager.Lock()
+ c.BeforeData = c.Manager.BeforeSwitch
+ c.Manager.Unlock()
+ }
+ }
+ return s
+}
+
+type AISettings struct {
+ Fields map[string]any `json:"fields"`
+ Keys map[string]bool `json:"keys"`
+ AddressRedacted map[string]bool `json:"addressRedacted"`
+ Revision string `json:"revision"`
+ Ready bool `json:"ready"`
+ Checks map[string]AIConfigCheck `json:"checks"`
+}
+type AIGroupSettings struct {
+ Service string `json:"service"`
+ GroupID string `json:"groupId"`
+ PrivateID string `json:"privateId,omitempty"`
+ Revision string `json:"revision"`
+ Rate int `json:"rate"`
+ Temperature int `json:"temperature"`
+ EffectiveTemperature float32 `json:"effectiveTemperature"`
+ UseAgent bool `json:"useAgent"`
+ VoiceOutput bool `json:"voiceOutput"`
+ RespondAt bool `json:"respondAt"`
+}
+type AIView struct {
+ Available bool `json:"available"`
+ Global *AISettings `json:"global,omitempty"`
+ Groups []AIGroupSettings `json:"groups"`
+}
+
+func (s *AIStore) Get(gid int64) (AIView, error) {
+ v := AIView{Groups: []AIGroupSettings{}}
+ if s == nil || s.repository == nil {
+ return v, nil
+ }
+ c, revision, err := s.repository.Read()
+ if err != nil {
+ return v, err
+ }
+ raw, err := json.Marshal(c)
+ if err != nil {
+ return v, err
+ }
+ fields := map[string]any{}
+ _ = json.Unmarshal(raw, &fields)
+ keys := map[string]bool{"Key": c.Key != "", "ImageKey": c.ImageKey != "", "AgentKey": c.AgentKey != ""}
+ for name := range keys {
+ delete(fields, name)
+ }
+ redacted := map[string]bool{}
+ for _, name := range []string{"API", "ImageAPI", "AgentAPI"} {
+ fields[name], redacted[name] = chat.DisplayAPI(fields[name].(string))
+ }
+ for name, value := range fields {
+ if text, ok := value.(string); ok {
+ fields[name] = c.Redact(text)
+ }
+ }
+ v.Available = true
+ v.Global = &AISettings{Fields: fields, Keys: keys, AddressRedacted: redacted, Revision: revision, Ready: c.Ready()}
+ v.Global.Checks = aiConfigChecks(c)
+ if gid != 0 {
+ for _, service := range []string{"aichatcfg", "llm"} {
+ control := s.groups[service]
+ if control == nil {
+ continue
+ }
+ data, revision, err := control.DataVersion(gid)
+ if err != nil {
+ return AIView{}, err
+ }
+ stor := chat.Storage(data)
+ setting := AIGroupSettings{Service: service, Revision: revision, Rate: int(stor.Rate()), Temperature: int(ctxext.Storage(data).Get(chat.BitmapTemp)), EffectiveTemperature: stor.Temp(), UseAgent: !stor.NoAgent(), VoiceOutput: !stor.NoRecord(), RespondAt: !stor.NoReplyAt()}
+ if gid < 0 {
+ setting.PrivateID = strconv.FormatInt(-gid, 10)
+ } else {
+ setting.GroupID = strconv.FormatInt(gid, 10)
+ }
+ v.Groups = append(v.Groups, setting)
+ }
+ }
+ return v, nil
+}
+
+type AIKeyChange struct {
+ Action string `json:"action"`
+ Value string `json:"value"`
+ Confirm bool `json:"confirm"`
+}
+type AIUpdate struct {
+ Revision string `json:"revision"`
+ Fields map[string]json.RawMessage `json:"fields"`
+ Keys map[string]AIKeyChange `json:"keys"`
+}
+
+func validRevision(text string) bool {
+ data, err := hex.DecodeString(text)
+ return err == nil && len(data) == 32
+}
+
+func (s *AIStore) Save(update AIUpdate) error {
+ if s == nil || s.repository == nil {
+ return invalidAI("AI 配置插件未编译")
+ }
+ if !validRevision(update.Revision) {
+ return invalidAI("AI 配置版本无效")
+ }
+ fields := map[string]json.RawMessage{}
+ for name, value := range update.Fields {
+ if name == "Key" || name == "ImageKey" || name == "AgentKey" {
+ return invalidAI("密钥须通过保留、替换或清除操作设置")
+ }
+ fields[name] = value
+ }
+ for name, key := range update.Keys {
+ if name != "Key" && name != "ImageKey" && name != "AgentKey" {
+ return invalidAI("未知密钥设置")
+ }
+ switch key.Action {
+ case "keep":
+ if key.Value != "" || key.Confirm {
+ return invalidAI("保留密钥时无需填写新值或确认")
+ }
+ case "replace":
+ if key.Value == "" || key.Confirm {
+ return invalidAI("替换密钥时须填写新值")
+ }
+ fields[name], _ = json.Marshal(key.Value)
+ case "clear":
+ if !key.Confirm || key.Value != "" {
+ return invalidAI("请确认清除密钥")
+ }
+ fields[name] = json.RawMessage(`""`)
+ default:
+ return invalidAI("密钥操作无效")
+ }
+ }
+ if err := chat.ValidateConfigFields(fields); err != nil {
+ return invalidAI(err.Error())
+ }
+ _, err := s.repository.Update(update.Revision, fields)
+ return err
+}
+
+type AIGroupUpdate struct {
+ Service string `json:"service"`
+ GroupID string `json:"groupId"`
+ PrivateID string `json:"privateId,omitempty"`
+ Revision string `json:"revision"`
+ Rate *int `json:"rate,omitempty"`
+ Temperature *int `json:"temperature,omitempty"`
+ UseAgent *bool `json:"useAgent,omitempty"`
+ VoiceOutput *bool `json:"voiceOutput,omitempty"`
+ RespondAt *bool `json:"respondAt,omitempty"`
+}
+
+func aiConversationID(group, private string) (int64, error) {
+ if private == "" {
+ return pluginGroup(group)
+ }
+ if group != "" {
+ return 0, invalidAI("群号与私聊 QQ 不能同时填写")
+ }
+ id, err := pluginGroup(private)
+ if err != nil || id == 0 {
+ return 0, invalidAI("私聊 QQ 须为正整数")
+ }
+ return -id, nil
+}
+
+func (s *AIStore) SaveGroup(update AIGroupUpdate) error {
+ gid, err := aiConversationID(update.GroupID, update.PrivateID)
+ if err != nil || gid == 0 || !validRevision(update.Revision) {
+ return invalidAI("请选择有效群号或私聊 QQ 及配置版本")
+ }
+ c := s.groups[update.Service]
+ if c == nil {
+ return invalidAI("当前程序未编译此 AI 插件")
+ }
+ if update.Service == "llm" && (update.Rate != nil || update.UseAgent != nil || update.VoiceOutput != nil || update.RespondAt != nil) {
+ return invalidAI("GPT/群总结仅支持温度设置")
+ }
+ for _, n := range []*int{update.Rate, update.Temperature} {
+ if n != nil && (*n < 0 || *n > 100) {
+ return invalidAI("概率和温度须为 0~100;温度 0 使用默认 70")
+ }
+ }
+ _, err = c.UpdateData(gid, update.Revision, func(old int64) (int64, error) {
+ stor := ctxext.Storage(old)
+ if update.Rate != nil {
+ stor = stor.Set(int64(*update.Rate), chat.BitmapRate)
+ }
+ if update.Temperature != nil {
+ stor = stor.Set(int64(*update.Temperature), chat.BitmapTemp)
+ }
+ for _, pair := range []struct {
+ value *bool
+ mask int64
+ }{{update.UseAgent, chat.BitmapNagt}, {update.VoiceOutput, chat.BitmapNrec}, {update.RespondAt, chat.BitmapNrat}} {
+ if pair.value != nil {
+ x := int64(1)
+ if *pair.value {
+ x = 0
+ }
+ stor = stor.Set(x, pair.mask)
+ }
+ }
+ return int64(stor), nil
+ })
+ return err
+}
+
+func (s *Server) getAI(w http.ResponseWriter, r *http.Request) {
+ gid, err := aiConversationID(r.URL.Query().Get("group"), r.URL.Query().Get("private"))
+ if err != nil {
+ apiError(w, 400, err.Error())
+ return
+ }
+ v, err := s.cfg.AI.Get(gid)
+ if err != nil {
+ apiError(w, 500, "AI 配置读取失败,请检查控制数据库")
+ return
+ }
+ jsonReply(w, 200, v)
+}
+func (s *Server) saveAI(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.AI == nil || s.cfg.AI.repository == nil {
+ apiError(w, 409, "当前程序未接入 AI 设置")
+ return
+ }
+ var update AIUpdate
+ if !readJSONLimit(w, r, &update, 131072) {
+ return
+ }
+ if s.cfg.Logs != nil {
+ for _, key := range update.Keys {
+ s.cfg.Logs.AddSecrets(key.Value)
+ }
+ }
+ err := s.cfg.AI.Save(update)
+ if err != nil {
+ s.aiSaveError(w, err)
+ return
+ }
+ v, err := s.cfg.AI.Get(0)
+ if err != nil {
+ apiError(w, 500, "配置已保存,但读取新状态失败,请重新加载")
+ return
+ }
+ jsonReply(w, 200, v)
+}
+func (s *Server) saveAIGroup(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.AI == nil || s.cfg.AI.repository == nil {
+ apiError(w, 409, "当前程序未接入 AI 设置")
+ return
+ }
+ var update AIGroupUpdate
+ if !readJSON(w, r, &update) {
+ return
+ }
+ err := s.cfg.AI.SaveGroup(update)
+ if err != nil {
+ s.aiSaveError(w, err)
+ return
+ }
+ gid, _ := aiConversationID(update.GroupID, update.PrivateID)
+ v, err := s.cfg.AI.Get(gid)
+ if err != nil {
+ apiError(w, 500, "配置已保存,但读取新状态失败,请重新加载")
+ return
+ }
+ jsonReply(w, 200, v)
+}
+func (s *Server) aiSaveError(w http.ResponseWriter, err error) {
+ if errors.Is(err, ctrl.ErrConfigConflict) {
+ apiError(w, 409, "AI 配置已变化,请重新加载后核对再保存")
+ return
+ }
+ if errors.Is(err, errAIValidation) {
+ apiError(w, 400, err.Error())
+ return
+ }
+ apiError(w, 500, "AI 配置保存失败,原设置保留;请检查备份目录及控制数据库")
+}
diff --git a/internal/localadmin/ai_checks.go b/internal/localadmin/ai_checks.go
new file mode 100644
index 0000000000..3077e83343
--- /dev/null
+++ b/internal/localadmin/ai_checks.go
@@ -0,0 +1,42 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/FloatTech/zbputils/chat"
+)
+
+// AIConfigCheck contains only local field checks, never a connectivity verdict.
+type AIConfigCheck struct {
+ TypeValid bool `json:"typeValid"`
+ ModelConfigured bool `json:"modelConfigured"`
+ ModelRequired bool `json:"modelRequired"`
+ AddressConfigured bool `json:"addressConfigured"`
+ AddressValid bool `json:"addressValid"`
+ KeyConfigured bool `json:"keyConfigured"`
+ KeyRequired bool `json:"keyRequired"`
+ Complete bool `json:"complete"`
+}
+
+func checkAIConfig(kind chat.ModelType, model, address, key, addressField string, modelRequired bool) AIConfigCheck {
+ check := AIConfigCheck{
+ TypeValid: kind >= 0 && kind <= 2, ModelConfigured: strings.TrimSpace(model) != "", ModelRequired: modelRequired,
+ AddressConfigured: strings.TrimSpace(address) != "", KeyConfigured: key != "", KeyRequired: kind != 1,
+ }
+ if check.AddressConfigured {
+ raw, _ := json.Marshal(address)
+ check.AddressValid = chat.ValidateConfigFields(map[string]json.RawMessage{addressField: raw}) == nil
+ }
+ check.Complete = check.TypeValid && check.AddressConfigured && check.AddressValid &&
+ (check.ModelConfigured || !check.ModelRequired) && (check.KeyConfigured || !check.KeyRequired)
+ return check
+}
+
+func aiConfigChecks(c chat.AgentConfig) map[string]AIConfigCheck {
+ return map[string]AIConfigCheck{
+ "chat": checkAIConfig(c.Type, c.ModelName, c.API, string(c.Key), "API", true),
+ "agent": checkAIConfig(c.AgentType, c.AgentModelName, c.AgentAPI, string(c.AgentKey), "AgentAPI", true),
+ "image": checkAIConfig(c.ImageType, c.ImageModelName, c.ImageAPI, string(c.ImageKey), "ImageAPI", false),
+ }
+}
diff --git a/internal/localadmin/ai_checks_test.go b/internal/localadmin/ai_checks_test.go
new file mode 100644
index 0000000000..d2de6e1860
--- /dev/null
+++ b/internal/localadmin/ai_checks_test.go
@@ -0,0 +1,136 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/FloatTech/zbputils/chat"
+)
+
+func TestAIConfigCheckTypesRequiredFieldsAndIndependence(t *testing.T) {
+ for _, kind := range []chat.ModelType{-1, 0, 1, 2, 3} {
+ for _, key := range []string{"", "synthetic-key"} {
+ c := chat.AgentConfig{Type: kind, ModelName: "fixture", API: "http://fixture.invalid/chat", Key: chat.ModelKey(key)}
+ checks := aiConfigChecks(c)
+ got := checks["chat"]
+ want := kind >= 0 && kind <= 2 && (kind == 1 || key != "")
+ if got.Complete != want || got.KeyRequired != (kind != 1) || got.KeyConfigured != (key != "") || !got.ModelRequired {
+ t.Fatal("type/key check disagrees", kind, got)
+ }
+ if checks["agent"].Complete || checks["image"].Complete || checks["image"].ModelRequired {
+ t.Fatal("independent or optional model state incorrect")
+ }
+ }
+ }
+ c := chat.AgentConfig{Type: 1, ModelName: " \t", API: "http://fixture.invalid/chat", AgentType: 1, AgentModelName: "agent", AgentAPI: "http://fixture.invalid/agent", ImageType: 1, ImageAPI: "http://fixture.invalid/image"}
+ checks := aiConfigChecks(c)
+ if checks["chat"].Complete || checks["chat"].ModelConfigured || !checks["agent"].Complete || !checks["image"].Complete || checks["image"].ModelConfigured {
+ t.Fatal("blank required/optional models incorrect", checks)
+ }
+}
+
+func TestAIConfigCheckAddressRulesAndBooleanOnlyOutput(t *testing.T) {
+ for _, address := range []string{"", " ", "file:///private-key", "ftp://fixture.invalid", "http://fixture.invalid:0", "http://fixture.invalid:65536", "https://private-user:secret@fixture.invalid/v1", "https://fixture.invalid/v1?key=secret", "https://fixture.invalid/v1#secret", "http://bad host"} {
+ check := checkAIConfig(1, "private-model-name", address, "private-model-key", "API", true)
+ if check.AddressValid || check.Complete {
+ t.Fatal("invalid/legacy address passed")
+ }
+ raw, err := json.Marshal(check)
+ if err != nil || strings.Contains(string(raw), "private") || strings.Contains(string(raw), "secret") || strings.Contains(string(raw), "fixture.invalid") {
+ t.Fatal("check serialized field contents")
+ }
+ var fields map[string]any
+ _ = json.Unmarshal(raw, &fields)
+ if len(fields) != 8 {
+ t.Fatal("unexpected check fields")
+ }
+ for _, value := range fields {
+ if _, ok := value.(bool); !ok {
+ t.Fatal("check contains non-boolean metadata")
+ }
+ }
+ }
+ for _, address := range []string{"http://127.0.0.1:11434/api/chat", "https://fixture.invalid:65535/v1"} {
+ if !checkAIConfig(1, "fixture", address, "", "API", true).Complete {
+ t.Fatal("valid HTTP address rejected")
+ }
+ }
+}
+
+func TestAIConfigChecksUseRawSnapshotAndReadDoesNotWrite(t *testing.T) {
+ s, c, _, backups := aiFixture(t)
+ if err := c.SetExtra(map[string]any{"Type": 1, "ModelName": "synthetic-model-secret", "API": "http://fixture.invalid/synthetic-model-secret", "Key": "synthetic-model-secret", "AgentType": 0, "AgentModelName": "agent", "AgentAPI": "https://user:hidden-url-secret@fixture.invalid/v1", "AgentKey": "synthetic-agent-secret", "Future": map[string]any{"n": 9007199254740993}}); err != nil {
+ t.Fatal(err)
+ }
+ before, revision, err := c.ExtraJSON()
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(backups)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, gid := range []int64{0, 101, -101} {
+ v := aiView(t, s, gid)
+ if !v.Global.Checks["chat"].Complete || v.Global.Checks["agent"].Complete || !v.Global.Checks["agent"].AddressConfigured || v.Global.Checks["agent"].AddressValid {
+ t.Fatal("raw/redacted check state incorrect")
+ }
+ raw, _ := json.Marshal(v)
+ for _, secret := range []string{"synthetic-model-secret", "synthetic-agent-secret", "hidden-url-secret"} {
+ if strings.Contains(string(raw), secret) {
+ t.Fatal("AI read exposes secret")
+ }
+ }
+ }
+ after, afterRevision, err := c.ExtraJSON()
+ if err != nil || string(before) != string(after) || revision != afterRevision {
+ t.Fatal("check mutated configuration")
+ }
+ afterEntries, _ := os.ReadDir(backups)
+ if len(entries) != len(afterEntries) {
+ t.Fatal("check created a backup")
+ }
+}
+
+func TestAIConfigChecksAuthenticatedAPIAndSaveSnapshot(t *testing.T) {
+ s := newTestServer(t)
+ store, _, _, _ := aiFixture(t)
+ s.cfg.AI = store
+ expectStatus(t, request(s, "GET", "/api/ai", nil, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ read := func() AIView {
+ t.Helper()
+ rsp := request(s, "GET", "/api/ai", nil, cookie, "")
+ expectStatus(t, rsp, 200)
+ var v AIView
+ if err := json.Unmarshal(rsp.Body.Bytes(), &v); err != nil {
+ t.Fatal(err)
+ }
+ return v
+ }
+ v := read()
+ if v.Global.Checks["chat"].Complete {
+ t.Fatal("unconfigured key assumed ready")
+ }
+ update := AIUpdate{Revision: v.Global.Revision, Fields: aiFieldsRaw(map[string]any{"Type": 1, "ModelName": "local-model", "API": "http://fixture.invalid/chat", "AgentType": 1, "AgentModelName": "agent", "AgentAPI": "http://fixture.invalid/agent"})}
+ rsp := request(s, "POST", "/api/ai", update, cookie, csrf)
+ expectStatus(t, rsp, 200)
+ var saved AIView
+ if err := json.Unmarshal(rsp.Body.Bytes(), &saved); err != nil {
+ t.Fatal(err)
+ }
+ if !saved.Global.Checks["chat"].Complete || !saved.Global.Checks["agent"].Complete || saved.Global.Checks["image"].Complete || !reflect.DeepEqual(saved.Global.Checks, read().Global.Checks) {
+ t.Fatal("saved and read check snapshots differ")
+ }
+ expectStatus(t, request(s, "POST", "/api/ai", update, cookie, csrf), 409)
+ if !reflect.DeepEqual(saved.Global.Checks, read().Global.Checks) {
+ t.Fatal("conflict changed checks")
+ }
+ s.cfg.AI = nil
+ if v := read(); v.Available || v.Global != nil {
+ t.Fatal("missing store inferred configured")
+ }
+}
diff --git a/internal/localadmin/ai_conversations_test.go b/internal/localadmin/ai_conversations_test.go
new file mode 100644
index 0000000000..c0e3d558b0
--- /dev/null
+++ b/internal/localadmin/ai_conversations_test.go
@@ -0,0 +1,111 @@
+package localadmin
+
+import (
+ "errors"
+ "os"
+ "testing"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/FloatTech/zbputils/chat"
+ "github.com/FloatTech/zbputils/ctxext"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func TestAIPrivateStorageIsolationBackupAndChatRevision(t *testing.T) {
+ s, c, path, backups := aiFixture(t)
+ if err := c.SetData(-101, 1<<30); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Disable(-101); err != nil {
+ t.Fatal(err)
+ }
+ global := aiView(t, s, 0).Global.Revision
+ v := aiView(t, s, -101)
+ if v.Groups[0].PrivateID != "101" || v.Groups[0].GroupID != "" {
+ t.Fatal("private identity not explicit")
+ }
+ temp, rate, enabled := 84, 25, true
+ update := AIGroupUpdate{Service: "aichatcfg", PrivateID: "101", Revision: v.Groups[0].Revision, Temperature: &temp, Rate: &rate, UseAgent: &enabled}
+ before, err := os.ReadDir(backups)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SaveGroup(update); err != nil {
+ t.Fatal(err)
+ }
+ after, err := os.ReadDir(backups)
+ if err != nil || len(after) != len(before)+1 {
+ t.Fatal("private update lacks backup")
+ }
+ if c.IsEnabledIn(-101) || c.GetData(-101)&(1<<30) == 0 {
+ t.Fatal("private update changed switch or unknown bits")
+ }
+ actual := aiView(t, s, -101).Groups[0]
+ if actual.Temperature != 84 || actual.Rate != 25 || !actual.UseAgent {
+ t.Fatal("private values wrong")
+ }
+ for _, id := range []int64{101, -102} {
+ other := aiView(t, s, id).Groups[0]
+ if other.Temperature != 0 || other.Rate != 0 {
+ t.Fatal("private update leaked into another conversation")
+ }
+ }
+ if aiView(t, s, 0).Global.Revision != global {
+ t.Fatal("private settings changed global model")
+ }
+ ctx := &zero.Ctx{State: zero.State{"manager": c}}
+ if err := ctxext.UpdateStorage(ctx, -101, func(old ctxext.Storage) ctxext.Storage { return old.Set(31, chat.BitmapRate) }); err != nil {
+ t.Fatal(err)
+ }
+ if aiView(t, s, -101).Groups[0].Rate != 31 {
+ t.Fatal("chat and admin private storage differ")
+ }
+ if err := s.SaveGroup(update); !errors.Is(err, ctrl.ErrConfigConflict) {
+ t.Fatal("stale private revision accepted")
+ }
+ c.Manager.Lock()
+ c.BeforeData = func() error { return errors.New("synthetic backup failure") }
+ c.Manager.Unlock()
+ update.Revision = aiView(t, s, -101).Groups[0].Revision
+ if err := s.SaveGroup(update); err == nil || aiView(t, s, -101).Groups[0].Rate != 31 {
+ t.Fatal("failed private backup changed settings")
+ }
+ if err := c.Manager.D.Close(); err != nil {
+ t.Fatal(err)
+ }
+ m := ctrl.NewManager[*zero.Ctx](path)
+ c2 := m.NewControl("aichatcfg", &ctrl.Options[*zero.Ctx]{Extra: 12345})
+ l2 := m.NewControl("llm", nil)
+ t.Cleanup(func() { m.Lock(); defer m.Unlock(); _ = m.D.Close() })
+ reopened := aiView(t, NewAIStore([]*ctrl.Control[*zero.Ctx]{c2, l2}), -101).Groups[0]
+ if reopened.Rate != 31 || reopened.Temperature != 84 {
+ t.Fatal("private settings did not survive database reopening")
+ }
+}
+
+func TestAIPrivateAPIValidationAuthAndTarget(t *testing.T) {
+ s := newTestServer(t)
+ store, _, _, _ := aiFixture(t)
+ s.cfg.AI = store
+ cookie, csrf := enroll(t, s)
+ for _, path := range []string{"/api/ai?private=0", "/api/ai?private=-101", "/api/ai?private=01", "/api/ai?private=9223372036854775808", "/api/ai?private=101&group=101", "/api/ai?group=-101"} {
+ expectStatus(t, request(s, "GET", path, nil, cookie, ""), 400)
+ }
+ expectStatus(t, request(s, "GET", "/api/ai?private=101", nil, nil, ""), 401)
+ expectStatus(t, request(s, "GET", "/api/ai?private=101", nil, cookie, ""), 200)
+ temp := 80
+ update := AIGroupUpdate{Service: "llm", PrivateID: "101", Revision: aiView(t, store, -101).Groups[1].Revision, Temperature: &temp}
+ expectStatus(t, request(s, "POST", "/api/ai-group", update, nil, ""), 401)
+ expectStatus(t, request(s, "POST", "/api/ai-group", update, cookie, "bad"), 403)
+ expectStatus(t, request(s, "POST", "/api/ai-group", update, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/ai-group", update, cookie, csrf), 409)
+ if aiView(t, store, -101).Groups[1].Temperature != 80 || aiView(t, store, 101).Groups[1].Temperature != 0 {
+ t.Fatal("API saved wrong conversation")
+ }
+ update.Revision = aiView(t, store, -101).Groups[1].Revision
+ update.GroupID = "101"
+ expectStatus(t, request(s, "POST", "/api/ai-group", update, cookie, csrf), 400)
+ update.GroupID = ""
+ update.Rate = &temp
+ expectStatus(t, request(s, "POST", "/api/ai-group", update, cookie, csrf), 400)
+}
diff --git a/internal/localadmin/ai_test.go b/internal/localadmin/ai_test.go
new file mode 100644
index 0000000000..0a15ababe8
--- /dev/null
+++ b/internal/localadmin/ai_test.go
@@ -0,0 +1,295 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/FloatTech/zbputils/chat"
+ "github.com/FloatTech/zbputils/ctxext"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func aiFixture(t *testing.T) (*AIStore, *ctrl.Control[*zero.Ctx], string, string) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.ToSlash(filepath.Join(dir, "plugins.db"))
+ m := ctrl.NewManager[*zero.Ctx](path)
+ c := m.NewControl("aichatcfg", &ctrl.Options[*zero.Ctx]{Extra: 12345})
+ llm := m.NewControl("llm", nil)
+ backups := filepath.Join(dir, "private", "backups")
+ NewPluginStore([]*ctrl.Control[*zero.Ctx]{c, llm}, backups)
+ s := NewAIStore([]*ctrl.Control[*zero.Ctx]{c, llm})
+ t.Cleanup(func() { m.Lock(); defer m.Unlock(); _ = m.D.Close() })
+ return s, c, path, backups
+}
+func aiView(t *testing.T, s *AIStore, gid int64) AIView {
+ t.Helper()
+ v, err := s.Get(gid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return v
+}
+func aiFieldsRaw(values map[string]any) map[string]json.RawMessage {
+ fields := map[string]json.RawMessage{}
+ for name, value := range values {
+ fields[name], _ = json.Marshal(value)
+ }
+ return fields
+}
+
+func TestAISharedPersistenceUnknownFieldsAndRedaction(t *testing.T) {
+ s, c, path, backups := aiFixture(t)
+ initial := map[string]any{"ModelName": "partial-model", "API": "https://user:legacy-url-secret@example.test/v1?key=legacy-query-secret", "Key": "fake-key-must-stay-hidden", "ImageKey": "fake-image-key", "AgentKey": "fake-agent-key", "SystemP": "Never print fake-key-must-stay-hidden", "Future": map[string]any{"n": 9007199254740993}}
+ if err := c.SetExtra(initial); err != nil {
+ t.Fatal(err)
+ }
+ v := aiView(t, s, 101)
+ b, _ := json.Marshal(v)
+ for _, secret := range []string{"fake-key-must-stay-hidden", "fake-image-key", "fake-agent-key", "legacy-url-secret", "legacy-query-secret"} {
+ if strings.Contains(string(b), secret) {
+ t.Fatalf("read exposed secret %q", secret)
+ }
+ }
+ if !v.Global.AddressRedacted["API"] || !v.Global.Keys["Key"] {
+ t.Fatal("missing secret state")
+ }
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Fields: aiFieldsRaw(map[string]any{"ModelName": "new-model"})}); err != nil {
+ t.Fatal(err)
+ }
+ raw, _, err := c.ExtraJSON()
+ if err != nil || !strings.Contains(string(raw), "legacy-query-secret") || !strings.Contains(string(raw), "fake-image-key") || !strings.Contains(string(raw), "Future") {
+ t.Fatal("unedited fields lost")
+ }
+ cfg, _, err := s.repository.Read()
+ if err != nil || cfg.ModelName != "new-model" || cfg.Key != "fake-key-must-stay-hidden" {
+ t.Fatal("runtime not seeing persisted config")
+ }
+ entries, err := os.ReadDir(backups)
+ if err != nil || len(entries) != 2 {
+ t.Fatal("write-before snapshots missing")
+ }
+ if err := s.Save(AIUpdate{Revision: aiView(t, s, 0).Global.Revision}); err != nil {
+ t.Fatal(err)
+ }
+ entries, _ = os.ReadDir(backups)
+ if len(entries) != 2 {
+ t.Fatal("unchanged save created backup")
+ }
+ if err := c.Manager.D.Close(); err != nil {
+ t.Fatal(err)
+ }
+ m2 := ctrl.NewManager[*zero.Ctx](path)
+ defer m2.D.Close()
+ c2 := m2.NewControl("aichatcfg", &ctrl.Options[*zero.Ctx]{Extra: 12345})
+ r := chat.NewConfigRepository(c2)
+ cfg, _, err = r.Read()
+ if err != nil || cfg.ModelName != "new-model" || cfg.ImageKey != "fake-image-key" {
+ t.Fatal("restart lost configuration")
+ }
+}
+
+func TestAIValidationKeepClearAndWriteFailures(t *testing.T) {
+ s, c, _, backups := aiFixture(t)
+ v := aiView(t, s, 0)
+ for _, fields := range []map[string]any{{"Type": 99}, {"TopP": 2}, {"MaxN": 131073}, {"Type": json.RawMessage(" null ")}, {"API": "file:///tmp/a"}, {"API": "http://127.0.0.1:65536"}, {"API": "http://127.0.0.1:0"}, {"API": "https://user:password@example.test"}, {"API": "https://example.test/?key=secret"}, {"ModelName": ""}, {"Unknown": 1}, {"SystemP": strings.Repeat("x", 32769)}} {
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Fields: aiFieldsRaw(fields)}); !errors.Is(err, errAIValidation) {
+ t.Fatalf("invalid fields accepted: %v", err)
+ }
+ if aiView(t, s, 0).Global.Revision != v.Global.Revision {
+ t.Fatal("invalid save changed state")
+ }
+ }
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Keys: map[string]AIKeyChange{"Key": {Action: "replace", Value: "synthetic-secret"}}}); err != nil {
+ t.Fatal(err)
+ }
+ v = aiView(t, s, 0)
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Keys: map[string]AIKeyChange{"Key": {Action: "clear"}}}); !errors.Is(err, errAIValidation) {
+ t.Fatal("unconfirmed clear accepted")
+ }
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Keys: map[string]AIKeyChange{"Key": {Action: "keep"}}}); err != nil {
+ t.Fatal(err)
+ }
+ c.Manager.Lock()
+ oldHook := c.Manager.BeforeExtra
+ c.Manager.BeforeExtra = func() error { return errors.New("synthetic-secret-backup-error") }
+ c.Manager.Unlock()
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Fields: aiFieldsRaw(map[string]any{"ModelName": "not-committed"})}); err == nil {
+ t.Fatal("backup failure ignored")
+ }
+ if aiView(t, s, 0).Global.Revision != v.Global.Revision {
+ t.Fatal("failed backup changed state")
+ }
+ c.Manager.Lock()
+ c.Manager.BeforeExtra = oldHook
+ _, err := c.Manager.D.Exec("CREATE TRIGGER fail_extra BEFORE INSERT ON __resp BEGIN SELECT RAISE(ABORT,'synthetic-secret-sql-error'); END")
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Keys: map[string]AIKeyChange{"Key": {Action: "clear", Confirm: true}}}); err == nil {
+ t.Fatal("SQL failure ignored")
+ }
+ if aiView(t, s, 0).Global.Revision != v.Global.Revision {
+ t.Fatal("failed SQL changed runtime")
+ }
+ c.Manager.Lock()
+ _, err = c.Manager.D.Exec("DROP TRIGGER fail_extra")
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Keys: map[string]AIKeyChange{"Key": {Action: "clear", Confirm: true}}}); err != nil {
+ t.Fatal(err)
+ }
+ if aiView(t, s, 0).Global.Keys["Key"] {
+ t.Fatal("confirmed clear failed")
+ }
+ entries, _ := os.ReadDir(backups)
+ if len(entries) != 3 {
+ t.Fatalf("snapshot count %d", len(entries))
+ }
+}
+
+func TestAIGroupAtomicMergeAndSwitchPreservation(t *testing.T) {
+ s, c, _, _ := aiFixture(t)
+ if err := c.SetData(101, 1<<30); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ before := switchState(t, c, 101)
+ v := aiView(t, s, 101)
+ temp, rate := 84, 25
+ if err := s.SaveGroup(AIGroupUpdate{Service: "aichatcfg", GroupID: "101", Revision: v.Groups[0].Revision, Temperature: &temp, Rate: &rate}); err != nil {
+ t.Fatal(err)
+ }
+ if c.IsEnabledIn(101) || switchState(t, c, 101).GroupMode != before.GroupMode || c.GetData(101)&(1<<30) == 0 {
+ t.Fatal("group settings changed switch or unknown bits")
+ }
+ other := aiView(t, s, 202)
+ if other.Groups[0].Temperature != 0 || other.Groups[0].Rate != 0 {
+ t.Fatal("group settings leaked")
+ }
+ ctx := &zero.Ctx{State: zero.State{"manager": c}}
+ var wg sync.WaitGroup
+ errs := make(chan error, 2)
+ for _, setting := range []struct{ mask, value int64 }{{chat.BitmapRate, 31}, {chat.BitmapTemp, 77}} {
+ wg.Add(1)
+ go func(mask, value int64) {
+ defer wg.Done()
+ errs <- ctxext.UpdateStorage(ctx, 101, func(old ctxext.Storage) ctxext.Storage { return old.Set(value, mask) })
+ }(setting.mask, setting.value)
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ updated := aiView(t, s, 101).Groups[0]
+ if updated.Rate != 31 || updated.Temperature != 77 {
+ t.Fatal("concurrent field update lost")
+ }
+ if err := s.SaveGroup(AIGroupUpdate{Service: "aichatcfg", GroupID: "101", Revision: v.Groups[0].Revision, Temperature: &temp}); !errors.Is(err, ctrl.ErrConfigConflict) {
+ t.Fatal("chat change did not invalidate web version")
+ }
+ c.Manager.Lock()
+ _, err := c.Manager.D.Exec("CREATE TRIGGER fail_group BEFORE INSERT ON __switch_inherit BEGIN SELECT RAISE(ABORT,'group-failure'); END")
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SaveGroup(AIGroupUpdate{Service: "aichatcfg", GroupID: "202", Revision: other.Groups[0].Revision, Temperature: &temp}); err == nil {
+ t.Fatal("group transaction failure ignored")
+ }
+ if aiView(t, s, 202).Groups[0].Temperature != 0 {
+ t.Fatal("failed transaction changed group")
+ }
+}
+
+func TestAIConcurrentWebAndChatAndManagerIsolation(t *testing.T) {
+ s, c, _, _ := aiFixture(t)
+ v := aiView(t, s, 0)
+ results := make(chan error, 2)
+ for _, name := range []string{"winner-a", "winner-b"} {
+ go func(name string) {
+ results <- s.Save(AIUpdate{Revision: v.Global.Revision, Fields: aiFieldsRaw(map[string]any{"ModelName": name})})
+ }(name)
+ }
+ win, conflict := 0, 0
+ for i := 0; i < 2; i++ {
+ err := <-results
+ if err == nil {
+ win++
+ } else if errors.Is(err, ctrl.ErrConfigConflict) {
+ conflict++
+ } else {
+ t.Fatal(err)
+ }
+ }
+ if win != 1 || conflict != 1 {
+ t.Fatal("old pages both committed")
+ }
+ v = aiView(t, s, 0)
+ if _, err := s.repository.Update("", aiFieldsRaw(map[string]any{"TopP": 0.6})); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Save(AIUpdate{Revision: v.Global.Revision, Fields: aiFieldsRaw(map[string]any{"ModelName": "stale"})}); !errors.Is(err, ctrl.ErrConfigConflict) {
+ t.Fatal("chat write not detected")
+ }
+ s2, c2, _, _ := aiFixture(t)
+ if err := c.Manager.Response(0); err != nil {
+ t.Fatal(err)
+ }
+ if c2.Manager.CanResponse(101) {
+ t.Fatal("response cache crossed Managers")
+ }
+ if aiView(t, s2, 0).Global.Fields["ModelName"] == aiView(t, s, 0).Global.Fields["ModelName"] {
+ t.Fatal("AI configuration crossed Managers")
+ }
+}
+
+func TestAIAPIAuthConflictAndSafeErrors(t *testing.T) {
+ s := newTestServer(t)
+ store, c, _, _ := aiFixture(t)
+ s.cfg.AI = store
+ update := AIUpdate{Revision: aiView(t, store, 0).Global.Revision, Fields: aiFieldsRaw(map[string]any{"ModelName": "api-model"})}
+ expectStatus(t, request(s, "GET", "/api/ai", nil, nil, ""), 401)
+ expectStatus(t, request(s, "POST", "/api/ai", update, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/ai", update, cookie, "bad"), 403)
+ expectStatus(t, request(s, "POST", "/api/ai", update, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/ai", update, cookie, csrf), 409)
+ update.Revision = aiView(t, store, 0).Global.Revision
+ update.Fields = aiFieldsRaw(map[string]any{"TopP": 2})
+ expectStatus(t, request(s, "POST", "/api/ai", update, cookie, csrf), 400)
+ update.Fields = aiFieldsRaw(map[string]any{"ModelName": "fail"})
+ c.Manager.Lock()
+ c.Manager.BeforeExtra = func() error { return errors.New("synthetic-api-key-error") }
+ c.Manager.Unlock()
+ rsp := request(s, "POST", "/api/ai", update, cookie, csrf)
+ expectStatus(t, rsp, 500)
+ if strings.Contains(rsp.Body.String(), "synthetic-api-key-error") {
+ t.Fatal("error leaked")
+ }
+ for _, group := range []string{"-1", "01", "9223372036854775808"} {
+ expectStatus(t, request(s, "GET", "/api/ai?group="+group, nil, cookie, ""), 400)
+ }
+ g := aiView(t, store, 101).Groups[0]
+ temp := 85
+ body := AIGroupUpdate{Service: "aichatcfg", GroupID: "101", Revision: g.Revision, Temperature: &temp}
+ expectStatus(t, request(s, "POST", "/api/ai-group", body, cookie, "bad"), 403)
+ expectStatus(t, request(s, "POST", "/api/ai-group", body, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/ai-group", body, cookie, csrf), 409)
+ bad := AIGroupUpdate{Service: "llm", GroupID: "101", Revision: aiView(t, store, 101).Groups[1].Revision, Rate: &temp}
+ expectStatus(t, request(s, "POST", "/api/ai-group", bad, cookie, csrf), 400)
+}
diff --git a/internal/localadmin/assets/ai.js b/internal/localadmin/assets/ai.js
new file mode 100644
index 0000000000..d841afa7d4
--- /dev/null
+++ b/internal/localadmin/assets/ai.js
@@ -0,0 +1,237 @@
+"use strict";
+let aiState = null, aiBusy = false, aiGroup = "0", aiPrivate = "", aiGeneration = 0;
+const aiFields = [
+ ["Type","聊天接口类型","type"], ["ModelName","聊天模型名","text"], ["API","聊天接口地址","text"],
+ ["MaxN","最大输出长度(0 为默认 4096)","number",0,131072], ["TopP","TopP(0 为默认 0.9)","number",0,1],
+ ["SystemP","系统提示词","textarea"], ["NoSystemP","不发送系统提示词","checkbox"],
+ ["ReasoningEffort","推理努力度(DeepSeek 官方接口:none 关闭思考,low/high/max 开启;留空用模型默认)","text"], ["Separator","回复分隔符(可留空)","text"],
+ ["ImageType","识图接口类型","type"], ["ImageModelName","识图模型名(可留空)","text"], ["ImageAPI","识图接口地址(可留空)","text"],
+ ["AgentType","Agent 接口类型","type"], ["AgentModelName","Agent 模型名(可留空)","text"], ["AgentAPI","Agent 接口地址(可留空)","text"],
+ ["AgentChar","Agent 人物设定(修改后保留对话记录)","textarea"], ["AgentSex","Agent 性别","text"]
+];
+const aiKeys = [["Key","聊天密钥"],["ImageKey","识图密钥"],["AgentKey","Agent 密钥"]];
+const aiBaselines = new Map();
+function aiFormSnapshot(form) {
+ return JSON.stringify([...form.querySelectorAll("input,select,textarea")].map(input=>[input.id,input.type==="checkbox" ? input.checked : input.value]));
+}
+function aiDrafts() {
+ return [...aiBaselines.entries()].filter(([,entry])=>entry.form.isConnected && aiFormSnapshot(entry.form)!==entry.value).map(([name])=>name);
+}
+function renderAIDrafts() {
+ const drafts=aiDrafts();
+ renderAIDraftFields(drafts);
+ $("ai-draft-status").textContent=drafts.length ? "未保存更改:"+drafts.map(name=>({global:"全局模型与人物设定",aichatcfg:"聊天会话参数",llm:"GPT / 群总结温度"}[name])).join("、")+"。请分别保存;切换后台页面会保留草稿。" : aiState ? "当前 AI 表单没有未保存的更改。" : "读取设置后可编辑;草稿仅保留在当前页面。";
+ for (const button of document.querySelectorAll("[data-ai-discard]")) button.disabled = !authenticated || aiBusy || !drafts.includes(button.dataset.aiDiscard);
+}
+function discardAIForm(name) {
+ if (!authenticated || aiBusy || !aiDrafts().includes(name)) return;
+ const entry = aiBaselines.get(name), generation = aiGeneration;
+ const title = entry.form.querySelector("h2")?.textContent || "此表单";
+ if (!window.confirm("放弃“" + title + "”的未保存更改?其他表单的编辑和已保存配置保持不变。")) return;
+ if (!authenticated || aiBusy || generation !== aiGeneration || aiBaselines.get(name) !== entry || !entry.form.isConnected) return;
+ const values = new Map(JSON.parse(entry.value));
+ for (const input of entry.form.querySelectorAll("input,select,textarea")) {
+ if (!values.has(input.id)) continue;
+ if (input.type === "checkbox") input.checked = values.get(input.id); else input.value = values.get(input.id);
+ }
+ if (name === "global") {
+ // Reapply the key action UI: empty replacement input, keep mode, no clear confirmation.
+ for (const [key] of aiKeys) $("ai-action-" + key).dispatchEvent(new Event("change", {bubbles:true}));
+ }
+ $("ai-status").textContent = "已放弃“" + title + "”的未保存更改;已保存配置保持不变。";
+ renderAIDrafts();
+}
+function aiChangedFieldNames(name,entry) {
+ const baseline=new Map(JSON.parse(entry.value)),current=new Map(JSON.parse(aiFormSnapshot(entry.form)));
+ const changed=new Set([...new Set([...baseline.keys(),...current.keys()])].filter(id=>baseline.get(id)!==current.get(id)));
+ const labels=[];
+ const take=(ids,label)=>{if(ids.some(id=>changed.has(id)))labels.push(label);for(const id of ids)changed.delete(id);};
+ if (name==="global") {
+ for (const [field,label] of aiFields) take(["ai-field-"+field],label);
+ for (const [key,label] of aiKeys) take(["ai-action-"+key,"ai-key-"+key,"ai-clear-"+key],label);
+ } else if (name==="aichatcfg" || name==="llm") {
+ for (const [field,label] of [["temperature","温度百分比"],...(name==="aichatcfg" ? [["rate","非 @ 消息触发概率"],["agent","Agent 模式"],["voice","QQ AI 语音输出"],["at","响应 @ 消息"]] : [])]) take(["ai-field-"+field+"-"+name],label);
+ }
+ // Only fixed labels reach the DOM: never display input IDs or old/new values.
+ if (changed.size) labels.push("其他表单字段");
+ return labels;
+}
+function aiDraftFieldScope(name,entry) {
+ if (name==="global") return "全局";
+ const scope=entry.scope,valid=id=>typeof id==="string" && /^[1-9][0-9]{0,18}$/.test(id);
+ if (valid(scope?.privateId) && (!scope.groupId || scope.groupId==="0")) return "私聊 QQ "+scope.privateId;
+ if (!scope?.privateId && valid(scope?.groupId)) return "群 "+scope.groupId;
+ return "范围暂不可核对";
+}
+function renderAIDraftFields(drafts) {
+ const panel=$("ai-draft-fields"),list=$("ai-draft-field-list");list.replaceChildren();
+ panel.hidden=!authenticated || !aiState?.available || !drafts.length;
+ $("ai-draft-field-state").textContent="";
+ if (panel.hidden) return;
+ $("ai-draft-field-state").textContent=aiBusy ? "请求处理中,保留当前字段摘要,请等待结果;不会自动保存。" : "与该表单最近读取或保存成功的页面基线比较;只列字段名称,不表示已生效或服务端最新状态。";
+ for (const name of drafts) {
+ const entry=aiBaselines.get(name),labels=aiChangedFieldNames(name,entry);
+ const item=document.createElement("article"),heading=document.createElement("h3"),count=document.createElement("p"),fields=document.createElement("ul");
+ const scope=aiDraftFieldScope(name,entry),title=name==="llm" && scope.startsWith("私聊 QQ ") ? "GPT 温度" : ({global:"全局模型与人物设定",aichatcfg:"聊天会话参数",llm:"GPT / 群总结温度"}[name] || "其他表单");
+ item.dataset.aiDraftForm=["global","aichatcfg","llm"].includes(name) ? name : "other";heading.textContent=title+" · "+scope;
+ count.className="field-help";count.textContent="未保存字段:"+labels.length+" 项(密钥操作、输入与确认合并计数)。";
+ for (const label of labels) {const field=document.createElement("li");field.textContent=label;fields.append(field);}
+ item.append(heading,count,fields);list.append(item);
+ }
+}
+function rememberAIForm(name,form,scope=null) {
+ aiBaselines.set(name,{form,value:aiFormSnapshot(form),scope}); renderAIDrafts();
+}
+function confirmAIDiscard(action) {
+ if (aiBusy) { window.alert("AI 设置请求尚未结束,请等待结果后再"+action+"。"); return false; }
+ return !aiDrafts().length || window.confirm("AI 设置有未保存的更改。"+action+"将放弃这些更改,是否继续?");
+}
+
+function resetAI() {
+ aiGeneration++; aiState = null; aiGroup = "0"; aiPrivate = "";
+ aiBaselines.clear(); aiBusy=false;
+ renderAIConfigChecks(null);
+ $("ai-global-inputs").replaceChildren(); $("ai-group-forms").replaceChildren(); $("ai-group").value = "";
+ $("ai-scope-kind").value = "group"; renderAIScopeLabel();
+ aiBusyState(false); renderAIDrafts();
+}
+function aiBusyState(busy) {
+ aiBusy = busy; $("reload-ai").disabled = busy; $("apply-ai-group").disabled = busy;
+ $("ai-global-fields").disabled = busy || !aiState?.available;
+ for (const fields of $("ai-group-forms").querySelectorAll("fieldset")) fields.disabled = busy;
+ renderAIDrafts();
+}
+function aiInput(container, name, labelText, kind, value, min, max) {
+ const label = document.createElement("label"), input = document.createElement(kind === "textarea" ? "textarea" : kind === "type" ? "select" : "input");
+ input.id = "ai-field-" + name; label.htmlFor = input.id; label.textContent = labelText;
+ if (kind === "type") {
+ for (const [index, text] of ["OpenAI", "OLLaMA", "GenAI"].entries()) { const option = document.createElement("option"); option.value = String(index); option.textContent = text; input.append(option); }
+ if (![0,1,2].includes(value)) { const option = document.createElement("option"); option.value = String(value); option.textContent = "原配置类型无效,请选择"; input.append(option); }
+ input.value = String(value);
+ } else if (kind === "checkbox") { input.type = "checkbox"; input.checked = value; label.className = "check-label"; label.prepend(input); container.append(label); return input; }
+ else {
+ if (kind !== "textarea") input.type = kind;
+ input.value = value ?? ""; input.autocomplete = "off";
+ if (kind === "number") { input.min = min; input.max = max; input.step = name === "TopP" ? "0.01" : "1"; input.required = true; }
+ else { input.maxLength = kind === "textarea" ? 32768 : name.endsWith("API") || name === "API" ? 2048 : 256; if (kind === "textarea") input.rows = 6; }
+ }
+ container.append(label, input); return input;
+}
+function renderAIGlobal(value) {
+ renderAIConfigChecks(value);
+ if (!value.available) { $("ai-status").textContent = "当前程序没有编译 AI 配置插件。"; $("ai-global-inputs").replaceChildren(); aiBaselines.delete("global"); renderAIDrafts(); return; }
+ const container = $("ai-global-inputs"); container.replaceChildren();
+ for (const [name,label,kind,min,max] of aiFields) {
+ aiInput(container,name,label,kind,value.global.fields[name],min,max);
+ if (value.global.addressRedacted[name]) { const note = document.createElement("p"); note.className="field-help"; note.textContent="旧地址中的认证参数已隐藏;不修改时保留原地址,替换时请填写独立密钥。"; container.append(note); }
+ }
+ for (const [name,text] of aiKeys) {
+ const label=document.createElement("label"), select=document.createElement("select"), key=document.createElement("input"), note=document.createElement("p"), confirm=document.createElement("label"), checkbox=document.createElement("input");
+ select.id="ai-action-"+name; label.htmlFor=select.id; label.textContent=text;
+ for (const [action,title] of [["keep","保留当前密钥"],["replace","替换为新密钥"],["clear","清除密钥"]]) { const option=document.createElement("option"); option.value=action; option.textContent=title; select.append(option); }
+ key.id="ai-key-"+name; key.type="password"; key.autocomplete="off"; key.maxLength=4096; key.placeholder="输入新密钥"; key.setAttribute("aria-label",text+"新值"); key.disabled=true;
+ note.className="field-help"; note.textContent=value.global.keys[name] ? "已配置;当前值不会显示。" : "未配置。";
+ confirm.className="check-label"; confirm.hidden=true; checkbox.type="checkbox"; checkbox.id="ai-clear-"+name; confirm.append(checkbox,document.createTextNode("我确认清除"+text));
+ select.addEventListener("change",()=>{key.disabled=select.value!=="replace"; key.required=select.value==="replace"; if (select.value!=="replace") key.value=""; confirm.hidden=select.value!=="clear"; checkbox.checked=false;});
+ container.append(label,select,note,key,confirm);
+ }
+ $("ai-status").textContent=value.global.ready ? "配置已读取。保存后的设置供后续请求使用。" : "聊天接口尚未配置完整;OLLaMA 可使用无密钥接口。";
+ rememberAIForm("global",$("ai-global-form"));
+}
+function renderAIConfigChecks(value) {
+ const panel = $("ai-config-checks"), list = $("ai-config-check-list");
+ list.replaceChildren(); panel.hidden = !authenticated || !value?.available;
+ if (panel.hidden) return;
+ for (const [name,title] of [["chat","聊天 / GPT"],["agent","Agent"],["image","识图(可选)"]]) {
+ const item = document.createElement("article"), heading = document.createElement("h3"), status = document.createElement("p"), detail = document.createElement("p");
+ item.dataset.aiCheck = name; heading.textContent = title; status.className = "ai-check-result"; detail.className = "field-help";
+ const check = value.global?.checks?.[name];
+ const fields = ["typeValid","modelConfigured","modelRequired","addressConfigured","addressValid","keyConfigured","keyRequired","complete"];
+ const valid = check && fields.every(key => typeof check[key] === "boolean") && check.modelRequired === (name !== "image") &&
+ (!check.addressValid || check.addressConfigured) && check.complete === (check.typeValid && check.addressConfigured && check.addressValid && (check.modelConfigured || !check.modelRequired) && (check.keyConfigured || !check.keyRequired));
+ if (!valid) {
+ status.textContent = "暂不可核对基础状态"; detail.textContent = "接口未提供可核对的检查结果,请重新加载或更新程序。";
+ } else {
+ const optionalEmpty = name === "image" && check.typeValid && !check.modelConfigured && !check.addressConfigured && !check.keyConfigured;
+ status.textContent = optionalEmpty ? "未配置(可选,不阻止文字聊天)" : check.complete ? "基础字段检查通过;尚未验证实际调用" : "基础字段待补充或检查";
+ detail.textContent = "接口类型:" + (check.typeValid ? "支持" : "需检查") + ";模型:" + (check.modelConfigured ? "已填写" : check.modelRequired ? "未填写(需要)" : "未填写(可选,按接口要求核对)") +
+ ";地址:" + (!check.addressConfigured ? "未填写" : check.addressValid ? "格式检查通过" : "需检查格式或隐藏认证参数") + ";密钥:" + (check.keyConfigured ? "已配置(未验证)" : check.keyRequired ? "未配置(需要)" : "未配置(OLLaMA 可无密钥)") + "。";
+ }
+ item.append(heading, status, detail); list.append(item);
+ }
+}
+function renderAIGroups(groups, onlyService="") {
+ const container=$("ai-group-forms"); if (!onlyService) {container.replaceChildren(); aiBaselines.delete("aichatcfg"); aiBaselines.delete("llm");}
+ for (const value of groups) {
+ if (onlyService && value.service !== onlyService) continue;
+ const form=document.createElement("form"), fields=document.createElement("fieldset"), heading=document.createElement("h2"), save=document.createElement("button"); form.className="panel ai-form";
+ form.dataset.service=value.service;
+ heading.textContent=(value.service==="llm" ? (value.privateId ? "GPT 温度" : "GPT / 群总结温度") : "聊天会话参数")+" · "+(value.privateId ? "私聊 QQ "+value.privateId : "群 "+value.groupId); fields.append(heading);
+ const suffix=value.service;
+ const temp=aiInput(fields,"temperature-"+suffix,"温度百分比(0 为默认 70)","number",value.temperature,0,100);
+ let rate,agent,voice,at;
+ if (value.service==="aichatcfg") {
+ rate=aiInput(fields,"rate-"+suffix,"非 @ 消息触发概率(百分比)","number",value.rate,0,100);
+ agent=aiInput(fields,"agent-"+suffix,"使用 Agent 模式(可调用机器人操作)","checkbox",value.useAgent);
+ voice=aiInput(fields,"voice-"+suffix,"尝试使用 QQ AI 语音输出","checkbox",value.voiceOutput);
+ at=aiInput(fields,"at-"+suffix,"响应 @ 消息","checkbox",value.respondAt);
+ }
+ save.type="submit"; save.className="primary"; save.textContent="备份并保存会话参数";
+ const discard = document.createElement("button"); discard.type="button"; discard.className="secondary ai-discard"; discard.textContent="放弃此表单更改";
+ discard.id="discard-ai-"+value.service; discard.dataset.aiDiscard=value.service; discard.addEventListener("click",()=>discardAIForm(value.service));
+ fields.append(save,discard); form.append(fields);
+ const existing=onlyService ? [...container.children].find(item=>item.dataset.service===value.service) : null;
+ if (existing) existing.replaceWith(form); else container.append(form);
+ rememberAIForm(value.service,form,{groupId:value.groupId,privateId:value.privateId});
+ form.addEventListener("submit",async(event)=>{
+ event.preventDefault(); if (aiBusy || !authenticated) return;
+ const generation=aiGeneration, body={service:value.service,revision:value.revision,temperature:Number(temp.value)};
+ if (value.privateId) body.privateId=value.privateId; else body.groupId=value.groupId;
+ if (rate) Object.assign(body,{rate:Number(rate.value),useAgent:agent.checked,voiceOutput:voice.checked,respondAt:at.checked});
+ aiBusyState(true); $("ai-error").hidden=true;
+ try { const saved=await api("/api/ai-group",body); if (generation!==aiGeneration || !authenticated) return; aiState.groups=saved.groups; renderAIGroups(saved.groups,value.service); $("ai-status").textContent="会话参数已保存;后续消息使用新参数。"; }
+ catch(error) { if (generation===aiGeneration && authenticated) showError("ai-error",error); }
+ finally { if (generation===aiGeneration) {aiBusyState(false); renderAIDrafts();} }
+ });
+ }
+}
+async function loadAI(group=aiGroup,privateId=aiPrivate) {
+ if (aiBusy || !authenticated) return;
+ if (!confirmAIDiscard(group===aiGroup && privateId===aiPrivate ? "重新加载设置" : "切换会话参数")) return;
+ const generation=++aiGeneration; aiBusyState(true); $("ai-error").hidden=true;
+ try { const value=await api("/api/ai?"+(privateId ? "private="+encodeURIComponent(privateId) : "group="+encodeURIComponent(group))); if (generation!==aiGeneration || !authenticated) return; aiGroup=group; aiPrivate=privateId; aiState=value; $("ai-scope-kind").value=privateId ? "private" : "group"; if (privateId || group!=="0") $("ai-group").value=privateId || group; renderAIScopeLabel(); renderAIGlobal(value); renderAIGroups(value.groups); }
+ catch(error) { if (generation===aiGeneration && authenticated) { $("ai-status").textContent="读取失败,请重新加载。"; showError("ai-error",error); } }
+ finally { if (generation===aiGeneration) {aiBusyState(false); renderAIDrafts();} }
+}
+$("reload-ai").addEventListener("click",()=>loadAI());
+$("discard-ai-global").addEventListener("click",()=>discardAIForm("global"));
+function renderAIScopeLabel() {
+ const privateScope=$("ai-scope-kind").value==="private";
+ $("ai-conversation-label").textContent=privateScope ? "私聊用户 QQ" : "群号";
+ $("ai-group").placeholder=privateScope ? "填写与机器人聊天的用户 QQ" : "填写要管理的群号";
+}
+$("ai-scope-kind").addEventListener("change",renderAIScopeLabel);
+$("ai-scope-form").addEventListener("submit",(event)=>{ event.preventDefault(); const id=$("ai-group").value.trim(); if (!/^[1-9][0-9]{0,18}$/.test(id)) {showError("ai-error",new Error("群号或私聊 QQ 须为正整数。")); return;} loadAI($("ai-scope-kind").value==="private" ? "0" : id,$("ai-scope-kind").value==="private" ? id : ""); });
+$("ai-global-form").addEventListener("submit",async(event)=>{
+ event.preventDefault(); if (aiBusy || !aiState?.available || !authenticated) return;
+ const generation=aiGeneration, body={revision:aiState.global.revision,fields:{},keys:{}};
+ for (const [name,,kind] of aiFields) {
+ const input=$("ai-field-"+name), value=kind==="checkbox" ? input.checked : kind==="number" || kind==="type" ? Number(input.value) : input.value;
+ if (value !== aiState.global.fields[name]) body.fields[name]=value;
+ }
+ for (const [name] of aiKeys) {
+ const action=$("ai-action-"+name).value; body.keys[name]={action};
+ if (action==="replace") body.keys[name].value=$("ai-key-"+name).value;
+ if (action==="clear") { if (!$("ai-clear-"+name).checked) {showError("ai-error",new Error("请先确认清除密钥。")); return;} body.keys[name].confirm=true; }
+ }
+ aiBusyState(true); $("ai-error").hidden=true;
+ try { const saved=await api("/api/ai",body); if (generation!==aiGeneration || !authenticated) return; aiState.global=saved.global; renderAIGlobal(aiState); $("ai-status").textContent="全局配置已保存,后续请求使用新设置,无需重启。"; }
+ catch(error) { if (generation===aiGeneration && authenticated) showError("ai-error",error); }
+ finally { if (generation===aiGeneration) {aiBusyState(false); renderAIDrafts();} }
+});
+for (const id of ["ai-global-form","ai-group-forms"]) {
+ $(id).addEventListener("input",renderAIDrafts); $(id).addEventListener("change",renderAIDrafts);
+}
+window.addEventListener("beforeunload",event=>{
+ if (authenticated && (aiBusy || aiDrafts().length)) {event.preventDefault();event.returnValue="";}
+});
diff --git a/internal/localadmin/assets/app.js b/internal/localadmin/assets/app.js
new file mode 100644
index 0000000000..e62efc90f4
--- /dev/null
+++ b/internal/localadmin/assets/app.js
@@ -0,0 +1,840 @@
+"use strict";
+const $ = (id) => document.getElementById(id);
+let csrf = "", setupRequired = false, plugins = [], authenticated = false;
+let connectionSettings = null, connectionBusy = false, connectionGeneration = 0, connectionIndex = 0, connectionBaseline = null;
+let connectionDraftConfirmation = null;
+let pluginGroup = "0", pluginControlAvailable = false, pluginSaving = false, pluginScopeBusy = false, pluginListRequest = 0, pluginGeneration = 0;
+const pluginDrafts = new Map();
+let pluginPage = 1, pluginPageSize = 20, pluginPageCount = 1, pluginMatchCount = 0, pluginSearchQuery = "", pluginStateFilter = "all";
+let pluginOnlyDrafts = false;
+let pluginSort = "default";
+let pluginBatch = null;
+let pluginResponse = null, pluginResponseLoaded = false;
+let pluginUsage = null;
+let pluginCatalogReadAt=null, pluginCatalogStateAt=null, pluginCatalogReadFailed=false, pluginCatalogLoading=null;
+let pluginReloadAfterRestore=false;
+
+async function api(path, body, signal) {
+ const requestToken = csrf;
+ const options = { credentials: "same-origin", cache: "no-store", signal };
+ if (body !== undefined) {
+ options.method = "POST";
+ options.headers = { "Content-Type": "application/json", "X-Local-Request": "1", "X-CSRF-Token": csrf };
+ options.body = JSON.stringify(body);
+ }
+ const response = await fetch(path, options);
+ const value = await response.json();
+ if (signal?.aborted) throw Object.assign(new Error("请求已取消"), {name:"AbortError"});
+ if (!response.ok) {
+ if (response.status === 401 && authenticated && csrf === requestToken) { authenticated = false; csrf = ""; showAuth(); }
+ throw new Error(value.error || "请求未完成,请重试。");
+ }
+ return value;
+}
+function showError(id, error) { $(id).textContent = error.message; $(id).hidden = false; }
+function showAuth() {
+ closePluginUsage();
+ if (typeof resetSecurity === "function") resetSecurity();
+ if (typeof resetOverview === "function") resetOverview();
+ if (typeof resetAI === "function") resetAI();
+ if (typeof resetLogs === "function") resetLogs();
+ if (typeof resetBackups === "function") resetBackups();
+ resetConnections();
+ pluginDrafts.clear(); pluginListRequest++; pluginGeneration++; plugins = [];
+ pluginBatch = null; $("plugin-batch-status").textContent = "";
+ pluginResponse = null; pluginResponseLoaded = false;
+ pluginReloadAfterRestore = false;
+ pluginGroup = "0"; pluginControlAvailable = false; pluginSaving = false; pluginScopeBusy = false;
+ resetPluginCatalog();
+ $("plugin-scope").value = "global"; $("plugin-group").value = "";
+ $("plugin-error").hidden = true; $("plugin-save-state").textContent = "";
+ $("plugin-search").value = ""; $("plugin-state-filter").value = "all";
+ pluginPage = 1; pluginPageSize = 20; pluginSearchQuery = ""; pluginStateFilter = "all"; $("plugin-page-size").value = "20";
+ pluginOnlyDrafts = false; $("plugin-only-drafts").checked = false;
+ pluginSort = "default";
+ renderPluginScope(); renderPluginBusy(); renderPlugins();
+ $("auth").hidden = false; $("workspace").hidden = true;
+ $("setup-fields").hidden = !setupRequired; $("confirm-fields").hidden = !setupRequired;
+ $("setup-code").required = setupRequired; $("confirm-password").required = setupRequired;
+ $("auth-title").textContent = setupRequired ? "建立后台密码" : "登录管理后台";
+ $("auth-description").textContent = setupRequired ? "只需设置一次,用于管理这台电脑上的机器人。" : "使用本机后台密码继续。";
+ $("password").autocomplete = setupRequired ? "new-password" : "current-password";
+ $("password").minLength = setupRequired ? 12 : 1;
+ $("login-button").textContent = setupRequired ? "设置密码并进入 →" : "登录后台 →";
+}
+async function showWorkspace() {
+ authenticated = true; setupRequired = false;
+ $("auth-notice").textContent = "";
+ const pluginSession = pluginGeneration;
+ $("auth").hidden = true; $("workspace").hidden = false; $("auth-error").hidden = true;
+ $("password").value = ""; $("confirm-password").value = ""; $("setup-code").value = ""; $("setup-file").value = "";
+ const view = document.querySelector(".nav-button.active")?.dataset.view;
+ showOverview(view === "overview");
+ try { await loadPluginList(); }
+ catch (error) { if (authenticated && pluginSession === pluginGeneration) showError("plugin-error",error); }
+ if (!authenticated || pluginSession !== pluginGeneration) return;
+ const currentView = document.querySelector(".nav-button.active")?.dataset.view;
+ if (typeof showLogs === "function") showLogs(currentView === "logs");
+ if (typeof showBackups === "function") showBackups(currentView === "backups");
+ if (typeof showSecurity === "function") showSecurity(currentView === "security");
+ if (currentView === "connections") loadConnections();
+ if (currentView === "ai") loadAI();
+}
+function duration(seconds) {
+ if (seconds >= 86400) return Math.floor(seconds / 86400) + " 天";
+ if (seconds >= 3600) return Math.floor(seconds / 3600) + " 小时 " + Math.floor(seconds % 3600 / 60) + " 分";
+ return Math.floor(seconds / 60) + " 分钟";
+}
+function renderOverview(value) {
+ renderOverviewAlerts(value.alerts);
+ renderModelRequests(value.modelRequests);
+ if (typeof renderReplyTasks==="function") renderReplyTasks(value.replyTasks);
+ const accounts = value.botAccounts || [];
+ $("account-count").textContent = accounts.length;
+ $("plugin-count").textContent = value.pluginCount;
+ $("nav-count").textContent = value.pluginCount;
+ $("uptime").textContent = duration(value.uptimeSeconds);
+ $("version").textContent = value.version;
+ $("go-version").textContent = value.goVersion;
+ $("configured-count").textContent = value.configuredConnections;
+ $("checked-at").textContent = new Date(value.checkedAt).toLocaleTimeString("zh-CN", { hour12: false });
+ const connected = value.connectionState === "connected";
+ $("connection-badge").textContent = connected ? "已连接" : value.connectionState === "unconfigured" ? "未配置连接" : "等待连接";
+ $("connection-badge").classList.toggle("waiting", !connected);
+ $("connection-detail").textContent = connected ? "后台运行正常,已接入 OneBot。" : "后台运行正常,目前没有已连接的机器人账号。";
+ $("accounts").replaceChildren();
+ if (!accounts.length) {
+ const empty = document.createElement("p"); empty.className = "empty"; empty.textContent = "暂时没有已连接的账号。"; $("accounts").append(empty);
+ }
+ for (const account of accounts) {
+ const row = document.createElement("div"); row.className = "account-row";
+ const avatar = document.createElement("span"); avatar.className = "account-avatar"; avatar.textContent = "QQ";
+ const info = document.createElement("div"), name = document.createElement("strong"), note = document.createElement("small"), dot = document.createElement("span");
+ name.textContent = account; note.textContent = "OneBot 已连接"; dot.className = "dot";
+ info.append(name, note); row.append(avatar, info, dot); $("accounts").append(row);
+ }
+}
+function sortPluginItems(items) {
+ if (pluginSort === "default") return items;
+ const rank = plugin => {
+ if (pluginSort === "drafts") return pluginDrafts.has(plugin.name) ? 0 : 1;
+ const state = pluginEffectiveState(plugin);
+ return state === "unknown" ? 2 : state === pluginSort ? 0 : 1;
+ };
+ // Sort a view, retaining the loaded order for ties and sequential saves.
+ return items.map((plugin,index) => ({plugin,index})).sort((a,b) => {
+ const order = pluginSort === "name" ? a.plugin.name.localeCompare(b.plugin.name,"zh-CN",{numeric:true,sensitivity:"base"}) : rank(a.plugin)-rank(b.plugin);
+ return order || a.index-b.index;
+ }).map(item => item.plugin);
+}
+function filteredPluginItems() {
+ const query=$("plugin-search").value.toLocaleLowerCase().trim(),filter=$("plugin-state-filter").value;
+ return sortPluginItems(plugins.filter(p=>(!pluginOnlyDrafts || pluginDrafts.has(p.name)) && (filter==="all" || filter===pluginEffectiveState(p)) && [p.name, p.brief, p.usage].join(" ").toLocaleLowerCase().includes(query)));
+}
+function resetPluginCatalog() {
+ pluginCatalogReadAt=null;pluginCatalogStateAt=null;pluginCatalogReadFailed=false;pluginCatalogLoading=null;
+ renderPluginCatalogControls();
+}
+function pluginCatalogReady() {
+ return authenticated && !!pluginCatalogReadAt && !pluginCatalogLoading && !pluginSaving && !pluginScopeBusy && !$("plugins-view").hidden && !document.hidden;
+}
+function renderPluginCatalogControls() {
+ $("download-plugin-catalog").disabled=!pluginCatalogReady();
+ $("plugin-catalog-status").textContent="";
+ $("plugin-catalog-source").textContent=pluginCatalogReadAt ? "清单范围:"+(pluginGroup==="0" ? "全局" : "群 "+pluginGroup)+";最近成功读取:"+new Date(pluginCatalogReadAt).toLocaleString("zh-CN",{hour12:false})+"。"+(pluginCatalogStateAt ? "最近成功保存开关:"+new Date(pluginCatalogStateAt).toLocaleString("zh-CN",{hour12:false})+"(只更新已保存项)。" : "")+(pluginCatalogReadFailed ? "最近读取失败,清单仍使用当前已加载范围的旧结果。" : "")+"不是服务端实时快照。" : "成功读取插件范围后可下载清单。";
+}
+function downloadPluginCatalog() {
+ if (!pluginCatalogReady()) return;
+ let url=null,link=null;
+ try {
+ const exportedAt=new Date().toISOString(),items=filteredPluginItems();
+ const mode=value=>["inherit","enabled","disabled"].includes(value) ? value : null;
+ const entries=items.map(plugin=>{
+ const known=pluginEffectiveState(plugin)!=="unknown",state=known ? plugin.state : null;
+ return {name:typeof plugin.name==="string" ? plugin.name : null,
+ defaultEnabledAtStartup:typeof plugin.defaultEnabledAtStartup==="boolean" ? plugin.defaultEnabledAtStartup : null,
+ effectiveState:pluginEffectiveState(plugin),defaultEnabled:typeof state?.defaultEnabled==="boolean" ? state.defaultEnabled : null,
+ globalMode:mode(state?.globalMode),groupMode:pluginGroup==="0" ? null : mode(state?.groupMode),
+ source:["global","group","default"].includes(state?.source) ? state.source : null,
+ hasUnsavedChanges:pluginDrafts.has(plugin.name)};
+ });
+ const value={format:"zerobot-plugin-state-catalog",version:1,exportedAt,readAt:pluginCatalogReadAt,lastSuccessfulSwitchSaveAt:pluginCatalogStateAt,
+ scope:{kind:pluginGroup==="0" ? "global" : "group",groupId:pluginGroup==="0" ? null : pluginGroup},
+ source:{loaded:plugins.length,matched:entries.length,unsaved:pluginDrafts.size,lastReadFailed:pluginCatalogReadFailed,liveSnapshot:false},
+ filters:{state:["all","enabled","disabled","unknown"].includes(pluginStateFilter) ? pluginStateFilter : "all",keywordApplied:!!pluginSearchQuery,onlyUnsaved:pluginOnlyDrafts,sort:["default","name","enabled","disabled","drafts"].includes(pluginSort) ? pluginSort : "default"},plugins:entries};
+ // Export an explicit metadata allowlist, never serialize plugin.state or drafts.
+ url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+"\n"],{type:"application/json;charset=utf-8"}));
+ link=document.createElement("a");link.href=url;link.download="zerobot-plugin-state-catalog-"+exportedAt.replace(/[-:.]/g,"")+".json";
+ document.body.append(link);link.click();
+ $("plugin-catalog-status").textContent="已发起清单下载,共 "+entries.length+" 个匹配插件(跨全部已加载页)。"+(pluginCatalogReadFailed ? "最近读取失败,使用当前已加载范围的旧结果。" : "")+"未保存标记不表示已生效;清单不能导入或恢复。";
+ } catch {
+ $("plugin-catalog-status").textContent="清单下载未完成,请检查浏览器下载设置后重试。";
+ } finally {
+ link?.remove();if(url) setTimeout(()=>URL.revokeObjectURL(url),0);
+ }
+}
+function renderPlugins() {
+ renderPluginResponse();
+ const query = $("plugin-search").value.toLocaleLowerCase().trim();
+ const filter = $("plugin-state-filter").value;
+ if (query !== pluginSearchQuery || filter !== pluginStateFilter) pluginPage = 1;
+ pluginSearchQuery = query; pluginStateFilter = filter;
+ const counts = {enabled:0, disabled:0, unknown:0};
+ for (const plugin of plugins) counts[pluginEffectiveState(plugin)]++;
+ const items = filteredPluginItems();
+ $("search-count").textContent = items.length + " / " + plugins.length + " 个插件;启用 " + counts.enabled + ",禁用 " + counts.disabled + ",未知 " + counts.unknown;
+ $("plugin-empty").textContent = plugins.length ? "没有符合搜索和状态条件的插件,可调整条件或清除筛选。" : "当前程序未注册插件。";
+ if (pluginReloadAfterRestore && !plugins.length && !pluginCatalogReadAt) $("plugin-empty").textContent = pluginScopeBusy || pluginCatalogLoading ? "正在读取插件状态…" : pluginCatalogReadFailed ? "未能读取插件状态,请点击刷新重试。" : "插件状态尚未读取,请点击刷新。";
+ if (plugins.length && pluginOnlyDrafts) $("plugin-empty").textContent = pluginDrafts.size ? "没有符合条件的待保存插件,可调整条件或清除筛选;其他草稿仍保留。" : "当前范围没有未保存的开关更改。取消只看未保存可浏览全部插件。";
+ $("plugin-list").replaceChildren(); $("plugin-empty").hidden = items.length !== 0;
+ pluginMatchCount = items.length; pluginPageCount = Math.max(1, Math.ceil(items.length / pluginPageSize)); pluginPage = Math.min(pluginPage, pluginPageCount);
+ renderPluginPagination();
+ for (const plugin of items.slice((pluginPage - 1) * pluginPageSize, pluginPage * pluginPageSize)) {
+ const card = document.createElement("article"), title = document.createElement("h2"), brief = document.createElement("p"), footer = document.createElement("footer"), badge = document.createElement("span"), button = document.createElement("button");
+ card.className = "plugin-card"; card.dataset.plugin = plugin.name; title.textContent = plugin.name; brief.textContent = plugin.brief || "暂无功能简介";
+ badge.className = "plugin-default" + (plugin.defaultEnabledAtStartup ? "" : " disabled");
+ const state = plugin.state;
+ if (state) {
+ badge.className = "plugin-default" + (state.effectiveEnabled ? "" : " disabled");
+ badge.textContent = state.effectiveEnabled ? "当前生效:启用" : "当前生效:禁用";
+ const reason = document.createElement("p"); reason.className = "field-help";
+ reason.textContent = "生效来源:" + ({global:"全局覆盖", group:"此群设置", default:"默认值"}[state.source]) + ";当前默认" + (state.defaultEnabled ? "启用" : "禁用");
+ if (pluginGroup !== "0" && state.globalMode !== "inherit") reason.textContent += "。全局覆盖中,此群设置暂不决定生效状态。";
+ card.append(reason);
+ } else badge.textContent = plugin.defaultEnabledAtStartup ? "启动时默认启用" : "启动时默认禁用";
+ button.className = "text-button"; button.textContent = "查看用法 →";
+ button.addEventListener("click", () => openPluginUsage(plugin));
+ footer.append(badge, button); card.prepend(title, brief); card.append(footer);
+ if (pluginControlAvailable && state) {
+ const controls = document.createElement("div"), select = document.createElement("select"), save = document.createElement("button");
+ controls.className = "plugin-controls"; select.setAttribute("aria-label", plugin.name + " 的开关设置");
+ for (const [mode, label] of [["inherit", pluginGroup === "0" ? "继承默认" : "继承全局或默认"], ["enabled","显式启用"], ["disabled","显式禁用"]]) {
+ const option = document.createElement("option"); option.value = mode; option.textContent = label; select.append(option);
+ }
+ select.value = pluginDrafts.get(plugin.name)?.mode || (pluginGroup === "0" ? state.globalMode : state.groupMode);
+ select.disabled = pluginSaving || pluginScopeBusy; save.disabled = pluginSaving || pluginScopeBusy; save.className = "secondary"; save.textContent = "保存开关";
+ select.addEventListener("change", () => {
+ const baseline = pluginGroup === "0" ? state.globalMode : state.groupMode;
+ if (select.value === baseline) pluginDrafts.delete(plugin.name);
+ else pluginDrafts.set(plugin.name, { mode:select.value, revision:pluginDrafts.get(plugin.name)?.revision || state.revision });
+ if (pluginOnlyDrafts || pluginSort === "drafts") renderPlugins(); else renderPluginDrafts();
+ });
+ save.addEventListener("click", () => savePlugin(plugin, select.value));
+ controls.append(select,save); card.append(controls);
+ const draftActions = document.createElement("div"), note = document.createElement("p"), discard = document.createElement("button");
+ draftActions.className = "plugin-single-draft";
+ note.className = "field-help plugin-draft-note"; note.textContent = "未保存";
+ discard.type = "button"; discard.className = "secondary plugin-discard-one"; discard.textContent = "放弃此项更改";
+ discard.dataset.plugin = plugin.name; discard.setAttribute("aria-label", "放弃 " + plugin.name + " 的未保存更改");
+ discard.addEventListener("click", () => discardOnePluginDraft(plugin));
+ draftActions.append(note,discard); card.append(draftActions);
+ }
+ $("plugin-list").append(card);
+ }
+ renderPluginDrafts();
+}
+function renderPluginDrafts() {
+ renderPluginCatalogControls();
+ $("plugin-draft-status").textContent = pluginDrafts.size ? "未保存更改:" + pluginDrafts.size + " 个插件(" + (pluginGroup === "0" ? "全局" : "群 " + pluginGroup) + ")。可单项保存或逐项保存全部草稿;搜索、筛选和切换页面会保留选择。" : "没有未保存的开关更改。";
+ for (const card of $("plugin-list").children) {
+ const note = card.querySelector(".plugin-draft-note"); if (note) note.hidden = !pluginDrafts.has(card.dataset.plugin);
+ }
+ renderPluginDraftActions();
+}
+function renderPluginDraftActions() {
+ const busy = pluginSaving || pluginScopeBusy;
+ for (const button of $("plugin-list").querySelectorAll(".plugin-discard-one")) button.disabled = busy || !authenticated || !pluginControlAvailable || !pluginDrafts.has(button.dataset.plugin);
+ $("plugin-only-drafts").disabled = busy || !authenticated;
+ $("discard-plugin-drafts").disabled = busy || !authenticated || !pluginDrafts.size;
+ $("save-all-plugin-drafts").disabled = busy || !authenticated || !pluginControlAvailable || !pluginDrafts.size;
+ $("stop-plugin-batch").disabled = !authenticated || !pluginBatch || pluginBatch.stop;
+}
+function pluginBatchSnapshot() { return JSON.stringify([...pluginDrafts]); }
+function pluginBatchCurrent(batch) {
+ return pluginBatch === batch && authenticated && csrf === batch.token && pluginGeneration === batch.generation && pluginGroup === batch.group;
+}
+function pluginBatchResultValid(value, item, group) {
+ const state = value?.state;
+ return value?.name === item.name && value?.groupId === group && state &&
+ typeof state.effectiveEnabled === "boolean" && /^[a-f0-9]{64}$/.test(state.revision || "") &&
+ ["global", "group", "default"].includes(state.source) &&
+ (group === "0" ? state.globalMode : state.groupMode) === item.mode;
+}
+async function saveAllPluginDrafts() {
+ if (!authenticated || pluginSaving || pluginScopeBusy || !pluginControlAvailable || !pluginDrafts.size) return;
+ const generation = pluginGeneration, group = pluginGroup, token = csrf, snapshot = pluginBatchSnapshot(), list = plugins;
+ const items = plugins.filter(p => pluginDrafts.has(p.name)).map(p => ({name:p.name, plugin:p, ...pluginDrafts.get(p.name)}));
+ if (items.length !== pluginDrafts.size || new Set(items.map(p => p.name)).size !== items.length || items.some(p => !p.plugin.state || !["inherit", "enabled", "disabled"].includes(p.mode) || !/^[a-f0-9]{64}$/.test(p.revision || ""))) {
+ showError("plugin-error", new Error("草稿与已读取插件状态不一致,请先核对并刷新状态。")); return;
+ }
+ const scope = group === "0" ? "全局(覆盖所有群与私聊)" : "群 " + group;
+ if (!window.confirm("逐项保存“" + scope + "”的全部 " + items.length + " 个插件草稿?包含其他页和筛选隐藏项,每项分别备份并立即生效;遇错停止,已成功规则不回滚。")) return;
+ if (!authenticated || pluginSaving || pluginScopeBusy || token !== csrf || generation !== pluginGeneration || group !== pluginGroup || list !== plugins || snapshot !== pluginBatchSnapshot()) return;
+ const batch = {generation, group, token, items, completed:0, stop:false};
+ pluginBatch = batch; pluginSaving = true; pluginListRequest++; $("plugin-error").hidden = true;
+ renderPluginBusy(); renderPlugins();
+ try {
+ for (const item of items) {
+ if (!pluginBatchCurrent(batch)) return;
+ if (batch.stop) break;
+ const draft = pluginDrafts.get(item.name);
+ if (!draft || draft.mode !== item.mode || draft.revision !== item.revision) throw new Error("待保存草稿已变化,后续操作已停止,请核对余下选择。");
+ $("plugin-batch-status").textContent = scope + ":已确认成功 " + batch.completed + " / " + items.length + " 项,正在保存 " + item.name + "。";
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 30000);
+ let value;
+ try { value = await api("/api/plugin-switch", {name:item.name, groupId:group, mode:item.mode, revision:item.revision}, controller.signal); }
+ finally { clearTimeout(timer); }
+ if (!pluginBatchCurrent(batch)) return;
+ if (!pluginBatchResultValid(value, item, group)) throw new Error("返回结果无法核对,请先重新读取状态,勿直接重复保存。");
+ item.plugin.state = value.state;
+ pluginCatalogStateAt=new Date().toISOString();
+ pluginDrafts.delete(item.name); batch.completed++; renderPlugins();
+ }
+ if (!pluginBatchCurrent(batch)) return;
+ $("plugin-batch-status").textContent = batch.completed === items.length ? scope + ":全部 " + items.length + " 项已确认保存,规则立即更新。" : scope + ":已停止后续保存;已确认成功 " + batch.completed + " / " + items.length + " 项,余下 " + (items.length - batch.completed) + " 项草稿保留,已成功规则不回滚。";
+ } catch (error) {
+ if (pluginBatchCurrent(batch)) {
+ $("plugin-batch-status").textContent = scope + ":保存已停止;已确认成功 " + batch.completed + " / " + items.length + " 项,余下 " + (items.length - batch.completed) + " 项未确认、草稿保留。已提交项可能已生效,请核对状态;不会自动重试或回滚。";
+ showError("plugin-error", error.name === "AbortError" ? new Error("当前项请求超时或中断,结果未确认,请重新读取状态核对。") : error);
+ }
+ } finally {
+ if (pluginBatchCurrent(batch)) { pluginBatch = null; pluginSaving = false; renderPluginBusy(); renderPlugins(); }
+ }
+}
+function stopPluginBatch() {
+ if (!pluginBatch || !pluginBatchCurrent(pluginBatch) || pluginBatch.stop) return;
+ pluginBatch.stop = true;
+ $("plugin-batch-status").textContent = "已请求停止后续保存;等待当前请求结束。当前项可能生效,已成功规则不会撤销。";
+ renderPluginDraftActions();
+}
+function discardOnePluginDraft(plugin) {
+ if (!authenticated || pluginSaving || pluginScopeBusy || !pluginControlAvailable || !plugin?.state || !plugins.includes(plugin) || !pluginDrafts.has(plugin.name)) return;
+ const generation = pluginGeneration, group = pluginGroup, token = csrf, list = plugins, name = plugin.name;
+ const snapshot = JSON.stringify({draft:pluginDrafts.get(name),state:plugin.state});
+ const scope = group === "0" ? "全局" : "群 " + group;
+ if (!window.confirm("放弃「" + name + "」(" + scope + ")的未保存更改?仅恢复页面最近读取或保存的选择,其他插件草稿保留。")) return;
+ if (!authenticated || pluginSaving || pluginScopeBusy || !pluginControlAvailable || generation !== pluginGeneration || group !== pluginGroup || token !== csrf || list !== plugins || name !== plugin.name || !plugins.includes(plugin) || snapshot !== JSON.stringify({draft:pluginDrafts.get(name),state:plugin.state})) return;
+ pluginDrafts.delete(name); renderPlugins();
+ $("plugin-save-state").textContent = "已放弃「" + name + "」(" + scope + ")的未保存选择;其他草稿保留,已保存规则未改变。";
+}
+function discardPluginDrafts() {
+ if (!authenticated || pluginSaving || pluginScopeBusy || !pluginDrafts.size) return;
+ const generation = pluginGeneration, group = pluginGroup;
+ if (!confirmPluginDiscard("放弃全部更改")) return;
+ if (!authenticated || pluginSaving || pluginScopeBusy || generation !== pluginGeneration || group !== pluginGroup) return;
+ const count = pluginDrafts.size;
+ pluginDrafts.clear(); renderPlugins();
+ $("plugin-save-state").textContent = "已放弃 " + count + " 个插件的未保存选择;已保存的开关规则未改变。";
+}
+function renderPluginPagination() {
+ const busy = pluginSaving || pluginScopeBusy;
+ $("plugin-page-status").textContent = pluginMatchCount ? "第 " + pluginPage + " / " + pluginPageCount + " 页;显示 " + ((pluginPage - 1) * pluginPageSize + 1) + "~" + Math.min(pluginPage * pluginPageSize, pluginMatchCount) + " / " + pluginMatchCount + " 个匹配插件。" : "没有匹配插件。";
+ $("plugin-prev").disabled = busy || !pluginMatchCount || pluginPage <= 1;
+ $("plugin-next").disabled = busy || !pluginMatchCount || pluginPage >= pluginPageCount;
+ $("plugin-page-size").disabled = busy;
+ $("plugin-sort").disabled = busy || !authenticated;
+ $("plugin-sort").value = pluginSort;
+}
+function changePluginPage(step) {
+ if (!authenticated || pluginSaving || pluginScopeBusy) return;
+ const next = pluginPage + step;
+ if (next < 1 || next > pluginPageCount) return;
+ pluginPage = next; renderPlugins();
+}
+function renderPluginScope() {
+ $("plugin-scope").value = pluginGroup === "0" ? "global" : "group";
+ if (pluginGroup !== "0") $("plugin-group").value = pluginGroup;
+ $("plugin-scope-state").textContent = pluginGroup === "0" ? "当前范围:全局。显式全局设置会覆盖所有群与私聊的插件开关。" : "当前范围:群 " + pluginGroup + "。显式全局设置优先生效。";
+}
+function renderPluginResponse() {
+ $("plugin-response-panel").hidden = !authenticated || !pluginResponseLoaded;
+ $("plugin-response-state").textContent = ""; $("plugin-response-detail").textContent = "";
+ $("plugin-response-title").textContent = "响应状态说明";
+ if (!authenticated || !pluginResponseLoaded) return;
+ const scope = pluginGroup === "0" ? "全局" : "群 " + pluginGroup;
+ $("plugin-response-title").textContent = "响应状态 · " + scope;
+ const value = pluginResponse;
+ const valid = value && [value.globalEnabled, value.groupEnabled, value.effectiveEnabled].every(v => typeof v === "boolean") &&
+ value.effectiveEnabled === (value.globalEnabled || value.groupEnabled) &&
+ value.source === (value.globalEnabled ? "global" : value.groupEnabled ? "group" : "none") &&
+ (pluginGroup !== "0" || !value.groupEnabled);
+ if (!valid) { $("plugin-response-state").textContent = "当前实例未提供可核对的统一响应状态,请使用现有 QQ 指令或日志核对。"; return; }
+ if (pluginGroup === "0") {
+ $("plugin-response-state").textContent = "全局响应:" + (value.globalEnabled ? "开启" : "关闭") + "。";
+ $("plugin-response-detail").textContent = value.globalEnabled ? "已开启全局响应;各群单独响应关闭也不能阻止全局响应。" : "全局入口未开启;部分群可能仍单独响应,请读取具体群核对。";
+ } else {
+ $("plugin-response-state").textContent = "全局响应:" + (value.globalEnabled ? "开启" : "关闭") + ";此群单独响应:" + (value.groupEnabled ? "开启" : "关闭") + ";此群响应检查:" + (value.effectiveEnabled ? "通过" : "未通过") + "。";
+ $("plugin-response-detail").textContent = value.source === "global" ? "响应来源:全局。此群单独关闭不能覆盖已开启的全局响应。" : value.source === "group" ? "响应来源:此群单独设置。全局关闭不清除此群已开启的响应。" : "当前未开启全局或此群响应;仅启用插件不会自动打开响应。";
+ }
+}
+function renderPluginBusy() {
+ const busy = pluginSaving || pluginScopeBusy;
+ $("apply-plugin-scope").disabled = busy; $("reload-plugins").disabled = busy;
+ $("plugin-scope").disabled = busy; $("plugin-group").disabled = busy || $("plugin-scope").value !== "group";
+ $("plugin-group").required = $("plugin-scope").value === "group";
+ renderPluginPagination();
+ renderPluginDraftActions();
+}
+function confirmPluginDiscard(action) {
+ if (pluginSaving || pluginScopeBusy) { $("plugin-save-state").textContent = "插件请求尚未完成,请稍候再" + action + "。"; return false; }
+ return !pluginDrafts.size || window.confirm("有 " + pluginDrafts.size + " 个插件开关未保存。" + action + "将丢弃这些选择,是否继续?");
+}
+function pluginEffectiveState(plugin) {
+ return typeof plugin.state?.effectiveEnabled === "boolean" ? (plugin.state.effectiveEnabled ? "enabled" : "disabled") : "unknown";
+}
+async function loadPluginList(group = pluginGroup, discard = false) {
+ const request = ++pluginListRequest, generation = pluginGeneration;
+ pluginCatalogLoading=request;renderPluginCatalogControls();
+ let list;
+ try {list = await api("/api/plugins?group=" + encodeURIComponent(group));}
+ catch(error) {
+ if (request===pluginListRequest && generation===pluginGeneration && authenticated) {pluginCatalogReadFailed=true;renderPluginCatalogControls();}
+ throw error;
+ } finally {
+ if (pluginCatalogLoading===request && generation===pluginGeneration) {pluginCatalogLoading=null;renderPluginCatalogControls();}
+ }
+ if (request !== pluginListRequest || generation !== pluginGeneration || pluginSaving || !authenticated) return false;
+ if (discard) pluginDrafts.clear();
+ if (group !== pluginGroup) pluginPage = 1;
+ pluginGroup = group; plugins = list.plugins || []; pluginControlAvailable = !!list.controlAvailable;
+ pluginReloadAfterRestore = false;
+ pluginCatalogReadAt=new Date().toISOString();pluginCatalogStateAt=null;pluginCatalogReadFailed=false;
+ pluginResponse = list.response || null; pluginResponseLoaded = true;
+ $("plugin-batch-status").textContent = "";
+ renderPluginScope(); renderPlugins(); return true;
+}
+async function ensurePluginList() {
+ if (!authenticated || !pluginReloadAfterRestore || pluginCatalogLoading || pluginSaving || pluginScopeBusy) return;
+ const generation = pluginGeneration, group = pluginGroup;
+ pluginScopeBusy = true; $("plugin-error").hidden = true;
+ renderPluginBusy(); renderPlugins();
+ try { await loadPluginList(group, true); }
+ catch (error) { if (generation === pluginGeneration && authenticated) showError("plugin-error", error); }
+ finally { if (generation === pluginGeneration) { pluginScopeBusy = false; renderPluginBusy(); renderPlugins(); } }
+}
+async function savePlugin(plugin, mode) {
+ if (pluginSaving || pluginScopeBusy || !authenticated) return;
+ const revision = pluginDrafts.get(plugin.name)?.revision || plugin.state.revision, generation = pluginGeneration, group = pluginGroup;
+ pluginSaving = true; pluginListRequest++; $("plugin-error").hidden = true; $("plugin-save-state").textContent = "正在保存 " + plugin.name + "…";
+ renderPluginBusy(); renderPlugins();
+ try {
+ const value = await api("/api/plugin-switch", {name:plugin.name,groupId:group,mode,revision});
+ if (generation !== pluginGeneration || !authenticated) return;
+ plugin.state = value.state; pluginDrafts.delete(plugin.name);pluginCatalogStateAt=new Date().toISOString();
+ $("plugin-save-state").textContent = plugin.name + " 已保存,运行规则已更新。" + (pluginGroup !== "0" && value.state.source === "global" ? "当前仍由全局设置决定生效状态。" : "");
+ } catch (error) { if (generation === pluginGeneration && authenticated) { $("plugin-save-state").textContent = "保存未完成。"; showError("plugin-error", error); } }
+ finally { if (generation === pluginGeneration) { pluginSaving = false; renderPluginBusy(); renderPlugins(); } }
+}
+$("plugin-scope").addEventListener("change", renderPluginBusy);
+$("plugin-scope-form").addEventListener("submit", async (event) => {
+ event.preventDefault(); if (pluginSaving || pluginScopeBusy || !authenticated) return;
+ const group = $("plugin-scope").value === "global" ? "0" : $("plugin-group").value.trim();
+ if ($("plugin-scope").value === "group" && !/^[1-9][0-9]{0,18}$/.test(group)) { showError("plugin-error",new Error("请填写有效的群号。")); return; }
+ if (!confirmPluginDiscard("读取插件范围")) { renderPluginScope(); renderPluginBusy(); return; }
+ $("plugin-error").hidden = true;
+ const generation = pluginGeneration;
+ pluginScopeBusy = true; renderPluginBusy(); renderPlugins();
+ try {
+ if (!await loadPluginList(group, true)) return;
+ $("plugin-save-state").textContent = "";
+ } catch (error) { if (generation === pluginGeneration && authenticated) { renderPluginScope(); showError("plugin-error",error); } }
+ finally { if (generation === pluginGeneration) { pluginScopeBusy = false; renderPluginBusy(); renderPlugins(); } }
+});
+$("reload-plugins").addEventListener("click", async () => {
+ if (!authenticated || !confirmPluginDiscard("刷新插件状态")) return;
+ const generation = pluginGeneration;
+ pluginScopeBusy = true; renderPluginBusy(); renderPlugins();
+ $("plugin-error").hidden = true;
+ try { if (await loadPluginList(pluginGroup, true)) $("plugin-save-state").textContent="状态已重新加载。"; }
+ catch (error) { if (generation === pluginGeneration && authenticated) showError("plugin-error",error); }
+ finally { if (generation === pluginGeneration) { pluginScopeBusy = false; renderPluginBusy(); renderPlugins(); } }
+});
+window.addEventListener("beforeunload", event => {
+ if (authenticated && (pluginSaving || pluginScopeBusy || pluginDrafts.size || connectionBusy || connectionDirty())) { event.preventDefault(); event.returnValue = ""; }
+});
+$("auth-form").addEventListener("submit", async (event) => {
+ event.preventDefault(); $("auth-error").hidden = true;
+ if (setupRequired && $("password").value !== $("confirm-password").value) { showError("auth-error", new Error("两次输入的密码不一致。")); return; }
+ $("login-button").disabled = true;
+ try {
+ const value = await api(setupRequired ? "/api/setup" : "/api/login", setupRequired ? { setupCode: $("setup-code").value, password: $("password").value } : { password: $("password").value });
+ csrf = value.csrfToken; await showWorkspace();
+ } catch (error) { showError("auth-error", error); }
+ finally { $("login-button").disabled = false; }
+});
+$("setup-file").addEventListener("change", async () => {
+ const file = $("setup-file").files[0];
+ if (!file) return;
+ if (file.size > 1024) { showError("auth-error", new Error("请选择后台生成的设置码文件。")); return; }
+ $("setup-code").value = (await file.text()).trim();
+});
+$("logout").addEventListener("click", async () => {
+ if (typeof securityCanLeave === "function" && !securityCanLeave()) return;
+ const session = csrf;
+ if (!await confirmConnectionDiscard("退出登录")) return;
+ if (!confirmPluginDiscard("退出登录")) return;
+ if (typeof confirmAIDiscard === "function" && !confirmAIDiscard("退出登录")) return;
+ if (!authenticated || session !== csrf) return;
+ try { await api("/api/logout", {}); if (!authenticated || session !== csrf) return; authenticated = false; csrf = ""; showAuth(); }
+ catch (error) { if (authenticated && session === csrf) showError("page-error", error); }
+});
+$("refresh").addEventListener("click", () => refreshOverview());
+$("plugin-search").addEventListener("input", renderPlugins);
+$("plugin-sort").addEventListener("change", () => {
+ const order = $("plugin-sort").value;
+ if (!authenticated || pluginSaving || pluginScopeBusy || !["default","name","enabled","disabled","drafts"].includes(order)) { $("plugin-sort").value = pluginSort; return; }
+ pluginSort = order; pluginPage = 1; renderPlugins();
+});
+$("plugin-state-filter").addEventListener("change", renderPlugins);
+$("clear-plugin-filter").addEventListener("click", () => {
+ pluginOnlyDrafts = false; $("plugin-only-drafts").checked = false;
+ pluginPage = 1; $("plugin-search").value = ""; $("plugin-state-filter").value = "all"; renderPlugins();
+});
+$("plugin-only-drafts").addEventListener("change", () => {
+ if (!authenticated || pluginSaving || pluginScopeBusy) { $("plugin-only-drafts").checked = pluginOnlyDrafts; return; }
+ pluginOnlyDrafts = $("plugin-only-drafts").checked; pluginPage = 1; renderPlugins();
+});
+$("discard-plugin-drafts").addEventListener("click", discardPluginDrafts);
+$("save-all-plugin-drafts").addEventListener("click", saveAllPluginDrafts);
+$("stop-plugin-batch").addEventListener("click", stopPluginBatch);
+$("plugin-prev").addEventListener("click", () => changePluginPage(-1));
+$("plugin-next").addEventListener("click", () => changePluginPage(1));
+$("plugin-page-size").addEventListener("change", () => {
+ if (!authenticated || pluginSaving || pluginScopeBusy) return;
+ const size = Number($("plugin-page-size").value);
+ if (![10, 20, 50].includes(size)) { $("plugin-page-size").value = String(pluginPageSize); return; }
+ pluginPageSize = size; pluginPage = 1; renderPlugins();
+});
+function resetPluginUsage() {
+ pluginUsage = null;
+ $("usage-title").textContent = ""; $("usage-text").textContent = "";
+ $("usage-export-status").textContent = "";
+ $("copy-plugin-usage").disabled = true; $("download-plugin-usage").disabled = true;
+ const selection = window.getSelection();
+ if (selection?.anchorNode && $("usage-text").contains(selection.anchorNode)) selection.removeAllRanges();
+}
+function closePluginUsage() {
+ resetPluginUsage();
+ if ($("usage-dialog").open) $("usage-dialog").close();
+}
+function openPluginUsage(plugin) {
+ if (!authenticated) return;
+ const name = typeof plugin.name === "string" ? plugin.name : "插件";
+ const brief = typeof plugin.brief === "string" ? plugin.brief : "";
+ const usage = typeof plugin.usage === "string" ? plugin.usage : "";
+ const safeName = Array.from(name.replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, "_")).slice(0, 64).join("").replace(/[. ]+$/g, "") || "usage";
+ pluginUsage = {name, usage, token:csrf, text:`插件:${name}\n简介:${brief}\n\n注册用法:\n${usage}\n`, filename:`zerobot-plugin-${safeName}.txt`, copying:false};
+ $("usage-title").textContent = name;
+ $("usage-text").textContent = usage.trim() ? usage : "这个插件暂未提供用法说明。";
+ $("usage-export-status").textContent = "";
+ $("copy-plugin-usage").disabled = !usage.trim(); $("download-plugin-usage").disabled = !usage.trim();
+ if (!$("usage-dialog").open) $("usage-dialog").showModal();
+}
+function pluginUsageCurrent(snapshot) {
+ return authenticated && pluginUsage === snapshot && csrf === snapshot.token && $("usage-dialog").open;
+}
+async function copyPluginUsage() {
+ const snapshot = pluginUsage;
+ if (!snapshot || !snapshot.usage.trim() || snapshot.copying || !pluginUsageCurrent(snapshot)) return;
+ snapshot.copying = true; $("copy-plugin-usage").disabled = true;
+ $("usage-export-status").textContent = "正在复制用法…";
+ try {
+ if (!navigator.clipboard?.writeText) throw new Error("clipboard unavailable");
+ await navigator.clipboard.writeText(snapshot.usage);
+ if (pluginUsageCurrent(snapshot)) $("usage-export-status").textContent = "已复制注册用法。";
+ } catch {
+ if (pluginUsageCurrent(snapshot)) {
+ const range = document.createRange(); range.selectNodeContents($("usage-text"));
+ const selection = window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range);
+ $("usage-text").focus();
+ $("usage-export-status").textContent = "复制未完成,已选中用法;请按 Ctrl+C 手动复制,或下载文本。";
+ }
+ } finally {
+ if (pluginUsageCurrent(snapshot)) { snapshot.copying = false; $("copy-plugin-usage").disabled = false; }
+ }
+}
+function downloadPluginUsage() {
+ const snapshot = pluginUsage;
+ if (!snapshot || !snapshot.usage.trim() || !pluginUsageCurrent(snapshot)) return;
+ let url = "", link;
+ try {
+ url = URL.createObjectURL(new Blob([snapshot.text], {type:"text/plain;charset=utf-8"}));
+ link = document.createElement("a"); link.href = url; link.download = snapshot.filename; link.hidden = true;
+ document.body.append(link); link.click();
+ $("usage-export-status").textContent = "已发起文本下载,请在浏览器下载列表核对。";
+ } catch {
+ $("usage-export-status").textContent = "下载未完成,请重试或手动复制用法。";
+ } finally {
+ link?.remove();
+ if (url) setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+}
+$("close-dialog").addEventListener("click", closePluginUsage);
+$("usage-dialog").addEventListener("close", () => { if (!$("usage-dialog").open) resetPluginUsage(); });
+$("copy-plugin-usage").addEventListener("click", copyPluginUsage);
+$("download-plugin-usage").addEventListener("click", downloadPluginUsage);
+$("download-plugin-catalog").addEventListener("click", downloadPluginCatalog);
+document.addEventListener("visibilitychange",renderPluginCatalogControls);
+document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => {
+ if (typeof connectionDeleteCanLeave === "function" && !connectionDeleteCanLeave()) return;
+ if (typeof connectionAddCanLeave === "function" && !connectionAddCanLeave()) return;
+ if (typeof securityCanLeave === "function" && !securityCanLeave()) return;
+ document.querySelectorAll("[data-view]").forEach((item) => item.classList.toggle("active", item === button));
+ for (const view of ["overview", "plugins", "connections", "ai", "logs", "backups", "security"]) $(view + "-view").hidden = button.dataset.view !== view;
+ renderPluginCatalogControls();
+ $("breadcrumb").textContent = "工作空间 / " + ({ overview:"运行概览", plugins:"插件列表", connections:"连接设置", ai:"AI 设置", logs:"运行日志", backups:"备份恢复", security:"后台安全" }[button.dataset.view]);
+ showOverview(button.dataset.view === "overview");
+ if (button.dataset.view === "plugins") ensurePluginList();
+ if (button.dataset.view === "connections" && !connectionSettings) loadConnections();
+ if (button.dataset.view === "ai" && !aiState) loadAI();
+ if (typeof showLogs === "function") showLogs(button.dataset.view === "logs");
+ if (typeof showBackups === "function") showBackups(button.dataset.view === "backups");
+ if (typeof showSecurity === "function") showSecurity(button.dataset.view === "security");
+ if (typeof showConnectionDelete === "function") showConnectionDelete(button.dataset.view === "connections");
+}));
+
+function selectedConnection() { return connectionSettings?.connections?.[connectionIndex]; }
+function connectionSnapshot() {
+ return JSON.stringify([$("connection-url").value, $("token-action").value, $("connection-token").value, $("clear-token-confirm").checked]);
+}
+function connectionDirty(connectionOnly = false) {
+ if (!connectionSettings?.available) return false;
+ const changed = connectionBaseline !== null && connectionSnapshot() !== connectionBaseline;
+ return changed || (!connectionOnly && (connectionOwnersDirty() || (typeof connectionAddDirty === "function" && connectionAddDirty())));
+}
+function connectionOwnersDirty() {
+ return !!connectionSettings?.available && $("connection-owners").value !== (connectionSettings.superUsers || []).join(", ");
+}
+function renderConnectionDrafts() {
+ const parts = [];
+ if (connectionDirty(true)) parts.push("当前连接地址或 Token");
+ if (connectionOwnersDirty()) parts.push("主人账号");
+ if (typeof connectionAddDirty === "function" && connectionAddDirty()) parts.push("新增连接");
+ $("connection-draft-status").textContent = parts.length ? "未保存更改:" + parts.join("、") + "。保存前请核对;切换后台页面保留草稿。" : "没有未保存的连接配置更改。";
+ renderConnectionDraftFields();
+ renderConnectionDiscardControls();
+ if (typeof renderConnectionAddControls === "function") renderConnectionAddControls();
+ if (typeof renderConnectionDeleteControls === "function") renderConnectionDeleteControls();
+}
+function renderConnectionDiscardControls() {
+ const blocked = !authenticated || connectionBusy || !connectionSettings?.available;
+ $("discard-current-connection").disabled = blocked || !selectedConnection() || !connectionDirty(true);
+ $("discard-connection-owners").disabled = blocked || !connectionOwnersDirty();
+}
+function renderConnectionDraftFields() {
+ const list = $("connection-draft-field-list"); list.replaceChildren();
+ $("connection-draft-fields").hidden = true;
+ $("connection-draft-field-state").textContent = "";
+ if (!authenticated || !connectionSettings?.available) return;
+ const entries = [];
+ if (selectedConnection() && connectionBaseline !== null && connectionDirty(true)) {
+ const before = JSON.parse(connectionBaseline), current = JSON.parse(connectionSnapshot()), fields = [];
+ if (before[0] !== current[0]) fields.push("WebSocket 地址");
+ if ([1, 2, 3].some(index => before[index] !== current[index])) fields.push("Token");
+ const connection = selectedConnection();
+ const known = ["ws", "wss"].includes(connection.kind) && Number.isSafeInteger(connection.index) && connection.index >= 0 && connection.index < Number.MAX_SAFE_INTEGER;
+ const title = known ? (connection.kind === "ws" ? "正向连接 " : "反向监听 ") + (connection.index + 1) : "当前连接(目标暂不可核对)";
+ entries.push({part:"connection", title, fields});
+ }
+ if (connectionOwnersDirty()) entries.push({part:"owners", title:"主人账号 · 启动配置", fields:["主人 QQ 账号"]});
+ if (typeof connectionAddDirty === "function" && connectionAddDirty()) {
+ const fields = [];
+ if ($("connection-add-kind").value !== "ws") fields.push("连接方向");
+ if ($("connection-add-url").value) fields.push("WebSocket 地址");
+ if ($("connection-add-token").value || $("connection-add-without-token").checked) fields.push("Token");
+ entries.push({part:"addition", title:"尚未保存的新连接", fields});
+ }
+ if (!entries.length) return;
+ $("connection-draft-fields").hidden = false;
+ $("connection-draft-field-state").textContent = connectionBusy ? "请求处理中,保留当前字段摘要,请等待结果;不会自动保存。" : "仅比较当前输入与页面基线;保存后仍按原提示重启生效。";
+ for (const entry of entries) {
+ const item = document.createElement("article"); item.dataset.connectionDraftPart = entry.part;
+ const heading = document.createElement("h3"); heading.textContent = entry.title;
+ const count = document.createElement("p"); count.className = "field-help"; count.textContent = "未保存字段:" + entry.fields.length + " 项(Token 操作、输入与确认合并计数)。";
+ const fields = document.createElement("ul");
+ for (const label of entry.fields) { const field = document.createElement("li"); field.textContent = label; fields.append(field); }
+ item.append(heading, count, fields); list.append(item);
+ }
+}
+function finishConnectionDraftConfirmation(accepted = false) {
+ const pending = connectionDraftConfirmation;
+ connectionDraftConfirmation = null;
+ const dialog = $("connection-draft-confirmation");
+ if (dialog.open) dialog.close();
+ $("connection-draft-confirm-message").textContent = "";
+ if (!pending) return;
+ pending.resolve(accepted);
+ if (authenticated && pending.origin?.isConnected && !pending.origin.disabled) pending.origin.focus();
+}
+function confirmConnectionDraft(message, label) {
+ if (connectionDraftConfirmation || (typeof connectionAddConfirmation !== "undefined" && connectionAddConfirmation) || !authenticated || connectionBusy) return Promise.resolve(false);
+ return new Promise(resolve => {
+ connectionDraftConfirmation = {resolve, origin:document.activeElement};
+ $("connection-draft-confirm-message").textContent = message;
+ $("connection-draft-confirm-accept").textContent = label;
+ try { $("connection-draft-confirmation").showModal(); }
+ catch (_) { finishConnectionDraftConfirmation(); showError("connection-error", new Error("无法打开确认窗口,请重新加载页面后重试。")); }
+ });
+}
+$("connection-draft-confirm-cancel").addEventListener("click", () => finishConnectionDraftConfirmation());
+$("connection-draft-confirm-accept").addEventListener("click", () => finishConnectionDraftConfirmation(true));
+$("connection-draft-confirmation").addEventListener("cancel", event => { event.preventDefault(); finishConnectionDraftConfirmation(); });
+$("connection-draft-confirmation").addEventListener("close", () => { if (!$("connection-draft-confirmation").open) finishConnectionDraftConfirmation(); });
+function connectionDraftContext(snapshot) {
+ const session = csrf, generation = connectionGeneration, settings = connectionSettings, index = connectionIndex, draft = snapshot();
+ return () => authenticated && !connectionBusy && session === csrf && generation === connectionGeneration && settings === connectionSettings && index === connectionIndex && draft === snapshot();
+}
+async function discardConnectionDraft(part) {
+ if (!authenticated || connectionBusy || !connectionSettings?.available) return;
+ if (part === "connection" ? !selectedConnection() || !connectionDirty(true) : part !== "owners" || !connectionOwnersDirty()) return;
+ const settings = connectionSettings;
+ const current = connectionDraftContext(part === "connection" ? connectionSnapshot : () => $("connection-owners").value);
+ const connection = selectedConnection();
+ const title = part === "owners" ? "主人账号" : (connection.kind === "ws" ? "正向连接 " : "反向监听 ") + (connection.index + 1) + " 的地址与 Token";
+ if (!await confirmConnectionDraft("放弃“" + title + "”的未保存更改?另一部分草稿和已保存配置保持不变。", "确认放弃") || !current()) return;
+ if (part === "connection") renderSelectedConnection(); else $("connection-owners").value = (settings.superUsers || []).join(", ");
+ renderConnectionDrafts();
+ $("connection-discard-status").textContent = "已放弃“" + title + "”的未保存更改;已保存配置和运行状态保持不变。";
+}
+function connectionControls(busy) {
+ connectionBusy = busy;
+ $("connection-fields").disabled = busy || !connectionSettings?.available;
+ $("save-connections").disabled = busy || !connectionSettings?.available;
+ $("reload-connections").disabled = busy;
+ renderConnectionDraftFields();
+ renderConnectionDiscardControls();
+ if (typeof renderConnectionAddControls === "function") renderConnectionAddControls();
+ if (typeof renderConnectionDeleteControls === "function") renderConnectionDeleteControls();
+}
+async function confirmConnectionDiscard(action, connectionOnly = false) {
+ if (connectionBusy) { $("connection-save-state").textContent = "连接请求尚未完成,请稍候再" + action + "。"; return false; }
+ if (connectionDraftConfirmation || (typeof connectionAddConfirmation !== "undefined" && connectionAddConfirmation)) return false;
+ const current = connectionDraftContext(connectionOnly ? connectionSnapshot : () => JSON.stringify([connectionSnapshot(), $("connection-owners").value, typeof connectionAddSnapshot === "function" ? connectionAddSnapshot() : null]));
+ const accepted = !connectionDirty(connectionOnly) || await confirmConnectionDraft("连接设置有未保存更改。" + action + "将丢弃" + (connectionOnly ? "当前连接的地址与 Token 选择(主人账号草稿保留)" : "这些修改") + ",是否继续?", "确认" + action);
+ return accepted && current();
+}
+function resetConnections() {
+ finishConnectionDraftConfirmation();
+ connectionGeneration++; connectionSettings = null; connectionIndex = 0; connectionBaseline = null;
+ renderStartupDifferences(null);
+ if (typeof resetConnectionDelete === "function") resetConnectionDelete();
+ if (typeof resetConnectionAdd === "function") resetConnectionAdd();
+ $("connection-select").replaceChildren();
+ for (const id of ["connection-url", "connection-token", "connection-owners"]) $(id).value = "";
+ $("token-action").value = "keep"; updateTokenAction();
+ for (const id of ["connection-running", "connection-running-owners", "connection-address-note", "token-state"]) $(id).textContent = "";
+ $("connection-save-state").textContent = "请重新读取配置。"; $("connection-error").hidden = true;
+ $("connection-discard-status").textContent = "";
+ connectionControls(false); renderConnectionDrafts();
+}
+function updateTokenAction() {
+ const mode = $("token-action").value;
+ $("connection-token").disabled = mode !== "replace"; $("connection-token").required = mode === "replace";
+ if (mode !== "replace") $("connection-token").value = "";
+ $("clear-token-label").hidden = mode !== "clear"; $("clear-token-confirm").checked = false;
+}
+function renderSelectedConnection() {
+ const connection = selectedConnection();
+ $("connection-select").value = String(connectionIndex);
+ $("connection-url").value = connection?.url || ""; $("connection-url").disabled = !connection;
+ $("token-action").value = "keep"; $("token-action").disabled = !connection;
+ $("connection-token").value = ""; updateTokenAction();
+ $("token-state").textContent = connection?.tokenConfigured ? "已配置;当前值不会显示。保留时无需填写。" : "未配置 Token。";
+ $("connection-address-note").textContent = connection?.addressRedacted ? "原地址中的认证参数已隐藏。不改动地址时会完整保留;替换地址请使用单独的 Token。" : "支持 ws:// 或 wss://,认证信息填写在 Token 中。";
+ const running = connectionSettings?.runningConnections?.find(item => item.kind === connection?.kind && item.index === connection?.index);
+ $("connection-running").textContent = running ? "本次启动原序号 " + (connection.kind === "ws" ? "正向连接 " : "反向监听 ") + (connection.index + 1) + " 的地址:" + running.url + ";Token " + (running.tokenConfigured ? "已配置" : "未配置") : "本次启动没有这个连接。";
+ connectionBaseline = connectionSnapshot(); renderConnectionDrafts();
+}
+function renderStartupDifferences(value) {
+ const list = $("startup-difference-list"); list.replaceChildren();
+ $("startup-differences").hidden = !value?.available;
+ $("startup-difference-status").textContent = "";
+ if (!value?.available) return;
+ const items = value.startupDifferences;
+ if (!Array.isArray(items) || !items.length) {
+ $("startup-difference-status").textContent = "暂无分类摘要;请以配置保存状态为准。"; return;
+ }
+ const changed = items.filter(item => item.changed).length;
+ $("startup-difference-status").textContent = value.restartRequired ? "已保存配置与本次启动不同,待重启;" + changed + " 个类别有变化。" : "已保存配置与本次启动一致,无需为这些配置重启。";
+ for (const item of items) {
+ const row = document.createElement("article"); row.className = "startup-difference-row";
+ const title = document.createElement("strong"); title.textContent = item.label;
+ const state = document.createElement("span"); state.className = "startup-difference-state"; state.classList.toggle("difference-changed", !!item.changed); state.textContent = item.changed ? "有变化" : "一致";
+ row.append(title, state);
+ if (Number.isSafeInteger(item.runningCount) && item.runningCount >= 0 && Number.isSafeInteger(item.savedCount) && item.savedCount >= 0) {
+ const detail = document.createElement("p"); detail.className = "field-help";
+ detail.textContent = "本次启动 " + item.runningCount + (item.unit || "") + " → 已保存 " + item.savedCount + (item.unit || "") + (item.changed && item.runningCount === item.savedCount ? "(数量相同,内容不同)" : ""); row.append(detail);
+ }
+ list.append(row);
+ }
+}
+function renderConnectionSettings(value) {
+ connectionSettings = value;
+ renderStartupDifferences(value);
+ $("connection-discard-status").textContent = "";
+ if (connectionIndex >= (value.connections || []).length) connectionIndex = 0;
+ $("connection-save-state").textContent = !value.available ? "当前程序未使用启动配置文件,请以 -c 配置文件 启动后再管理连接。" : value.restartRequired ? "配置已保存,与本次启动配置不同,待重启机器人生效。" : "已保存配置与本次启动配置一致。";
+ $("connection-select").replaceChildren();
+ for (const [index, connection] of (value.connections || []).entries()) {
+ const option = document.createElement("option"); option.value = String(index);
+ option.textContent = (connection.kind === "ws" ? "正向连接 " : "反向监听 ") + (connection.index + 1) + " · " + connection.url;
+ $("connection-select").append(option);
+ }
+ $("connection-owners").value = (value.superUsers || []).join(", ");
+ $("connection-running-owners").textContent = "本次启动主人账号:" + ((value.runningSuperUsers || []).join("、") || "未配置");
+ renderSelectedConnection();
+}
+async function loadConnections() {
+ if (connectionBusy || !authenticated) return;
+ if (!await confirmConnectionDiscard("重新加载配置")) return;
+ const generation = connectionGeneration;
+ connectionControls(true); $("connection-error").hidden = true;
+ try {
+ const value = await api("/api/connections");
+ if (generation !== connectionGeneration || !authenticated) return;
+ if (typeof resetConnectionAdd === "function") resetConnectionAdd();
+ renderConnectionSettings(value);
+ }
+ catch (error) { if (generation === connectionGeneration && authenticated) { $("connection-save-state").textContent = "配置读取失败,原编辑保留。"; showError("connection-error", error); } }
+ finally { if (generation === connectionGeneration) { connectionControls(false); renderConnectionDrafts(); } }
+}
+$("reload-connections").addEventListener("click", loadConnections);
+$("discard-current-connection").addEventListener("click", () => discardConnectionDraft("connection"));
+$("discard-connection-owners").addEventListener("click", () => discardConnectionDraft("owners"));
+$("connection-select").addEventListener("change", async () => {
+ const next = Number($("connection-select").value);
+ const generation = connectionGeneration, settings = connectionSettings, index = connectionIndex;
+ if (!authenticated || !settings?.connections?.[next] || !await confirmConnectionDiscard("切换连接", true)) {
+ if (generation === connectionGeneration && settings === connectionSettings && index === connectionIndex) $("connection-select").value = String(connectionIndex);
+ return;
+ }
+ connectionIndex = next; renderSelectedConnection();
+});
+$("token-action").addEventListener("change", updateTokenAction);
+for (const event of ["input", "change"]) $("connection-form").addEventListener(event, () => { $("connection-discard-status").textContent = ""; renderConnectionDrafts(); });
+$("connection-form").addEventListener("submit", async (event) => {
+ event.preventDefault(); if (connectionBusy || !connectionSettings?.available || !authenticated) return;
+ const generation = connectionGeneration;
+ $("connection-error").hidden = true;
+ const connection = selectedConnection(), mode = $("token-action").value;
+ if (connection && mode === "clear" && !$("clear-token-confirm").checked) { showError("connection-error", new Error("请先确认清除 Token。")); return; }
+ const update = { revision:connectionSettings.revision, superUsers:$("connection-owners").value.trim().split(/[\s,,]+/).filter(Boolean) };
+ if (connection) {
+ update.connection = {kind:connection.kind, index:connection.index, tokenAction:mode};
+ if ($("connection-url").value !== connection.url) update.connection.url = $("connection-url").value;
+ if (mode === "replace") update.connection.token = $("connection-token").value;
+ }
+ connectionControls(true);
+ try {
+ const value = await api("/api/connections", update);
+ if (generation !== connectionGeneration || !authenticated) return;
+ renderConnectionSettings(value);
+ if (value.backupId) $("connection-save-state").textContent += " 旧配置备份已创建。";
+ } catch (error) { if (generation === connectionGeneration && authenticated) showError("connection-error", error); }
+ finally { if (generation === connectionGeneration) { connectionControls(false); renderConnectionDrafts(); } }
+});
+document.addEventListener("DOMContentLoaded", async () => {
+ try { const state = await api("/api/session"); setupRequired = state.setupRequired; csrf = state.csrfToken || ""; if (state.authenticated) await showWorkspace(); else showAuth(); }
+ catch (error) { showAuth(); showError("auth-error", error); $("login-button").disabled = true; }
+});
+
diff --git a/internal/localadmin/assets/appearance.css b/internal/localadmin/assets/appearance.css
new file mode 100644
index 0000000000..4482931f3b
--- /dev/null
+++ b/internal/localadmin/assets/appearance.css
@@ -0,0 +1,90 @@
+/* Shared palette keeps forms, dialogs and dynamically rendered cards consistent. */
+:root {
+ color-scheme: light;
+ --page:#f6f8f5; --surface:#ffffff; --surface-alt:#fafbf8;
+ --text:#1e302a; --muted:#52654f; --line:#dce5d7; --accent:#496541;
+ --green:#365e43; --primary-text:#ffffff; --primary-hover:#2b4b36;
+ --soft:#eef4e7; --soft-text:#45603a; --soft-strong:#d3e2c6;
+ --disabled-bg:#f0f2ed; --disabled-text:#586151;
+ --error-bg:#fff1eb; --error-text:#9d3b2b; --error-line:#ebccbc;
+ --warning-bg:#fff7e5; --warning-text:#805b24; --warning-line:#d9c49d;
+ --focus:#618456; --focus-ring:#d4e5c8;
+}
+:root[data-theme="dark"] {
+ color-scheme: dark;
+ --page:#131d18; --surface:#1d2a22; --surface-alt:#18231d;
+ --text:#e4eddf; --muted:#afbcad; --line:#40503e; --accent:#b1d29f;
+ --green:#a0c58d; --primary-text:#162817; --primary-hover:#b7d8a7;
+ --soft:#283b28; --soft-text:#b7d8a7; --soft-strong:#384f33;
+ --disabled-bg:#2b332c; --disabled-text:#b6c0b0;
+ --error-bg:#422822; --error-text:#ffc0a6; --error-line:#795045;
+ --warning-bg:#3c3220; --warning-text:#f0cf91; --warning-line:#756039;
+ --focus:#b1d29f; --focus-ring:#405d35;
+}
+html,body {background:var(--page); color:var(--text);}
+.auth-panel {background:var(--surface-alt);}
+.topbar {background:var(--surface-alt); color:var(--muted);}
+.panel,.metric,.plugin-card,dialog {background:var(--surface); color:var(--text); border-color:var(--line);}
+input:not([type=file]),.connection-form select,.plugin-scope select,.plugin-controls select,.plugin-filter-label select,.ai-form select,.ai-form textarea,.log-toolbar select,.backup-create select,.backup-filter select,.backup-pagination select,.plugin-pagination select {
+ background:var(--surface); color:var(--text); border-color:var(--line);
+}
+input:focus,select:focus,textarea:focus {border-color:var(--focus); outline:2px solid var(--focus-ring); outline-offset:2px;}
+button:focus-visible,a:focus-visible {outline:2px solid var(--focus); outline-offset:3px;}
+input::placeholder {color:var(--muted); opacity:1;}
+input:-webkit-autofill {-webkit-text-fill-color:var(--text); box-shadow:0 0 0 1000px var(--surface) inset; caret-color:var(--text);}
+.muted,.field-help,.panel-note,.auth-note,.content-footer,.empty,.metric>span,.metric small,.plugin-card p,.info-list dt,.account-row small {color:var(--muted);}
+.info-list>div {border-color:var(--line);}
+.info-list dd,.text-button,.file-picker {color:var(--accent);}
+.secondary,.theme-choice select {background:var(--surface-alt); color:var(--accent); border-color:var(--line);}
+.secondary:hover {background:var(--soft);}
+.primary {color:var(--primary-text);}
+.primary:hover {background:var(--primary-hover);}
+.status-banner,.local-pill,.subtle-tag,.plugin-default,.account-avatar,.log-level {background:var(--soft); color:var(--soft-text); border-color:var(--line);}
+.status-banner p {color:var(--muted);}
+.status-emblem {background:var(--soft-strong); color:var(--soft-text);}
+.state-badge {background:var(--soft); color:var(--soft-text); border-color:var(--line);}
+.state-badge.waiting,.log-warning-level {background:var(--warning-bg); color:var(--warning-text); border-color:var(--warning-line);}
+.plugin-default.disabled {background:var(--disabled-bg); color:var(--disabled-text);}
+.error,.log-error-level {background:var(--error-bg); color:var(--error-text); border-color:var(--error-line);}
+pre {background:var(--surface-alt); color:var(--text);}
+.log-entry pre {background:none;}
+.theme-choice {display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted);}
+.theme-choice select {max-width:132px; border:1px solid var(--line); border-radius:7px; padding:8px; font:inherit; cursor:pointer;}
+.auth-theme {justify-content:flex-end; margin-bottom:28px;}
+.auth-form label.theme-choice {display:flex; margin:0 0 28px; font-weight:400;}
+.plugin-toolbar {flex-wrap:wrap; gap:12px;}
+.plugin-toolbar input {flex:1 1 280px; min-width:0;}
+.plugin-filter-label {display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted);}
+.plugin-filter-label select {font:inherit; padding:12px; border:1px solid var(--line); border-radius:8px;}
+.plugin-toolbar #search-count {flex-basis:100%; margin-left:0;}
+/* Narrow windows retain named navigation and stack controls without clipping. */
+@media(max-width:700px) {
+ .page-heading {flex-wrap:wrap; gap:14px;}
+ .page-heading>div {min-width:0;}
+ .panel,.plugin-card,.metric {min-width:0;}
+ .plugin-card h2,.plugin-card p,.panel-note,.field-help {overflow-wrap:anywhere;}
+ .plugin-controls select {flex-basis:100%;}
+ .plugin-controls button {width:100%;}
+ .topbar {height:auto; min-height:70px; gap:12px; padding:14px 18px; flex-wrap:wrap;}
+ .topbar>div {gap:12px; flex-wrap:wrap;}
+ .status-banner {flex-wrap:wrap;}
+ .state-badge {margin-left:0;}
+}
+@media(max-width:640px) {
+ .workspace {flex-direction:column;}
+ .sidebar {width:100%; padding:16px;}
+ .sidebar .brand>span:last-child {display:block;}
+ .sidebar nav {display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:16px;}
+ .sidebar .nav-button {display:flex; justify-content:center; gap:6px; padding:12px 4px; margin:0; font-size:14px; min-width:0;}
+ .sidebar .nav-button span {display:inline; font-size:12px;}
+ .sidebar .nav-button .nav-count {display:none;}
+ .content {padding:24px 16px;}
+ .content-shell {width:100%;}
+ .log-meta {flex-wrap:wrap; gap:8px;}
+ .plugin-card footer {flex-wrap:wrap;}
+ .info-list>div {flex-wrap:wrap;}
+ .info-list dd {max-width:100%;}
+ .theme-choice select,input:not([type=file]),.connection-form select,.plugin-scope select,.plugin-controls select,.plugin-filter-label select,.ai-form select,.ai-form textarea,.log-toolbar select,.backup-create select,.backup-filter select,.backup-pagination select,.plugin-pagination select {font-size:16px;}
+ .secondary,.text-button {min-height:44px;}
+ dialog {width:calc(100vw - 24px); padding:18px;}
+}
diff --git a/internal/localadmin/assets/backups.js b/internal/localadmin/assets/backups.js
new file mode 100644
index 0000000000..97fbe046fe
--- /dev/null
+++ b/internal/localadmin/assets/backups.js
@@ -0,0 +1,435 @@
+"use strict";
+let backupPreview = null, backupCleanupPreview = null, backupBusy = false, backupGeneration = 0, backupActive = false, backupRequest = null;
+const backupScopeNames = {"startup-config":"启动配置", "control-db":"控制数据库", "admin-options":"后台启用与地址"};
+let backupCatalog=null, backupSearch="", backupScopeFilter="all", backupStateFilter="all", backupReadFailed=false, backupReadAt=null;
+let backupPage=1, backupPageSize=20, backupPageCount=1, backupMatchCount=0;
+let backupSort="default";
+let backupFromDate="", backupUntilDate="", backupDateStart=null, backupDateEnd=null, backupDateTimeZone="";
+let backupExpiryTimer=null, backupExpiryGeneration=0;
+const expiredBackupPreviews=new WeakSet();
+
+function stopBackupExpiryTimer() {
+ backupExpiryGeneration++;clearTimeout(backupExpiryTimer);backupExpiryTimer=null;
+}
+function validBackupExpiry(preview) {
+ return Number.isSafeInteger(preview?.expiresAt) && preview.expiresAt>0 && Number.isFinite(new Date(preview.expiresAt*1000).getTime());
+}
+function backupPreviewExpired(preview) {
+ return !validBackupExpiry(preview) || expiredBackupPreviews.has(preview) || preview.expiresAt*1000<=Date.now();
+}
+function renderBackupExpiry() {
+ stopBackupExpiryTimer();let ticking=false;
+ for (const [kind,preview,actionable] of [["restore",backupPreview,!!backupPreview?.changed],["cleanup",backupCleanupPreview,!!backupCleanupPreview?.candidates?.length]]) {
+ const line=$(kind+"-expiry"),state=$(kind+"-expiry-state"),confirm=$(kind+"-confirm"),button=$(kind==="restore" ? "restore-backup" : "cleanup-backups");
+ let message="",expired=false;
+ if (!preview) {
+ line.textContent="";confirm.checked=false;confirm.disabled=true;button.disabled=true;
+ } else if (!authenticated || !backupActive) {
+ confirm.disabled=true;button.disabled=true;
+ } else if (backupBusy) {
+ confirm.disabled=true;button.disabled=true;message="请求处理中,请等待结果;计时不会中断已提交的操作。";
+ } else if (backupPreviewExpired(preview)) {
+ expired=true;
+ if (typeof preview==="object") expiredBackupPreviews.add(preview);confirm.checked=false;confirm.disabled=true;button.disabled=true;
+ line.textContent=validBackupExpiry(preview) ? "预览有效至 "+new Date(preview.expiresAt*1000).toLocaleTimeString("zh-CN",{hour12:false})+";剩余 0分00秒。" : "预览有效期无法确认。";
+ message=validBackupExpiry(preview) ? "预览已过期,请重新预览并核对后再次确认。" : "无法确认有效期,请重新预览后再确认。";
+ } else {
+ const seconds=Math.ceil((preview.expiresAt*1000-Date.now())/1000);
+ line.textContent="预览有效至 "+new Date(preview.expiresAt*1000).toLocaleTimeString("zh-CN",{hour12:false})+";剩余 "+Math.floor(seconds/60)+"分"+String(seconds%60).padStart(2,"0")+"秒(本机估算)。";
+ confirm.disabled=false;button.disabled=!actionable || !confirm.checked;
+ message="到期需重新预览;提交时仍会校验当前状态,不会自动"+(kind==="restore" ? "恢复。" : "删除。");ticking=true;
+ }
+ state.dataset.expired=String(expired);
+ if (state.textContent!==message) state.textContent=message;
+ }
+ if (ticking && authenticated && backupActive && !backupBusy && !document.hidden) {
+ const generation=backupExpiryGeneration;
+ backupExpiryTimer=setTimeout(()=>{if (generation===backupExpiryGeneration && authenticated && backupActive && !backupBusy && !document.hidden) renderBackupExpiry();},1000);
+ }
+}
+
+function resetBackups() {
+ backupGeneration++; backupRequest?.abort(); backupRequest = null;
+ backupPreview = null; backupBusy = false; backupActive = false;
+ backupCatalog=null;backupSearch="";backupScopeFilter="all";backupStateFilter="all";backupReadFailed=false;
+ backupReadAt=null;$("backup-export-status").textContent="";
+ backupFromDate="";backupUntilDate="";backupDateStart=null;backupDateEnd=null;backupDateTimeZone="";
+ $("backup-from-date").value="";$("backup-until-date").value="";$("backup-date-error").hidden=true;
+ renderBackupSizeSummary([],[]);
+ backupPage=1;backupPageSize=20;backupPageCount=1;backupMatchCount=0;backupSort="default";$("backup-page-size").value="20";
+ renderBackupPagination();
+ $("backup-search").value="";$("backup-scope-filter").value="all";$("backup-state-filter").value="all";
+ $("backup-filter-count").textContent="尚未读取备份列表。";$("restore-backup-id").textContent="";
+ clearCleanupPreview();
+ $("backup-list").replaceChildren(); $("restore-preview").hidden = true;
+ $("restore-confirm").checked = false; $("restore-backup").disabled = true;
+ $("restore-impact-list").replaceChildren();$("restore-impact-status").textContent="";
+ $("backup-error").hidden = true; $("backup-status").textContent = "进入页面后读取备份。";
+ $("reload-backups").disabled = false; $("create-backup").disabled = true;
+ for (const id of ["backup-search","backup-scope-filter","backup-state-filter","clear-backup-filter"]) $(id).disabled=false;
+ renderBackupDateFilter();
+}
+function showBackups(active) {
+ backupActive = active;
+ if (!active) {
+ backupGeneration++; backupRequest?.abort(); backupRequest = null;
+ backupBusy = false; clearRestorePreview();
+ clearCleanupPreview();
+ $("restore-confirm").checked = false; $("restore-backup").disabled = true;
+ renderBackupExportControls();
+ renderBackupDateFilter();
+ } else loadBackups();
+}
+function backupReady(generation) { return generation === backupGeneration && authenticated && backupActive; }
+function clearRestorePreview() {
+ $("restore-impact-list").replaceChildren();$("restore-impact-status").textContent="";
+ backupPreview = null; $("restore-preview").hidden = true;
+ $("restore-backup-id").textContent="";
+ $("restore-confirm").checked = false; $("restore-backup").disabled = true;
+ renderBackupExpiry();
+}
+function renderRestoreImpacts(impacts) {
+ const list=$("restore-impact-list");list.replaceChildren();
+ const items=Array.isArray(impacts) ? impacts.filter(item=>item && typeof item.label==="string" && typeof item.changed==="boolean").slice(0,12) : [];
+ $("restore-impact-status").textContent=items.length ? "共 "+items.length+" 个类别,"+items.filter(item=>item.changed).length+" 个有变化;按预览时当前保存状态比较。" : "当前预览未提供类别摘要,以上方覆盖说明为准。";
+ for (const item of items) {
+ const row=document.createElement("article"),label=document.createElement("strong"),state=document.createElement("span");
+ row.className="restore-impact-row";label.textContent=item.label;
+ state.className="restore-impact-state"+(item.changed ? " impact-changed" : "");state.textContent=item.changed ? "有变化" : "一致";
+ row.append(label,state);
+ if (Number.isSafeInteger(item.currentCount) && item.currentCount>=0 && Number.isSafeInteger(item.backupCount) && item.backupCount>=0) {
+ const counts=document.createElement("p");counts.className="field-help";
+ const unit=typeof item.unit==="string" ? item.unit : "";
+ counts.textContent="当前 "+item.currentCount+unit+" → 备份 "+item.backupCount+unit+(item.changed && item.currentCount===item.backupCount ? "(数量相同,内容不同)" : "");row.append(counts);
+ }
+ list.append(row);
+ }
+}
+function backupControls(busy) {
+ if (busy) $("backup-export-status").textContent="";
+ backupBusy = busy; $("reload-backups").disabled = busy;
+ $("create-backup").disabled = busy || !$("backup-scope").value;
+ $("backup-scope").disabled = busy;
+ $("preview-cleanup").disabled = busy;
+ $("cleanup-keep").disabled = busy; $("cleanup-days").disabled = busy;
+ $("cleanup-backups").disabled = busy || !backupCleanupPreview?.candidates?.length || !$("cleanup-confirm").checked;
+ $("restore-backup").disabled = busy || !backupPreview?.changed || !$("restore-confirm").checked;
+ for (const id of ["backup-search","backup-scope-filter","backup-state-filter","clear-backup-filter"]) $(id).disabled=busy;
+ for (const button of $("backup-list").querySelectorAll("button")) button.disabled = busy || button.dataset.invalid === "true";
+ renderBackupPagination();
+ renderBackupExpiry();
+}
+function renderBackupPagination() {
+ renderBackupExportControls();
+ renderBackupDateFilter();
+ $("backup-page-status").textContent=backupMatchCount ? "第 "+backupPage+" / "+backupPageCount+" 页;显示 "+((backupPage-1)*backupPageSize+1)+"~"+Math.min(backupPage*backupPageSize,backupMatchCount)+" / "+backupMatchCount+" 份匹配记录。" : backupCatalog ? "没有匹配记录。" : "尚未读取备份。";
+ $("backup-prev").disabled=backupBusy || !backupMatchCount || backupPage<=1;
+ $("backup-next").disabled=backupBusy || !backupMatchCount || backupPage>=backupPageCount;
+ $("backup-page-size").disabled=backupBusy;
+ $("backup-sort").value=backupSort;
+ $("backup-sort").disabled=backupBusy || !authenticated || !backupActive || !backupCatalog;
+}
+function renderBackups(value) {
+ backupCatalog=value;backupReadFailed=false;backupReadAt=new Date().toISOString(); $("backup-scope").replaceChildren();
+ for (const [scope, label] of Object.entries(backupScopeNames)) {
+ if (!value.scopes?.[scope]) continue;
+ const option = document.createElement("option"); option.value = scope; option.textContent = label; $("backup-scope").append(option);
+ }
+ $("backup-scope").value = $("backup-scope").firstElementChild?.value || "";
+ renderBackupList();
+ $("backup-status").textContent = value.more ? "仅列出最近 200 个备份。更早记录保留在本机目录。" : "已读取 " + (value.backups || []).length + " 个备份。恢复前会再次校验完整性和兼容性。";
+}
+function backupEntryState(entry) { return !entry.valid ? "invalid" : entry.integrity ? "verified" : "legacy"; }
+function backupEntryScope(entry) { return Object.hasOwn(backupScopeNames,entry.scope) ? entry.scope : "unknown"; }
+function filteredBackupEntries() {
+ const items=(backupCatalog?.backups || []).filter(entry=>{
+ const state=backupEntryState(entry),scope=backupEntryScope(entry);
+ const label=scope==="unknown" ? "异常/未知范围" : backupScopeNames[scope];
+ const date=entry.valid ? new Date(entry.createdAt).toLocaleString("zh-CN",{hour12:false}) : "";
+ const time=entry.valid===true && typeof entry.createdAt==="string" ? Date.parse(entry.createdAt) : NaN;
+ const dateMatches=backupDateStart===null && backupDateEnd===null || Number.isFinite(time) && (backupDateStart===null || time>=backupDateStart) && (backupDateEnd===null || time{
+ if (entry.valid!==true) return null;
+ if (backupSort==="oldest") {
+ const value=typeof entry.createdAt==="string" ? Date.parse(entry.createdAt) : NaN;
+ return Number.isFinite(value) ? value : null;
+ }
+ return Number.isSafeInteger(entry.size) && entry.size>=0 ? entry.size : null;
+ };
+ return items.map((entry,index)=>({entry,index,key:key(entry)})).sort((a,b)=>{
+ if (a.key===null || b.key===null) return (a.key===null)-(b.key===null) || a.index-b.index;
+ return (backupSort==="largest" ? b.key-a.key : a.key-b.key) || a.index-b.index;
+ }).map(item=>item.entry);
+}
+function renderBackupList() {
+ $("backup-export-status").textContent="";
+ $("backup-list").replaceChildren();
+ const entries=backupCatalog?.backups || [],counts={verified:0,legacy:0,invalid:0};
+ for (const entry of entries) counts[backupEntryState(entry)]++;
+ const items=filteredBackupEntries();
+ renderBackupSizeSummary(entries,items);
+ $("backup-filter-count").textContent=backupCatalog ? items.length+" / "+entries.length+" 份已加载备份;含摘要 "+counts.verified+",旧版无摘要 "+counts.legacy+",不可恢复 "+counts.invalid+"。"+(backupCatalog.more ? " 仅检索本次加载的最新 200 份,更早未加载记录不在结果中。" : "")+(backupReadFailed ? " 上次读取结果,刷新失败。" : "") : "尚未读取备份列表。";
+ backupMatchCount=items.length;backupPageCount=Math.max(1,Math.ceil(items.length/backupPageSize));backupPage=Math.min(backupPage,backupPageCount);
+ renderBackupPagination();
+ for (const entry of items.slice((backupPage-1)*backupPageSize,backupPage*backupPageSize)) {
+ const card = document.createElement("article"), info = document.createElement("div"), title = document.createElement("h2"), detail = document.createElement("p"), id = document.createElement("small"), button = document.createElement("button");
+ card.className = "panel backup-card";
+ title.textContent = backupScopeNames[entry.scope] || "无效备份";
+ detail.className = "muted";
+ detail.textContent = entry.valid ? new Date(entry.createdAt).toLocaleString("zh-CN", {hour12:false}) + " · " + Math.ceil(entry.size/1024) + " KiB · 格式 v" + entry.version + (entry.integrity ? " · 含校验摘要" : " · 旧版无摘要") : "损坏或格式不支持,不能恢复。";
+ id.textContent = entry.id; button.className = "secondary"; button.textContent = "预览恢复";
+ button.disabled = backupBusy || !entry.valid; button.dataset.invalid = String(!entry.valid);
+ button.addEventListener("click", () => previewBackup(entry.id));
+ info.append(title,detail,id); card.append(info,button); $("backup-list").append(card);
+ }
+ if (!items.length) {
+ const empty = document.createElement("p"); empty.className = "empty";
+ empty.textContent = !backupCatalog ? "尚未读取备份,请刷新列表。" : entries.length ? "没有符合筛选条件的备份,请调整条件或清除筛选。" : "暂无备份。可以手动创建,配置修改前也会自动备份。";
+ $("backup-list").append(empty);
+ }
+}
+function backupSizeStats(entries) {
+ let bytes=0n,unknown=0;
+ for (const entry of entries) {
+ if (entry.valid===true && Number.isSafeInteger(entry.size) && entry.size>=0) bytes+=BigInt(entry.size);
+ else unknown++;
+ }
+ return {count:entries.length,bytes,unknown};
+}
+function formatBackupBytes(bytes) {
+ if (bytes<1024n) return bytes+" 字节";
+ const units=["字节","KiB","MiB","GiB","TiB","PiB","EiB"];
+ let unit=0,scale=1n;
+ while (unit=scale*1024n) {unit++;scale*=1024n;}
+ const rounded=(bytes*100n+scale/2n)/scale;
+ return rounded/100n+"."+String(rounded%100n).padStart(2,"0")+" "+units[unit]+"("+bytes+" 字节)";
+}
+function backupSizeDescription(entries) {
+ const stats=backupSizeStats(entries);
+ return stats.count+" 份;可核对内容大小:"+formatBackupBytes(stats.bytes)+";大小无法核对:"+stats.unknown+" 份。";
+}
+function renderBackupSizeSummary(entries,items) {
+ const panel=$("backup-size-summary"),list=$("backup-size-scopes");
+ panel.hidden=!backupCatalog;list.replaceChildren();
+ for (const id of ["backup-size-loaded","backup-size-matched","backup-size-status"]) $(id).textContent="";
+ if (!backupCatalog) return;
+ $("backup-size-loaded").textContent="已加载:"+backupSizeDescription(entries);
+ $("backup-size-matched").textContent="当前匹配(跨全部已加载页):"+backupSizeDescription(items);
+ $("backup-size-status").textContent="最近成功读取:"+new Date(backupReadAt).toLocaleString("zh-CN",{hour12:false})+"。"+(backupReadFailed ? "上次读取结果,刷新失败。" : "")+(backupCatalog.more ? "仅加载最新200份,更早未加载记录不在摘要中。" : "");
+ for (const [scope,label] of [...Object.entries(backupScopeNames),["unknown","异常/未知范围"]]) {
+ const row=document.createElement("article"),title=document.createElement("h3"),loaded=document.createElement("p"),matched=document.createElement("p");
+ title.textContent=label;loaded.textContent="已加载:"+backupSizeDescription(entries.filter(entry=>backupEntryScope(entry)===scope));
+ matched.textContent="当前匹配:"+backupSizeDescription(items.filter(entry=>backupEntryScope(entry)===scope));
+ row.append(title,loaded,matched);list.append(row);
+ }
+}
+function renderBackupExportControls() {
+ $("download-backup-catalog").disabled=!authenticated || !backupActive || backupBusy || !backupCatalog;
+}
+function downloadBackupCatalog() {
+ if (!authenticated || !backupActive || backupBusy || !backupCatalog) return;
+ let url=null,link=null;
+ try {
+ const exportedAt=new Date().toISOString(),entries=filteredBackupEntries().map(entry=>({
+ id:typeof entry.id==="string" && /^[0-9]{8}T[0-9]{6}\.[0-9]{9}Z-[0-9a-f]{16}(\.json|-control)$/.test(entry.id) ? entry.id : null,
+ createdAt:entry.valid && typeof entry.createdAt==="string" && Number.isFinite(Date.parse(entry.createdAt)) ? new Date(entry.createdAt).toISOString() : null,
+ scope:backupEntryScope(entry),
+ size:Number.isSafeInteger(entry.size) && entry.size>=0 ? entry.size : null,
+ version:Number.isSafeInteger(entry.version) && entry.version>0 ? entry.version : null,
+ integrity:entry.integrity===true,valid:entry.valid===true
+ }));
+ // Explicit metadata fields only: never serialize the catalog or restore ticket.
+ const value={format:"zerobot-backup-catalog",version:1,exportedAt,readAt:backupReadAt,
+ source:{loaded:backupCatalog.backups.length,matched:entries.length,more:backupCatalog.more===true,lastReadFailed:backupReadFailed},
+ filters:{scope:backupScopeFilter,state:backupStateFilter,keywordApplied:!!backupSearch,sort:backupSort,
+ ...(backupFromDate || backupUntilDate ? {fromDate:backupFromDate,untilDate:backupUntilDate,dateTimeZone:backupDateTimeZone} : {})},backups:entries};
+ url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+"\n"],{type:"application/json;charset=utf-8"}));
+ link=document.createElement("a");link.href=url;link.download="zerobot-backup-catalog-"+exportedAt.replace(/[-:.]/g,"")+".json";
+ document.body.append(link);link.click();
+ $("backup-export-status").textContent="已发起清单下载,共 "+entries.length+" 份匹配记录(跨全部已加载页)。"+(backupReadFailed ? " 使用上次读取结果,最近刷新失败。" : "")+(backupCatalog.more ? " 更早未加载备份未列入。" : "")+" 清单仅供核对,不能用于恢复。";
+ } catch {
+ $("backup-export-status").textContent="清单下载未完成,请检查浏览器下载设置后重试。";
+ } finally {
+ link?.remove();if (url) window.setTimeout(()=>URL.revokeObjectURL(url),1000);
+ }
+}
+$("download-backup-catalog").addEventListener("click",downloadBackupCatalog);
+$("backup-sort").addEventListener("change",()=>{
+ const sort=$("backup-sort").value;
+ if (!authenticated || !backupActive || backupBusy || !backupCatalog || !["default","oldest","largest","smallest"].includes(sort)) {$("backup-sort").value=backupSort;return;}
+ backupSort=sort;backupPage=1;renderBackupList();
+});
+function backupDateBoundary(value, nextDay=false) {
+ if (!value) return null;
+ const match=/^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+ if (!match || Number(match[1])<1) throw new Error("请填写有效的本地日期。");
+ const [year,month,day]=match.slice(1).map(Number),date=new Date(0);
+ date.setFullYear(year,month-1,day);date.setHours(0,0,0,0);
+ if (!Number.isFinite(date.getTime()) || date.getFullYear()!==year || date.getMonth()!==month-1 || date.getDate()!==day) throw new Error("请填写有效的本地日期。");
+ // Use the next calendar day, not a fixed 24 hours, across daylight-saving changes.
+ if (nextDay) {date.setDate(date.getDate()+1);date.setHours(0,0,0,0);}
+ return date.getTime();
+}
+function renderBackupDateFilter() {
+ const draft=$("backup-from-date").value!==backupFromDate || $("backup-until-date").value!==backupUntilDate;
+ $("backup-date-status").textContent=(backupFromDate || backupUntilDate ? "已应用日期(本地,包含结束当天):"+(backupFromDate || "不限")+" 至 "+(backupUntilDate || "不限")+"。有日期限制时排除无法核对创建时间的记录。" : "已应用:不限日期。")+(draft ? " 日期修改尚未应用,列表、摘要与清单仍使用原条件。" : "");
+ const disabled=backupBusy || !authenticated || !backupActive || !backupCatalog;
+ for (const id of ["backup-from-date","backup-until-date","apply-backup-date"]) $(id).disabled=disabled;
+}
+function applyBackupDateFilter() {
+ if (!authenticated || !backupActive || backupBusy || !backupCatalog) return;
+ const from=$("backup-from-date").value,until=$("backup-until-date").value;
+ try {
+ if (!$("backup-from-date").validity.valid || !$("backup-until-date").validity.valid) throw new Error("请填写有效的本地日期。");
+ const start=backupDateBoundary(from),end=backupDateBoundary(until,true);
+ if (from && until && from>until) throw new Error("开始日期不能晚于结束日期。");
+ backupFromDate=from;backupUntilDate=until;backupDateStart=start;backupDateEnd=end;
+ backupDateTimeZone=Intl.DateTimeFormat().resolvedOptions().timeZone;
+ $("backup-date-error").hidden=true;backupPage=1;renderBackupList();
+ } catch(error) {showError("backup-date-error",error);renderBackupDateFilter();}
+}
+$("backup-date-filter").addEventListener("submit",event=>{event.preventDefault();applyBackupDateFilter();});
+for (const id of ["backup-from-date","backup-until-date"]) $(id).addEventListener("input",()=>{$("backup-date-error").hidden=true;renderBackupDateFilter();});
+function applyBackupFilter() {
+ if (!authenticated || !backupActive || backupBusy) return;
+ const search=$("backup-search").value.toLocaleLowerCase().trim(),scope=$("backup-scope-filter").value || "all",state=$("backup-state-filter").value || "all";
+ if (search!==backupSearch || scope!==backupScopeFilter || state!==backupStateFilter) backupPage=1;
+ backupSearch=search;backupScopeFilter=scope;backupStateFilter=state;
+ renderBackupList();
+}
+$("backup-filter").addEventListener("submit",event=>{event.preventDefault();applyBackupFilter();});
+$("backup-search").addEventListener("input",applyBackupFilter);
+for (const id of ["backup-scope-filter","backup-state-filter"]) $(id).addEventListener("change",applyBackupFilter);
+$("clear-backup-filter").addEventListener("click",()=>{
+ if (!authenticated || !backupActive || backupBusy) return;
+ backupFromDate="";backupUntilDate="";backupDateStart=null;backupDateEnd=null;backupDateTimeZone="";
+ $("backup-from-date").value="";$("backup-until-date").value="";$("backup-date-error").hidden=true;
+ backupPage=1;$("backup-search").value="";$("backup-scope-filter").value="all";$("backup-state-filter").value="all";applyBackupFilter();
+});
+function changeBackupPage(step) {
+ if (!authenticated || !backupActive || backupBusy) return;
+ const next=backupPage+step;if (next<1 || next>backupPageCount) return;
+ backupPage=next;renderBackupList();
+}
+$("backup-prev").addEventListener("click",()=>changeBackupPage(-1));
+$("backup-next").addEventListener("click",()=>changeBackupPage(1));
+$("backup-page-size").addEventListener("change",()=>{
+ if (!authenticated || !backupActive || backupBusy) return;
+ const size=Number($("backup-page-size").value);
+ if (![10,20,50].includes(size)) {$("backup-page-size").value=String(backupPageSize);return;}
+ backupPageSize=size;backupPage=1;renderBackupList();
+});
+async function loadBackups() {
+ if (!authenticated || !backupActive || backupBusy) return;
+ const generation = ++backupGeneration; clearRestorePreview(); clearCleanupPreview(); $("backup-error").hidden = true; backupControls(true);
+ const controller = new AbortController(); backupRequest = controller;
+ try { const value = await api("/api/backups", undefined, controller.signal); if (backupReady(generation)) renderBackups(value); }
+ catch (error) { if (backupReady(generation) && error.name !== "AbortError") {backupReadFailed=true;renderBackupList();$("backup-status").textContent=backupCatalog ? "备份列表读取失败;当前显示上次读取结果。" : "备份列表读取失败;尚未取得记录。";showError("backup-error",error);} }
+ finally { if (backupReady(generation)) {backupRequest=null; backupControls(false);} }
+}
+async function previewBackup(id) {
+ if (!authenticated || !backupActive || backupBusy) return;
+ const generation = ++backupGeneration; clearRestorePreview(); clearCleanupPreview(); $("backup-error").hidden = true; backupControls(true);
+ const controller = new AbortController(); backupRequest = controller;
+ try {
+ const value = await api("/api/backups/preview", {id}, controller.signal);
+ if (!backupReady(generation)) return;
+ backupPreview = value; $("restore-preview").hidden = false;
+ $("restore-backup-id").textContent="备份编号:"+value.backup.id;
+ $("restore-title").textContent = "恢复预览:" + backupScopeNames[value.backup.scope];
+ $("restore-detail").textContent = value.changed ? "此操作将覆盖该范围的当前状态。" + (value.backup.scope === "control-db" ? " 包含 " + value.tables + " 个表、" + value.rows + " 条记录," + value.changedTables + " 个表有变化。" : "") : "备份与当前配置一致,无需恢复。";
+ renderRestoreImpacts(value.impacts);
+ $("restore-warnings").replaceChildren();
+ for (const warning of value.warnings || []) {const item=document.createElement("li"); item.textContent=warning; $("restore-warnings").append(item);}
+ $("restore-preview").scrollIntoView?.({block:"start",behavior:window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth"});
+ } catch (error) { if (backupReady(generation) && error.name !== "AbortError") showError("backup-error",error); }
+ finally { if (backupReady(generation)) {backupRequest=null; backupControls(false);} }
+}
+$("reload-backups").addEventListener("click",loadBackups);
+$("backup-scope").addEventListener("change",()=>backupControls(backupBusy));
+$("restore-confirm").addEventListener("change",()=>backupControls(backupBusy));
+$("cancel-restore").addEventListener("click",clearRestorePreview);
+$("backup-create-form").addEventListener("submit",async event=>{
+ event.preventDefault(); if (!authenticated || !backupActive || backupBusy || !$("backup-scope").value) return;
+ const generation=++backupGeneration, scope=$("backup-scope").value;
+ clearRestorePreview(); clearCleanupPreview(); $("backup-error").hidden=true; backupControls(true);
+ try {
+ await api("/api/backups",{scope});
+ if (!backupReady(generation)) return;
+ backupControls(false); await loadBackups();
+ if (authenticated && backupActive) $("backup-status").textContent += " 手动备份已创建。";
+ } catch(error) { if (backupReady(generation)) showError("backup-error",new Error(error.message + " 请求中断时,请刷新列表确认是否已创建;不要连续重复提交。")); }
+ finally { if (backupReady(generation)) backupControls(false); }
+});
+$("restore-backup").addEventListener("click",async()=>{
+ if (!authenticated || !backupActive || backupBusy || !backupPreview?.changed || !$("restore-confirm").checked) return;
+ if (backupPreviewExpired(backupPreview)) {clearRestorePreview(); showError("backup-error",new Error("预览已过期或有效期无法确认,请重新预览。")); return;}
+ const generation=++backupGeneration, preview=backupPreview;
+ $("backup-error").hidden=true; backupControls(true);
+ try {
+ const result=await api("/api/backups/restore",{id:preview.backup.id,revision:preview.revision,expiresAt:preview.expiresAt,token:preview.token,confirm:true});
+ if (!backupReady(generation)) return;
+ // Restored values must be reloaded in their own pages; no stale drafts survive.
+ if (typeof resetConnections === "function") resetConnections(); else connectionSettings=null;
+ if (typeof resetAI === "function") resetAI();
+ plugins=[]; pluginDrafts.clear(); pluginListRequest++; pluginGeneration++;
+ pluginSaving=false; pluginScopeBusy=false; pluginBatch=null; pluginControlAvailable=false;
+ pluginResponse=null; pluginResponseLoaded=false;
+ pluginReloadAfterRestore=true;
+ $("plugin-save-state").textContent=""; $("plugin-batch-status").textContent="";
+ resetPluginCatalog(); renderPluginBusy(); renderPlugins();
+ clearRestorePreview(); backupControls(false); await loadBackups();
+ if (authenticated && backupActive) $("backup-status").textContent = result.changed ? "恢复完成,恢复前状态已备份。" + (result.restartRequired ? " 请重启机器人完成全部生效。" : "") : "配置一致,无需恢复。";
+ } catch(error) { if (backupReady(generation)) {clearRestorePreview(); showError("backup-error",new Error(error.message + " 如果请求中断,请检查当前配置后再重新预览。"));} }
+ finally { if (backupReady(generation)) backupControls(false); }
+});
+
+function clearCleanupPreview() {
+ backupCleanupPreview=null; $("cleanup-preview").hidden=true;
+ $("cleanup-confirm").checked=false; $("cleanup-backups").disabled=true;
+ $("cleanup-list").replaceChildren();
+ renderBackupExpiry();
+}
+$("cleanup-confirm").addEventListener("change",()=>backupControls(backupBusy));
+$("cancel-cleanup").addEventListener("click",clearCleanupPreview);
+for (const id of ["cleanup-keep","cleanup-days"]) $(id).addEventListener("input",()=>{if (!backupBusy) {clearCleanupPreview();backupControls(false);}});
+$("cleanup-form").addEventListener("submit",async event=>{
+ event.preventDefault(); if (!authenticated || !backupActive || backupBusy) return;
+ const policy={keepPerScope:Number($("cleanup-keep").value),keepDays:Number($("cleanup-days").value)};
+ if (!Number.isInteger(policy.keepPerScope) || policy.keepPerScope<3 || policy.keepPerScope>200 || !Number.isInteger(policy.keepDays) || policy.keepDays<1 || policy.keepDays>3650) {
+ clearCleanupPreview(); showError("backup-error",new Error("保留份数须为 3~200,保留天数须为 1~3650。")); return;
+ }
+ const generation=++backupGeneration;clearRestorePreview();clearCleanupPreview();$("backup-error").hidden=true;backupControls(true);
+ const controller=new AbortController();backupRequest=controller;
+ try {
+ const value=await api("/api/backups/cleanup-preview",policy,controller.signal);
+ if (!backupReady(generation)) return;
+ backupCleanupPreview=value;$("cleanup-preview").hidden=false;
+ $("cleanup-detail").textContent="本次将删除 "+value.candidates.length+" 份旧备份,约 "+Math.ceil(value.bytes/1024)+" KiB;跳过 "+value.skipped+" 份无法安全确认的备份。"+(value.more ? " 每次最多 100 份,剩余部分需重新预览。" : "")+" 永久删除不能撤销,当前配置与对话记录不受影响。";
+ for (const entry of value.candidates) {const item=document.createElement("li");item.textContent=(backupScopeNames[entry.scope] || entry.scope)+" · "+new Date(entry.createdAt).toLocaleString("zh-CN",{hour12:false})+" · "+entry.id;$("cleanup-list").append(item);}
+ } catch(error) {if (backupReady(generation) && error.name!=="AbortError") showError("backup-error",error);}
+ finally {if (backupReady(generation)) {backupRequest=null;backupControls(false);}}
+});
+$("cleanup-backups").addEventListener("click",async()=>{
+ if (!authenticated || !backupActive || backupBusy || !backupCleanupPreview?.candidates?.length || !$("cleanup-confirm").checked) return;
+ const preview=backupCleanupPreview;
+ if (backupPreviewExpired(preview)) {clearCleanupPreview();showError("backup-error",new Error("清理预览已过期或有效期无法确认,请重新预览。"));return;}
+ const generation=++backupGeneration;$("backup-error").hidden=true;backupControls(true);
+ try {
+ const result=await api("/api/backups/cleanup",{policy:preview.policy,revision:preview.revision,asOf:preview.asOf,expiresAt:preview.expiresAt,token:preview.token,confirm:true});
+ if (!backupReady(generation)) return;
+ clearCleanupPreview();backupControls(false);const refreshGeneration=backupGeneration+1;await loadBackups();
+ if (backupReady(refreshGeneration)) {$("backup-status").textContent=result.message+" 已完整删除 "+result.deleted.length+" 份。";if(result.partial) showError("backup-error",new Error(result.message));}
+ } catch(error) {if (backupReady(generation)) {clearCleanupPreview();showError("backup-error",new Error(error.message+" 请求中断时请刷新列表核对实际结果,已删除内容不会自动恢复。"));}}
+ finally {if (backupReady(generation)) backupControls(false);}
+});
+document.addEventListener("visibilitychange",()=>{
+ stopBackupExpiryTimer();
+ if (!document.hidden && authenticated && backupActive) renderBackupExpiry();
+});
diff --git a/internal/localadmin/assets/connections-add.js b/internal/localadmin/assets/connections-add.js
new file mode 100644
index 0000000000..5f2e6b2592
--- /dev/null
+++ b/internal/localadmin/assets/connections-add.js
@@ -0,0 +1,121 @@
+"use strict";
+let connectionAdding = false, connectionAddGeneration = 0;
+let connectionAddConfirmation = null;
+function finishConnectionAddConfirmation(accepted = false) {
+ const pending = connectionAddConfirmation;
+ connectionAddConfirmation = null;
+ const dialog = $("connection-add-confirmation");
+ if (dialog.open) dialog.close();
+ $("connection-add-confirm-message").textContent = "";
+ if (!pending) return;
+ pending.resolve(accepted);
+ if (authenticated && pending.origin?.isConnected && !pending.origin.disabled) pending.origin.focus();
+}
+function confirmConnectionAddition(message, label) {
+ if (connectionAddConfirmation || connectionDraftConfirmation || !authenticated || connectionBusy) return Promise.resolve(false);
+ return new Promise(resolve => {
+ connectionAddConfirmation = {resolve, origin:document.activeElement};
+ $("connection-add-confirm-message").textContent = message;
+ $("connection-add-confirm-accept").textContent = label;
+ try { $("connection-add-confirmation").showModal(); }
+ catch (_) {
+ finishConnectionAddConfirmation(false);
+ showError("connection-add-error", new Error("无法打开确认窗口,请重新加载页面后再操作。"));
+ }
+ });
+}
+$("connection-add-confirm-cancel").addEventListener("click", () => finishConnectionAddConfirmation(false));
+$("connection-add-confirm-accept").addEventListener("click", () => finishConnectionAddConfirmation(true));
+$("connection-add-confirmation").addEventListener("cancel", event => {
+ event.preventDefault(); finishConnectionAddConfirmation(false);
+});
+$("connection-add-confirmation").addEventListener("close", () => {
+ if (!$("connection-add-confirmation").open) finishConnectionAddConfirmation(false);
+});
+function connectionAddSnapshot() {
+ return JSON.stringify([$("connection-add-kind").value,$("connection-add-url").value, $("connection-add-token").value, $("connection-add-without-token").checked]);
+}
+function connectionAddDirty() {
+ return !!connectionSettings?.available && !!($("connection-add-kind").value!=="ws" || $("connection-add-url").value || $("connection-add-token").value || $("connection-add-without-token").checked);
+}
+function renderConnectionAddControls() {
+ const blocked = !authenticated || connectionBusy || !connectionSettings?.available;
+ $("connection-add-fields").disabled = blocked;
+ const reverse=$("connection-add-kind").value==="wss";
+ $("connection-add-without-token").disabled=reverse;
+ $("connection-add-kind-note").textContent=reverse ? "仅支持 ws://127.0.0.1:明确端口和根路径,必须填写 Token;重启后在接入程序配置相同地址与 Token。" : "正向连接支持 ws/wss 接入地址,可明确选择不使用 Token。";
+ $("connection-add-token").disabled = $("connection-add-without-token").checked;
+ $("connection-add-token").required = !$("connection-add-without-token").checked;
+ $("connection-add-save").disabled = blocked || !connectionAddDirty();
+ $("connection-add-discard").disabled = blocked || !connectionAddDirty();
+}
+function resetConnectionAdd() {
+ finishConnectionAddConfirmation(false);
+ connectionAddGeneration++; connectionAdding = false;
+ $("connection-add-url").value = ""; $("connection-add-token").value = "";
+ $("connection-add-kind").value="ws"; $("connection-add-without-token").checked = false; $("connection-add-error").hidden = true;
+ $("connection-add-status").textContent = "新增与现有连接/主人分别保存;重启后生效。";
+ renderConnectionAddControls();
+}
+function connectionAddCanLeave() {
+ if (connectionAddConfirmation || connectionDraftConfirmation) return false;
+ if (!connectionAdding) return true;
+ $("connection-add-status").textContent = "新增请求尚未完成,请稍候再切换页面。";
+ return false;
+}
+$("connection-add-kind").addEventListener("change",()=>{
+ if($("connection-add-kind").value==="wss") $("connection-add-without-token").checked=false;
+ renderConnectionDrafts();
+});
+$("connection-add-without-token").addEventListener("change", () => {
+ if ($("connection-add-without-token").checked) $("connection-add-token").value = "";
+ renderConnectionDrafts();
+});
+for (const event of ["input", "change"]) $("connection-add-form").addEventListener(event, () => {
+ $("connection-add-status").textContent = "新增草稿未保存;保存不会自动连接或重启。";
+ renderConnectionDrafts();
+});
+$("connection-add-discard").addEventListener("click", async () => {
+ if (!authenticated || connectionBusy || !connectionAddDirty()) return;
+ const generation=connectionGeneration, addGeneration=connectionAddGeneration, draft=connectionAddSnapshot(), token=csrf, settings=connectionSettings;
+ if (!await confirmConnectionAddition("放弃新增连接的未保存更改?已有连接、主人草稿与已保存配置保持不变。", "确认放弃")) return;
+ if (!authenticated || connectionBusy || token!==csrf || settings!==connectionSettings || generation!==connectionGeneration || addGeneration!==connectionAddGeneration || draft!==connectionAddSnapshot()) return;
+ resetConnectionAdd(); renderConnectionDrafts(); $("connection-add-status").textContent="已放弃新增草稿,已有配置保持不变。";
+});
+$("connection-add-form").addEventListener("submit", async event => {
+ event.preventDefault();
+ if (!authenticated || connectionBusy || !connectionSettings?.available) return;
+ $("connection-add-error").hidden = true;
+ if (connectionDirty(true) || connectionOwnersDirty()) { showError("connection-add-error",new Error("请先保存或放弃现有连接与主人账号的未保存更改,再新增连接。")); return; }
+ const settings=connectionSettings, generation=connectionGeneration, addGeneration=connectionAddGeneration, token=csrf, draft=connectionAddSnapshot();
+ const kind=$("connection-add-kind").value;
+ const addConnection={url:$("connection-add-url").value,token:$("connection-add-token").value,withoutToken:$("connection-add-without-token").checked};
+ if(kind!=="ws" && kind!=="wss") {showError("connection-add-error",new Error("请选择有效的新增连接方向。"));return;}
+ if(kind==="wss") addConnection.kind="wss";
+ if(kind==="wss" && (addConnection.withoutToken || !addConnection.token)) {showError("connection-add-error",new Error("本机反向监听必须填写 Token。"));return;}
+ if (!addConnection.url || (!addConnection.withoutToken && !addConnection.token)) { showError("connection-add-error",new Error("填写新连接地址及 Token,或明确选择不使用 Token。")); return; }
+ if (!await confirmConnectionAddition("新增"+(kind==="wss" ? "本机反向监听" : "正向连接")+"并备份当前配置?保存不自动连接或监听,接入程序请使用相同地址与 Token。", "确认新增")) return;
+ if (!authenticated || connectionBusy || token!==csrf || settings!==connectionSettings || generation!==connectionGeneration || addGeneration!==connectionAddGeneration || draft!==connectionAddSnapshot()) return;
+ connectionAdding = true; connectionControls(true);
+ $("connection-add-status").textContent = "正在备份并保存新连接…";
+ try {
+ const value = await api("/api/connections",{revision:settings.revision,addConnection});
+ if (!authenticated || token!==csrf || generation!==connectionGeneration || addGeneration!==connectionAddGeneration) return;
+ const index=(value.connections || []).findIndex(item=>item.kind===kind && item.index===(settings.connections || []).filter(item=>item.kind===kind).length);
+ if (!value.available || !value.backupId || !value.revision || index<0 || value.connections.length!==(settings.connections || []).length+1) throw new Error("新增结果无法确认,请重新读取配置核对后再操作。");
+ connectionIndex=index; resetConnectionAdd(); renderConnectionSettings(value);
+ $("connection-save-state").textContent += " 旧配置备份已创建。";
+ $("connection-add-status").textContent = value.restartRequired ? "新连接已保存并选中,待重启机器人生效;新增 Token 输入已清空。" : "新连接已保存,配置与本次启动一致,无需为本次新增重启;Token 输入已清空。";
+ } catch (error) {
+ if (authenticated && token===csrf && generation===connectionGeneration && addGeneration===connectionAddGeneration) {
+ showError("connection-add-error",error);
+ $("connection-add-status").textContent="新增未确认成功,草稿保留;若网络中断,请先重新读取配置核对。";
+ }
+ } finally {
+ // Successful save resets the addition generation; shared connection generation
+ // still identifies the same authenticated session and its busy ownership.
+ if (authenticated && token===csrf && generation===connectionGeneration) {
+ connectionAdding=false; connectionControls(false); renderConnectionDrafts();
+ }
+ }
+});
diff --git a/internal/localadmin/assets/connections-delete.js b/internal/localadmin/assets/connections-delete.js
new file mode 100644
index 0000000000..0a26ebe778
--- /dev/null
+++ b/internal/localadmin/assets/connections-delete.js
@@ -0,0 +1,69 @@
+"use strict";
+let connectionDeletion = null, connectionDeleting = false;
+function clearConnectionDeletion() {
+ connectionDeletion=null;
+ $("connection-delete-preview").hidden=true;
+ $("connection-delete-confirm").checked=false;
+ $("connection-delete-save").disabled=true;
+ for(const id of ["connection-delete-target","connection-delete-address","connection-delete-impact"]) $(id).textContent="";
+}
+function resetConnectionDelete() {
+ connectionDeleting=false;clearConnectionDeletion();
+ $("connection-delete-status").textContent="";$("connection-delete-error").hidden=true;
+}
+function deletionCurrent(preview) {
+ return preview && authenticated && preview.token===csrf && preview.generation===connectionGeneration && preview.settings===connectionSettings && preview.selected===connectionIndex && !connectionDirty();
+}
+function renderConnectionDeleteControls() {
+ if(connectionDeletion && !deletionCurrent(connectionDeletion)) clearConnectionDeletion();
+ const total=(connectionSettings?.connections || []).length;
+ const blocked=!authenticated || connectionBusy || !connectionSettings?.available;
+ $("preview-connection-delete").disabled=blocked || !selectedConnection() || total<=1 || connectionDirty();
+ $("connection-delete-hint").textContent=!connectionSettings?.available ? "读取配置后可管理删除。" : total<=1 ? "至少保留一条连接,不能删除最后一条。" : connectionDirty() ? "请先保存或放弃现有及新增草稿,再预览删除。" : "预览只核对目标;确认删除后需重启机器人生效。";
+ $("connection-delete-confirm").disabled=blocked || !connectionDeletion;
+ $("connection-delete-save").disabled=blocked || !connectionDeletion || !$("connection-delete-confirm").checked;
+ $("connection-delete-cancel").disabled=connectionBusy;
+}
+function showConnectionDelete(active) {
+ if(!active) {clearConnectionDeletion();renderConnectionDeleteControls();}
+}
+function connectionDeleteCanLeave() {
+ if(!connectionDeleting) return true;
+ $("connection-delete-status").textContent="删除请求尚未完成,请稍候再切换页面。";
+ return false;
+}
+$("preview-connection-delete").addEventListener("click",()=>{
+ if(!authenticated || connectionBusy || !connectionSettings?.available || connectionDirty() || !selectedConnection() || connectionSettings.connections.length<=1) return;
+ clearConnectionDeletion();$("connection-delete-error").hidden=true;
+ const connection=selectedConnection();
+ connectionDeletion={settings:connectionSettings,generation:connectionGeneration,token:csrf,selected:connectionIndex,kind:connection.kind,index:connection.index};
+ $("connection-delete-target").textContent="删除目标:"+(connection.kind==="ws" ? "正向连接 " : "反向监听 ")+(connection.index+1);
+ $("connection-delete-address").textContent="已保存地址:"+connection.url;
+ $("connection-delete-impact").textContent="已保存连接共 "+connectionSettings.connections.length+" 条,删除后保留 "+(connectionSettings.connections.length-1)+" 条。同类型序号会调整,本次启动信息仍按原序号展示。";
+ $("connection-delete-preview").hidden=false;$("connection-delete-status").textContent="请核对固定目标,预览尚未修改配置。";
+ renderConnectionDeleteControls();
+});
+$("connection-delete-confirm").addEventListener("change",renderConnectionDeleteControls);
+$("connection-delete-cancel").addEventListener("click",()=>{
+ if(connectionBusy) return;
+ clearConnectionDeletion();renderConnectionDeleteControls();$("connection-delete-status").textContent="已取消删除,配置保持不变。";
+});
+$("connection-delete-save").addEventListener("click",async()=>{
+ const preview=connectionDeletion;
+ if(connectionBusy || !deletionCurrent(preview) || !$("connection-delete-confirm").checked) {renderConnectionDeleteControls();return;}
+ connectionDeleting=true;connectionControls(true);$("connection-delete-error").hidden=true;$("connection-delete-status").textContent="正在备份并删除所选连接…";
+ try {
+ const value=await api("/api/connections",{revision:preview.settings.revision,removeConnection:{kind:preview.kind,index:preview.index,confirm:true}});
+ if(!deletionCurrent(preview) || preview!==connectionDeletion) return;
+ if(!value.available || !value.backupId || !value.revision || !Array.isArray(value.connections) || value.connections.length!==preview.settings.connections.length-1) throw new Error("删除结果无法确认,请先重新读取配置核对。");
+ connectionIndex=Math.min(preview.selected,value.connections.length-1);clearConnectionDeletion();renderConnectionSettings(value);
+ $("connection-save-state").textContent+=" 删除前配置备份已创建。";$("connection-delete-status").textContent=value.restartRequired ? "所选连接已从保存配置删除,重启后生效;列表序号已调整,当前运行连接保持不变。" : "所选连接已从保存配置删除;已保存配置与本次启动一致,无需为本次删除重启。列表序号已调整。";
+ } catch(error) {
+ if(authenticated && preview.token===csrf && preview.generation===connectionGeneration) {
+ clearConnectionDeletion();showError("connection-delete-error",error);
+ $("connection-delete-status").textContent="删除未确认成功,确认已清空;若网络中断,请先重新读取配置核对。";
+ }
+ } finally {
+ if(authenticated && preview.token===csrf && preview.generation===connectionGeneration) {connectionDeleting=false;connectionControls(false);renderConnectionDrafts();}
+ }
+});
diff --git a/internal/localadmin/assets/index.html b/internal/localadmin/assets/index.html
new file mode 100644
index 0000000000..80bce2257d
--- /dev/null
+++ b/internal/localadmin/assets/index.html
@@ -0,0 +1,181 @@
+
+
+
+
+
+ ZeroBot · 本地管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ZZeroBot 本地管理
+ YOUR BOT, YOUR SPACE
让机器人管理
更清晰。
在自己的电脑上查看机器人状态,
了解已加载的功能。
仅本机访问
+
+
+
+
+
+
+
+
工作空间 / 运行概览仅本机访问
+
+
+
+ OVERVIEW
运行概览
随时了解你的机器人与当前工作空间。
+
+ 已连接账号—来自当前 OneBot 连接已加载插件—本次程序实际注册的功能运行时间—当前机器人进程
+ 机器人账号
OneBot这里显示 OneBot 连接状态。QQ 登录由接入程序管理。
运行信息
- 程序版本
- —
- Go 版本
- —
- 已配置连接
- —
- 最近检查
- —
+ 模型接口请求
本次启动累计正在读取请求统计…
仅统计 GPT/群总结、聊天、Agent 与识图的模型接口传输,重启后清零。接口完成表示响应正常传输,不保证内容有效或 QQ 已回复;重定向逐次计数。耗时包括响应读取,不包括 QQ 发送;不记录聊天内容或密钥。
+ 模型请求观察报告
当前页面尚未记录观察起点。
使用最近成功读取的快照,自动刷新后更新结束值;切换后台页面保留起点,刷新页面或退出后清除。报告包含分类、次数、时间、累计耗时及可读取的进程关联信息,不含聊天、QQ账号、程序路径或配置。跨起点的进行中请求可能在窗口内结束;不能从累计最长耗时推算窗口最长耗时,同一进程也不代表真实业务已验收。
+ AI回复处理耗时
本次启动累计正在读取回复处理统计…
从消息进入机器人匹配流程到当前群或私聊的回复发送回执,包含模型处理与发送等待;记录首条和最后一条确认回复。平均值只统计已结束且有确认回复的任务,可能包括部分回复后失败的任务。重启清零,不保存正文或账号;不包括QQ客户端到机器人之前的延迟,也不证明收到或已读。Agent/识图及其回退按一次任务计,思考中和错误提示不计回复。
+ 近期异常
本次启动正在读取近期异常…
完整筛选与脱敏导出请进入“运行日志”。
+
+
+ CONNECTIONS
连接设置
管理 OneBot 连接与机器人主人账号。
+ 正在读取配置…
读取配置后可编辑;草稿仅保留在当前页面。
+ 未保存字段摘要
只显示字段名称,不显示地址、Token 或主人账号值;已有连接以实际加载目标为准。新增与现有配置分别保存,摘要不表示已保存或正在运行。
+ 启动配置差异
比较最近一次成功读取或保存的配置与本次启动快照,不含未保存草稿,不表示连接实时在线状态。数量相同也可能内容不同(包括 Token);配置值与密钥隐藏。外部修改后请重新加载核对。
+
+
+
+ 确认删除连接
+ 保存前备份当前启动配置。删除在重启后生效,当前连接继续运行;接入程序登录与其他配置保持不变。
+
+
+
+
+
+
+ PLUGINS
插件管理
查看实际状态,管理全局和指定群的插件开关。
+
+ 当前范围:全局。显式全局设置会覆盖所有群与私聊的插件开关。
+ 响应状态说明
最近成功读取的响应规则,刷新插件状态后更新;卡片表示插件开关,收到回复还取决于封禁、权限、@ 和具体指令。
仅查看,不自动调整:主人私聊可用 /全局响应、/全局沉默;群内由主人或群管理员 @机器人后使用 /响应、/沉默。全局沉默不清除各群单独响应。
+ 没有未保存的开关更改。
+ 包含其他页和筛选隐藏项;逐项保存遇错停止,已成功规则不回滚。放弃仅撤销未保存选择。
+
+ 搜索包含插件注册的使用说明;状态按当前范围的实际结果筛选。排序覆盖全部匹配项后再分页,按状态排序时未知排在最后;清除筛选保留排序。筛选不会保存开关,未保存的选择会保留;刷新或切换范围先确认,成功读取后才丢弃未保存选择。
+ 包含当前已加载范围的全部匹配项;使用最近成功读取或保存的开关状态,未保存更改单独标记。清单仅供核对,不能导入或恢复。
+ 成功读取插件范围后可下载清单。
+
+ 生效顺序:显式全局设置 → 群设置 → 默认值。保存后立即生效并保留到重启后;实际修改前备份控制数据库。备份可能包含模型密钥等扩展配置,仅保存在本机。
没有符合条件的插件。
+
+
+ AI SETTINGS
AI 设置
管理聊天、GPT 与群总结使用的模型,以及指定群的参数。
+ 进入页面后读取配置。
+ AI 配置检查
最近成功读取或保存的全局配置(含程序默认值),不包含未保存草稿。只检查本机基础字段,尚未验证实际调用。
聊天/GPT共享聊天接口;Agent还需启用会话模式,并满足插件开关、响应、@和权限规则。识图是可选配置;检查通过不保证密钥有效、模型存在、网络或图片/工具能力可用。
+ 读取设置后可编辑;草稿仅保留在当前页面。
+ 未保存字段摘要
只列改动字段名称,不显示密钥、地址或提示词新旧值。会话范围按各表单已读取目标,仍须分别保存;数字等沿用输入文字比较。
+ 保存后供后续请求使用,无需重启;正在执行的请求继续使用原配置。修改前备份控制数据库,备份可能含密钥,仅保存在本机。切换后台页面保留草稿;重新加载、切换群或退出时提示未保存更改,不会自动保存。
+ 聊天会向配置的模型接口发送本次消息、聊天上下文及系统提示词。群聊总结会发送所选近期消息的昵称和文字(最多 1000 条);GPT 回复消息会附带被回复的文字。
+
+
+ 参数仅作用于已读取的群或私聊,保存目标见各表单标题。私聊填写用户 QQ,群与同号私聊互不覆盖;不改变插件启停、响应门控或 Agent 权限。聊天参数与 GPT 温度分别保存,私聊不支持群总结;温度 0 表示默认 70(实际 0.7)。
+
+
+ RUNTIME LOGS
运行日志
查看本次启动后的近期运行记录。
+
+ 时间使用浏览器本地时区,精确到秒、包含边界;可留空一端。填写后点击应用,导出沿用已应用条件。
+
+ 快捷范围截止点击时刻,沿用已应用的级别和关键词;不会随更新自动移动,再次点击可更新范围。仅查看本次启动仍保留的日志。
+ 已应用:全部级别;未限制关键词。
+ 日志匹配统计
读取当前筛选后显示统计。
最近成功读取时,服务仍保留日志中的全部匹配数,不受200条返回或增量游标限制;暂停、隐藏页面或读取中断时保留旧统计。只涵盖本次启动的保留范围,不代表完整历史或问题已解决。
+ 进入页面后读取日志。
+ 清空只移除本页已显示记录,保留筛选、暂停状态及读取位置;统计和导出仍使用服务保留记录,不删除日志。
+ 导出已应用筛选下仍保留的本次启动日志,最多 1000 条;不包含重启前或已超出保留范围的记录。
暂停时可读取一次当前已应用条件的新日志,完成后仍保持暂停;读取沿用增量游标和每次200条限制,不自动补齐全部历史。
+ 每条记录可复制页面已显示的脱敏时间、级别与正文;剪贴板受限时可按提示手动复制正文。不会读取原始日志或改变更新状态。
+
+ 保留最近最多 1000 条,较长记录会截断;重启会清空本页日志。认证信息与已配置密钥隐藏,收发消息正文不展示。调试记录需要程序启用相应日志级别。
+ 暂停或断线期间部分旧日志已超出保留范围,仅显示仍可读取的记录。
+
+
+
+ LOCAL ACCOUNT
后台安全
修改这台电脑上的管理后台密码。
+
+
+
+ BACKUPS
备份恢复
保存当前配置,预览并恢复此前状态。
+ 备份可能包含 OneBot Token、模型密钥,仅保存在本机。一次恢复一个范围,不覆盖后台密码、QQ 登录、聊天媒体或其他业务数据库。恢复前自动备份当前状态。
+
+ 进入页面后读取备份。
+
+ 排序覆盖全部已加载匹配项后再分页;自定义排序将不可恢复或排序字段无效的记录放在最后。清除筛选保留排序,下载清单沿用所选顺序;仍只加载最新200份。
+
+ 已应用:不限日期。
+ 日期按浏览器本地时区,结束日期包含当天;只筛选已加载记录。日期筛选不影响恢复目标或清理策略。
+ 尚未读取备份列表。
+ 备份内容大小
只汇总最近目录中可核对的配置正文或数据库大小,不含封装、清单及磁盘簇开销;不是目录实际占用或可释放空间,大小无法核对的记录不作为0相加。
+ 包括全部已加载页的匹配记录;只有编号等目录信息,不能用于恢复。
+ 恢复预览
类别影响对比
数量相同也可能内容不同;摘要隐藏配置值与数据库内容,实际恢复仍覆盖整个所选范围。
+
+
+
+ 旧备份清理预览
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/localadmin/assets/logs.js b/internal/localadmin/assets/logs.js
new file mode 100644
index 0000000000..2a7027ab25
--- /dev/null
+++ b/internal/localadmin/assets/logs.js
@@ -0,0 +1,277 @@
+"use strict";
+let logActive=false, logPaused=false, logCursor="0", logInstance="", logGeneration=0;
+let logTimer=null, logRequest=null, logLevel="", logKeyword="", logSeen=new Set();
+let logExportRequest=null;
+let logFrom="", logUntil="";
+const logCopyEntries=new WeakMap();
+const logLevelLabels={"":"全部级别",issues:"警告与错误",panic:"Panic",fatal:"Fatal",error:"错误",warning:"警告",info:"信息",debug:"调试",trace:"跟踪"};
+
+function renderLogFilters() {
+ const draft=$("log-level").value!==logLevel || $("log-keyword").value.trim()!==logKeyword || $("log-from").value!==logFrom || $("log-until").value!==logUntil;
+ const range=logFrom || logUntil ? "时间(本地,含边界):"+(logFrom.replace("T"," ") || "不限")+" 至 "+(logUntil.replace("T"," ") || "不限")+"。" : "未限制时间。";
+ $("log-filter-status").textContent="已应用:"+logLevelLabels[logLevel]+";"+(logKeyword ? "关键词“"+logKeyword+"”。" : "未限制关键词。")+range+(draft ? " 输入修改尚未应用;查看与导出仍使用上述条件。" : "");
+}
+function logTimeValue(value) {
+ if (!value) return "";
+ const date=new Date(value);
+ // Reject normalization of impossible calendar or daylight-saving local times.
+ const match=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
+ if (!match || !Number.isFinite(date.getTime()) || Number(match[1])<1 || [date.getFullYear(),date.getMonth()+1,date.getDate(),date.getHours(),date.getMinutes(),date.getSeconds()].some((n,i)=>n!==Number(match[i+1] || 0))) throw new Error("日志时间无效,请填写本地有效日期与时间。");
+ return date.toISOString();
+}
+function appliedLogQuery() {
+ const query=new URLSearchParams({level:logLevel,q:logKeyword});
+ if (logFrom) query.set("from",logTimeValue(logFrom));
+ if (logUntil) query.set("until",logTimeValue(logUntil));
+ return query;
+}
+function logLocalTime(timestamp) {
+ const date=new Date(timestamp), pad=n=>String(n).padStart(2,"0");
+ const value=String(date.getFullYear()).padStart(4,"0")+"-"+pad(date.getMonth()+1)+"-"+pad(date.getDate())+"T"+pad(date.getHours())+":"+pad(date.getMinutes())+":"+pad(date.getSeconds());
+ // datetime-local cannot distinguish the second occurrence of a repeated DST hour.
+ if (Date.parse(logTimeValue(value))!==timestamp) throw new Error("当前快捷范围包含无法明确表示的本地时间,请手动选择起止时间。");
+ return value;
+}
+function applyRecentLogRange(minutes) {
+ if (!authenticated || !logActive || document.hidden || ![15,60,1440].includes(minutes)) return;
+ try {
+ const end=Math.floor(Date.now()/1000)*1000;
+ const from=logLocalTime(end-minutes*60000),until=logLocalTime(end);
+ applyLogFilters(logLevel,logKeyword,from,until);
+ } catch(error) {showError("log-filter-error",error);renderLogFilters();}
+}
+function applyLogFilters(level,keyword,from="",until="") {
+ try {
+ const start=logTimeValue(from),end=logTimeValue(until);
+ if (start && end && start>end) throw new Error("开始时间不能晚于结束时间。");
+ } catch(error) {showError("log-filter-error",error);renderLogFilters();return;}
+ $("log-filter-error").hidden=true;
+ logLevel=level;logKeyword=keyword.trim();logFrom=from;logUntil=until;
+ $("log-level").value=logLevel;$("log-keyword").value=logKeyword;$("log-from").value=logFrom;$("log-until").value=logUntil;
+ renderLogFilters();reloadLogs();
+}
+
+function stopLogRequests() {
+ logGeneration++;
+ clearTimeout(logTimer); logTimer=null;
+ if (logRequest) logRequest.abort();
+ logRequest=null;
+ if (logExportRequest) logExportRequest.abort();
+ logExportRequest=null;
+ $("export-logs").disabled=false; $("log-export-status").textContent="";
+ renderLogReadOnce();
+ renderLogCopyControls(true);
+}
+function resetLogs() {
+ stopLogRequests(); logActive=false; logPaused=false; logCursor="0"; logInstance=""; logLevel=""; logKeyword=""; logSeen.clear();
+ logFrom="";logUntil="";$("log-from").value="";$("log-until").value="";$("log-filter-error").hidden=true;
+ $("log-records").replaceChildren(); $("log-keyword").value=""; $("log-level").value="";
+ $("pause-logs").textContent="暂停更新"; $("log-gap").hidden=true; $("log-error").hidden=true;
+ $("log-status").textContent="进入页面后读取日志。";
+ $("log-clear-status").textContent="";
+ clearLogSummary();
+ renderLogFilters();
+ renderLogReadOnce();
+}
+function showLogs(active) {
+ stopLogRequests(); logActive=active;
+ renderLogReadOnce();
+ renderLogCopyControls();
+ if (active && authenticated && !document.hidden) {
+ if (!logPaused) pollLogs();
+ else $("log-status").textContent="已暂停更新;恢复后读取保留范围内的新记录。";
+ }
+}
+function renderLogs(value) {
+ renderLogSummary(value);
+ const container=$("log-records");
+ if (value.reset) {container.replaceChildren(); logSeen.clear(); $("log-gap").hidden=false;$("log-clear-status").textContent="";}
+ if (value.gap) $("log-gap").hidden=false;
+ const empty=container.querySelector(".empty"); if (empty) empty.remove();
+ for (const entry of value.entries) {
+ if (logSeen.has(entry.id)) continue;
+ logSeen.add(entry.id);
+ const row=document.createElement("article"), meta=document.createElement("div"), time=document.createElement("time"), level=document.createElement("span"), text=document.createElement("pre");
+ row.className="log-entry"; row.dataset.id=entry.id; meta.className="log-meta";
+ time.dateTime=entry.time; time.textContent=new Date(entry.time).toLocaleString("zh-CN",{hour12:false});
+ level.className="log-level"; level.textContent=entry.level.toUpperCase();
+ if (["error","fatal","panic"].includes(entry.level)) level.classList.add("log-error-level");
+ if (entry.level==="warning") level.classList.add("log-warning-level");
+ text.textContent=entry.message;text.tabIndex=-1;
+ const actions=document.createElement("div"),copy=document.createElement("button"),status=document.createElement("span");
+ actions.className="log-copy-actions";copy.type="button";copy.className="secondary copy-log-entry";copy.textContent="复制本条";
+ copy.setAttribute("aria-label","复制 "+time.textContent+" 的 "+level.textContent+" 日志");
+ status.className="field-help";status.setAttribute("role","status");status.setAttribute("aria-live","polite");
+ logCopyEntries.set(copy,{row,time,level,text,status,request:null});copy.addEventListener("click",()=>copyLogEntry(copy));
+ actions.append(copy,status);meta.append(time,level);row.append(meta,text,actions);container.append(row);
+ while (container.children.length>1000) {const old=container.firstElementChild; logSeen.delete(old.dataset.id); old.remove();}
+ }
+ if (!container.children.length) {const empty=document.createElement("p"); empty.className="empty"; empty.textContent="暂时没有符合筛选条件的日志。"; container.append(empty);}
+ logCursor=value.cursor; logInstance=value.instance;
+ $("log-status").textContent=(logPaused ? "已暂停" : "持续更新")+" · 当前显示 "+logSeen.size+" 条 / 服务保留 "+value.retained+" 条";
+ renderLogCopyControls();
+ renderLogClearControls();
+ if ($("log-autoscroll").checked) container.scrollTop=container.scrollHeight;
+}
+function renderLogClearControls() {
+ $("clear-log-display").disabled=!authenticated || !logActive || document.hidden || !!logRequest || !logSeen.size;
+}
+function clearLogDisplay() {
+ if (!authenticated || !logActive || document.hidden || logRequest || !logSeen.size) return;
+ // Keep the instance and cursor: this clears only records already displayed.
+ renderLogCopyControls(true);
+ logSeen.clear();
+ const empty=document.createElement("p");empty.className="empty";empty.textContent="已清空当前显示;后续沿原游标读取,服务保留记录未删除。";
+ $("log-records").replaceChildren(empty);
+ $("log-clear-status").textContent="仅清空本页显示,匹配统计和导出仍涵盖服务保留记录;重新加载后可读取近期日志。";
+ $("log-status").textContent=(logPaused ? "已暂停" : "持续更新")+" · 当前显示 0 条(仅清空页面)。";
+ renderLogClearControls();
+}
+function logCopyReady(record) {
+ return authenticated && logActive && !document.hidden && record.row.isConnected && $("log-records").contains(record.row);
+}
+function renderLogCopyControls(reset=false) {
+ if (reset) {
+ const selection=window.getSelection?.();
+ if (selection?.anchorNode && $("log-records").contains(selection.anchorNode)) selection.removeAllRanges();
+ }
+ for (const button of $("log-records").querySelectorAll(".copy-log-entry")) {
+ const record=logCopyEntries.get(button);if (!record) continue;
+ if (reset) {record.request=null;record.status.textContent="";}
+ button.disabled=!logCopyReady(record) || !!record.request;
+ }
+}
+function logCopyCurrent(record,snapshot) {
+ return record.request===snapshot && snapshot.generation===logGeneration && snapshot.token===csrf && logCopyReady(record);
+}
+async function copyLogEntry(button) {
+ const record=logCopyEntries.get(button);
+ if (!record || record.request || !logCopyReady(record)) return;
+ const snapshot={generation:logGeneration,token:csrf};record.request=snapshot;button.disabled=true;
+ // Copy exactly the displayed text, never the raw API entry or extra fields.
+ const content=record.time.textContent+" ["+record.level.textContent+"]\n"+record.text.textContent;
+ record.status.textContent="正在复制…";
+ try {
+ if (!navigator.clipboard?.writeText) throw new Error("clipboard unavailable");
+ await navigator.clipboard.writeText(content);
+ if (logCopyCurrent(record,snapshot)) record.status.textContent="已复制本条日志。";
+ } catch {
+ if (logCopyCurrent(record,snapshot)) {
+ const range=document.createRange();range.selectNodeContents(record.text);
+ const selection=window.getSelection();selection?.removeAllRanges();selection?.addRange(range);
+ record.text.focus({preventScroll:true});
+ record.status.textContent="复制未完成,已选中日志正文;请按 Ctrl+C 手动复制。";
+ }
+ } finally {
+ if (logCopyCurrent(record,snapshot)) {record.request=null;button.disabled=false;}
+ }
+}
+function clearLogSummary() {
+ $("log-summary-state").textContent = "读取当前筛选后显示统计。";
+ $("log-summary-levels").replaceChildren();
+}
+function renderLogSummary(value) {
+ const summary = value.summary, levels = summary?.levels;
+ const names = [["panic","Panic"],["fatal","Fatal"],["error","错误"],["warning","警告"],["info","信息"],["debug","调试"],["trace","跟踪"],["other","其他"]];
+ const count = n => Number.isInteger(n) && n >= 0 && n <= 1000;
+ const valid = summary && count(value.retained) && count(summary.matched) && summary.matched <= value.retained && levels && names.every(([name]) => count(levels[name])) && names.reduce((sum,[name]) => sum + levels[name],0) === summary.matched;
+ const list = $("log-summary-levels"); list.replaceChildren();
+ if (!valid) { $("log-summary-state").textContent = "当前接口未提供可核对的匹配统计,不按页面条数推断。"; return; }
+ $("log-summary-state").textContent = "符合已应用筛选:" + summary.matched + " / 服务仍保留:" + value.retained + " 条(最近成功读取于 " + new Date().toLocaleTimeString("zh-CN",{hour12:false}) + ")。";
+ for (const [name,label] of names) {
+ const item = document.createElement("span"); item.textContent = label + ":" + levels[name]; list.append(item);
+ }
+}
+function renderLogReadOnce() {
+ $("read-logs-once").disabled=!logActive || !authenticated || !logPaused || document.hidden || !!logRequest;
+ renderLogClearControls();
+}
+async function readLogsOnce() {
+ if (!logPaused) return;
+ await pollLogs(true);
+}
+async function pollLogs(once=false) {
+ if (!logActive || (logPaused && once!==true) || !authenticated || document.hidden || logRequest) return;
+ const generation=logGeneration, controller=new AbortController(); logRequest=controller;
+ renderLogReadOnce();
+ if (once===true) $("log-status").textContent="正在读取一次;自动更新仍保持暂停。";
+ const query=appliedLogQuery();query.set("after",logCursor);query.set("instance",logInstance);query.set("limit","200");
+ try {
+ const value=await api("/api/logs?"+query,undefined,controller.signal);
+ if (generation!==logGeneration || !authenticated || !logActive) return;
+ $("log-error").hidden=true; renderLogs(value);
+ } catch (error) {
+ if (error.name!=="AbortError" && generation===logGeneration && authenticated && logActive) {
+ showError("log-error",error); $("log-status").textContent=logPaused ? "读取未完成;仍保持暂停,可再次点击读取一次。" : "读取中断,稍后自动重试。";
+ }
+ } finally {
+ if (logRequest===controller) logRequest=null;
+ if (generation===logGeneration) renderLogReadOnce();
+ if (generation===logGeneration && logActive && !logPaused && authenticated && !document.hidden) logTimer=setTimeout(pollLogs,2000);
+ }
+}
+function reloadLogs() {
+ stopLogRequests(); logCursor="0"; logInstance=""; logSeen.clear(); $("log-records").replaceChildren(); $("log-gap").hidden=true;
+ clearLogSummary();
+ $("log-clear-status").textContent="";
+ renderLogClearControls();
+ if (!logPaused) pollLogs(); else $("log-status").textContent="已暂停更新;恢复后应用当前筛选。";
+}
+async function exportLogs() {
+ if (!logActive || !authenticated || document.hidden || logExportRequest) return;
+ const generation=logGeneration, controller=new AbortController(); logExportRequest=controller;
+ $("export-logs").disabled=true; $("log-export-status").textContent="正在准备脱敏日志…";
+ try {
+ const query=appliedLogQuery();
+ const response=await fetch("/api/logs/export?"+query,{credentials:"same-origin",signal:controller.signal});
+ if (generation!==logGeneration || !authenticated || !logActive || document.hidden) return;
+ if (!response.ok) {
+ const value=await response.json();
+ if (generation!==logGeneration || !authenticated || !logActive) return;
+ if (response.status===401) {authenticated=false; csrf=""; showAuth(); return;}
+ throw new Error(value.error || "日志导出失败");
+ }
+ const disposition=response.headers.get("Content-Disposition") || "";
+ const filename=/^attachment; filename="(zerobot-logs-\d{8}T\d{6}Z\.txt)"$/.exec(disposition)?.[1];
+ const maxBytes=3*1024*1024;
+ const length=response.headers.get("Content-Length") || "";
+ if (!filename || !response.headers.get("Content-Type")?.startsWith("text/plain") || !/^\d+$/.test(length) || Number(length)>maxBytes) throw new Error("日志导出格式异常");
+ const content=await response.blob();
+ if (generation!==logGeneration || !authenticated || !logActive || document.hidden) return;
+ if (content.size>maxBytes) throw new Error("日志导出超过容量限制");
+ const url=URL.createObjectURL(content), link=document.createElement("a");
+ try {
+ link.href=url; link.download=filename; document.body.append(link); link.click();
+ $("log-export-status").textContent="已发起下载;文件仅包含仍保留的脱敏日志。";
+ } finally {link.remove(); setTimeout(()=>URL.revokeObjectURL(url),0);}
+ } catch (error) {
+ if (error.name!=="AbortError" && generation===logGeneration && authenticated && logActive) $("log-export-status").textContent=error.message;
+ } finally {
+ if (logExportRequest===controller) {logExportRequest=null; $("export-logs").disabled=false;}
+ }
+}
+$("export-logs").addEventListener("click",exportLogs);
+$("read-logs-once").addEventListener("click",readLogsOnce);
+$("clear-log-display").addEventListener("click",clearLogDisplay);
+for (const [id,minutes] of [["log-recent-15m",15],["log-recent-1h",60],["log-recent-24h",1440]]) $(id).addEventListener("click",()=>applyRecentLogRange(minutes));
+$("log-any-time").addEventListener("click",()=>{if (authenticated && logActive && !document.hidden) applyLogFilters(logLevel,logKeyword);});
+$("log-filter").addEventListener("submit",event=>{event.preventDefault(); if (authenticated && logActive) applyLogFilters($("log-level").value,$("log-keyword").value,$("log-from").value,$("log-until").value);});
+$("only-issue-logs").addEventListener("click",()=>{if (authenticated && logActive) applyLogFilters("issues",logKeyword,logFrom,logUntil);});
+$("clear-log-filter").addEventListener("click",()=>{if (authenticated && logActive) applyLogFilters("","");});
+$("view-issue-logs").addEventListener("click",()=>{
+ if (!authenticated) return;
+ // Set conditions before navigation so the first visible request uses them.
+ stopLogRequests();logActive=false;applyLogFilters("issues","");
+ document.querySelector('.nav-button[data-view="logs"]').click();
+});
+$("log-level").addEventListener("change",renderLogFilters);
+$("log-keyword").addEventListener("input",renderLogFilters);
+for (const id of ["log-from","log-until"]) $(id).addEventListener("input",()=>{$("log-filter-error").hidden=true;renderLogFilters();});
+$("reload-logs").addEventListener("click",reloadLogs);
+$("pause-logs").addEventListener("click",()=>{
+ logPaused=!logPaused; stopLogRequests(); $("pause-logs").textContent=logPaused ? "恢复更新" : "暂停更新";
+ if (logPaused) $("log-status").textContent="已暂停更新;恢复后读取保留范围内的新记录。"; else pollLogs();
+});
+$("log-autoscroll").addEventListener("change",()=>{if ($("log-autoscroll").checked) $("log-records").scrollTop=$("log-records").scrollHeight;});
+document.addEventListener("visibilitychange",()=>{stopLogRequests(); if (!document.hidden) pollLogs();});
+window.addEventListener("pagehide",stopLogRequests);
diff --git a/internal/localadmin/assets/model-observation.js b/internal/localadmin/assets/model-observation.js
new file mode 100644
index 0000000000..b558669862
--- /dev/null
+++ b/internal/localadmin/assets/model-observation.js
@@ -0,0 +1,122 @@
+"use strict";
+let modelObservationCurrent=null, modelObservationBaseline=null, modelObservationDownloading=false, modelObservationGeneration=0;
+const modelObservationKinds={gpt:"GPT / 群总结",chat:"普通聊天",agent:"Agent",vision:"识图"};
+const modelObservationCounts=["started","active","completed","failed","incomplete"];
+const modelObservationTimes=["averageMilliseconds","maxMilliseconds"];
+
+function modelObservationStamp(value) {
+ if (typeof value!=="string") return null;
+ const match=value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/);
+ if (!match || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0,19)!==match[1]) return null;
+ return match[1]+"."+(match[2]||"").padEnd(9,"0")+"Z";
+}
+function copyModelObservation(value) {
+ const copy=row=>{
+ if (!row || !modelObservationCounts.every(key=>Number.isSafeInteger(row[key]) && row[key]>=0) ||
+ row.started!==row.active+row.completed+row.failed+row.incomplete ||
+ !modelObservationTimes.every(key=>Number.isFinite(row[key]) && row[key]>=0) || row.averageMilliseconds>row.maxMilliseconds ||
+ (row.completed+row.failed+row.incomplete===0 && (row.averageMilliseconds!==0 || row.maxMilliseconds!==0))) return null;
+ return Object.fromEntries([...modelObservationCounts,...modelObservationTimes].map(key=>[key,row[key]]));
+ };
+ const totals=copy(value),startedAt=modelObservationStamp(value?.startedAt),checkedAt=modelObservationStamp(value?.checkedAt);
+ if (!totals || !startedAt || !checkedAt || checkedAtrow?.kind===kind);
+ if (rows.length!==1) return null;
+ const counts=copy(rows[0]);if (!counts) return null;
+ categories.push({kind,...counts});
+ }
+ if (!modelObservationCounts.every(key=>totals[key]===categories.reduce((sum,row)=>sum+row[key],0))) return null;
+ let process;
+ if (value.process!=null) {
+ const source=value.process,processStartedAt=modelObservationStamp(source.processStartedAt);
+ if (!Number.isSafeInteger(source.processId) || source.processId<=0 || !processStartedAt || processStartedAt>startedAt ||
+ typeof source.programSHA256!=="string" || !/^[a-fA-F0-9]{64}$/.test(source.programSHA256)) return null;
+ process={processId:source.processId,processStartedAt,programSHA256:source.programSHA256.toUpperCase()};
+ }
+ return {startedAt,checkedAt,...totals,categories,...(process ? {process} : {})};
+}
+function modelObservationDelta(before,after) {
+ if (!before || !after || before.startedAt!==after.startedAt || after.checkedAt{
+ const delta={};
+ for (const key of ["started","completed","failed","incomplete"]) {
+ if (end[key]{
+ if (url) URL.revokeObjectURL(url);
+ if (generation===modelObservationGeneration && authenticated && csrf===token) {modelObservationDownloading=false;renderModelObservation();}
+ },1000);
+ }
+}
+$("start-model-observation").addEventListener("click",startModelObservation);
+$("clear-model-observation").addEventListener("click",clearModelObservation);
+$("download-model-observation").addEventListener("click",downloadModelObservation);
diff --git a/internal/localadmin/assets/overview.js b/internal/localadmin/assets/overview.js
new file mode 100644
index 0000000000..19b47e0ba8
--- /dev/null
+++ b/internal/localadmin/assets/overview.js
@@ -0,0 +1,96 @@
+"use strict";
+let overviewActive=false, overviewBusy=false, overviewGeneration=0, overviewRequest=null, overviewTimer=null;
+
+function clearModelRequests(message="请求统计待更新。") {
+ if (typeof clearModelObservationCurrent==="function") clearModelObservationCurrent();
+ $("model-request-summary").textContent=message;
+ $("model-request-categories").replaceChildren();
+}
+function renderModelRequests(value) {
+ const names={gpt:"GPT / 群总结",chat:"普通聊天",agent:"Agent",vision:"识图"};
+ const counts=["started","active","completed","failed","incomplete"];
+ const valid=row=>row && counts.every(key=>Number.isSafeInteger(row[key]) && row[key]>=0) &&
+ row.started===row.active+row.completed+row.failed+row.incomplete &&
+ ["averageMilliseconds","maxMilliseconds"].every(key=>Number.isFinite(row[key]) && row[key]>=0) && row.maxMilliseconds>=row.averageMilliseconds;
+ if (!valid(value) || !Array.isArray(value.categories) || value.categories.length!==4 ||
+ !Object.keys(names).every(kind=>value.categories.filter(row=>row?.kind===kind && valid(row)).length===1) ||
+ !counts.every(key=>value[key]===value.categories.reduce((sum,row)=>sum+row[key],0)) ||
+ typeof value.startedAt!=="string" || !Number.isFinite(Date.parse(value.startedAt)) ||
+ typeof value.checkedAt!=="string" || !Number.isFinite(Date.parse(value.checkedAt))) {
+ clearModelRequests("请求统计暂不可读取。");return;
+ }
+ const elapsed=row=>row.completed+row.failed+row.incomplete ?
+ "已结束请求平均 "+(row.averageMilliseconds/1000).toFixed(3)+" 秒,最长 "+(row.maxMilliseconds/1000).toFixed(3)+" 秒。" : "尚无已结束请求,耗时待统计。";
+ const countsText=row=>"发起 "+row.started+" 次;进行中 "+row.active+" 次;接口完成 "+row.completed+" 次;失败 "+row.failed+" 次;提前关闭 "+row.incomplete+" 次。";
+ $("model-request-summary").textContent="统计起点:"+new Date(value.startedAt).toLocaleString("zh-CN",{hour12:false})+"。"+countsText(value)+" "+elapsed(value)+(value.started===0 ? " 本次启动尚无模型接口请求。" : "");
+ $("model-request-categories").replaceChildren();
+ for (const [kind,name] of Object.entries(names)) {
+ const row=value.categories.find(item=>item.kind===kind),card=document.createElement("article"),title=document.createElement("h3"),count=document.createElement("p"),time=document.createElement("p");
+ title.textContent=name;count.textContent=countsText(row);time.className="field-help";time.textContent=elapsed(row);card.append(title,count,time);$("model-request-categories").append(card);
+ }
+ if (typeof receiveModelObservation==="function") receiveModelObservation(value);
+}
+
+function renderOverviewAlerts(value) {
+ $("overview-alerts").replaceChildren();
+ if (!value) {
+ $("overview-alert-summary").textContent="近期异常暂不可读取,请到运行日志检查配置是否可读。";
+ return;
+ }
+ $("overview-alert-summary").textContent="本次启动仍保留的日志:警告 "+value.warningCount+" 条,错误 "+value.errorCount+" 条。"+(value.evicted ? " 更早记录已淘汰。" : "")+" 显示最新 5 条;历史记录不代表问题仍未解决。";
+ if (!value.entries.length) {
+ const empty=document.createElement("p");empty.className="empty";empty.textContent="保留日志中暂无警告或错误。";$("overview-alerts").append(empty);
+ }
+ for (const entry of value.entries) {
+ const row=document.createElement("article"),meta=document.createElement("div"),time=document.createElement("time"),level=document.createElement("span"),text=document.createElement("pre");
+ row.className="log-entry";meta.className="log-meta";
+ time.textContent=new Date(entry.time).toLocaleString("zh-CN",{hour12:false});
+ level.textContent=entry.level.toUpperCase();level.className="log-level "+(entry.level==="warning" ? "log-warning-level" : "log-error-level");
+ text.textContent=entry.message;meta.append(time,level);row.append(meta,text);$("overview-alerts").append(row);
+ }
+}
+function overviewReady(generation) {return authenticated && overviewActive && !document.hidden && generation===overviewGeneration;}
+function staleOverview(message="状态待更新") {
+ if (typeof clearReplyTasks==="function") clearReplyTasks();
+ clearModelRequests();
+ $("connection-badge").textContent=message;$("connection-badge").classList.add("waiting");
+ $("connection-detail").textContent="请等待重新读取当前连接状态。";
+ $("account-count").textContent="—";$("checked-at").textContent="—";$("accounts").replaceChildren();
+ $("overview-alerts").replaceChildren();$("overview-alert-summary").textContent="近期异常待更新。";
+}
+function cancelOverview() {
+ overviewGeneration++;clearTimeout(overviewTimer);overviewTimer=null;
+ overviewRequest?.abort();overviewRequest=null;overviewBusy=false;$("refresh").disabled=false;
+ if (typeof renderModelObservation==="function") renderModelObservation();
+ if (typeof renderReplyDownload==="function") renderReplyDownload();
+}
+function resetOverview() {
+ overviewActive=false;cancelOverview();staleOverview();$("page-error").hidden=true;
+ if (typeof resetModelObservation==="function") resetModelObservation();
+}
+function showOverview(active) {
+ overviewActive=active;cancelOverview();staleOverview();
+ $("page-error").hidden=true;
+ if (active && authenticated && !document.hidden) refreshOverview();
+}
+async function refreshOverview() {
+ if (!authenticated || !overviewActive || document.hidden || overviewBusy) return;
+ clearTimeout(overviewTimer);overviewTimer=null;
+ const generation=++overviewGeneration,controller=new AbortController();overviewRequest=controller;overviewBusy=true;$("refresh").disabled=true;$("page-error").hidden=true;
+ if (typeof renderModelObservation==="function") renderModelObservation();
+ if (typeof renderReplyDownload==="function") renderReplyDownload();
+ try {
+ const value=await api("/api/overview",undefined,controller.signal);
+ if (overviewReady(generation)) renderOverview(value);
+ } catch(error) {
+ if (overviewReady(generation) && error.name!=="AbortError") {staleOverview("无法获取状态");$("connection-detail").textContent="状态更新失败,旧连接信息已清除。";showError("page-error",error);}
+ } finally {
+ if (overviewReady(generation)) {overviewRequest=null;overviewBusy=false;$("refresh").disabled=false;overviewTimer=setTimeout(refreshOverview,10000);}
+ if (overviewReady(generation) && typeof renderModelObservation==="function") renderModelObservation();
+ if (overviewReady(generation) && typeof renderReplyDownload==="function") renderReplyDownload();
+ }
+}
+document.addEventListener("visibilitychange",()=>{
+ cancelOverview();staleOverview();
+ if (!document.hidden && authenticated && overviewActive) refreshOverview();
+});
diff --git a/internal/localadmin/assets/reply-metrics.js b/internal/localadmin/assets/reply-metrics.js
new file mode 100644
index 0000000000..1331fba0ff
--- /dev/null
+++ b/internal/localadmin/assets/reply-metrics.js
@@ -0,0 +1,79 @@
+"use strict";
+const replyTaskCounts=["started","active","completed","failed","noReply","withReply","missingIngress","replyAttempts","replyAccepted","replyUnconfirmed"];
+const replyTaskTimes=["averageFirstReplyMilliseconds","maxFirstReplyMilliseconds","averageLastReplyMilliseconds","maxLastReplyMilliseconds"];
+let replyTaskCurrent=null,replyDownloadBusy=false,replyDownloadGeneration=0;
+function clearReplyTasks(message="回复处理统计待更新。") {
+ replyTaskCurrent=null;replyDownloadGeneration++;replyDownloadBusy=false;
+ $("reply-task-summary").textContent=message;$("reply-task-categories").replaceChildren();
+ $("reply-download-status").textContent="";renderReplyDownload();
+}
+function replyTaskStamp(value) {
+ if (typeof value!=="string") return null;
+ const m=value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/);
+ if (!m || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0,19)!==m[1]) return null;
+ return m[1]+"."+(m[2]||"").padEnd(9,"0")+"Z";
+}
+function copyReplyTasks(value) {
+ const kinds=["gpt","chat","agent"];
+ const valid=row=>row && replyTaskCounts.every(k=>Number.isSafeInteger(row[k])&&row[k]>=0) &&
+ BigInt(row.started)===BigInt(row.active)+BigInt(row.completed)+BigInt(row.failed)+BigInt(row.noReply) &&
+ BigInt(row.replyAttempts)===BigInt(row.replyAccepted)+BigInt(row.replyUnconfirmed) && row.missingIngress<=row.started &&
+ BigInt(row.withReply)<=BigInt(row.completed)+BigInt(row.failed) && row.completed<=row.withReply && row.withReply<=row.replyAccepted &&
+ replyTaskTimes.every(k=>Number.isFinite(row[k])&&row[k]>=0) && row.averageFirstReplyMilliseconds<=row.maxFirstReplyMilliseconds &&
+ row.averageLastReplyMilliseconds<=row.maxLastReplyMilliseconds && row.averageLastReplyMilliseconds>=row.averageFirstReplyMilliseconds && row.maxLastReplyMilliseconds>=row.maxFirstReplyMilliseconds &&
+ (row.withReply!==0||replyTaskTimes.every(k=>row[k]===0));
+ const start=replyTaskStamp(value?.startedAt),checked=replyTaskStamp(value?.checkedAt);
+ if (!valid(value)||value.scope!=="bot-matcher-entry-to-onebot-send-receipt"||!start||!checked||checkedvalue.categories.filter(row=>row?.kind===kind&&valid(row)).length===1)||
+ !replyTaskCounts.every(k=>BigInt(value[k])===value.categories.reduce((n,row)=>n+BigInt(row[k]),0n))) return null;
+ let process;
+ if (value.process!=null) {
+ const p=value.process,processStartedAt=replyTaskStamp(p.processStartedAt);
+ if (!Number.isSafeInteger(p.processId)||p.processId<=0||!processStartedAt||processStartedAt>start||typeof p.programSHA256!=="string"||!/^[a-fA-F0-9]{64}$/.test(p.programSHA256)) return null;
+ process={processId:p.processId,processStartedAt,programSHA256:p.programSHA256.toUpperCase()};
+ }
+ const copy=row=>Object.fromEntries([...replyTaskCounts,...replyTaskTimes].map(k=>[k,row[k]]));
+ return {startedAt:start,checkedAt:checked,scope:value.scope,...copy(value),categories:kinds.map(kind=>({kind,...copy(value.categories.find(row=>row.kind===kind))})),...(process?{process}:{})};
+}
+function renderReplyTasks(value) {
+ value=copyReplyTasks(value);
+ if (!value) { clearReplyTasks("回复处理统计暂不可读取。");return; }
+ replyTaskCurrent=value;renderReplyDownload();
+ const names={gpt:"GPT / 群总结",chat:"普通聊天",agent:"Agent / 识图"};
+ const counts=row=>"任务 "+row.started+" 次;进行中 "+row.active+" 次;取得回复回执 "+row.completed+" 次;失败 "+row.failed+" 次;没有回复 "+row.noReply+" 次。";
+ const timings=row=>row.withReply ? "已有确认回复的已结束任务 "+row.withReply+" 次:首条平均 "+(row.averageFirstReplyMilliseconds/1000).toFixed(3)+" 秒,最长 "+(row.maxFirstReplyMilliseconds/1000).toFixed(3)+" 秒;最后一条平均 "+(row.averageLastReplyMilliseconds/1000).toFixed(3)+" 秒,最长 "+(row.maxLastReplyMilliseconds/1000).toFixed(3)+" 秒。" : "尚无已结束且取得确认回复的任务,耗时待统计。";
+ const receipts=row=>"当前会话回复发送 "+row.replyAttempts+" 次;确认 "+row.replyAccepted+" 次;未确认 "+row.replyUnconfirmed+" 次。缺少匹配起点 "+row.missingIngress+" 次。";
+ $("reply-task-summary").textContent="统计起点:"+new Date(value.startedAt).toLocaleString("zh-CN",{hour12:false})+"。"+counts(value)+" "+timings(value)+" "+receipts(value);
+ $("reply-task-categories").replaceChildren();
+ for (const [kind,name] of Object.entries(names)) {
+ const row=value.categories.find(item=>item.kind===kind),card=document.createElement("article"),title=document.createElement("h3"),count=document.createElement("p"),time=document.createElement("p"),receipt=document.createElement("p");
+ title.textContent=name;count.textContent=counts(row);time.className=receipt.className="field-help";time.textContent=timings(row);receipt.textContent=receipts(row);card.append(title,count,time,receipt);$("reply-task-categories").append(card);
+ }
+}
+function renderReplyDownload() {
+ $("download-reply-statistics").disabled=!authenticated||!overviewActive||document.hidden||overviewBusy||replyDownloadBusy||!replyTaskCurrent;
+}
+function downloadReplyStatistics() {
+ renderReplyDownload();
+ if ($("download-reply-statistics").disabled) return;
+ const snapshot=copyReplyTasks(replyTaskCurrent);
+ if (!snapshot) return;
+ const exportedAt=new Date().toISOString(),report={format:"zerobot-reply-statistics",version:1,exportedAt,
+ scope:{kind:"ai-reply-tasks",latencyScope:"process-cumulative",timingBoundary:"bot-matcher-entry-to-onebot-send-receipt",businessAcceptancePassed:false},snapshot};
+ const generation=replyDownloadGeneration,token=csrf;let url=null,link=null;
+ replyDownloadBusy=true;renderReplyDownload();
+ try {
+ url=URL.createObjectURL(new Blob([JSON.stringify(report,null,2)+"\n"],{type:"application/json;charset=utf-8"}));
+ link=document.createElement("a");link.href=url;link.download="zerobot-reply-statistics-"+exportedAt.replace(/[-:.]/g,"")+".json";document.body.append(link);link.click();
+ $("reply-download-status").textContent="已发起回复统计下载,内容为最近读取的累计快照;请核对文件中的读取时间。平均/最长值属于本次启动,不是观察窗口耗时,也不证明QQ客户端收到。";
+ } catch {
+ $("reply-download-status").textContent="下载未完成,请稍后重试。";
+ } finally {
+ link?.remove();
+ setTimeout(()=>{
+ if (url) URL.revokeObjectURL(url);
+ if (generation===replyDownloadGeneration&&authenticated&&csrf===token) {replyDownloadBusy=false;renderReplyDownload();}
+ },1000);
+ }
+}
+$("download-reply-statistics").addEventListener("click",downloadReplyStatistics);
diff --git a/internal/localadmin/assets/security.js b/internal/localadmin/assets/security.js
new file mode 100644
index 0000000000..9b24992e80
--- /dev/null
+++ b/internal/localadmin/assets/security.js
@@ -0,0 +1,63 @@
+"use strict";
+let securityActive=false, securityBusy=false, securityGeneration=0, securityRequest=null;
+const securityInputIDs=["current-admin-password","new-admin-password","confirm-admin-password"];
+function clearSecurityInputs() {for (const id of securityInputIDs) $(id).value="";}
+function securityControls() {
+ $("security-fields").disabled=!authenticated || securityBusy;
+ for (const button of document.querySelectorAll(".nav-button")) button.disabled=securityBusy;
+ $("logout").disabled=securityBusy;
+}
+function resetSecurity() {
+ securityGeneration++;
+ if (securityRequest) securityRequest.abort();
+ securityRequest=null;securityActive=false;securityBusy=false;
+ clearSecurityInputs();$("security-error").hidden=true;$("security-status").textContent="";securityControls();
+}
+function showSecurity(active) {
+ if (!active && securityActive) resetSecurity();
+ securityActive=active;securityControls();
+}
+function securityCanLeave() {
+ if (!securityBusy) return true;
+ $("security-status").textContent="密码修改请求尚未结束,请等待结果后再切页或退出。";
+ return false;
+}
+function otherSettingsBusy() {
+ return connectionBusy || pluginSaving || pluginScopeBusy || (typeof aiBusy!=="undefined" && aiBusy) || (typeof backupBusy!=="undefined" && backupBusy);
+}
+async function changeAdminPassword(event) {
+ event.preventDefault();
+ if (!authenticated || !securityActive || securityBusy) return;
+ $("security-error").hidden=true;
+ const body={currentPassword:$(securityInputIDs[0]).value,newPassword:$(securityInputIDs[1]).value,confirmPassword:$(securityInputIDs[2]).value};
+ if (!body.currentPassword || new TextEncoder().encode(body.currentPassword).length>512 || Array.from(body.newPassword).length<12 || new TextEncoder().encode(body.newPassword).length>512) {
+ showError("security-error",new Error("请填写当前密码;新密码至少12个字符,且不超过512字节。"));return;
+ }
+ if (body.newPassword!==body.confirmPassword) {showError("security-error",new Error("两次输入的新密码不一致。"));return;}
+ if (body.newPassword===body.currentPassword) {showError("security-error",new Error("新密码需与当前密码不同。"));return;}
+ if (otherSettingsBusy()) {showError("security-error",new Error("其他设置请求尚未完成,请等待结果后再修改密码。"));return;}
+ const generation=securityGeneration,requestToken=csrf;
+ if (!window.confirm("修改成功后所有后台会话都将退出,未保存编辑会清空。是否修改后台密码?")) return;
+ if (!authenticated || !securityActive || securityBusy || generation!==securityGeneration || csrf!==requestToken || otherSettingsBusy() || securityInputIDs.some((id,i)=>$(id).value!==[body.currentPassword,body.newPassword,body.confirmPassword][i])) return;
+ const controller=new AbortController();securityRequest=controller;securityBusy=true;securityControls();
+ $("security-status").textContent="正在保存新密码…";
+ try {
+ const value=await api("/api/password",body,controller.signal);
+ if (generation!==securityGeneration || csrf!==requestToken || !authenticated) return;
+ if (value.passwordChanged!==true || value.authenticated!==false) throw new Error("提交结果无法确认,请重新登录核对新密码。");
+ authenticated=false;csrf="";showAuth();
+ $("auth-notice").textContent="后台密码已修改,所有后台会话已退出。请使用新密码登录。";
+ } catch(error) {
+ if (generation===securityGeneration && csrf===requestToken && authenticated && error.name!=="AbortError") {
+ showError("security-error",error);$("security-status").textContent="";
+ }
+ } finally {
+ if (generation===securityGeneration && securityRequest===controller) {
+ securityRequest=null;securityBusy=false;clearSecurityInputs();securityControls();
+ }
+ }
+}
+$("password-change-form").addEventListener("submit",changeAdminPassword);
+$("clear-admin-passwords").addEventListener("click",()=>{if (!authenticated || !securityActive || securityBusy) return;clearSecurityInputs();$("security-error").hidden=true;$("security-status").textContent="输入已清空。";});
+window.addEventListener("beforeunload",event=>{if (authenticated && securityBusy) {event.preventDefault();event.returnValue="";}});
+window.addEventListener("pagehide",resetSecurity);
diff --git a/internal/localadmin/assets/style.css b/internal/localadmin/assets/style.css
new file mode 100644
index 0000000000..190fbffc80
--- /dev/null
+++ b/internal/localadmin/assets/style.css
@@ -0,0 +1,77 @@
+:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#1e302a;background:#f6f8f5;font-synthesis:none;--green:#365e43;--muted:#768279;--line:#e4e9e1}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button,a,input{outline-offset:4px}button{cursor:pointer}button:disabled{cursor:wait;opacity:.65}[hidden]{display:none!important}a{color:inherit;text-decoration:none}.brand{display:flex;align-items:center;gap:12px;font-size:19px;font-weight:650;letter-spacing:-.5px}.brand-icon{display:grid;place-items:center;width:39px;height:39px;background:#b8d7a4;border-radius:11px;color:#243e2c;font-size:24px}.brand small{display:block;font-size:11px;font-weight:400;letter-spacing:1px;opacity:.65;margin-top:4px}.auth-layout{display:grid;grid-template-columns:1fr 1fr;min-height:100vh}.auth-intro{background:#233c31;color:#edf5e9;padding:42px 56px;display:flex;flex-direction:column}.intro-body{margin:auto 0;padding:75px 0}.eyebrow{font-size:11px;letter-spacing:2px;font-weight:600;color:var(--muted);margin:0 0 14px}.intro-body .eyebrow{color:#a8c799}.intro-body h1{font-size:clamp(38px,4.8vw,66px);font-weight:500;line-height:1.25;letter-spacing:-2px;margin:0 0 28px}.intro-body>p:not(.eyebrow){font-size:16px;line-height:1.9;color:#bccbbb}.local-tag{display:inline-flex;align-items:center;gap:10px;border:1px solid #54715b;border-radius:30px;padding:10px 16px;font-size:12px;margin-top:24px}.dot{height:7px;width:7px;background:#a6ca8f;display:inline-block;border-radius:50%}.intro-footer{font-size:11px;color:#91a990}.auth-panel{background:#fafbf8;display:flex;align-items:center;justify-content:center;padding:42px}.auth-form{width:min(100%,360px)}.auth-form h2{font-size:28px;letter-spacing:-.7px;font-weight:600;margin:0 0 12px}.muted{color:var(--muted);font-size:13px;line-height:1.6}.auth-form>.muted{margin-bottom:32px}.auth-form label:not(.file-picker){display:block;font-size:13px;font-weight:550;margin:22px 0 9px}input:not([type=file]){width:100%;padding:13px 15px;border:1px solid #dbe3d7;border-radius:8px;background:white;color:#203a2b;transition:border-color .2s}input:focus{border-color:#618456;outline:2px solid #d4e5c8}input::placeholder{color:#98a395;font-size:12px}.primary{width:100%;padding:14px 18px;display:flex;align-items:center;justify-content:space-between;background:var(--green);color:#fff;border:0;border-radius:8px;margin-top:25px;font-weight:550;font-size:13px}.primary:hover{background:#2b4b36}.auth-note{font-size:11px;color:#8b9687;text-align:center;margin-top:22px}.field-help{font-size:11px;line-height:1.7;color:var(--muted)}code{overflow-wrap:anywhere}.file-picker{display:block;position:relative;color:var(--green);font-size:12px;padding:10px 0;cursor:pointer}.file-picker input{position:absolute;opacity:0;width:100%;height:100%;left:0;top:0;cursor:pointer}.error{color:#9d3b2b;background:#fff1eb;border:1px solid #ebccbc;border-radius:8px;padding:12px 15px;font-size:13px;line-height:1.6}.workspace{display:flex;min-height:100vh}.sidebar{width:222px;flex-shrink:0;background:#233c31;color:#f2f6ef;padding:28px 23px;display:flex;flex-direction:column}.sidebar .brand{font-size:17px}.sidebar .brand-icon{width:35px;height:35px}.nav-label{margin:47px 12px 14px;font-size:10px;color:#91a58f;letter-spacing:1px}.nav-button{display:flex;align-items:center;gap:12px;width:100%;background:none;border:0;border-radius:8px;color:#b8c8b8;padding:13px 14px;margin-bottom:5px;text-align:left;font-size:15px}.nav-button span{font-size:12px}.nav-button.active{color:#f6faf2;background:#385242}.nav-button:hover{background:#304c3b}.nav-count{margin-left:auto;color:#c2d3b3;background:#496047;padding:2px 6px;border-radius:4px}.sidebar-footer{display:flex;align-items:center;gap:10px;font-size:11px;color:#c4d0c0;margin-top:auto;padding-top:30px}.sidebar-footer small{display:block;color:#92aa90;margin-top:6px;font-size:10px}.content-shell{flex:1;min-width:0}.topbar{height:70px;padding:0 42px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:#fbfcf9;color:#80907c;font-size:11px}.topbar>div{display:flex;align-items:center;gap:20px}.local-pill,.subtle-tag{font-size:10px;border:1px solid #dce5d5;background:#f1f5ed;color:#5d7351;border-radius:20px;padding:6px 10px}.text-button{background:none;border:0;color:#63775a;padding:5px;font-size:12px}.content{padding:36px 42px 20px;max-width:1380px;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:28px}.page-heading .eyebrow{font-size:10px;margin-bottom:10px}.page-heading h1{font-weight:600;font-size:28px;letter-spacing:-1px;margin:0 0 10px}.page-heading p{margin:0}.secondary{border:1px solid #d5dfcf;border-radius:7px;background:#fbfcf9;padding:10px 14px;color:#546e4b;font-size:12px;white-space:nowrap}.secondary:hover{background:#edf2e7}.status-banner{display:flex;align-items:center;gap:16px;background:#eaf1e3;border:1px solid #dce7d4;border-radius:11px;padding:22px 24px;margin-bottom:22px}.status-emblem{height:47px;width:47px;background:#d3e2c6;border-radius:12px;display:grid;place-items:center;color:#3a5a30;font-size:27px}.status-banner h2{font-size:16px;font-weight:600;margin:0 0 7px}.status-banner p{font-size:11px;color:#728266;margin:0}.state-badge{margin-left:auto;font-size:11px;color:#45673a;border:1px solid #c0d4b0;border-radius:5px;padding:7px 10px;background:#f1f6eb;white-space:nowrap}.state-badge.waiting{color:#8b7244;background:#fbf6e9;border-color:#e6d9ba}.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin-bottom:25px}.metric{border:1px solid var(--line);border-radius:10px;background:#fff;padding:22px 24px}.metric>span{display:block;font-size:12px;color:#768570}.metric strong{display:block;font-size:33px;font-weight:550;margin:17px 0 10px;letter-spacing:-1px}.metric small{font-size:10px;color:#a1ac99}.overview-grid{display:grid;grid-template-columns:1.15fr 1fr;gap:23px}.panel{background:#fff;border:1px solid var(--line);border-radius:10px;padding:22px 24px}.panel-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.panel h2{font-size:14px;margin:0;font-weight:550}.account-row{display:flex;align-items:center;gap:12px;border:1px solid var(--line);border-radius:8px;padding:13px;margin-bottom:10px}.account-avatar{display:grid;place-items:center;height:36px;width:36px;border-radius:8px;background:#eef2e7;color:#6e885c;font-size:13px}.account-row strong{display:block;font-size:13px;font-weight:550;letter-spacing:.4px}.account-row small{display:block;color:#8d9d82;margin-top:5px;font-size:10px}.account-row .dot{margin-left:auto}.panel-note{font-size:10px;color:#8a9881;line-height:1.7;margin:17px 0 0}.info-list{margin:0}.info-list>div{display:flex;justify-content:space-between;gap:12px;padding:11px 0;border-bottom:1px solid #f0f3ec;font-size:11px}.info-list>div:last-child{border-bottom:0}.info-list dt{color:#86947c}.info-list dd{margin:0;color:#4e6544;text-align:right;overflow-wrap:anywhere}.content-footer{font-size:10px;color:#9aaa90;text-align:center;padding:35px 0 8px}.plugin-toolbar{display:flex;gap:20px;align-items:center;justify-content:space-between}.plugin-toolbar input{max-width:380px}.plugin-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:20px}.plugin-card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:20px}.plugin-card h2{font-size:14px;margin:0 0 10px;font-weight:600}.plugin-card p{font-size:12px;color:#7d8c74;line-height:1.6;min-height:38px;margin:0 0 16px}.plugin-card footer{display:flex;justify-content:space-between;align-items:center;gap:10px}.plugin-default{font-size:10px;color:#637a53;background:#eef4e7;padding:5px 8px;border-radius:4px}.plugin-default.disabled{color:#929a8c;background:#f3f4f0}.empty{padding:28px;color:#8d9a83;font-size:13px;text-align:center}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}dialog{width:min(600px,calc(100vw - 40px));border:1px solid #dce4d6;border-radius:12px;padding:24px;color:#304b32;background:#fff}dialog::backdrop{background:#19302088}.dialog-heading{display:flex;align-items:center;justify-content:space-between;gap:20px}.dialog-heading h2{font-size:17px;margin:0}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.8 "Segoe UI","Microsoft YaHei",sans-serif;max-height:55vh;overflow:auto;background:#f6f8f2;padding:17px;border-radius:8px;margin-top:20px}@media(max-width:900px){.auth-intro{padding:35px}.sidebar{width:184px;padding:24px 17px}.content{padding:28px 25px}.topbar{padding:0 25px}.overview-grid{grid-template-columns:1fr}.metric{padding:20px}.metric strong{font-size:27px}}@media(max-width:640px){.auth-layout{grid-template-columns:1fr}.auth-intro{min-height:240px;padding:25px}.intro-body{padding:32px 0 5px}.intro-body h1{font-size:33px}.intro-body>p:not(.eyebrow),.intro-footer,.intro-body .local-tag{display:none}.auth-panel{padding:35px 25px}.sidebar{width:62px;padding:20px 9px}.sidebar .brand>span:last-child,.nav-label,.nav-button span,.sidebar-footer{display:none}.nav-button{justify-content:center;padding:13px 0;margin-top:12px}.sidebar .brand-icon{width:34px}.topbar{height:60px;padding:0 16px}.local-pill{display:none}.content{padding:24px 16px}.page-heading{align-items:flex-start}.page-heading h1{font-size:24px}.metrics{grid-template-columns:1fr;gap:12px}.metric{padding:18px;display:grid;grid-template-columns:1fr auto;gap:8px}.metric strong{grid-column:2;grid-row:1/3;margin:0}.metric small{grid-column:1}.status-banner{padding:17px;gap:12px}.status-emblem{width:35px;height:35px;flex-shrink:0}.status-banner h2{font-size:13px}.status-banner p{font-size:10px}.state-badge{font-size:10px;padding:6px}.plugin-list{grid-template-columns:1fr}.panel{padding:20px}.plugin-toolbar{flex-wrap:wrap;gap:10px}}
+
+.connection-form{max-width:760px;margin-top:20px}.connection-form fieldset{border:0;margin:0;padding:0;min-width:0}.connection-form select{display:block;width:100%;padding:13px 15px;border:1px solid #dfe5db;border-radius:7px;background:#fff;color:#263f2d;font:inherit;font-size:13px}.check-label{display:flex;align-items:center;gap:10px;font-weight:400}.check-label input{width:auto}.connection-form .field-help{overflow-wrap:anywhere}
+.plugin-scope{display:flex;align-items:end;gap:18px;margin-bottom:18px}.plugin-scope>div{flex:1;max-width:330px}.plugin-scope label{display:block;margin-bottom:8px}.plugin-scope input{margin:0}.plugin-scope select,.plugin-controls select{width:100%;border:1px solid var(--line);border-radius:9px;background:#fff;padding:13px;color:#203a2b;font:inherit}.plugin-controls{display:flex;align-items:center;gap:12px;margin-top:18px}.plugin-controls select{flex:1;min-width:0}.plugin-controls button{white-space:nowrap}.plugin-card>.field-help{margin:14px 0}.plugin-controls button:disabled,.plugin-controls select:disabled{opacity:.55}@media(max-width:700px){.plugin-scope{flex-direction:column;align-items:stretch}.plugin-scope>div{max-width:none}.plugin-controls{flex-wrap:wrap}}
+
+.ai-form { margin-bottom: 24px; max-width: 960px; }
+.ai-form fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
+.ai-form label { display: block; margin-top: 20px; margin-bottom: 6px; }
+.ai-form select { display: block; width: 100%; min-height: 44px; padding: 13px 15px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: #203a2b; font: inherit; }
+.ai-form textarea { display: block; width: 100%; padding: 14px 16px; border: 1px solid #dfe6dc; border-radius: 8px; color: #203a2b; background: #fff; resize: vertical; font: inherit; line-height: 1.6; }
+.ai-form .check-label { display: flex; align-items: center; gap: 10px; }
+.ai-form .check-label input { width: auto; }
+.ai-form button { margin-top: 24px; }
+
+.log-toolbar{display:flex;flex-wrap:wrap;gap:16px;align-items:end;margin-bottom:16px}.log-toolbar>div{min-width:140px}.log-toolbar label{display:block;margin-bottom:8px;font-size:12px}.log-toolbar select{width:100%;padding:13px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#203a2b;font:inherit}.log-search{flex:1;min-width:180px}.log-time{flex:1;min-width:220px;max-width:100%}.log-time input{min-width:0;width:100%;max-width:100%}.log-options{display:flex;align-items:center;gap:20px;flex-wrap:wrap}.log-options .check-label{font-size:12px}.log-options input{width:auto}.log-records{margin-top:20px;max-height:65vh;overflow:auto;min-height:220px}.log-entry{padding:12px 0;border-bottom:1px solid var(--line)}.log-entry:last-child{border:0}.log-meta{display:flex;gap:16px;align-items:center;color:var(--muted);font-size:11px}.log-level{font-size:10px;padding:3px 7px;border-radius:4px;background:#edf2e9;color:#4e6545}.log-error-level{color:#9d3b2b;background:#fff1eb}.log-warning-level{color:#8c652e;background:#fff7e5}.log-entry pre{font-family:Consolas,"Microsoft YaHei",monospace;font-size:12px;line-height:1.65;max-height:none;overflow:visible;background:none;padding:0;border-radius:0;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:700px){.log-toolbar{flex-direction:column;align-items:stretch}.log-toolbar>div{min-width:0}.log-records{max-height:60vh}.log-options{gap:12px}}
+/* Local backup list and explicit restore preview. */
+.backup-create,.backup-card,.restore-actions{display:flex;align-items:center;gap:20px;justify-content:space-between}
+.backup-create>div{flex:1}.backup-create label{display:block;margin-bottom:8px;font-size:12px}.backup-create select{width:100%;padding:13px;border:1px solid var(--line);border-radius:8px;background:#fff;font:inherit}.backup-list{display:grid;gap:16px}
+.backup-card h2{font-size:18px;margin:0 0 10px}.backup-card p{margin:0 0 8px}.backup-card small{overflow-wrap:anywhere;color:var(--muted)}
+.backup-card>div{min-width:0}.backup-card button{flex-shrink:0}.restore-preview{margin-bottom:24px}
+.restore-preview ul{padding-left:24px;line-height:1.8}.restore-preview .check-label input{width:auto}.restore-actions{justify-content:flex-start;margin-top:20px}.restore-actions .primary{width:auto;margin-top:0}
+@media(max-width:700px){.backup-create,.backup-card,.restore-actions{align-items:stretch;flex-direction:column}.backup-card button,.restore-actions button{width:100%}}
+.cleanup-form{margin:24px 0}.cleanup-fields{display:flex;align-items:end;gap:18px;margin-top:16px}.cleanup-fields>div{flex:1;min-width:0}.cleanup-fields label{display:block;margin-bottom:8px;font-size:12px}.cleanup-fields input{margin:0}.cleanup-list{max-height:280px;overflow:auto;overflow-wrap:anywhere;font-size:12px;line-height:1.8}.cleanup-preview .check-label input{width:auto}@media(max-width:700px){.cleanup-fields{flex-direction:column;align-items:stretch}}
+.overview-alert-panel{margin-top:24px}.overview-alert-panel .log-meta{flex-wrap:wrap}.overview-alert-panel #overview-alerts{max-height:420px;overflow:auto}
+.log-filter-options{margin-bottom:12px}#log-filter-status{min-width:0;overflow-wrap:anywhere;flex:1 1 300px}
+.backup-filter{display:flex;gap:16px;align-items:end;flex-wrap:wrap;margin-top:20px}.backup-filter>div{flex:1;min-width:160px}.backup-filter .backup-filter-search{flex:2;min-width:0}.backup-filter label{display:block;margin-bottom:8px;font-size:12px}.backup-filter select{width:100%;padding:13px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#203a2b;font:inherit}.backup-filter input{margin-bottom:0}.restore-backup-id{overflow-wrap:anywhere}@media(max-width:700px){.backup-filter{align-items:stretch;flex-direction:column}.backup-filter>div{min-width:0}}
+.backup-pagination,.plugin-pagination{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:18px 0}.backup-pagination label,.plugin-pagination label{display:flex;gap:8px;align-items:center;font-size:12px;margin-left:auto}.backup-pagination select,.plugin-pagination select{border:1px solid var(--line);border-radius:8px;padding:8px;background:#fff;color:#203a2b;font:inherit}.backup-pagination .muted,.plugin-pagination .muted{min-width:0;overflow-wrap:anywhere}.backup-list{margin-bottom:24px}.restore-preview{scroll-margin-top:20px}@media(max-width:700px){.backup-pagination,.plugin-pagination{gap:10px}.backup-pagination label,.plugin-pagination label{margin-left:0}.backup-pagination .muted,.plugin-pagination .muted{flex-basis:100%;order:-1}}
+
+.plugin-draft-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:16px 0}.plugin-draft-actions label{display:flex;align-items:center;gap:8px;font-size:13px}.plugin-draft-actions input[type=checkbox]{width:auto;padding:0;margin:0}.plugin-draft-actions .field-help{min-width:0;overflow-wrap:anywhere}
+
+.ai-discard{margin-top:12px}
+
+.connection-draft-actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:18px}
+
+.security-form{max-width:680px}.security-form fieldset{border:0;margin:0;padding:0;min-width:0}.security-form label{display:block;margin:20px 0 10px}.security-form label:first-child{margin-top:0}.security-actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px}.security-actions .primary{width:auto;margin-top:0}@media(max-width:700px){.security-actions{flex-direction:column}.security-actions button{width:100%}}
+
+.restore-impact{margin:20px 0;padding:16px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.restore-impact h3{margin:0 0 10px;font-size:15px}.restore-impact-list{display:grid;gap:10px}.restore-impact-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;padding:12px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.restore-impact-row:last-child{border-bottom:0}.restore-impact-row strong{font-size:13px;min-width:0}.restore-impact-row p{grid-column:1/-1;margin:0}.restore-impact-state{font-size:12px;color:var(--muted)}
+
+.restore-impact-state.impact-changed{color:var(--text);font-weight:600}
+.connection-add-form h2{margin-top:0}.connection-add-actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}.connection-add-actions .primary{width:auto;margin-top:0}@media(max-width:700px){.connection-add-actions{flex-direction:column}.connection-add-actions button{width:100%}}
+.connection-delete-preview{max-width:760px;margin-top:20px}.connection-delete-preview h2{margin-top:0}.connection-delete-preview p{overflow-wrap:anywhere}.connection-delete-actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}#preview-connection-delete{margin-top:16px}@media(max-width:700px){.connection-delete-actions{flex-direction:column}.connection-delete-actions button{width:100%}}
+
+.startup-differences{margin-bottom:20px}.startup-differences h2{margin:0 0 12px;font-size:18px}.startup-difference-list{display:grid;gap:8px}.startup-difference-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;padding:12px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.startup-difference-row:last-child{border-bottom:0}.startup-difference-row strong{font-size:13px;min-width:0}.startup-difference-row p{grid-column:1/-1;margin:0}.startup-difference-state{font-size:12px;color:var(--muted)}.startup-difference-state.difference-changed{color:var(--text);font-weight:600}
+
+#plugin-batch-status{overflow-wrap:anywhere}
+
+.plugin-response-panel{margin:16px 0}.plugin-response-panel h2{font-size:16px;margin:0 0 12px}.plugin-response-panel p{overflow-wrap:anywhere;font-size:12px;line-height:1.7}
+
+.usage-actions{display:flex;flex-wrap:wrap;gap:12px}.dialog-heading h2{min-width:0;overflow-wrap:anywhere}#usage-dialog{max-height:calc(100dvh - 32px);overflow:auto}#usage-export-status{overflow-wrap:anywhere}
+
+#usage-dialog .dialog-heading{align-items:flex-start}#usage-dialog .dialog-heading h2{max-height:120px;overflow:auto}#close-dialog{flex-shrink:0;white-space:nowrap}#usage-dialog pre{max-height:min(45vh,420px)}
+
+.ai-config-checks{margin:16px 0}.ai-config-check-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:16px}.ai-config-check-list article{min-width:0;border:1px solid var(--line);border-radius:8px;padding:14px}.ai-config-check-list h3{font-size:14px;margin:0 0 10px}.ai-config-check-list p{overflow-wrap:anywhere;line-height:1.7}.ai-check-result{font-size:12px;margin:0 0 8px}.ai-config-check-list .field-help{margin:0}@media(max-width:900px){.ai-config-check-list{grid-template-columns:1fr}}
+
+.log-summary{margin:16px 0}.log-summary p{overflow-wrap:anywhere}.log-summary-levels{display:flex;flex-wrap:wrap;gap:10px}.log-summary-levels span{font-size:12px;border:1px solid var(--line);border-radius:6px;padding:8px 10px;overflow-wrap:anywhere}
+
+.backup-export{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.backup-export .field-help{flex:1;min-width:0;overflow-wrap:anywhere}#backup-export-status{overflow-wrap:anywhere}
+@media(max-width:700px){.backup-export{align-items:stretch;flex-direction:column}}
+
+.preview-expiry-state{overflow-wrap:anywhere}.preview-expiry-state[data-expired="true"]{color:var(--error-text,#9d3b2b);font-weight:600}
+.plugin-single-draft{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-top:12px}.plugin-card .plugin-single-draft .plugin-draft-note{min-height:0;margin:0}.plugin-discard-one{margin-left:auto}
+.backup-size-summary{margin-top:16px}.backup-size-summary h2{margin:0 0 12px}.backup-size-summary p{font-size:12px;line-height:1.7;overflow-wrap:anywhere}.backup-size-scopes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.backup-size-scopes article{min-width:0;border:1px solid var(--line);border-radius:8px;padding:12px}.backup-size-scopes h3{font-size:13px;margin:0 0 8px}.backup-size-scopes p{margin:6px 0}@media(max-width:700px){.backup-size-scopes{grid-template-columns:1fr}}
+
+.plugin-catalog-actions{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:16px}.plugin-catalog-actions p{flex:1;min-width:160px;margin:0}@media(max-width:700px){.plugin-catalog-actions{flex-direction:column;align-items:stretch}.plugin-catalog-actions p{min-width:0}}
+
+.log-copy-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:10px}.log-copy-actions .secondary{padding:7px 10px;font-size:11px}.log-copy-actions .field-help{flex:1;min-width:120px;margin:0}
+
+.ai-draft-fields{margin:16px 0}.ai-draft-field-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:16px}.ai-draft-field-list article{min-width:0;padding:14px;border:1px solid var(--line);border-radius:8px}.ai-draft-field-list h3{font-size:14px;overflow-wrap:anywhere;margin:0 0 10px}.ai-draft-field-list ul{font-size:12px;line-height:1.7;padding-left:20px;margin-bottom:0;overflow-wrap:anywhere}@media(max-width:900px){.ai-draft-field-list{grid-template-columns:1fr}}
+.connection-draft-fields{margin:16px 0}.connection-draft-field-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:16px}.connection-draft-field-list article{min-width:0;padding:14px;border:1px solid var(--line);border-radius:8px}.connection-draft-field-list h3{font-size:14px;overflow-wrap:anywhere;margin:0 0 10px}.connection-draft-field-list ul{font-size:12px;line-height:1.7;padding-left:20px;margin-bottom:0;overflow-wrap:anywhere}@media(max-width:900px){.connection-draft-field-list{grid-template-columns:1fr}}
+.model-request-panel { margin-top:24px; }
+.model-request-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }
+.model-request-grid article { min-width:0; overflow-wrap:anywhere; }
+.model-request-grid h3 { font-size:15px; margin:0 0 8px; }
+@media (max-width:600px) { .model-request-grid { grid-template-columns:1fr; } }
+.model-observation-panel { margin-top:24px; }
+.model-observation-actions { display:flex; flex-wrap:wrap; gap:12px; }
+@media (max-width:600px) { .model-observation-actions button { width:100%; } }
diff --git a/internal/localadmin/assets/theme.js b/internal/localadmin/assets/theme.js
new file mode 100644
index 0000000000..eed4ea9800
--- /dev/null
+++ b/internal/localadmin/assets/theme.js
@@ -0,0 +1,31 @@
+"use strict";
+// Only the appearance preference is stored here; credentials stay in cookies.
+(() => {
+ const key="zerobot.localadmin.theme.v1", choices=new Set(["system","light","dark"]);
+ let media=null;
+ try {media=window.matchMedia("(prefers-color-scheme: dark)");} catch {}
+ function readChoice() {
+ try {const value=window.localStorage.getItem(key); return choices.has(value) ? value : "system";} catch {return "system";}
+ }
+ let choice=readChoice();
+ function apply() {
+ document.documentElement.dataset.theme=choice==="system" ? (media?.matches ? "dark" : "light") : choice;
+ for (const select of document.querySelectorAll("[data-theme-select]")) select.value=choice;
+ }
+ apply();
+ function connectControls() {
+ for (const select of document.querySelectorAll("[data-theme-select]")) {
+ select.addEventListener("change",()=>{
+ if (!choices.has(select.value)) {apply(); return;}
+ choice=select.value;
+ try {window.localStorage.setItem(key,choice);} catch {}
+ apply();
+ });
+ }
+ apply();
+ }
+ if (document.readyState==="loading") document.addEventListener("DOMContentLoaded",connectControls,{once:true});
+ else connectControls();
+ if (media?.addEventListener) media.addEventListener("change",()=>{if (choice==="system") apply();});
+ window.addEventListener("storage",event=>{if (event.key===key || event.key===null) {choice=readChoice(); apply();}});
+})();
diff --git a/internal/localadmin/auth.go b/internal/localadmin/auth.go
new file mode 100644
index 0000000000..7aa1332ada
--- /dev/null
+++ b/internal/localadmin/auth.go
@@ -0,0 +1,173 @@
+package localadmin
+
+import (
+ "crypto/pbkdf2"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const passwordIterations = 600000
+
+type credential struct {
+ Version int `json:"version"`
+ Salt []byte `json:"salt"`
+ Hash []byte `json:"hash"`
+}
+
+type session struct {
+ CSRF string
+ Expires time.Time
+}
+
+func randomCode() string {
+ return base64.RawURLEncoding.EncodeToString(randomBytes(32))
+}
+
+func randomBytes(size int) []byte {
+ b := make([]byte, size)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return b
+}
+
+func regularFile(path string) error {
+ i, err := os.Lstat(path)
+ if err != nil {
+ return err
+ }
+ if !i.Mode().IsRegular() {
+ return errors.New("后台凭据必须为普通文件")
+ }
+ return nil
+}
+
+func (s *Server) loadAuth() error {
+ if err := os.MkdirAll(s.cfg.DataDir, 0700); err != nil {
+ return err
+ }
+ i, err := os.Lstat(s.cfg.DataDir)
+ if err != nil {
+ return err
+ }
+ if !i.IsDir() || i.Mode()&os.ModeSymlink != 0 {
+ return errors.New("后台数据目录不能为符号链接")
+ }
+ if err := protectPath(s.cfg.DataDir, true); err != nil {
+ return err
+ }
+ path := filepath.Join(s.cfg.DataDir, "credential.json")
+ if err = regularFile(path); err == nil {
+ if err = protectPath(path, false); err != nil {
+ return err
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var c credential
+ if err = json.Unmarshal(b, &c); err != nil || c.Version != 1 || len(c.Salt) != 16 || len(c.Hash) != 32 {
+ return errors.New("后台凭据文件损坏或版本不兼容")
+ }
+ s.credential = &c
+ return nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ path = filepath.Join(s.cfg.DataDir, "setup-code.txt")
+ if err = regularFile(path); err == nil {
+ if err = protectPath(path, false); err != nil {
+ return err
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ code := strings.TrimSpace(string(b))
+ decoded, err := base64.RawURLEncoding.DecodeString(code)
+ if err != nil || len(decoded) != 32 {
+ return errors.New("后台设置码文件损坏")
+ }
+ s.setupCode = code
+ return nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ s.setupCode = randomCode()
+ return writeNewPrivateFile(path, []byte(s.setupCode+"\n"))
+}
+
+// Exclusive creation prevents overwriting an existing credential or a link.
+func writeNewPrivateFile(path string, data []byte) error {
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ ok := false
+ defer func() {
+ f.Close()
+ if !ok {
+ os.Remove(path)
+ }
+ }()
+ if err = protectPath(path, false); err != nil {
+ return err
+ }
+ if _, err = f.Write(data); err != nil {
+ return err
+ }
+ if err = f.Sync(); err != nil {
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ ok = true
+ return nil
+}
+
+func derivePassword(password string, salt []byte) ([]byte, error) {
+ return pbkdf2.Key(sha256.New, password, salt, passwordIterations, 32)
+}
+
+func (s *Server) createCredential(password string) error {
+ c := &credential{Version: 1, Salt: randomBytes(16)}
+ var err error
+ c.Hash, err = derivePassword(password, c.Salt)
+ if err != nil {
+ return err
+ }
+ b, err := json.Marshal(c)
+ if err != nil {
+ return err
+ }
+ if err = writeNewPrivateFile(filepath.Join(s.cfg.DataDir, "credential.json"), b); err != nil {
+ return err
+ }
+ s.credential = c
+ s.setupCode = ""
+ // A leftover setup file cannot be used after credential creation.
+ _ = os.Remove(filepath.Join(s.cfg.DataDir, "setup-code.txt"))
+ return nil
+}
+
+func (s *Server) validPassword(password string) bool {
+ hash, err := derivePassword(password, s.credential.Salt)
+ return err == nil && subtle.ConstantTimeCompare(hash, s.credential.Hash) == 1
+}
+
+func (s *Server) pruneSessions() {
+ for key, value := range s.sessions {
+ if !s.now().Before(value.Expires) {
+ delete(s.sessions, key)
+ }
+ }
+}
diff --git a/internal/localadmin/backup_cleanup.go b/internal/localadmin/backup_cleanup.go
new file mode 100644
index 0000000000..5abb312b61
--- /dev/null
+++ b/internal/localadmin/backup_cleanup.go
@@ -0,0 +1,304 @@
+package localadmin
+
+import (
+ "context"
+ "crypto/hmac"
+ "encoding/hex"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+const maxCleanupScanBytes = 256 << 20
+
+type CleanupPolicy struct {
+ KeepPerScope int `json:"keepPerScope"`
+ KeepDays int `json:"keepDays"`
+}
+
+func (p CleanupPolicy) valid() bool {
+ return p.KeepPerScope >= 3 && p.KeepPerScope <= 200 && p.KeepDays >= 1 && p.KeepDays <= 3650
+}
+
+type CleanupPreview struct {
+ Policy CleanupPolicy `json:"policy"`
+ Candidates []BackupEntry `json:"candidates"`
+ Bytes int64 `json:"bytes"`
+ Skipped int `json:"skipped"`
+ More bool `json:"more"`
+ Revision string `json:"revision"`
+ AsOf int64 `json:"asOf"`
+ ExpiresAt int64 `json:"expiresAt"`
+ Token string `json:"token"`
+}
+
+type CleanupRequest struct {
+ Policy CleanupPolicy `json:"policy"`
+ Revision string `json:"revision"`
+ AsOf int64 `json:"asOf"`
+ ExpiresAt int64 `json:"expiresAt"`
+ Token string `json:"token"`
+ Confirm bool `json:"confirm"`
+}
+
+type CleanupResult struct {
+ Deleted []string `json:"deleted"`
+ Bytes int64 `json:"bytes"`
+ Partial bool `json:"partial"`
+ Message string `json:"message"`
+}
+
+type cleanupItem struct {
+ Entry BackupEntry
+ Hash string
+ Bytes int64
+ Files []string
+}
+
+// Only the expected regular files are eligible. Unknown children are retained.
+// All deletion is relative to os.Root, so links cannot lead outside this tree.
+func (s *BackupStore) cleanupFiles(id string) ([]string, int64, error) {
+ if !backupIDPattern.MatchString(id) || filepath.Base(id) != id {
+ return nil, 0, errBackupInvalid
+ }
+ files := []string{id}
+ if strings.HasSuffix(id, "-control") {
+ dir := filepath.Join(s.directory, id)
+ if safeDirectory(dir) != nil {
+ return nil, 0, errBackupInvalid
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil || len(entries) != 2 {
+ return nil, 0, errBackupInvalid
+ }
+ files = []string{filepath.Join(id, "plugins.sqlite"), filepath.Join(id, "manifest.json")}
+ }
+ var bytes int64
+ for _, file := range files {
+ path := filepath.Join(s.directory, file)
+ if regularFile(path) != nil {
+ return nil, 0, errBackupInvalid
+ }
+ info, err := os.Lstat(path)
+ if err != nil || info.Size() > maxBackupBytes+4096 {
+ return nil, 0, errBackupInvalid
+ }
+ bytes += info.Size()
+ }
+ return files, bytes, nil
+}
+
+func (s *BackupStore) cleanupPlan(ctx context.Context, policy CleanupPolicy, asOf int64) (CleanupPreview, []cleanupItem, error) {
+ v := CleanupPreview{Policy: policy, AsOf: asOf, Candidates: []BackupEntry{}}
+ if !policy.valid() || asOf <= 0 {
+ return v, nil, errBackupInvalid
+ }
+ if _, err := os.Lstat(s.directory); os.IsNotExist(err) {
+ v.Revision = s.revision([]any{policy, asOf, "empty"})
+ return v, nil, nil
+ }
+ if safeDirectory(s.directory) != nil {
+ return v, nil, errBackupInvalid
+ }
+ dir, err := os.Open(s.directory)
+ if err != nil {
+ return v, nil, errBackupInvalid
+ }
+ defer dir.Close()
+ entries, err := dir.ReadDir(4097)
+ if err != nil && err != io.EOF {
+ return v, nil, errBackupInvalid
+ }
+ if len(entries) > 4096 {
+ return v, nil, errors.New("备份目录超过 4096 项,请先在本机整理后再预览")
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
+ all := []cleanupItem{}
+ inventory := []any{}
+ var scanned int64
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return v, nil, err
+ }
+ id := entry.Name()
+ if !backupIDPattern.MatchString(id) {
+ continue
+ }
+ files, size, err := s.cleanupFiles(id)
+ // Ineligible items are never deleted; sign their presence/eligibility,
+ // not filesystem timestamps that may change when directories are read.
+ if err != nil {
+ v.Skipped++
+ inventory = append(inventory, []any{id, "skipped"})
+ continue
+ }
+ scanned += size
+ if scanned > maxCleanupScanBytes {
+ return v, nil, errors.New("备份扫描超过 256 MiB,请先在本机整理后再预览")
+ }
+ backup, raw, err := s.load(id)
+ if err != nil || !backup.Integrity {
+ v.Skipped++
+ inventory = append(inventory, []any{id, "skipped"})
+ continue
+ }
+ item := cleanupItem{Entry: backup, Hash: digestBytes(raw), Bytes: size, Files: files}
+ all = append(all, item)
+ inventory = append(inventory, item)
+ }
+ v.Revision = s.revision([]any{policy, asOf, inventory})
+ sort.Slice(all, func(i, j int) bool {
+ if all[i].Entry.CreatedAt.Equal(all[j].Entry.CreatedAt) {
+ return all[i].Entry.ID > all[j].Entry.ID
+ }
+ return all[i].Entry.CreatedAt.After(all[j].Entry.CreatedAt)
+ })
+ counts := map[string]int{}
+ selected := []cleanupItem{}
+ cutoff := time.Unix(asOf, 0).AddDate(0, 0, -policy.KeepDays)
+ for _, item := range all {
+ counts[item.Entry.Scope]++
+ if counts[item.Entry.Scope] <= policy.KeepPerScope || !item.Entry.CreatedAt.Before(cutoff) {
+ continue
+ }
+ if len(selected) == 100 {
+ v.More = true
+ continue
+ }
+ selected = append(selected, item)
+ v.Candidates = append(v.Candidates, item.Entry)
+ v.Bytes += item.Bytes
+ }
+ return v, selected, nil
+}
+
+func (s *BackupStore) cleanupTicket(req CleanupRequest, subject string) string {
+ return s.revision([]any{"cleanup", req.Policy, req.Revision, req.AsOf, req.ExpiresAt, subject})
+}
+
+func (s *BackupStore) PreviewCleanup(ctx context.Context, policy CleanupPolicy, subject string) (CleanupPreview, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ v, _, err := s.cleanupPlan(ctx, policy, s.now().Unix())
+ if err != nil {
+ return v, err
+ }
+ v.ExpiresAt = v.AsOf + 600
+ v.Token = s.cleanupTicket(CleanupRequest{Policy: v.Policy, Revision: v.Revision, AsOf: v.AsOf, ExpiresAt: v.ExpiresAt}, subject)
+ return v, nil
+}
+
+func (s *BackupStore) Cleanup(ctx context.Context, req CleanupRequest, subject string) (CleanupResult, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ result := CleanupResult{Deleted: []string{}}
+ if !req.Confirm || !req.Policy.valid() {
+ return result, errBackupInvalid
+ }
+ if req.AsOf > s.now().Unix() || req.ExpiresAt <= s.now().Unix() || req.ExpiresAt-req.AsOf > 600 || req.ExpiresAt <= req.AsOf {
+ return result, errBackupConflict
+ }
+ token, err := hex.DecodeString(req.Token)
+ expected, _ := hex.DecodeString(s.cleanupTicket(req, subject))
+ if err != nil || !hmac.Equal(token, expected) {
+ return result, errBackupConflict
+ }
+ v, items, err := s.cleanupPlan(ctx, req.Policy, req.AsOf)
+ if err != nil {
+ return result, err
+ }
+ if v.Revision != req.Revision || req.ExpiresAt <= s.now().Unix() {
+ return result, errBackupConflict
+ }
+ if len(items) == 0 {
+ result.Message = "没有符合条件的旧备份。"
+ return result, nil
+ }
+ root, err := os.OpenRoot(s.directory)
+ if err != nil {
+ return result, errBackupInvalid
+ }
+ defer root.Close()
+ for _, item := range items {
+ // Recheck each target before deleting; partial failures are reported
+ // explicitly, never presented as an atomic all-or-nothing cleanup.
+ current, raw, readErr := s.load(item.Entry.ID)
+ files, _, filesErr := s.cleanupFiles(item.Entry.ID)
+ if ctx.Err() != nil || readErr != nil || filesErr != nil || !current.Integrity || digestBytes(raw) != item.Hash {
+ result.Partial = true
+ break
+ }
+ for _, file := range files {
+ if s.cleanupRemove != nil {
+ err = s.cleanupRemove(root, file)
+ } else {
+ err = root.Remove(file)
+ }
+ if err != nil {
+ result.Partial = true
+ break
+ }
+ }
+ if result.Partial {
+ break
+ }
+ if strings.HasSuffix(item.Entry.ID, "-control") && root.Remove(item.Entry.ID) != nil {
+ result.Partial = true
+ }
+ result.Deleted = append(result.Deleted, item.Entry.ID)
+ result.Bytes += item.Bytes
+ if result.Partial {
+ break
+ }
+ }
+ result.Message = "清理完成,当前配置未改变。"
+ if result.Partial {
+ result.Message = "清理已停止,部分文件可能已删除;请刷新列表检查实际状态后重新预览。"
+ }
+ return result, nil
+}
+
+func (s *Server) previewBackupCleanup(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Backups == nil {
+ apiError(w, 409, "当前程序未接入备份管理")
+ return
+ }
+ var policy CleanupPolicy
+ if !readJSON(w, r, &policy) {
+ return
+ }
+ cookie, _ := r.Cookie("local_admin_session")
+ ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
+ defer cancel()
+ value, err := s.cfg.Backups.PreviewCleanup(ctx, policy, cookie.Value)
+ if err != nil {
+ s.backupError(w, err)
+ return
+ }
+ jsonReply(w, 200, value)
+}
+
+func (s *Server) cleanupBackups(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Backups == nil {
+ apiError(w, 409, "当前程序未接入备份管理")
+ return
+ }
+ var req CleanupRequest
+ if !readJSON(w, r, &req) {
+ return
+ }
+ cookie, _ := r.Cookie("local_admin_session")
+ ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
+ defer cancel()
+ value, err := s.cfg.Backups.Cleanup(ctx, req, cookie.Value)
+ if err != nil {
+ s.backupError(w, err)
+ return
+ }
+ jsonReply(w, 200, value)
+}
diff --git a/internal/localadmin/backup_cleanup_test.go b/internal/localadmin/backup_cleanup_test.go
new file mode 100644
index 0000000000..40b3bedeba
--- /dev/null
+++ b/internal/localadmin/backup_cleanup_test.go
@@ -0,0 +1,305 @@
+package localadmin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func agedBackup(t *testing.T, s *BackupStore, scope string, age int) string {
+ t.Helper()
+ id, err := s.Create(scope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(s.directory, id)
+ if scope == "control-db" {
+ path = filepath.Join(path, "manifest.json")
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ createdAt := s.now().AddDate(0, 0, -age).UTC()
+ if scope == "control-db" {
+ var value controlManifest
+ if json.Unmarshal(raw, &value) != nil {
+ t.Fatal("bad fixture")
+ }
+ value.CreatedAt = createdAt
+ raw, _ = json.Marshal(value)
+ } else {
+ // Preserve RawMessage and large integers so aging metadata cannot
+ // invalidate the checksum of the actual backed-up configuration.
+ var value fileBackup
+ if json.Unmarshal(raw, &value) != nil {
+ t.Fatal("bad fixture")
+ }
+ value.CreatedAt = createdAt
+ raw, _ = json.Marshal(value)
+ }
+ if os.WriteFile(path, raw, 0600) != nil {
+ t.Fatal("fixture write failed")
+ }
+ return id
+}
+
+func cleanupRequest(v CleanupPreview) CleanupRequest {
+ return CleanupRequest{Policy: v.Policy, Revision: v.Revision, AsOf: v.AsOf, ExpiresAt: v.ExpiresAt, Token: v.Token, Confirm: true}
+}
+
+func TestCleanupRetentionScopesIntegrityAndLiveState(t *testing.T) {
+ s, c := backupFixture(t)
+ s.now = func() time.Time { return time.Date(2026, 10, 3, 4, 0, 0, 0, time.UTC) }
+ protected := map[string]bool{}
+ for _, scope := range []string{"startup-config", "control-db", "admin-options"} {
+ protected[agedBackup(t, s, scope, 0)] = true
+ for i := 1; i <= 6; i++ {
+ id := agedBackup(t, s, scope, i*10)
+ if i <= 2 {
+ protected[id] = true
+ }
+ }
+ }
+ invalid := agedBackup(t, s, "startup-config", 90)
+ if os.WriteFile(filepath.Join(s.directory, invalid), []byte("invalid"), 0600) != nil {
+ t.Fatal("fixture")
+ }
+ legacy := agedBackup(t, s, "startup-config", 90)
+ path := filepath.Join(s.directory, legacy)
+ raw, _ := os.ReadFile(path)
+ var legacyFile fileBackup
+ _ = json.Unmarshal(raw, &legacyFile)
+ legacyFile.SHA256 = ""
+ raw, _ = json.Marshal(legacyFile)
+ _ = os.WriteFile(path, raw, 0600)
+ extra := agedBackup(t, s, "control-db", 90)
+ if os.WriteFile(filepath.Join(s.directory, extra, "unrelated.txt"), []byte("keep"), 0600) != nil {
+ t.Fatal("extra fixture")
+ }
+ startup, _ := os.ReadFile(s.connections.path)
+ options, _ := os.ReadFile(s.optionsPath)
+ dataBefore := c.GetData(101)
+ v, err := s.PreviewCleanup(context.Background(), CleanupPolicy{3, 7}, "session")
+ if err != nil || len(v.Candidates) != 12 || v.Skipped != 3 || v.Bytes <= 0 {
+ t.Fatal(v, err)
+ }
+ for _, entry := range v.Candidates {
+ if protected[entry.ID] {
+ t.Fatal("protected backup selected")
+ }
+ }
+ if s.cleanupTicket(cleanupRequest(v), "session") != v.Token {
+ t.Fatal("ticket changed without mutation")
+ }
+ check, _, checkErr := s.cleanupPlan(context.Background(), v.Policy, v.AsOf)
+ if checkErr != nil || check.Revision != v.Revision {
+ t.Fatalf("inventory unstable: before=%s after=%s candidates=%d skipped=%d error=%v", v.Revision, check.Revision, len(check.Candidates), check.Skipped, checkErr)
+ }
+ result, err := s.Cleanup(context.Background(), cleanupRequest(v), "session")
+ if err != nil || result.Partial || len(result.Deleted) != 12 || result.Bytes != v.Bytes {
+ t.Fatal(result, err)
+ }
+ for id := range protected {
+ if _, err := os.Stat(filepath.Join(s.directory, id)); err != nil {
+ t.Fatal("protected backup lost", err)
+ }
+ }
+ for _, id := range []string{invalid, legacy, extra} {
+ if _, err := os.Stat(filepath.Join(s.directory, id)); err != nil {
+ t.Fatal("unverified backup removed")
+ }
+ }
+ nowStartup, _ := os.ReadFile(s.connections.path)
+ nowOptions, _ := os.ReadFile(s.optionsPath)
+ if !bytes.Equal(startup, nowStartup) || !bytes.Equal(options, nowOptions) || c.GetData(101) != dataBefore {
+ t.Fatal("live state changed")
+ }
+ if _, err = s.Cleanup(context.Background(), cleanupRequest(v), "session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("replayed deletion accepted", err)
+ }
+}
+
+func TestCleanupTicketsInventoryAndCancellation(t *testing.T) {
+ s, _ := backupFixture(t)
+ for i := 0; i < 5; i++ {
+ agedBackup(t, s, "startup-config", 30+i)
+ }
+ v, err := s.PreviewCleanup(context.Background(), CleanupPolicy{3, 7}, "session")
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := cleanupRequest(v)
+ req.Confirm = false
+ if _, err = s.Cleanup(context.Background(), req, "session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("unconfirmed request accepted")
+ }
+ req.Confirm = true
+ if _, err = s.Cleanup(context.Background(), req, "other-session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("other session accepted")
+ }
+ req.Policy.KeepDays = 8
+ if _, err = s.Cleanup(context.Background(), req, "session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("modified policy accepted")
+ }
+ req = cleanupRequest(v)
+ req.Token = strings.Repeat("0", 64)
+ if _, err = s.Cleanup(context.Background(), req, "session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("invalid token accepted")
+ }
+ req = cleanupRequest(v)
+ oldNow := s.now
+ s.now = func() time.Time { return oldNow().Add(11 * time.Minute) }
+ if _, err = s.Cleanup(context.Background(), req, "session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("expired preview accepted")
+ }
+ s.now = oldNow
+ if _, err = s.Create("admin-options"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = s.Cleanup(context.Background(), req, "session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("changed inventory accepted")
+ }
+ v, err = s.PreviewCleanup(context.Background(), CleanupPolicy{3, 7}, "session")
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(s.directory, v.Candidates[0].ID)
+ raw, _ := os.ReadFile(path)
+ var file fileBackup
+ _ = json.Unmarshal(raw, &file)
+ file.Config = []byte(strings.ReplaceAll(string(file.Config), "secret-A", "changed-synthetic-key"))
+ file.SHA256 = digestBytes(file.Config)
+ raw, _ = json.Marshal(file)
+ _ = os.WriteFile(path, raw, 0600)
+ if _, err = s.Cleanup(context.Background(), cleanupRequest(v), "session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("changed backup accepted")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err = s.PreviewCleanup(ctx, CleanupPolicy{3, 7}, "session"); err == nil {
+ t.Fatal("canceled scan accepted")
+ }
+ for _, policy := range []CleanupPolicy{{2, 7}, {201, 7}, {3, 0}, {3, 3651}} {
+ if _, err = s.PreviewCleanup(context.Background(), policy, "session"); err == nil {
+ t.Fatal("invalid policy")
+ }
+ }
+ if _, _, err = s.cleanupFiles("../outside.json"); err == nil {
+ t.Fatal("traversal accepted")
+ }
+}
+
+func TestCleanupPartialDeletionIsVisible(t *testing.T) {
+ s, _ := backupFixture(t)
+ for i := 0; i < 5; i++ {
+ agedBackup(t, s, "startup-config", 30+i)
+ }
+ v, err := s.PreviewCleanup(context.Background(), CleanupPolicy{3, 7}, "session")
+ if err != nil {
+ t.Fatal(err)
+ }
+ calls := 0
+ s.cleanupRemove = func(root *os.Root, name string) error {
+ calls++
+ if calls == 2 {
+ return errors.New("sensitive filesystem detail")
+ }
+ return root.Remove(name)
+ }
+ result, err := s.Cleanup(context.Background(), cleanupRequest(v), "session")
+ if err != nil || !result.Partial || len(result.Deleted) != 1 || strings.Contains(result.Message, "sensitive") {
+ t.Fatal(result, err)
+ }
+ if _, err = os.Stat(filepath.Join(s.directory, v.Candidates[0].ID)); !os.IsNotExist(err) {
+ t.Fatal("first deletion not reflected")
+ }
+ if _, err = os.Stat(filepath.Join(s.directory, v.Candidates[1].ID)); err != nil {
+ t.Fatal("failed target lost")
+ }
+}
+
+func TestCleanupBatchAndControlResidualProtection(t *testing.T) {
+ s, _ := backupFixture(t)
+ for i := 0; i < 105; i++ {
+ agedBackup(t, s, "admin-options", 30+i)
+ }
+ v, err := s.PreviewCleanup(context.Background(), CleanupPolicy{3, 7}, "session")
+ if err != nil || len(v.Candidates) != 100 || !v.More {
+ t.Fatal(v, err)
+ }
+ result, err := s.Cleanup(context.Background(), cleanupRequest(v), "session")
+ if err != nil || result.Partial || len(result.Deleted) != 100 {
+ t.Fatal(result, err)
+ }
+ v, err = s.PreviewCleanup(context.Background(), CleanupPolicy{3, 7}, "session")
+ if err != nil || len(v.Candidates) != 2 || v.More {
+ t.Fatal(v, err)
+ }
+ for i := 0; i < 4; i++ {
+ agedBackup(t, s, "control-db", 30+i)
+ }
+ // A failed second file removal must not be reported as a complete deletion.
+ s.cleanupRemove = func(root *os.Root, name string) error {
+ if strings.HasSuffix(name, "manifest.json") {
+ return errors.New("injected")
+ }
+ return root.Remove(name)
+ }
+ // The control fixture is newer than remaining eligible file backups, so
+ // the partial control failure occurs before any complete deletion.
+ controlID := agedBackup(t, s, "control-db", 120)
+ v, err = s.PreviewCleanup(context.Background(), CleanupPolicy{3, 90}, "session")
+ if err != nil {
+ t.Fatal(err)
+ }
+ result, err = s.Cleanup(context.Background(), cleanupRequest(v), "session")
+ if err != nil || !result.Partial || len(result.Deleted) != 0 {
+ t.Fatal(result, err)
+ }
+ if _, _, err = s.load(controlID); err == nil {
+ t.Fatal("incomplete control backup remains valid")
+ }
+ v, err = s.PreviewCleanup(context.Background(), CleanupPolicy{3, 90}, "session")
+ if err != nil || v.Skipped != 1 {
+ t.Fatal(v, err)
+ }
+}
+
+func TestCleanupAPIAuthAndSessionBinding(t *testing.T) {
+ store, _ := backupFixture(t)
+ s, err := New(Config{Addr: "127.0.0.1:3010", DataDir: t.TempDir(), Overview: func() Overview { return Overview{} }, Backups: store})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/api/backups/cleanup-preview", "/api/backups/cleanup"} {
+ expectStatus(t, request(s, "POST", path, map[string]any{}, nil, ""), http.StatusUnauthorized)
+ }
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/backups/cleanup-preview", CleanupPolicy{3, 7}, cookie, "bad"), http.StatusForbidden)
+ for i := 0; i < 4; i++ {
+ agedBackup(t, store, "admin-options", 30+i)
+ }
+ w := request(s, "POST", "/api/backups/cleanup-preview", CleanupPolicy{3, 7}, cookie, csrf)
+ expectStatus(t, w, 200)
+ var preview CleanupPreview
+ if json.Unmarshal(w.Body.Bytes(), &preview) != nil || len(preview.Candidates) != 1 {
+ t.Fatal(w.Body.String())
+ }
+ if strings.Contains(w.Body.String(), "secret-A") || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("preview privacy")
+ }
+ req := cleanupRequest(preview)
+ req.Confirm = false
+ expectStatus(t, request(s, "POST", "/api/backups/cleanup", req, cookie, csrf), 400)
+ req.Confirm = true
+ expectStatus(t, request(s, "POST", "/api/backups/cleanup", req, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/backups/cleanup", req, cookie, csrf), 409)
+}
diff --git a/internal/localadmin/backup_impact.go b/internal/localadmin/backup_impact.go
new file mode 100644
index 0000000000..f4027bc8cf
--- /dev/null
+++ b/internal/localadmin/backup_impact.go
@@ -0,0 +1,137 @@
+package localadmin
+
+import "encoding/json"
+
+// Fixed labels and aggregate counts only: no config values, unknown keys,
+// database names, record identifiers, credential hashes or rows leave the API.
+type RestoreImpact struct {
+ Label string `json:"label"`
+ Changed bool `json:"changed"`
+ CurrentCount *int `json:"currentCount,omitempty"`
+ BackupCount *int `json:"backupCount,omitempty"`
+ Unit string `json:"unit,omitempty"`
+}
+
+func impactComparison(label, unit string, current, backup []byte, currentCount, backupCount int) RestoreImpact {
+ v := RestoreImpact{Label: label, Changed: !equivalentConfig(current, backup), Unit: unit}
+ if currentCount >= 0 && backupCount >= 0 {
+ v.CurrentCount, v.BackupCount = ¤tCount, &backupCount
+ }
+ return v
+}
+
+func takeImpactField(obj jsonObject, name string) (json.RawMessage, error) {
+ key, raw, err := field(obj, name)
+ if err == nil && raw != nil {
+ delete(obj, key)
+ }
+ return raw, err
+}
+
+func arrayImpactCount(raw json.RawMessage) int {
+ var items []json.RawMessage
+ _ = json.Unmarshal(raw, &items) // Inputs have already passed decodeConfig.
+ return len(items)
+}
+
+func fileRestoreImpacts(scope string, current, backup []byte) ([]RestoreImpact, error) {
+ if scope == "admin-options" {
+ a, err := object(current)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ b, err := object(backup)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ return []RestoreImpact{
+ impactComparison("后台启用状态", "", a["enabled"], b["enabled"], -1, -1),
+ impactComparison("后台访问地址", "", a["address"], b["address"], -1, -1),
+ }, nil
+ }
+ if scope != "startup-config" {
+ return nil, errBackupInvalid
+ }
+ a, err := decodeConfig(current)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ b, err := decodeConfig(backup)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ impacts := make([]RestoreImpact, 0, 6)
+ for _, item := range []struct{ key, label string }{{"ws", "正向 OneBot 连接"}, {"wss", "反向 OneBot 连接"}} {
+ x, err := takeImpactField(a, item.key)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ y, err := takeImpactField(b, item.key)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ impacts = append(impacts, impactComparison(item.label, "条连接", x, y, arrayImpactCount(x), arrayImpactCount(y)))
+ }
+ x, err := takeImpactField(a, "zero")
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ y, err := takeImpactField(b, "zero")
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ az, err := object(x)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ bz, err := object(y)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ x, err = takeImpactField(az, "super_users")
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ y, err = takeImpactField(bz, "super_users")
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ impacts = append(impacts, impactComparison("机器人主人", "个账号", x, y, arrayImpactCount(x), arrayImpactCount(y)))
+ x, _ = json.Marshal(az)
+ y, _ = json.Marshal(bz)
+ impacts = append(impacts, impactComparison("其他机器人参数", "项设置", x, y, len(az), len(bz)))
+ x, _ = json.Marshal(a)
+ y, _ = json.Marshal(b)
+ impacts = append(impacts, impactComparison("其他启动设置", "项设置", x, y, len(a), len(b)))
+ changed := false
+ for _, item := range impacts {
+ changed = changed || item.Changed
+ }
+ if !changed && !equivalentConfig(current, backup) {
+ // Case aliases / absent versus null still follow the existing full-JSON comparison.
+ impacts = append(impacts, RestoreImpact{Label: "字段名称或表示差异", Changed: true})
+ }
+ return impacts, nil
+}
+
+func databaseImpactBucket(name string) int {
+ switch name {
+ case "__resp":
+ return 0
+ case "__block":
+ return 1
+ case "__switch_inherit":
+ return 2
+ default:
+ return 3
+ }
+}
+
+func newDatabaseImpacts() []RestoreImpact {
+ labels := []string{"响应规则与AI扩展", "全局用户封禁", "插件开关继承标记", "插件与其他库内数据"}
+ items := make([]RestoreImpact, len(labels))
+ for i, label := range labels {
+ items[i] = RestoreImpact{Label: label, Unit: "条记录", CurrentCount: new(int), BackupCount: new(int)}
+ }
+ return items
+}
diff --git a/internal/localadmin/backup_impact_test.go b/internal/localadmin/backup_impact_test.go
new file mode 100644
index 0000000000..30b22cc182
--- /dev/null
+++ b/internal/localadmin/backup_impact_test.go
@@ -0,0 +1,249 @@
+package localadmin
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+)
+
+func impactNamed(t *testing.T, items []RestoreImpact, label string) RestoreImpact {
+ t.Helper()
+ for _, item := range items {
+ if item.Label == label {
+ return item
+ }
+ }
+ t.Fatalf("missing impact: %s", label)
+ return RestoreImpact{}
+}
+func checkImpactCounts(t *testing.T, item RestoreImpact, current, backup int) {
+ t.Helper()
+ if item.CurrentCount == nil || item.BackupCount == nil || *item.CurrentCount != current || *item.BackupCount != backup {
+ t.Fatal("impact counts wrong")
+ }
+}
+func TestStartupRestoreImpactsUseSameSnapshotHideValuesAndDoNotWrite(t *testing.T) {
+ s, _ := backupFixture(t)
+ id, err := s.Create("startup-config")
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, _ := decodeConfig([]byte(connectionFixture))
+ var connections []jsonObject
+ _ = json.Unmarshal(root["ws"], &connections)
+ connections[0]["AccessToken"] = json.RawMessage(`"new-private-key"`)
+ root["ws"], _ = json.Marshal(connections)
+ root["wss"] = json.RawMessage(`[]`)
+ zero, _ := object(root["zero"])
+ zero["super_users"] = json.RawMessage(`[87654321]`)
+ zero["future"] = json.RawMessage(`{"n":9007199254740992}`)
+ root["zero"], _ = json.Marshal(zero)
+ root["private-name-key"] = json.RawMessage(`"private-name-value"`)
+ current, _ := json.Marshal(root)
+ if err = os.WriteFile(s.connections.path, current, 0600); err != nil {
+ t.Fatal(err)
+ }
+ before, _, _ := s.List()
+ v, err := s.Preview(context.Background(), id, "fixture-subject")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !v.Changed || len(v.Impacts) != 5 {
+ t.Fatal("startup impact incomplete")
+ }
+ for _, item := range v.Impacts {
+ if !item.Changed {
+ t.Fatal("changed category marked equal")
+ }
+ }
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "正向 OneBot 连接"), 2, 2)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "反向 OneBot 连接"), 0, 1)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "机器人主人"), 1, 1)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "其他机器人参数"), 2, 2)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "其他启动设置"), 3, 2)
+ raw, _ := json.Marshal(v)
+ for _, secret := range []string{"secret-A", "secret-B", "secret-C", "new-private-key", "87654321", "private-name-key", "private-name-value", "9007199254740992"} {
+ if bytes.Contains(raw, []byte(secret)) {
+ t.Fatal("impact preview leaked a value or unknown field name")
+ }
+ }
+ after, _, _ := s.List()
+ actual, _ := os.ReadFile(s.connections.path)
+ if len(after) != len(before) || !bytes.Equal(actual, current) || !bytes.Equal(s.connections.running, []byte(connectionFixture)) {
+ t.Fatal("preview wrote config, backup or running state")
+ }
+ request := RestoreRequest{ID: id, Revision: v.Revision, ExpiresAt: v.ExpiresAt, Token: v.Token, Confirm: true}
+ if err = os.WriteFile(s.connections.path, []byte(connectionFixture), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = s.Restore(context.Background(), request, "fixture-subject"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("impact preview bypassed stale snapshot protection")
+ }
+}
+func TestFileRestoreImpactsSemanticEqualityAliasesMissingAndLargeNumbers(t *testing.T) {
+ cases := []struct {
+ a, b string
+ changed, fallback bool
+ }{
+ {`{"zero":{},"ws":[],"wss":[]}`, "{\n \"wss\": [], \"ws\": [], \"zero\": {} }", false, false},
+ {`{"zero":{}}`, `{"ZERO":{}}`, true, true},
+ {`{"zero":{}}`, `{"zero":{"super_users":null}}`, true, true},
+ {`{"zero":{},"ws":[]}`, `{"zero":{}}`, true, false},
+ {`{"zero":{},"unknown":9007199254740992}`, `{"zero":{},"unknown":9007199254740993}`, true, false},
+ {`{"zero":{"super_users":[1,2]}}`, `{"zero":{"super_users":[2,1]}}`, true, false},
+ }
+ for _, tc := range cases {
+ items, err := fileRestoreImpacts("startup-config", []byte(tc.a), []byte(tc.b))
+ if err != nil {
+ t.Fatal(err)
+ }
+ changed, fallback := false, false
+ for _, item := range items {
+ changed = changed || item.Changed
+ fallback = fallback || item.Label == "字段名称或表示差异"
+ }
+ if changed != tc.changed || fallback != tc.fallback {
+ t.Fatal("semantic or representation comparison wrong")
+ }
+ }
+ if _, err := fileRestoreImpacts("startup-config", []byte(`{"zero":{},"ws":[],"WS":[]}`), []byte(connectionFixture)); err == nil {
+ t.Fatal("ambiguous aliases accepted")
+ }
+ if _, err := fileRestoreImpacts("unknown", nil, nil); err == nil {
+ t.Fatal("unsupported scope accepted")
+ }
+ items, err := fileRestoreImpacts("admin-options", []byte(`{"enabled":true,"address":"127.0.0.1:3010"}`), []byte(`{"address":"127.0.0.1:3111","enabled":false}`))
+ if err != nil || len(items) != 2 {
+ t.Fatal("options impact wrong", err)
+ }
+ for _, item := range items {
+ if !item.Changed || item.CurrentCount != nil || item.BackupCount != nil {
+ t.Fatal("options exposed value/count")
+ }
+ }
+}
+func TestControlRestoreImpactsAggregateRowsAndHideTableNames(t *testing.T) {
+ s, c := backupFixture(t)
+ ctx := context.Background()
+ if err := c.SetData(101, 123); err != nil {
+ t.Fatal(err)
+ }
+ exec := func(statements ...string) {
+ t.Helper()
+ c.Manager.Lock()
+ defer c.Manager.Unlock()
+ if err := c.Manager.D.TransactionContext(ctx, func(tx *sql.Tx) error {
+ for _, statement := range statements {
+ if _, err := tx.ExecContext(ctx, statement); err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := c.SetSwitch(101, "enabled", ""); err != nil {
+ t.Fatal(err)
+ }
+ exec(`CREATE TABLE "private-secret-table" (id INTEGER PRIMARY KEY, value TEXT)`, `INSERT INTO "private-secret-table" VALUES(1,'old-private-value')`)
+ id, err := s.Create("control-db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.SetData(101, 987); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Manager.Response(101); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Manager.DoBlock(87654321); err != nil {
+ t.Fatal(err)
+ }
+ state := switchState(t, c, 101)
+ if _, err = c.SetSwitch(101, "inherit", state.Revision); err != nil {
+ t.Fatal(err)
+ }
+ exec(`UPDATE "private-secret-table" SET value='new-private-value'`, `UPDATE __resp SET ext='{"Key":"private-model-key","SystemP":"private-persona"}' WHERE gid=101`)
+ v, err := s.Preview(ctx, id, "fixture-subject")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !v.Changed || len(v.Impacts) != 4 {
+ t.Fatal("control impacts incomplete")
+ }
+ sum := 0
+ for _, item := range v.Impacts {
+ if !item.Changed {
+ t.Fatal("control category missed changes", item.Label)
+ }
+ sum += *item.BackupCount
+ }
+ if sum != v.Rows {
+ t.Fatal("aggregated backup count lost records")
+ }
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "响应规则与AI扩展"), 1, 0)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "全局用户封禁"), 1, 0)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "插件开关继承标记"), 1, 0)
+ checkImpactCounts(t, impactNamed(t, v.Impacts, "插件与其他库内数据"), 2, 2)
+ raw, _ := json.Marshal(v)
+ for _, secret := range []string{"private-secret-table", "old-private-value", "new-private-value", "private-model-key", "private-persona", "87654321", "__resp", "test-plugin"} {
+ if strings.Contains(string(raw), secret) {
+ t.Fatal("control impact exposed private record or table name")
+ }
+ }
+ sameID, err := s.Create("control-db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ same, err := s.Preview(ctx, sameID, "fixture-subject")
+ if err != nil || same.Changed {
+ t.Fatal("equal database changed", err)
+ }
+ for _, item := range same.Impacts {
+ if item.Changed || *item.CurrentCount != *item.BackupCount {
+ t.Fatal("equal database impact not equal")
+ }
+ }
+}
+func TestRestoreImpactAPIRequiresAuthAndKeepsReadonlyOptions(t *testing.T) {
+ store, _ := backupFixture(t)
+ id, err := store.Create("admin-options")
+ if err != nil {
+ t.Fatal(err)
+ }
+ current := []byte(`{"enabled":false,"address":"127.0.0.1:3111"}`)
+ if err = os.WriteFile(store.optionsPath, current, 0600); err != nil {
+ t.Fatal(err)
+ }
+ s, err := New(Config{Addr: "127.0.0.1:3010", DataDir: t.TempDir(), Overview: func() Overview { return Overview{} }, Backups: store})
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := map[string]string{"id": id}
+ expectStatus(t, request(s, "POST", "/api/backups/preview", body, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/backups/preview", body, cookie, "wrong-csrf"), 403)
+ w := request(s, "POST", "/api/backups/preview", body, cookie, csrf)
+ expectStatus(t, w, 200)
+ var v RestorePreview
+ if err = json.Unmarshal(w.Body.Bytes(), &v); err != nil {
+ t.Fatal(err)
+ }
+ if len(v.Impacts) != 2 || !v.Changed || !v.RestartRequired {
+ t.Fatal("API omitted options impacts")
+ }
+ if strings.Contains(w.Body.String(), "127.0.0.1:3111") {
+ t.Fatal("API exposed options value")
+ }
+ actual, _ := os.ReadFile(store.optionsPath)
+ if !bytes.Equal(actual, current) {
+ t.Fatal("preview changed current options")
+ }
+ expectStatus(t, request(s, "POST", "/api/backups/preview", map[string]string{"id": "../credential.json"}, cookie, csrf), 400)
+}
diff --git a/internal/localadmin/backups.go b/internal/localadmin/backups.go
new file mode 100644
index 0000000000..7a73d96ec8
--- /dev/null
+++ b/internal/localadmin/backups.go
@@ -0,0 +1,862 @@
+package localadmin
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ db "github.com/FloatTech/sqlite"
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+const maxBackupBytes = 32 << 20
+
+var backupIDPattern = regexp.MustCompile(`^[0-9]{8}T[0-9]{6}\.[0-9]{9}Z-[0-9a-f]{16}(\.json|-control)$`)
+var errBackupInvalid = errors.New("备份损坏、格式不支持或与当前程序不兼容")
+var errBackupConflict = errors.New("当前配置或备份已变化,请重新预览后确认")
+
+type BackupStore struct {
+ mu sync.Mutex
+ directory, optionsPath string
+ connections *ConnectionStore
+ manager *ctrl.Manager[*zero.Ctx]
+ logs *LogStore
+ key []byte
+ now func() time.Time
+ // Failure injection runs inside the database transaction, before commit.
+ afterTable func(int) error
+ replace func(string, string) error
+ cleanupRemove func(*os.Root, string) error
+}
+
+func NewBackupStore(directory string, connections *ConnectionStore, plugins *PluginStore, logs *LogStore) *BackupStore {
+ directory, _ = filepath.Abs(directory)
+ s := &BackupStore{directory: directory, optionsPath: filepath.Join(filepath.Dir(directory), "options.json"), connections: connections, logs: logs, key: randomBytes(32), now: time.Now, replace: replaceConfigFile}
+ if plugins != nil {
+ for _, c := range plugins.controls {
+ if s.manager != nil && s.manager != c.Manager { // Do not silently omit a second database.
+ s.manager = nil
+ break
+ }
+ s.manager = c.Manager
+ }
+ }
+ return s
+}
+
+type BackupEntry struct {
+ ID string `json:"id"`
+ CreatedAt time.Time `json:"createdAt"`
+ Version int `json:"version"`
+ Scope string `json:"scope"`
+ Size int64 `json:"size"`
+ Integrity bool `json:"integrity"`
+ Valid bool `json:"valid"`
+}
+
+type fileBackup struct {
+ Version int `json:"version"`
+ CreatedAt time.Time `json:"createdAt"`
+ Scope string `json:"scope"`
+ Config json.RawMessage `json:"config"`
+ SHA256 string `json:"sha256,omitempty"`
+}
+
+type controlManifest struct {
+ Version int `json:"version"`
+ CreatedAt time.Time `json:"createdAt"`
+ Scope string `json:"scope"`
+ File string `json:"file"`
+ SHA256 string `json:"sha256"`
+}
+
+func digestBytes(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
+
+func safeDirectory(path string) error {
+ // Reject linked ancestors too, including Windows junctions exposed as links.
+ for p := filepath.Clean(path); ; p = filepath.Dir(p) {
+ info, err := os.Lstat(p)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return errBackupInvalid
+ }
+ if filepath.Dir(p) == p {
+ return nil
+ }
+ }
+}
+
+func readBoundedFile(path string, limit int64) ([]byte, error) {
+ if err := safeDirectory(filepath.Dir(path)); err != nil {
+ return nil, err
+ }
+ if err := regularFile(path); err != nil {
+ return nil, errBackupInvalid
+ }
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ defer f.Close()
+ raw, err := io.ReadAll(io.LimitReader(f, limit+1))
+ if err != nil || int64(len(raw)) > limit {
+ return nil, errBackupInvalid
+ }
+ return raw, nil
+}
+
+func validateOptions(raw []byte) error {
+ obj, err := object(raw)
+ if err != nil || len(obj) != 2 {
+ return errBackupInvalid
+ }
+ var enabled bool
+ var address string
+ if bytes.Equal(bytes.TrimSpace(obj["enabled"]), []byte("null")) || json.Unmarshal(obj["enabled"], &enabled) != nil || json.Unmarshal(obj["address"], &address) != nil {
+ return errBackupInvalid
+ }
+ host, port, err := net.SplitHostPort(address)
+ n, parseErr := strconv.Atoi(port)
+ if err != nil || parseErr != nil || host != "127.0.0.1" || n < 1 || n > 65535 || strconv.Itoa(n) != port {
+ return errBackupInvalid
+ }
+ return nil
+}
+
+func validateFileConfig(scope string, raw []byte) error {
+ if len(raw) > 1<<20 {
+ return errBackupInvalid
+ }
+ switch scope {
+ case "startup-config":
+ if _, err := decodeConfig(raw); err != nil {
+ return errBackupInvalid
+ }
+ case "admin-options":
+ return validateOptions(raw)
+ default:
+ return errBackupInvalid
+ }
+ return nil
+}
+
+func (s *BackupStore) ensureDirectory() error {
+ if err := os.MkdirAll(s.directory, 0700); err != nil {
+ return errors.New("备份目录不可写")
+ }
+ if err := safeDirectory(s.directory); err != nil {
+ return err
+ }
+ if err := protectPath(s.directory, true); err != nil {
+ return errors.New("备份目录权限设置失败")
+ }
+ return nil
+}
+
+func writeFileBackup(directory, scope string, raw []byte) (string, error) {
+ id := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + hex.EncodeToString(randomBytes(8)) + ".json"
+ // Use the exact compact/HTML-escaped form the outer JSON encoder will store.
+ canonical, err := json.Marshal(json.RawMessage(raw))
+ if err != nil || validateFileConfig(scope, canonical) != nil {
+ return "", errBackupInvalid
+ }
+ entry := fileBackup{1, time.Now().UTC(), scope, canonical, digestBytes(canonical)}
+ b, err := json.Marshal(entry)
+ if err != nil {
+ return "", err
+ }
+ if err = writeNewPrivateFile(filepath.Join(directory, id), b); err != nil {
+ return "", errors.New("配置备份写入失败,原配置保留")
+ }
+ return id, nil
+}
+
+func (s *BackupStore) load(id string) (BackupEntry, []byte, error) {
+ entry := BackupEntry{ID: id}
+ if !backupIDPattern.MatchString(id) {
+ return entry, nil, errBackupInvalid
+ }
+ if strings.HasSuffix(id, ".json") {
+ b, err := readBoundedFile(filepath.Join(s.directory, id), (1<<20)+4096)
+ if err != nil {
+ return entry, nil, err
+ }
+ var file fileBackup
+ if json.Unmarshal(b, &file) != nil || file.Version != 1 || file.CreatedAt.IsZero() || validateFileConfig(file.Scope, file.Config) != nil {
+ return entry, nil, errBackupInvalid
+ }
+ if file.SHA256 != "" && file.SHA256 != digestBytes(file.Config) {
+ return entry, nil, errBackupInvalid
+ }
+ entry.CreatedAt, entry.Version, entry.Scope, entry.Size, entry.Integrity, entry.Valid = file.CreatedAt, file.Version, file.Scope, int64(len(file.Config)), file.SHA256 != "", true
+ return entry, file.Config, nil
+ }
+ root := filepath.Join(s.directory, id)
+ b, err := readBoundedFile(filepath.Join(root, "manifest.json"), 4096)
+ if err != nil {
+ return entry, nil, err
+ }
+ var manifest controlManifest
+ if json.Unmarshal(b, &manifest) != nil || manifest.Version != 1 || manifest.Scope != "control-db" || manifest.File != "plugins.sqlite" || manifest.CreatedAt.IsZero() || !validRevision(manifest.SHA256) {
+ return entry, nil, errBackupInvalid
+ }
+ raw, err := readBoundedFile(filepath.Join(root, "plugins.sqlite"), maxBackupBytes)
+ if err != nil || manifest.SHA256 != digestBytes(raw) {
+ return entry, nil, errBackupInvalid
+ }
+ entry.CreatedAt, entry.Version, entry.Scope, entry.Size, entry.Integrity, entry.Valid = manifest.CreatedAt, 1, manifest.Scope, int64(len(raw)), true, true
+ return entry, raw, nil
+}
+
+func (s *BackupStore) List() ([]BackupEntry, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ entries := []BackupEntry{}
+ if _, err := os.Lstat(s.directory); os.IsNotExist(err) {
+ return entries, false, nil
+ }
+ if err := safeDirectory(s.directory); err != nil {
+ return nil, false, err
+ }
+ f, err := os.Open(s.directory)
+ if err != nil {
+ return nil, false, errBackupInvalid
+ }
+ defer f.Close()
+ ids := []string{}
+ for count := 0; ; {
+ part, err := f.ReadDir(128)
+ count += len(part)
+ if count > 4096 {
+ return nil, false, errors.New("备份目录超过 4096 项,请先在本机整理旧备份")
+ }
+ for _, item := range part {
+ if backupIDPattern.MatchString(item.Name()) {
+ ids = append(ids, item.Name())
+ }
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, false, errBackupInvalid
+ }
+ }
+ sort.Sort(sort.Reverse(sort.StringSlice(ids)))
+ more := len(ids) > 200
+ if more {
+ ids = ids[:200]
+ }
+ for _, id := range ids {
+ entry, _, err := s.load(id)
+ if err != nil {
+ entry = BackupEntry{ID: id}
+ }
+ entries = append(entries, entry)
+ }
+ return entries, more, nil
+}
+
+func (s *BackupStore) Create(scope string) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.ensureDirectory(); err != nil {
+ return "", err
+ }
+ switch scope {
+ case "control-db":
+ if s.manager == nil {
+ return "", errBackupInvalid
+ }
+ s.manager.Lock()
+ defer s.manager.Unlock()
+ return BackupControlDatabase(&s.manager.D, s.directory)
+ case "startup-config":
+ if s.connections == nil {
+ return "", errBackupInvalid
+ }
+ s.connections.mu.Lock()
+ defer s.connections.mu.Unlock()
+ raw, err := s.connections.read()
+ if err != nil {
+ return "", errBackupInvalid
+ }
+ return writeFileBackup(s.directory, scope, raw)
+ case "admin-options":
+ raw, err := readBoundedFile(s.optionsPath, 4096)
+ if err != nil || validateOptions(raw) != nil {
+ return "", errBackupInvalid
+ }
+ return writeFileBackup(s.directory, scope, raw)
+ default:
+ return "", errBackupInvalid
+ }
+}
+
+// All source SQL is inspected, never executed. Existing live schema must match.
+type schemaItem struct{ Type, Name, Table, SQL string }
+type tableCopy struct {
+ Name string
+ Columns []string
+ Rows [][]any
+}
+type databaseCopy struct {
+ Schema []schemaItem
+ Tables []tableCopy
+}
+
+func quoteName(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` }
+
+func readDatabase(ctx context.Context, tx *sql.Tx) (*databaseCopy, error) {
+ var check string
+ if tx.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&check) != nil || check != "ok" {
+ return nil, errBackupInvalid
+ }
+ rows, err := tx.QueryContext(ctx, "SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_master WHERE name NOT GLOB 'sqlite_*' ORDER BY type,name")
+ if err != nil {
+ return nil, err
+ }
+ copy := &databaseCopy{}
+ for rows.Next() {
+ var item schemaItem
+ if err = rows.Scan(&item.Type, &item.Name, &item.Table, &item.SQL); err != nil {
+ break
+ }
+ if item.Type != "table" && item.Type != "index" {
+ err = errBackupInvalid
+ break
+ }
+ copy.Schema = append(copy.Schema, item)
+ if len(copy.Schema) > 512 {
+ err = errBackupInvalid
+ break
+ }
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ count, memory := 0, 0
+ for _, item := range copy.Schema {
+ if item.Type != "table" {
+ continue
+ }
+ rows, err := tx.QueryContext(ctx, "SELECT * FROM "+quoteName(item.Name)+" ORDER BY 1")
+ if err != nil {
+ return nil, err
+ }
+ columns, err := rows.Columns()
+ if err != nil || len(columns) == 0 || len(columns) > 32 {
+ rows.Close()
+ return nil, errBackupInvalid
+ }
+ table := tableCopy{Name: item.Name, Columns: columns, Rows: [][]any{}}
+ for rows.Next() {
+ values := make([]any, len(columns))
+ destinations := make([]any, len(columns))
+ for i := range values {
+ destinations[i] = &values[i]
+ }
+ if err = rows.Scan(destinations...); err != nil {
+ break
+ }
+ for i, value := range values {
+ switch v := value.(type) {
+ case []byte:
+ values[i] = bytes.Clone(v)
+ memory += len(v)
+ case string:
+ memory += len(v)
+ default:
+ memory += 16
+ }
+ }
+ count++
+ memory += len(columns) * 24
+ if count > 100000 || memory > 64<<20 {
+ err = errBackupInvalid
+ break
+ }
+ table.Rows = append(table.Rows, values)
+ }
+ if err == nil {
+ err = rows.Err()
+ }
+ rows.Close()
+ if err != nil {
+ return nil, err
+ }
+ copy.Tables = append(copy.Tables, table)
+ }
+ return copy, nil
+}
+
+func (s *BackupStore) sourceDatabase(ctx context.Context, raw []byte) (*databaseCopy, error) {
+ if err := s.ensureDirectory(); err != nil {
+ return nil, err
+ }
+ // Work from private verified bytes so file edits cannot race SQLite reads.
+ f, err := os.CreateTemp(s.directory, ".restore-check-*.sqlite")
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ path := f.Name()
+ defer os.Remove(path)
+ if err = protectPath(path, false); err == nil {
+ _, err = f.Write(raw)
+ }
+ closeErr := f.Close()
+ if err != nil || closeErr != nil {
+ return nil, errBackupInvalid
+ }
+ database, err := sql.Open(db.DriverName, filepath.ToSlash(path))
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ defer database.Close()
+ database.SetMaxOpenConns(1)
+ if _, err = database.ExecContext(ctx, "PRAGMA query_only=ON"); err != nil {
+ return nil, errBackupInvalid
+ }
+ tx, err := database.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ defer tx.Rollback()
+ copy, err := readDatabase(ctx, tx)
+ if err != nil {
+ return nil, errBackupInvalid
+ }
+ return copy, nil
+}
+
+func (s *BackupStore) revision(value any) string {
+ b, _ := json.Marshal(value)
+ h := hmac.New(sha256.New, s.key)
+ h.Write(b)
+ return hex.EncodeToString(h.Sum(nil))
+}
+func defaultBits(copy *databaseCopy) map[string]int64 {
+ result := map[string]int64{}
+ for _, table := range copy.Tables {
+ if len(table.Columns) != 2 || table.Columns[0] != "gid" || table.Columns[1] != "disable" {
+ continue
+ }
+ for _, row := range table.Rows {
+ if row[0] == int64(0) {
+ if value, ok := row[1].(int64); ok {
+ result[table.Name] = value
+ }
+ }
+ }
+ }
+ return result
+}
+func (s *BackupStore) protectRestored(raw []byte, copy *databaseCopy) {
+ if s.logs == nil {
+ return
+ }
+ s.logs.ProtectConfig(raw)
+ if copy != nil {
+ for _, table := range copy.Tables {
+ if table.Name == "__resp" {
+ for _, row := range table.Rows {
+ for _, value := range row {
+ if text, ok := value.(string); ok && json.Valid([]byte(text)) {
+ s.logs.ProtectConfig([]byte(text))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+type RestorePreview struct {
+ Backup BackupEntry `json:"backup"`
+ Revision string `json:"revision"`
+ ExpiresAt int64 `json:"expiresAt"`
+ Token string `json:"token"`
+ Changed bool `json:"changed"`
+ RestartRequired bool `json:"restartRequired"`
+ Tables int `json:"tables"`
+ Rows int `json:"rows"`
+ ChangedTables int `json:"changedTables"`
+ Warnings []string `json:"warnings"`
+ Impacts []RestoreImpact `json:"impacts"`
+}
+type RestoreRequest struct {
+ ID string `json:"id"`
+ Revision string `json:"revision"`
+ ExpiresAt int64 `json:"expiresAt"`
+ Token string `json:"token"`
+ Confirm bool `json:"confirm"`
+}
+
+func (s *BackupStore) ticket(id, digest, revision, subject string, expiry int64) string {
+ return s.revision([]any{id, digest, revision, subject, expiry})
+}
+
+func (s *BackupStore) Preview(ctx context.Context, id, subject string) (RestorePreview, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ entry, raw, err := s.load(id)
+ if err != nil {
+ return RestorePreview{}, err
+ }
+ v := RestorePreview{Backup: entry, ExpiresAt: s.now().Add(10 * time.Minute).Unix(), Warnings: []string{"一次仅恢复所选备份的范围;不恢复 QQ 登录、聊天媒体、后台密码或会话。"}}
+ if !entry.Integrity {
+ v.Warnings = append(v.Warnings, "此旧版 JSON 备份没有校验摘要,格式有效但无法证明内容未被修改。")
+ }
+ if entry.Scope == "control-db" {
+ if s.manager == nil {
+ return v, errBackupInvalid
+ }
+ source, err := s.sourceDatabase(ctx, raw)
+ if err != nil {
+ return v, err
+ }
+ s.manager.Lock()
+ defer s.manager.Unlock()
+ err = s.manager.D.TransactionContext(ctx, func(tx *sql.Tx) error {
+ current, err := readDatabase(ctx, tx)
+ if err != nil {
+ return err
+ }
+ if s.revision(source.Schema) != s.revision(current.Schema) {
+ return errBackupInvalid
+ }
+ v.Revision = s.revision(current)
+ v.Changed = s.revision(source) != v.Revision
+ v.Tables = len(source.Tables)
+ v.Impacts = newDatabaseImpacts()
+ for i, table := range source.Tables {
+ v.Rows += len(table.Rows)
+ impact := &v.Impacts[databaseImpactBucket(table.Name)]
+ *impact.CurrentCount += len(current.Tables[i].Rows)
+ *impact.BackupCount += len(table.Rows)
+ if s.revision(table) != s.revision(current.Tables[i]) {
+ v.ChangedTables++
+ impact.Changed = true
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return v, errBackupInvalid
+ }
+ v.RestartRequired = v.Changed
+ v.Warnings = append(v.Warnings, "覆盖整个控制库:插件开关、群参数、AI 扩展、响应/封禁及库内插件数据。控制层立即更新;其他插件缓存可能需要重启。")
+ } else {
+ current, unlock, err := s.currentFile(entry.Scope)
+ if err != nil {
+ return v, err
+ }
+ defer unlock()
+ v.Revision = s.revision(current)
+ v.Changed = !equivalentConfig(current, raw)
+ v.Impacts, err = fileRestoreImpacts(entry.Scope, current, raw)
+ if err != nil {
+ return v, err
+ }
+ v.RestartRequired = v.Changed
+ if entry.Scope == "startup-config" {
+ v.Warnings = append(v.Warnings, "覆盖完整启动 JSON,包括所有连接、Token、主人和未知字段;重启后生效。")
+ } else {
+ v.Warnings = append(v.Warnings, "覆盖后台启用与回环地址设置;重启后可能关闭后台或改变访问端口。")
+ }
+ }
+ v.Token = s.ticket(id, digestBytes(raw), v.Revision, subject, v.ExpiresAt)
+ return v, nil
+}
+
+// Caller must invoke unlock even when it elects not to write.
+func (s *BackupStore) currentFile(scope string) ([]byte, func(), error) {
+ if scope == "startup-config" && s.connections != nil {
+ s.connections.mu.Lock()
+ raw, err := s.connections.read()
+ if err != nil {
+ s.connections.mu.Unlock()
+ return nil, func() {}, errBackupInvalid
+ }
+ return raw, s.connections.mu.Unlock, nil
+ }
+ if scope == "admin-options" {
+ raw, err := readBoundedFile(s.optionsPath, 4096)
+ if err != nil || validateOptions(raw) != nil {
+ return nil, func() {}, errBackupInvalid
+ }
+ return raw, func() {}, nil
+ }
+ return nil, func() {}, errBackupInvalid
+}
+
+type RestoreResult struct {
+ BackupID string `json:"backupId"`
+ Scope string `json:"scope"`
+ Changed bool `json:"changed"`
+ RestartRequired bool `json:"restartRequired"`
+}
+
+func (s *BackupStore) Restore(ctx context.Context, request RestoreRequest, subject string) (RestoreResult, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ result := RestoreResult{}
+ if !request.Confirm || !validRevision(request.Revision) || !validRevision(request.Token) {
+ return result, errBackupInvalid
+ }
+ if request.ExpiresAt <= s.now().Unix() || request.ExpiresAt > s.now().Add(10*time.Minute).Unix() {
+ return result, errBackupConflict
+ }
+ entry, raw, err := s.load(request.ID)
+ if err != nil {
+ return result, err
+ }
+ want := s.ticket(request.ID, digestBytes(raw), request.Revision, subject, request.ExpiresAt)
+ if !hmac.Equal([]byte(want), []byte(request.Token)) {
+ return result, errBackupConflict
+ }
+ result.Scope = entry.Scope
+ if err = s.ensureDirectory(); err != nil {
+ return result, err
+ }
+ if err = ctx.Err(); err != nil {
+ return result, err
+ }
+ if entry.Scope == "control-db" {
+ if s.manager == nil {
+ return result, errBackupInvalid
+ }
+ source, err := s.sourceDatabase(ctx, raw)
+ if err != nil {
+ return result, err
+ }
+ s.manager.Lock()
+ defer s.manager.Unlock()
+ var current *databaseCopy
+ err = s.manager.D.TransactionContext(ctx, func(tx *sql.Tx) error { var err error; current, err = readDatabase(ctx, tx); return err })
+ if err != nil {
+ return result, errBackupInvalid
+ }
+ if s.revision(current) != request.Revision {
+ return result, errBackupConflict
+ }
+ if s.revision(source.Schema) != s.revision(current.Schema) {
+ return result, errBackupInvalid
+ }
+ if s.revision(source) == request.Revision {
+ return result, nil
+ }
+ result.BackupID, err = BackupControlDatabase(&s.manager.D, s.directory)
+ if err != nil {
+ return result, err
+ }
+ s.protectRestored(nil, source)
+ err = s.manager.D.TransactionContext(ctx, func(tx *sql.Tx) error {
+ // Recheck inside the write transaction, including outside DB writers.
+ latest, err := readDatabase(ctx, tx)
+ if err != nil {
+ return err
+ }
+ if s.revision(latest) != request.Revision {
+ return errBackupConflict
+ }
+ for i, table := range source.Tables {
+ if _, err = tx.ExecContext(ctx, "DELETE FROM "+quoteName(table.Name)); err != nil {
+ return err
+ }
+ columns := []string{}
+ for _, name := range table.Columns {
+ columns = append(columns, quoteName(name))
+ }
+ query := "INSERT INTO " + quoteName(table.Name) + " (" + strings.Join(columns, ",") + ") VALUES (" + strings.TrimSuffix(strings.Repeat("?,", len(columns)), ",") + ")"
+ stmt, err := tx.PrepareContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ for _, row := range table.Rows {
+ if _, err = stmt.ExecContext(ctx, row...); err != nil {
+ break
+ }
+ }
+ stmt.Close()
+ if err != nil {
+ return err
+ }
+ if s.afterTable != nil {
+ if err = s.afterTable(i); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ if errors.Is(err, errBackupConflict) {
+ return result, err
+ }
+ return result, errors.New("控制库恢复失败,事务已回滚,原配置与缓存保留;恢复前备份已创建")
+ }
+ s.manager.RestoredLocked(defaultBits(current), defaultBits(source))
+ } else {
+ current, unlock, err := s.currentFile(entry.Scope)
+ if err != nil {
+ return result, err
+ }
+ defer unlock()
+ if s.revision(current) != request.Revision {
+ return result, errBackupConflict
+ }
+ if equivalentConfig(current, raw) {
+ return result, nil
+ }
+ result.BackupID, err = writeFileBackup(s.directory, entry.Scope, current)
+ if err != nil {
+ return result, err
+ }
+ path, replace := s.optionsPath, s.replace
+ if entry.Scope == "startup-config" {
+ path, replace = s.connections.path, s.connections.replace
+ }
+ f, err := os.CreateTemp(filepath.Dir(path), ".admin-restore-*.tmp")
+ if err != nil {
+ return result, errors.New("恢复文件创建失败,原配置保留")
+ }
+ tmp := f.Name()
+ defer os.Remove(tmp)
+ if err = protectPath(tmp, false); err == nil {
+ _, err = f.Write(raw)
+ }
+ if err == nil {
+ err = f.Sync()
+ }
+ closeErr := f.Close()
+ if err == nil {
+ err = closeErr
+ }
+ if err == nil {
+ latest, readErr := readBoundedFile(path, 1<<20)
+ if readErr != nil || !bytes.Equal(latest, current) {
+ return result, errBackupConflict
+ }
+ if err = ctx.Err(); err == nil {
+ s.protectRestored(raw, nil)
+ err = replace(tmp, path)
+ }
+ }
+ if err != nil {
+ return result, errors.New("配置恢复失败,原文件保留;恢复前备份已创建")
+ }
+ }
+ result.Changed, result.RestartRequired = true, true
+ return result, nil
+}
+
+func (s *Server) backupError(w http.ResponseWriter, err error) {
+ status := 500
+ if errors.Is(err, errBackupInvalid) {
+ status = 400
+ }
+ if errors.Is(err, errBackupConflict) {
+ status = 409
+ }
+ // Database and filesystem errors may contain paths or configuration text.
+ message := "备份操作失败,原配置保留;请检查本机备份目录或数据库"
+ if status != 500 {
+ message = err.Error()
+ }
+ apiError(w, status, message)
+}
+func (s *Server) getBackups(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Backups == nil {
+ apiError(w, 409, "当前程序未接入备份管理")
+ return
+ }
+ entries, more, err := s.cfg.Backups.List()
+ if err != nil {
+ s.backupError(w, err)
+ return
+ }
+ jsonReply(w, 200, map[string]any{"backups": entries, "more": more, "scopes": map[string]bool{"startup-config": s.cfg.Backups.connections != nil, "control-db": s.cfg.Backups.manager != nil, "admin-options": validateOptionsFile(s.cfg.Backups.optionsPath)}})
+}
+func validateOptionsFile(path string) bool {
+ raw, err := readBoundedFile(path, 4096)
+ return err == nil && validateOptions(raw) == nil
+}
+func (s *Server) createBackup(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Backups == nil {
+ apiError(w, 409, "当前程序未接入备份管理")
+ return
+ }
+ var req struct {
+ Scope string `json:"scope"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ id, err := s.cfg.Backups.Create(req.Scope)
+ if err != nil {
+ s.backupError(w, err)
+ return
+ }
+ jsonReply(w, 200, map[string]string{"backupId": id})
+}
+func (s *Server) previewBackup(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Backups == nil {
+ apiError(w, 409, "当前程序未接入备份管理")
+ return
+ }
+ var req struct {
+ ID string `json:"id"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ cookie, _ := r.Cookie("local_admin_session")
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ v, err := s.cfg.Backups.Preview(ctx, req.ID, cookie.Value)
+ if err != nil {
+ s.backupError(w, err)
+ return
+ }
+ jsonReply(w, 200, v)
+}
+func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Backups == nil {
+ apiError(w, 409, "当前程序未接入备份管理")
+ return
+ }
+ var req RestoreRequest
+ if !readJSON(w, r, &req) {
+ return
+ }
+ cookie, _ := r.Cookie("local_admin_session")
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ v, err := s.cfg.Backups.Restore(ctx, req, cookie.Value)
+ if err != nil {
+ s.backupError(w, err)
+ return
+ }
+ jsonReply(w, 200, v)
+}
diff --git a/internal/localadmin/backups_test.go b/internal/localadmin/backups_test.go
new file mode 100644
index 0000000000..e703d6c1fd
--- /dev/null
+++ b/internal/localadmin/backups_test.go
@@ -0,0 +1,438 @@
+package localadmin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func backupFixture(t *testing.T) (*BackupStore, *ctrl.Control[*zero.Ctx]) {
+ t.Helper()
+ connections := fixtureConnectionStore(t)
+ c, _ := pluginFixture(t, false)
+ plugins := NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, connections.backups)
+ s := NewBackupStore(connections.backups, connections, plugins, NewLogStore())
+ if err := os.MkdirAll(filepath.Dir(s.optionsPath), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(s.optionsPath, []byte(`{"enabled":true,"address":"127.0.0.1:3010"}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ return s, c
+}
+func previewRestore(t *testing.T, s *BackupStore, id string) RestoreRequest {
+ t.Helper()
+ v, err := s.Preview(context.Background(), id, "fixture-session")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return RestoreRequest{ID: id, Revision: v.Revision, ExpiresAt: v.ExpiresAt, Token: v.Token, Confirm: true}
+}
+
+func TestBackupFileScopesRestoreAndConflict(t *testing.T) {
+ s, _ := backupFixture(t)
+ for _, scope := range []string{"startup-config", "admin-options"} {
+ id, err := s.Create(scope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := s.optionsPath
+ if scope == "startup-config" {
+ path = s.connections.path
+ }
+ old, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var next []byte
+ if scope == "startup-config" {
+ next = []byte(strings.ReplaceAll(string(old), "secret-A", "new-synthetic-key"))
+ } else {
+ next = []byte(`{"enabled":false,"address":"127.0.0.1:3111"}`)
+ }
+ if err = os.WriteFile(path, next, 0600); err != nil {
+ t.Fatal(err)
+ }
+ preview := previewRestore(t, s, id)
+ refused := preview
+ refused.Confirm = false
+ if _, err = s.Restore(context.Background(), refused, "fixture-session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("missing confirmation accepted", err)
+ }
+ if _, err = s.Restore(context.Background(), preview, "other-session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("ticket not tied to session", err)
+ }
+ // Failure leaves current bytes and running configuration untouched.
+ failure := func(string, string) error { return errors.New("injected replacement failure") }
+ if scope == "startup-config" {
+ s.connections.replace = failure
+ } else {
+ s.replace = failure
+ }
+ if _, err = s.Restore(context.Background(), preview, "fixture-session"); err == nil {
+ t.Fatal("write failure accepted")
+ }
+ actual, _ := os.ReadFile(path)
+ if !bytes.Equal(actual, next) {
+ t.Fatal("failed restore modified original")
+ }
+ if scope == "startup-config" {
+ s.connections.replace = replaceConfigFile
+ } else {
+ s.replace = replaceConfigFile
+ }
+ result, err := s.Restore(context.Background(), preview, "fixture-session")
+ if err != nil || !result.Changed || !result.RestartRequired || result.BackupID == "" {
+ t.Fatal(result, err)
+ }
+ actual, _ = os.ReadFile(path)
+ if !equivalentConfig(actual, old) {
+ t.Fatal("restore lost unknown configuration")
+ }
+ _, saved, err := s.load(result.BackupID)
+ if err != nil || !equivalentConfig(saved, next) {
+ t.Fatal("pre-restore backup wrong", err)
+ }
+ if _, err = s.Restore(context.Background(), preview, "fixture-session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("stale ticket accepted", err)
+ }
+ unchanged := previewRestore(t, s, id)
+ result, err = s.Restore(context.Background(), unchanged, "fixture-session")
+ if err != nil || result.Changed || result.BackupID != "" {
+ t.Fatal("no-op created backup or failed", result, err)
+ }
+ }
+ if !bytes.Equal(s.connections.running, []byte(connectionFixture)) {
+ t.Fatal("running configuration changed")
+ }
+}
+
+func TestBackupControlRestoreAtomicityAndCaches(t *testing.T) {
+ s, c := backupFixture(t)
+ if err := c.SetData(101, 123456); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Reset(101); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.SetExtra(map[string]string{"Key": "old-synthetic-model-key"}); err == nil {
+ t.Fatal("fixture must not register extension")
+ }
+ id, err := s.Create("control-db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.SetData(101, 987654); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Flip(); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Manager.Response(101); err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Manager.DoBlock(456); err != nil {
+ t.Fatal(err)
+ }
+ c.Ban(789, 101)
+ // Prime every control cache before restore.
+ _ = c.IsEnabledIn(101)
+ _ = c.Manager.CanResponse(101)
+ _ = c.Manager.IsBlocked(456)
+ _ = c.IsBannedIn(789, 101)
+ before := switchState(t, c, 101)
+ preview := previewRestore(t, s, id)
+ s.afterTable = func(i int) error {
+ if i == 1 {
+ return errors.New("injected mid-restore failure")
+ }
+ return nil
+ }
+ if _, err = s.Restore(context.Background(), preview, "fixture-session"); err == nil {
+ t.Fatal("mid-transaction failure accepted")
+ }
+ if c.GetData(101) != 987654 || switchState(t, c, 101) != before || !c.Manager.CanResponse(101) || !c.Manager.IsBlocked(456) || !c.IsBannedIn(789, 101) {
+ t.Fatal("failed restore altered data/cache")
+ }
+ s.afterTable = nil
+ result, err := s.Restore(context.Background(), preview, "fixture-session")
+ if err != nil || !result.Changed || result.BackupID == "" {
+ t.Fatal(result, err)
+ }
+ state := switchState(t, c, 101)
+ if c.GetData(101) != 123456 || state.GroupMode != "inherit" || !state.DefaultEnabled || !c.IsEnabledIn(101) || c.Manager.CanResponse(101) || c.Manager.IsBlocked(456) || c.IsBannedIn(789, 101) {
+ t.Fatal("restore/cache invalidation wrong", state)
+ }
+ // Recover the state before restoration using its mandatory backup.
+ recovery := previewRestore(t, s, result.BackupID)
+ if _, err = s.Restore(context.Background(), recovery, "fixture-session"); err != nil {
+ t.Fatal(err)
+ }
+ if c.GetData(101) != 987654 || switchState(t, c, 101) != before || !c.Manager.CanResponse(101) || !c.Manager.IsBlocked(456) || !c.IsBannedIn(789, 101) {
+ t.Fatalf("recovery did not restore pre-restore state: data=%d state=%+v want=%+v response=%t block=%t ban=%t", c.GetData(101), switchState(t, c, 101), before, c.Manager.CanResponse(101), c.Manager.IsBlocked(456), c.IsBannedIn(789, 101))
+ }
+}
+
+func TestBackupRejectsDamagePathsSchemaExpiredAndChangedSource(t *testing.T) {
+ s, c := backupFixture(t)
+ id, err := s.Create("startup-config")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"../config.json", id + "/../credential.json", filepath.Join(s.directory, id), "credential.json"} {
+ if _, err = s.Preview(context.Background(), bad, "fixture-session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("path allowed", bad, err)
+ }
+ }
+ preview := previewRestore(t, s, id)
+ s.now = func() time.Time { return time.Now().Add(11 * time.Minute) }
+ if _, err = s.Restore(context.Background(), preview, "fixture-session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("expired accepted", err)
+ }
+ s.now = time.Now
+ path := filepath.Join(s.directory, id)
+ raw, _ := os.ReadFile(path)
+ var file fileBackup
+ _ = json.Unmarshal(raw, &file)
+ file.Config = json.RawMessage(strings.ReplaceAll(string(file.Config), "secret-A", "changed-key"))
+ modified, _ := json.Marshal(file)
+ _ = os.WriteFile(path, modified, 0600)
+ if _, err = s.Preview(context.Background(), id, "fixture-session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("corrupt digest accepted", err)
+ }
+ file.SHA256 = digestBytes(file.Config)
+ modified, _ = json.Marshal(file)
+ _ = os.WriteFile(path, modified, 0600)
+ if _, err = s.Restore(context.Background(), preview, "fixture-session"); !errors.Is(err, errBackupConflict) {
+ t.Fatal("changed source accepted", err)
+ }
+ file.Version = 2
+ modified, _ = json.Marshal(file)
+ _ = os.WriteFile(path, modified, 0600)
+ if _, err = s.Preview(context.Background(), id, "fixture-session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("unsupported version accepted", err)
+ }
+ dbID, err := s.Create("control-db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.Manager.Lock()
+ _, err = c.Manager.D.Exec("CREATE TABLE future_schema (id INTEGER PRIMARY KEY)")
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = s.Preview(context.Background(), dbID, "fixture-session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("incompatible schema accepted", err)
+ }
+ rows, _, err := s.List()
+ if err != nil || len(rows) < 2 {
+ t.Fatal(rows, err)
+ }
+ found := false
+ for _, entry := range rows {
+ if entry.ID == id {
+ found = true
+ if entry.Valid {
+ t.Fatal("damaged backup marked valid")
+ }
+ }
+ }
+ if !found {
+ t.Fatal("damaged row disappeared")
+ }
+}
+
+func TestBackupAPIAuthPreviewAndPrivateResponses(t *testing.T) {
+ store, c := backupFixture(t)
+ s, err := New(Config{Addr: "127.0.0.1:3010", DataDir: filepath.Dir(store.directory), Overview: func() Overview { return Overview{} }, Backups: store})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/api/backups", "/api/backups/preview", "/api/backups/restore"} {
+ method := "POST"
+ if path == "/api/backups" {
+ method = "GET"
+ }
+ expectStatus(t, request(s, method, path, map[string]string{}, nil, ""), 401)
+ }
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/backups", map[string]string{"scope": "startup-config"}, cookie, "invalid"), 403)
+ w := request(s, "POST", "/api/backups", map[string]string{"scope": "control-db"}, cookie, csrf)
+ expectStatus(t, w, 200)
+ var created struct {
+ ID string `json:"backupId"`
+ }
+ _ = json.Unmarshal(w.Body.Bytes(), &created)
+ if err = c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ w = request(s, "POST", "/api/backups/preview", map[string]string{"id": created.ID}, cookie, csrf)
+ expectStatus(t, w, 200)
+ var preview RestorePreview
+ _ = json.Unmarshal(w.Body.Bytes(), &preview)
+ if !preview.Changed || preview.Revision == "" || preview.Token == "" {
+ t.Fatal("preview incomplete")
+ }
+ for _, path := range []string{"/api/backups", "/backups/" + created.ID, "/api/backups/" + created.ID + "/download"} {
+ w = request(s, "GET", path, nil, cookie, "")
+ if path == "/api/backups" {
+ expectStatus(t, w, 200)
+ } else {
+ expectStatus(t, w, 404)
+ }
+ if strings.Contains(w.Body.String(), "secret-A") || strings.Contains(w.Body.String(), "plugins.sqlite") {
+ t.Fatal("backup contents leaked")
+ }
+ if w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("sensitive data cached")
+ }
+ }
+ req := RestoreRequest{created.ID, preview.Revision, preview.ExpiresAt, preview.Token, true}
+ w = request(s, "POST", "/api/backups/restore", req, cookie, csrf)
+ expectStatus(t, w, 200)
+ if !c.IsEnabledIn(101) {
+ t.Fatal("API restore failed")
+ }
+ expectStatus(t, request(s, "POST", "/api/logout", map[string]string{}, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/backups/restore", req, cookie, csrf), 401)
+}
+
+func TestBackupLegacyIntegrityDatabaseDamageAndCancellation(t *testing.T) {
+ s, c := backupFixture(t)
+ jsonWithMarkup := []byte(strings.ReplaceAll(connectionFixture, "fixture", ""))
+ if err := s.ensureDirectory(); err != nil {
+ t.Fatal(err)
+ }
+ markupID, err := writeFileBackup(s.directory, "startup-config", jsonWithMarkup)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, markup, err := s.load(markupID)
+ if err != nil || !equivalentConfig(markup, jsonWithMarkup) {
+ t.Fatal("escaped JSON hash/payload mismatch", err)
+ }
+ if validateOptions([]byte(`{"enabled":null,"address":"127.0.0.1:3010"}`)) == nil {
+ t.Fatal("null enabled accepted")
+ }
+ id, err := s.Create("startup-config")
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(s.directory, id)
+ raw, _ := os.ReadFile(path)
+ var file fileBackup
+ _ = json.Unmarshal(raw, &file)
+ file.SHA256 = ""
+ raw, _ = json.Marshal(file)
+ _ = os.WriteFile(path, raw, 0600)
+ v, err := s.Preview(context.Background(), id, "fixture-session")
+ if err != nil || v.Backup.Integrity || len(v.Warnings) < 2 {
+ t.Fatal("legacy warning missing", v, err)
+ }
+ dbID, err := s.Create("control-db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ preview := previewRestore(t, s, dbID)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err = s.Restore(ctx, preview, "fixture-session"); err == nil || c.IsEnabledIn(101) {
+ t.Fatal("cancelled restore changed state", err)
+ }
+ root := filepath.Join(s.directory, dbID)
+ manifestRaw, _ := os.ReadFile(filepath.Join(root, "manifest.json"))
+ var manifest controlManifest
+ _ = json.Unmarshal(manifestRaw, &manifest)
+ broken := []byte("synthetic corrupted SQLite")
+ manifest.SHA256 = digestBytes(broken)
+ manifestRaw, _ = json.Marshal(manifest)
+ _ = os.WriteFile(filepath.Join(root, "manifest.json"), manifestRaw, 0600)
+ _ = os.WriteFile(filepath.Join(root, "plugins.sqlite"), broken, 0600)
+ if _, err = s.Preview(context.Background(), dbID, "fixture-session"); !errors.Is(err, errBackupInvalid) {
+ t.Fatal("damaged SQLite accepted", err)
+ }
+}
+
+func TestBackupRestoreSerializesWithNormalControlWrites(t *testing.T) {
+ s, c := backupFixture(t)
+ id, err := s.Create("control-db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ preview := previewRestore(t, s, id)
+ entered, release := make(chan struct{}), make(chan struct{})
+ s.afterTable = func(i int) error {
+ if i == 0 {
+ close(entered)
+ <-release
+ }
+ return nil
+ }
+ restored := make(chan error, 1)
+ go func() { _, err := s.Restore(context.Background(), preview, "fixture-session"); restored <- err }()
+ select {
+ case <-entered:
+ case err := <-restored:
+ t.Fatal("restore never entered", err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("restore stalled")
+ }
+ if c.Manager.TryLock() {
+ c.Manager.Unlock()
+ close(release)
+ t.Fatal("restore did not retain Manager lock")
+ }
+ written := make(chan error, 1)
+ go func() { written <- c.Disable(101) }()
+ close(release)
+ if err := <-restored; err != nil {
+ t.Fatal(err)
+ }
+ if err := <-written; err != nil {
+ t.Fatal(err)
+ }
+ if c.IsEnabledIn(101) {
+ t.Fatal("normal writer was lost after restore")
+ }
+}
+
+func TestControlBanReloadSignedIDsAndGlobalFallback(t *testing.T) {
+ _, c := backupFixture(t)
+ c.Ban(789, 101) // Fixture digest has its high bit set.
+ c.Manager.Lock()
+ c.Manager.RestoredLocked(nil, nil)
+ c.Manager.Unlock()
+ if !c.IsBannedIn(789, 101) {
+ t.Fatal("signed persisted ban unreadable after reload")
+ }
+ c.Permit(789, 101)
+ c.Manager.Lock()
+ c.Manager.RestoredLocked(nil, nil)
+ c.Manager.Unlock()
+ if c.IsBannedIn(789, 101) {
+ t.Fatal("signed persisted ban not removed")
+ }
+ c.Ban(456, 0)
+ if !c.IsBannedIn(456, 101) || !c.IsBannedIn(456, 101) {
+ t.Fatal("cached group miss bypassed global ban")
+ }
+}
diff --git a/internal/localadmin/connection_add.go b/internal/localadmin/connection_add.go
new file mode 100644
index 0000000000..eb60285da6
--- /dev/null
+++ b/internal/localadmin/connection_add.go
@@ -0,0 +1,87 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "errors"
+ "net/url"
+ "strconv"
+ "strings"
+ "unicode"
+)
+
+// Existing clients omit Kind for forward drivers. New reverse listeners are
+// constrained to local authenticated plain WebSockets supported by this driver.
+type ConnectionAddition struct {
+ Kind string `json:"kind,omitempty"`
+ URL string `json:"url"`
+ Token string `json:"token"`
+ WithoutToken bool `json:"withoutToken"`
+}
+
+func connectionEndpoint(address string) string {
+ u, err := url.Parse(address)
+ if err != nil {
+ return address
+ }
+ port := u.Port()
+ if port == "" {
+ if u.Scheme == "wss" {
+ port = "443"
+ } else {
+ port = "80"
+ }
+ }
+ if n, err := strconv.Atoi(port); err == nil {
+ port = strconv.Itoa(n)
+ }
+ p := u.EscapedPath()
+ if p == "/" {
+ p = ""
+ }
+ return u.Scheme + "|" + strings.ToLower(u.Hostname()) + "|" + port + "|" + p
+}
+
+func applyConnectionAddition(root jsonObject, add ConnectionAddition) ([]byte, error) {
+ kind := add.Kind
+ if kind == "" {
+ kind = "ws"
+ }
+ if kind != "ws" && kind != "wss" {
+ return nil, errors.New("新增连接类型无效")
+ }
+ if kind == "wss" {
+ if err := validateReverseAddition(root, add); err != nil {
+ return nil, err
+ }
+ }
+ if !validConnectionURL(add.URL) {
+ return nil, errors.New("地址须为有效的 ws/wss 地址;认证信息请填写在 Token 中")
+ }
+ if add.WithoutToken {
+ if add.Token != "" {
+ return nil, errors.New("不使用 Token 时不能同时提交新值")
+ }
+ } else if add.Token == "" || len(add.Token) > 2048 || strings.IndexFunc(add.Token, unicode.IsControl) >= 0 || strings.Trim(add.Token, "*• ") == "" {
+ return nil, errors.New("请输入有效的新 Token,或明确选择此连接不使用 Token")
+ }
+ key, raw, _ := field(root, kind)
+ var list []json.RawMessage
+ _ = json.Unmarshal(raw, &list)
+ if len(list) >= 64 {
+ return nil, errors.New("所选类型已达到64条上限,不能继续新增")
+ }
+ for _, item := range list {
+ conn, _ := object(item)
+ _, rawURL, _ := field(conn, "URL")
+ var address string
+ _ = json.Unmarshal(rawURL, &address)
+ if validConnectionURL(address) && connectionEndpoint(address) == connectionEndpoint(add.URL) {
+ return nil, errors.New("已存在相同地址的正向连接,请编辑已有连接")
+ }
+ }
+ conn, _ := json.Marshal(map[string]string{"URL": add.URL, "AccessToken": add.Token})
+ list = append(list, conn)
+ root[key], _ = json.Marshal(list)
+ b, err := json.MarshalIndent(root, "", " ")
+ return append(b, '\n'), err
+}
diff --git a/internal/localadmin/connection_add_test.go b/internal/localadmin/connection_add_test.go
new file mode 100644
index 0000000000..3043756cd6
--- /dev/null
+++ b/internal/localadmin/connection_add_test.go
@@ -0,0 +1,198 @@
+package localadmin
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestConnectionAdditionBackupPreservesOtherFieldsAndRuntime(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ initial := getSettings(t, s)
+ saved, err := s.Save(ConnectionUpdate{Revision: initial.Revision, AddConnection: &ConnectionAddition{URL: "ws://127.0.0.1:7702/ws", Token: "new-add-secret"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved.BackupID == "" || !saved.RestartRequired || len(saved.Connections) != 4 || len(saved.RunningConnections) != 3 {
+ t.Fatal("addition changed running state or omitted backup")
+ }
+ if saved.Connections[2].Kind != "ws" || saved.Connections[2].Index != 2 || !saved.Connections[2].TokenConfigured {
+ t.Fatal("addition target incorrect")
+ }
+ raw, _ := os.ReadFile(s.path)
+ root, _ := decodeConfig(raw)
+ old, _ := decodeConfig([]byte(connectionFixture))
+ for _, key := range []string{"zero", "wss", "force_base64_file", "unknown"} {
+ if !equivalentConfig(root[key], old[key]) {
+ t.Fatal("lost existing field", key)
+ }
+ }
+ var a, b []json.RawMessage
+ _ = json.Unmarshal(root["ws"], &a)
+ _ = json.Unmarshal(old["ws"], &b)
+ for i := range b {
+ if !bytes.Equal(a[i], b[i]) && !equivalentConfig(a[i], b[i]) {
+ t.Fatal("changed old connection")
+ }
+ }
+ if !bytes.Contains(raw, []byte("9007199254740993")) {
+ t.Fatal("integer precision lost")
+ }
+ encoded, _ := json.Marshal(saved)
+ if bytes.Contains(encoded, []byte("secret")) {
+ t.Fatal("token exposed")
+ }
+ backup, _ := os.ReadFile(filepath.Join(s.backups, saved.BackupID))
+ var snapshot fileBackup
+ if json.Unmarshal(backup, &snapshot) != nil || !equivalentConfig(snapshot.Config, []byte(connectionFixture)) {
+ t.Fatal("wrong backup")
+ }
+ next, err := NewConnectionStore(s.path, s.backups, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view := getSettings(t, next); view.RestartRequired || len(view.RunningConnections) != 4 {
+ t.Fatal("restart snapshot did not load addition")
+ }
+}
+func TestConnectionAdditionValidationAliasesMissingAndLimit(t *testing.T) {
+ users := []string{"12345678"}
+ for _, add := range []ConnectionAddition{
+ {URL: "http://127.0.0.1:7777", Token: "valid"}, {URL: "ws://127.0.0.1:0", Token: "valid"}, {URL: "ws://user:secret@localhost/ws", Token: "valid"}, {URL: "ws://localhost/ws?key=secret", Token: "valid"},
+ {URL: "ws://localhost/ws", Token: ""}, {URL: "ws://localhost/ws", Token: "***"}, {URL: "ws://localhost/ws", Token: "line\nbreak"}, {URL: "ws://localhost/ws", Token: strings.Repeat("中", 700)}, {URL: "ws://localhost/ws", Token: "valid", WithoutToken: true}, {URL: "ws://127.0.0.1:6700/", Token: "valid"}, {URL: "wss://BOT.EXAMPLE:0443/ws", Token: "valid"},
+ } {
+ if _, err := applyConnectionUpdate([]byte(connectionFixture), ConnectionUpdate{AddConnection: &add}); err == nil {
+ t.Fatal("invalid addition accepted", add.URL)
+ }
+ }
+ add := &ConnectionAddition{URL: "ws://localhost:7777", WithoutToken: true}
+ for _, update := range []ConnectionUpdate{{AddConnection: add, SuperUsers: &users}, {AddConnection: add, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "keep"}}} {
+ if _, err := applyConnectionUpdate([]byte(connectionFixture), update); err == nil {
+ t.Fatal("mixed write accepted")
+ }
+ }
+ for _, raw := range []string{`{"zero":{}}`, `{"zero":{},"ws":null}`, `{"zero":{},"WS":[],"unknown":9007199254740993}`} {
+ next, err := applyConnectionUpdate([]byte(raw), ConnectionUpdate{AddConnection: add})
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, _ := decodeConfig(next)
+ key, list, _ := field(root, "ws")
+ var conns []ConnectionAddition
+ _ = json.Unmarshal(list, &conns)
+ if len(conns) != 1 || conns[0].Token != "" || strings.Contains(raw, "WS") && key != "WS" {
+ t.Fatal("missing/null/alias addition incorrect")
+ }
+ }
+ root, _ := decodeConfig([]byte(connectionFixture))
+ var list []map[string]string
+ for i := 0; i < 64; i++ {
+ list = append(list, map[string]string{"URL": fmt.Sprintf("ws://127.0.0.1:%d", 10000+i)})
+ }
+ root["ws"], _ = json.Marshal(list)
+ raw, _ := json.Marshal(root)
+ if _, err := applyConnectionUpdate(raw, ConnectionUpdate{AddConnection: add}); err == nil {
+ t.Fatal("64 limit bypassed")
+ }
+ // The new limit does not block editing an existing large configuration.
+ if _, err := applyConnectionUpdate(raw, ConnectionUpdate{Connection: &ConnectionChange{Kind: "ws", Index: 63, TokenAction: "keep"}}); err != nil {
+ t.Fatal("old list cannot be edited", err)
+ }
+ for _, pair := range [][2]string{{"ws://LOCALHOST/", "ws://localhost:80"}, {"wss://localhost:0443/ws", "wss://LOCALHOST/ws"}, {"ws://[::1]/", "ws://[::1]:80"}} {
+ if connectionEndpoint(pair[0]) != connectionEndpoint(pair[1]) {
+ t.Fatal("equivalent address missed")
+ }
+ }
+ if connectionEndpoint("ws://localhost/ws") == connectionEndpoint("wss://localhost/ws") || connectionEndpoint("ws://localhost/ws/") == connectionEndpoint("ws://localhost/ws") {
+ t.Fatal("different endpoint collapsed")
+ }
+}
+func TestConnectionAdditionFailuresAndConcurrentConflictKeepCurrent(t *testing.T) {
+ for _, failure := range []string{"backup", "replace", "invalid"} {
+ t.Run(failure, func(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ view := getSettings(t, s)
+ add := ConnectionAddition{URL: "ws://localhost:7777", Token: "new-private-secret"}
+ switch failure {
+ case "backup":
+ _ = os.MkdirAll(filepath.Dir(s.backups), 0700)
+ _ = os.WriteFile(s.backups, []byte("blocked"), 0600)
+ case "replace":
+ s.replace = func(string, string) error { return errors.New("injected-private-error") }
+ case "invalid":
+ add.URL = "http://localhost"
+ }
+ _, err := s.Save(ConnectionUpdate{Revision: view.Revision, AddConnection: &add})
+ if err == nil || strings.Contains(err.Error(), "private") {
+ t.Fatal("failure accepted or leaked")
+ }
+ actual, _ := os.ReadFile(s.path)
+ if !bytes.Equal(actual, []byte(connectionFixture)) {
+ t.Fatal("failed add overwrote config")
+ }
+ })
+ }
+ s := fixtureConnectionStore(t)
+ view := getSettings(t, s)
+ var wg sync.WaitGroup
+ results := make(chan error, 6)
+ for i := 0; i < 6; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ _, err := s.Save(ConnectionUpdate{Revision: view.Revision, AddConnection: &ConnectionAddition{URL: fmt.Sprintf("ws://localhost:%d", 17000+i), WithoutToken: true}})
+ results <- err
+ }(i)
+ }
+ wg.Wait()
+ close(results)
+ success := 0
+ for err := range results {
+ if err == nil {
+ success++
+ } else if !errors.Is(err, errConfigConflict) {
+ t.Fatal(err)
+ }
+ }
+ if success != 1 || len(getSettings(t, s).Connections) != 4 {
+ t.Fatal("concurrent adds bypassed version")
+ }
+}
+func TestConnectionAdditionAPIAuthCSRFRedactionAndUnknownFields(t *testing.T) {
+ store := fixtureConnectionStore(t)
+ cfg := testConfig(t)
+ cfg.Connections = store
+ cfg.Logs = NewLogStore()
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ update := ConnectionUpdate{Revision: getSettings(t, store).Revision, AddConnection: &ConnectionAddition{URL: "ws://localhost:17700", Token: "api-new-private-secret"}}
+ expectStatus(t, request(s, "POST", "/api/connections", update, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/connections", update, cookie, "bad"), 403)
+ unknown := map[string]any{"revision": update.Revision, "addConnection": map[string]any{"url": "ws://localhost:17700", "token": "api-new-private-secret", "future": "unknown"}}
+ expectStatus(t, request(s, "POST", "/api/connections", unknown, cookie, csrf), 400)
+ w := request(s, "POST", "/api/connections", update, cookie, csrf)
+ expectStatus(t, w, 200)
+ if strings.Contains(w.Body.String(), "private-secret") {
+ t.Fatal("API exposed token")
+ }
+ expectStatus(t, request(s, "POST", "/api/connections", update, cookie, csrf), 409)
+ w = request(s, "GET", "/api/connections", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if strings.Contains(w.Body.String(), "private-secret") {
+ t.Fatal("read exposed token")
+ }
+ asset := request(s, "GET", "/connections-add.js", nil, nil, "")
+ expectStatus(t, asset, 200)
+ if asset.Header().Get("Cache-Control") != "no-store" || !strings.Contains(asset.Body.String(), "connectionAddSnapshot") {
+ t.Fatal("addition asset absent")
+ }
+}
diff --git a/internal/localadmin/connection_delete.go b/internal/localadmin/connection_delete.go
new file mode 100644
index 0000000000..09787de363
--- /dev/null
+++ b/internal/localadmin/connection_delete.go
@@ -0,0 +1,38 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "errors"
+)
+
+type ConnectionRemoval struct {
+ Kind string `json:"kind"`
+ Index *int `json:"index"`
+ Confirm bool `json:"confirm"`
+}
+
+func applyConnectionRemoval(root jsonObject, remove ConnectionRemoval) ([]byte, error) {
+ if !remove.Confirm || remove.Index == nil || (remove.Kind != "ws" && remove.Kind != "wss") {
+ return nil, errors.New("请选择有效的连接类型、明确序号并确认删除")
+ }
+ key, raw, _ := field(root, remove.Kind)
+ var list []json.RawMessage
+ _ = json.Unmarshal(raw, &list)
+ if *remove.Index < 0 || *remove.Index >= len(list) {
+ return nil, errors.New("连接不存在,请重新加载")
+ }
+ total := 0
+ for _, kind := range []string{"ws", "wss"} {
+ _, items, _ := field(root, kind)
+ var connections []json.RawMessage
+ _ = json.Unmarshal(items, &connections)
+ total += len(connections)
+ }
+ if total <= 1 {
+ return nil, errors.New("至少保留一条连接,不能删除最后一条")
+ }
+ remaining := append(list[:*remove.Index], list[*remove.Index+1:]...)
+ root[key], _ = json.Marshal(remaining)
+ b, err := json.MarshalIndent(root, "", " ")
+ return append(b, '\n'), err
+}
diff --git a/internal/localadmin/connection_delete_test.go b/internal/localadmin/connection_delete_test.go
new file mode 100644
index 0000000000..81520b3bf2
--- /dev/null
+++ b/internal/localadmin/connection_delete_test.go
@@ -0,0 +1,187 @@
+package localadmin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func deleteIndex(v int) *int { return &v }
+func TestConnectionRemovalPreservesFieldsBackupRuntimeAndRestores(t *testing.T) {
+ for _, target := range []ConnectionRemoval{{Kind: "ws", Index: deleteIndex(0), Confirm: true}, {Kind: "ws", Index: deleteIndex(1), Confirm: true}, {Kind: "wss", Index: deleteIndex(0), Confirm: true}} {
+ t.Run(target.Kind+string(rune('0'+*target.Index)), func(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ saved, err := s.Save(ConnectionUpdate{Revision: v.Revision, RemoveConnection: &target})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(saved.Connections) != 2 || len(saved.RunningConnections) != 3 || !saved.RestartRequired || saved.BackupID == "" {
+ t.Fatal("runtime changed or missing backup")
+ }
+ raw, _ := os.ReadFile(s.path)
+ root, _ := decodeConfig(raw)
+ old, _ := decodeConfig([]byte(connectionFixture))
+ for key := range old {
+ if key != target.Kind && !equivalentConfig(root[key], old[key]) {
+ t.Fatal("unselected field changed", key)
+ }
+ }
+ var a, b []json.RawMessage
+ _ = json.Unmarshal(root[target.Kind], &a)
+ _ = json.Unmarshal(old[target.Kind], &b)
+ b = append(b[:*target.Index], b[*target.Index+1:]...)
+ if len(a) != len(b) {
+ t.Fatal("wrong item removed")
+ }
+ for i := range a {
+ if !equivalentConfig(a[i], b[i]) {
+ t.Fatal("remaining item changed")
+ }
+ }
+ if !bytes.Contains(raw, []byte("9007199254740993")) {
+ t.Fatal("precision lost")
+ }
+ out, _ := json.Marshal(saved)
+ if bytes.Contains(out, []byte("secret-")) {
+ t.Fatal("secret exposed")
+ }
+ restarted, err := NewConnectionStore(s.path, s.backups, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view := getSettings(t, restarted); view.RestartRequired || len(view.RunningConnections) != 2 {
+ t.Fatal("new startup snapshot wrong")
+ }
+ store := NewBackupStore(s.backups, s, nil, NewLogStore())
+ preview, err := store.Preview(context.Background(), saved.BackupID, "remove-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = store.Restore(context.Background(), RestoreRequest{ID: saved.BackupID, Revision: preview.Revision, ExpiresAt: preview.ExpiresAt, Token: preview.Token, Confirm: true}, "remove-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ actual, _ := os.ReadFile(s.path)
+ if !equivalentConfig(actual, []byte(connectionFixture)) || getSettings(t, s).RestartRequired {
+ t.Fatal("deletion backup not restorable")
+ }
+ })
+ }
+}
+func TestConnectionRemovalValidationLastConnectionAliasesAndMixedWrites(t *testing.T) {
+ for _, target := range []ConnectionRemoval{{Kind: "ws", Index: deleteIndex(0)}, {Kind: "ws", Confirm: true}, {Kind: "../ws", Index: deleteIndex(0), Confirm: true}, {Kind: "WS", Index: deleteIndex(0), Confirm: true}, {Kind: "ws", Index: deleteIndex(-1), Confirm: true}, {Kind: "ws", Index: deleteIndex(2), Confirm: true}} {
+ if _, err := applyConnectionUpdate([]byte(connectionFixture), ConnectionUpdate{RemoveConnection: &target}); err == nil {
+ t.Fatal("invalid deletion accepted")
+ }
+ }
+ target := &ConnectionRemoval{Kind: "ws", Index: deleteIndex(0), Confirm: true}
+ users := []string{"12345678"}
+ for _, update := range []ConnectionUpdate{{RemoveConnection: target, SuperUsers: &users}, {RemoveConnection: target, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "keep"}}, {RemoveConnection: target, AddConnection: &ConnectionAddition{URL: "ws://localhost:18000", WithoutToken: true}}} {
+ if _, err := applyConnectionUpdate([]byte(connectionFixture), update); err == nil {
+ t.Fatal("mixed deletion accepted")
+ }
+ }
+ for _, raw := range []string{`{"zero":{},"ws":[{"URL":"ws://localhost:17000"}]}`, `{"zero":{},"ws":[{}],"wss":null}`} {
+ if _, err := applyConnectionUpdate([]byte(raw), ConnectionUpdate{RemoveConnection: target}); err == nil {
+ t.Fatal("last connection removed")
+ }
+ }
+ target.Kind = "wss"
+ if _, err := applyConnectionUpdate([]byte(`{"zero":{},"wss":[{}],"ws":null}`), ConnectionUpdate{RemoveConnection: target}); err == nil {
+ t.Fatal("last reverse connection removed")
+ }
+ raw := []byte(`{"zero":{"super_users":[12345678]},"WS":[{}],"WSS":[{}],"private":9007199254740993}`)
+ next, err := applyConnectionUpdate(raw, ConnectionUpdate{RemoveConnection: target})
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, _ := decodeConfig(next)
+ if !bytes.Equal(root["WSS"], []byte(`[]`)) || root["wss"] != nil || !bytes.Contains(root["private"], []byte("9007199254740993")) {
+ t.Fatal("aliases or empty list changed")
+ }
+}
+func TestConnectionRemovalFailureAndConcurrentConflictKeepCurrent(t *testing.T) {
+ for _, kind := range []string{"backup", "replace", "invalid"} {
+ t.Run(kind, func(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ target := &ConnectionRemoval{Kind: "ws", Index: deleteIndex(0), Confirm: true}
+ switch kind {
+ case "backup":
+ _ = os.MkdirAll(filepath.Dir(s.backups), 0700)
+ _ = os.WriteFile(s.backups, []byte("blocked"), 0600)
+ case "replace":
+ s.replace = func(string, string) error { return errors.New("private-failure") }
+ case "invalid":
+ target.Index = nil
+ }
+ _, err := s.Save(ConnectionUpdate{Revision: v.Revision, RemoveConnection: target})
+ if err == nil || strings.Contains(err.Error(), "private-failure") {
+ t.Fatal("failure accepted or exposed")
+ }
+ actual, _ := os.ReadFile(s.path)
+ if !bytes.Equal(actual, []byte(connectionFixture)) {
+ t.Fatal("failed removal changed current file")
+ }
+ })
+ }
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ var wg sync.WaitGroup
+ results := make(chan error, 6)
+ for i := 0; i < 6; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, err := s.Save(ConnectionUpdate{Revision: v.Revision, RemoveConnection: &ConnectionRemoval{Kind: "ws", Index: deleteIndex(0), Confirm: true}})
+ results <- err
+ }()
+ }
+ wg.Wait()
+ close(results)
+ success := 0
+ for err := range results {
+ if err == nil {
+ success++
+ } else if !errors.Is(err, errConfigConflict) {
+ t.Fatal(err)
+ }
+ }
+ if success != 1 || len(getSettings(t, s).Connections) != 2 {
+ t.Fatal("concurrent deletion accepted")
+ }
+}
+func TestConnectionRemovalAPIRequiresExplicitTargetAuthAndConfirmation(t *testing.T) {
+ store := fixtureConnectionStore(t)
+ cfg := testConfig(t)
+ cfg.Connections = store
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := ConnectionUpdate{Revision: getSettings(t, store).Revision, RemoveConnection: &ConnectionRemoval{Kind: "ws", Index: deleteIndex(0), Confirm: true}}
+ expectStatus(t, request(s, "POST", "/api/connections", body, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/connections", body, cookie, "bad"), 403)
+ for _, target := range []map[string]any{{"kind": "ws", "confirm": true}, {"kind": "ws", "index": nil, "confirm": true}, {"kind": "ws", "index": 0, "confirm": false}, {"kind": "ws", "index": 0, "confirm": true, "url": "private"}} {
+ expectStatus(t, request(s, "POST", "/api/connections", map[string]any{"revision": body.Revision, "removeConnection": target}, cookie, csrf), 400)
+ }
+ w := request(s, "POST", "/api/connections", body, cookie, csrf)
+ expectStatus(t, w, 200)
+ if strings.Contains(w.Body.String(), "secret-") {
+ t.Fatal("secret returned")
+ }
+ expectStatus(t, request(s, "POST", "/api/connections", body, cookie, csrf), 409)
+ asset := request(s, "GET", "/connections-delete.js", nil, nil, "")
+ expectStatus(t, asset, 200)
+ if asset.Header().Get("Cache-Control") != "no-store" || !strings.Contains(asset.Body.String(), "deletionCurrent") {
+ t.Fatal("embedded deletion script missing")
+ }
+}
diff --git a/internal/localadmin/connection_reverse.go b/internal/localadmin/connection_reverse.go
new file mode 100644
index 0000000000..3ca34af453
--- /dev/null
+++ b/internal/localadmin/connection_reverse.go
@@ -0,0 +1,58 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "errors"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+func reversePort(address string) (int, bool) {
+ u, err := url.Parse(address)
+ var port string
+ if err == nil && u.Host != "" {
+ port = u.Port()
+ } else {
+ _, port, err = net.SplitHostPort(address)
+ if err != nil {
+ return 0, false
+ }
+ }
+ n, err := strconv.Atoi(port)
+ return n, err == nil && n > 0 && n <= 65535
+}
+
+func validateReverseAddition(root jsonObject, add ConnectionAddition) error {
+ u, err := url.Parse(add.URL)
+ n, valid := reversePort(add.URL)
+ if !validConnectionURL(add.URL) || err != nil || u.Scheme != "ws" || u.Hostname() != "127.0.0.1" || !valid || strconv.Itoa(n) != u.Port() || (u.EscapedPath() != "" && u.EscapedPath() != "/") {
+ return errors.New("本机反向监听须使用 ws://127.0.0.1:端口,且只能使用根路径")
+ }
+ if add.WithoutToken || add.Token == "" {
+ return errors.New("本机反向监听必须配置新的 Token")
+ }
+ for _, kind := range []string{"ws", "wss"} {
+ _, raw, _ := field(root, kind)
+ var list []json.RawMessage
+ _ = json.Unmarshal(raw, &list)
+ for _, item := range list {
+ conn, _ := object(item)
+ _, value, _ := field(conn, "URL")
+ var address string
+ _ = json.Unmarshal(value, &address)
+ if port, ok := reversePort(address); !ok || port != n {
+ continue
+ }
+ if kind == "wss" {
+ return errors.New("该端口已用于已保存的反向监听,请编辑已有项或选择其他端口")
+ }
+ other, err := url.Parse(address)
+ if err == nil && (other.Hostname() == "127.0.0.1" || strings.EqualFold(other.Hostname(), "localhost") || other.Hostname() == "::1") {
+ return errors.New("该端口已用于本机正向连接目标,请选择其他监听端口")
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/localadmin/connection_reverse_test.go b/internal/localadmin/connection_reverse_test.go
new file mode 100644
index 0000000000..3f447c3ffd
--- /dev/null
+++ b/internal/localadmin/connection_reverse_test.go
@@ -0,0 +1,153 @@
+package localadmin
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestReverseAdditionPreservesForwardOtherFieldsAndRuntime(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ saved, err := s.Save(ConnectionUpdate{Revision: v.Revision, AddConnection: &ConnectionAddition{Kind: "wss", URL: "ws://127.0.0.1:17002/", Token: "reverse-new-secret"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(saved.Connections) != 4 || saved.Connections[3].Kind != "wss" || saved.Connections[3].Index != 1 || !saved.Connections[3].TokenConfigured || len(saved.RunningConnections) != 3 || saved.BackupID == "" || !saved.RestartRequired {
+ t.Fatal("reverse target/runtime/backup wrong")
+ }
+ raw, _ := os.ReadFile(s.path)
+ root, _ := decodeConfig(raw)
+ old, _ := decodeConfig([]byte(connectionFixture))
+ for k := range old {
+ if k != "wss" && !equivalentConfig(root[k], old[k]) {
+ t.Fatal("other field changed", k)
+ }
+ }
+ if !bytes.Contains(raw, []byte("9007199254740993")) {
+ t.Fatal("precision lost")
+ }
+ out, _ := json.Marshal(saved)
+ if bytes.Contains(out, []byte("secret")) {
+ t.Fatal("token returned")
+ }
+ reopened, err := NewConnectionStore(s.path, s.backups, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v := getSettings(t, reopened); v.RestartRequired || len(v.RunningConnections) != 4 {
+ t.Fatal("startup snapshot wrong")
+ }
+}
+func TestReverseAdditionValidatesDriverAddressPortsTokenAndLimits(t *testing.T) {
+ for _, address := range []string{"wss://127.0.0.1:17002", "ws://localhost:17002", "ws://0.0.0.0:17002", "ws://[::1]:17002", "ws://bot.example:17002", "ws://127.0.0.1", "ws://127.0.0.1:017002", "ws://127.0.0.1:0", "ws://127.0.0.1:65536", "ws://127.0.0.1:17002/custom", "ws://127.0.0.1:17002?key=private", "ws://user:private@127.0.0.1:17002", "ws://127.0.0.1:6700", "ws://127.0.0.1:6701"} {
+ _, err := applyConnectionUpdate([]byte(connectionFixture), ConnectionUpdate{AddConnection: &ConnectionAddition{Kind: "wss", URL: address, Token: "valid"}})
+ if err == nil {
+ t.Fatal("bad reverse address accepted", address)
+ }
+ }
+ for _, add := range []ConnectionAddition{{Kind: "unknown", URL: "ws://127.0.0.1:17002", Token: "valid"}, {Kind: "wss", URL: "ws://127.0.0.1:17002", WithoutToken: true}, {Kind: "wss", URL: "ws://127.0.0.1:17002"}, {Kind: "wss", URL: "ws://127.0.0.1:17002", Token: "***"}, {Kind: "wss", URL: "ws://127.0.0.1:17002", Token: "line\nbreak"}, {Kind: "wss", URL: "ws://127.0.0.1:17002", Token: strings.Repeat("中", 700)}} {
+ if _, err := applyConnectionUpdate([]byte(connectionFixture), ConnectionUpdate{AddConnection: &add}); err == nil {
+ t.Fatal("bad direction/token accepted")
+ }
+ }
+ for _, raw := range []string{`{"zero":{},"WSS":null}`, `{"zero":{}}`} {
+ next, err := applyConnectionUpdate([]byte(raw), ConnectionUpdate{AddConnection: &ConnectionAddition{Kind: "wss", URL: "ws://127.0.0.1:17002", Token: "valid"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, _ := decodeConfig(next)
+ k, items, _ := field(root, "wss")
+ if strings.Contains(raw, "WSS") && k != "WSS" || !bytes.Contains(items, []byte("17002")) {
+ t.Fatal("alias or missing list incorrect")
+ }
+ }
+ for _, address := range []string{"ws://0.0.0.0:17002/path", "tcp://127.0.0.1:17002", "127.0.0.1:17002"} {
+ root, _ := decodeConfig([]byte(connectionFixture))
+ root["wss"], _ = json.Marshal([]map[string]string{{"URL": address}})
+ raw, _ := json.Marshal(root)
+ if _, err := applyConnectionUpdate(raw, ConnectionUpdate{AddConnection: &ConnectionAddition{Kind: "wss", URL: "ws://127.0.0.1:17002", Token: "valid"}}); err == nil {
+ t.Fatal("listener port conflict ignored")
+ }
+ }
+ root, _ := decodeConfig([]byte(connectionFixture))
+ var list []map[string]string
+ for i := 0; i < 64; i++ {
+ list = append(list, map[string]string{"URL": fmt.Sprintf("ws://127.0.0.1:%d", 18000+i)})
+ }
+ root["wss"], _ = json.Marshal(list)
+ raw, _ := json.Marshal(root)
+ if _, err := applyConnectionUpdate(raw, ConnectionUpdate{AddConnection: &ConnectionAddition{Kind: "wss", URL: "ws://127.0.0.1:17002", Token: "valid"}}); err == nil {
+ t.Fatal("reverse limit bypassed")
+ }
+}
+func TestReverseAdditionReplacementFailureAndConcurrentVersion(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ s.replace = func(string, string) error { return errors.New("secret-private-failure") }
+ _, err := s.Save(ConnectionUpdate{Revision: v.Revision, AddConnection: &ConnectionAddition{Kind: "wss", URL: "ws://127.0.0.1:17002", Token: "valid"}})
+ if err == nil || strings.Contains(err.Error(), "secret-private") {
+ t.Fatal("failure accepted or exposed")
+ }
+ raw, _ := os.ReadFile(s.path)
+ if !bytes.Equal(raw, []byte(connectionFixture)) {
+ t.Fatal("failure changed config")
+ }
+ s = fixtureConnectionStore(t)
+ v = getSettings(t, s)
+ var wg sync.WaitGroup
+ results := make(chan error, 5)
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ _, err := s.Save(ConnectionUpdate{Revision: v.Revision, AddConnection: &ConnectionAddition{Kind: "wss", URL: fmt.Sprintf("ws://127.0.0.1:%d", 17002+i), Token: "valid"}})
+ results <- err
+ }(i)
+ }
+ wg.Wait()
+ close(results)
+ success := 0
+ for err := range results {
+ if err == nil {
+ success++
+ } else if !errors.Is(err, errConfigConflict) {
+ t.Fatal(err)
+ }
+ }
+ if success != 1 {
+ t.Fatal("reverse version not serialized")
+ }
+}
+func TestReverseAdditionAPIAuthAndAdminPortConflict(t *testing.T) {
+ store := fixtureConnectionStore(t)
+ cfg := testConfig(t)
+ cfg.Connections = store
+ cfg.Logs = NewLogStore()
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := getSettings(t, store)
+ body := ConnectionUpdate{Revision: v.Revision, AddConnection: &ConnectionAddition{Kind: "wss", URL: "ws://127.0.0.1:3010", Token: "reverse-api-secret"}}
+ expectStatus(t, request(s, "POST", "/api/connections", body, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/connections", body, cookie, "bad"), 403)
+ w := request(s, "POST", "/api/connections", body, cookie, csrf)
+ expectStatus(t, w, 400)
+ if !strings.Contains(w.Body.String(), "后台端口冲突") || getSettings(t, store).Revision != v.Revision {
+ t.Fatal("backend collision changed config")
+ }
+ body.AddConnection.URL = "ws://127.0.0.1:17002"
+ w = request(s, "POST", "/api/connections", body, cookie, csrf)
+ expectStatus(t, w, 200)
+ if strings.Contains(w.Body.String(), "reverse-api-secret") {
+ t.Fatal("API leaked token")
+ }
+ expectStatus(t, request(s, "POST", "/api/connections", body, cookie, csrf), 409)
+}
diff --git a/internal/localadmin/connections.go b/internal/localadmin/connections.go
new file mode 100644
index 0000000000..56eff544c2
--- /dev/null
+++ b/internal/localadmin/connections.go
@@ -0,0 +1,514 @@
+package localadmin
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "unicode"
+)
+
+var errConfigConflict = errors.New("配置已变化,请重新加载后再保存")
+var errConfigValidation = errors.New("配置校验失败")
+
+type jsonObject map[string]json.RawMessage
+
+// ConnectionStore changes only explicitly selected fields in the startup JSON.
+// Running drivers are never changed by saving; a restart loads the saved values.
+type ConnectionStore struct {
+ mu sync.Mutex
+ path, backups string
+ running []byte
+ revisionKey []byte
+ // replace is injectable for write failure tests.
+ replace func(string, string) error
+}
+
+type ConnectionView struct {
+ Kind string `json:"kind"`
+ Index int `json:"index"`
+ URL string `json:"url"`
+ TokenConfigured bool `json:"tokenConfigured"`
+ AddressRedacted bool `json:"addressRedacted"`
+}
+
+type ConnectionSettings struct {
+ Available bool `json:"available"`
+ Revision string `json:"revision"`
+ Connections []ConnectionView `json:"connections"`
+ SuperUsers []string `json:"superUsers"`
+ RunningConnections []ConnectionView `json:"runningConnections"`
+ RunningSuperUsers []string `json:"runningSuperUsers"`
+ RestartRequired bool `json:"restartRequired"`
+ BackupID string `json:"backupId,omitempty"`
+ StartupDifferences []StartupDifference `json:"startupDifferences"`
+}
+
+type ConnectionChange struct {
+ Kind string `json:"kind"`
+ Index int `json:"index"`
+ URL *string `json:"url,omitempty"`
+ TokenAction string `json:"tokenAction"`
+ Token string `json:"token,omitempty"`
+}
+
+type ConnectionUpdate struct {
+ Revision string `json:"revision"`
+ Connection *ConnectionChange `json:"connection,omitempty"`
+ SuperUsers *[]string `json:"superUsers,omitempty"`
+ AddConnection *ConnectionAddition `json:"addConnection,omitempty"`
+ RemoveConnection *ConnectionRemoval `json:"removeConnection,omitempty"`
+}
+
+func NewConnectionStore(path, backups string, running []byte) (*ConnectionStore, error) {
+ if path == "" {
+ return nil, errors.New("未使用启动配置文件")
+ }
+ p, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ b, err := filepath.Abs(backups)
+ if err != nil {
+ return nil, err
+ }
+ if _, err = decodeConfig(running); err != nil {
+ return nil, err
+ }
+ s := &ConnectionStore{path: p, backups: b, running: bytes.Clone(running), revisionKey: randomBytes(32), replace: replaceConfigFile}
+ if _, err = s.read(); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func object(raw []byte) (jsonObject, error) {
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ token, err := dec.Token()
+ if err != nil || token != json.Delim('{') {
+ return nil, errors.New("配置格式无效,须为 JSON 对象")
+ }
+ result := jsonObject{}
+ for dec.More() {
+ token, err := dec.Token()
+ if err != nil {
+ return nil, errors.New("配置格式无效")
+ }
+ key, ok := token.(string)
+ if !ok {
+ return nil, errors.New("配置格式无效")
+ }
+ if _, exists := result[key]; exists {
+ return nil, errors.New("配置字段重复,请先修正本地文件")
+ }
+ var value json.RawMessage
+ if err := dec.Decode(&value); err != nil {
+ return nil, errors.New("配置格式无效")
+ }
+ result[key] = value
+ }
+ if _, err := dec.Token(); err != nil {
+ return nil, errors.New("配置格式无效")
+ }
+ if err := dec.Decode(&struct{}{}); err != io.EOF {
+ return nil, errors.New("配置格式无效")
+ }
+ return result, nil
+}
+
+// Match Go's case-insensitive field handling but reject ambiguous aliases.
+func field(obj jsonObject, name string) (string, json.RawMessage, error) {
+ key := name
+ var value json.RawMessage
+ for k, v := range obj {
+ if strings.EqualFold(k, name) {
+ if value != nil {
+ return "", nil, errors.New("配置字段存在重复别名")
+ }
+ key, value = k, v
+ }
+ }
+ return key, value, nil
+}
+
+func decodeConfig(raw []byte) (jsonObject, error) {
+ root, err := object(raw)
+ if err != nil {
+ return nil, err
+ }
+ _, z, err := field(root, "zero")
+ if err != nil {
+ return nil, err
+ }
+ zero, err := object(z)
+ if err != nil {
+ return nil, errors.New("启动配置缺少有效的 zero 对象")
+ }
+ _, users, err := field(zero, "super_users")
+ if err != nil {
+ return nil, err
+ }
+ var ids []int64
+ if users != nil && json.Unmarshal(users, &ids) != nil {
+ return nil, errors.New("主人账号配置格式无效")
+ }
+ for _, id := range ids {
+ if id <= 0 {
+ return nil, errors.New("主人账号须为正整数")
+ }
+ }
+ for _, kind := range []string{"ws", "wss"} {
+ _, rawList, err := field(root, kind)
+ if err != nil {
+ return nil, err
+ }
+ var list []json.RawMessage
+ if rawList != nil && json.Unmarshal(rawList, &list) != nil {
+ return nil, errors.New("连接列表格式无效")
+ }
+ for _, rawConn := range list {
+ conn, err := object(rawConn)
+ if err != nil {
+ return nil, errors.New("连接须为 JSON 对象")
+ }
+ for _, key := range []string{"URL", "AccessToken"} {
+ _, value, err := field(conn, key)
+ if err != nil {
+ return nil, err
+ }
+ var text string
+ if value != nil && json.Unmarshal(value, &text) != nil {
+ return nil, errors.New("连接字段格式无效")
+ }
+ }
+ }
+ }
+ return root, nil
+}
+
+func (s *ConnectionStore) read() ([]byte, error) {
+ if err := regularFile(s.path); err != nil {
+ return nil, errors.New("配置文件不可读取或不是普通文件")
+ }
+ f, err := os.Open(s.path)
+ if err != nil {
+ return nil, errors.New("配置文件不可读取")
+ }
+ defer f.Close()
+ b, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
+ if err != nil || len(b) > 1<<20 {
+ return nil, errors.New("配置文件读取失败或超过 1 MiB")
+ }
+ if _, err = decodeConfig(b); err != nil {
+ return nil, err
+ }
+ return b, nil
+}
+
+func (s *ConnectionStore) revision(b []byte) string {
+ h := hmac.New(sha256.New, s.revisionKey)
+ h.Write(b)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func configView(raw []byte) ([]ConnectionView, []string) {
+ root, _ := decodeConfig(raw)
+ connections := []ConnectionView{}
+ for _, kind := range []string{"ws", "wss"} {
+ _, rawList, _ := field(root, kind)
+ var list []json.RawMessage
+ _ = json.Unmarshal(rawList, &list)
+ for i, item := range list {
+ conn, _ := object(item)
+ _, address, _ := field(conn, "URL")
+ _, secret, _ := field(conn, "AccessToken")
+ var endpoint, token string
+ _ = json.Unmarshal(address, &endpoint)
+ _ = json.Unmarshal(secret, &token)
+ redacted := false
+ u, err := url.Parse(endpoint)
+ if err != nil {
+ endpoint, redacted = "(地址格式无法展示)", true
+ } else if u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
+ u.User, u.RawQuery, u.Fragment, u.ForceQuery = nil, "", "", false
+ endpoint, redacted = u.String(), true
+ }
+ connections = append(connections, ConnectionView{kind, i, endpoint, token != "", redacted})
+ }
+ }
+ _, z, _ := field(root, "zero")
+ zero, _ := object(z)
+ _, users, _ := field(zero, "super_users")
+ var ids []int64
+ _ = json.Unmarshal(users, &ids)
+ result := []string{}
+ for _, id := range ids {
+ result = append(result, strconv.FormatInt(id, 10))
+ }
+ return connections, result
+}
+
+func equivalentConfig(a, b []byte) bool {
+ var av, bv any
+ ad, bd := json.NewDecoder(bytes.NewReader(a)), json.NewDecoder(bytes.NewReader(b))
+ ad.UseNumber()
+ bd.UseNumber()
+ _ = ad.Decode(&av)
+ _ = bd.Decode(&bv)
+ x, _ := json.Marshal(av)
+ y, _ := json.Marshal(bv)
+ return bytes.Equal(x, y)
+}
+
+func (s *ConnectionStore) view(raw []byte) ConnectionSettings {
+ c, users := configView(raw)
+ running, runtimeUsers := configView(s.running)
+ return ConnectionSettings{Available: true, Revision: s.revision(raw), Connections: c, SuperUsers: users, RunningConnections: running, RunningSuperUsers: runtimeUsers, RestartRequired: !equivalentConfig(raw, s.running), StartupDifferences: startupDifferences(s.running, raw)}
+}
+
+func (s *ConnectionStore) Get() (ConnectionSettings, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, err := s.read()
+ if err != nil {
+ return ConnectionSettings{}, err
+ }
+ return s.view(b), nil
+}
+
+func validConnectionURL(address string) bool {
+ if len(address) > 2048 || strings.IndexFunc(address, unicode.IsSpace) >= 0 {
+ return false
+ }
+ u, err := url.Parse(address)
+ if err != nil || (u.Scheme != "ws" && u.Scheme != "wss") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.Opaque != "" {
+ return false
+ }
+ if port := u.Port(); port != "" {
+ n, err := strconv.Atoi(port)
+ if err != nil || n < 1 || n > 65535 {
+ return false
+ }
+ }
+ return !strings.HasSuffix(u.Host, ":")
+}
+
+func applyConnectionUpdate(raw []byte, update ConnectionUpdate) ([]byte, error) {
+ root, _ := decodeConfig(raw)
+ if update.RemoveConnection != nil {
+ if update.Connection != nil || update.SuperUsers != nil || update.AddConnection != nil {
+ return nil, errors.New("删除连接须独立保存,不能同时提交其他配置修改")
+ }
+ return applyConnectionRemoval(root, *update.RemoveConnection)
+ }
+ if update.AddConnection != nil {
+ if update.Connection != nil || update.SuperUsers != nil {
+ return nil, errors.New("新增连接须独立保存,请先处理现有连接与主人草稿")
+ }
+ return applyConnectionAddition(root, *update.AddConnection)
+ }
+ if update.Connection == nil && update.SuperUsers == nil {
+ return nil, errors.New("没有选择要修改的配置")
+ }
+ if change := update.Connection; change != nil {
+ if change.Kind != "ws" && change.Kind != "wss" {
+ return nil, errors.New("连接类型无效")
+ }
+ key, rawList, _ := field(root, change.Kind)
+ var list []json.RawMessage
+ _ = json.Unmarshal(rawList, &list)
+ if change.Index < 0 || change.Index >= len(list) {
+ return nil, errors.New("连接不存在,请重新加载")
+ }
+ conn, _ := object(list[change.Index])
+ if change.URL != nil {
+ if !validConnectionURL(*change.URL) {
+ return nil, errors.New("地址须为有效的 ws/wss 地址;认证信息请填写在 Token 中")
+ }
+ k, _, _ := field(conn, "URL")
+ conn[k], _ = json.Marshal(*change.URL)
+ }
+ switch change.TokenAction {
+ case "keep":
+ if change.Token != "" {
+ return nil, errors.New("保留 Token 时不能同时提交新值")
+ }
+ case "replace":
+ if change.Token == "" || len(change.Token) > 2048 || strings.IndexFunc(change.Token, unicode.IsControl) >= 0 || strings.Trim(change.Token, "*• ") == "" {
+ return nil, errors.New("请输入有效的新 Token,不能使用脱敏占位文本")
+ }
+ k, _, _ := field(conn, "AccessToken")
+ conn[k], _ = json.Marshal(change.Token)
+ case "clear":
+ if change.Token != "" {
+ return nil, errors.New("清除 Token 时不能同时提交新值")
+ }
+ k, _, _ := field(conn, "AccessToken")
+ conn[k] = json.RawMessage(`""`)
+ default:
+ return nil, errors.New("请选择保留、替换或清除 Token")
+ }
+ list[change.Index], _ = json.Marshal(conn)
+ root[key], _ = json.Marshal(list)
+ }
+ if update.SuperUsers != nil {
+ if len(*update.SuperUsers) == 0 {
+ return nil, errors.New("请至少保留一个主人账号")
+ }
+ if len(*update.SuperUsers) > 32 {
+ return nil, errors.New("主人账号最多 32 个")
+ }
+ ids := []int64{}
+ seen := map[int64]bool{}
+ for _, text := range *update.SuperUsers {
+ id, err := strconv.ParseInt(text, 10, 64)
+ if err != nil || id <= 0 || strconv.FormatInt(id, 10) != text {
+ return nil, errors.New("主人账号须为不重复的正整数")
+ }
+ if seen[id] {
+ return nil, errors.New("主人账号不能重复")
+ }
+ seen[id] = true
+ ids = append(ids, id)
+ }
+ k, z, _ := field(root, "zero")
+ zero, _ := object(z)
+ userKey, _, _ := field(zero, "super_users")
+ zero[userKey], _ = json.Marshal(ids)
+ root[k], _ = json.Marshal(zero)
+ }
+ b, err := json.MarshalIndent(root, "", " ")
+ return append(b, '\n'), err
+}
+
+func (s *ConnectionStore) Save(update ConnectionUpdate) (ConnectionSettings, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ old, err := s.read()
+ if err != nil {
+ return ConnectionSettings{}, err
+ }
+ if !hmac.Equal([]byte(update.Revision), []byte(s.revision(old))) {
+ return ConnectionSettings{}, errConfigConflict
+ }
+ next, err := applyConnectionUpdate(old, update)
+ if err != nil {
+ return ConnectionSettings{}, fmt.Errorf("%w: %s", errConfigValidation, err)
+ }
+ if equivalentConfig(old, next) {
+ return s.view(old), nil
+ }
+ if len(next) > 1<<20 {
+ return ConnectionSettings{}, fmt.Errorf("%w: 修改后配置超过 1 MiB,原配置保留", errConfigValidation)
+ }
+ if err = os.MkdirAll(s.backups, 0700); err != nil {
+ return ConnectionSettings{}, errors.New("备份目录不可写,配置未保存")
+ }
+ info, err := os.Lstat(s.backups)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return ConnectionSettings{}, errors.New("备份目录无效,配置未保存")
+ }
+ if err = protectPath(s.backups, true); err != nil {
+ return ConnectionSettings{}, errors.New("备份权限设置失败,配置未保存")
+ }
+ id, err := writeFileBackup(s.backups, "startup-config", old)
+ if err != nil {
+ return ConnectionSettings{}, errors.New("写前备份失败,配置未保存")
+ }
+ // Catch an external edit made while the backup was being created.
+ current, err := s.read()
+ if err != nil || !bytes.Equal(old, current) {
+ return ConnectionSettings{}, errConfigConflict
+ }
+ f, err := os.CreateTemp(filepath.Dir(s.path), ".admin-config-*.tmp")
+ if err != nil {
+ return ConnectionSettings{}, errors.New("配置目录不可写,原配置保留")
+ }
+ tmp := f.Name()
+ defer os.Remove(tmp)
+ if err = protectPath(tmp, false); err == nil {
+ _, err = f.Write(next)
+ }
+ if err == nil {
+ err = f.Sync()
+ }
+ closeErr := f.Close()
+ if err == nil {
+ err = closeErr
+ }
+ if err == nil {
+ current, checkErr := s.read()
+ if checkErr != nil || !bytes.Equal(old, current) {
+ return ConnectionSettings{}, errConfigConflict
+ }
+ err = s.replace(tmp, s.path)
+ }
+ if err != nil {
+ return ConnectionSettings{}, errors.New("配置写入失败,原配置保留;写前备份已保存")
+ }
+ result := s.view(next)
+ result.BackupID = id
+ return result, nil
+}
+
+func (s *Server) getConnections(w http.ResponseWriter, _ *http.Request) {
+ if s.cfg.Connections == nil {
+ jsonReply(w, 200, ConnectionSettings{Available: false})
+ return
+ }
+ value, err := s.cfg.Connections.Get()
+ if err != nil {
+ apiError(w, 500, err.Error())
+ return
+ }
+ jsonReply(w, 200, value)
+}
+
+func (s *Server) saveConnections(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Connections == nil {
+ apiError(w, 409, "请使用 -c 启动配置文件后再管理连接")
+ return
+ }
+ var update ConnectionUpdate
+ if !readJSONLimit(w, r, &update, 16384) {
+ return
+ }
+ if s.cfg.Logs != nil && update.Connection != nil {
+ s.cfg.Logs.AddSecrets(update.Connection.Token)
+ }
+ if s.cfg.Logs != nil && update.AddConnection != nil {
+ s.cfg.Logs.AddSecrets(update.AddConnection.Token)
+ }
+ if add := update.AddConnection; add != nil && add.Kind == "wss" {
+ port, ok := reversePort(add.URL)
+ adminPort, adminOK := reversePort("ws://" + s.cfg.Addr)
+ if ok && adminOK && port == adminPort {
+ apiError(w, 400, "反向监听端口与当前后台端口冲突,请选择其他端口")
+ return
+ }
+ }
+ value, err := s.cfg.Connections.Save(update)
+ if err != nil {
+ status := 500
+ if errors.Is(err, errConfigValidation) {
+ status = 400
+ }
+ if errors.Is(err, errConfigConflict) {
+ status = 409
+ }
+ apiError(w, status, err.Error())
+ return
+ }
+ jsonReply(w, 200, value)
+}
diff --git a/internal/localadmin/connections_test.go b/internal/localadmin/connections_test.go
new file mode 100644
index 0000000000..f2f4bd6281
--- /dev/null
+++ b/internal/localadmin/connections_test.go
@@ -0,0 +1,307 @@
+package localadmin
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+const connectionFixture = `{"zero":{"super_users":[12345678],"nickname":["fixture"],"future":{"n":9007199254740993}},"ws":[{"Url":"ws://127.0.0.1:6700","AccessToken":"secret-A","extension":{"keep":true}},{"URL":"wss://bot.example/ws","AccessToken":"secret-B"}],"wss":[{"URL":"ws://127.0.0.1:6701","AccessToken":"secret-C"}],"force_base64_file":true,"unknown":{"nested":[1,2,3]}}`
+
+func fixtureConnectionStore(t *testing.T) *ConnectionStore {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(path, []byte(connectionFixture), 0600); err != nil {
+ t.Fatal(err)
+ }
+ s, err := NewConnectionStore(path, filepath.Join(dir, "private", "backups"), []byte(connectionFixture))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func getSettings(t *testing.T, s *ConnectionStore) ConnectionSettings {
+ t.Helper()
+ v, err := s.Get()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return v
+}
+
+func stringPointer(s string) *string { return &s }
+
+func TestConnectionSavePreservesFieldsBackupAndRuntime(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ encoded, _ := json.Marshal(v)
+ if strings.Contains(string(encoded), "secret-") || v.RestartRequired || len(v.Connections) != 3 || v.SuperUsers[0] != "12345678" {
+ t.Fatal("bad initial redacted view")
+ }
+ users := []string{"87654321", "9007199254740993"}
+ saved, err := s.Save(ConnectionUpdate{Revision: v.Revision, Connection: &ConnectionChange{Kind: "ws", Index: 0, URL: stringPointer("ws://127.0.0.1:7700/ws"), TokenAction: "keep"}, SuperUsers: &users})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !saved.RestartRequired || saved.BackupID == "" || saved.RunningConnections[0].URL != v.Connections[0].URL || saved.RunningSuperUsers[0] != "12345678" {
+ t.Fatal("running state changed on save")
+ }
+ raw, _ := os.ReadFile(s.path)
+ root, _ := decodeConfig(raw)
+ original, _ := decodeConfig([]byte(connectionFixture))
+ for _, key := range []string{"unknown", "force_base64_file", "wss"} {
+ if !equivalentConfig(root[key], original[key]) {
+ t.Fatalf("lost %s", key)
+ }
+ }
+ _, zeroRaw, _ := field(root, "zero")
+ zero, _ := object(zeroRaw)
+ if !bytes.Contains(zero["future"], []byte("9007199254740993")) || !strings.Contains(string(raw), "secret-A") || !strings.Contains(string(raw), "secret-B") {
+ t.Fatal("lost original fields, precision, or token")
+ }
+ var list []json.RawMessage
+ _ = json.Unmarshal(root["ws"], &list)
+ var originalList []json.RawMessage
+ _ = json.Unmarshal(original["ws"], &originalList)
+ if !equivalentConfig(list[1], originalList[1]) || !bytes.Contains(list[0], []byte(`"extension"`)) {
+ t.Fatal("changed unedited connection")
+ }
+ backupRaw, err := os.ReadFile(filepath.Join(s.backups, saved.BackupID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var backup struct {
+ Version int
+ Scope string
+ Config json.RawMessage
+ }
+ if json.Unmarshal(backupRaw, &backup) != nil || backup.Version != 1 || backup.Scope != "startup-config" || !equivalentConfig(backup.Config, []byte(connectionFixture)) {
+ t.Fatal("backup did not contain old config")
+ }
+ restarted, err := NewConnectionStore(s.path, s.backups, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if getSettings(t, restarted).RestartRequired {
+ t.Fatal("restart did not consume saved config")
+ }
+ if _, err = s.Save(ConnectionUpdate{Revision: v.Revision, SuperUsers: &users}); !errors.Is(err, errConfigConflict) {
+ t.Fatal("stale write was accepted")
+ }
+}
+
+func TestTokenKeepReplaceClearAndNoop(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ noop, err := s.Save(ConnectionUpdate{Revision: v.Revision, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "keep"}})
+ if err != nil || noop.BackupID != "" || noop.Revision != v.Revision {
+ t.Fatal("no-op created a backup or changed revision")
+ }
+ changed, err := s.Save(ConnectionUpdate{Revision: v.Revision, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "replace", Token: "new-fake-token"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := os.ReadFile(s.path)
+ if !strings.Contains(string(raw), "new-fake-token") || strings.Contains(string(raw), "secret-A") {
+ t.Fatal("replacement did not persist")
+ }
+ cleared, err := s.Save(ConnectionUpdate{Revision: changed.Revision, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "clear"}})
+ if err != nil || cleared.Connections[0].TokenConfigured || !cleared.RunningConnections[0].TokenConfigured {
+ t.Fatal("clear or runtime separation failed")
+ }
+}
+
+func TestConnectionValidationDoesNotWrite(t *testing.T) {
+ badChanges := []ConnectionChange{
+ {Kind: "../ws", Index: 0, TokenAction: "keep"}, {Kind: "ws", Index: -1, TokenAction: "keep"}, {Kind: "ws", Index: 9, TokenAction: "keep"},
+ {Kind: "ws", Index: 0, URL: stringPointer("http://127.0.0.1:6700"), TokenAction: "keep"},
+ {Kind: "ws", Index: 0, URL: stringPointer("ws://127.0.0.1:0"), TokenAction: "keep"},
+ {Kind: "ws", Index: 0, URL: stringPointer("ws://127.0.0.1:65536"), TokenAction: "keep"},
+ {Kind: "ws", Index: 0, URL: stringPointer("ws://user:password@host/ws"), TokenAction: "keep"},
+ {Kind: "ws", Index: 0, URL: stringPointer("ws://host/ws?token=secret"), TokenAction: "keep"},
+ {Kind: "ws", Index: 0, TokenAction: "replace", Token: "***"}, {Kind: "ws", Index: 0, TokenAction: "replace"},
+ {Kind: "ws", Index: 0, TokenAction: "replace", Token: "token\nheader"}, {Kind: "ws", Index: 0, TokenAction: "keep", Token: "unexpected"},
+ {Kind: "ws", Index: 0, TokenAction: "clear", Token: "unexpected"}, {Kind: "ws", Index: 0, TokenAction: "unknown"},
+ }
+ s := fixtureConnectionStore(t)
+ initial := getSettings(t, s)
+ for i, change := range badChanges {
+ if _, err := s.Save(ConnectionUpdate{Revision: initial.Revision, Connection: &change}); err == nil {
+ t.Fatalf("accepted invalid change %d", i)
+ }
+ }
+ for _, users := range [][]string{{}, {"abc"}, {"-1"}, {"01"}, {"123", "123"}, {"9223372036854775808"}} {
+ if _, err := s.Save(ConnectionUpdate{Revision: initial.Revision, SuperUsers: &users}); err == nil {
+ t.Fatal("accepted invalid owner")
+ }
+ }
+ raw, _ := os.ReadFile(s.path)
+ if string(raw) != connectionFixture {
+ t.Fatal("invalid input changed disk")
+ }
+ if _, err := os.Stat(s.backups); !os.IsNotExist(err) {
+ t.Fatal("invalid input created backups")
+ }
+}
+
+func TestFormattedConfigSizeLimitDoesNotWrite(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ raw := []byte(`{"zero":{"super_users":[12345678]},"ws":[],"extra":[` + strings.Repeat("0,", 200000) + `0]}`)
+ if err := os.WriteFile(s.path, raw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ view := getSettings(t, s)
+ users := []string{"87654321"}
+ if _, err := s.Save(ConnectionUpdate{Revision: view.Revision, SuperUsers: &users}); !errors.Is(err, errConfigValidation) {
+ t.Fatal("accepted formatted output over startup size limit")
+ }
+ actual, err := os.ReadFile(s.path)
+ if err != nil || !bytes.Equal(raw, actual) {
+ t.Fatal("oversized output changed original")
+ }
+ if _, err := os.Stat(s.backups); !os.IsNotExist(err) {
+ t.Fatal("oversized output created backups")
+ }
+}
+
+func TestBackupAndReplaceFailureLeaveOriginal(t *testing.T) {
+ for _, failure := range []string{"backup", "replace"} {
+ t.Run(failure, func(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ users := []string{"87654321"}
+ if failure == "backup" {
+ if err := os.MkdirAll(filepath.Dir(s.backups), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(s.backups, []byte("blocked"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ s.replace = func(string, string) error { return errors.New("fake-secret-must-not-leak") }
+ }
+ _, err := s.Save(ConnectionUpdate{Revision: v.Revision, SuperUsers: &users})
+ if err == nil || strings.Contains(err.Error(), "fake-secret") {
+ t.Fatal("failure was not safely reported")
+ }
+ raw, _ := os.ReadFile(s.path)
+ if string(raw) != connectionFixture {
+ t.Fatal("failed write changed original")
+ }
+ files, _ := filepath.Glob(filepath.Join(filepath.Dir(s.path), ".admin-config-*.tmp"))
+ if len(files) != 0 {
+ t.Fatal("temporary config leaked")
+ }
+ if failure == "replace" {
+ files, _ := os.ReadDir(s.backups)
+ if len(files) != 1 {
+ t.Fatal("write failure lost backup")
+ }
+ }
+ })
+ }
+}
+
+func TestConcurrentAndExternalEdits(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ initial := getSettings(t, s)
+ var wg sync.WaitGroup
+ results := make(chan error, 8)
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ users := []string{"87654321"}
+ _, err := s.Save(ConnectionUpdate{Revision: initial.Revision, SuperUsers: &users})
+ results <- err
+ }()
+ }
+ wg.Wait()
+ close(results)
+ success := 0
+ for err := range results {
+ if err == nil {
+ success++
+ } else if !errors.Is(err, errConfigConflict) {
+ t.Fatal(err)
+ }
+ }
+ if success != 1 {
+ t.Fatalf("concurrent writes succeeded %d times", success)
+ }
+ view := getSettings(t, s)
+ if err := os.WriteFile(s.path, []byte(connectionFixture), 0600); err != nil {
+ t.Fatal(err)
+ }
+ users := []string{"76543210"}
+ if _, err := s.Save(ConnectionUpdate{Revision: view.Revision, SuperUsers: &users}); !errors.Is(err, errConfigConflict) {
+ t.Fatal("external edit overwritten")
+ }
+}
+
+func TestConnectionsAPIAuthCSRFAndRedaction(t *testing.T) {
+ s := newTestServer(t)
+ s.cfg.Connections = fixtureConnectionStore(t)
+ expectStatus(t, request(s, "GET", "/api/connections", nil, nil, ""), 401)
+ view := getSettings(t, s.cfg.Connections)
+ users := []string{"87654321"}
+ update := ConnectionUpdate{Revision: view.Revision, SuperUsers: &users}
+ expectStatus(t, request(s, "POST", "/api/connections", update, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/connections", update, cookie, "wrong"), 403)
+ rsp := request(s, "GET", "/api/connections", nil, cookie, "")
+ expectStatus(t, rsp, 200)
+ if strings.Contains(rsp.Body.String(), "secret-") || rsp.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("secret leaked or response cached")
+ }
+ expectStatus(t, request(s, "POST", "/api/connections", update, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/connections", update, cookie, csrf), 409)
+ invalid := ConnectionUpdate{Revision: getSettings(t, s.cfg.Connections).Revision, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "replace", Token: "***"}}
+ expectStatus(t, request(s, "POST", "/api/connections", invalid, cookie, csrf), 400)
+ s.cfg.Connections.replace = func(string, string) error { return errors.New("injected disk failure") }
+ update.Revision = getSettings(t, s.cfg.Connections).Revision
+ users = []string{"34567890"}
+ update.SuperUsers = &users
+ expectStatus(t, request(s, "POST", "/api/connections", update, cookie, csrf), 500)
+ expectStatus(t, request(s, "GET", "/backups/anything.json", nil, cookie, ""), 404)
+}
+
+func TestSensitiveURLAndBrokenJSON(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ raw := strings.ReplaceAll(connectionFixture, "ws://127.0.0.1:6700", "ws://user:password@127.0.0.1:6700/ws?token=url-secret#private")
+ if err := os.WriteFile(s.path, []byte(raw), 0600); err != nil {
+ t.Fatal(err)
+ }
+ v := getSettings(t, s)
+ encoded, _ := json.Marshal(v)
+ for _, secret := range []string{"password", "url-secret", "private"} {
+ if bytes.Contains(encoded, []byte(secret)) {
+ t.Fatal("URL credential leaked")
+ }
+ }
+ if !v.Connections[0].AddressRedacted {
+ t.Fatal("redaction not marked")
+ }
+ if err := os.WriteFile(s.path, []byte(`{"zero":`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Get(); err == nil {
+ t.Fatal("broken JSON accepted")
+ }
+ if _, err := decodeConfig([]byte(`{"zero":{},"ws":[null]}`)); err == nil {
+ t.Fatal("null connection accepted")
+ }
+ if _, err := decodeConfig([]byte(`{"zero":{},"ws":[{"URL":"ws://localhost","url":"ws://other"}]}`)); err == nil {
+ t.Fatal("ambiguous fields accepted")
+ }
+ if _, err := decodeConfig([]byte(`{"zero":{},"ws":[],"ws":[]}`)); err == nil {
+ t.Fatal("duplicate root field accepted")
+ }
+}
diff --git a/internal/localadmin/database_backup.go b/internal/localadmin/database_backup.go
new file mode 100644
index 0000000000..f0366e8d75
--- /dev/null
+++ b/internal/localadmin/database_backup.go
@@ -0,0 +1,74 @@
+package localadmin
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+// BackupControlDatabase uses SQLite's consistent snapshot, including WAL data.
+// Call under the control Manager lock to keep the snapshot before its mutation.
+func BackupControlDatabase(database *sql.Sqlite, directory string) (string, error) {
+ if err := os.MkdirAll(directory, 0700); err != nil {
+ return "", errors.New("控制数据库备份目录不可写")
+ }
+ info, err := os.Lstat(directory)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("控制数据库备份目录无效")
+ }
+ if err = protectPath(directory, true); err != nil {
+ return "", errors.New("控制数据库备份权限设置失败")
+ }
+ id := time.Now().UTC().Format("20060102T150405.000000000Z") + "-" + hex.EncodeToString(randomBytes(8)) + "-control"
+ root := filepath.Join(directory, id)
+ if err = os.Mkdir(root, 0700); err != nil {
+ return "", err
+ }
+ if err = protectPath(root, true); err != nil {
+ return "", err
+ }
+ path := filepath.Join(root, "plugins.sqlite")
+ if err = writeNewPrivateFile(path, nil); err != nil {
+ return "", err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if _, err = database.ExecContext(ctx, "VACUUM INTO ?", path); err != nil {
+ return "", errors.New("控制数据库快照失败,开关未修改")
+ }
+ f, err := os.OpenFile(path, os.O_RDWR, 0600)
+ if err != nil {
+ return "", err
+ }
+ digest := sha256.New()
+ _, err = io.Copy(digest, f)
+ if err == nil {
+ err = f.Sync()
+ }
+ closeErr := f.Close()
+ if err != nil {
+ return "", err
+ }
+ if closeErr != nil {
+ return "", closeErr
+ }
+ manifest, _ := json.MarshalIndent(struct {
+ Version int `json:"version"`
+ CreatedAt time.Time `json:"createdAt"`
+ Scope string `json:"scope"`
+ File string `json:"file"`
+ SHA256 string `json:"sha256"`
+ }{1, time.Now().UTC(), "control-db", "plugins.sqlite", hex.EncodeToString(digest.Sum(nil))}, "", " ")
+ if err = writeNewPrivateFile(filepath.Join(root, "manifest.json"), manifest); err != nil {
+ return "", err
+ }
+ return id, nil
+}
diff --git a/internal/localadmin/log_issues_test.go b/internal/localadmin/log_issues_test.go
new file mode 100644
index 0000000000..06c045afbb
--- /dev/null
+++ b/internal/localadmin/log_issues_test.go
@@ -0,0 +1,95 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+func fillIssueLogs(t *testing.T, l *LogStore) {
+ t.Helper()
+ // Fire captures these levels without invoking logger.Fatal/Panic.
+ for _, level := range logrus.AllLevels {
+ if err := l.Fire(&logrus.Entry{Level: level, Time: time.Now(), Message: "Needle " + level.String() + " synthetic-log-secret"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestLogIssuesFilterPaginationAndRedaction(t *testing.T) {
+ l := NewLogStore()
+ fillIssueLogs(t, l)
+ l.AddSecrets("synthetic-log-secret")
+ q, err := parseLogQuery(url.Values{"level": {"ISSUES"}, "q": {"needle"}, "limit": {"2"}})
+ if err != nil || q.level != "issues" {
+ t.Fatal("issue filter normalization failed", err)
+ }
+ p := l.page(q)
+ if len(p.Entries) != 2 || p.Entries[0].Level != "error" || p.Entries[1].Level != "warning" || p.Cursor != "7" {
+ t.Fatal("initial latest page did not select only issue levels", p)
+ }
+ q.after, q.instance = 1, l.instance
+ p = l.page(q)
+ if len(p.Entries) != 2 || p.Entries[0].Level != "fatal" || p.Entries[1].Level != "error" || p.Cursor != "3" {
+ t.Fatal("incremental issue page lost order/cursor", p)
+ }
+ q.after = 3
+ p = l.page(q)
+ if len(p.Entries) != 1 || p.Entries[0].Level != "warning" || p.Cursor != "7" {
+ t.Fatal("incremental issue tail incorrect", p)
+ }
+ q.after, q.limit = 0, logCapacity
+ q.keyword = "synthetic-log-secret"
+ if len(l.page(q).Entries) != 0 {
+ t.Fatal("issue keyword matched unredacted secret")
+ }
+ q.keyword = "[已隐藏]"
+ if len(l.page(q).Entries) != 4 {
+ t.Fatal("issue keyword did not match redacted text")
+ }
+ q.level, q.keyword = "info", "needle"
+ if p := l.page(q); len(p.Entries) != 1 || p.Entries[0].Level != "info" {
+ t.Fatal("exact level filter changed")
+ }
+}
+
+func TestLogIssuesAPIAndExportShareSelection(t *testing.T) {
+ l := NewLogStore()
+ fillIssueLogs(t, l)
+ l.AddSecrets("synthetic-log-secret")
+ cfg := testConfig(t)
+ cfg.Logs = l
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/api/logs?level=issues", "/api/logs/export?level=issues"} {
+ expectStatus(t, request(s, "GET", path, nil, nil, ""), 401)
+ }
+ cookie, _ := enroll(t, s)
+ w := request(s, "GET", "/api/logs?level=ISSUES&q=needle", nil, cookie, "")
+ expectStatus(t, w, 200)
+ var page LogPage
+ if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil || len(page.Entries) != 4 {
+ t.Fatal("issues API returned wrong selection", err)
+ }
+ w = request(s, "GET", "/api/logs/export?level=issues&q=needle", nil, cookie, "")
+ expectStatus(t, w, 200)
+ ids := regexp.MustCompile(`\] #([0-9]+) `).FindAllStringSubmatch(w.Body.String(), -1)
+ if len(ids) != len(page.Entries) || strings.Contains(w.Body.String(), "synthetic-log-secret") || strings.Contains(w.Body.String(), "[INFO]") || strings.Contains(w.Body.String(), "[DEBUG]") || strings.Contains(w.Body.String(), "[TRACE]") || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("issues export selection/redaction differs from viewer")
+ }
+ for i, row := range page.Entries {
+ if ids[i][1] != row.ID {
+ t.Fatal("issues export lost chronological order")
+ }
+ }
+ for _, path := range []string{"/api/logs?level=unknown", "/api/logs/export?level=unknown", "/api/logs/export?level=issues&level=info"} {
+ expectStatus(t, request(s, "GET", path, nil, cookie, ""), 400)
+ }
+}
diff --git a/internal/localadmin/log_summary_test.go b/internal/localadmin/log_summary_test.go
new file mode 100644
index 0000000000..b9a7453813
--- /dev/null
+++ b/internal/localadmin/log_summary_test.go
@@ -0,0 +1,229 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+func summaryLog(t *testing.T, l *LogStore, at time.Time, level logrus.Level, message string) {
+ t.Helper()
+ if err := l.Fire(&logrus.Entry{Time: at, Level: level, Message: message}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestLogSummaryIndependentOfLimitCursorAndReset(t *testing.T) {
+ l := NewLogStore()
+ now := time.Now()
+ for i := 0; i < 250; i++ {
+ summaryLog(t, l, now, logrus.InfoLevel, "info fixture")
+ }
+ for _, level := range []logrus.Level{logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel, logrus.WarnLevel, logrus.DebugLevel, logrus.TraceLevel} {
+ summaryLog(t, l, now, level, "issue fixture")
+ }
+ first := l.page(logQuery{limit: 200})
+ if len(first.Entries) != 200 || first.Summary.Matched != 256 || first.Summary.Levels["info"] != 250 || first.Cursor != "256" {
+ t.Fatal("first page summary limited to displayed rows")
+ }
+ for _, q := range []logQuery{{after: 1, limit: 2}, {after: 256, limit: 200}, {after: 999, limit: 2}, {after: 256, instance: "0000000000000000", limit: 2}} {
+ p := l.page(q)
+ if !reflect.DeepEqual(p.Summary, first.Summary) {
+ t.Fatal("cursor/limit/reset changed snapshot summary")
+ }
+ if q.after == 1 && (len(p.Entries) != 2 || p.Cursor != "3") {
+ t.Fatal("incremental cursor semantics changed")
+ }
+ if q.after == 256 && q.instance == "" && len(p.Entries) != 0 {
+ t.Fatal("old entries returned again")
+ }
+ }
+ issues := l.page(logQuery{after: 250, level: "issues", limit: 2})
+ if len(issues.Entries) != 2 || issues.Cursor != "252" || issues.Summary.Matched != 4 || issues.Summary.Levels["panic"] != 1 || issues.Summary.Levels["fatal"] != 1 || issues.Summary.Levels["error"] != 1 || issues.Summary.Levels["warning"] != 1 || issues.Summary.Levels["info"] != 0 {
+ t.Fatal("issues summary or cursor incorrect")
+ }
+}
+
+func TestLogSummaryRedactedKeywordsTimeBoundsAndLateSecrets(t *testing.T) {
+ l := NewLogStore()
+ base := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
+ summaryLog(t, l, base, logrus.WarnLevel, "Needle before")
+ summaryLog(t, l, base.Add(time.Second), logrus.ErrorLevel, "Needle unicode 汉🙂")
+ summaryLog(t, l, base.Add(2*time.Second), logrus.InfoLevel, "Needle after")
+ summaryLog(t, l, base.Add(time.Second), logrus.WarnLevel, "unknown-late-credential")
+ l.AddSecrets("unknown-late-credential")
+ for _, after := range []uint64{0, 2, 4} {
+ p := l.page(logQuery{after: after, limit: 1, keyword: "unknown-late-credential"})
+ if p.Summary.Matched != 0 || len(p.Entries) != 0 {
+ t.Fatal("summary inferred a raw credential")
+ }
+ p = l.page(logQuery{after: after, limit: 1, keyword: "[已隐藏]"})
+ if p.Summary.Matched != 1 || p.Summary.Levels["warning"] != 1 {
+ t.Fatal("old row keyword did not use current redaction")
+ }
+ }
+ bound := base.Add(time.Second)
+ p := l.page(logQuery{limit: 1, from: &bound, until: &bound, keyword: "NEEDLE", level: "issues"})
+ if p.Summary.Matched != 1 || p.Summary.Levels["error"] != 1 || len(p.Entries) != 1 {
+ t.Fatal("inclusive time/severity/Unicode keyword combination incorrect")
+ }
+ before := l.page(logQuery{limit: 1}).Summary
+ l.maskAll = true
+ p = l.page(logQuery{after: 4, limit: 1, keyword: "needle"})
+ if p.Summary.Matched != 0 {
+ t.Fatal("mask-all keyword leaked old content")
+ }
+ if !reflect.DeepEqual(before, l.page(logQuery{after: 4, limit: 1}).Summary) {
+ t.Fatal("metadata-only counts changed with current credentials")
+ }
+}
+
+func TestLogSummaryEvictionByteBoundAndUnknownLevelBucket(t *testing.T) {
+ l := NewLogStore()
+ for i := 0; i < 1100; i++ {
+ level := logrus.InfoLevel
+ if i < 100 {
+ level = logrus.WarnLevel
+ }
+ summaryLog(t, l, time.Now(), level, "eviction fixture")
+ }
+ p := l.page(logQuery{after: 1, limit: 2})
+ if p.Summary.Matched != 1000 || p.Summary.Levels["warning"] != 0 || p.Summary.Levels["info"] != 1000 || !p.Gap {
+ t.Fatal("summary includes evicted logs")
+ }
+ l.mu.Lock()
+ l.rows[l.start].Level = ""
+ l.mu.Unlock()
+ p = l.page(logQuery{after: 1100, limit: 2})
+ raw, _ := json.Marshal(p.Summary)
+ if p.Summary.Levels["other"] != 1 || p.Summary.Levels["info"] != 999 || strings.Contains(string(raw), "private") || len(p.Summary.Levels) != 8 {
+ t.Fatal("unknown level leaked or broke fixed summary")
+ }
+ for i := 0; i < 1000; i++ {
+ summaryLog(t, l, time.Now(), logrus.DebugLevel, strings.Repeat("汉", 600))
+ }
+ p = l.page(logQuery{limit: 1})
+ if p.Retained >= 1000 || p.Summary.Matched != p.Retained {
+ t.Fatal("summary ignores byte-capacity eviction")
+ }
+ var total int
+ for _, count := range p.Summary.Levels {
+ total += count
+ }
+ if total != p.Summary.Matched {
+ t.Fatal("level counts do not sum to matching retained rows")
+ }
+}
+
+func TestLogSummaryAPIAuthReadOnlyAndExportAgreement(t *testing.T) {
+ l := NewLogStore()
+ cfg := testConfig(t)
+ cfg.Logs = l
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(s, "GET", "/api/logs?q=needle", nil, nil, ""), 401)
+ cookie, _ := enroll(t, s)
+ for i := 0; i < 250; i++ {
+ summaryLog(t, l, time.Now(), logrus.WarnLevel, "Needle synthetic-warning")
+ }
+ before := l.next
+ rsp := request(s, "GET", "/api/logs?after=250&limit=1&level=issues&q=needle", nil, cookie, "")
+ expectStatus(t, rsp, 200)
+ var p LogPage
+ if err := json.Unmarshal(rsp.Body.Bytes(), &p); err != nil {
+ t.Fatal(err)
+ }
+ if len(p.Entries) != 0 || p.Summary.Matched != 250 || p.Summary.Levels["warning"] != 250 || before != l.next {
+ t.Fatal("read-only API summary incorrect")
+ }
+ exported := request(s, "GET", "/api/logs/export?level=issues&q=needle", nil, cookie, "")
+ expectStatus(t, exported, 200)
+ if strings.Count(exported.Body.String(), "] [WARNING] #") != p.Summary.Matched {
+ t.Fatal("summary disagrees with unpaginated export")
+ }
+ for _, query := range []string{"level=invalid", "after=-1", "limit=201", "from=invalid"} {
+ expectStatus(t, request(s, "GET", "/api/logs?"+query, nil, cookie, ""), 400)
+ }
+}
+
+func TestLogSummaryReadCacheSecretChangesAndEviction(t *testing.T) {
+ l := NewLogStore()
+ summaryLog(t, l, time.Now(), logrus.InfoLevel, "late-model-secret needle")
+ query := logQuery{limit: 200, keyword: "late-model-secret"}
+ if l.page(query).Summary.Matched != 1 {
+ t.Fatal("initial cache fixture mismatch")
+ }
+ version := l.maskVersion
+ l.AddSecrets("late-model-secret")
+ if l.maskVersion == version || l.page(query).Summary.Matched != 0 {
+ t.Fatal("new key did not invalidate old read result")
+ }
+ version = l.maskVersion
+ l.AddSecrets("late-model-secret")
+ if l.maskVersion != version || l.page(query).Summary.Matched != 0 {
+ t.Fatal("duplicate keys invalidated or changed redaction")
+ }
+ if l.page(logQuery{limit: 200, keyword: "[已隐藏]"}).Summary.Matched != 1 {
+ t.Fatal("refreshed cache did not contain current mask")
+ }
+ for i := 0; i < 1000; i++ {
+ summaryLog(t, l, time.Now(), logrus.DebugLevel, "fresh-cache-fixture")
+ }
+ if l.page(logQuery{limit: 200, keyword: "needle"}).Summary.Matched != 0 {
+ t.Fatal("evicted row survived in cached search")
+ }
+ l.mu.Lock()
+ for l.count > 0 {
+ l.evictLocked()
+ }
+ for _, cache := range l.readMasks {
+ if cache != (logReadMask{}) {
+ l.mu.Unlock()
+ t.Fatal("eviction retained a cached message reference")
+ }
+ }
+ l.mu.Unlock()
+ summaryLog(t, l, time.Now(), logrus.InfoLevel, "another-late-secret")
+ if l.page(logQuery{limit: 200, keyword: "another-late-secret"}).Summary.Matched != 1 {
+ t.Fatal("new row was replaced by old cache")
+ }
+ l.AddSecrets(strings.Repeat("x", 16385))
+ if l.page(logQuery{limit: 200, keyword: "another-late-secret"}).Summary.Matched != 0 {
+ t.Fatal("fail-closed state did not invalidate cached content")
+ }
+}
+
+func BenchmarkLogSummaryIncremental(b *testing.B) {
+ l := NewLogStore()
+ for i := 0; i < 1000; i++ {
+ _ = l.Fire(&logrus.Entry{Time: time.Now(), Level: logrus.InfoLevel, Message: fmt.Sprintf("needle synthetic log %04d %s", i, strings.Repeat("x", 256))})
+ }
+ for _, keyword := range []string{"", "needle"} {
+ name := "metadata-only"
+ if keyword != "" {
+ name = "redacted-keyword"
+ }
+ b.Run(name, func(b *testing.B) {
+ query, err := parseLogQuery(url.Values{"after": {"1000"}, "q": {keyword}})
+ if err != nil {
+ b.Fatal(err)
+ }
+ // Model an active viewer after its initial retained-page read.
+ _ = l.page(logQuery{limit: 200, keyword: keyword})
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ if l.page(query).Summary.Matched != 1000 {
+ b.Fatal("incomplete benchmark summary")
+ }
+ }
+ })
+ }
+}
diff --git a/internal/localadmin/logs.go b/internal/localadmin/logs.go
new file mode 100644
index 0000000000..0bdbd82b03
--- /dev/null
+++ b/internal/localadmin/logs.go
@@ -0,0 +1,540 @@
+package localadmin
+
+import (
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/sirupsen/logrus"
+)
+
+const logCapacity = 1000
+const logByteCapacity = 1 << 20
+const logMessageLimit = 2048
+
+var logURL = regexp.MustCompile(`(?i)(?:https?|wss?)://[^\s<>"']+`)
+var logHeader = regexp.MustCompile(`(?im)(?:authorization|proxy-authorization|set-cookie|cookie)["']?\s*[:=]\s*[^\r\n]*`)
+var logSecret = regexp.MustCompile(`(?i)(?:access[_-]?token|api[_-]?key|imagekey|agentkey|token|key|password|passwd|secret|credential|setup[_-]?code|hash)["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;\]}]+)`)
+var logKeyCommand = regexp.MustCompile(`设置AI聊天(?:识图|Agent)?密钥.*`)
+var logBearer = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9_+/=.-]+`)
+
+type LogEntry struct {
+ ID string `json:"id"`
+ Time time.Time `json:"time"`
+ Level string `json:"level"`
+ Message string `json:"message"`
+ Truncated bool `json:"truncated"`
+}
+
+// LogStore is a bounded in-memory logrus hook. It neither changes the logger's
+// output nor reads databases/files from Fire (control writers can log under lock).
+type LogStore struct {
+ mu sync.Mutex
+ instance string
+ next uint64
+ rows [logCapacity]LogEntry
+ start int
+ count int
+ bytes int
+ secrets map[string]struct{}
+ maskAll bool
+ replacer *strings.Replacer
+ dirty bool
+ maskVersion uint64
+ readMasks [logCapacity]logReadMask
+}
+
+type logReadMask struct {
+ id, raw, message, lower string
+ version uint64
+ maskAll, truncated bool
+}
+
+func NewLogStore() *LogStore {
+ return &LogStore{instance: hex.EncodeToString(randomBytes(8)), secrets: map[string]struct{}{}}
+}
+func (l *LogStore) Levels() []logrus.Level { return logrus.AllLevels }
+
+func (l *LogStore) ProtectConfig(raw []byte) { l.AddSecrets(secretsFromConfig(raw)...) }
+
+// RedactingFormatter keeps the existing output destination and formatter style,
+// while protecting subsequent standard logrus output as well as the web view.
+type RedactingFormatter struct {
+ Logs *LogStore
+ Next logrus.Formatter
+}
+
+func (f RedactingFormatter) Format(entry *logrus.Entry) ([]byte, error) {
+ copy := *entry
+ f.Logs.mu.Lock()
+ copy.Message = f.Logs.maskLocked(entry.Message)
+ copy.Message, _ = clipLogMessage(copy.Message)
+ copy.Data = logrus.Fields{}
+ for key, value := range entry.Data {
+ if len(copy.Data) >= 16 {
+ break
+ }
+ text := fmt.Sprint(value)
+ if len(text) > 65536 {
+ text = "[超长字段已省略]"
+ }
+ safeKey := f.Logs.maskLocked(key)
+ if logSecret.MatchString(key+"=x") || logHeader.MatchString(key+"=x") {
+ copy.Data[safeKey] = "[已隐藏]"
+ } else {
+ copy.Data[safeKey], _ = clipLogMessage(f.Logs.maskLocked(text))
+ }
+ }
+ f.Logs.mu.Unlock()
+ return f.Next.Format(©)
+}
+
+func (l *LogStore) AddSecrets(values ...string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ for _, value := range values {
+ if value == "" {
+ continue
+ }
+ encoded, _ := json.Marshal(value)
+ for _, variant := range []string{value, url.QueryEscape(value), url.PathEscape(value), string(encoded[1 : len(encoded)-1])} {
+ if _, exists := l.secrets[variant]; exists {
+ continue
+ }
+ // Fail closed instead of growing a secret history without a bound.
+ if len(variant) > 16384 || len(l.secrets) >= 256 {
+ l.maskAll = true
+ return
+ }
+ l.secrets[variant] = struct{}{}
+ l.dirty = true
+ l.maskVersion++
+ }
+ }
+}
+
+func genericLogMask(text string) string {
+ // Message bodies can contain credentials before a chat setter commits them.
+ if strings.Contains(text, "[bot]") && strings.Contains(text, "收到") && strings.Contains(text, "消息") {
+ return "[bot] 收到消息(正文已隐藏)"
+ }
+ if strings.Contains(text, "[api]") && strings.Contains(text, "发送") && strings.Contains(text, "消息") {
+ return "[api] 发送消息(正文已隐藏)"
+ }
+ text = logKeyCommand.ReplaceAllString(text, "设置AI聊天密钥[已隐藏]")
+ text = logURL.ReplaceAllStringFunc(text, func(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil || u.Hostname() == "" {
+ return "[地址已隐藏]"
+ }
+ if u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
+ u.User, u.RawQuery, u.Fragment, u.ForceQuery = nil, "", "", false
+ return u.String() + "[认证参数已隐藏]"
+ }
+ return raw
+ })
+ text = logHeader.ReplaceAllString(text, "[认证头已隐藏]")
+ text = logSecret.ReplaceAllString(text, "[凭据已隐藏]")
+ text = logBearer.ReplaceAllString(text, "[认证值已隐藏]")
+ return strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) && r != '\n' && r != '\t' {
+ return -1
+ }
+ return r
+ }, strings.ToValidUTF8(text, "�"))
+}
+
+func (l *LogStore) maskLocked(text string) string {
+ if l.maskAll {
+ return "[日志已隐藏:密钥历史达到上限,请重启后台]"
+ }
+ if len(text) > 65536 {
+ return "[超长日志已省略]"
+ }
+ // A simultaneous replacer prevents overlapping keys or replacements from
+ // exposing suffixes or repeatedly rewriting the redaction marker.
+ if l.dirty {
+ keys := make([]string, 0, len(l.secrets))
+ for key := range l.secrets {
+ keys = append(keys, key)
+ }
+ sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) })
+ pairs := make([]string, 0, len(keys)*2)
+ for _, key := range keys {
+ pairs = append(pairs, key, "[已隐藏]")
+ }
+ l.replacer = strings.NewReplacer(pairs...)
+ l.dirty = false
+ }
+ if l.replacer != nil {
+ text = l.replacer.Replace(text)
+ }
+ return genericLogMask(text)
+}
+
+func (l *LogStore) evictLocked() {
+ l.bytes -= len(l.rows[l.start].Message)
+ l.rows[l.start] = LogEntry{}
+ l.readMasks[l.start] = logReadMask{}
+ l.start = (l.start + 1) % logCapacity
+ l.count--
+}
+
+func (l *LogStore) Fire(entry *logrus.Entry) error {
+ text := entry.Message
+ if len(text) > 65536 {
+ text = "[超长日志已省略]"
+ } else {
+ text = genericLogMask(text)
+ }
+ keys := make([]string, 0, len(entry.Data))
+ for key := range entry.Data {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for i, key := range keys {
+ if i >= 16 {
+ text += "\n[其余字段已省略]"
+ break
+ }
+ if logSecret.MatchString(key+"=x") || strings.EqualFold(key, "Authorization") || strings.EqualFold(key, "Cookie") {
+ text += "\n[凭据字段已隐藏]"
+ continue
+ }
+ value := fmt.Sprint(entry.Data[key])
+ if len(key)+len(value) > 4096 {
+ text += "\n[超长字段已省略]"
+ continue
+ }
+ text += "\n" + genericLogMask(key+"="+value)
+ }
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ text = l.maskLocked(text)
+ text, truncated := clipLogMessage(text)
+ for l.count > 0 && (l.count == logCapacity || l.bytes+len(text) > logByteCapacity) {
+ l.evictLocked()
+ }
+ l.next++
+ row := LogEntry{ID: strconv.FormatUint(l.next, 10), Time: entry.Time, Level: entry.Level.String(), Message: text, Truncated: truncated}
+ l.rows[(l.start+l.count)%logCapacity] = row
+ l.readMasks[(l.start+l.count)%logCapacity] = logReadMask{}
+ l.count++
+ l.bytes += len(text)
+ return nil
+}
+
+func clipLogMessage(text string) (string, bool) {
+ if len(text) > logMessageLimit {
+ end := logMessageLimit
+ for !utf8.ValidString(text[:end]) {
+ end--
+ }
+ return text[:end] + "…[已截断]", true
+ }
+ return text, false
+}
+
+type logQuery struct {
+ after uint64
+ instance string
+ level string
+ keyword string
+ from *time.Time
+ until *time.Time
+ limit int
+}
+type LogPage struct {
+ Entries []LogEntry `json:"entries"`
+ Cursor string `json:"cursor"`
+ Instance string `json:"instance"`
+ Retained int `json:"retained"`
+ Capacity int `json:"capacity"`
+ Gap bool `json:"gap"`
+ Reset bool `json:"reset"`
+ Evicted bool `json:"evicted"`
+ Summary LogSummary `json:"summary"`
+}
+
+type LogSummary struct {
+ Matched int `json:"matched"`
+ Levels map[string]int `json:"levels"`
+}
+
+func parseLogQuery(v url.Values) (logQuery, error) {
+ q := logQuery{instance: v.Get("instance"), level: v.Get("level"), keyword: v.Get("q"), limit: 200}
+ if text := v.Get("after"); text != "" {
+ n, err := strconv.ParseUint(text, 10, 64)
+ if err != nil || strconv.FormatUint(n, 10) != text {
+ return q, errors.New("日志游标无效")
+ }
+ q.after = n
+ }
+ if text := v.Get("limit"); text != "" {
+ n, err := strconv.Atoi(text)
+ if err != nil || n < 1 || n > 200 {
+ return q, errors.New("每次最多读取 200 条日志")
+ }
+ q.limit = n
+ }
+ if q.instance != "" {
+ b, err := hex.DecodeString(q.instance)
+ if err != nil || len(b) != 8 {
+ return q, errors.New("日志实例无效")
+ }
+ }
+ q.level = strings.ToLower(q.level)
+ if q.level == "warn" {
+ q.level = "warning"
+ }
+ if q.level != "" && q.level != "issues" {
+ if _, err := logrus.ParseLevel(q.level); err != nil {
+ return q, errors.New("日志级别无效")
+ }
+ }
+ if len(q.keyword) > 256 || !utf8.ValidString(q.keyword) || strings.ContainsRune(q.keyword, 0) {
+ return q, errors.New("日志关键词过长或包含无效字符")
+ }
+ for name, target := range map[string]**time.Time{"from": &q.from, "until": &q.until} {
+ items := v[name]
+ if len(items) > 1 {
+ return q, errors.New("日志时间条件不可重复")
+ }
+ if len(items) == 0 || items[0] == "" {
+ continue
+ }
+ if len(items[0]) > 64 {
+ return q, errors.New("日志时间无效,需包含时区")
+ }
+ value, err := time.Parse(time.RFC3339Nano, items[0])
+ if err != nil || value.Year() < 1 || value.Year() > 9999 {
+ return q, errors.New("日志时间无效,需包含时区")
+ }
+ *target = &value
+ }
+ if q.from != nil && q.until != nil && q.from.After(*q.until) {
+ return q, errors.New("开始时间不能晚于结束时间")
+ }
+ return q, nil
+}
+
+func matchesLogLevel(level, filter string) bool {
+ if filter == "issues" {
+ return level == "warning" || level == "error" || level == "fatal" || level == "panic"
+ }
+ return filter == "" || level == filter
+}
+
+func (l *LogStore) page(q logQuery) LogPage {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ p := LogPage{Entries: []LogEntry{}, Cursor: strconv.FormatUint(l.next, 10), Instance: l.instance, Retained: l.count, Capacity: logCapacity, Evicted: l.next > uint64(l.count)}
+ p.Summary.Levels = map[string]int{"panic": 0, "fatal": 0, "error": 0, "warning": 0, "info": 0, "debug": 0, "trace": 0, "other": 0}
+ if (q.instance != "" && q.instance != l.instance) || q.after > l.next {
+ p.Reset, q.after = true, 0
+ }
+ if l.count > 0 && q.after > 0 {
+ oldest, _ := strconv.ParseUint(l.rows[l.start].ID, 10, 64)
+ p.Gap = q.after < oldest-1
+ }
+ needle := strings.ToLower(q.keyword)
+ for i := 0; i < l.count; i++ {
+ index := (l.start + i) % logCapacity
+ row := l.rows[index]
+ id, _ := strconv.ParseUint(row.ID, 10, 64)
+ if (q.from != nil && row.Time.Before(*q.from)) || (q.until != nil && row.Time.After(*q.until)) || !matchesLogLevel(row.Level, q.level) {
+ continue
+ }
+ returned := id > q.after && (q.after == 0 || len(p.Entries) < q.limit)
+ // Keyword matches and returned messages always use current redaction.
+ // With no keyword, older rows only need level/time metadata for counts.
+ if returned || needle != "" {
+ cached := &l.readMasks[index]
+ if cached.id != row.ID || cached.raw != row.Message || cached.version != l.maskVersion || cached.maskAll != l.maskAll {
+ message, clipped := clipLogMessage(l.maskLocked(row.Message))
+ *cached = logReadMask{id: row.ID, raw: row.Message, message: message, version: l.maskVersion, maskAll: l.maskAll, truncated: row.Truncated || clipped}
+ }
+ row.Message, row.Truncated = cached.message, cached.truncated
+ if needle != "" {
+ if cached.lower == "" {
+ cached.lower = strings.ToLower(cached.message)
+ }
+ if !strings.Contains(cached.lower, needle) {
+ continue
+ }
+ }
+ }
+ p.Summary.Matched++
+ level := row.Level
+ if _, ok := p.Summary.Levels[level]; !ok {
+ level = "other"
+ }
+ p.Summary.Levels[level]++
+ if !returned {
+ continue
+ }
+ p.Entries = append(p.Entries, row)
+ if q.after > 0 && len(p.Entries) == q.limit {
+ p.Cursor = row.ID
+ }
+ }
+ if q.after == 0 && len(p.Entries) > q.limit {
+ p.Entries = p.Entries[len(p.Entries)-q.limit:]
+ }
+ return p
+}
+
+func secretsFromConfig(raw []byte) []string {
+ var root any
+ if json.Unmarshal(raw, &root) != nil {
+ return nil
+ }
+ values := []string{}
+ var visit func(any)
+ visit = func(node any) {
+ switch n := node.(type) {
+ case map[string]any:
+ for name, value := range n {
+ if text, ok := value.(string); ok {
+ if logSecret.MatchString(name + "=x") {
+ values = append(values, text)
+ }
+ if u, err := url.Parse(text); err == nil && u.Hostname() != "" {
+ if u.User != nil {
+ if password, ok := u.User.Password(); ok {
+ values = append(values, password)
+ }
+ }
+ for key, items := range u.Query() {
+ if logSecret.MatchString(key + "=x") {
+ values = append(values, items...)
+ }
+ }
+ }
+ }
+ visit(value)
+ }
+ case []any:
+ for _, value := range n {
+ visit(value)
+ }
+ }
+ }
+ visit(root)
+ return values
+}
+
+// refreshLogSecrets is called outside the hook/buffer lock. It fails closed if
+// live configuration cannot be read, rather than returning unsanitized logs.
+func (s *Server) refreshLogSecrets() error {
+ values := []string{}
+ if c := s.cfg.Connections; c != nil {
+ c.mu.Lock()
+ raw, err := c.read()
+ if err == nil {
+ values = append(values, secretsFromConfig(raw)...)
+ values = append(values, secretsFromConfig(c.running)...)
+ }
+ c.mu.Unlock()
+ if err != nil {
+ return err
+ }
+ }
+ if ai := s.cfg.AI; ai != nil && ai.repository != nil {
+ c, _, err := ai.repository.Read()
+ if err != nil {
+ return err
+ }
+ raw, _ := json.Marshal(c)
+ values = append(values, secretsFromConfig(raw)...)
+ }
+ s.cfg.Logs.AddSecrets(values...)
+ return nil
+}
+
+func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Logs == nil {
+ apiError(w, 409, "当前程序未接入日志查看")
+ return
+ }
+ q, err := parseLogQuery(r.URL.Query())
+ if err != nil {
+ apiError(w, 400, err.Error())
+ return
+ }
+ if r.Context().Err() != nil {
+ return
+ }
+ if err := s.refreshLogSecrets(); err != nil {
+ apiError(w, 503, "日志暂不可读取,请检查配置数据库与启动配置")
+ return
+ }
+ jsonReply(w, 200, s.cfg.Logs.page(q))
+}
+
+// Export uses the same read-time secret refresh and retained buffer as the viewer.
+// It never reads historical stderr files or accepts paths/cursors from the caller.
+func (s *Server) exportLogs(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.Logs == nil {
+ apiError(w, 409, "当前程序未接入日志查看")
+ return
+ }
+ values := r.URL.Query()
+ for name, items := range values {
+ if (name != "level" && name != "q" && name != "from" && name != "until") || len(items) != 1 {
+ apiError(w, 400, "导出仅支持级别、关键词与起止时间筛选")
+ return
+ }
+ }
+ q, err := parseLogQuery(values)
+ if err != nil {
+ apiError(w, 400, err.Error())
+ return
+ }
+ if r.Context().Err() != nil {
+ return
+ }
+ if err := s.refreshLogSecrets(); err != nil {
+ apiError(w, 503, "日志暂不可读取,请检查配置数据库与启动配置")
+ return
+ }
+ q.limit = logCapacity
+ page := s.cfg.Logs.page(q)
+ now := time.Now().UTC()
+ var body strings.Builder
+ fmt.Fprintf(&body, "ZeroBot 本次启动的脱敏日志\n导出时间:%s\n保留:%d / %d 条;符合已应用筛选:%d 条\n", now.Format(time.RFC3339), page.Retained, page.Capacity, len(page.Entries))
+ from, until := "不限", "不限"
+ if q.from != nil {
+ from = q.from.UTC().Format(time.RFC3339Nano)
+ }
+ if q.until != nil {
+ until = q.until.UTC().Format(time.RFC3339Nano)
+ }
+ fmt.Fprintf(&body, "时间范围(包含边界,UTC):%s 至 %s\n", from, until)
+ if page.Evicted {
+ body.WriteString("部分旧日志已超出条数或容量限制,本文件不包含这些记录。\n")
+ }
+ body.WriteString("仅包含内存中仍保留的日志;重启前的日志不包含在内。长记录可能截断,密钥与消息正文已隐藏。\n正文使用引号包围,换行以 \\n 表示,每条记录占一行。\n\n")
+ for _, row := range page.Entries {
+ fmt.Fprintf(&body, "[%s] [%s] #%s %s\n", row.Time.UTC().Format(time.RFC3339Nano), strings.ToUpper(row.Level), row.ID, strconv.Quote(row.Message))
+ }
+ if r.Context().Err() != nil {
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Content-Disposition", `attachment; filename="zerobot-logs-`+now.Format("20060102T150405Z")+`.txt"`)
+ w.Header().Set("Content-Length", strconv.Itoa(body.Len()))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(body.String()))
+}
diff --git a/internal/localadmin/logs_test.go b/internal/localadmin/logs_test.go
new file mode 100644
index 0000000000..0249447e6c
--- /dev/null
+++ b/internal/localadmin/logs_test.go
@@ -0,0 +1,258 @@
+package localadmin
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/sirupsen/logrus"
+)
+
+func logFixture() (*LogStore, *logrus.Logger) {
+ l := NewLogStore()
+ logger := logrus.New()
+ logger.SetLevel(logrus.TraceLevel)
+ logger.SetOutput(&bytes.Buffer{})
+ logger.AddHook(l)
+ return l, logger
+}
+
+func TestLogExportSnapshotBoundsFiltersAndEncoding(t *testing.T) {
+ l, logger := logFixture()
+ cfg := testConfig(t)
+ cfg.Logs = l
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(s, "GET", "/api/logs/export", nil, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ for i := 0; i < 1100; i++ {
+ logger.Infof("record-%04d", i)
+ }
+ w := request(s, "GET", "/api/logs/export", nil, cookie, "")
+ expectStatus(t, w, 200)
+ body := w.Body.String()
+ if strings.Count(body, "] [INFO] #") != 1000 || strings.Contains(body, "record-0099") || !strings.Contains(body, "record-0100") || strings.Index(body, "record-0100") > strings.Index(body, "record-1099") || !strings.Contains(body, "部分旧日志已超出") {
+ t.Fatal("export did not snapshot all retained rows in order")
+ }
+ if w.Header().Get("Content-Type") != "text/plain; charset=utf-8" || w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("X-Content-Type-Options") != "nosniff" || !regexp.MustCompile(`^attachment; filename="zerobot-logs-\d{8}T\d{6}Z\.txt"$`).MatchString(w.Header().Get("Content-Disposition")) {
+ t.Fatal("unsafe download headers")
+ }
+ logger.Warn("测试 Needle\n[forged record] ")
+ logger.Info("other Needle")
+ w = request(s, "GET", "/api/logs/export?level=WARN&q=needle", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if strings.Count(w.Body.String(), "] [WARNING] #") != 1 || strings.Contains(w.Body.String(), "other Needle") || !strings.Contains(w.Body.String(), `Needle\n[forged record]`) || !utf8.Valid(w.Body.Bytes()) {
+ t.Fatal("filter/Unicode/multiline export failed")
+ }
+ for _, query := range []string{"after=0", "instance=", "limit=1000", "path=stderr.log", "q=a&q=b", "level=unknown", "q=" + strings.Repeat("x", 257), "q=%00", "q=%FF"} {
+ w = request(s, "GET", "/api/logs/export?"+query, nil, cookie, "")
+ expectStatus(t, w, 400)
+ if w.Header().Get("Content-Disposition") != "" {
+ t.Fatal("error became a download")
+ }
+ }
+ for i := 0; i < 1100; i++ {
+ logger.Info(strings.Repeat("汉🙂\\\n", 600))
+ }
+ w = request(s, "GET", "/api/logs/export", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if !utf8.Valid(w.Body.Bytes()) || w.Body.Len() > 3<<20 || !strings.Contains(w.Body.String(), "已截断") {
+ t.Fatal("export bytes or truncation exceeded bounds")
+ }
+ expectStatus(t, request(s, "POST", "/api/logout", map[string]any{}, cookie, csrf), 200)
+ expectStatus(t, request(s, "GET", "/api/logs/export", nil, cookie, ""), 401)
+}
+
+func TestLogExportReadTimeSecretsAndFailClosed(t *testing.T) {
+ l, logger := logFixture()
+ cfg := testConfig(t)
+ cfg.Logs = l
+ ai, c, _, _ := aiFixture(t)
+ cfg.AI = ai
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cookie, _ := enroll(t, s)
+ logger.Info("new-export-credential")
+ logger.Info("[api] 发送群消息(101): secret message body")
+ if err := c.SetExtra(map[string]any{"Key": "new-export-credential"}); err != nil {
+ t.Fatal(err)
+ }
+ w := request(s, "GET", "/api/logs/export?q=new-export-credential", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if strings.Contains(w.Body.String(), "new-export-credential") {
+ t.Fatal("query or old record exposed new credential")
+ }
+ w = request(s, "GET", "/api/logs/export", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if strings.Contains(w.Body.String(), "secret message body") || strings.Contains(w.Body.String(), "new-export-credential") || !strings.Contains(w.Body.String(), "正文已隐藏") {
+ t.Fatal("export bypassed redaction")
+ }
+ if err := c.Manager.D.Close(); err != nil {
+ t.Fatal(err)
+ }
+ w = request(s, "GET", "/api/logs/export", nil, cookie, "")
+ expectStatus(t, w, 503)
+ if w.Header().Get("Content-Disposition") != "" || strings.Contains(w.Body.String(), "credential") {
+ t.Fatal("failed export returned sensitive file")
+ }
+ s.cfg.Logs = nil
+ expectStatus(t, request(s, "GET", "/api/logs/export", nil, cookie, ""), 409)
+}
+
+func TestLogRedactionAndExistingOutput(t *testing.T) {
+ l, logger := logFixture()
+ output := &bytes.Buffer{}
+ logger.SetOutput(output)
+ logger.SetFormatter(RedactingFormatter{Logs: l, Next: &logrus.TextFormatter{DisableColors: true}})
+ l.AddSecrets("fake-token-long", "fake-token", "fake/key+quoted\"secret")
+ logger.WithFields(logrus.Fields{"Authorization": "Bearer header-secret", "Cookie": "session=cookie-secret", "payload": map[string]string{"Key": "json-secret"}, "extra": "fake-token-long"}).Error("failure https://user:url-password@example.test/path?token=query-secret#fragment-secret Bearer bearer-secret")
+ logger.Info("encoded fake%2Fkey%2Bquoted%22secret plain fake/key+quoted\"secret")
+ logger.Info("[bot] 收到私聊消息 user : 设置AI聊天密钥chat-secret")
+ logger.Info("[api] 发送群消息(101): output-secret")
+ logger.Info("Authorization: Basic basic-secret\nCookie: cookie-on-next-line\npassword=assignment-secret")
+ page := l.page(logQuery{limit: 200})
+ raw, _ := json.Marshal(page)
+ for _, secret := range []string{"fake-token", "fake%2Fkey", "header-secret", "cookie-secret", "json-secret", "url-password", "query-secret", "fragment-secret", "bearer-secret", "chat-secret", "output-secret", "basic-secret", "cookie-on-next-line", "assignment-secret"} {
+ if bytes.Contains(raw, []byte(secret)) || strings.Contains(output.String(), secret) {
+ t.Fatalf("credential leaked: %q", secret)
+ }
+ }
+ if len(page.Entries) != 5 || !strings.Contains(output.String(), "level=error") || !strings.Contains(output.String(), "https://example.test/path") {
+ t.Fatal("normal logger output or capture missing")
+ }
+ // Preserve the original Entry; the formatter works on a copy.
+ entry := &logrus.Entry{Logger: logger, Message: "fake-token-long", Data: logrus.Fields{"password": "do-not-mutate"}, Time: time.Now()}
+ _, err := (RedactingFormatter{Logs: l, Next: &logrus.JSONFormatter{}}).Format(entry)
+ if err != nil || entry.Message != "fake-token-long" || entry.Data["password"] != "do-not-mutate" {
+ t.Fatal("formatter mutated original entry")
+ }
+}
+
+func TestLogBoundsCursorFiltersAndRestart(t *testing.T) {
+ l, logger := logFixture()
+ for i := 0; i < 1100; i++ {
+ logger.Infof("record-%04d", i)
+ }
+ first := l.page(logQuery{limit: 200})
+ if first.Retained != 1000 || len(first.Entries) != 200 || first.Entries[0].ID != "901" || first.Cursor != "1100" {
+ t.Fatalf("wrong bounded initial page: retained %d entries %d cursor %s", first.Retained, len(first.Entries), first.Cursor)
+ }
+ gap := l.page(logQuery{after: 1, limit: 2})
+ if !gap.Gap || gap.Entries[0].ID != "101" || gap.Cursor != "102" {
+ t.Fatal("missing cursor expiry/backlog indication")
+ }
+ logger.Warn("needle ")
+ logger.Info("other needle")
+ filtered := l.page(logQuery{after: 1100, limit: 200, level: "warning", keyword: "NEEDLE"})
+ if len(filtered.Entries) != 1 || filtered.Cursor != "1102" || !strings.Contains(filtered.Entries[0].Message, "")
+ if err := c.SetExtra(map[string]any{"Key": "newer-overview-credential"}); err != nil {
+ t.Fatal(err)
+ }
+ w := request(s, "GET", "/api/overview", nil, cookie, "")
+ expectStatus(t, w, 200)
+ var v Overview
+ if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
+ t.Fatal(err)
+ }
+ if v.ConnectionState != "connected" || v.Alerts == nil || v.Alerts.ErrorCount < 1 || strings.Contains(w.Body.String(), "newer-overview-credential") || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("authenticated overview did not safely enrich runtime state")
+ }
+ if err := c.Manager.D.Close(); err != nil {
+ t.Fatal(err)
+ }
+ w = request(s, "GET", "/api/overview", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
+ t.Fatal(err)
+ }
+ if v.ConnectionState != "connected" || v.Alerts != nil || strings.Contains(w.Body.String(), "credential") || strings.Contains(w.Body.String(), "script") {
+ t.Fatal("log read failure exposed text or changed connection status")
+ }
+ s.cfg.Logs = nil
+ w = request(s, "GET", "/api/overview", nil, cookie, "")
+ expectStatus(t, w, 200)
+ if !strings.Contains(w.Body.String(), `"alerts":null`) {
+ t.Fatal("missing log source misreported as empty logs")
+ }
+ expectStatus(t, request(s, "POST", "/api/logout", map[string]any{}, cookie, csrf), 200)
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 401)
+}
diff --git a/internal/localadmin/password.go b/internal/localadmin/password.go
new file mode 100644
index 0000000000..11a101cce0
--- /dev/null
+++ b/internal/localadmin/password.go
@@ -0,0 +1,144 @@
+package localadmin
+
+import (
+ "bytes"
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+ "unicode/utf8"
+)
+
+// Caller holds s.mu; login and password changes share failed-attempt throttling.
+func (s *Server) authRateLimited() bool {
+ cutoff := s.now().Add(-time.Minute)
+ kept := s.failures[:0]
+ for _, at := range s.failures {
+ if at.After(cutoff) {
+ kept = append(kept, at)
+ }
+ }
+ s.failures = kept
+ return len(kept) >= 5
+}
+
+var errCredentialConflict = errors.New("后台凭据已被外部修改,请检查后重新启动后台")
+
+// A private temporary file is synced and atomically replaces the credential.
+// Memory and sessions change only after the file replacement succeeds.
+func (s *Server) persistCredential(ctx context.Context, next *credential) error {
+ path := filepath.Join(s.cfg.DataDir, "credential.json")
+ old, err := readBoundedFile(path, 4096)
+ if err != nil {
+ return errCredentialConflict
+ }
+ var disk credential
+ if json.Unmarshal(old, &disk) != nil || disk.Version != s.credential.Version || !bytes.Equal(disk.Salt, s.credential.Salt) || !bytes.Equal(disk.Hash, s.credential.Hash) {
+ return errCredentialConflict
+ }
+ b, err := json.Marshal(next)
+ if err != nil {
+ return err
+ }
+ f, err := os.CreateTemp(s.cfg.DataDir, ".admin-credential-*.tmp")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(f.Name())
+ if err = protectPath(f.Name(), false); err == nil {
+ _, err = f.Write(b)
+ }
+ if err == nil {
+ err = f.Sync()
+ }
+ closeErr := f.Close()
+ if err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ return err
+ }
+ current, err := readBoundedFile(path, 4096)
+ if err != nil || !bytes.Equal(old, current) {
+ return errCredentialConflict
+ }
+ if err = ctx.Err(); err != nil {
+ return err
+ }
+ return s.replaceCredential(f.Name(), path)
+}
+
+func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ CurrentPassword string `json:"currentPassword"`
+ NewPassword string `json:"newPassword"`
+ ConfirmPassword string `json:"confirmPassword"`
+ }
+ if !readJSONLimit(w, r, &body, 12<<10) {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ // Authorization may have preceded another password change or expiry.
+ _, active, ok := s.currentSession(r)
+ if !ok {
+ apiError(w, 401, "请先登录本地后台")
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(active.CSRF), []byte(r.Header.Get("X-CSRF-Token"))) != 1 {
+ apiError(w, 403, "请求校验失败,请刷新页面")
+ return
+ }
+ if len(body.CurrentPassword) == 0 || len(body.CurrentPassword) > 512 || !utf8.ValidString(body.NewPassword) || utf8.RuneCountInString(body.NewPassword) < 12 || len(body.NewPassword) > 512 {
+ apiError(w, 400, "请填写当前密码;新密码至少12个字符,且不超过512字节")
+ return
+ }
+ if body.NewPassword != body.ConfirmPassword {
+ apiError(w, 400, "两次输入的新密码不一致")
+ return
+ }
+ if body.NewPassword == body.CurrentPassword {
+ apiError(w, 400, "新密码需与当前密码不同")
+ return
+ }
+ if s.authRateLimited() {
+ apiError(w, 429, "尝试次数过多,请一分钟后重试")
+ return
+ }
+ if !s.validPassword(body.CurrentPassword) {
+ s.failures = append(s.failures, s.now())
+ apiError(w, 403, "当前后台密码不正确")
+ return
+ }
+ next := &credential{Version: 1, Salt: randomBytes(16)}
+ var err error
+ next.Hash, err = derivePassword(body.NewPassword, next.Salt)
+ if err != nil {
+ apiError(w, 500, "密码保存失败,旧密码保留")
+ return
+ }
+ if r.Context().Err() != nil {
+ return
+ }
+ if _, _, ok = s.currentSession(r); !ok {
+ apiError(w, 401, "请先登录本地后台")
+ return
+ }
+ if err = s.persistCredential(r.Context(), next); err != nil {
+ if errors.Is(err, errCredentialConflict) {
+ apiError(w, 409, errCredentialConflict.Error())
+ } else {
+ apiError(w, 500, "密码保存失败,旧密码保留,请检查后台数据目录权限")
+ }
+ return
+ }
+ s.credential = next
+ clear(s.sessions)
+ s.failures = nil
+ http.SetCookie(w, &http.Cookie{Name: "local_admin_session", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
+ jsonReply(w, 200, map[string]bool{"authenticated": false, "passwordChanged": true})
+}
diff --git a/internal/localadmin/password_test.go b/internal/localadmin/password_test.go
new file mode 100644
index 0000000000..858f608304
--- /dev/null
+++ b/internal/localadmin/password_test.go
@@ -0,0 +1,152 @@
+package localadmin
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+const changedTestPassword = " 新密码测试-password-🙂-only "
+
+func passwordBody(current, next string) map[string]string {
+ return map[string]string{"currentPassword": current, "newPassword": next, "confirmPassword": next}
+}
+func TestPasswordChangeRevokesAllSessionsAndPersists(t *testing.T) {
+ s := newTestServer(t)
+ cookie, csrf := enroll(t, s)
+ login := request(s, "POST", "/api/login", map[string]string{"password": testPassword}, nil, "")
+ expectStatus(t, login, 200)
+ second := login.Result().Cookies()[0]
+ old, _ := os.ReadFile(filepath.Join(s.cfg.DataDir, "credential.json"))
+ oldSalt := bytes.Clone(s.credential.Salt)
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody("wrong", changedTestPassword), cookie, csrf), 403)
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 200)
+ w := request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, csrf)
+ expectStatus(t, w, 200)
+ if len(s.sessions) != 0 || len(w.Result().Cookies()) != 1 || w.Result().Cookies()[0].MaxAge != -1 || bytes.Contains(w.Body.Bytes(), []byte(changedTestPassword)) {
+ t.Fatal("session invalidation or reply failed")
+ }
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 401)
+ expectStatus(t, request(s, "GET", "/api/overview", nil, second, ""), 401)
+ raw, _ := os.ReadFile(filepath.Join(s.cfg.DataDir, "credential.json"))
+ if bytes.Equal(old, raw) || bytes.Equal(oldSalt, s.credential.Salt) || bytes.Contains(raw, []byte(testPassword)) || bytes.Contains(raw, []byte(changedTestPassword)) {
+ t.Fatal("credential replacement/hash failed")
+ }
+ reloaded, err := New(s.cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(reloaded, "POST", "/api/login", map[string]string{"password": testPassword}, nil, ""), 401)
+ expectStatus(t, request(reloaded, "POST", "/api/login", map[string]string{"password": changedTestPassword}, nil, ""), 200)
+ expectStatus(t, request(reloaded, "POST", "/api/login", map[string]string{"password": strings.TrimSpace(changedTestPassword)}, nil, ""), 401)
+ files, _ := os.ReadDir(s.cfg.DataDir)
+ if len(files) != 1 || files[0].Name() != "credential.json" {
+ t.Fatal("credential history/temp file retained")
+ }
+}
+func TestPasswordChangeValidationAndSharedThrottle(t *testing.T) {
+ s := newTestServer(t)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), nil, csrf), 401)
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, "incorrect-csrf"), 403)
+ invalid := []map[string]string{passwordBody(testPassword, "short"), passwordBody(testPassword, strings.Repeat("汉", 171)), passwordBody("", changedTestPassword), passwordBody(strings.Repeat("a", 513), changedTestPassword), passwordBody(testPassword, testPassword), {"currentPassword": testPassword, "newPassword": changedTestPassword, "confirmPassword": "different"}, {"currentPassword": testPassword, "newPassword": changedTestPassword, "confirmPassword": changedTestPassword, "unknown": "x"}}
+ for _, body := range invalid {
+ expectStatus(t, request(s, "POST", "/api/password", body, cookie, csrf), 400)
+ }
+ for i := 0; i < 3; i++ {
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody("wrong", changedTestPassword), cookie, csrf), 403)
+ }
+ for i := 0; i < 2; i++ {
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": "wrong"}, nil, ""), 401)
+ }
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, csrf), 429)
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": testPassword}, nil, ""), 429)
+ later := s.now().Add(61 * time.Second)
+ s.now = func() time.Time { return later }
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody(testPassword, strings.Repeat("x", 512)), cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": strings.Repeat("x", 512)}, nil, ""), 200)
+}
+func TestPasswordChangeFailureAndExternalConflictKeepSessions(t *testing.T) {
+ s := newTestServer(t)
+ cookie, csrf := enroll(t, s)
+ path := filepath.Join(s.cfg.DataDir, "credential.json")
+ old, _ := os.ReadFile(path)
+ s.replaceCredential = func(string, string) error { return errors.New("private path and secret should not escape") }
+ w := request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, csrf)
+ expectStatus(t, w, 500)
+ current, _ := os.ReadFile(path)
+ if !bytes.Equal(old, current) || !s.validPassword(testPassword) || strings.Contains(w.Body.String(), "private path") {
+ t.Fatal("failed replacement changed/exposed credential")
+ }
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 200)
+ files, _ := os.ReadDir(s.cfg.DataDir)
+ if len(files) != 1 {
+ t.Fatal("failed temporary credential not removed")
+ }
+ if err := os.WriteFile(path, []byte("externally changed"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ w = request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, csrf)
+ expectStatus(t, w, 409)
+ current, _ = os.ReadFile(path)
+ if string(current) != "externally changed" || !s.validPassword(testPassword) {
+ t.Fatal("external credential overwritten")
+ }
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 200)
+ if err := os.WriteFile(path, old, 0600); err != nil {
+ t.Fatal(err)
+ }
+ s.replaceCredential = replaceConfigFile
+ later := s.now().Add(9 * time.Hour)
+ s.now = func() time.Time { return later }
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, csrf), 401)
+ current, _ = os.ReadFile(path)
+ if !bytes.Equal(old, current) {
+ t.Fatal("expired session changed credential")
+ }
+}
+
+// The second request is authorized before the first change, then blocks reading its body.
+type delayedPasswordReader struct {
+ reader io.Reader
+ once sync.Once
+ entered, release chan struct{}
+}
+
+func (r *delayedPasswordReader) Read(p []byte) (int, error) {
+ r.once.Do(func() { close(r.entered); <-r.release })
+ return r.reader.Read(p)
+}
+func TestPasswordChangeRechecksAlreadyAuthorizedRequest(t *testing.T) {
+ s := newTestServer(t)
+ cookie, csrf := enroll(t, s)
+ raw, _ := json.Marshal(passwordBody(testPassword, "other-test-password-only"))
+ gated := &delayedPasswordReader{reader: bytes.NewReader(raw), entered: make(chan struct{}), release: make(chan struct{})}
+ r := httptest.NewRequest(http.MethodPost, "http://"+s.cfg.Addr+"/api/password", gated)
+ r.RemoteAddr = "127.0.0.1:54321"
+ r.AddCookie(cookie)
+ r.Header.Set("Origin", "http://"+s.cfg.Addr)
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("X-Local-Request", "1")
+ r.Header.Set("X-CSRF-Token", csrf)
+ w := httptest.NewRecorder()
+ done := make(chan struct{})
+ go func() { s.Handler().ServeHTTP(w, r); close(done) }()
+ <-gated.entered
+ expectStatus(t, request(s, "POST", "/api/password", passwordBody(testPassword, changedTestPassword), cookie, csrf), 200)
+ close(gated.release)
+ <-done
+ expectStatus(t, w, 401)
+ if !s.validPassword(changedTestPassword) || s.validPassword("other-test-password-only") {
+ t.Fatal("preauthorized stale request replaced new password")
+ }
+}
diff --git a/internal/localadmin/permissions_other.go b/internal/localadmin/permissions_other.go
new file mode 100644
index 0000000000..0e4b0a0765
--- /dev/null
+++ b/internal/localadmin/permissions_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package localadmin
+
+import "os"
+
+func protectPath(path string, directory bool) error {
+ mode := os.FileMode(0600)
+ if directory {
+ mode = 0700
+ }
+ return os.Chmod(path, mode)
+}
diff --git a/internal/localadmin/permissions_windows.go b/internal/localadmin/permissions_windows.go
new file mode 100644
index 0000000000..0001911f9f
--- /dev/null
+++ b/internal/localadmin/permissions_windows.go
@@ -0,0 +1,24 @@
+package localadmin
+
+import "golang.org/x/sys/windows"
+
+// Restrict credential storage to the current Windows user and SYSTEM.
+func protectPath(path string, directory bool) error {
+ user, err := windows.GetCurrentProcessToken().GetTokenUser()
+ if err != nil {
+ return err
+ }
+ flags := ""
+ if directory {
+ flags = "OICI"
+ }
+ sd, err := windows.SecurityDescriptorFromString("D:P(A;" + flags + ";FA;;;" + user.User.Sid.String() + ")(A;" + flags + ";FA;;;SY)")
+ if err != nil {
+ return err
+ }
+ dacl, _, err := sd.DACL()
+ if err != nil {
+ return err
+ }
+ return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
+}
diff --git a/internal/localadmin/plugins.go b/internal/localadmin/plugins.go
new file mode 100644
index 0000000000..d75188b7fc
--- /dev/null
+++ b/internal/localadmin/plugins.go
@@ -0,0 +1,126 @@
+package localadmin
+
+import (
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "strconv"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+type PluginState = ctrl.SwitchState
+
+type PluginStore struct {
+ controls map[string]*ctrl.Control[*zero.Ctx]
+ responseManager *ctrl.Manager[*zero.Ctx]
+}
+
+func NewPluginStore(controls []*ctrl.Control[*zero.Ctx], backups string) *PluginStore {
+ s := &PluginStore{controls: map[string]*ctrl.Control[*zero.Ctx]{}}
+ managers := map[*ctrl.Manager[*zero.Ctx]]bool{}
+ for _, c := range controls {
+ s.controls[c.Service] = c
+ if !managers[c.Manager] {
+ manager := c.Manager
+ manager.Lock()
+ manager.BeforeSwitch = func() error {
+ _, err := BackupControlDatabase(&manager.D, backups)
+ return err
+ }
+ manager.BeforeExtra = manager.BeforeSwitch
+ manager.BeforePolicy = manager.BeforeSwitch
+ manager.Unlock()
+ managers[manager] = true
+ }
+ }
+ if len(managers) == 1 {
+ for manager := range managers {
+ s.responseManager = manager
+ }
+ }
+ return s
+}
+
+func pluginGroup(text string) (int64, error) {
+ if text == "" || text == "0" {
+ return 0, nil
+ }
+ id, err := strconv.ParseInt(text, 10, 64)
+ if err != nil || id <= 0 || strconv.FormatInt(id, 10) != text {
+ return 0, errors.New("群号须为正整数;全局设置使用 0")
+ }
+ return id, nil
+}
+
+func (s *Server) getPlugins(w http.ResponseWriter, r *http.Request) {
+ gid, err := pluginGroup(r.URL.Query().Get("group"))
+ if err != nil {
+ apiError(w, 400, err.Error())
+ return
+ }
+ plugins := append([]Plugin{}, s.cfg.Plugins...)
+ var response *ctrl.ResponseState
+ if store := s.cfg.PluginControl; store != nil {
+ for i := range plugins {
+ c := store.controls[plugins[i].Name]
+ if c == nil {
+ apiError(w, 500, "插件注册状态不一致")
+ return
+ }
+ state, err := c.SwitchState(gid)
+ if err != nil {
+ apiError(w, 500, "插件状态读取失败,请检查本机控制数据库")
+ return
+ }
+ plugins[i].State = &state
+ }
+ if store.responseManager != nil {
+ state, err := store.responseManager.ReadResponseState(gid)
+ if err != nil {
+ apiError(w, 500, "响应状态读取失败,请检查本机控制数据库")
+ return
+ }
+ response = &state
+ }
+ }
+ jsonReply(w, 200, map[string]any{"plugins": plugins, "groupId": strconv.FormatInt(gid, 10), "controlAvailable": s.cfg.PluginControl != nil, "response": response})
+}
+
+func (s *Server) setPluginSwitch(w http.ResponseWriter, r *http.Request) {
+ var update struct {
+ Name string `json:"name"`
+ GroupID string `json:"groupId"`
+ Mode string `json:"mode"`
+ Revision string `json:"revision"`
+ }
+ if !readJSON(w, r, &update) {
+ return
+ }
+ if s.cfg.PluginControl == nil {
+ apiError(w, 409, "当前程序未接入插件控制")
+ return
+ }
+ gid, err := pluginGroup(update.GroupID)
+ decoded, revisionErr := hex.DecodeString(update.Revision)
+ if err != nil || update.GroupID == "" || revisionErr != nil || len(decoded) != 32 || (update.Mode != "inherit" && update.Mode != "enabled" && update.Mode != "disabled") {
+ apiError(w, 400, "请选择有效的群号、开关状态及配置版本")
+ return
+ }
+ c := s.cfg.PluginControl.controls[update.Name]
+ if c == nil {
+ apiError(w, 404, "当前程序没有注册这个插件")
+ return
+ }
+ state, err := c.SetSwitch(gid, update.Mode, update.Revision)
+ if errors.Is(err, ctrl.ErrSwitchConflict) {
+ apiError(w, 409, "插件设置已变化,请刷新状态后再保存")
+ return
+ }
+ if err != nil {
+ apiError(w, 500, "插件设置保存失败,原设置保留;请检查备份目录及控制数据库")
+ return
+ }
+ jsonReply(w, 200, map[string]any{"name": c.Service, "groupId": strconv.FormatInt(gid, 10), "state": state})
+}
diff --git a/internal/localadmin/plugins_test.go b/internal/localadmin/plugins_test.go
new file mode 100644
index 0000000000..3b26d09df0
--- /dev/null
+++ b/internal/localadmin/plugins_test.go
@@ -0,0 +1,286 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ sql "github.com/FloatTech/sqlite"
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func pluginFixture(t *testing.T, disabled bool) (*ctrl.Control[*zero.Ctx], string) {
+ t.Helper()
+ path := filepath.ToSlash(filepath.Join(t.TempDir(), "plugins.db"))
+ m := ctrl.NewManager[*zero.Ctx](path)
+ c := m.NewControl("test-plugin", &ctrl.Options[*zero.Ctx]{DisableOnDefault: disabled})
+ t.Cleanup(func() { m.Lock(); defer m.Unlock(); _ = m.D.Close() })
+ return c, path
+}
+
+func switchState(t *testing.T, c *ctrl.Control[*zero.Ctx], gid int64) ctrl.SwitchState {
+ t.Helper()
+ v, err := c.SwitchState(gid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return v
+}
+
+func TestPluginSwitchPriorityAndLocationIsolation(t *testing.T) {
+ for _, disabled := range []bool{false, true} {
+ c, _ := pluginFixture(t, disabled)
+ for _, global := range []string{"inherit", "enabled", "disabled"} {
+ for _, group := range []string{"inherit", "enabled", "disabled"} {
+ if _, err := c.SetSwitch(0, global, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.SetSwitch(101, group, ""); err != nil {
+ t.Fatal(err)
+ }
+ enabled, source := !disabled, "default"
+ if group != "inherit" {
+ enabled, source = group == "enabled", "group"
+ }
+ if global != "inherit" {
+ enabled, source = global == "enabled", "global"
+ }
+ v := switchState(t, c, 101)
+ if v.EffectiveEnabled != enabled || v.Source != source || c.IsEnabledIn(101) != enabled {
+ t.Fatalf("priority mismatch %v %s %s: %+v", disabled, global, group, v)
+ }
+ other := !disabled
+ if global != "inherit" {
+ other = global == "enabled"
+ }
+ if c.IsEnabledIn(202) != other || c.IsEnabledIn(-123) != other || c.IsEnabledIn(0) != other {
+ t.Fatal("group override leaked")
+ }
+ }
+ }
+ }
+}
+
+func TestPluginInheritancePreservesDataAndFlipAcrossRestart(t *testing.T) {
+ c, path := pluginFixture(t, false)
+ if err := c.SetData(101, 123456789); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Reset(101); err != nil {
+ t.Fatal(err)
+ }
+ if c.GetData(101) != 123456789 || switchState(t, c, 101).GroupMode != "inherit" {
+ t.Fatal("reset lost group parameters")
+ }
+ if err := c.SetData(101, 987654321); err != nil {
+ t.Fatal(err)
+ }
+ if switchState(t, c, 101).GroupMode != "inherit" {
+ t.Fatal("parameter write changed inheritance")
+ }
+ if err := c.Flip(); err != nil {
+ t.Fatal(err)
+ }
+ if c.IsEnabledIn(101) || switchState(t, c, 0).GlobalMode != "inherit" {
+ t.Fatal("flip created a global override")
+ }
+ if err := c.SetData(0, 42); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Enable(0); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Reset(0); err != nil {
+ t.Fatal(err)
+ }
+ if c.GetData(0) != 42 || c.IsEnabledIn(101) {
+ t.Fatal("global inheritance lost data or default")
+ }
+ c.Manager.Lock()
+ err := c.Manager.D.Close()
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ reopened := ctrl.NewManager[*zero.Ctx](path)
+ defer reopened.D.Close()
+ r := reopened.NewControl("test-plugin", &ctrl.Options[*zero.Ctx]{})
+ if r.IsEnabledIn(101) || r.GetData(101) != 987654321 || r.GetData(0) != 42 || switchState(t, r, 101).GroupMode != "inherit" {
+ t.Fatal("restart did not retain default/inheritance/data")
+ }
+ if err := r.Flip(); err != nil {
+ t.Fatal(err)
+ }
+ if !r.IsEnabledIn(101) {
+ t.Fatal("second flip failed")
+ }
+}
+
+func TestPluginLegacyRowsAndTransactionalFailure(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ c.Manager.Lock()
+ err := c.Manager.D.Insert(c.Service, &ctrl.GroupConfig{GroupID: 101, Disable: 19})
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.IsEnabledIn(101) || switchState(t, c, 101).GroupMode != "disabled" {
+ t.Fatal("legacy group row changed")
+ }
+ before := switchState(t, c, 101)
+ // Fail the second transaction statement, after the plugin row would change.
+ c.Manager.Lock()
+ _, err = c.Manager.D.Exec("CREATE TRIGGER fail_inheritance BEFORE INSERT ON __switch_inherit BEGIN SELECT RAISE(ABORT,'injected-secret'); END")
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Reset(101); err == nil {
+ t.Fatal("expected transaction failure")
+ }
+ if switchState(t, c, 101) != before || c.IsEnabledIn(101) || c.GetData(101) != 9 {
+ t.Fatal("failed transaction changed disk or cache")
+ }
+}
+
+func TestPluginConcurrentChatAndWebWrites(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ v := switchState(t, c, 101)
+ results := make(chan error, 8)
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() { defer wg.Done(); _, err := c.SetSwitch(101, "disabled", v.Revision); results <- err }()
+ }
+ wg.Wait()
+ close(results)
+ count := 0
+ for err := range results {
+ if err == nil {
+ count++
+ } else if !errors.Is(err, ctrl.ErrSwitchConflict) {
+ t.Fatal(err)
+ }
+ }
+ if count != 1 {
+ t.Fatalf("%d concurrent writes succeeded", count)
+ }
+ v = switchState(t, c, 101)
+ if err := c.Enable(101); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.SetSwitch(101, "disabled", v.Revision); !errors.Is(err, ctrl.ErrSwitchConflict) {
+ t.Fatal("chat update did not invalidate web revision")
+ }
+ if !c.IsEnabledIn(101) {
+ t.Fatal("chat write not visible")
+ }
+}
+
+func TestPluginSQLiteSnapshotAndBackupFailure(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ backups := filepath.Join(t.TempDir(), "backups")
+ _ = NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, backups)
+ c.Manager.Lock()
+ _, err := c.Manager.D.Exec("PRAGMA journal_mode=WAL")
+ if err == nil {
+ err = c.Manager.D.Insert(c.Service, &ctrl.GroupConfig{GroupID: 101, Disable: 34})
+ }
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := os.ReadDir(backups)
+ if err != nil || len(entries) != 1 {
+ t.Fatalf("snapshot missing: %v", err)
+ }
+ root := filepath.Join(backups, entries[0].Name())
+ var manifest struct {
+ Version int
+ Scope, File, SHA256 string
+ CreatedAt time.Time
+ }
+ raw, err := os.ReadFile(filepath.Join(root, "manifest.json"))
+ if err != nil || json.Unmarshal(raw, &manifest) != nil || manifest.Scope != "control-db" || manifest.Version != 1 || len(manifest.SHA256) != 64 || manifest.CreatedAt.IsZero() {
+ t.Fatal("invalid manifest")
+ }
+ copyDB := sql.New(filepath.Join(root, manifest.File))
+ if err = copyDB.Open(time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ defer copyDB.Close()
+ var old ctrl.GroupConfig
+ if err = copyDB.Find(c.Service, &old, "WHERE gid=101"); err != nil || old.Disable != 34 {
+ t.Fatal("snapshot omitted pre-write WAL data")
+ }
+ if err = c.Disable(101); err != nil {
+ t.Fatal(err)
+ }
+ entries, _ = os.ReadDir(backups)
+ if len(entries) != 1 {
+ t.Fatal("no-op made another snapshot")
+ }
+ blocked := filepath.Join(t.TempDir(), "blocked")
+ if err = os.WriteFile(blocked, []byte("x"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ _ = NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, blocked)
+ before := switchState(t, c, 101)
+ if err = c.Enable(101); err == nil {
+ t.Fatal("backup failure allowed write")
+ }
+ if switchState(t, c, 101) != before || c.IsEnabledIn(101) {
+ t.Fatal("backup failure changed state")
+ }
+}
+
+func TestPluginAPIAuthenticationValidationAndConflict(t *testing.T) {
+ s := newTestServer(t)
+ c, _ := pluginFixture(t, false)
+ s.cfg.PluginControl = NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, filepath.Join(t.TempDir(), "backups"))
+ update := map[string]string{"name": c.Service, "groupId": "101", "mode": "disabled", "revision": switchState(t, c, 101).Revision}
+ expectStatus(t, request(s, "POST", "/api/plugin-switch", update, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ expectStatus(t, request(s, "POST", "/api/plugin-switch", update, cookie, "wrong"), 403)
+ for _, group := range []string{"-1", "01", "abc", "9223372036854775808"} {
+ expectStatus(t, request(s, "GET", "/api/plugins?group="+group, nil, cookie, ""), 400)
+ }
+ expectStatus(t, request(s, "POST", "/api/plugin-switch", update, cookie, csrf), 200)
+ expectStatus(t, request(s, "POST", "/api/plugin-switch", update, cookie, csrf), 409)
+ if c.IsEnabledIn(101) {
+ t.Fatal("API not visible to engine")
+ }
+ update["revision"] = switchState(t, c, 101).Revision
+ update["name"] = "../../unknown"
+ expectStatus(t, request(s, "POST", "/api/plugin-switch", update, cookie, csrf), 404)
+ update["name"] = c.Service
+ update["revision"] = ""
+ expectStatus(t, request(s, "POST", "/api/plugin-switch", update, cookie, csrf), 400)
+ c.Manager.Lock()
+ c.Manager.BeforeSwitch = func() error { return errors.New("secret-must-not-leak") }
+ c.Manager.Unlock()
+ update["revision"] = switchState(t, c, 101).Revision
+ update["mode"] = "enabled"
+ rsp := request(s, "POST", "/api/plugin-switch", update, cookie, csrf)
+ expectStatus(t, rsp, 500)
+ if strings.Contains(rsp.Body.String(), "secret-must-not-leak") {
+ t.Fatal("error leaked")
+ }
+ rsp = request(s, "GET", "/api/plugins?group=101", nil, cookie, "")
+ expectStatus(t, rsp, 200)
+ if !strings.Contains(rsp.Body.String(), `"effectiveEnabled":false`) || !strings.Contains(rsp.Body.String(), `"source":"group"`) {
+ t.Fatal("actual state missing")
+ }
+}
diff --git a/internal/localadmin/policies_test.go b/internal/localadmin/policies_test.go
new file mode 100644
index 0000000000..59bf9b9ff7
--- /dev/null
+++ b/internal/localadmin/policies_test.go
@@ -0,0 +1,202 @@
+package localadmin
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ sql "github.com/FloatTech/sqlite"
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+type policyCase struct {
+ name, table, event, where string
+ prepare, apply func(*ctrl.Control[*zero.Ctx]) error
+ presentBefore bool
+}
+
+func policyCases() []policyCase {
+ return []policyCase{
+ {"response", "__resp", "INSERT", "WHERE gid=101", nil, func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.Response(101) }, false},
+ {"silence", "__resp", "DELETE", "WHERE gid=101", func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.Response(101) }, func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.Silence(101) }, true},
+ {"global-response", "__resp", "INSERT", "WHERE gid=0", nil, func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.Response(0) }, false},
+ {"global-silence", "__resp", "DELETE", "WHERE gid=0", func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.Response(0) }, func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.Silence(0) }, true},
+ {"block", "__block", "INSERT", "WHERE uid=123", nil, func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.DoBlock(123) }, false},
+ {"unblock", "__block", "DELETE", "WHERE uid=123", func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.DoBlock(123) }, func(c *ctrl.Control[*zero.Ctx]) error { return c.Manager.DoUnblock(123) }, true},
+ {"ban", "test-pluginban", "INSERT", "WHERE uid=123 AND gid=101", nil, func(c *ctrl.Control[*zero.Ctx]) error { return c.Ban(123, 101) }, false},
+ {"permit", "test-pluginban", "DELETE", "WHERE uid=123 AND gid=101", func(c *ctrl.Control[*zero.Ctx]) error { return c.Ban(123, 101) }, func(c *ctrl.Control[*zero.Ctx]) error { return c.Permit(123, 101) }, true},
+ }
+}
+
+func policyCaches(c *ctrl.Control[*zero.Ctx]) [4]bool {
+ return [4]bool{c.Manager.CanResponse(101), c.Manager.CanResponse(0), c.Manager.IsBlocked(123), c.IsBannedIn(123, 101)}
+}
+
+func policyBackupEntries(t *testing.T, directory string) []os.DirEntry {
+ t.Helper()
+ entries, err := os.ReadDir(directory)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ return entries
+}
+
+func TestPolicySnapshotsIncludePreWriteWALStateAndSkipNoOps(t *testing.T) {
+ for _, tc := range policyCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ c, dbPath := pluginFixture(t, false)
+ if _, err := c.Manager.D.Exec("PRAGMA journal_mode=WAL"); err != nil {
+ t.Fatal(err)
+ }
+ if tc.prepare != nil {
+ if err := tc.prepare(c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // A committed marker still in WAL must be included in every snapshot.
+ if err := c.Manager.D.Insert(c.Service, &ctrl.GroupConfig{GroupID: 202, Disable: 34}); err != nil {
+ t.Fatal(err)
+ }
+ backups := filepath.Join(t.TempDir(), "backups")
+ NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, backups)
+ if err := tc.apply(c); err != nil {
+ t.Fatal(err)
+ }
+ entries := policyBackupEntries(t, backups)
+ if len(entries) != 1 {
+ t.Fatalf("got %d snapshots", len(entries))
+ }
+ copyDB := sql.New(filepath.Join(backups, entries[0].Name(), "plugins.sqlite"))
+ if err := copyDB.Open(time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ defer copyDB.Close()
+ if copyDB.CanFind(tc.table, tc.where) != tc.presentBefore {
+ t.Fatal("snapshot contains post-write policy")
+ }
+ var marker ctrl.GroupConfig
+ if err := copyDB.Find(c.Service, &marker, "WHERE gid=202"); err != nil || marker.Disable != 34 {
+ t.Fatal("snapshot omitted WAL marker")
+ }
+ if c.Manager.D.CanFind(tc.table, tc.where) == tc.presentBefore {
+ t.Fatal("policy was not committed")
+ }
+ before := policyCaches(c)
+ m := ctrl.NewManager[*zero.Ctx](dbPath)
+ reloaded := m.NewControl(c.Service, &ctrl.Options[*zero.Ctx]{})
+ if policyCaches(reloaded) != before {
+ t.Fatal("policy did not survive a fresh manager")
+ }
+ _ = m.D.Close()
+ err := tc.apply(c)
+ if err != nil && !errors.Is(err, ctrl.ErrAlreadyResponding) && !errors.Is(err, ctrl.ErrAlreadySilent) {
+ t.Fatal(err)
+ }
+ if len(policyBackupEntries(t, backups)) != 1 {
+ t.Fatal("duplicate policy created a snapshot")
+ }
+ })
+ }
+}
+
+func TestPolicyBackupAndSQLFailurePreserveDiskAndCaches(t *testing.T) {
+ for _, mode := range []string{"backup", "sql", "read"} {
+ for _, tc := range policyCases() {
+ t.Run(mode+"/"+tc.name, func(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ if tc.prepare != nil {
+ if err := tc.prepare(c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := policyCaches(c)
+ backups := filepath.Join(t.TempDir(), "backups")
+ NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, backups)
+ switch mode {
+ case "backup":
+ if err := os.WriteFile(backups, []byte("blocked"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ case "sql":
+ if _, err := c.Manager.D.Exec("CREATE TRIGGER reject_policy BEFORE " + tc.event + " ON \"" + tc.table + "\" BEGIN SELECT RAISE(ABORT,'synthetic-secret'); END"); err != nil {
+ t.Fatal(err)
+ }
+ case "read":
+ // Force the persisted-state lookup to fail without removing cached state.
+ if _, err := c.Manager.D.Exec("ALTER TABLE \"" + tc.table + "\" RENAME TO hidden_policy"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := tc.apply(c); err == nil {
+ t.Fatal("write unexpectedly succeeded")
+ }
+ if policyCaches(c) != before {
+ t.Fatal("failed write changed cache")
+ }
+ table := tc.table
+ if mode == "read" {
+ table = "hidden_policy"
+ }
+ if c.Manager.D.CanFind(table, tc.where) != tc.presentBefore {
+ t.Fatal("failed write changed disk")
+ }
+ if mode == "sql" && len(policyBackupEntries(t, backups)) != 1 {
+ t.Fatal("write failure lost recovery snapshot")
+ }
+ if mode == "read" && len(policyBackupEntries(t, backups)) != 0 {
+ t.Fatal("read failure invoked backup")
+ }
+ })
+ }
+ }
+}
+
+func TestPolicyGlobalFallbackAndConcurrentSnapshots(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ backups := filepath.Join(t.TempDir(), "backups")
+ NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, backups)
+ if err := c.Manager.Response(0); err != nil {
+ t.Fatal(err)
+ }
+ if !errors.Is(c.Manager.Response(101), ctrl.ErrAlreadyResponding) {
+ t.Fatal("global response precedence changed")
+ }
+ if err := c.Manager.Silence(101); err != nil {
+ t.Fatal(err)
+ }
+ if !c.Manager.CanResponse(101) || len(policyBackupEntries(t, backups)) != 1 {
+ t.Fatal("absent local row changed global response or backed up")
+ }
+ if err := c.Ban(123, 0); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Permit(123, 101); err != nil {
+ t.Fatal(err)
+ }
+ if !c.IsBannedIn(123, 101) || len(policyBackupEntries(t, backups)) != 2 {
+ t.Fatal("local permit changed global ban or made snapshot")
+ }
+ var wg sync.WaitGroup
+ results := make(chan error, 8)
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() { defer wg.Done(); results <- c.Manager.DoBlock(123) }()
+ }
+ wg.Wait()
+ close(results)
+ for err := range results {
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if !c.Manager.IsBlocked(123) || len(policyBackupEntries(t, backups)) != 3 {
+ t.Fatal("concurrent duplicate writes did not share a single snapshot")
+ }
+}
diff --git a/internal/localadmin/replace_other.go b/internal/localadmin/replace_other.go
new file mode 100644
index 0000000000..8b2271a543
--- /dev/null
+++ b/internal/localadmin/replace_other.go
@@ -0,0 +1,7 @@
+//go:build !windows
+
+package localadmin
+
+import "os"
+
+func replaceConfigFile(source, target string) error { return os.Rename(source, target) }
diff --git a/internal/localadmin/replace_windows.go b/internal/localadmin/replace_windows.go
new file mode 100644
index 0000000000..548d38453d
--- /dev/null
+++ b/internal/localadmin/replace_windows.go
@@ -0,0 +1,15 @@
+package localadmin
+
+import "golang.org/x/sys/windows"
+
+func replaceConfigFile(source, target string) error {
+ from, err := windows.UTF16PtrFromString(source)
+ if err != nil {
+ return err
+ }
+ to, err := windows.UTF16PtrFromString(target)
+ if err != nil {
+ return err
+ }
+ return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
+}
diff --git a/internal/localadmin/reply_statistics_test.go b/internal/localadmin/reply_statistics_test.go
new file mode 100644
index 0000000000..fb4b6cda81
--- /dev/null
+++ b/internal/localadmin/reply_statistics_test.go
@@ -0,0 +1,40 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/FloatTech/ZeroBot-Plugin/internal/replystats"
+)
+
+func TestReplyStatisticsRequireAuthAndMissingSourceIsNotZero(t *testing.T) {
+ cfg := testConfig(t)
+ c := replystats.New()
+ reads := 0
+ cfg.ReplyTasks = func() replystats.Snapshot { reads++; return c.Read() }
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(s, "GET", "/api/overview", nil, nil, ""), 401)
+ if reads != 0 {
+ t.Fatal("unauthenticated read")
+ }
+ cookie, _ := enroll(t, s)
+ w := request(s, "GET", "/api/overview", nil, cookie, "")
+ expectStatus(t, w, 200)
+ var got Overview
+ if err = json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.ReplyTasks == nil || got.ReplyTasks.Started != 0 || len(got.ReplyTasks.Categories) != 3 || got.ReplyTasks.Scope != "bot-matcher-entry-to-onebot-send-receipt" || reads != 1 || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("reply statistics missing or cacheable")
+ }
+ s.cfg.ReplyTasks = nil
+ w = request(s, "GET", "/api/overview", nil, cookie, "")
+ json.Unmarshal(w.Body.Bytes(), &got)
+ if got.ReplyTasks != nil {
+ t.Fatal("missing source reported as zero")
+ }
+ expectStatus(t, request(s, "POST", "/api/overview", map[string]string{}, cookie, ""), 405)
+}
diff --git a/internal/localadmin/response_state_test.go b/internal/localadmin/response_state_test.go
new file mode 100644
index 0000000000..5292f7cdfc
--- /dev/null
+++ b/internal/localadmin/response_state_test.go
@@ -0,0 +1,146 @@
+package localadmin
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func TestResponseStateGlobalOrGroupMatchesExistingRules(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ m := c.Manager
+ check := func(gid int64, global, group bool, source string) {
+ t.Helper()
+ v, err := m.ReadResponseState(gid)
+ if err != nil || v.GlobalEnabled != global || v.GroupEnabled != group || v.EffectiveEnabled != (global || group) || v.Source != source || m.CanResponse(gid) != v.EffectiveEnabled {
+ t.Fatal("response gate mismatch", v, err)
+ }
+ }
+ check(0, false, false, "none")
+ check(101, false, false, "none")
+ if err := m.Response(101); err != nil {
+ t.Fatal(err)
+ }
+ check(101, false, true, "group")
+ check(202, false, false, "none")
+ if err := m.Response(0); err != nil {
+ t.Fatal(err)
+ }
+ check(0, true, false, "global")
+ check(101, true, true, "global")
+ check(202, true, false, "global")
+ if err := m.Silence(202); err != nil {
+ t.Fatal(err)
+ }
+ check(202, true, false, "global")
+ if err := m.Silence(0); err != nil {
+ t.Fatal(err)
+ }
+ check(101, false, true, "group")
+ check(202, false, false, "none")
+}
+
+func TestResponseStateReadHidesAndPreservesExtensionsAndNeverBacksUp(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ m := c.Manager
+ secret := `{"Key":"private-model-secret","future":9007199254740993}`
+ m.Lock()
+ err := m.D.Insert("__resp", &ctrl.ResponseGroup{GroupID: 9223372036854775807, Extra: secret})
+ m.BeforePolicy = func() error { t.Fatal("read invoked policy backup"); return nil }
+ m.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ v, err := m.ReadResponseState(9223372036854775807)
+ if err != nil || !v.GroupEnabled || v.Source != "group" {
+ t.Fatal(v, err)
+ }
+ out, _ := json.Marshal(v)
+ if strings.Contains(string(out), "private") || strings.Contains(string(out), "future") || strings.Contains(string(out), "9007199") {
+ t.Fatal("extension leaked")
+ }
+ m.Lock()
+ var row ctrl.ResponseGroup
+ err = m.D.Find("__resp", &row, "WHERE gid = ?", int64(9223372036854775807))
+ if err == nil {
+ err = m.D.Insert("__resp", &ctrl.ResponseGroup{GroupID: 101, Extra: "-"})
+ }
+ m.Unlock()
+ if err != nil || row.Extra != secret {
+ t.Fatal("read changed extension", err)
+ }
+ if v, err := m.ReadResponseState(101); err != nil || v.EffectiveEnabled {
+ t.Fatal("sentinel interpreted as responding", err)
+ }
+}
+
+func TestResponseStateReadErrorsDoNotReturnCachedActiveState(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ if err := c.Manager.Response(0); err != nil || !c.Manager.CanResponse(101) {
+ t.Fatal("fixture not active", err)
+ }
+ if _, err := c.Manager.ReadResponseState(-1); err == nil {
+ t.Fatal("private/reserved row exposed as a group")
+ }
+ c.Manager.Lock()
+ _, err := c.Manager.D.Exec("DROP TABLE __resp")
+ c.Manager.Unlock()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.Manager.ReadResponseState(101); err == nil {
+ t.Fatal("database error silently converted to cached state")
+ }
+}
+
+func TestPluginResponseStateAPIAuthUnavailableAndReadOnly(t *testing.T) {
+ c, _ := pluginFixture(t, false)
+ backups := filepath.Join(t.TempDir(), "backups")
+ cfg := testConfig(t)
+ cfg.Plugins = []Plugin{{Name: c.Service}}
+ cfg.PluginControl = NewPluginStore([]*ctrl.Control[*zero.Ctx]{c}, backups)
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(s, "GET", "/api/plugins?group=101", nil, nil, ""), 401)
+ cookie, _ := enroll(t, s)
+ read := func() map[string]any {
+ t.Helper()
+ w := request(s, "GET", "/api/plugins?group=101", nil, cookie, "")
+ expectStatus(t, w, 200)
+ var value map[string]any
+ if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
+ t.Fatal(err)
+ }
+ return value
+ }
+ if read()["response"].(map[string]any)["effectiveEnabled"] != false {
+ t.Fatal("empty response assumed active")
+ }
+ if _, err := os.Stat(backups); !os.IsNotExist(err) {
+ t.Fatal("read created backup directory", err)
+ }
+ if err := c.Manager.Response(101); err != nil {
+ t.Fatal(err)
+ }
+ if read()["response"].(map[string]any)["source"] != "group" {
+ t.Fatal("chat policy change not visible")
+ }
+ other, _ := pluginFixture(t, false)
+ second := other.Manager.NewControl("second", &ctrl.Options[*zero.Ctx]{})
+ s.cfg.PluginControl = NewPluginStore([]*ctrl.Control[*zero.Ctx]{c, second}, backups)
+ if read()["response"] != nil {
+ t.Fatal("multiple managers guessed unified response")
+ }
+ s.cfg.PluginControl = nil
+ if read()["response"] != nil {
+ t.Fatal("unavailable control guessed response")
+ }
+ expectStatus(t, request(s, "GET", "/api/plugins?group=-1", nil, cookie, ""), 400)
+}
diff --git a/internal/localadmin/runtime.go b/internal/localadmin/runtime.go
new file mode 100644
index 0000000000..31f938224e
--- /dev/null
+++ b/internal/localadmin/runtime.go
@@ -0,0 +1,46 @@
+package localadmin
+
+import (
+ "runtime"
+ "runtime/debug"
+ "sort"
+ "strconv"
+ "time"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// RuntimeOverview reads the current caller registry on every request. It reports
+// OneBot transport availability, not an unverified claim about QQ login state.
+func RuntimeOverview(started time.Time, configured, pluginCount int) func() Overview {
+ version := "开发版本"
+ if info, ok := debug.ReadBuildInfo(); ok {
+ for _, setting := range info.Settings {
+ if setting.Key == "vcs.revision" && len(setting.Value) >= 8 {
+ version = setting.Value[:8]
+ }
+ if setting.Key == "vcs.modified" && setting.Value == "true" {
+ version += " · 本地修改"
+ }
+ }
+ }
+ return func() Overview {
+ accounts := []string{}
+ zero.APICallers.Range(func(id int64, _ zero.APICaller) bool {
+ if id > 0 {
+ accounts = append(accounts, strconv.FormatInt(id, 10))
+ }
+ return true
+ })
+ sort.Strings(accounts)
+ state := "waiting"
+ if configured == 0 {
+ state = "unconfigured"
+ }
+ if len(accounts) > 0 {
+ state = "connected"
+ }
+ now := time.Now()
+ return Overview{StartedAt: started, CheckedAt: now, UptimeSeconds: int64(now.Sub(started).Seconds()), Version: version, GoVersion: runtime.Version(), ConnectionState: state, ConfiguredConnections: configured, BotAccounts: accounts, PluginCount: pluginCount}
+ }
+}
diff --git a/internal/localadmin/server.go b/internal/localadmin/server.go
new file mode 100644
index 0000000000..b64f90cb8c
--- /dev/null
+++ b/internal/localadmin/server.go
@@ -0,0 +1,357 @@
+// Package localadmin serves the local owner's admin UI and authenticated APIs.
+package localadmin
+
+import (
+ "context"
+ "crypto/subtle"
+ "embed"
+ "encoding/json"
+ "errors"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/modelstats"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/processidentity"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/replystats"
+ "io"
+ "net"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+)
+
+//go:embed assets/index.html assets/app.js assets/connections-add.js assets/connections-delete.js assets/overview.js assets/reply-metrics.js assets/model-observation.js assets/ai.js assets/logs.js assets/backups.js assets/security.js assets/theme.js assets/style.css assets/appearance.css
+var assets embed.FS
+
+type Overview struct {
+ StartedAt time.Time `json:"startedAt"`
+ CheckedAt time.Time `json:"checkedAt"`
+ UptimeSeconds int64 `json:"uptimeSeconds"`
+ Version string `json:"version"`
+ GoVersion string `json:"goVersion"`
+ ConnectionState string `json:"connectionState"`
+ ConfiguredConnections int `json:"configuredConnections"`
+ BotAccounts []string `json:"botAccounts"`
+ PluginCount int `json:"pluginCount"`
+ Alerts *OverviewAlerts `json:"alerts"`
+ ModelRequests *modelstats.Snapshot `json:"modelRequests"`
+ ReplyTasks *replystats.Snapshot `json:"replyTasks"`
+}
+
+type Plugin struct {
+ Name string `json:"name"`
+ Brief string `json:"brief"`
+ Usage string `json:"usage"`
+ DefaultEnabledAtStartup bool `json:"defaultEnabledAtStartup"`
+ State *PluginState `json:"state,omitempty"`
+}
+
+type Config struct {
+ Addr string
+ DataDir string
+ Overview func() Overview
+ Plugins []Plugin
+ Connections *ConnectionStore
+ PluginControl *PluginStore
+ AI *AIStore
+ Logs *LogStore
+ Backups *BackupStore
+ ModelRequests func() modelstats.Snapshot
+ ReplyTasks func() replystats.Snapshot
+ ProcessIdentity *processidentity.Identity
+}
+
+type Server struct {
+ cfg Config
+ http *http.Server
+ mu sync.Mutex
+ credential *credential
+ setupCode string
+ sessions map[string]session
+ failures []time.Time
+ now func() time.Time
+ replaceCredential func(string, string) error
+}
+
+func New(cfg Config) (*Server, error) {
+ host, port, err := net.SplitHostPort(cfg.Addr)
+ n, parseErr := strconv.Atoi(port)
+ if err != nil || parseErr != nil || host != "127.0.0.1" || n < 1 || n > 65535 || strconv.Itoa(n) != port {
+ return nil, errors.New("后台地址必须是 127.0.0.1 和有效端口,例如 127.0.0.1:3010")
+ }
+ if cfg.DataDir == "" || cfg.Overview == nil {
+ return nil, errors.New("后台数据目录与状态来源不能为空")
+ }
+ cfg.DataDir, err = filepath.Abs(cfg.DataDir)
+ if err != nil {
+ return nil, err
+ }
+ cfg.Plugins = append([]Plugin{}, cfg.Plugins...)
+ s := &Server{cfg: cfg, sessions: make(map[string]session), now: time.Now, replaceCredential: replaceConfigFile}
+ if err := s.loadAuth(); err != nil {
+ return nil, err
+ }
+ if cfg.Logs != nil {
+ // A failed refresh does not prevent bot startup; log reads fail closed.
+ _ = s.refreshLogSecrets()
+ }
+ return s, nil
+}
+
+// Start binds synchronously so port conflicts are reported before bot startup.
+func Start(cfg Config) (*Server, error) {
+ // Validate before listening or creating local credentials.
+ host, port, err := net.SplitHostPort(cfg.Addr)
+ n, parseErr := strconv.Atoi(port)
+ if err != nil || parseErr != nil || host != "127.0.0.1" || n < 1 || n > 65535 || strconv.Itoa(n) != port {
+ return nil, errors.New("后台仅支持 127.0.0.1 和有效端口")
+ }
+ listener, err := net.Listen("tcp4", cfg.Addr)
+ if err != nil {
+ return nil, errors.New("后台端口无法监听,请检查是否被占用")
+ }
+ s, err := New(cfg)
+ if err != nil {
+ listener.Close()
+ return nil, err
+ }
+ s.http = &http.Server{Handler: s.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16384}
+ go func() { _ = s.http.Serve(listener) }()
+ return s, nil
+}
+
+func (s *Server) Close(ctx context.Context) error {
+ if s.http == nil {
+ return nil
+ }
+ return s.http.Shutdown(ctx)
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/session", s.getSession)
+ mux.HandleFunc("POST /api/setup", s.setup)
+ mux.HandleFunc("POST /api/login", s.login)
+ mux.HandleFunc("POST /api/logout", s.authorize(s.logout, true))
+ mux.HandleFunc("POST /api/password", s.authorize(s.changePassword, true))
+ mux.HandleFunc("GET /api/connections", s.authorize(s.getConnections, false))
+ mux.HandleFunc("POST /api/connections", s.authorize(s.saveConnections, true))
+ mux.HandleFunc("GET /api/overview", s.authorize(s.getOverview, false))
+ mux.HandleFunc("GET /api/plugins", s.authorize(s.getPlugins, false))
+ mux.HandleFunc("POST /api/plugin-switch", s.authorize(s.setPluginSwitch, true))
+ mux.HandleFunc("GET /api/ai", s.authorize(s.getAI, false))
+ mux.HandleFunc("POST /api/ai", s.authorize(s.saveAI, true))
+ mux.HandleFunc("POST /api/ai-group", s.authorize(s.saveAIGroup, true))
+ mux.HandleFunc("GET /api/logs", s.authorize(s.getLogs, false))
+ mux.HandleFunc("GET /api/logs/export", s.authorize(s.exportLogs, false))
+ mux.HandleFunc("GET /api/backups", s.authorize(s.getBackups, false))
+ mux.HandleFunc("POST /api/backups", s.authorize(s.createBackup, true))
+ mux.HandleFunc("POST /api/backups/preview", s.authorize(s.previewBackup, true))
+ mux.HandleFunc("POST /api/backups/restore", s.authorize(s.restoreBackup, true))
+ mux.HandleFunc("POST /api/backups/cleanup-preview", s.authorize(s.previewBackupCleanup, true))
+ mux.HandleFunc("POST /api/backups/cleanup", s.authorize(s.cleanupBackups, true))
+ for route, asset := range map[string]string{"/": "index.html", "/app.js": "app.js", "/connections-add.js": "connections-add.js", "/connections-delete.js": "connections-delete.js", "/overview.js": "overview.js", "/reply-metrics.js": "reply-metrics.js", "/model-observation.js": "model-observation.js", "/ai.js": "ai.js", "/logs.js": "logs.js", "/backups.js": "backups.js", "/security.js": "security.js", "/theme.js": "theme.js", "/style.css": "style.css", "/appearance.css": "appearance.css"} {
+ pattern := "GET " + route
+ if route == "/" {
+ pattern = "GET /{$}"
+ }
+ mux.HandleFunc(pattern, func(w http.ResponseWriter, _ *http.Request) {
+ b, err := assets.ReadFile("assets/" + asset)
+ if err != nil {
+ apiError(w, 500, "页面资源无法读取")
+ return
+ }
+ kind := "text/html; charset=utf-8"
+ if strings.HasSuffix(asset, ".js") {
+ kind = "text/javascript; charset=utf-8"
+ }
+ if strings.HasSuffix(asset, ".css") {
+ kind = "text/css; charset=utf-8"
+ }
+ w.Header().Set("Content-Type", kind)
+ _, _ = w.Write(b)
+ })
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil || host != "127.0.0.1" || r.Host != s.cfg.Addr {
+ apiError(w, 403, "仅允许本机地址访问")
+ return
+ }
+ origin := "http://" + s.cfg.Addr
+ if value := r.Header.Get("Origin"); value != "" && value != origin {
+ apiError(w, 403, "请求来源不受信任")
+ return
+ }
+ // The root serves only the public UI shell. Browser extensions and links
+ // from other sites can open it as a cross-site top-level navigation.
+ // Keep APIs, subresources, embedded documents and writes same-origin.
+ publicNavigation := r.Method == http.MethodGet && r.URL.Path == "/" &&
+ r.Header.Get("Sec-Fetch-Mode") == "navigate" && r.Header.Get("Sec-Fetch-Dest") == "document"
+ if value := r.Header.Get("Sec-Fetch-Site"); value != "" && value != "same-origin" && value != "none" &&
+ !((value == "cross-site" || value == "same-site") && publicNavigation) {
+ apiError(w, 403, "请求来源不受信任")
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ if r.Header.Get("Origin") != origin || r.Header.Get("X-Local-Request") != "1" || r.Header.Get("Content-Type") != "application/json" {
+ apiError(w, 403, "请通过本地后台提交请求")
+ return
+ }
+ }
+ mux.ServeHTTP(w, r)
+ })
+}
+
+func jsonReply(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
+func apiError(w http.ResponseWriter, status int, text string) {
+ jsonReply(w, status, map[string]string{"error": text})
+}
+
+func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
+ return readJSONLimit(w, r, dst, 4096)
+}
+
+func readJSONLimit(w http.ResponseWriter, r *http.Request, dst any, limit int64) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, limit)
+ dec := json.NewDecoder(r.Body)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(dst); err != nil {
+ apiError(w, 400, "请求格式无效")
+ return false
+ }
+ if err := dec.Decode(&struct{}{}); err != io.EOF {
+ apiError(w, 400, "请求格式无效")
+ return false
+ }
+ return true
+}
+
+func (s *Server) currentSession(r *http.Request) (string, session, bool) {
+ cookie, err := r.Cookie("local_admin_session")
+ if err != nil {
+ return "", session{}, false
+ }
+ value, ok := s.sessions[cookie.Value]
+ if !ok || !s.now().Before(value.Expires) {
+ delete(s.sessions, cookie.Value)
+ return "", session{}, false
+ }
+ return cookie.Value, value, true
+}
+
+func (s *Server) getSession(w http.ResponseWriter, r *http.Request) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ _, value, ok := s.currentSession(r)
+ response := map[string]any{"authenticated": ok, "setupRequired": s.credential == nil}
+ if ok {
+ response["csrfToken"] = value.CSRF
+ }
+ jsonReply(w, 200, response)
+}
+
+func (s *Server) authorize(next http.HandlerFunc, write bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ s.mu.Lock()
+ _, value, ok := s.currentSession(r)
+ s.mu.Unlock()
+ if !ok {
+ apiError(w, 401, "请先登录本地后台")
+ return
+ }
+ if write && subtle.ConstantTimeCompare([]byte(value.CSRF), []byte(r.Header.Get("X-CSRF-Token"))) != 1 {
+ apiError(w, 403, "请求校验失败,请刷新页面")
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (s *Server) newSession(w http.ResponseWriter) {
+ s.pruneSessions()
+ if len(s.sessions) >= 32 {
+ apiError(w, 429, "登录会话过多,请稍后重试")
+ return
+ }
+ key := randomCode()
+ value := session{CSRF: randomCode(), Expires: s.now().Add(8 * time.Hour)}
+ s.sessions[key] = value
+ http.SetCookie(w, &http.Cookie{Name: "local_admin_session", Value: key, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 8 * 60 * 60})
+ jsonReply(w, 200, map[string]any{"authenticated": true, "csrfToken": value.CSRF})
+}
+
+func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ SetupCode string `json:"setupCode"`
+ Password string `json:"password"`
+ }
+ if !readJSON(w, r, &body) {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.credential != nil {
+ apiError(w, 409, "后台密码已经设置,请登录")
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(s.setupCode), []byte(strings.TrimSpace(body.SetupCode))) != 1 {
+ apiError(w, 401, "设置码不正确")
+ return
+ }
+ if utf8.RuneCountInString(body.Password) < 12 || len(body.Password) > 512 {
+ apiError(w, 400, "密码至少 12 个字符,且不超过 512 字节")
+ return
+ }
+ if err := s.createCredential(body.Password); err != nil {
+ apiError(w, 500, "密码保存失败,请检查后台数据目录权限")
+ return
+ }
+ s.newSession(w)
+}
+
+func (s *Server) login(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Password string `json:"password"`
+ }
+ if !readJSON(w, r, &body) {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.credential == nil {
+ apiError(w, 409, "请先设置后台密码")
+ return
+ }
+ if s.authRateLimited() {
+ apiError(w, 429, "尝试次数过多,请一分钟后重试")
+ return
+ }
+ if len(body.Password) > 512 || !s.validPassword(body.Password) {
+ s.failures = append(s.failures, s.now())
+ apiError(w, 401, "密码不正确")
+ return
+ }
+ s.failures = nil
+ s.newSession(w)
+}
+
+func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
+ s.mu.Lock()
+ if cookie, err := r.Cookie("local_admin_session"); err == nil {
+ delete(s.sessions, cookie.Value)
+ }
+ s.mu.Unlock()
+ http.SetCookie(w, &http.Cookie{Name: "local_admin_session", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteStrictMode})
+ jsonReply(w, 200, map[string]bool{"authenticated": false})
+}
diff --git a/internal/localadmin/server_test.go b/internal/localadmin/server_test.go
new file mode 100644
index 0000000000..dbd9bc3479
--- /dev/null
+++ b/internal/localadmin/server_test.go
@@ -0,0 +1,259 @@
+package localadmin
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+const testPassword = "local-test-password-only"
+
+func testConfig(t *testing.T) Config {
+ t.Helper()
+ return Config{Addr: "127.0.0.1:3010", DataDir: t.TempDir(), Overview: func() Overview { return Overview{BotAccounts: []string{"12345678"}, ConnectionState: "connected"} }, Plugins: []Plugin{{Name: "test-plugin", Brief: "local fixture", Usage: "help"}}}
+}
+
+func newTestServer(t *testing.T) *Server {
+ t.Helper()
+ s, err := New(testConfig(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func request(s *Server, method, path string, body any, cookie *http.Cookie, csrf string) *httptest.ResponseRecorder {
+ var reader io.Reader
+ if body != nil {
+ b, _ := json.Marshal(body)
+ reader = strings.NewReader(string(b))
+ }
+ r := httptest.NewRequest(method, "http://"+s.cfg.Addr+path, reader)
+ r.RemoteAddr = "127.0.0.1:54321"
+ if method != "GET" {
+ r.Header.Set("Origin", "http://"+s.cfg.Addr)
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("X-Local-Request", "1")
+ r.Header.Set("X-CSRF-Token", csrf)
+ }
+ if cookie != nil {
+ r.AddCookie(cookie)
+ }
+ w := httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ return w
+}
+
+func expectStatus(t *testing.T, w *httptest.ResponseRecorder, expected int) {
+ t.Helper()
+ if w.Code != expected {
+ t.Fatalf("HTTP %d, want %d: %s", w.Code, expected, w.Body.String())
+ }
+}
+
+func enroll(t *testing.T, s *Server) (*http.Cookie, string) {
+ t.Helper()
+ w := request(s, "POST", "/api/setup", map[string]string{"setupCode": s.setupCode, "password": testPassword}, nil, "")
+ expectStatus(t, w, 200)
+ var value struct {
+ CSRF string `json:"csrfToken"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
+ t.Fatal(err)
+ }
+ return w.Result().Cookies()[0], value.CSRF
+}
+
+func TestAuthLifecycleAndPersistence(t *testing.T) {
+ s := newTestServer(t)
+ for _, path := range []string{"/api/overview", "/api/plugins"} {
+ expectStatus(t, request(s, "GET", path, nil, nil, ""), 401)
+ }
+ state := request(s, "GET", "/api/session", nil, nil, "")
+ if !strings.Contains(state.Body.String(), `"setupRequired":true`) || strings.Contains(state.Body.String(), s.setupCode) {
+ t.Fatal("setup status exposed code or was incorrect")
+ }
+ expectStatus(t, request(s, "POST", "/api/setup", map[string]string{"setupCode": "incorrect", "password": testPassword}, nil, ""), 401)
+ expectStatus(t, request(s, "POST", "/api/setup", map[string]string{"setupCode": s.setupCode, "password": "short"}, nil, ""), 400)
+ cookie, csrf := enroll(t, s)
+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || csrf == "" {
+ t.Fatal("invalid session properties")
+ }
+ expectStatus(t, request(s, "POST", "/api/setup", map[string]string{"setupCode": "anything", "password": testPassword}, nil, ""), 409)
+ for _, path := range []string{"/api/overview", "/api/plugins", "/api/session"} {
+ w := request(s, "GET", path, nil, cookie, "")
+ expectStatus(t, w, 200)
+ if w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("sensitive response was cacheable")
+ }
+ }
+ b, err := os.ReadFile(filepath.Join(s.cfg.DataDir, "credential.json"))
+ if err != nil || strings.Contains(string(b), testPassword) {
+ t.Fatal("password persisted as plaintext or was not saved")
+ }
+ if _, err = os.Stat(filepath.Join(s.cfg.DataDir, "setup-code.txt")); !os.IsNotExist(err) {
+ t.Fatal("setup code was not removed")
+ }
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": "incorrect"}, nil, ""), 401)
+ expectStatus(t, request(s, "POST", "/api/logout", map[string]string{}, cookie, "incorrect"), 403)
+ expectStatus(t, request(s, "POST", "/api/logout", map[string]string{}, cookie, csrf), 200)
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 401)
+ reloaded, err := New(s.cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(reloaded, "POST", "/api/login", map[string]string{"password": testPassword}, nil, ""), 200)
+ expectStatus(t, request(reloaded, "GET", "/api/overview", nil, cookie, ""), 401)
+}
+
+func TestBoundaryChecks(t *testing.T) {
+ s := newTestServer(t)
+ cookie, _ := enroll(t, s)
+ for _, tc := range []struct {
+ name string
+ mutate func(*http.Request)
+ }{
+ {"remote peer", func(r *http.Request) { r.RemoteAddr = "192.168.1.2:1234" }},
+ {"rebinding host", func(r *http.Request) { r.Host = "evil.example:3010" }},
+ {"null origin", func(r *http.Request) { r.Header.Set("Origin", "null") }},
+ {"cross origin", func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }},
+ {"other localhost port", func(r *http.Request) { r.Header.Set("Origin", "http://127.0.0.1:3011") }},
+ {"fetch cross-site", func(r *http.Request) { r.Header.Set("Sec-Fetch-Site", "cross-site") }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ r := httptest.NewRequest("GET", "http://"+s.cfg.Addr+"/api/overview", nil)
+ r.RemoteAddr = "127.0.0.1:1234"
+ r.AddCookie(cookie)
+ tc.mutate(r)
+ w := httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ expectStatus(t, w, 403)
+ })
+ }
+ for _, missing := range []string{"Origin", "Content-Type", "X-Local-Request"} {
+ r := httptest.NewRequest("POST", "http://"+s.cfg.Addr+"/api/login", strings.NewReader(`{"password":"test"}`))
+ r.RemoteAddr = "127.0.0.1:1234"
+ r.Header.Set("Origin", "http://"+s.cfg.Addr)
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("X-Local-Request", "1")
+ r.Header.Del(missing)
+ w := httptest.NewRecorder()
+ s.Handler().ServeHTTP(w, r)
+ expectStatus(t, w, 403)
+ }
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": testPassword, "unknown": "x"}, nil, ""), 400)
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": strings.Repeat("x", 5000)}, nil, ""), 400)
+ expectStatus(t, request(s, "GET", "/api/logout", nil, cookie, ""), 405)
+ for _, path := range []string{"/credential.json", "/setup-code.txt", "/.local/admin/credential.json", "/data/control/plugins.db"} {
+ expectStatus(t, request(s, "GET", path, nil, cookie, ""), 404)
+ }
+}
+
+func TestExpiryAndLoginThrottle(t *testing.T) {
+ s := newTestServer(t)
+ cookie, _ := enroll(t, s)
+ now := time.Now().Add(9 * time.Hour)
+ s.now = func() time.Time { return now }
+ expectStatus(t, request(s, "GET", "/api/overview", nil, cookie, ""), 401)
+ for range 5 {
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": "incorrect"}, nil, ""), 401)
+ }
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": testPassword}, nil, ""), 429)
+ now = now.Add(61 * time.Second)
+ expectStatus(t, request(s, "POST", "/api/login", map[string]string{"password": testPassword}, nil, ""), 200)
+}
+
+func TestCorruptCredentialFailsClosed(t *testing.T) {
+ s := newTestServer(t)
+ enroll(t, s)
+ path := filepath.Join(s.cfg.DataDir, "credential.json")
+ if err := os.WriteFile(path, []byte("broken"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := New(s.cfg); err == nil {
+ t.Fatal("corrupt credentials accepted")
+ }
+ b, _ := os.ReadFile(path)
+ if string(b) != "broken" {
+ t.Fatal("corrupt credential overwritten")
+ }
+}
+
+func TestBindingAndOfflineAssets(t *testing.T) {
+ for _, addr := range []string{"0.0.0.0:3010", "localhost:3010", "[::1]:3010", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:03010"} {
+ cfg := testConfig(t)
+ cfg.Addr = addr
+ if _, err := Start(cfg); err == nil {
+ t.Fatalf("accepted address %s", addr)
+ }
+ }
+ listener, err := net.Listen("tcp4", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := testConfig(t)
+ cfg.Addr = listener.Addr().String()
+ if _, err := Start(cfg); err == nil {
+ t.Fatal("accepted occupied port")
+ }
+ listener.Close()
+ s, err := Start(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close(context.Background())
+ client := &http.Client{Timeout: 2 * time.Second}
+ for _, path := range []string{"/", "/app.js", "/theme.js", "/style.css", "/appearance.css"} {
+ response, err := client.Get("http://" + cfg.Addr + path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := io.ReadAll(response.Body)
+ response.Body.Close()
+ if err != nil || response.StatusCode != 200 || len(b) == 0 {
+ t.Fatalf("offline asset failed: %s", path)
+ }
+ if strings.Contains(string(b), "https://") || strings.Contains(string(b), "http://cdn") {
+ t.Fatalf("external asset in %s", path)
+ }
+ if response.Header.Get("Content-Security-Policy") == "" {
+ t.Fatal("CSP missing")
+ }
+ }
+}
+
+type unusedCaller struct{}
+
+func (unusedCaller) CallAPI(context.Context, zero.APIRequest) (zero.APIResponse, error) {
+ panic("overview must not call OneBot APIs")
+}
+
+func TestOverviewReflectsDisconnect(t *testing.T) {
+ const id int64 = 987654321
+ read := RuntimeOverview(time.Now(), 1, 3)
+ if read().ConnectionState != "waiting" {
+ t.Fatal("unexpected initial state")
+ }
+ zero.APICallers.Store(id, unusedCaller{})
+ defer zero.APICallers.Delete(id)
+ if value := read(); value.ConnectionState != "connected" || len(value.BotAccounts) != 1 {
+ t.Fatal("connection absent")
+ }
+ zero.APICallers.Delete(id)
+ if value := read(); value.ConnectionState != "waiting" || len(value.BotAccounts) != 0 {
+ t.Fatal("stale connected state")
+ }
+ if RuntimeOverview(time.Now(), 0, 0)().ConnectionState != "unconfigured" {
+ t.Fatal("unconfigured state missing")
+ }
+}
diff --git a/internal/localadmin/startup_differences.go b/internal/localadmin/startup_differences.go
new file mode 100644
index 0000000000..3ad9f9dcfb
--- /dev/null
+++ b/internal/localadmin/startup_differences.go
@@ -0,0 +1,25 @@
+package localadmin
+
+// StartupDifference exposes only fixed categories and aggregate counts. The
+// comparison includes secrets, but neither their values nor hashes leave it.
+type StartupDifference struct {
+ Label string `json:"label"`
+ Changed bool `json:"changed"`
+ RunningCount *int `json:"runningCount,omitempty"`
+ SavedCount *int `json:"savedCount,omitempty"`
+ Unit string `json:"unit,omitempty"`
+}
+
+func startupDifferences(running, saved []byte) []StartupDifference {
+ // Both inputs have already passed decodeConfig. Use the same comparison as
+ // restore previews to retain number precision and representation differences.
+ impacts, err := fileRestoreImpacts("startup-config", running, saved)
+ if err != nil {
+ return nil
+ }
+ items := make([]StartupDifference, 0, len(impacts))
+ for _, v := range impacts {
+ items = append(items, StartupDifference{Label: v.Label, Changed: v.Changed, RunningCount: v.CurrentCount, SavedCount: v.BackupCount, Unit: v.Unit})
+ }
+ return items
+}
diff --git a/internal/localadmin/startup_differences_test.go b/internal/localadmin/startup_differences_test.go
new file mode 100644
index 0000000000..c96773d635
--- /dev/null
+++ b/internal/localadmin/startup_differences_test.go
@@ -0,0 +1,126 @@
+package localadmin
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestStartupDifferencesSameCountsDetectSecretsAndUnknownChanges(t *testing.T) {
+ a := []byte(connectionFixture)
+ b := []byte(strings.ReplaceAll(strings.ReplaceAll(connectionFixture, "secret-A", "new-private-token"), "9007199254740993", "9007199254740994"))
+ items := startupDifferences(a, b)
+ if len(items) != 5 || !items[0].Changed || !items[3].Changed {
+ t.Fatal("same-count changes were omitted")
+ }
+ for i, expected := range []int{2, 1, 1, 2, 2} {
+ if *items[i].RunningCount != expected || *items[i].SavedCount != expected {
+ t.Fatal("wrong category counts", i)
+ }
+ if items[i].Changed != (i == 0 || i == 3) {
+ t.Fatal("wrong category change", i)
+ }
+ }
+ out, _ := json.Marshal(items)
+ for _, value := range []string{"secret", "new-private", "900719925", "future", "nickname", "bot.example", "extension", "hash"} {
+ if bytes.Contains(out, []byte(value)) {
+ t.Fatal("summary exposed a value or field", value)
+ }
+ }
+}
+
+func TestStartupDifferencesRepresentationAndEmptyConfigStayConsistent(t *testing.T) {
+ for _, pair := range [][2]string{
+ {`{"zero":{},"ws":null}`, `{"zero":{},"ws":[]}`},
+ {`{"zero":{},"ws":[]}`, `{"ZERO":{},"WS":[]}`},
+ {`{"zero":{"super_users":null}}`, `{"zero":{"super_users":[]}}`},
+ {`{"zero":{},"other":9007199254740993}`, `{"zero":{},"other":9007199254740994}`},
+ {`{"zero":{},"other":1}`, `{"other":1,"zero":{}}`},
+ {`{"zero":{}}`, `{"zero":{}}`},
+ } {
+ items := startupDifferences([]byte(pair[0]), []byte(pair[1]))
+ changed := false
+ for _, item := range items {
+ changed = changed || item.Changed
+ }
+ if changed == equivalentConfig([]byte(pair[0]), []byte(pair[1])) {
+ t.Fatal("summary disagrees with restart decision", pair)
+ }
+ if len(items) < 5 {
+ t.Fatal("empty categories missing")
+ }
+ }
+ items := startupDifferences([]byte(`{"zero":{},"ws":[]}`), []byte(`{"ZERO":{},"WS":[]}`))
+ last := items[len(items)-1]
+ if last.Label != "字段名称或表示差异" || !last.Changed || last.RunningCount != nil || last.SavedCount != nil {
+ t.Fatal("representation difference was assigned invented counts")
+ }
+}
+
+func TestStartupDifferencesFollowSaveRestoreAndNewStartupWithoutMutating(t *testing.T) {
+ s := fixtureConnectionStore(t)
+ v := getSettings(t, s)
+ for _, item := range v.StartupDifferences {
+ if item.Changed {
+ t.Fatal("initial state changed")
+ }
+ }
+ saved, err := s.Save(ConnectionUpdate{Revision: v.Revision, AddConnection: &ConnectionAddition{URL: "ws://127.0.0.1:17005", Token: "new-token"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ item := saved.StartupDifferences[0]
+ if !saved.RestartRequired || !item.Changed || *item.RunningCount != 2 || *item.SavedCount != 3 {
+ t.Fatal("save summary incorrect")
+ }
+ raw, _ := os.ReadFile(s.path)
+ newStartup, err := NewConnectionStore(s.path, s.backups, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if v := getSettings(t, newStartup); v.RestartRequired || v.StartupDifferences[0].Changed || *v.StartupDifferences[0].RunningCount != 3 {
+ t.Fatal("new startup kept old snapshot")
+ }
+ if err := os.WriteFile(s.path, []byte(connectionFixture), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if v := getSettings(t, s); v.RestartRequired || v.StartupDifferences[0].Changed {
+ t.Fatal("restored saved config did not become consistent")
+ }
+ before, _ := os.ReadFile(s.path)
+ _ = getSettings(t, s)
+ after, _ := os.ReadFile(s.path)
+ if !bytes.Equal(before, after) || !bytes.Equal(s.running, []byte(connectionFixture)) {
+ t.Fatal("read comparison mutated saved or running config")
+ }
+}
+
+func TestStartupDifferencesAPIAuthAndSameCountSecretChange(t *testing.T) {
+ store := fixtureConnectionStore(t)
+ cfg := testConfig(t)
+ cfg.Connections = store
+ s, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectStatus(t, request(s, "GET", "/api/connections", nil, nil, ""), 401)
+ cookie, csrf := enroll(t, s)
+ v := getSettings(t, store)
+ update := ConnectionUpdate{Revision: v.Revision, Connection: &ConnectionChange{Kind: "ws", Index: 0, TokenAction: "replace", Token: "different-token-private"}}
+ w := request(s, "POST", "/api/connections", update, cookie, csrf)
+ expectStatus(t, w, 200)
+ for _, w := range []string{w.Body.String(), request(s, "GET", "/api/connections", nil, cookie, "").Body.String()} {
+ var value ConnectionSettings
+ if err := json.Unmarshal([]byte(w), &value); err != nil {
+ t.Fatal(err)
+ }
+ if !value.RestartRequired || !value.StartupDifferences[0].Changed || *value.StartupDifferences[0].SavedCount != 2 || *value.StartupDifferences[0].RunningCount != 2 {
+ t.Fatal("secret-only change lost in API")
+ }
+ if strings.Contains(w, "different-token-private") || strings.Contains(w, "secret-A") {
+ t.Fatal("API exposed secret")
+ }
+ }
+}
diff --git a/internal/modelstats/stats.go b/internal/modelstats/stats.go
new file mode 100644
index 0000000000..0d1e2b8ae6
--- /dev/null
+++ b/internal/modelstats/stats.go
@@ -0,0 +1,201 @@
+// Package modelstats counts model HTTP exchanges without retaining their contents.
+package modelstats
+
+import (
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/FloatTech/ZeroBot-Plugin/internal/processidentity"
+)
+
+type Kind uint8
+
+const (
+ GPT Kind = iota // /gpt and group summary
+ Chat
+ Agent
+ Vision
+ kindCount
+)
+
+type Counts struct {
+ Started uint64 `json:"started"`
+ Active uint64 `json:"active"`
+ Completed uint64 `json:"completed"`
+ Failed uint64 `json:"failed"`
+ Incomplete uint64 `json:"incomplete"`
+ AverageMilliseconds float64 `json:"averageMilliseconds"`
+ MaxMilliseconds float64 `json:"maxMilliseconds"`
+}
+
+type Category struct {
+ Kind string `json:"kind"`
+ Counts
+}
+
+type Snapshot struct {
+ Process *processidentity.Identity `json:"process,omitempty"`
+ StartedAt time.Time `json:"startedAt"`
+ CheckedAt time.Time `json:"checkedAt"`
+ Counts
+ Categories []Category `json:"categories"`
+}
+
+type aggregate struct {
+ Counts
+ totalMilliseconds float64
+}
+type Collector struct {
+ mu sync.Mutex
+ started time.Time
+ total aggregate
+ rows [kindCount]aggregate
+}
+
+func New() *Collector { return &Collector{started: time.Now().UTC()} }
+
+var Default = New()
+
+// Read copies constant-size aggregates; there is no request history or reset API.
+func (c *Collector) Read() Snapshot {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ s := Snapshot{StartedAt: c.started, CheckedAt: time.Now().UTC(), Counts: c.total.Counts, Categories: make([]Category, kindCount)}
+ for i, name := range [...]string{"gpt", "chat", "agent", "vision"} {
+ s.Categories[i] = Category{Kind: name, Counts: c.rows[i].Counts}
+ }
+ return s
+}
+
+func (c *Collector) begin(kind Kind) func(outcome uint8) {
+ c.mu.Lock()
+ c.total.Started++
+ c.total.Active++
+ c.rows[kind].Started++
+ c.rows[kind].Active++
+ c.mu.Unlock()
+ start := time.Now()
+ var once sync.Once
+ return func(outcome uint8) {
+ once.Do(func() {
+ ms := float64(time.Since(start)) / float64(time.Millisecond)
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ for _, a := range []*aggregate{&c.total, &c.rows[kind]} {
+ a.Active--
+ switch outcome {
+ case 0:
+ a.Completed++
+ case 1:
+ a.Failed++
+ default:
+ a.Incomplete++
+ }
+ a.totalMilliseconds += ms
+ finished := a.Completed + a.Failed + a.Incomplete
+ a.AverageMilliseconds = a.totalMilliseconds / float64(finished)
+ if ms > a.MaxMilliseconds {
+ a.MaxMilliseconds = ms
+ }
+ }
+ })
+ }
+}
+
+// Client copies the client and preserves its timeout, jar, redirect policy and
+// original transport. Exchanges include redirects; it neither retries nor reads
+// any request/response payload. Invalid categories simply leave it unchanged.
+func (c *Collector) Client(client *http.Client, kind Kind) *http.Client {
+ if client == nil {
+ client = http.DefaultClient
+ }
+ if kind >= kindCount {
+ return client
+ }
+ copy := *client
+ base := copy.Transport
+ if base == nil {
+ base = http.DefaultTransport
+ }
+ copy.Transport = &transport{base: base, collector: c, kind: kind}
+ return ©
+}
+
+type transport struct {
+ base http.RoundTripper
+ collector *Collector
+ kind Kind
+}
+
+func (t *transport) CloseIdleConnections() {
+ if closer, ok := t.base.(interface{ CloseIdleConnections() }); ok {
+ closer.CloseIdleConnections()
+ }
+}
+func (t *transport) RoundTrip(r *http.Request) (response *http.Response, err error) {
+ finish := t.collector.begin(t.kind)
+ returned := false
+ defer func() {
+ if !returned {
+ finish(1)
+ }
+ }() // Preserve a transport panic.
+ response, err = t.base.RoundTrip(r)
+ returned = true
+ if err != nil || response == nil {
+ finish(1)
+ return
+ }
+ ok := response.StatusCode >= 200 && response.StatusCode < 300
+ if response.Body == nil || response.Body == http.NoBody {
+ if ok {
+ finish(0)
+ } else {
+ finish(1)
+ }
+ return
+ }
+ response.Body = &body{ReadCloser: response.Body, finish: finish, ok: ok, remaining: response.ContentLength}
+ return
+}
+
+// Completion means 2xx plus EOF or the full declared Content-Length. An early close
+// is incomplete, not successful. Failures never retain error messages.
+type body struct {
+ io.ReadCloser
+ finish func(uint8)
+ ok bool
+ remaining int64
+}
+
+func (b *body) Read(p []byte) (int, error) {
+ n, err := b.ReadCloser.Read(p)
+ if b.remaining >= 0 {
+ b.remaining -= int64(n)
+ }
+ if err != nil {
+ if err == io.EOF && b.ok && b.remaining <= 0 {
+ b.finish(0)
+ } else {
+ b.finish(1)
+ }
+ } else if b.remaining == 0 {
+ if b.ok {
+ b.finish(0)
+ } else {
+ b.finish(1)
+ }
+ }
+ return n, err
+}
+func (b *body) Close() error {
+ err := b.ReadCloser.Close()
+ if err != nil || !b.ok {
+ b.finish(1)
+ } else {
+ b.finish(2)
+ }
+ return err
+}
diff --git a/internal/modelstats/stats_test.go b/internal/modelstats/stats_test.go
new file mode 100644
index 0000000000..e522c431fb
--- /dev/null
+++ b/internal/modelstats/stats_test.go
@@ -0,0 +1,283 @@
+package modelstats
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+type roundTrip func(*http.Request) (*http.Response, error)
+
+func (fn roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return fn(r) }
+
+type failedBody struct{ readErr, closeErr error }
+
+func (b failedBody) Read([]byte) (int, error) { return 0, b.readErr }
+func (b failedBody) Close() error { return b.closeErr }
+func response(status int, b io.ReadCloser) *http.Response {
+ return &http.Response{StatusCode: status, Body: b, ContentLength: -1, Header: make(http.Header)}
+}
+func exchange(c *Collector, kind Kind, status int, b io.ReadCloser) (*http.Response, error) {
+ cl := c.Client(&http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { return response(status, b), nil })}, kind)
+ return cl.Get("http://example.invalid/private-path?secret=never-retained")
+}
+func invariant(t *testing.T, s Snapshot) {
+ t.Helper()
+ if s.Started != s.Active+s.Completed+s.Failed+s.Incomplete || s.MaxMilliseconds < s.AverageMilliseconds {
+ t.Fatalf("inconsistent counters: %+v", s)
+ }
+ var total Counts
+ for _, row := range s.Categories {
+ total.Started += row.Started
+ total.Active += row.Active
+ total.Completed += row.Completed
+ total.Failed += row.Failed
+ total.Incomplete += row.Incomplete
+ }
+ if total.Started != s.Started || total.Active != s.Active || total.Completed != s.Completed || total.Failed != s.Failed || total.Incomplete != s.Incomplete {
+ t.Fatal("category totals differ")
+ }
+}
+func TestCompletionIncludesBodyAndIsCountedOnce(t *testing.T) {
+ c := New()
+ r, err := exchange(c, Vision, 200, io.NopCloser(strings.NewReader("private-response")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s := c.Read(); s.Started != 1 || s.Active != 1 || s.Completed != 0 {
+ t.Fatal(s)
+ }
+ data, err := io.ReadAll(r.Body)
+ if err != nil || string(data) != "private-response" {
+ t.Fatal("payload altered")
+ }
+ for i := 0; i < 2; i++ {
+ r.Body.Close()
+ r.Body.Read(make([]byte, 1))
+ }
+ s := c.Read()
+ invariant(t, s)
+ if s.Active != 0 || s.Completed != 1 || s.Failed != 0 || s.Incomplete != 0 || s.Categories[Vision].Completed != 1 {
+ t.Fatal(s)
+ }
+ b, _ := json.Marshal(s)
+ for _, secret := range []string{"private-response", "private-path", "never-retained", "example.invalid"} {
+ if strings.Contains(string(b), secret) {
+ t.Fatal("payload retained")
+ }
+ }
+ s.Categories[Vision].Started = 999
+ if c.Read().Categories[Vision].Started != 1 {
+ t.Fatal("snapshot aliases collector")
+ }
+}
+func TestTerminalOutcomes(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ status int
+ b io.ReadCloser
+ read bool
+ completed, failed, incomplete uint64
+ }{
+ {"empty", 204, http.NoBody, true, 1, 0, 0},
+ {"http failure", 503, io.NopCloser(strings.NewReader("private-error")), true, 0, 1, 0},
+ {"early close", 200, io.NopCloser(strings.NewReader("unused")), false, 0, 0, 1},
+ {"read failure", 200, failedBody{readErr: io.ErrUnexpectedEOF}, true, 0, 1, 0},
+ {"close failure", 200, failedBody{readErr: nil, closeErr: errors.New("secret-close-error")}, false, 0, 1, 0},
+ {"http failure early close", 401, io.NopCloser(strings.NewReader("unused")), false, 0, 1, 0},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ c := New()
+ r, e := exchange(c, Chat, tc.status, tc.b)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if tc.read {
+ io.ReadAll(r.Body)
+ }
+ r.Body.Close()
+ s := c.Read()
+ invariant(t, s)
+ if s.Active != 0 || s.Completed != tc.completed || s.Failed != tc.failed || s.Incomplete != tc.incomplete {
+ t.Fatal(s)
+ }
+ })
+ }
+}
+func TestTransportErrorAndPanic(t *testing.T) {
+ for _, panics := range []bool{false, true} {
+ c := New()
+ cl := c.Client(&http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) {
+ if panics {
+ panic("private-panic")
+ }
+ return nil, errors.New("private-network-error")
+ })}, Agent)
+ func() {
+ defer func() {
+ if recovered := recover(); panics != (recovered != nil) {
+ t.Fatal("panic changed")
+ }
+ }()
+ cl.Get("http://example.invalid/")
+ }()
+ s := c.Read()
+ if s.Active != 0 || s.Failed != 1 {
+ t.Fatal(s)
+ }
+ b, _ := json.Marshal(s)
+ if strings.Contains(string(b), "private-") {
+ t.Fatal("error retained")
+ }
+ }
+}
+func TestRedirectPolicyAndClientPreserved(t *testing.T) {
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/first" {
+ http.Redirect(w, r, server.URL+"/last", 302)
+ return
+ }
+ io.WriteString(w, "answer")
+ }))
+ defer server.Close()
+ jar, _ := cookiejar.New(nil)
+ base := &http.Client{Timeout: time.Second, Jar: jar}
+ c := New()
+ cl := c.Client(base, GPT)
+ if base.Transport != nil || cl == base || cl.Jar != base.Jar || cl.Timeout != base.Timeout {
+ t.Fatal("client mutated")
+ }
+ r, err := cl.Get(server.URL + "/first")
+ if err != nil {
+ t.Fatal(err)
+ }
+ io.ReadAll(r.Body)
+ r.Body.Close()
+ s := c.Read()
+ invariant(t, s)
+ if s.Started != 2 || s.Completed != 1 || s.Failed != 1 {
+ t.Fatal(s)
+ }
+ base.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
+ c = New()
+ r, err = c.Client(base, GPT).Get(server.URL + "/first")
+ if err != nil || r.StatusCode != 302 {
+ t.Fatal("redirect policy altered")
+ }
+ io.ReadAll(r.Body)
+ r.Body.Close()
+ if s = c.Read(); s.Started != 1 || s.Failed != 1 {
+ t.Fatal(s)
+ }
+}
+func TestConcurrentRequestsBoundedAndNoHistory(t *testing.T) {
+ c := New()
+ var wg sync.WaitGroup
+ for i := 0; i < 200; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ r, err := exchange(c, Kind(i%4), 200, io.NopCloser(strings.NewReader("answer")))
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ invariant(t, c.Read())
+ io.Copy(io.Discard, r.Body)
+ r.Body.Close()
+ }(i)
+ }
+ wg.Wait()
+ s := c.Read()
+ invariant(t, s)
+ if s.Started != 200 || s.Completed != 200 || s.Active != 0 || len(s.Categories) != 4 {
+ t.Fatal(s)
+ }
+}
+
+type idleTransport struct{ closed bool }
+
+func (*idleTransport) RoundTrip(*http.Request) (*http.Response, error) {
+ return response(204, http.NoBody), nil
+}
+func (t *idleTransport) CloseIdleConnections() { t.closed = true }
+func TestDefaultsInvalidKindAndIdleConnections(t *testing.T) {
+ c := New()
+ if cl := c.Client(nil, Chat); cl.Timeout != http.DefaultClient.Timeout || cl.Transport == nil {
+ t.Fatal("default client changed")
+ }
+ base := &http.Client{}
+ if c.Client(base, kindCount) != base || c.Read().Started != 0 {
+ t.Fatal("invalid category accepted")
+ }
+ idle := &idleTransport{}
+ c.Client(&http.Client{Transport: idle}, Chat).CloseIdleConnections()
+ if !idle.closed {
+ t.Fatal("idle close not forwarded")
+ }
+}
+
+func TestCancellationAfterHeadersEndsActiveRequest(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+ w.(http.Flusher).Flush()
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+ c := New()
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ r, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
+ response, err := c.Client(&http.Client{Timeout: time.Second}, Agent).Do(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s := c.Read(); s.Active != 1 || s.Completed != 0 {
+ t.Fatal("headers ended request")
+ }
+ cancel()
+ _, err = io.ReadAll(response.Body)
+ response.Body.Close()
+ if err == nil {
+ t.Fatal("cancelled read succeeded")
+ }
+ s := c.Read()
+ invariant(t, s)
+ if s.Active != 0 || s.Failed != 1 {
+ t.Fatal(s)
+ }
+}
+
+func TestDeclaredLengthCompletesWithoutDecoderReadingEOF(t *testing.T) {
+ for _, size := range []int{512, 1024, 65536} {
+ c := New()
+ data := `{"text":"` + strings.Repeat("a", size-11) + `"}`
+ cl := c.Client(&http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) {
+ r := response(200, io.NopCloser(strings.NewReader(data)))
+ r.ContentLength = int64(len(data))
+ return r, nil
+ })}, GPT)
+ r, err := cl.Get("http://example.invalid/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string]string
+ if err = json.NewDecoder(r.Body).Decode(&got); err != nil {
+ t.Fatal(err)
+ }
+ r.Body.Close()
+ if s := c.Read(); s.Completed != 1 || s.Incomplete != 0 || s.Active != 0 {
+ t.Fatal("valid complete JSON mislabeled", size, s)
+ }
+ }
+}
diff --git a/internal/processidentity/creation_other.go b/internal/processidentity/creation_other.go
new file mode 100644
index 0000000000..49a74ff81d
--- /dev/null
+++ b/internal/processidentity/creation_other.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package processidentity
+
+import (
+ "errors"
+ "time"
+)
+
+func creationTime() (time.Time, error) {
+ return time.Time{}, errors.New("OS process creation time unsupported")
+}
diff --git a/internal/processidentity/creation_windows.go b/internal/processidentity/creation_windows.go
new file mode 100644
index 0000000000..f3dfc9fef0
--- /dev/null
+++ b/internal/processidentity/creation_windows.go
@@ -0,0 +1,17 @@
+//go:build windows
+
+package processidentity
+
+import (
+ "time"
+
+ "golang.org/x/sys/windows"
+)
+
+func creationTime() (time.Time, error) {
+ var created, exited, kernel, user windows.Filetime
+ if err := windows.GetProcessTimes(windows.CurrentProcess(), &created, &exited, &kernel, &user); err != nil {
+ return time.Time{}, err
+ }
+ return time.Unix(0, created.Nanoseconds()).UTC(), nil
+}
diff --git a/internal/processidentity/identity.go b/internal/processidentity/identity.go
new file mode 100644
index 0000000000..82774ce221
--- /dev/null
+++ b/internal/processidentity/identity.go
@@ -0,0 +1,56 @@
+// Package processidentity reads bounded, non-secret identity metadata once at startup.
+package processidentity
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+type Identity struct {
+ ProcessID int `json:"processId"`
+ ProcessStartedAt time.Time `json:"processStartedAt"`
+ ProgramSHA256 string `json:"programSHA256"`
+}
+
+func (i Identity) Valid() bool {
+ if i.ProcessID <= 0 || i.ProcessStartedAt.IsZero() || len(i.ProgramSHA256) != 64 {
+ return false
+ }
+ _, err := hex.DecodeString(i.ProgramSHA256)
+ return err == nil
+}
+
+// Read uses the OS creation time, never the time this function or the admin starts.
+// The caller keeps the value; there is no hashing on overview polls or HTTP requests.
+func Read() (*Identity, error) {
+ started, err := creationTime()
+ if err != nil {
+ return nil, err
+ }
+ executable, err := os.Executable()
+ if err != nil {
+ return nil, err
+ }
+ return readFile(os.Getpid(), started, executable)
+}
+
+func readFile(pid int, started time.Time, executable string) (*Identity, error) {
+ if pid <= 0 || started.IsZero() {
+ return nil, errors.New("process identity unavailable")
+ }
+ file, err := os.Open(executable)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ hash := sha256.New()
+ if _, err = io.Copy(hash, file); err != nil {
+ return nil, err
+ }
+ return &Identity{ProcessID: pid, ProcessStartedAt: started.UTC(), ProgramSHA256: strings.ToUpper(hex.EncodeToString(hash.Sum(nil)))}, nil
+}
diff --git a/internal/processidentity/identity_test.go b/internal/processidentity/identity_test.go
new file mode 100644
index 0000000000..8ee04bad95
--- /dev/null
+++ b/internal/processidentity/identity_test.go
@@ -0,0 +1,69 @@
+package processidentity
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+func TestExactFileHashAndFixedIdentityFields(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "program.bin")
+ body := []byte("not a path, account or config")
+ if err := os.WriteFile(file, body, 0600); err != nil {
+ t.Fatal(err)
+ }
+ start := time.Date(2026, 10, 13, 8, 1, 2, 123456700, time.FixedZone("local", 8*3600))
+ got, err := readFile(12345, start, file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Valid() || got.ProcessID != 12345 || got.ProcessStartedAt.Location() != time.UTC || !got.ProcessStartedAt.Equal(start) || got.ProgramSHA256 != fmt.Sprintf("%X", sha256.Sum256(body)) {
+ t.Fatalf("unexpected identity: %+v", got)
+ }
+ if err = os.WriteFile(file, []byte("replaced after capture"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if got.ProgramSHA256 != fmt.Sprintf("%X", sha256.Sum256(body)) {
+ t.Fatal("captured identity changed")
+ }
+}
+
+func TestMissingIdentityIsNotInvented(t *testing.T) {
+ for _, tc := range []struct {
+ pid int
+ start time.Time
+ file string
+ }{
+ {0, time.Now(), "missing"}, {1, time.Time{}, "missing"}, {1, time.Now(), filepath.Join(t.TempDir(), "missing")}, {1, time.Now(), t.TempDir()},
+ } {
+ if got, err := readFile(tc.pid, tc.start, tc.file); err == nil || got != nil {
+ t.Fatal("unreadable identity accepted")
+ }
+ }
+ for _, got := range []Identity{{}, {ProcessID: 1, ProcessStartedAt: time.Now(), ProgramSHA256: "invalid"}} {
+ if got.Valid() {
+ t.Fatal("invalid identity accepted")
+ }
+ }
+}
+
+func TestCurrentOSIdentity(t *testing.T) {
+ before := time.Now()
+ got, err := Read()
+ if runtime.GOOS != "windows" {
+ if err == nil || got != nil {
+ t.Fatal("unsupported OS invented a creation time")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Valid() || got.ProcessID != os.Getpid() || got.ProcessStartedAt.After(before) {
+ t.Fatalf("wrong OS identity: %+v", got)
+ }
+}
diff --git a/internal/qqvoice/send.go b/internal/qqvoice/send.go
new file mode 100644
index 0000000000..97c06ff18e
--- /dev/null
+++ b/internal/qqvoice/send.go
@@ -0,0 +1,32 @@
+// Package qqvoice sends QQ's native AI voice directly into the current group.
+package qqvoice
+
+import (
+ "strings"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// Deliver never calls get_ai_record: on NapCat that API also posts into a group.
+// A timeout/failure must not be retried because the first call may have sent.
+func Deliver(text, modelID string, groupID int64, call func(string, zero.Params) zero.APIResponse) string {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return "请在“发送AI语音”后填写要朗读的文字。"
+ }
+ if strings.TrimSpace(modelID) == "" {
+ return "尚未选择AI语音声音,请主人在私聊发送“设置AI语音模型”。"
+ }
+ if groupID == 0 {
+ return "QQ内置AI语音目前仅支持群聊,请在群内发送该命令;私聊内容不会发送到生成群。"
+ }
+ if groupID < 0 {
+ return "AI语音群号无效。"
+ }
+ response := call("send_group_ai_record", zero.Params{"character": modelID, "group_id": groupID, "text": text})
+ // NapCat v4.18.28 returns message_id=0 even after successful delivery.
+ if response.Status == "ok" && response.RetCode == 0 {
+ return ""
+ }
+ return "AI语音发送失败或回执未确认,未自动重试;请先检查群中是否已收到语音。"
+}
diff --git a/internal/qqvoice/send_test.go b/internal/qqvoice/send_test.go
new file mode 100644
index 0000000000..f1c1cfcc25
--- /dev/null
+++ b/internal/qqvoice/send_test.go
@@ -0,0 +1,44 @@
+package qqvoice
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func TestNativeVoiceSideEffectsAndAcknowledgement(t *testing.T) {
+ for _, c := range []struct {
+ name, text, model, status, data, want string
+ group, retcode int64
+ calls int
+ }{
+ {"native zero ID success", " 中文🙂 ", "voice", "ok", `{"message_id":0}`, "", 627, 0, 1},
+ {"positive ID success", "中文🙂", "voice", "ok", `{"message_id":123}`, "", 627, 0, 1},
+ {"no ID success", "中文🙂", "voice", "ok", `{}`, "", 627, 0, 1},
+ {"failed status despite ID", "中文🙂", "voice", "failed", `{"message_id":123}`, "未自动重试", 627, 0, 1},
+ {"nonzero retcode", "中文🙂", "voice", "ok", `{}`, "未自动重试", 627, 1400, 1},
+ {"timeout may have sent", "中文🙂", "voice", "", `{}`, "未自动重试", 627, 0, 1},
+ {"async unknown receipt", "中文🙂", "voice", "async", `{}`, "未自动重试", 627, 1, 1},
+ {"private never generates in group", "中文🙂", "voice", "ok", `{}`, "仅支持群聊", 0, 0, 0},
+ {"negative group", "中文🙂", "voice", "ok", `{}`, "无效", -627, 0, 0},
+ {"empty text", " \t ", "voice", "ok", `{}`, "填写", 627, 0, 0},
+ {"missing voice", "中文🙂", "", "ok", `{}`, "未选择", 627, 0, 0},
+ {"blank voice", "中文🙂", " \t", "ok", `{}`, "未选择", 627, 0, 0},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ calls := 0
+ feedback := Deliver(c.text, c.model, c.group, func(action string, params zero.Params) zero.APIResponse {
+ calls++
+ if action != "send_group_ai_record" || len(params) != 3 || params["group_id"] != c.group || params["character"] != c.model || params["text"] != "中文🙂" {
+ t.Fatal("unexpected voice generation, forwarding, or destination")
+ }
+ return zero.APIResponse{Status: c.status, RetCode: c.retcode, Data: gjson.Parse(c.data)}
+ })
+ if calls != c.calls || (c.want == "" && feedback != "") || (c.want != "" && !strings.Contains(feedback, c.want)) {
+ t.Fatalf("calls=%d feedback=%q", calls, feedback)
+ }
+ })
+ }
+}
diff --git a/internal/replystats/send.go b/internal/replystats/send.go
new file mode 100644
index 0000000000..e4a64e7779
--- /dev/null
+++ b/internal/replystats/send.go
@@ -0,0 +1,132 @@
+package replystats
+
+import (
+ "encoding/json"
+ "math"
+ "strconv"
+ "strings"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+func targetID(value any) (int64, bool) {
+ switch v := value.(type) {
+ case int64:
+ return v, true
+ case int:
+ return int64(v), true
+ case float64:
+ if !math.IsNaN(v) && !math.IsInf(v, 0) && math.Trunc(v) == v && math.Abs(v) <= 9007199254740991 {
+ return int64(v), true
+ }
+ case json.Number:
+ n, err := v.Int64()
+ return n, err == nil
+ case string:
+ n, err := strconv.ParseInt(v, 10, 64)
+ return n, err == nil
+ }
+ return 0, false
+}
+func (t *Task) currentReply(action string, params zero.Params) bool {
+ if t == nil {
+ return false
+ }
+ groupAction := false
+ privateAction := false
+ switch action {
+ case "send_group_msg", "send_group_forward_msg", "send_group_ai_record":
+ groupAction = true
+ case "send_private_msg", "send_private_forward_msg":
+ privateAction = true
+ case "send_msg", "send_forward_msg":
+ if params["message_type"] == "group" {
+ groupAction = true
+ } else if params["message_type"] == "private" {
+ privateAction = true
+ } else if gid, ok := targetID(params["group_id"]); ok && gid != 0 {
+ groupAction = true
+ } else {
+ privateAction = true
+ }
+ default:
+ return false
+ }
+ if t.groupID != 0 {
+ gid, ok := targetID(params["group_id"])
+ return groupAction && ok && gid == t.groupID
+ }
+ uid, ok := targetID(params["user_id"])
+ return privateAction && ok && uid == t.userID
+}
+func confirmed(action string, response zero.APIResponse) bool {
+ if response.Status != "ok" || response.RetCode != 0 {
+ return false
+ }
+ if action == "send_group_ai_record" {
+ return true
+ } // Existing NapCat voice receipt may contain zero ID.
+ id := response.Data.Get("message_id")
+ if !id.Exists() {
+ return false
+ }
+ n, err := strconv.ParseInt(id.String(), 10, 64)
+ return err == nil && n != 0
+}
+
+// Call observes the original API response without modifying params, results or retry policy.
+func (t *Task) Call(action string, params zero.Params, call func(string, zero.Params) zero.APIResponse) (response zero.APIResponse) {
+ observe := t.currentReply(action, params)
+ returned := false
+ defer func() {
+ if observe && !returned {
+ t.record(false)
+ }
+ }()
+ response = call(action, params)
+ returned = true
+ if observe {
+ t.record(confirmed(action, response))
+ }
+ return response
+}
+
+// Send follows ZeroBot's existing private/group and merged-forward routing.
+// Unsupported event types retain the SDK path and are outside these statistics.
+func (t *Task) Send(ctx *zero.Ctx, msg message.Message) message.ID {
+ if t == nil {
+ return ctx.Send(msg)
+ }
+ group := ctx.Event.MessageType == "group"
+ action := "send_private_msg"
+ key := "message"
+ params := zero.Params{"user_id": ctx.Event.UserID}
+ if group {
+ action = "send_group_msg"
+ params = zero.Params{"group_id": ctx.Event.GroupID}
+ }
+ if len(msg) > 0 && msg[0].Type == "node" {
+ key = "messages"
+ action = strings.Replace(action, "_msg", "_forward_msg", 1)
+ }
+ params[key] = msg
+ response := t.Call(action, params, ctx.CallAction)
+ return message.NewMessageIDFromInteger(response.Data.Get("message_id").Int())
+}
+func (t *Task) SendChain(ctx *zero.Ctx, msg ...message.Segment) message.ID {
+ if t == nil {
+ return ctx.SendChain(msg...)
+ }
+ if zero.BotConfig.AddSpaceAfterAt && len(msg) > 0 {
+ spaced := make(message.Message, 0, len(msg)*2)
+ for i := 0; i < len(msg)-1; i++ {
+ spaced = append(spaced, msg[i])
+ if msg[i].Type == "at" && (msg[i+1].Type != "text" || (len(msg[i+1].Data["text"]) > 0 && msg[i+1].Data["text"][0] != ' ')) {
+ spaced = append(spaced, message.Text(" "))
+ }
+ }
+ msg = append(spaced, msg[len(msg)-1])
+ }
+ return t.Send(ctx, msg)
+}
diff --git a/internal/replystats/send_test.go b/internal/replystats/send_test.go
new file mode 100644
index 0000000000..c2eaf79673
--- /dev/null
+++ b/internal/replystats/send_test.go
@@ -0,0 +1,98 @@
+package replystats
+
+import (
+ "context"
+ "encoding/json"
+ "reflect"
+ "testing"
+ "time"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+type testDriver struct {
+ ready chan func([]byte, zero.APICaller)
+ stop chan struct{}
+ sent chan zero.APIRequest
+ done chan struct{}
+}
+
+func (*testDriver) Connect() {}
+func (d *testDriver) Listen(receive func([]byte, zero.APICaller)) { d.ready <- receive; <-d.stop }
+func (d *testDriver) CallAPI(_ context.Context, req zero.APIRequest) (zero.APIResponse, error) {
+ time.Sleep(15 * time.Millisecond)
+ d.sent <- req
+ return receipt("123"), nil
+}
+
+func TestActualSDKIngressAndOriginalSendParameterEquivalence(t *testing.T) {
+ c := New()
+ driver := &testDriver{ready: make(chan func([]byte, zero.APICaller), 1), stop: make(chan struct{}), sent: make(chan zero.APIRequest, 16), done: make(chan struct{}, 16)}
+ defer close(driver.stop)
+ zero.OnMessage().SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ defer func() { driver.done <- struct{}{} }()
+ var task *Task
+ if ctx.Event.RawMessage == "observed" {
+ task = c.Begin(ctx, GPT)
+ defer task.End()
+ }
+ if ctx.Event.SubType == "forward" {
+ msg := message.Message{{Type: "node", Data: map[string]string{"id": "42"}}}
+ if task != nil {
+ task.Send(ctx, msg)
+ } else {
+ ctx.Send(msg)
+ }
+ } else {
+ msg := []message.Segment{message.At(111), message.Text("hello🙂\nworld")}
+ if task != nil {
+ task.SendChain(ctx, msg...)
+ } else {
+ ctx.SendChain(msg...)
+ }
+ }
+ })
+ zero.Run(&zero.Config{MaxProcessTime: time.Second, AddSpaceAfterAt: true, Driver: []zero.Driver{driver}})
+ var receive func([]byte, zero.APICaller)
+ select {
+ case receive = <-driver.ready:
+ case <-time.After(time.Second):
+ t.Fatal("driver not ready")
+ }
+ for _, group := range []bool{false, true} {
+ for _, forward := range []bool{false, true} {
+ var requests []zero.APIRequest
+ for _, mode := range []string{"original", "observed"} {
+ event := map[string]any{"time": time.Now().Unix(), "self_id": 87654321, "post_type": "message", "message_type": "private", "sub_type": "friend", "message_id": 11, "user_id": 12345, "raw_message": mode, "message": message.Message{message.Text(mode)}, "sender": map[string]any{"user_id": 12345, "nickname": "Synthetic"}}
+ if group {
+ event["message_type"] = "group"
+ event["group_id"] = 67890
+ }
+ if forward {
+ event["sub_type"] = "forward"
+ }
+ raw, _ := json.Marshal(event)
+ receive(raw, driver)
+ select {
+ case req := <-driver.sent:
+ requests = append(requests, req)
+ case <-time.After(2 * time.Second):
+ t.Fatal("no send")
+ }
+ select {
+ case <-driver.done:
+ case <-time.After(time.Second):
+ t.Fatal("handler not done")
+ }
+ }
+ if requests[0].Action != requests[1].Action || !reflect.DeepEqual(requests[0].Params, requests[1].Params) {
+ t.Fatalf("SDK parameters changed: %#v %#v", requests[0], requests[1])
+ }
+ }
+ }
+ got := c.Read()
+ if got.Started != 4 || got.Completed != 4 || got.MissingIngress != 0 || got.ReplyAccepted != 4 || got.AverageFirstReplyMilliseconds < 15 {
+ t.Fatalf("wrong SDK pipeline statistics: %+v", got)
+ }
+}
diff --git a/internal/replystats/stats.go b/internal/replystats/stats.go
new file mode 100644
index 0000000000..ce7d6fa2de
--- /dev/null
+++ b/internal/replystats/stats.go
@@ -0,0 +1,208 @@
+// Package replystats keeps bounded AI task counts and matcher-to-send receipt timings.
+package replystats
+
+import (
+ "math"
+ "sync"
+ "time"
+
+ "github.com/FloatTech/ZeroBot-Plugin/internal/processidentity"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+type Kind uint8
+
+const (
+ GPT Kind = iota
+ Chat
+ Agent
+ kindCount
+)
+
+const ingressKey = zero.StateKeyPrefixKeep + "__ai_reply_ingress__"
+
+var ingressOnce sync.Once
+
+// This early non-blocking rule adds only an ephemeral monotonic timestamp. It
+// performs no I/O and returns false so no handler, reply or blocking is added.
+func InstallIngress() {
+ ingressOnce.Do(func() {
+ zero.OnMessage(func(ctx *zero.Ctx) bool {
+ if ctx.State != nil {
+ ctx.State[ingressKey] = time.Now()
+ }
+ return false
+ }).SetPriority(math.MinInt).SetBlock(false)
+ })
+}
+
+func init() { InstallIngress() }
+
+type Counts struct {
+ Started uint64 `json:"started"`
+ Active uint64 `json:"active"`
+ Completed uint64 `json:"completed"`
+ Failed uint64 `json:"failed"`
+ NoReply uint64 `json:"noReply"`
+ WithReply uint64 `json:"withReply"`
+ MissingIngress uint64 `json:"missingIngress"`
+ ReplyAttempts uint64 `json:"replyAttempts"`
+ ReplyAccepted uint64 `json:"replyAccepted"`
+ ReplyUnconfirmed uint64 `json:"replyUnconfirmed"`
+ AverageFirstReplyMilliseconds float64 `json:"averageFirstReplyMilliseconds"`
+ MaxFirstReplyMilliseconds float64 `json:"maxFirstReplyMilliseconds"`
+ AverageLastReplyMilliseconds float64 `json:"averageLastReplyMilliseconds"`
+ MaxLastReplyMilliseconds float64 `json:"maxLastReplyMilliseconds"`
+}
+type Category struct {
+ Kind string `json:"kind"`
+ Counts
+}
+type Snapshot struct {
+ Process *processidentity.Identity `json:"process,omitempty"`
+ StartedAt time.Time `json:"startedAt"`
+ CheckedAt time.Time `json:"checkedAt"`
+ Scope string `json:"scope"`
+ Counts
+ Categories []Category `json:"categories"`
+}
+type aggregate struct {
+ Counts
+ firstSum, lastSum float64
+}
+type Collector struct {
+ mu sync.Mutex
+ started time.Time
+ total aggregate
+ rows [kindCount]aggregate
+}
+
+func New() *Collector { return &Collector{started: time.Now().UTC()} }
+
+var Default = New()
+
+func (c *Collector) Read() Snapshot {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ s := Snapshot{StartedAt: c.started, CheckedAt: time.Now().UTC(), Scope: "bot-matcher-entry-to-onebot-send-receipt", Counts: c.total.Counts, Categories: make([]Category, kindCount)}
+ for i, kind := range [...]string{"gpt", "chat", "agent"} {
+ s.Categories[i] = Category{Kind: kind, Counts: c.rows[i].Counts}
+ }
+ return s
+}
+
+// Task retains only an ephemeral target needed to distinguish replies to the
+// current conversation. Neither targets nor request/response contents enter the collector.
+type Task struct {
+ mu sync.Mutex
+ c *Collector
+ kind Kind
+ start time.Time
+ groupID, userID int64
+ hasReply, failed, finished bool
+ first, last float64
+}
+
+func (c *Collector) Begin(ctx *zero.Ctx, kind Kind) *Task {
+ if c == nil || kind >= kindCount || ctx == nil || ctx.Event == nil || ctx.Event.PostType != "message" || (ctx.Event.MessageType != "group" && ctx.Event.MessageType != "private") {
+ return nil
+ }
+ now := time.Now()
+ start, ok := ctx.State[ingressKey].(time.Time)
+ if !ok || start.IsZero() || start.After(now) {
+ start = now
+ ok = false
+ }
+ c.mu.Lock()
+ for _, a := range []*aggregate{&c.total, &c.rows[kind]} {
+ a.Started++
+ a.Active++
+ if !ok {
+ a.MissingIngress++
+ }
+ }
+ c.mu.Unlock()
+ return &Task{c: c, kind: kind, start: start, groupID: ctx.Event.GroupID, userID: ctx.Event.UserID}
+}
+func (t *Task) Fail() {
+ if t == nil {
+ return
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if !t.finished {
+ t.failed = true
+ }
+}
+func (t *Task) record(confirmed bool) {
+ if t == nil {
+ return
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.finished {
+ return
+ }
+ ms := math.Max(0, float64(time.Since(t.start))/float64(time.Millisecond))
+ if confirmed {
+ if !t.hasReply {
+ t.hasReply = true
+ t.first = ms
+ }
+ t.last = math.Max(t.last, ms)
+ } else {
+ t.failed = true
+ }
+ t.c.mu.Lock()
+ defer t.c.mu.Unlock()
+ for _, a := range []*aggregate{&t.c.total, &t.c.rows[t.kind]} {
+ a.ReplyAttempts++
+ if confirmed {
+ a.ReplyAccepted++
+ } else {
+ a.ReplyUnconfirmed++
+ }
+ }
+}
+func (t *Task) finish() {
+ if t == nil {
+ return
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.finished {
+ return
+ }
+ t.finished = true
+ t.c.mu.Lock()
+ defer t.c.mu.Unlock()
+ for _, a := range []*aggregate{&t.c.total, &t.c.rows[t.kind]} {
+ a.Active--
+ if t.failed {
+ a.Failed++
+ } else if t.hasReply {
+ a.Completed++
+ } else {
+ a.NoReply++
+ }
+ if t.hasReply {
+ a.WithReply++
+ a.firstSum += t.first
+ a.lastSum += t.last
+ a.AverageFirstReplyMilliseconds = a.firstSum / float64(a.WithReply)
+ a.AverageLastReplyMilliseconds = a.lastSum / float64(a.WithReply)
+ a.MaxFirstReplyMilliseconds = math.Max(a.MaxFirstReplyMilliseconds, t.first)
+ a.MaxLastReplyMilliseconds = math.Max(a.MaxLastReplyMilliseconds, t.last)
+ }
+ }
+}
+
+// End must be deferred directly by the handler. Preserve any original panic.
+func (t *Task) End() {
+ if p := recover(); p != nil {
+ t.Fail()
+ t.finish()
+ panic(p)
+ }
+ t.finish()
+}
diff --git a/internal/replystats/stats_test.go b/internal/replystats/stats_test.go
new file mode 100644
index 0000000000..8e5b82822d
--- /dev/null
+++ b/internal/replystats/stats_test.go
@@ -0,0 +1,173 @@
+package replystats
+
+import (
+ "encoding/json"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/tidwall/gjson"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func testContext(group bool, stamped bool) *zero.Ctx {
+ e := &zero.Event{PostType: "message", MessageType: "private", UserID: 12345}
+ if group {
+ e.MessageType = "group"
+ e.GroupID = 67890
+ }
+ ctx := &zero.Ctx{Event: e, State: zero.State{}}
+ if stamped {
+ ctx.State[ingressKey] = time.Now().Add(-50 * time.Millisecond)
+ }
+ return ctx
+}
+func receipt(id string) zero.APIResponse {
+ return zero.APIResponse{Status: "ok", Data: gjson.Parse(`{"message_id":` + id + `}`)}
+}
+func TestReplyTimingsIncludeIngressAndOnlyFinishedTasks(t *testing.T) {
+ c := New()
+ task := c.Begin(testContext(false, true), GPT)
+ params := zero.Params{"user_id": int64(12345), "message": "SECRET-BODY"}
+ response := task.Call("send_private_msg", params, func(action string, p zero.Params) zero.APIResponse {
+ if action != "send_private_msg" || p["message"] != "SECRET-BODY" {
+ t.Fatal("API parameters changed")
+ }
+ return receipt("-123")
+ })
+ if response.Data.Get("message_id").Int() != -123 {
+ t.Fatal("response changed")
+ }
+ active := c.Read()
+ if active.Active != 1 || active.WithReply != 0 || active.ReplyAccepted != 1 || active.AverageFirstReplyMilliseconds != 0 {
+ t.Fatal("unfinished task mixed into average")
+ }
+ time.Sleep(10 * time.Millisecond)
+ task.Call("send_private_msg", params, func(string, zero.Params) zero.APIResponse { return receipt("234") })
+ task.End()
+ task.End()
+ got := c.Read()
+ if got.Completed != 1 || got.WithReply != 1 || got.Active != 0 || got.MissingIngress != 0 || got.ReplyAccepted != 2 || got.AverageFirstReplyMilliseconds < 50 || got.AverageLastReplyMilliseconds < got.AverageFirstReplyMilliseconds+8 {
+ t.Fatalf("wrong timings: %+v", got)
+ }
+ body, _ := json.Marshal(got)
+ if strings.Contains(string(body), "SECRET") || strings.Contains(string(body), "12345") || strings.Contains(string(body), "67890") {
+ t.Fatal("contents or targets retained")
+ }
+ task.Call("send_private_msg", params, func(string, zero.Params) zero.APIResponse { return receipt("345") })
+ if c.Read().ReplyAccepted != 2 {
+ t.Fatal("late call changed ended statistics")
+ }
+}
+func TestReceiptBoundariesAndVoiceZeroID(t *testing.T) {
+ for _, tc := range []struct {
+ name, action string
+ r zero.APIResponse
+ ok bool
+ }{
+ {"positive", "send_group_msg", receipt("123"), true}, {"negative", "send_private_msg", receipt("-123"), true}, {"integer-string", "send_group_msg", receipt(`"123"`), true},
+ {"zero", "send_group_msg", receipt("0"), false}, {"fraction", "send_group_msg", receipt("1.5"), false}, {"missing", "send_group_msg", zero.APIResponse{Status: "ok"}, false},
+ {"status-failure", "send_group_msg", zero.APIResponse{Status: "failed", Data: gjson.Parse(`{"message_id":123}`)}, false}, {"code-failure", "send_group_msg", zero.APIResponse{Status: "ok", RetCode: 1, Data: gjson.Parse(`{"message_id":123}`)}, false},
+ {"voice-zero", "send_group_ai_record", receipt("0"), true}, {"voice-failure", "send_group_ai_record", zero.APIResponse{Status: "failed"}, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if confirmed(tc.action, tc.r) != tc.ok {
+ t.Fatal("receipt boundary differs")
+ }
+ })
+ }
+}
+func TestFailedPartialReplyNoReplyAndMissingIngress(t *testing.T) {
+ c := New()
+ empty := c.Begin(testContext(false, false), Chat)
+ empty.End()
+ partial := c.Begin(testContext(false, true), Agent)
+ partial.record(true)
+ partial.record(false)
+ partial.End()
+ err := c.Begin(testContext(false, true), GPT)
+ err.Fail()
+ err.End()
+ got := c.Read()
+ if got.Started != 3 || got.NoReply != 1 || got.Failed != 2 || got.WithReply != 1 || got.MissingIngress != 1 || got.ReplyUnconfirmed != 1 || got.Active != 0 {
+ t.Fatalf("misclassified tasks: %+v", got)
+ }
+ if c.Begin(nil, GPT) != nil || c.Begin(testContext(false, true), kindCount) != nil {
+ t.Fatal("invalid task counted")
+ }
+ ctx := testContext(false, true)
+ ctx.Event.MessageType = "guild"
+ if c.Begin(ctx, GPT) != nil {
+ t.Fatal("guild task counted")
+ }
+}
+func TestCurrentConversationAndActionAllowlist(t *testing.T) {
+ for _, group := range []bool{false, true} {
+ c := New()
+ task := c.Begin(testContext(group, true), Agent)
+ for _, tc := range []struct {
+ action string
+ p zero.Params
+ want bool
+ }{
+ {"send_group_msg", zero.Params{"group_id": int64(67890)}, group}, {"send_private_msg", zero.Params{"user_id": "12345"}, !group},
+ {"send_group_forward_msg", zero.Params{"group_id": float64(67890)}, group}, {"send_private_forward_msg", zero.Params{"user_id": json.Number("12345")}, !group},
+ {"send_group_ai_record", zero.Params{"group_id": 67890}, group}, {"send_msg", zero.Params{"message_type": "group", "group_id": 67890}, group},
+ {"send_forward_msg", zero.Params{"user_id": 12345}, !group}, {"send_group_msg", zero.Params{"group_id": 67891}, false}, {"send_private_msg", zero.Params{"user_id": 12346}, false},
+ {"get_group_info", zero.Params{"group_id": 67890}, false}, {"send_group_msg", zero.Params{"group_id": 67890.5}, false},
+ } {
+ if task.currentReply(tc.action, tc.p) != tc.want {
+ t.Fatalf("wrong target selection: %s %v", tc.action, tc.p)
+ }
+ }
+ task.Call("get_group_info", zero.Params{"group_id": 67890}, func(string, zero.Params) zero.APIResponse { return receipt("123") })
+ task.End()
+ if c.Read().ReplyAttempts != 0 || c.Read().NoReply != 1 {
+ t.Fatal("non-message action counted as reply")
+ }
+ }
+}
+func TestPanicsArePreservedAndTasksEndOnce(t *testing.T) {
+ c := New()
+ func() {
+ defer func() {
+ if recover() != "original-panic" {
+ t.Fatal("panic changed or swallowed")
+ }
+ }()
+ task := c.Begin(testContext(false, true), GPT)
+ defer task.End()
+ task.Call("send_private_msg", zero.Params{"user_id": 12345}, func(string, zero.Params) zero.APIResponse { panic("original-panic") })
+ }()
+ got := c.Read()
+ if got.Failed != 1 || got.ReplyUnconfirmed != 1 || got.Active != 0 {
+ t.Fatal("panic leaked active task")
+ }
+}
+func TestConcurrentTasksStayBoundedAndConserveCounts(t *testing.T) {
+ c := New()
+ var wg sync.WaitGroup
+ for i := 0; i < 200; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ task := c.Begin(testContext(false, true), Kind(i%3))
+ defer task.End()
+ task.record(true)
+ }(i)
+ }
+ wg.Wait()
+ got := c.Read()
+ if got.Started != 200 || got.Completed != 200 || got.WithReply != 200 || got.ReplyAccepted != 200 || got.Active != 0 {
+ t.Fatalf("concurrent counts: %+v", got)
+ }
+ var started, accepted uint64
+ for _, r := range got.Categories {
+ started += r.Started
+ accepted += r.ReplyAccepted
+ }
+ if started != got.Started || accepted != got.ReplyAccepted {
+ t.Fatal("category totals do not conserve")
+ }
+}
diff --git a/kanban/init.go b/kanban/init.go
index 5ff823c73a..faa95125bb 100644
--- a/kanban/init.go
+++ b/kanban/init.go
@@ -3,9 +3,7 @@ package kanban
import (
"fmt"
-
- "github.com/FloatTech/zbputils/control"
- "github.com/fumiama/go-registry"
+ "time"
"github.com/FloatTech/ZeroBot-Plugin/kanban/banner"
)
@@ -16,8 +14,6 @@ func init() {
PrintBanner()
}
-var reg = registry.NewRegReader("reilia.fumiama.top:32664", control.Md5File, "fumiama")
-
// PrintBanner ...
func PrintBanner() {
fmt.Print(
@@ -31,14 +27,9 @@ func PrintBanner() {
// Kanban ...
func Kanban() string {
- err := reg.Connect()
- if err != nil {
- return err.Error()
- }
- defer reg.Close()
- text, err := reg.Get("ZeroBot-Plugin/kanban")
+ text, err := fetchKanban("reilia.fumiama.top:32664", 5*time.Second)
if err != nil {
- return err.Error()
+ return "公告暂不可用: " + err.Error()
}
return text
}
diff --git a/kanban/reader.go b/kanban/reader.go
new file mode 100644
index 0000000000..b1f3f989db
--- /dev/null
+++ b/kanban/reader.go
@@ -0,0 +1,63 @@
+package kanban
+
+import (
+ "bufio"
+ "io"
+ "net"
+ "time"
+
+ "github.com/fumiama/go-registry"
+ tea "github.com/fumiama/gofastTEA"
+)
+
+// fetchKanban bounds both dialing and protocol I/O by the same deadline.
+// go-registry's ConnectIn only limits dialing, while Get has no read deadline.
+func fetchKanban(address string, timeout time.Duration) (string, error) {
+ deadline := time.Now().Add(timeout)
+ dialer := net.Dialer{Deadline: deadline}
+ conn, err := dialer.Dial("tcp", address)
+ if err != nil {
+ return "", err
+ }
+ defer conn.Close()
+ if err = conn.SetDeadline(deadline); err != nil {
+ return "", err
+ }
+
+ var password [16]byte
+ copy(password[:], "fumiama")
+ cipher := tea.NewTeaCipherLittleEndian(password[:])
+ request := registry.NewCmdPacket(registry.CMDGET, []byte("ZeroBot-Plugin/kanban"), &cipher)
+ defer request.Put()
+ raw := request.Encrypt(0)
+ if written, writeErr := conn.Write(raw); writeErr != nil {
+ return "", writeErr
+ } else if written != len(raw) {
+ return "", io.ErrShortWrite
+ }
+
+ reader := bufio.NewReader(conn)
+ header, err := reader.Peek(1)
+ if err != nil {
+ return "", err
+ }
+ ack := header[0]
+ // ReadCmdPacket in the pinned library does not initialize the cipher on a
+ // fresh pooled packet. NewCmdPacket + ReadFrom keeps it initialized.
+ response := registry.NewCmdPacket(registry.CMDGET, nil, &cipher)
+ defer response.Put()
+ if _, err = response.ReadFrom(reader); err != nil {
+ return "", err
+ }
+ if err = response.Decrypt(1); err != nil {
+ return "", registry.ErrDecAck
+ }
+ text := string(response.Data)
+ if ack == registry.ACKERRO && text == "erro" {
+ return "", registry.ErrInternalServer
+ }
+ if ack == registry.ACKNULL && text == "null" {
+ return "", registry.ErrNoSuchKey
+ }
+ return text, nil
+}
diff --git a/kanban/reader_test.go b/kanban/reader_test.go
new file mode 100644
index 0000000000..3cbbe26f97
--- /dev/null
+++ b/kanban/reader_test.go
@@ -0,0 +1,113 @@
+package kanban
+
+import (
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/fumiama/go-registry"
+ tea "github.com/fumiama/gofastTEA"
+)
+
+func TestFetchKanbanProtocol(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ ack uint8
+ text string
+ wantErr error
+ }{
+ {"announcement", registry.ACKDATA, "本地测试公告", nil},
+ {"literalNull", registry.ACKDATA, "null", nil},
+ {"missingKey", registry.ACKNULL, "null", registry.ErrNoSuchKey},
+ {"serverError", registry.ACKERRO, "erro", registry.ErrInternalServer},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ serverDone := make(chan error, 1)
+ go func() {
+ conn, err := listener.Accept()
+ if err != nil {
+ serverDone <- err
+ return
+ }
+ defer conn.Close()
+ _ = conn.SetDeadline(time.Now().Add(2 * time.Second))
+ var password [16]byte
+ copy(password[:], "fumiama")
+ cipher := tea.NewTeaCipherLittleEndian(password[:])
+ request := registry.NewCmdPacket(registry.CMDGET, nil, &cipher)
+ defer request.Put()
+ if _, err = request.ReadFrom(conn); err != nil {
+ serverDone <- err
+ return
+ }
+ if err = request.Decrypt(0); err != nil {
+ serverDone <- err
+ return
+ }
+ if string(request.Data) != "ZeroBot-Plugin/kanban" {
+ serverDone <- errors.New("unexpected key")
+ return
+ }
+ response := registry.NewCmdPacket(tc.ack, []byte(tc.text), &cipher)
+ defer response.Put()
+ _, err = conn.Write(response.Encrypt(1))
+ serverDone <- err
+ }()
+ text, err := fetchKanban(listener.Addr().String(), time.Second)
+ if !errors.Is(err, tc.wantErr) {
+ t.Fatalf("error = %v, want %v", err, tc.wantErr)
+ }
+ if err == nil && text != tc.text {
+ t.Fatalf("text = %q, want %q", text, tc.text)
+ }
+ if err := <-serverDone; err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+
+func TestFetchKanbanReadDeadline(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ serverDone := make(chan struct{})
+ go func() {
+ defer close(serverDone)
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ _ = conn.SetDeadline(time.Now().Add(2 * time.Second))
+ // Accept and consume the request, but never send an announcement.
+ var buffer [256]byte
+ for {
+ if _, err := conn.Read(buffer[:]); err != nil {
+ return
+ }
+ }
+ }()
+ start := time.Now()
+ _, err = fetchKanban(listener.Addr().String(), 100*time.Millisecond)
+ var netErr net.Error
+ if !errors.As(err, &netErr) || !netErr.Timeout() {
+ t.Fatalf("expected timeout, got %v", err)
+ }
+ if elapsed := time.Since(start); elapsed > time.Second {
+ t.Fatalf("deadline exceeded: %v", elapsed)
+ }
+ select {
+ case <-serverDone:
+ case <-time.After(time.Second):
+ t.Fatal("client did not close its connection")
+ }
+}
diff --git a/main.go b/main.go
index 4ee2794a68..cac1322f76 100644
--- a/main.go
+++ b/main.go
@@ -4,18 +4,26 @@ package main
//go:generate go run github.com/FloatTech/ZeroBot-Plugin/abineundo/ref -r .
import (
+ "context"
"encoding/json"
"flag"
"fmt"
+ "io"
"math/rand"
"os"
+ "path/filepath"
"runtime"
"strconv"
"strings"
"time"
_ "github.com/FloatTech/ZeroBot-Plugin/abineundo" // 设置插件优先级&更改控制台属性
- "github.com/FloatTech/ZeroBot-Plugin/kanban" // 打印 banner
+ "github.com/FloatTech/ZeroBot-Plugin/internal/basiccommands"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/localadmin"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/modelstats"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/processidentity"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/replystats"
+ "github.com/FloatTech/ZeroBot-Plugin/kanban" // 打印 banner
// ---------以下插件均可通过前面加 // 注释,注释后停用并不加载插件--------- //
// ----------------------插件优先级按顺序从高到低---------------------- //
@@ -206,6 +214,8 @@ import (
// -----------------------以下为内置依赖,勿动------------------------ //
"github.com/FloatTech/floatbox/file"
"github.com/FloatTech/floatbox/process"
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/FloatTech/zbputils/control"
"github.com/sirupsen/logrus"
zero "github.com/wdvxdr1123/ZeroBot"
"github.com/wdvxdr1123/ZeroBot/driver"
@@ -225,6 +235,14 @@ type zbpcfg struct {
}
var config zbpcfg
+var startupConfigPath string
+var startupConfigJSON []byte
+
+var (
+ adminEnabled bool
+ adminAddr string
+ adminDataDir string
+)
func init() {
sus := make([]int64, 0, 16)
@@ -248,6 +266,9 @@ func init() {
markmsg := flag.Bool("m", false, "Don't mark message as read automatically")
fb64 := flag.Bool("fb64", false, "Force to send base64 file.")
flag.BoolVar(&file.SkipOriginal, "mirror", false, "Use mirrored lazy data at first")
+ flag.BoolVar(&adminEnabled, "admin", false, "Enable the local admin UI.")
+ flag.StringVar(&adminAddr, "admin-addr", "127.0.0.1:3010", "Set loopback local admin address.")
+ flag.StringVar(&adminDataDir, "admin-data", ".local/admin", "Set private local admin credential directory.")
flag.Parse()
@@ -284,11 +305,18 @@ func init() {
panic(err)
}
config.W = make([]*driver.WSClient, 0, 2)
- err = json.NewDecoder(f).Decode(&config)
+ startupConfigJSON, err = io.ReadAll(io.LimitReader(f, (1<<20)+1))
f.Close()
+ if err == nil && len(startupConfigJSON) > 1<<20 {
+ err = fmt.Errorf("启动配置超过 1 MiB")
+ }
+ if err == nil {
+ err = json.Unmarshal(startupConfigJSON, &config)
+ }
if err != nil {
panic(err)
}
+ startupConfigPath = *runcfg
config.Z.Driver = make([]zero.Driver, len(config.W)+len(config.S))
for i, w := range config.W {
config.Z.Driver[i] = w
@@ -328,15 +356,51 @@ func init() {
}
func main() {
+ if adminEnabled {
+ plugins := []localadmin.Plugin{}
+ controls := []*ctrl.Control[*zero.Ctx]{}
+ control.ForEachByPrio(func(_ int, manager *ctrl.Control[*zero.Ctx]) bool {
+ plugins = append(plugins, localadmin.Plugin{Name: manager.Service, Brief: manager.Options.Brief, Usage: manager.Options.Help, DefaultEnabledAtStartup: !manager.Options.DisableOnDefault})
+ controls = append(controls, manager)
+ return true
+ })
+ var connections *localadmin.ConnectionStore
+ if startupConfigPath != "" {
+ var err error
+ connections, err = localadmin.NewConnectionStore(startupConfigPath, filepath.Join(adminDataDir, "backups"), startupConfigJSON)
+ if err != nil {
+ logrus.Fatalln("[admin] 启动配置不可管理:", err)
+ }
+ }
+ pluginControl := localadmin.NewPluginStore(controls, filepath.Join(adminDataDir, "backups"))
+ logs := localadmin.NewLogStore()
+ logConfig, _ := json.Marshal(config)
+ logs.ProtectConfig(logConfig)
+ backups := localadmin.NewBackupStore(filepath.Join(adminDataDir, "backups"), connections, pluginControl, logs)
+ identity, identityErr := processidentity.Read()
+ if identityErr != nil {
+ logrus.Warnln("[admin] 观察报告进程信息不可读取,保留无关联报告")
+ }
+ admin, err := localadmin.Start(localadmin.Config{Addr: adminAddr, DataDir: adminDataDir, Plugins: plugins, PluginControl: pluginControl, Connections: connections, AI: localadmin.NewAIStore(controls), Logs: logs, Backups: backups, ModelRequests: modelstats.Default.Read, ReplyTasks: replystats.Default.Read, ProcessIdentity: identity, Overview: localadmin.RuntimeOverview(time.Now(), len(config.W)+len(config.S), len(plugins))})
+ if err != nil {
+ logrus.Fatalln("[admin] 本地后台启动失败:", err)
+ }
+ logrus.AddHook(logs)
+ logger := logrus.StandardLogger()
+ logger.SetFormatter(localadmin.RedactingFormatter{Logs: logs, Next: logger.Formatter})
+ defer func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _ = admin.Close(ctx)
+ }()
+ logrus.Infoln("[admin] 本地后台已启动: http://" + adminAddr)
+ }
if !strings.Contains(runtime.Version(), "go1.2") { // go1.20之前版本需要全局 seed,其他插件无需再 seed
rand.Seed(time.Now().UnixNano()) //nolint: staticcheck
}
message.SetForceBase64File(config.ForceBase64File)
// 帮助
- zero.OnFullMatchGroup([]string{"help", "/help", ".help", "菜单"}, zero.OnlyToMe).SetBlock(true).
- Handle(func(ctx *zero.Ctx) {
- ctx.SendChain(message.Text(banner.Banner, "\n管理发送\"/服务列表\"查看 bot 功能\n发送\"/用法name\"查看功能用法"))
- })
+ basiccommands.RegisterHelp(banner.Banner)
zero.OnFullMatch("查看zbp公告", zero.OnlyToMe, zero.AdminPermission).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
ctx.SendChain(message.Text(strings.ReplaceAll(kanban.Kanban(), "\t", "")))
diff --git a/plugin/aichat/main.go b/plugin/aichat/main.go
index 93d93a4aee..b4a64ee8f3 100644
--- a/plugin/aichat/main.go
+++ b/plugin/aichat/main.go
@@ -3,8 +3,12 @@ package aichat
import (
"encoding/json"
+ "errors"
"math/rand"
+ "net/http"
"strings"
+ "sync/atomic"
+ "time"
"github.com/RomiChan/syncx"
"github.com/fumiama/deepinfra"
@@ -16,6 +20,9 @@ import (
"github.com/wdvxdr1123/ZeroBot/message"
"github.com/FloatTech/AnimeAPI/airecord"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/modelstats"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/qqvoice"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/replystats"
"github.com/FloatTech/floatbox/process"
ctrl "github.com/FloatTech/zbpctrl"
"github.com/FloatTech/zbputils/chat"
@@ -43,11 +50,14 @@ var (
)
var (
- fastfailnorecord = false
+ fastfailnorecord atomic.Bool
)
func init() {
en.OnMessage(chat.EnsureConfig, func(ctx *zero.Ctx) bool {
+ if !chat.ConfigOf(ctx).Ready() {
+ return false
+ }
stor, ok := ctx.State[zero.StateKeyPrefixKeep+"aichatcfg_stor__"].(chat.Storage)
if !ok {
logrus.Warnln("ERROR: cannot get stor")
@@ -71,72 +81,86 @@ func init() {
}
return true
}).SetBlock(false).Handle(func(ctx *zero.Ctx) {
+ cfg := chat.ConfigOf(ctx)
+ kind := replystats.Chat
+ if !ctx.State[zero.StateKeyPrefixKeep+"aichatcfg_stor__"].(chat.Storage).NoAgent() && cfg.AgentAPI != "" && cfg.AgentModelName != "" && (cfg.AgentKey != "" || cfg.AgentType == 1) {
+ kind = replystats.Agent
+ }
+ task := replystats.Default.Begin(ctx, kind)
+ defer task.End()
gid := ctx.Event.GroupID
if gid == 0 {
gid = -ctx.Event.UserID
}
stor := ctx.State[zero.StateKeyPrefixKeep+"aichatcfg_stor__"].(chat.Storage)
temperature := stor.Temp()
- topp, maxn := chat.AC.MParams()
+ topp, maxn := cfg.MParams()
mp := ctx.State[control.StateKeySyncxState].(*syncx.Map[string, any])
- logrus.Debugln("[aichat] agent mode test: noagent", stor.NoAgent(), "hasapi", chat.AC.AgentAPI != "", "hasmodel", chat.AC.AgentModelName != "")
- if !stor.NoAgent() && chat.AC.AgentAPI != "" && chat.AC.AgentModelName != "" && chat.AC.Key != "" {
+ logrus.Debugln("[aichat] agent mode test: noagent", stor.NoAgent(), "hasapi", cfg.AgentAPI != "", "hasmodel", cfg.AgentModelName != "")
+ if !stor.NoAgent() && cfg.AgentAPI != "" && cfg.AgentModelName != "" && (cfg.AgentKey != "" || cfg.AgentType == 1) {
logrus.Debugln("[aichat] enter agent mode")
- x := deepinfra.NewAPI(chat.AC.AgentAPI, string(chat.AC.AgentKey))
- mod, err := chat.AC.Type.Protocol(chat.AC.AgentModelName, temperature, topp, maxn, chat.AC.ReasoningEffort)
+ x := deepinfra.NewAPI(cfg.AgentAPI, string(cfg.AgentKey))
+ x.SetHTTPClient(modelstats.Default.Client(&http.Client{Timeout: 30 * time.Second}, modelstats.Agent))
+ mod, err := cfg.AgentType.ProtocolForAPI(cfg.AgentAPI, cfg.AgentModelName, temperature, topp, maxn, cfg.ReasoningEffort, cfg.Separator)
if err != nil {
+ task.Fail()
logrus.Warnln("ERROR: ", err)
return
}
- role := goba.PermRoleUser
- if zero.AdminPermission(ctx) {
- role = goba.PermRoleAdmin
- if zero.SuperUserPermission(ctx) {
- role = goba.PermRoleOwner
- }
- }
+ role := agentRole(ctx)
c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx])
if !ok {
logrus.Warnln("ERROR: cannot get ctrl mamager")
}
ag := chat.AgentOf(ctx.Event.SelfID, c.Service)
logrus.Debugln("[aichat] got agent")
- if chat.AC.ImageAPI != "" && !ag.CanViewImage() {
- mod, err := chat.AC.ImageType.Protocol(chat.AC.ImageModelName, temperature, topp, maxn, chat.AC.ReasoningEffort)
+ if cfg.ImageAPI != "" && !ag.CanViewImage() {
+ mod, err := cfg.ImageType.ProtocolForAPI(cfg.ImageAPI, cfg.ImageModelName, temperature, topp, maxn, cfg.ReasoningEffort, cfg.Separator)
if err != nil {
+ task.Fail()
logrus.Warnln("ERROR: ", err)
return
}
- ag.SetViewImageAPI(deepinfra.NewAPI(chat.AC.ImageAPI, string(chat.AC.ImageKey)), mod)
+ imageAPI := deepinfra.NewAPI(cfg.ImageAPI, string(cfg.ImageKey))
+ imageAPI.SetHTTPClient(modelstats.Default.Client(&http.Client{Timeout: 30 * time.Second}, modelstats.Vision))
+ ag.SetViewImageAPI(imageAPI, mod)
logrus.Debugln("[aichat] agent set img")
}
ctx.NoTimeout()
logrus.Debugln("[aichat] agent set no timeout")
hasresp := false
- for i := 0; i < 8; i++ { // 最大运行 8 轮因为问答上下文只有 16
- reqs := chat.CallAgent(ag, zero.SuperUserPermission(ctx), i+1, x, mod, gid, role)
- if len(reqs) == 0 {
- logrus.Debugln("[aichat] agent call got empty response")
- break
+ generation := chat.AgentGeneration()
+ err = chat.RunAgent(func(iter int) ([]zero.APIRequest, bool, error) {
+ return chat.NextAgentActions(ag, zero.SuperUserPermission(ctx), iter, x, mod.Clone(), gid, role)
+ }, func(req zero.APIRequest) error {
+ mp.Store(chat.StateKeyAgentTriggered, struct{}{})
+ if req.Action == goba.SVM { // is a fake action
+ return nil
+ }
+ logrus.Debugln("[chat] agent triggered", gid, "add requ:", &req)
+ if err := ag.AddRequest(gid, &req); err != nil {
+ return err
}
hasresp = true
- mp.Store(chat.StateKeyAgentTriggered, struct{}{})
- for _, req := range reqs {
- if req.Action == goba.SVM { // is a fake action
- continue
- }
- logrus.Debugln("[chat] agent triggered", gid, "add requ:", &req)
- ag.AddRequest(gid, &req)
- rsp := ctx.CallAction(req.Action, req.Params)
- logrus.Debugln("[chat] agent triggered", gid, "add resp:", &rsp)
- ag.AddResponse(gid, &goba.APIResponse{
- Status: rsp.Status,
- Data: json.RawMessage(rsp.Data.Raw),
- Message: rsp.Message,
- Wording: rsp.Wording,
- RetCode: rsp.RetCode,
- })
+ rsp := task.Call(req.Action, req.Params, ctx.CallAction)
+ if rsp.Status != "ok" || rsp.RetCode != 0 {
+ task.Fail()
+ }
+ logrus.Debugln("[chat] agent triggered", gid, "add resp:", &rsp)
+ return ag.AddResponse(gid, &goba.APIResponse{
+ Status: rsp.Status,
+ Data: json.RawMessage(rsp.Data.Raw),
+ Message: rsp.Message,
+ Wording: rsp.Wording,
+ RetCode: rsp.RetCode,
+ })
+ }, func() bool { return generation == chat.AgentGeneration() })
+ if err != nil {
+ task.Fail()
+ logrus.Warnln("[aichat] Agent 本次任务已停止,检查结束动作、记录存储或重复动作。")
+ if errors.Is(err, chat.ErrAgentReloaded) {
+ return
}
}
if hasresp {
@@ -146,15 +170,18 @@ func init() {
logrus.Debugln("[aichat] agent fell back to normal chat")
}
- x := deepinfra.NewAPI(chat.AC.API, string(chat.AC.Key))
- mod, err := chat.AC.Type.Protocol(chat.AC.ModelName, temperature, topp, maxn, chat.AC.ReasoningEffort)
+ x := deepinfra.NewAPI(cfg.API, string(cfg.Key))
+ x.SetHTTPClient(modelstats.Default.Client(&http.Client{Timeout: 30 * time.Second}, modelstats.Chat))
+ mod, err := cfg.Type.ProtocolForAPI(cfg.API, cfg.ModelName, temperature, topp, maxn, cfg.ReasoningEffort, cfg.Separator)
if err != nil {
+ task.Fail()
logrus.Warnln("ERROR: ", err)
return
}
- data, err := x.Request(chat.GetChatContext(mod, gid, chat.AC.SystemP, bool(chat.AC.NoSystemP)))
+ data, err := x.Request(chat.GetChatContext(mod, gid, cfg.SystemP, bool(cfg.NoSystemP)))
if err != nil {
- logrus.Warnln("[aichat] post err:", err)
+ task.Fail()
+ logrus.Warnln("[aichat] 模型请求失败,请检查接口、密钥及网络。")
return
}
@@ -174,19 +201,18 @@ func init() {
}
logrus.Debugln("[aichat] 回复内容:", t)
recCfg := airecord.GetConfig()
- record := ""
- if !fastfailnorecord && !stor.NoRecord() {
- record = ctx.GetAIRecord(recCfg.ModelID, recCfg.Customgid, t)
- if record != "" {
- ctx.SendChain(message.Record(record))
+ if ctx.Event.GroupID > 0 && !fastfailnorecord.Load() && !stor.NoRecord() {
+ if qqvoice.Deliver(t, recCfg.ModelID, ctx.Event.GroupID, func(action string, params zero.Params) zero.APIResponse {
+ return task.Call(action, params, ctx.CallAction)
+ }) == "" {
continue
}
- fastfailnorecord = true
+ fastfailnorecord.Store(true)
}
if id != nil {
- id = ctx.SendChain(message.Reply(id), message.Text(t))
+ id = task.SendChain(ctx, message.Reply(id), message.Text(t))
} else {
- id = ctx.SendChain(message.Text(t))
+ id = task.SendChain(ctx, message.Text(t))
}
process.SleepAbout1sTo2s()
}
diff --git a/plugin/aichat/permissions.go b/plugin/aichat/permissions.go
new file mode 100644
index 0000000000..89ca0610a8
--- /dev/null
+++ b/plugin/aichat/permissions.go
@@ -0,0 +1,18 @@
+package aichat
+
+import (
+ goba "github.com/fumiama/go-onebot-agent"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// Group owners retain the group-admin action list. Only configured bot
+// owners receive the owner list, including cross-group and private actions.
+func agentRole(ctx *zero.Ctx) goba.PermRole {
+ if zero.SuperUserPermission(ctx) {
+ return goba.PermRoleOwner
+ }
+ if ctx.Event.GroupID > 0 && ctx.Event.Sender != nil && zero.AdminPermission(ctx) {
+ return goba.PermRoleAdmin
+ }
+ return goba.PermRoleUser
+}
diff --git a/plugin/aichat/permissions_actions_test.go b/plugin/aichat/permissions_actions_test.go
new file mode 100644
index 0000000000..b33a79a86a
--- /dev/null
+++ b/plugin/aichat/permissions_actions_test.go
@@ -0,0 +1,72 @@
+package aichat
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/FloatTech/zbputils/chat"
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+ goba "github.com/fumiama/go-onebot-agent"
+)
+
+type permissionMemory struct{}
+
+func (permissionMemory) Load(int64) []string { return nil }
+func (permissionMemory) Save(int64, string) error { return nil }
+
+// Model output is deliberately hostile in rejected cases. The real Agent
+// parser and action checks must reject it before any OneBot action can execute.
+func TestAgentRoleActionBoundaries(t *testing.T) {
+ for _, c := range []struct {
+ name, action, params string
+ role goba.PermRole
+ group int64
+ sudo, allow bool
+ }{
+ {"member sends here", "send_group_msg", `{"group_id":101,"message":"hello"}`, goba.PermRoleUser, 101, false, true},
+ {"member cannot send elsewhere", "send_group_msg", `{"group_id":202,"message":"hello"}`, goba.PermRoleUser, 101, false, false},
+ {"member cannot ban", "set_group_ban", `{"group_id":101,"user_id":123,"duration":1}`, goba.PermRoleUser, 101, false, false},
+ {"admin can ban here", "set_group_ban", `{"group_id":101,"user_id":123,"duration":1}`, goba.PermRoleAdmin, 101, false, true},
+ {"admin cannot ban elsewhere", "set_group_ban", `{"group_id":202,"user_id":123,"duration":1}`, goba.PermRoleAdmin, 101, false, false},
+ {"admin cannot grant admin", "set_group_admin", `{"group_id":101,"user_id":123,"enable":true}`, goba.PermRoleAdmin, 101, false, false},
+ {"admin cannot leave", "set_group_leave", `{"group_id":101}`, goba.PermRoleAdmin, 101, false, false},
+ {"member reads here", "get_group_info", `{"group_id":101}`, goba.PermRoleUser, 101, false, true},
+ {"member cannot read elsewhere", "get_group_info", `{"group_id":202}`, goba.PermRoleUser, 101, false, false},
+ {"admin cannot read elsewhere", "get_group_info", `{"group_id":202}`, goba.PermRoleAdmin, 101, false, false},
+ {"private member cannot query group", "get_group_info", `{"group_id":101}`, goba.PermRoleUser, -123, false, false},
+ {"private member cannot send private action", "send_private_msg", `{"user_id":123,"message":"hello"}`, goba.PermRoleUser, -123, false, false},
+ {"owner private action unchanged", "send_private_msg", `{"user_id":123,"message":"hello"}`, goba.PermRoleOwner, -123, true, true},
+ {"owner other group unchanged", "get_group_info", `{"group_id":202}`, goba.PermRoleOwner, 101, true, true},
+ {"zero group invalid", "get_group_info", `{"group_id":0}`, goba.PermRoleOwner, 101, true, false},
+ {"negative group invalid", "get_group_info", `{"group_id":-123}`, goba.PermRoleOwner, 101, true, false},
+ {"string group invalid", "get_group_info", `{"group_id":"101"}`, goba.PermRoleUser, 101, false, false},
+ {"member array all denied", "send_group_msg", `{"group_id":101,"message":[{"type":"at","data":{"qq":"all"}}]}`, goba.PermRoleUser, 101, false, false},
+ {"member CQ all denied", "send_group_msg", `{"group_id":101,"message":"[CQ:at,qq=all]hello"}`, goba.PermRoleUser, 101, false, false},
+ {"member CQ person allowed", "send_group_msg", `{"group_id":101,"message":"[CQ:at,qq=123]hello"}`, goba.PermRoleUser, 101, false, true},
+ {"member array person allowed", "send_group_msg", `{"group_id":101,"message":[{"type":"at","data":{"qq":"123"}}]}`, goba.PermRoleUser, 101, false, true},
+ {"member invalid CQ at denied", "send_group_msg", `{"group_id":101,"message":"[CQ:at,qq=0]hello"}`, goba.PermRoleUser, 101, false, false},
+ {"admin CQ all unchanged", "send_group_msg", `{"group_id":101,"message":"[CQ:at,qq=all]hello"}`, goba.PermRoleAdmin, 101, false, true},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ content := `{"action":"` + c.action + `","params":` + c.params + `} ` + `{"action":"end_action","params":{}}`
+ _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": content}}}})
+ }))
+ defer server.Close()
+ ag := goba.NewAgent(&goba.Config{}, 16, 8, time.Hour, "", permissionMemory{}, true, true)
+ reqs, _, err := chat.NextAgentActions(&ag, c.sudo, 1, deepinfra.NewAPI(server.URL, "synthetic"), model.NewOpenAI("test", "", .7, 1, 512, ""), c.group, c.role)
+ if c.allow {
+ if err != nil || len(reqs) != 1 || reqs[0].Action != c.action {
+ t.Fatalf("allowed action lost: requests=%v error=%v", reqs, err)
+ }
+ } else if len(reqs) != 0 {
+ t.Fatalf("unauthorized action reached execution boundary: %v", reqs)
+ }
+ })
+ }
+}
diff --git a/plugin/aichat/permissions_test.go b/plugin/aichat/permissions_test.go
new file mode 100644
index 0000000000..00a01353e2
--- /dev/null
+++ b/plugin/aichat/permissions_test.go
@@ -0,0 +1,38 @@
+package aichat
+
+import (
+ "testing"
+
+ goba "github.com/fumiama/go-onebot-agent"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func TestAgentRoleUsesCurrentGroupAndConfiguredOwner(t *testing.T) {
+ old := zero.BotConfig.SuperUsers
+ zero.BotConfig.SuperUsers = []int64{123}
+ t.Cleanup(func() { zero.BotConfig.SuperUsers = old })
+ for _, c := range []struct {
+ name string
+ uid, gid int64
+ sender *zero.User
+ want goba.PermRole
+ }{
+ {"owner private", 123, 0, &zero.User{Role: "member"}, goba.PermRoleOwner},
+ {"owner group member", 123, 101, &zero.User{Role: "member"}, goba.PermRoleOwner},
+ {"group owner", 456, 101, &zero.User{Role: "owner"}, goba.PermRoleAdmin},
+ {"group admin", 456, 101, &zero.User{Role: "admin"}, goba.PermRoleAdmin},
+ {"group member", 456, 101, &zero.User{Role: "member"}, goba.PermRoleUser},
+ {"unknown group role", 456, 101, &zero.User{Role: "unexpected"}, goba.PermRoleUser},
+ {"missing sender", 456, 101, nil, goba.PermRoleUser},
+ {"ordinary private", 456, 0, &zero.User{Role: "member"}, goba.PermRoleUser},
+ {"private role cannot grant admin", 456, 0, &zero.User{Role: "admin"}, goba.PermRoleUser},
+ {"private role cannot grant owner", 456, 0, &zero.User{Role: "owner"}, goba.PermRoleUser},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ ctx := &zero.Ctx{Event: &zero.Event{UserID: c.uid, GroupID: c.gid, Sender: c.sender}}
+ if got := agentRole(ctx); got != c.want {
+ t.Fatalf("got %s, want %s", got, c.want)
+ }
+ })
+ }
+}
diff --git a/plugin/aichatcfg/main.go b/plugin/aichatcfg/main.go
index 972b2680d2..9fd24c9ef9 100644
--- a/plugin/aichatcfg/main.go
+++ b/plugin/aichatcfg/main.go
@@ -69,89 +69,59 @@ func init() {
en.OnPrefix("设置AI聊天温度", zero.AdminPermission).SetBlock(true).
Handle(ctxext.NewStorageSaveBitmapHandler(chat.BitmapTemp, 0, 100))
en.OnPrefix("设置AI聊天接口类型", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetModelType(&chat.AC.Type))
+ Handle(chat.NewExtraSetModelType("Type"))
en.OnPrefix("设置AI聊天识图接口类型", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetModelType(&chat.AC.ImageType))
+ Handle(chat.NewExtraSetModelType("ImageType"))
en.OnPrefix("设置AI聊天Agent接口类型", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetModelType(&chat.AC.AgentType))
+ Handle(chat.NewExtraSetModelType("AgentType"))
en.OnPrefix("设置AI聊天接口地址", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.API))
+ Handle(chat.NewExtraSetStr("API"))
en.OnPrefix("设置AI聊天识图接口地址", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.ImageAPI))
+ Handle(chat.NewExtraSetStr("ImageAPI"))
en.OnPrefix("设置AI聊天Agent接口地址", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.AgentAPI))
+ Handle(chat.NewExtraSetStr("AgentAPI"))
en.OnPrefix("设置AI聊天密钥", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.Key))
+ Handle(chat.NewExtraSetStr("Key"))
en.OnPrefix("设置AI聊天识图密钥", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.ImageKey))
+ Handle(chat.NewExtraSetStr("ImageKey"))
en.OnPrefix("设置AI聊天Agent密钥", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.AgentKey))
+ Handle(chat.NewExtraSetStr("AgentKey"))
en.OnPrefix("设置AI聊天模型名", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.ModelName))
+ Handle(chat.NewExtraSetStr("ModelName"))
en.OnPrefix("设置AI聊天识图模型名", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.ImageModelName))
+ Handle(chat.NewExtraSetStr("ImageModelName"))
en.OnPrefix("设置AI聊天Agent模型名", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.AgentModelName))
+ Handle(chat.NewExtraSetStr("AgentModelName"))
en.OnPrefix("设置AI聊天系统提示词", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.SystemP))
+ Handle(chat.NewExtraSetStr("SystemP"))
en.OnPrefix("设置AI聊天Agent性格", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.AgentChar), func(_ *zero.Ctx) {
- chat.AgentCharConfig.Chars = chat.AC.AgentChar
- })
+ Handle(chat.NewExtraSetStr("AgentChar"))
en.OnPrefix("设置AI聊天Agent性别", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.AgentSex), func(_ *zero.Ctx) {
- chat.AgentCharConfig.Sex = chat.AC.AgentSex
- })
+ Handle(chat.NewExtraSetStr("AgentSex"))
en.OnFullMatch("查看AI聊天系统提示词", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
- ctx.SendChain(message.Text(chat.AC.SystemP))
+ ctx.SendChain(message.Text(chat.ConfigOf(ctx).Redact(chat.ConfigOf(ctx).SystemP)))
})
en.OnFullMatch("查看AI聊天Agent性格", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
- ctx.SendChain(message.Text(chat.AC.AgentChar))
- })
- en.OnFullMatch("重置AI聊天系统提示词", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
- c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx])
- if !ok {
- ctx.SendChain(message.Text("ERROR: no such plugin"))
- return
- }
- chat.AC.SystemP = chat.SystemPrompt
- err := c.SetExtra(&chat.AC)
- if err != nil {
- ctx.SendChain(message.Text("ERROR: set extra err: ", err))
- return
- }
- ctx.SendChain(message.Text("成功"))
- })
- en.OnFullMatch("重置AI聊天Agent性格性别", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
- c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx])
- if !ok {
- ctx.SendChain(message.Text("ERROR: no such plugin"))
- return
- }
- chat.ResetAgentCharConfig()
- err := c.SetExtra(&chat.AC)
- if err != nil {
- ctx.SendChain(message.Text("ERROR: set extra err: ", err))
- return
- }
- ctx.SendChain(message.Text("成功, 请重置AI聊天Agent"))
+ ctx.SendChain(message.Text(chat.ConfigOf(ctx).Redact(chat.ConfigOf(ctx).AgentChar)))
})
+ en.OnFullMatch("重置AI聊天系统提示词", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(chat.ResetSystemPrompt)
+ en.OnFullMatch("重置AI聊天Agent性格性别", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).Handle(chat.ResetAgentCharacter)
en.OnPrefix("设置AI聊天分隔符", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.Separator))
+ Handle(chat.NewExtraSetStr("Separator"))
en.OnRegex("^设置AI聊天(不)?响应AT$", zero.SuperUserPermission).SetBlock(true).
Handle(ctxext.NewStorageSaveBoolHandler(chat.BitmapNrat))
en.OnRegex("^设置AI聊天(不)?支持系统提示词$", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetBool(&chat.AC.NoSystemP))
+ Handle(chat.NewExtraSetBool("NoSystemP"))
en.OnRegex("^设置AI聊天(不)?使用Agent模式$", zero.SuperUserPermission).SetBlock(true).
Handle(ctxext.NewStorageSaveBoolHandler(chat.BitmapNagt))
en.OnPrefix("设置AI聊天最大长度", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetUint(&chat.AC.MaxN))
+ Handle(chat.NewExtraSetUint("MaxN"))
en.OnPrefix("设置AI聊天TopP", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetFloat32(&chat.AC.TopP))
+ Handle(chat.NewExtraSetFloat32("TopP"))
en.OnRegex("^设置AI聊天(不)?以AI语音输出$", zero.AdminPermission).SetBlock(true).
Handle(ctxext.NewStorageSaveBoolHandler(chat.BitmapNrec))
en.OnPrefix("设置AI聊天努力度", chat.EnsureConfig, zero.OnlyPrivate, zero.SuperUserPermission).SetBlock(true).
- Handle(chat.NewExtraSetStr(&chat.AC.ReasoningEffort))
+ Handle(chat.NewExtraSetStr("ReasoningEffort"))
en.OnFullMatch("查看AI聊天配置", chat.EnsureConfig, zero.SuperUserPermission).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
gid := ctx.Event.GroupID
@@ -172,12 +142,15 @@ func init() {
"• 使用Agent:", chat.ModelBool(!stor.NoAgent()), "\n",
"• 响应@:", chat.ModelBool(!stor.NoReplyAt()), "\n",
),
- message.Text("【当前AI聊天全局配置】\n", &chat.AC),
+ message.Text("【当前AI聊天全局配置】\n", chat.ConfigOf(ctx)),
)
})
en.OnFullMatch("重置AI聊天Agent", chat.EnsureConfig, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
- chat.ResetAgents()
- ctx.SendChain(message.Text("成功"))
+ if err := chat.ResetAgents(); err != nil {
+ ctx.SendChain(message.Text("重置失败,请检查本机对话记录存储。"))
+ return
+ }
+ ctx.SendChain(message.Text("已开启新的 Agent 上下文,历史记录仍保留在本机。"))
})
en.OnFullMatch("重置AI聊天", chat.EnsureConfig, zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
chat.ResetChat()
diff --git a/plugin/airecord/models.go b/plugin/airecord/models.go
new file mode 100644
index 0000000000..d972e15a59
--- /dev/null
+++ b/plugin/airecord/models.go
@@ -0,0 +1,24 @@
+package airecord
+
+import "github.com/tidwall/gjson"
+
+type voiceModel struct{ name, id, preview string }
+
+// QQ repeats some voices across categories. Use IDs, not names, to keep the
+// displayed index tied to the exact model the user selects.
+func availableVoiceModels(data gjson.Result) []voiceModel {
+ models := []voiceModel{}
+ seen := map[string]bool{}
+ data.ForEach(func(_, bucket gjson.Result) bool {
+ bucket.Get("characters").ForEach(func(_, character gjson.Result) bool {
+ name, id := character.Get("character_name").String(), character.Get("character_id").String()
+ if name != "" && id != "" && !seen[id] {
+ seen[id] = true
+ models = append(models, voiceModel{name: name, id: id, preview: character.Get("preview_url").String()})
+ }
+ return true
+ })
+ return true
+ })
+ return models
+}
diff --git a/plugin/airecord/models_test.go b/plugin/airecord/models_test.go
new file mode 100644
index 0000000000..a42b3321bf
--- /dev/null
+++ b/plugin/airecord/models_test.go
@@ -0,0 +1,17 @@
+package airecord
+
+import (
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestVoiceModelsKeepSelectionIDs(t *testing.T) {
+ models := availableVoiceModels(gjson.Parse(`[{"characters":[{"character_name":"同名","character_id":"first"},{"character_name":"同名","character_id":"second"}]},{"characters":[{"character_name":"重复","character_id":"first"},{"character_name":"无编号"},{"character_id":"missing-name"}]}]`))
+ if len(models) != 2 || models[0].id != "first" || models[1].id != "second" || models[0].name != "同名" {
+ t.Fatal("displayed index lost its model ID or duplicated categories")
+ }
+ if len(availableVoiceModels(gjson.Parse(`[]`))) != 0 {
+ t.Fatal("empty API invented voices")
+ }
+}
diff --git a/plugin/airecord/record.go b/plugin/airecord/record.go
index 908784d483..567c09c794 100644
--- a/plugin/airecord/record.go
+++ b/plugin/airecord/record.go
@@ -6,7 +6,7 @@ import (
"strings"
"time"
- "github.com/tidwall/gjson"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/qqvoice"
zero "github.com/wdvxdr1123/ZeroBot"
"github.com/wdvxdr1123/ZeroBot/message"
@@ -36,6 +36,10 @@ func init() {
ctx.SendChain(message.Text("ERROR: parse gid err: ", err))
return
}
+ if num <= 0 {
+ ctx.SendChain(message.Text("请填写正数群号。"))
+ return
+ }
err = airecord.SetCustomGID(num)
if err != nil {
ctx.SendChain(message.Text("ERROR: set gid err: ", err))
@@ -50,38 +54,23 @@ func init() {
defer cancel()
jsonData := ctx.GetAICharacters(0, 1)
- // 转换为字符串数组
- var names []string
- // 初始化两个映射表
- nameToID := make(map[string]string)
- nameToURL := make(map[string]string)
- characters := jsonData.Get("#.characters")
-
- // 遍历每个角色对象
- characters.ForEach(func(_, group gjson.Result) bool {
- group.ForEach(func(_, character gjson.Result) bool {
- // 提取当前角色的三个字段
- name := character.Get("character_name").String()
- names = append(names, name)
- // 存入映射表(重复名称会覆盖,保留最后出现的条目)
- nameToID[name] = character.Get("character_id").String()
- nameToURL[name] = character.Get("preview_url").String()
- return true // 继续遍历
- })
- return true // 继续遍历
- })
+ models := availableVoiceModels(jsonData)
+ if len(models) == 0 {
+ ctx.SendChain(message.Text("暂时无法获取AI语音声音,请稍后重试。"))
+ return
+ }
var builder strings.Builder
// 写入开头文本
builder.WriteString("请选择语音模型序号:\n")
// 遍历names数组,拼接序号和名称
- for i, v := range names {
+ for i, v := range models {
// 将数字转换为字符串(不依赖fmt)
numStr := strconv.Itoa(i)
// 拼接格式:"序号. 名称\n"
builder.WriteString(numStr)
builder.WriteString(". ")
- builder.WriteString(v)
+ builder.WriteString(v.name)
builder.WriteString("\n")
}
// 获取最终字符串
@@ -98,17 +87,19 @@ func init() {
ctx.SendChain(message.Text("请输入数字!"))
continue
}
- if num < 0 || num >= len(names) {
+ if num < 0 || num >= len(models) {
ctx.SendChain(message.Text("序号非法!"))
continue
}
- err = airecord.SetRecordModel(names[num], nameToID[names[num]])
+ err = airecord.SetRecordModel(models[num].name, models[num].id)
if err != nil {
ctx.SendChain(message.Text("ERROR: set model err: ", err))
continue
}
- ctx.SendChain(message.Text("已选择语音模型: ", names[num]))
- ctx.SendChain(message.Record(nameToURL[names[num]]))
+ ctx.SendChain(message.Text("已选择语音模型: ", models[num].name))
+ if models[num].preview != "" {
+ ctx.SendChain(message.Record(models[num].preview))
+ }
return
}
}
@@ -121,14 +112,9 @@ func init() {
Handle(func(ctx *zero.Ctx) {
u := strings.TrimSpace(ctx.State["args"].(string))
recCfg := airecord.GetConfig()
- record := ctx.GetAIRecord(recCfg.ModelID, recCfg.Customgid, u)
- if record == "" {
- id := ctx.SendGroupAIRecord(recCfg.ModelID, ctx.Event.GroupID, u)
- if id == "" {
- ctx.SendChain(message.Text("ERROR: get record err: empty record"))
- return
- }
+ failure := qqvoice.Deliver(u, recCfg.ModelID, ctx.Event.GroupID, ctx.CallAction)
+ if failure != "" {
+ ctx.SendChain(message.Text(failure))
}
- ctx.SendChain(message.Record(record))
})
}
diff --git a/plugin/llm/main.go b/plugin/llm/main.go
index 5c8b460e25..c050bf9425 100644
--- a/plugin/llm/main.go
+++ b/plugin/llm/main.go
@@ -5,15 +5,15 @@ import (
"strconv"
"strings"
"time"
+ "unicode/utf8"
- "github.com/fumiama/deepinfra"
- "github.com/fumiama/deepinfra/model"
"github.com/tidwall/gjson"
zero "github.com/wdvxdr1123/ZeroBot"
"github.com/wdvxdr1123/ZeroBot/extension/single"
"github.com/wdvxdr1123/ZeroBot/message"
+ "github.com/FloatTech/ZeroBot-Plugin/internal/replystats"
ctrl "github.com/FloatTech/zbpctrl"
"github.com/FloatTech/zbputils/chat"
"github.com/FloatTech/zbputils/control"
@@ -39,12 +39,17 @@ var (
)
var (
- limit = ctxext.NewLimiterManager(time.Second*30, 1)
+ limit = newRequestQuota(30*time.Second, 4096)
)
func init() {
// 添加群聊总结功能
- en.OnRegex(`^群聊总结\s?(\d*)$`, chat.EnsureConfig, zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Limit(limit.LimitByGroup).Handle(func(ctx *zero.Ctx) {
+ en.OnRegex(`^群聊总结\s?(\d*)$`, chat.EnsureConfig, zero.OnlyGroup, zero.AdminPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ if !allowRequest(ctx) {
+ return
+ }
+ task := replystats.Default.Begin(ctx, replystats.GPT)
+ defer task.End()
ctx.SendChain(message.Text("少女思考中..."))
gid := ctx.Event.GroupID
if gid == 0 {
@@ -59,6 +64,7 @@ func init() {
}
group := ctx.GetGroupInfo(gid, false)
if group.MemberCount == 0 {
+ task.Fail()
ctx.SendChain(message.Text(zero.BotConfig.NickName[0], "未加入", group.Name, "(", gid, "),无法获取总结"))
return
}
@@ -76,6 +82,7 @@ func init() {
})
if len(messages) == 0 {
+ task.Fail()
ctx.SendChain(message.Text("ERROR: 历史消息为空或者无法获得历史消息"))
return
}
@@ -97,13 +104,15 @@ func init() {
stor, err := chat.NewStorage(ctx, gid)
if err != nil {
+ task.Fail()
ctx.SendChain(message.Text("ERROR: ", err))
return
}
// 调用大模型API进行总结
- summary, err := llmchat(summaryPrompt, stor.Temp())
+ summary, err := requestLLM(chat.ConfigOf(ctx), summaryPrompt, stor.Temp())
if err != nil {
+ task.Fail()
ctx.SendChain(message.Text("ERROR: ", err))
return
}
@@ -118,37 +127,21 @@ func init() {
b.WriteString(" 条消息总结:\n\n")
b.WriteString(summary)
- // 分割总结内容为多段(按1000字符长度切割)
- summaryText := b.String()
- msg := make(message.Message, 0)
- for len(summaryText) > 0 {
- if len(summaryText) <= 1000 {
- msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text(summaryText)))
- break
- }
-
- // 查找1000字符内的最后一个换行符,尽量在换行处分割
- chunk := summaryText[:1000]
- lastNewline := strings.LastIndex(chunk, "\n")
- if lastNewline > 0 {
- chunk = summaryText[:lastNewline+1]
- }
-
- msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text(chunk)))
- summaryText = summaryText[len(chunk):]
- }
- if len(msg) > 0 {
- ctx.Send(msg)
- }
+ sendLLMReply(ctx, b.String(), task)
})
// 添加 /gpt 命令处理(同时支持回复消息和直接使用)
- en.OnKeyword("/gpt", chat.EnsureConfig).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ en.OnMessage(func(ctx *zero.Ctx) bool {
+ arg, matched := parseGPT(ctx.Event.Message.ExtractPlainText())
+ if matched {
+ ctx.State["gpt_query"] = arg
+ }
+ return matched
+ }, chat.EnsureConfig).SetBlock(true).Handle(func(ctx *zero.Ctx) {
gid := ctx.Event.GroupID
if gid == 0 {
gid = -ctx.Event.UserID
}
- text := ctx.MessageString()
var query string
var replyContent string
@@ -170,23 +163,12 @@ func init() {
}
}
- // 提取 /gpt 后面的内容
- parts := strings.SplitN(text, "/gpt", 2)
-
- var gContent string
- if len(parts) > 1 {
- gContent = strings.TrimSpace(parts[1])
- }
+ gContent := ctx.State["gpt_query"].(string)
- // 组合内容:优先使用回复内容,如果同时有/gpt内容则拼接
- switch {
- case replyContent != "" && gContent != "":
- query = replyContent + "\n" + gContent
- case replyContent != "":
- query = replyContent
- case gContent != "":
- query = gContent
- default:
+ // Distinguish source text from the user's instruction about that text.
+ query = buildGPTQuery(replyContent, gContent)
+ if query == "" {
+ ctx.SendChain(message.Text("请在 /gpt 后填写问题,或回复一条消息后发送 /gpt。"))
return
}
@@ -195,52 +177,48 @@ func init() {
ctx.SendChain(message.Text("ERROR: ", err))
return
}
+ if !allowRequest(ctx) {
+ return
+ }
+ task := replystats.Default.Begin(ctx, replystats.GPT)
+ defer task.End()
// 调用大模型API进行聊天
- reply, err := llmchat(query, stor.Temp())
+ reply, err := requestLLM(chat.ConfigOf(ctx), query, stor.Temp())
if err != nil {
+ task.Fail()
ctx.SendChain(message.Text("ERROR: ", err))
return
}
- // 分割总结内容为多段(按1000字符长度切割)
- msg := make(message.Message, 0)
- for len(reply) > 0 {
- if len(reply) <= 1000 {
- msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text(reply)))
- break
- }
-
- // 查找1000字符内的最后一个换行符,尽量在换行处分割
- chunk := reply[:1000]
- lastNewline := strings.LastIndex(chunk, "\n")
- if lastNewline > 0 {
- chunk = reply[:lastNewline+1]
- }
-
- msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text(chunk)))
- reply = reply[len(chunk):]
- }
- if len(msg) > 0 {
- ctx.Send(msg)
- }
+ sendLLMReply(ctx, reply, task)
})
}
-// llmchat 调用大模型API包装
-func llmchat(prompt string, temp float32) (string, error) {
- topp, maxn := chat.AC.MParams()
-
- x := deepinfra.NewAPI(chat.AC.API, string(chat.AC.Key))
-
- mod, err := chat.AC.Type.Protocol(chat.AC.ModelName, temp, topp, maxn, chat.AC.ReasoningEffort)
- if err != nil {
- return "", nil
+// Short replies stay readable in the conversation; longer replies retain
+// their complete content in the existing merged-forward format.
+func sendLLMReply(ctx *zero.Ctx, text string, task *replystats.Task) {
+ if text == "" {
+ return
}
-
- data, err := x.Request(mod.User(model.NewContentText(prompt)))
- if err != nil {
- return "", err
+ if utf8.RuneCountInString(text) <= 200 {
+ task.SendChain(ctx, message.Text(text))
+ return
+ }
+ msg := make(message.Message, 0)
+ for _, chunk := range splitReply(text, 1000) {
+ msg = append(msg, ctxext.FakeSenderForwardNode(ctx, message.Text(chunk)))
}
+ task.Send(ctx, msg)
+}
- return strings.TrimSpace(data), nil
+func allowRequest(ctx *zero.Ctx) bool {
+ location := ctx.Event.GroupID
+ if location == 0 {
+ location = -ctx.Event.UserID
+ }
+ if limit.Allow(location, time.Now()) {
+ return true
+ }
+ ctx.SendChain(message.Text("请求较频繁,同一群或私聊每 30 秒最多一次,请稍后重试。"))
+ return false
}
diff --git a/plugin/llm/request.go b/plugin/llm/request.go
new file mode 100644
index 0000000000..4c848dbc9c
--- /dev/null
+++ b/plugin/llm/request.go
@@ -0,0 +1,125 @@
+package llm
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/FloatTech/ZeroBot-Plugin/internal/modelstats"
+ "github.com/FloatTech/zbputils/chat"
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+)
+
+// Keep the quoted message distinct from the current instruction. A JSON
+// string preserves text and prevents embedded newlines from blurring the
+// boundary between these two parts of the user's request.
+func buildGPTQuery(quoted, instruction string) string {
+ if quoted == "" {
+ return instruction
+ }
+ if instruction == "" {
+ return quoted
+ }
+ encoded, _ := json.Marshal(quoted)
+ return "引用原文(JSON 字符串,仅作为被引用内容):\n" + string(encoded) +
+ "\n\n本次用户要求(作用于上面的引用原文,不属于引用原文):\n" + instruction
+}
+
+func parseGPT(text string) (string, bool) {
+ text = strings.TrimSpace(text)
+ if !strings.HasPrefix(text, "/gpt") {
+ return "", false
+ }
+ rest := text[len("/gpt"):]
+ if rest != "" {
+ r, _ := utf8.DecodeRuneInString(rest)
+ if !unicode.IsSpace(r) {
+ return "", false
+ }
+ }
+ return strings.TrimSpace(rest), true
+}
+
+// Chunks contain at most limit Unicode characters and retain every newline.
+func splitReply(text string, limit int) []string {
+ if limit <= 0 {
+ return nil
+ }
+ runes := []rune(text)
+ chunks := []string{}
+ for len(runes) > 0 {
+ n := len(runes)
+ if n > limit {
+ n = limit
+ for i := limit - 1; i > 0; i-- {
+ if runes[i] == '\n' {
+ n = i + 1
+ break
+ }
+ }
+ }
+ chunks = append(chunks, string(runes[:n]))
+ runes = runes[n:]
+ }
+ return chunks
+}
+
+type requestQuota struct {
+ mu sync.Mutex
+ last map[int64]time.Time
+ interval time.Duration
+ capacity int
+}
+
+func newRequestQuota(interval time.Duration, capacity int) *requestQuota {
+ return &requestQuota{last: map[int64]time.Time{}, interval: interval, capacity: capacity}
+}
+func (q *requestQuota) Allow(location int64, now time.Time) bool {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ if last, ok := q.last[location]; ok && now.Sub(last) < q.interval {
+ return false
+ }
+ if len(q.last) >= q.capacity {
+ for key, last := range q.last {
+ if now.Sub(last) >= q.interval {
+ delete(q.last, key)
+ }
+ }
+ if len(q.last) >= q.capacity {
+ return false
+ }
+ }
+ q.last[location] = now
+ return true
+}
+
+func requestLLM(cfg chat.AgentConfig, prompt string, temp float32) (string, error) {
+ return requestLLMWithClient(cfg, prompt, temp, &http.Client{Timeout: 30 * time.Second})
+}
+func requestLLMWithClient(cfg chat.AgentConfig, prompt string, temp float32, client *http.Client) (string, error) {
+ top, max := cfg.MParams()
+ protocol, err := cfg.Type.ProtocolForAPI(cfg.API, cfg.ModelName, temp, top, max, cfg.ReasoningEffort, cfg.Separator)
+ if err != nil {
+ return "", err
+ }
+ if !cfg.Ready() {
+ return "", errors.New("AI 接口尚未配置完整,请在后台设置接口、模型和密钥。")
+ }
+ if !bool(cfg.NoSystemP) && cfg.SystemP != "" {
+ protocol.System(cfg.SystemP)
+ }
+ api := deepinfra.NewAPI(cfg.API, string(cfg.Key))
+ api.SetHTTPClient(modelstats.Default.Client(client, modelstats.GPT))
+ data, err := api.Request(protocol.User(model.NewContentText(prompt)))
+ if err != nil {
+ return "", errors.New("模型请求失败,请检查接口、密钥及网络。")
+ }
+ return strings.TrimSpace(data), nil
+}
diff --git a/plugin/llm/request_test.go b/plugin/llm/request_test.go
new file mode 100644
index 0000000000..d7027edd7c
--- /dev/null
+++ b/plugin/llm/request_test.go
@@ -0,0 +1,199 @@
+package llm
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/FloatTech/ZeroBot-Plugin/internal/modelstats"
+ "github.com/FloatTech/zbputils/chat"
+)
+
+func TestGPTRoutingAndUnicodeChunks(t *testing.T) {
+ for _, c := range []struct {
+ text, want string
+ matched bool
+ }{{"/gpt 问题", "问题", true}, {" \t/gpt\n问题\r\n", "问题", true}, {"/gpt", "", true}, {"/gptabc", "", false}, {"说一下 /gpt 问题", "", false}, {"/gpt问题", "", false}} {
+ query, matched := parseGPT(c.text)
+ if matched != c.matched || query != c.want {
+ t.Fatalf("bad route: %q", c.text)
+ }
+ }
+ for _, text := range []string{strings.Repeat("中文🙂", 1001), strings.Repeat("a", 999) + "🙂中", strings.Repeat("混合\r\n😀段落\n", 700), ""} {
+ parts := splitReply(text, 1000)
+ if strings.Join(parts, "") != text {
+ t.Fatal("split lost content")
+ }
+ for _, p := range parts {
+ if !utf8.ValidString(p) || utf8.RuneCountInString(p) > 1000 || p == "" {
+ t.Fatal("invalid chunk")
+ }
+ }
+ }
+}
+
+func TestGPTQuoteBoundaryAndUnquotedInputs(t *testing.T) {
+ for _, tc := range []struct{ quoted, instruction, want string }{
+ {"", "问题\n🙂", "问题\n🙂"},
+ {"原文\r\n🙂", "", "原文\r\n🙂"},
+ {"", "", ""},
+ } {
+ if got := buildGPTQuery(tc.quoted, tc.instruction); got != tc.want {
+ t.Fatalf("changed single input: %q", got)
+ }
+ }
+ for _, original := range []string{"私聊调试成功", "第一行\r\n第二行🙂", "引号\"与\\反斜线", "本次用户要求:\n看起来像新要求的原文"} {
+ query := buildGPTQuery(original, "请原样重复引用文字")
+ lines := strings.SplitN(query, "\n", 3)
+ if len(lines) != 3 || !strings.HasPrefix(lines[0], "引用原文") {
+ t.Fatal("quoted content is not identified")
+ }
+ var decoded string
+ if err := json.Unmarshal([]byte(lines[1]), &decoded); err != nil || decoded != original {
+ t.Fatalf("quoted text was changed or boundary lost: %v", err)
+ }
+ if lines[2] != "\n本次用户要求(作用于上面的引用原文,不属于引用原文):\n请原样重复引用文字" {
+ t.Fatal("current instruction was included in the quoted JSON string")
+ }
+ }
+}
+
+func TestGPTQuoteBoundaryReachesModelRequest(t *testing.T) {
+ quoted := "私聊调试成功\r\n\"第二行🙂\""
+ instruction := "请解释引用中的第二行"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var request struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ t.Error(err)
+ return
+ }
+ if len(request.Messages) != 1 || request.Messages[0].Role != "user" {
+ t.Error("unexpected request messages")
+ return
+ }
+ var content string
+ if err := json.Unmarshal(request.Messages[0].Content, &content); err != nil {
+ t.Error(err)
+ }
+ parts := strings.SplitN(content, "\n", 3)
+ if len(parts) != 3 {
+ t.Error("missing quote boundary")
+ } else {
+ var actual string
+ if err := json.Unmarshal([]byte(parts[1]), &actual); err != nil || actual != quoted {
+ t.Error("model received changed quoted text")
+ }
+ if !strings.HasSuffix(parts[2], "\n"+instruction) {
+ t.Error("model received wrong current instruction")
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"模拟引用回答"}}]}`))
+ }))
+ defer server.Close()
+ cfg := chat.AgentConfig{API: server.URL, Key: "synthetic-key", ModelName: "mock", NoSystemP: true}
+ before := modelstats.Default.Read()
+ got, err := requestLLM(cfg, buildGPTQuery(quoted, instruction), 0.7)
+ if err != nil || got != "模拟引用回答" {
+ t.Fatalf("mock quote request failed: %v %q", err, got)
+ }
+ after := modelstats.Default.Read()
+ if after.Started != before.Started+1 || after.Completed != before.Completed+1 || after.Active != before.Active {
+ t.Fatal("model request was not completely measured")
+ }
+}
+
+func TestRequestQuotaIsolationBoundaryConcurrencyAndFailureRecovery(t *testing.T) {
+ q := newRequestQuota(30*time.Second, 4)
+ now := time.Unix(1000, 0)
+ if !q.Allow(101, now) || q.Allow(101, now.Add(29*time.Second)) || !q.Allow(202, now) || !q.Allow(-123, now) || !q.Allow(-234, now) {
+ t.Fatal("locations or window wrong")
+ }
+ if q.Allow(303, now) || !q.Allow(101, now.Add(30*time.Second)) || !q.Allow(303, now.Add(30*time.Second)) {
+ t.Fatal("quota boundary/pruning wrong")
+ }
+ q = newRequestQuota(30*time.Second, 8)
+ var wg sync.WaitGroup
+ accepted := make(chan bool, 8)
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() { defer wg.Done(); accepted <- q.Allow(101, now) }()
+ }
+ wg.Wait()
+ close(accepted)
+ count := 0
+ for allowed := range accepted {
+ if allowed {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Fatal("simultaneous calls all accepted")
+ }
+ // Failed requests still consume the attempt, but the lock/window recovers.
+ if !q.Allow(101, now.Add(30*time.Second)) {
+ t.Fatal("failed attempt blocked forever")
+ }
+}
+
+func TestModelProtocolDispatchErrorsAndMockRequests(t *testing.T) {
+ for typ := chat.ModelType(0); typ < 3; typ++ {
+ p, err := typ.Protocol("test", 0.7, 0.9, 128, "")
+ if err != nil || p == nil {
+ t.Fatal("supported protocol missing")
+ }
+ }
+ if _, err := requestLLM(chat.AgentConfig{Type: 99}, "hello", 0.7); err == nil {
+ t.Fatal("protocol error swallowed")
+ }
+ if _, err := requestLLM(chat.AgentConfig{Type: 0, API: "http://127.0.0.1:1", ModelName: "test"}, "hello", 0.7); err == nil {
+ t.Fatal("missing key not reported")
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer synthetic-model-key" {
+ t.Error("key not sent correctly")
+ }
+ var request map[string]any
+ if json.NewDecoder(r.Body).Decode(&request) != nil {
+ t.Error("invalid model request")
+ }
+ if request["model"] != "mock-model" {
+ t.Error("wrong model")
+ }
+ messages, _ := request["messages"].([]any)
+ if len(messages) != 2 {
+ t.Error("system prompt missing")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"你好🙂"}}]}`))
+ }))
+ defer server.Close()
+ cfg := chat.AgentConfig{API: server.URL, Key: "synthetic-model-key", ModelName: "mock-model", SystemP: "system"}
+ text, err := requestLLM(cfg, "question", 0.7)
+ if err != nil || text != "你好🙂" {
+ t.Fatalf("mock request failed: %v %q", err, text)
+ }
+ failure := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "synthetic-model-key", 500) }))
+ defer failure.Close()
+ cfg.API = failure.URL
+ _, err = requestLLM(cfg, "question", 0.7)
+ if err == nil || strings.Contains(err.Error(), "synthetic-model-key") {
+ t.Fatal("model failure swallowed or key leaked")
+ }
+ slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(80 * time.Millisecond) }))
+ defer slow.Close()
+ cfg.API = slow.URL
+ if _, err = requestLLMWithClient(cfg, "question", 0.7, &http.Client{Timeout: 10 * time.Millisecond}); err == nil {
+ t.Fatal("timeout not reported")
+ }
+}
diff --git a/plugin/tarot/tarot.go b/plugin/tarot/tarot.go
index ed692575c4..17bbc521a5 100644
--- a/plugin/tarot/tarot.go
+++ b/plugin/tarot/tarot.go
@@ -4,10 +4,12 @@ package tarot
import (
"encoding/json"
"math/rand"
+ "net/http"
"os"
"path"
"strconv"
"strings"
+ "time"
"github.com/FloatTech/floatbox/binary"
fcext "github.com/FloatTech/floatbox/ctxext"
@@ -443,16 +445,21 @@ func (draws drawResults) analyze(question, formationName string, temperature flo
build.WriteString(draw.Description)
build.WriteByte('\n')
}
- topp, maxn := chat.AC.MParams()
- mod, err := chat.AC.Type.Protocol(chat.AC.ModelName, temperature, topp, maxn, chat.AC.ReasoningEffort)
+ cfg, err := chat.CurrentConfig()
+ if err != nil {
+ return "", errors.New("读取 AI 配置失败")
+ }
+ topp, maxn := cfg.MParams()
+ mod, err := cfg.Type.Protocol(cfg.ModelName, temperature, topp, maxn, cfg.ReasoningEffort, cfg.Separator)
if err != nil {
return "", errors.Wrap(err, "创建 AI 模型协议失败")
}
- api := deepinfra.NewAPI(chat.AC.API, string(chat.AC.Key))
+ api := deepinfra.NewAPI(cfg.API, string(cfg.Key))
+ api.SetHTTPClient(&http.Client{Timeout: 30 * time.Second})
data, err := api.Request(mod.User(model.NewContentText(build.String())))
if err != nil {
- return "", errors.Wrap(err, "请求 AI 模型失败")
+ return "", errors.New("请求 AI 模型失败,请检查接口、密钥及网络")
}
return strings.TrimSpace(data), nil
}
diff --git a/plugin/wordcount/chart_test.go b/plugin/wordcount/chart_test.go
new file mode 100644
index 0000000000..05f56a646a
--- /dev/null
+++ b/plugin/wordcount/chart_test.go
@@ -0,0 +1,42 @@
+package wordcount
+
+import (
+ "bytes"
+ "image/png"
+ "testing"
+
+ chart "github.com/wcharczuk/go-chart/v2"
+)
+
+func TestWordCountChartRendersPositiveFrequencies(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ counts []float64
+ }{
+ {"single occurrence", []float64{1}},
+ {"single frequent word", []float64{7}},
+ {"equal single occurrences", []float64{1, 1, 1}},
+ {"equal frequent words", []float64{7, 7, 7}},
+ {"different frequencies", []float64{1, 3, 7}},
+ {"large equal frequencies", []float64{1000000, 1000000}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ bars := make([]chart.Value, len(tc.counts))
+ for i, count := range tc.counts {
+ bars[i] = chart.Value{Label: "word", Value: count}
+ }
+ graph := chart.BarChart{Height: 500, BarWidth: 25, Bars: bars, YAxis: wordCountYAxis(bars)}
+ var output bytes.Buffer
+ if err := graph.Render(chart.PNG, &output); err != nil {
+ t.Fatalf("render actual chart: %v", err)
+ }
+ img, err := png.Decode(bytes.NewReader(output.Bytes()))
+ if err != nil {
+ t.Fatalf("decode actual PNG: %v", err)
+ }
+ if img.Bounds().Dx() <= 0 || img.Bounds().Dy() != 500 {
+ t.Fatalf("invalid image size: %v", img.Bounds())
+ }
+ })
+ }
+}
diff --git a/plugin/wordcount/main.go b/plugin/wordcount/main.go
index dae704d2c8..c3528b02f7 100644
--- a/plugin/wordcount/main.go
+++ b/plugin/wordcount/main.go
@@ -2,7 +2,6 @@
package wordcount
import (
- "bytes"
"fmt"
"os"
"regexp"
@@ -17,8 +16,6 @@ import (
"github.com/tidwall/gjson"
"github.com/wcharczuk/go-chart/v2"
- "github.com/FloatTech/floatbox/binary"
- fcext "github.com/FloatTech/floatbox/ctxext"
"github.com/FloatTech/floatbox/file"
ctrl "github.com/FloatTech/zbpctrl"
"github.com/FloatTech/zbputils/control"
@@ -30,8 +27,7 @@ import (
)
var (
- re = regexp.MustCompile(`^[一-龥]+$`)
- stopwords []string
+ re = regexp.MustCompile(`^[一-龥]+$`)
)
func init() {
@@ -42,33 +38,26 @@ func init() {
PublicDataFolder: "WordCount",
})
cachePath := engine.DataFolder() + "cache/"
- dat, err := file.GetLazyData("data/Chat/dict.txt", control.Md5File, true)
- if err != nil {
- panic(err)
- }
- seg, err := jieba.LoadDictionary(bytes.NewReader(dat))
- if err != nil {
- panic(err)
- }
+ var resources wordResourceCache
_ = os.RemoveAll(cachePath)
_ = os.MkdirAll(cachePath, 0755)
- engine.OnRegex(`^热词\s?(\d*)$`, zero.OnlyGroup, fcext.DoOnceOnSuccess(func(ctx *zero.Ctx) bool {
- _, err := engine.GetLazyData("stopwords.txt", false)
- if err != nil {
- ctx.SendChain(message.Text("ERROR: ", err))
- return false
- }
- data, err := os.ReadFile(engine.DataFolder() + "stopwords.txt")
+ engine.OnRegex(`^热词\s?(\d*)$`, zero.OnlyGroup, func(ctx *zero.Ctx) bool {
+ loaded, words, err := resources.get(func() ([]byte, error) {
+ return file.GetLazyData("data/Chat/dict.txt", control.Md5File, true)
+ }, func() ([]byte, error) {
+ return engine.GetLazyData("stopwords.txt", false)
+ })
if err != nil {
- ctx.SendChain(message.Text("ERROR: ", err))
+ logrus.Warnln("[wordcount]资源加载失败:", err)
+ ctx.SendChain(message.Text("ERROR: 热词资源暂不可用,请稍后重试"))
return false
}
- stopwords = strings.Split(strings.ReplaceAll(binary.BytesToString(data), "\r", ""), "\n")
- sort.Strings(stopwords)
- logrus.Infoln("[wordcount]加载", len(stopwords), "条停用词")
+ ctx.State["wordcount_segmenter"], ctx.State["wordcount_stopwords"] = loaded, words
return true
- })).Limit(ctxext.LimitByUser).SetBlock(true).
+ }).Limit(ctxext.LimitByUser).SetBlock(true).
Handle(func(ctx *zero.Ctx) {
+ seg := ctx.State["wordcount_segmenter"].(*jieba.Segmenter)
+ stopwords := ctx.State["wordcount_stopwords"].([]string)
_, err := file.GetLazyData(text.FontFile, control.Md5File, true)
if err != nil {
ctx.SendChain(message.Text("ERROR: ", err))
@@ -149,6 +138,7 @@ func init() {
Height: 500,
BarWidth: 25,
Bars: bars,
+ YAxis: wordCountYAxis(bars),
}
f, err := os.Create(drawedFile)
if err != nil {
@@ -166,6 +156,18 @@ func init() {
})
}
+// Counts start at zero. The chart library otherwise infers min == max for a
+// single word or equal frequencies and rejects the resulting zero range.
+func wordCountYAxis(bars []chart.Value) chart.YAxis {
+ max := 1.0
+ for _, bar := range bars {
+ if bar.Value > max {
+ max = bar.Value
+ }
+ }
+ return chart.YAxis{Range: &chart.ContinuousRange{Min: 0, Max: max}}
+}
+
func rankByWordCount(wordFrequencies map[string]int) pairlist {
pl := make(pairlist, len(wordFrequencies))
i := 0
diff --git a/plugin/wordcount/resources.go b/plugin/wordcount/resources.go
new file mode 100644
index 0000000000..66fa8a4397
--- /dev/null
+++ b/plugin/wordcount/resources.go
@@ -0,0 +1,54 @@
+package wordcount
+
+import (
+ "bytes"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/fumiama/jieba"
+)
+
+type wordResourceCache struct {
+ mu sync.Mutex
+ seg *jieba.Segmenter
+ words []string
+}
+
+func (c *wordResourceCache) get(readDictionary, readStopwords func() ([]byte, error)) (*jieba.Segmenter, []string, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.seg != nil {
+ return c.seg, c.words, nil
+ }
+ seg, words, err := loadWordResources(readDictionary, readStopwords)
+ if err != nil {
+ return nil, nil, err
+ }
+ c.seg, c.words = seg, words
+ return seg, words, nil
+}
+
+// Publish resources only after both have loaded. Callers can retry failures.
+// This runs on a matching command, never during package initialization.
+func loadWordResources(readDictionary, readStopwords func() ([]byte, error)) (*jieba.Segmenter, []string, error) {
+ dat, err := readDictionary()
+ if err != nil {
+ return nil, nil, fmt.Errorf("分词词典读取失败: %w", err)
+ }
+ if len(bytes.TrimSpace(dat)) == 0 {
+ return nil, nil, fmt.Errorf("分词词典为空")
+ }
+ seg, err := jieba.LoadDictionary(bytes.NewReader(dat))
+ if err != nil {
+ return nil, nil, fmt.Errorf("分词词典解析失败: %w", err)
+ }
+ data, err := readStopwords()
+ if err != nil {
+ return nil, nil, fmt.Errorf("停用词读取失败: %w", err)
+ }
+ words := strings.Split(strings.ReplaceAll(string(data), "\r", ""), "\n")
+ sort.Strings(words)
+ return seg, words, nil
+}
diff --git a/plugin/wordcount/resources_test.go b/plugin/wordcount/resources_test.go
new file mode 100644
index 0000000000..7b20d8c284
--- /dev/null
+++ b/plugin/wordcount/resources_test.go
@@ -0,0 +1,97 @@
+package wordcount
+
+import (
+ "errors"
+ "reflect"
+ "sync"
+ "sync/atomic"
+ "testing"
+)
+
+func TestWordResourcesFailuresDoNotPublishPartialState(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ data []byte
+ err error
+ stopErr error
+ wantStop bool
+ }{
+ {"download EOF", nil, errors.New("EOF"), nil, false},
+ {"missing dictionary", nil, errors.New("missing"), nil, false},
+ {"empty dictionary", []byte(" \n"), nil, nil, false},
+ {"invalid frequency", []byte("测试 invalid n\n"), nil, nil, false},
+ {"missing stopwords", []byte("测试 10 n\n"), nil, errors.New("missing stopwords"), true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ called := false
+ seg, words, err := loadWordResources(func() ([]byte, error) { return tc.data, tc.err }, func() ([]byte, error) { called = true; return nil, tc.stopErr })
+ if err == nil || seg != nil || words != nil || called != tc.wantStop {
+ t.Fatal("failure published resources or proceeded past invalid dictionary")
+ }
+ })
+ }
+}
+
+func TestWordResourcesRetryAndSuccessfulSegmentation(t *testing.T) {
+ attempt := 0
+ read := func() ([]byte, error) {
+ attempt++
+ if attempt == 1 {
+ return nil, errors.New("temporary EOF")
+ }
+ return []byte("测试 100 n\n词典 100 n\n"), nil
+ }
+ stop := func() ([]byte, error) { return []byte("词典\r\n测试"), nil }
+ if _, _, err := loadWordResources(read, stop); err == nil {
+ t.Fatal("first failure unexpectedly passed")
+ }
+ seg, words, err := loadWordResources(read, stop)
+ if err != nil || attempt != 2 || !reflect.DeepEqual(words, []string{"测试", "词典"}) {
+ t.Fatalf("retry or sorted stopwords failed: %v %v", words, err)
+ }
+ if frequency, exists := seg.Frequency("测试"); !exists || frequency <= 0 {
+ t.Fatal("successful retry did not load dictionary")
+ }
+ if got := seg.Cut("测试词典", true); !reflect.DeepEqual(got, []string{"测试", "词典"}) {
+ t.Fatalf("segmentation failed: %v", got)
+ }
+}
+
+func TestWordResourceCacheConcurrentRetryAndReuse(t *testing.T) {
+ var cache wordResourceCache
+ var dictCalls, stopCalls atomic.Int32
+ read := func() ([]byte, error) {
+ if dictCalls.Add(1) == 1 {
+ return nil, errors.New("temporary EOF")
+ }
+ return []byte("测试 100 n\n词典 100 n\n"), nil
+ }
+ stop := func() ([]byte, error) { stopCalls.Add(1); return []byte("词典\n测试"), nil }
+ var wg sync.WaitGroup
+ var failures, successes atomic.Int32
+ for i := 0; i < 20; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ seg, words, err := cache.get(read, stop)
+ if err != nil {
+ failures.Add(1)
+ if seg != nil || words != nil {
+ t.Error("failed call received partial state")
+ }
+ return
+ }
+ if seg == nil || len(words) != 2 {
+ t.Error("successful waiter received no resources")
+ }
+ successes.Add(1)
+ }()
+ }
+ wg.Wait()
+ if failures.Load() != 1 || successes.Load() != 19 || dictCalls.Load() != 2 || stopCalls.Load() != 1 {
+ t.Fatal("concurrent failures/retries did not serialize or cached resources were reloaded")
+ }
+ if _, _, err := cache.get(func() ([]byte, error) { t.Error("cached dictionary read again"); return nil, errors.New("offline") }, func() ([]byte, error) { t.Error("cached stopwords read again"); return nil, errors.New("offline") }); err != nil {
+ t.Fatal("cached resources lost")
+ }
+}
diff --git a/scripts/backup-control.go b/scripts/backup-control.go
new file mode 100644
index 0000000000..a65a3bf7da
--- /dev/null
+++ b/scripts/backup-control.go
@@ -0,0 +1,34 @@
+//go:build ignore
+
+// Consistent private snapshot for a local program upgrade, without schema writes.
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/FloatTech/ZeroBot-Plugin/internal/localadmin"
+ sql "github.com/FloatTech/sqlite"
+)
+
+func main() {
+ info, err := os.Lstat(filepath.FromSlash("data/control/plugins.db"))
+ if err != nil || !info.Mode().IsRegular() {
+ fmt.Fprintln(os.Stderr, "Existing regular control database required")
+ os.Exit(1)
+ }
+ db := sql.New(filepath.FromSlash("data/control/plugins.db"))
+ if err := db.Open(time.Hour); err != nil {
+ fmt.Fprintln(os.Stderr, "Control database could not be opened")
+ os.Exit(1)
+ }
+ defer db.Close()
+ id, err := localadmin.BackupControlDatabase(&db, filepath.FromSlash(".local/admin/backups"))
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "Control database snapshot failed")
+ os.Exit(1)
+ }
+ fmt.Println("CONTROL_BACKUP_ID=" + id)
+}
diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1
new file mode 100644
index 0000000000..c3469d06e1
--- /dev/null
+++ b/scripts/build-windows.ps1
@@ -0,0 +1,36 @@
+[CmdletBinding()]
+param([switch]$RunTests,[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]*\.exe$')][string]$OutputName='zerobot.exe')
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$previousCgo = $env:CGO_ENABLED
+Push-Location $repoRoot
+try {
+ $env:CGO_ENABLED = '0'
+ & go version
+ if ($LASTEXITCODE -ne 0) { throw 'Go toolchain is unavailable.' }
+ & go mod download
+ if ($LASTEXITCODE -ne 0) { throw 'Dependency download failed.' }
+ & go mod verify
+ if ($LASTEXITCODE -ne 0) { throw 'Dependency verification failed.' }
+ & go generate main.go
+ if ($LASTEXITCODE -ne 0) { throw 'Code generation failed.' }
+ New-Item -ItemType Directory -Path '.local/bin' -Force | Out-Null
+ & go build -trimpath -o (Join-Path '.local/bin' $OutputName) .
+ if ($LASTEXITCODE -ne 0) { throw 'Windows build failed.' }
+ if ($RunTests) {
+ # Existing tests in other packages include live services and deliberate failures.
+ # Even these packages may download fonts during package initialization.
+ & go test ./internal/basiccommands ./internal/localadmin ./kanban ./plugin/chess ./plugin/tarot ./plugin/llm -count=1 -timeout=60s
+ if ($LASTEXITCODE -ne 0) { throw 'Selected baseline tests failed.' }
+ Push-Location (Join-Path $repoRoot 'third_party/go-registry')
+ try {
+ & go test ./... -count=1 -timeout=30s
+ if ($LASTEXITCODE -ne 0) { throw 'Registry timeout tests failed.' }
+ } finally { Pop-Location }
+ }
+ Write-Host "Build ready: $repoRoot/.local/bin/$OutputName"
+} finally {
+ $env:CGO_ENABLED = $previousCgo
+ Pop-Location
+}
diff --git a/scripts/check-combined-observation.mjs b/scripts/check-combined-observation.mjs
new file mode 100644
index 0000000000..79f58a594a
--- /dev/null
+++ b/scripts/check-combined-observation.mjs
@@ -0,0 +1,47 @@
+// Saved evidence only. Do not access credentials, networks or running services.
+import path from 'node:path';
+import {pathToFileURL} from 'node:url';
+import {checkEvidence,stamp,writeEvidence} from './check-observation-evidence.mjs';
+import {checkReplyEvidence} from './check-reply-evidence.mjs';
+
+export function combineObservation(model,reply){
+ const result={format:'zerobot-combined-observation-check',version:1,checkedAt:new Date().toISOString(),evidenceValid:false,resourceChecksPassed:false,sameProcessVerified:false,windowsOverlap:false,compatible:false,businessAcceptancePassed:false,issues:[],sources:{},model,reply};
+ const sameResources=['resourceSHA256','samplesSHA256'].every(k=>typeof model.sources?.[k]==='string'&&model.sources[k]===reply.sources?.[k]);
+ result.sources={resourceSHA256:model.sources?.resourceSHA256,samplesSHA256:model.sources?.samplesSHA256,modelSHA256:model.sources?.modelSHA256,beforeReplySHA256:reply.sources?.beforeSHA256,afterReplySHA256:reply.sources?.afterSHA256};
+ result.evidenceValid=model.evidenceValid===true&&reply.evidenceValid===true&&sameResources;
+ result.resourceChecksPassed=model.resourceChecksPassed===true&&reply.resourceChecksPassed===true;
+ result.sameProcessVerified=result.evidenceValid&&model.sameProcessVerified===true&&reply.sameProcessVerified===true;
+ if(!sameResources)result.issues.push('sources:resource-readings-differ-or-missing');
+ if(model.evidenceValid!==true)result.issues.push('model:evidence-invalid');
+ if(reply.evidenceValid!==true)result.issues.push('reply:evidence-invalid');
+ if(model.model&&reply.before&&reply.after){
+ try{
+ const a=stamp(model.model.before.checkedAt,true),b=stamp(model.model.after.checkedAt,true),c=stamp(reply.before.snapshot.checkedAt,true),d=stamp(reply.after.snapshot.checkedAt,true);
+ const start=a.ns>c.ns?a:c,end=b.ns0n;
+ result.overlap={kind:duration>0n?'positive':duration===0n?'boundary-only':'none',...(duration>=0n?{startedAt:start.text,endedAt:end.text,durationNanoseconds:duration.toString(),durationSeconds:Number(duration)/1e9}:{})};
+ }catch{result.evidenceValid=false;result.issues.push('windows:invalid-time');}
+ }
+ if(!result.windowsOverlap)result.issues.push('windows:no-positive-intersection');
+ if(!result.resourceChecksPassed)result.issues.push('resource:checks-not-passed');
+ if(!result.sameProcessVerified)result.issues.push('association:process-not-verified');
+ result.compatible=result.evidenceValid&&result.resourceChecksPassed&&result.sameProcessVerified&&result.windowsOverlap&&model.compatible===true&&reply.compatible===true;
+ result.issues.push('acceptance:real-business-not-verified');return result;
+}
+export async function checkCombinedObservation({resource,model,before,after}){
+ // Each tool reopens the resources. Compare both hashes to reject mid-read replacements.
+ const modelResult=await checkEvidence({resource,model});
+ const replyResult=await checkReplyEvidence({resource,before,after});
+ return combineObservation(modelResult,replyResult);
+}
+export const combinedExitCode=result=>result.evidenceValid!==true?1:result.compatible===true?0:2;
+export async function main(args=process.argv.slice(2)){
+ try{
+ const options={};
+ for(let i=0;itypeof value==='string'&&/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
+if(![2,4].includes(args.length) || args[0]!=='--evidence-prefix' || !validPrefix(args[1]) ||
+ (args.length===4&&(args[2]!=='--ui-evidence-prefix'||!validPrefix(args[3])))) {
+ console.error('Usage: node scripts/check-local-admin-delivery.mjs --evidence-prefix lowercase-name [--ui-evidence-prefix lowercase-name]');
+ process.exit(2);
+}
+const prefix=args[1],uiPrefix=args[3]||prefix,reusedUIEvidence=uiPrefix!==prefix,failures=[],checks={};
+function check(name,passed) {checks[name]=!!passed;if(!passed) failures.push(name);}
+function read(file) {try{return fs.readFileSync(file,'utf8').replace(/^\uFEFF/,'');}catch{failures.push('无法读取 '+file);return null;}}
+function json(file) {const value=read(file);if(value===null)return null;try{return JSON.parse(value);}catch{failures.push('无法解析 '+file);return null;}}
+function hash(file) {try{return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex').toUpperCase();}catch{failures.push('无法核对文件摘要 '+file);return null;}}
+function modified(file) {try{return fs.statSync(file).mtimeMs;}catch{failures.push('无法读取修改时间 '+file);return NaN;}}
+function counts(text) {
+ const stats={};for(const name of ['tests','pass','fail','cancelled','skipped','todo'])stats[name]=Number(text?.match(new RegExp('(?:^|\\n)[^\\n\\w]*'+name+' (\\d+)\\s*(?:\\r?\\n|$)'))?.[1] ?? NaN);
+ return stats;
+}
+const base='.local/'+prefix;
+const uiBase='.local/'+uiPrefix,uiSuffixes=['-ui-tests.txt','-focused-ui.txt','-layout-result.json'];
+const uiEvidenceFiles=uiSuffixes.map(suffix=>({file:uiPrefix+suffix,sha256:hash(uiBase+suffix)}));
+const ui=counts(read(uiBase+'-ui-tests.txt')),focused=counts(read(uiBase+'-focused-ui.txt'));
+for(const [name,stats] of [['界面回归',ui],['新增界面回归',focused]])check(name,Number.isSafeInteger(stats.tests)&&stats.tests>0&&stats.pass===stats.tests&&['fail','cancelled','skipped','todo'].every(key=>stats[key]===0));
+const layout=json(uiBase+'-layout-result.json');
+check('离线布局回归',layout?.passed===true&&layout.offline===true&&layout.networkRequestsAllowed===false&&Array.isArray(layout.measurements)&&layout.measurements.length>0);
+const go=read(base+'-go-tests.txt');
+for(const name of ['internal/localadmin','internal/basiccommands','plugin/llm','plugin/wordcount'])check('Go '+name,!!go&&new RegExp('^ok\\s+github\\.com/FloatTech/ZeroBot-Plugin/'+name+'\\s','m').test(go));
+check('Go 无失败记录',!!go&&!/^FAIL\b/m.test(go));
+const vet=read(base+'-vet.txt');check('vet 无诊断',vet!==null&&vet.trim()==='');
+const build=read(base+'-build.txt');
+const buildHasReadyMarker=!!build&&build.includes('Build ready:');
+const buildSnapshot=build&&!buildHasReadyMarker?json(base+'-before.json'):null;
+const smokeText=read(base+'-smoke.txt'),run=smokeText?.match(/ARTIFACTS=[^\r\n]*[\\/](run-\d+)\s*(?:\r?\n|$)/)?.[1];
+check('整机产物路径',!!run);
+const smoke=run?json('.local/smoke-admin/'+run+'/result.json'):null;
+check('Windows 整机回归',smoke?.passed===true);
+const smokeFlags=Object.entries(smoke||{}).filter(([key,value])=>key.endsWith('Passed')&&typeof value==='boolean');
+check('整机检查标记',smokeFlags.length>0&&smokeFlags.every(([,value])=>value===true));
+const deployment=json(base+'-deployment.json');
+const required=['programMatchesStaged','startupConfigUnchanged','controlDatabaseUnchanged','dailyCredentialUnchanged','napcatProcessPreserved','qqLoggedIn','onebotReadOnlyAPIsPassed','singleZeroBotInstance','processIdentityVerified'];
+for(const key of required)check('原部署 '+key,deployment?.[key]===true);
+check('原部署后台及启动检查',deployment?.adminHTTPStatus===200&&deployment.startupErrorDetected===false);
+const deploymentFlags=Object.entries(deployment||{}).filter(([key,value])=>typeof value==='boolean'&&key!=='startupErrorDetected');
+check('原部署检查标记',deploymentFlags.length>0&&deploymentFlags.every(([,value])=>value===true));
+const live='.local/bin/zerobot.exe',staged='.local/bin/zerobot-'+prefix+'-tested.exe';
+const programSHA256=hash(live),testedSHA256=hash(staged);
+check('构建就绪',!!build&&build.includes('all modules verified')&&(
+ (buildHasReadyMarker&&build.includes('zerobot-'+prefix+'-tested.exe'))||
+ (!buildHasReadyMarker&&/^go version go\d/m.test(build)&&typeof buildSnapshot?.stagedHash==='string'&&
+ /^[A-F0-9]{64}$/.test(buildSnapshot.stagedHash)&&!!testedSHA256&&buildSnapshot.stagedHash===testedSHA256)));
+check('磁盘程序与测试构建一致',!!programSHA256&&programSHA256===testedSHA256);
+const stagedTime=modified(staged),smokeTime=Date.parse(smoke?.completedAt),deploymentTime=Date.parse(deployment?.completedAt);
+check('构建整机部署时间顺序',Number.isFinite(stagedTime)&&Number.isFinite(smokeTime)&&Number.isFinite(deploymentTime)&&stagedTime<=smokeTime&&smokeTime<=deploymentTime);
+check('界面记录先于部署',Number.isFinite(deploymentTime)&&uiSuffixes.every(suffix=>modified(uiBase+suffix)<=deploymentTime));
+
+let linkCount=0,assetMarkerCount=0;const missingLinks=[],missingMarkers=[],assetHashes=[];
+const docs='docs/local-admin',assets='internal/localadmin/assets';
+try {
+ for(const file of fs.readdirSync(docs).filter(name=>name.endsWith('.md'))) {
+ const text=read(path.join(docs,file));if(text===null)continue;
+ for(const match of text.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
+ const target=match[1].split('#')[0];if(!target||/^(?:https?:|app:|codex:)/i.test(target))continue;
+ linkCount++;if(!fs.existsSync(path.resolve(docs,target)))missingLinks.push({file,target});
+ }
+ }
+ for(const file of fs.readdirSync(assets).filter(name=>/\.(?:html|js|css)$/.test(name))) {
+ const target=path.join(assets,file),sha256=hash(target),mtime=modified(target);assetHashes.push({file,sha256});
+ check('资源未晚于构建 '+file,Number.isFinite(stagedTime)&&mtime<=stagedTime);
+ }
+}catch{failures.push('文档或资源目录无法检查');}
+const smokeSource=read('scripts/smoke-local-admin.go');
+for(const match of smokeSource?.matchAll(/\{"\/(.*?)", "(?:text\/html|text\/javascript|text\/css); charset=utf-8", `([^`]+)`\}/g)||[]) {
+ assetMarkerCount++;const file=match[1]||'index.html';
+ if(!/^[A-Za-z0-9_.-]+$/.test(file)){missingMarkers.push({file});continue;}
+ const text=read(path.join(assets,file));if(text===null||!text.includes(match[2]))missingMarkers.push({file,marker:match[2]});
+}
+check('文档链接',linkCount>0&&!missingLinks.length);
+check('整机资源入口',assetMarkerCount>0&&!missingMarkers.length);
+let uiBaseline=null;
+if(reusedUIEvidence) {
+ uiBaseline=json(uiBase+'-ui-baseline.json');
+ check('界面基线来源',uiBaseline?.version===1&&uiBaseline.evidencePrefix===uiPrefix&&
+ Number.isFinite(Date.parse(uiBaseline.checkedAt))&&Number.isFinite(Date.parse(uiBaseline.sourceTestTimes?.deployment))&&
+ Date.parse(uiBaseline.sourceTestTimes.deployment)<=deploymentTime);
+ const validHashes=entries=>Array.isArray(entries)&&entries.every(entry=>typeof entry?.file==='string'&&/^[A-Za-z0-9_.-]+$/.test(entry.file)&&typeof entry.sha256==='string'&&/^[A-F0-9]{64}$/.test(entry.sha256))&&new Set(entries.map(entry=>entry.file)).size===entries.length;
+ const sameHashes=(expected,actual)=>validHashes(expected)&&expected.length===actual.length&&actual.every(entry=>entry.sha256&&expected.some(old=>old.file===entry.file&&old.sha256===entry.sha256));
+ check('原界面证据摘要一致',sameHashes(uiBaseline?.uiEvidenceFiles,uiEvidenceFiles));
+ check('全部界面资源保持一致',assetHashes.length>0&&sameHashes(uiBaseline?.assetHashes,assetHashes));
+}
+const result={checkedAt:new Date().toISOString(),passed:!failures.length,evidencePrefix:prefix,uiEvidencePrefix:uiPrefix,reusedUIEvidence,buildReadyMarkerRecorded:buildHasReadyMarker,buildHashFromUpgradeSnapshot:buildSnapshot?.stagedHash||null,testsExecuted:false,serviceAccessed:false,programSHA256,testedSHA256,sourceTestTimes:{smoke:smoke?.completedAt||null,deployment:deployment?.completedAt||null,uiBaseline:uiBaseline?.checkedAt||null,uiDeployment:uiBaseline?.sourceTestTimes?.deployment||deployment?.completedAt||null},ui,focused,layoutChecks:layout?.measurements?.length||0,smokeRun:run||null,linkCount,assetMarkerCount,checks,failures,missingLinks,missingMarkers,assetHashes,uiEvidenceFiles};
+fs.mkdirSync('.local',{recursive:true});fs.writeFileSync('.local/current-delivery-evidence.json',JSON.stringify(result,null,2)+'\n');
+if(result.passed&&!reusedUIEvidence) {
+ fs.writeFileSync(uiBase+'-ui-baseline.json',JSON.stringify({version:1,evidencePrefix:uiPrefix,checkedAt:result.checkedAt,sourceTestTimes:result.sourceTestTimes,uiEvidenceFiles,assetHashes},null,2)+'\n');
+}
+console.log(JSON.stringify({passed:result.passed,evidencePrefix:prefix,uiEvidencePrefix:uiPrefix,reusedUIEvidence,uiTests:ui.tests,layoutChecks:result.layoutChecks,links:linkCount,assetMarkers:assetMarkerCount,testsExecuted:false,serviceAccessed:false,failures}));
+process.exitCode=result.passed?0:1;
diff --git a/scripts/check-observation-evidence.mjs b/scripts/check-observation-evidence.mjs
new file mode 100644
index 0000000000..90406f36b5
--- /dev/null
+++ b/scripts/check-observation-evidence.mjs
@@ -0,0 +1,223 @@
+// Offline evidence validation. No network, credentials, service control or source rewriting.
+import {createReadStream} from 'node:fs';
+import {open} from 'node:fs/promises';
+import {createHash} from 'node:crypto';
+import path from 'node:path';
+import {pathToFileURL} from 'node:url';
+
+const kinds=['gpt','chat','agent','vision'];
+const counts=['started','active','completed','failed','incomplete'];
+const ended=['started','completed','failed','incomplete'];
+const deltaKeys=[...ended,'activeAtStart','activeAtEnd'];
+const MAX_JSON=1024*1024, MAX_SAMPLES=64*1024*1024;
+class InvalidEvidence extends Error { constructor(code){super(code);this.code=code;} }
+function requireEvidence(condition,code){if(!condition)throw new InvalidEvidence(code);}
+const integer=value=>Number.isSafeInteger(value)&&value>=0;
+const nonnegative=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0;
+const sum=(row,keys)=>keys.reduce((n,key)=>n+BigInt(row[key]),0n);
+const close=(a,b)=>typeof a==='number'&&typeof b==='number'&&Number.isFinite(a)&&Number.isFinite(b)&&Math.abs(a-b)<=Math.max(1e-9,Math.abs(b)*1e-10);
+
+export function stamp(value,utcOnly=false){
+ requireEvidence(typeof value==='string','invalid-time');
+ const m=value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/);
+ requireEvidence(m&&(!utcOnly||m[8]==='Z'),'invalid-time');
+ const [year,month,day,hour,minute,second]=m.slice(1,7).map(Number);
+ requireEvidence(year>=2000&&month>=1&&month<=12&&day>=1&&hour<=23&&minute<=59&&second<=59,'invalid-time');
+ const base=Date.UTC(year,month-1,day,hour,minute,second),d=new Date(base);
+ requireEvidence(d.getUTCFullYear()===year&&d.getUTCMonth()===month-1&&d.getUTCDate()===day,'invalid-time');
+ const offsetHour=Number(m[10]||0),offsetMinute=Number(m[11]||0);
+ requireEvidence(offsetHour<=14&&offsetMinute<=59&&(offsetHour!==14||offsetMinute===0),'invalid-time');
+ const offset=(offsetHour*60+offsetMinute)*60000*(m[9]==='-'?-1:1);
+ const fraction=(m[7]||'').padEnd(9,'0'),ns=BigInt(base-offset)*1000000n+BigInt(fraction);
+ return {ns,ms:base-offset+Number(fraction.slice(0,3)),text:new Date(base-offset).toISOString().slice(0,19)+'.'+fraction+'Z'};
+}
+
+function row(value){
+ requireEvidence(value&&counts.every(key=>integer(value[key])),'invalid-model-counts');
+ requireEvidence(BigInt(value.started)===sum(value,['active','completed','failed','incomplete']),'model-count-conservation');
+ requireEvidence(nonnegative(value.averageMilliseconds)&&nonnegative(value.maxMilliseconds)&&value.averageMilliseconds<=value.maxMilliseconds,'invalid-model-latency');
+ requireEvidence(sum(value,['completed','failed','incomplete'])!==0n||(value.averageMilliseconds===0&&value.maxMilliseconds===0),'invalid-model-latency');
+ return Object.fromEntries([...counts,'averageMilliseconds','maxMilliseconds'].map(key=>[key,value[key]]));
+}
+function categories(value,copy){
+ requireEvidence(Array.isArray(value)&&value.length===4,'invalid-model-categories');
+ return kinds.map(kind=>{
+ const matches=value.filter(item=>item?.kind===kind);
+ requireEvidence(matches.length===1,'invalid-model-categories');
+ return {kind,...copy(matches[0])};
+ });
+}
+function snapshot(value,withProcess=false){
+ const totals=row(value),start=stamp(value.startedAt,true),checked=stamp(value.checkedAt,true);
+ requireEvidence(checked.ns>=start.ns,'model-time-order');
+ const rows=categories(value.categories,row);
+ requireEvidence(counts.every(key=>BigInt(totals[key])===rows.reduce((n,r)=>n+BigInt(r[key]),0n)),'model-category-totals');
+ let process;
+ if(withProcess){
+ const p=value.process;
+ requireEvidence(p&&integer(p.processId)&&p.processId>0&&typeof p.programSHA256==='string'&&/^[a-fA-F0-9]{64}$/.test(p.programSHA256),'invalid-model-process');
+ const processStart=stamp(p.processStartedAt,true);
+ requireEvidence(processStart.ns<=start.ns,'model-process-time-order');
+ process={processId:p.processId,processStartedAt:processStart.text,programSHA256:p.programSHA256.toUpperCase()};
+ }
+ return {startedAt:start.text,checkedAt:checked.text,...totals,categories:rows,...(process?{process}:{})};
+}
+function delta(before,after){
+ requireEvidence(before.startedAt===after.startedAt,'model-collector-changed');
+ requireEvidence(JSON.stringify(before.process||null)===JSON.stringify(after.process||null),'model-process-changed');
+ requireEvidence(stamp(after.checkedAt).ns>=stamp(before.checkedAt).ns,'model-time-order');
+ requireEvidence(before.checkedAt!==after.checkedAt||JSON.stringify(before)===JSON.stringify(after),'model-same-time-changed');
+ const compare=(a,b)=>{
+ requireEvidence(ended.every(key=>b[key]>=a[key])&&b.maxMilliseconds>=a.maxMilliseconds,'model-counter-rollback');
+ const value=Object.fromEntries(ended.map(key=>[key,b[key]-a[key]]));
+ requireEvidence(BigInt(value.started)+BigInt(a.active)===sum(value,['completed','failed','incomplete'])+BigInt(b.active),'model-window-conservation');
+ return {...value,activeAtStart:a.active,activeAtEnd:b.active};
+ };
+ return {durationSeconds:(stamp(after.checkedAt).ms-stamp(before.checkedAt).ms)/1000,...compare(before,after),categories:kinds.map((kind,i)=>({kind,...compare(before.categories[i],after.categories[i])}))};
+}
+export function validateModel(value){
+ requireEvidence(value?.format==='zerobot-model-observation'&&[1,2].includes(value.version),'unsupported-model-format');
+ requireEvidence(value.scope?.kind==='model-http-exchanges'&&value.scope.latencyScope==='process-cumulative'&&value.scope.businessAcceptancePassed===false,'invalid-model-scope');
+ const before=snapshot(value.before,value.version===2),after=snapshot(value.after,value.version===2),window=delta(before,after),exported=stamp(value.exportedAt,true);
+ requireEvidence(exported.ns>=stamp(after.checkedAt).ns,'model-export-time-order');
+ const copyDelta=v=>{requireEvidence(v&&deltaKeys.every(key=>integer(v[key])),'invalid-model-window');return Object.fromEntries(deltaKeys.map(key=>[key,v[key]]));};
+ const actual={durationSeconds:value.window?.durationSeconds,...copyDelta(value.window),categories:categories(value.window?.categories,copyDelta)};
+ requireEvidence(JSON.stringify(actual)===JSON.stringify(window),'model-window-mismatch');
+ requireEvidence(!['averageMilliseconds','maxMilliseconds'].some(key=>Object.hasOwn(value.window,key)||value.window.categories.some(r=>Object.hasOwn(r,key))),'model-window-latency-scope');
+ return {version:value.version,exportedAt:exported.text,before,after,window};
+}
+
+async function readJSON(filename){
+ const chunks=[],hash=createHash('sha256');let size=0;
+ for await(const chunk of createReadStream(filename)){
+ size+=chunk.length;requireEvidence(size<=MAX_JSON,'json-size-limit');chunks.push(chunk);hash.update(chunk);
+ }
+ return {value:JSON.parse(Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/,'')),sha256:hash.digest('hex')};
+}
+async function readSamples(filename){
+ const samples=[],hash=createHash('sha256');let pending=Buffer.alloc(0),size=0;
+ const line=buffer=>{
+ requireEvidence(buffer.length<=4096,'sample-line-limit');
+ const text=buffer.toString('utf8').replace(/\r$/,'');
+ requireEvidence(text.trim().length>0,'empty-sample-line');
+ requireEvidence(samples.length<100000,'sample-count-limit');
+ const value=JSON.parse(text);
+ requireEvidence(value&&nonnegative(value.atSeconds)&&nonnegative(value.cpuSeconds)&&['privateBytes','workingSetBytes','threadCount','handleCount'].every(key=>integer(value[key])),'invalid-sample-values');
+ const time=stamp(value.at);
+ samples.push({at:time.text,ns:time.ns,...Object.fromEntries(['atSeconds','cpuSeconds','privateBytes','workingSetBytes','threadCount','handleCount'].map(key=>[key,value[key]]))});
+ };
+ for await(const chunk of createReadStream(filename)){
+ size+=chunk.length;requireEvidence(size<=MAX_SAMPLES,'sample-size-limit');hash.update(chunk);
+ const buffer=Buffer.concat([pending,chunk]);let offset=0,index;
+ while((index=buffer.indexOf(10,offset))!==-1){line(buffer.subarray(offset,index));offset=index+1;}
+ pending=buffer.subarray(offset);requireEvidence(pending.length<=4096,'sample-line-limit');
+ }
+ if(pending.length)line(pending);
+ requireEvidence(samples.length>=2,'sample-count-insufficient');
+ return {samples,sha256:hash.digest('hex')};
+}
+
+export function validateResource(meta,samples){
+ requireEvidence(meta&&integer(meta.processId)&&meta.processId>0&&typeof meta.programSHA256==='string'&&/^[A-Fa-f0-9]{64}$/.test(meta.programSHA256),'invalid-resource-identity');
+ const start=stamp(meta.startedAt),end=stamp(meta.completedAt),processStart=stamp(meta.processStartedAt);
+ requireEvidence(processStart.ns<=start.ns&&start.ns<=samples[0].ns&&samples.at(-1).ns<=end.ns,'resource-time-order');
+ requireEvidence(integer(meta.requestedDurationSeconds)&&meta.requestedDurationSeconds>=10&&meta.requestedDurationSeconds<=86400&&integer(meta.intervalSeconds)&&meta.intervalSeconds>=1&&meta.intervalSeconds<=30&&integer(meta.logicalCPUs)&&meta.logicalCPUs>0,'invalid-resource-parameters');
+ requireEvidence(nonnegative(meta.thresholds?.cpuAverageCapacityPercent)&&meta.thresholds.cpuAverageCapacityPercent>0&&nonnegative(meta.thresholds.privateGrowthMiB)&&meta.thresholds.privateGrowthMiB>0,'invalid-resource-thresholds');
+ requireEvidence(['completed','passed','identityStable','programUnchanged','noNewErrorLines'].every(key=>typeof meta[key]==='boolean'),'invalid-resource-flags');
+ requireEvidence(['duration-complete','stop-request','process-identity-changed','observation-error'].includes(meta.stopReason)&&meta.completed===(meta.stopReason==='duration-complete'),'invalid-resource-stop-reason');
+ requireEvidence(meta.realQQMessagesSent===false&&meta.modelCalls===0&&meta.businessLoadMeasured===false&&meta.businessAcceptancePassed===false&&meta.messageContentsSaved===false&&meta.servicesStartedOrStopped===false,'invalid-resource-scope');
+ const log=meta.logCounts,logKeys=['appendedLines','warningLines','errorLines','fatalLines','panicLines','otherLines','bytesRead','unreadBytes','partialLines'];
+ requireEvidence(log&&logKeys.every(key=>integer(log[key]))&&typeof log.logTruncated==='boolean'&&typeof log.coverageComplete==='boolean','invalid-log-summary');
+ requireEvidence(BigInt(log.appendedLines)===sum(log,['warningLines','errorLines','fatalLines','panicLines','otherLines'])&&log.appendedLines<=log.bytesRead,'log-count-conservation');
+ requireEvidence(log.coverageComplete===(!log.logTruncated&&log.unreadBytes===0&&log.partialLines===0)&&meta.noNewErrorLines===(log.errorLines===0&&log.fatalLines===0&&log.panicLines===0),'log-flags-mismatch');
+ const first=samples[0],last=samples.at(-1);let gapMax=0,cpuPeak=0,clockDrift=0;
+ let privateMax=first.privateBytes,workingMax=first.workingSetBytes,threadMax=first.threadCount,handleMax=first.handleCount;
+ for(let i=0;i0&&used>=0&&r.ns>prev.ns,'sample-counter-or-time-rollback');
+ gapMax=Math.max(gapMax,gap);cpuPeak=Math.max(cpuPeak,used/gap/meta.logicalCPUs*100);
+ }
+ }
+ const span=last.atSeconds-first.atSeconds,cpuAverage=(last.cpuSeconds-first.cpuSeconds)/span/meta.logicalCPUs*100,growth=(privateMax-first.privateBytes)/1048576;
+ const resources={sampleCount:samples.length,sampleSpanSeconds:span,maxSampleGapSeconds:gapMax,cpuAveragePercent:cpuAverage,cpuPeakSamplePercent:cpuPeak,privateFirstBytes:first.privateBytes,privateMaxBytes:privateMax,privateLastBytes:last.privateBytes,privateGrowthMiB:growth,workingSetMaxBytes:workingMax,threadMax,handleMax,metricsValid:true,completeWindow:span>=meta.requestedDurationSeconds,cadencePassed:gapMax<=2*meta.intervalSeconds+0.5&&samples.length>=Math.ceil(meta.requestedDurationSeconds/meta.intervalSeconds*0.8)+1,resourceThresholdsPassed:cpuAverage<=meta.thresholds.cpuAverageCapacityPercent&&growth<=meta.thresholds.privateGrowthMiB,fullTiming:meta.requestedDurationSeconds>=900};
+ const mismatches=Object.keys(resources).filter(key=>typeof resources[key]==='boolean'?meta.resources?.[key]!==resources[key]:integer(resources[key])&&['sampleCount','privateFirstBytes','privateMaxBytes','privateLastBytes','workingSetMaxBytes','threadMax','handleMax'].includes(key)?meta.resources?.[key]!==resources[key]:!close(meta.resources?.[key],resources[key]));
+ const passed=meta.completed&&meta.identityStable&&meta.programUnchanged&&resources.fullTiming&&resources.completeWindow&&resources.cadencePassed&&resources.resourceThresholdsPassed&&meta.noNewErrorLines&&log.coverageComplete&&meta.stopReason!=='observation-error';
+ if(meta.passed!==passed)mismatches.push('passed');
+ return {processId:meta.processId,processStartedAt:processStart.text,programSHA256:meta.programSHA256.toUpperCase(),firstSampleAt:first.at,lastSampleAt:last.at,requestedDurationSeconds:meta.requestedDurationSeconds,intervalSeconds:meta.intervalSeconds,logicalCPUs:meta.logicalCPUs,thresholds:{cpuAverageCapacityPercent:meta.thresholds.cpuAverageCapacityPercent,privateGrowthMiB:meta.thresholds.privateGrowthMiB},stopReason:meta.stopReason,resources,completed:meta.completed,identityStable:meta.identityStable,programUnchanged:meta.programUnchanged,logCounts:Object.fromEntries([...logKeys,'logTruncated','coverageComplete'].map(key=>[key,log[key]])),recordedSummaryMatches:mismatches.length===0,summaryMismatches:mismatches,clockConsistent:clockDrift<=1,maxClockDriftSeconds:clockDrift,resourceChecksPassed:passed};
+}
+
+async function loadResourceEvidence(output,resource){
+ try {
+ const input=await readJSON(path.join(resource,'result.json'));output.sources.resourceSHA256=input.sha256;
+ const raw=await readSamples(path.join(resource,'samples.jsonl'));output.sources.samplesSHA256=raw.sha256;
+ const result=validateResource(input.value,raw.samples);output.resource=result;output.resourceChecksPassed=result.resourceChecksPassed;
+ if(!result.recordedSummaryMatches)output.issues.push('resource:recorded-summary-mismatch');
+ return result;
+ }catch(error){output.issues.push('resource:'+(error instanceof InvalidEvidence?error.code:'file-or-json-unreadable'));return null;}
+}
+
+export async function checkResourceEvidence({resource}){
+ const output={format:'zerobot-observation-evidence-check',version:1,mode:'resource-only',checkedAt:new Date().toISOString(),evidenceValid:false,resourceChecksPassed:false,resourceObservationPassed:false,temporalCoverage:'unavailable',compatible:false,sameProcessVerified:false,businessAcceptancePassed:false,issues:[],sources:{}};
+ const result=await loadResourceEvidence(output,resource);
+ output.evidenceValid=!!result&&result.recordedSummaryMatches;
+ output.resourceObservationPassed=output.evidenceValid&&output.resourceChecksPassed&&result.clockConsistent;
+ if(result&&!result.clockConsistent)output.issues.push('resource:sample-clock-drift');
+ if(result&&!output.resourceChecksPassed)output.issues.push('resource:checks-not-passed');
+ output.issues.push('association:model-report-not-provided','acceptance:real-business-not-verified');
+ return output;
+}
+
+export async function checkEvidence({resource,model}){
+ const output={format:'zerobot-observation-evidence-check',version:1,checkedAt:new Date().toISOString(),evidenceValid:false,resourceChecksPassed:false,temporalCoverage:'unavailable',compatible:false,sameProcessVerified:false,businessAcceptancePassed:false,issues:[],sources:{}};
+ const failure=(scope,error)=>output.issues.push(scope+':'+(error instanceof InvalidEvidence?error.code:'file-or-json-unreadable'));
+ const [resourceInput,modelInput]=await Promise.allSettled([loadResourceEvidence(output,resource),readJSON(model)]);
+ const resourceResult=resourceInput.status==='fulfilled'?resourceInput.value:null;let modelResult;
+ if(resourceInput.status==='rejected')failure('resource',resourceInput.reason);
+ if(modelInput.status==='fulfilled'){
+ output.sources.modelSHA256=modelInput.value.sha256;
+ try{modelResult=validateModel(modelInput.value.value);output.model=modelResult;}catch(error){failure('model',error);}
+ }else failure('model',modelInput.reason);
+ output.evidenceValid=!!resourceResult&&!!modelResult&&resourceResult.recordedSummaryMatches;
+ if(resourceResult&&modelResult){
+ const a=stamp(modelResult.before.checkedAt).ns,b=stamp(modelResult.after.checkedAt).ns,first=stamp(resourceResult.firstSampleAt).ns,last=stamp(resourceResult.lastSampleAt).ns;
+ output.temporalCoverage=blast?'none':modelResult.window.durationSeconds===0?'zero-window':a>=first&&b<=last?'inside':'partial';
+ const collectorPossible=stamp(modelResult.before.startedAt).ns>=stamp(resourceResult.processStartedAt).ns;
+ if(output.temporalCoverage!=='inside')output.issues.push('association:window-'+output.temporalCoverage);
+ if(!collectorPossible)output.issues.push('association:collector-before-process');
+ if(!resourceResult.clockConsistent)output.issues.push('association:sample-clock-drift');
+ if(!resourceResult.resourceChecksPassed)output.issues.push('resource:checks-not-passed');
+ output.compatible=output.evidenceValid&&output.resourceChecksPassed&&output.temporalCoverage==='inside'&&collectorPossible&&resourceResult.clockConsistent;
+ if(modelResult.version===2){
+ const p=modelResult.before.process;
+ output.processComparison={processIdMatches:p.processId===resourceResult.processId,processStartedAtMatches:p.processStartedAt===resourceResult.processStartedAt,programSHA256Matches:p.programSHA256===resourceResult.programSHA256};
+ const matches=Object.values(output.processComparison).every(Boolean);
+ output.sameProcessVerified=output.evidenceValid&&matches&&resourceResult.identityStable&&resourceResult.programUnchanged;
+ if(!matches)output.issues.push('association:process-identity-mismatch');
+ if(!resourceResult.identityStable||!resourceResult.programUnchanged)output.issues.push('association:resource-process-not-stable');
+ output.compatible=output.compatible&&output.sameProcessVerified;
+ }
+ }
+ if(modelResult?.version!==2)output.issues.push('association:model-report-has-no-process-identity');
+ output.issues.push('acceptance:real-business-not-verified');
+ return output;
+}
+
+export function evidenceExitCode(result){return !result.evidenceValid?1:(result.mode==='resource-only'?result.resourceObservationPassed:result.compatible)?0:2;}
+export async function writeEvidence(filename,result){
+ // Exclusive creation protects existing output, inputs and symlink targets alike.
+ const handle=await open(filename,'wx',0o600);
+ try{await handle.writeFile(JSON.stringify(result,null,2)+'\n','utf8');}finally{await handle.close();}
+}
+export async function main(args=process.argv.slice(2)){
+ try{
+ const options={};
+ for(let i=0;ik!=='active');
+const times=['averageFirstReplyMilliseconds','maxFirstReplyMilliseconds','averageLastReplyMilliseconds','maxLastReplyMilliseconds'];
+const boundary='bot-matcher-entry-to-onebot-send-receipt';
+class InvalidReply extends Error {constructor(code){super(code);this.code=code;}}
+const requireReply=(condition,code)=>{if(!condition)throw new InvalidReply(code);};
+const integer=v=>Number.isSafeInteger(v)&&v>=0;
+const sum=(v,keys)=>keys.reduce((n,k)=>n+BigInt(v[k]),0n);
+const close=(a,b)=>Math.abs(a-b)<=Math.max(1e-9,Math.abs(b)*1e-10);
+function utc(value){try{return stamp(value,true);}catch{throw new InvalidReply('invalid-reply-time');}}
+function row(value){
+ requireReply(value&&counts.every(k=>integer(value[k])),'invalid-reply-counts');
+ requireReply(BigInt(value.started)===sum(value,['active','completed','failed','noReply'])&&BigInt(value.replyAttempts)===sum(value,['replyAccepted','replyUnconfirmed']),'reply-count-conservation');
+ requireReply(value.missingIngress<=value.started&&value.completed<=value.withReply&&BigInt(value.withReply)<=sum(value,['completed','failed'])&&value.withReply<=value.replyAccepted,'invalid-reply-denominator');
+ requireReply((value.replyAccepted===0||value.withReply>0||value.active>0)&&(value.replyUnconfirmed===0||value.failed>0||value.active>0),'reply-receipts-without-task');
+ requireReply(times.every(k=>typeof value[k]==='number'&&Number.isFinite(value[k])&&value[k]>=0)&&value.averageFirstReplyMilliseconds<=value.maxFirstReplyMilliseconds&&value.averageLastReplyMilliseconds<=value.maxLastReplyMilliseconds&&value.averageLastReplyMilliseconds>=value.averageFirstReplyMilliseconds&&value.maxLastReplyMilliseconds>=value.maxFirstReplyMilliseconds,'invalid-reply-latency');
+ requireReply(value.withReply!==0||times.every(k=>value[k]===0),'empty-reply-latency');
+ return Object.fromEntries([...counts,...times].map(k=>[k,value[k]]));
+}
+export function validateReplyReport(value){
+ requireReply(value?.format==='zerobot-reply-statistics'&&value.version===1,'unsupported-reply-format');
+ requireReply(value.scope?.kind==='ai-reply-tasks'&&value.scope.latencyScope==='process-cumulative'&&value.scope.timingBoundary===boundary&&value.scope.businessAcceptancePassed===false,'invalid-reply-scope');
+ requireReply(!Object.hasOwn(value,'window'),'unexpected-reply-window');
+ const source=value.snapshot,total=row(source),started=utc(source.startedAt),checked=utc(source.checkedAt),exported=utc(value.exportedAt);
+ requireReply(source.scope===boundary&&started.ns<=checked.ns&&checked.ns<=exported.ns,'reply-time-or-boundary');
+ requireReply(Array.isArray(source.categories)&&source.categories.length===3,'invalid-reply-categories');
+ const categories=kinds.map(kind=>{const matches=source.categories.filter(x=>x?.kind===kind);requireReply(matches.length===1,'invalid-reply-categories');return {kind,...row(matches[0])};});
+ requireReply(counts.every(k=>BigInt(total[k])===categories.reduce((n,r)=>n+BigInt(r[k]),0n)),'reply-category-totals');
+ for(const k of ['maxFirstReplyMilliseconds','maxLastReplyMilliseconds'])requireReply(total[k]===Math.max(...categories.map(r=>r[k])),'reply-category-latency');
+ for(const k of ['averageFirstReplyMilliseconds','averageLastReplyMilliseconds']){
+ const average=total.withReply?categories.reduce((n,r)=>n+r[k]*(r.withReply/total.withReply),0):0;
+ requireReply(Number.isFinite(average)&&close(total[k],average),'reply-category-latency');
+ }
+ let process;
+ if(source.process!=null){
+ const p=source.process;requireReply(integer(p.processId)&&p.processId>0&&typeof p.programSHA256==='string'&&/^[a-fA-F0-9]{64}$/.test(p.programSHA256),'invalid-reply-process');
+ const created=utc(p.processStartedAt);requireReply(created.ns<=started.ns,'reply-process-time-order');
+ process={processId:p.processId,processStartedAt:created.text,programSHA256:p.programSHA256.toUpperCase()};
+ }
+ return {exportedAt:exported.text,snapshot:{startedAt:started.text,checkedAt:checked.text,scope:boundary,...total,categories,...(process?{process}:{})}};
+}
+export function replyDelta(before,after){
+ requireReply(before.startedAt===after.startedAt,'reply-collector-changed');
+ requireReply(JSON.stringify(before.process||null)===JSON.stringify(after.process||null),'reply-process-changed');
+ const duration=utc(after.checkedAt).ns-utc(before.checkedAt).ns;
+ requireReply(duration>=0n,'reply-reading-time-reversed');
+ requireReply(duration!==0n||JSON.stringify(before)===JSON.stringify(after),'reply-same-time-changed');
+ const compare=(a,b)=>{
+ requireReply(cumulative.every(k=>b[k]>=a[k])&&b.maxFirstReplyMilliseconds>=a.maxFirstReplyMilliseconds&&b.maxLastReplyMilliseconds>=a.maxLastReplyMilliseconds,'reply-counter-or-maximum-rollback');
+ const d=Object.fromEntries(cumulative.map(k=>[k,b[k]-a[k]]));
+ requireReply(BigInt(d.started)+BigInt(a.active)===sum(d,['completed','failed','noReply'])+BigInt(b.active),'reply-window-conservation');
+ requireReply(d.missingIngress<=d.started&&d.completed<=d.withReply&&BigInt(d.withReply)<=sum(d,['completed','failed'])&&BigInt(d.replyAttempts)===sum(d,['replyAccepted','replyUnconfirmed']),'reply-window-denominator');
+ requireReply((d.replyAccepted===0||d.withReply>0||b.active>0)&&(d.replyUnconfirmed===0||d.failed>0||b.active>0),'reply-window-receipts-without-task');
+ requireReply(d.withReply!==0||times.every(k=>a[k]===b[k]),'reply-latency-without-ended-reply');
+ if(d.withReply>0){
+ for(const [average,maximum] of [['averageFirstReplyMilliseconds','maxFirstReplyMilliseconds'],['averageLastReplyMilliseconds','maxLastReplyMilliseconds']]){
+ const lower=a[average]*(a.withReply/b.withReply),upper=lower+b[maximum]*(d.withReply/b.withReply);
+ requireReply((b[average]>=lower||close(b[average],lower))&&(b[average]<=upper||close(b[average],upper)),'reply-cumulative-latency-inconsistent');
+ }
+ const prior=a.withReply/b.withReply,first=b.averageFirstReplyMilliseconds-a.averageFirstReplyMilliseconds*prior,last=b.averageLastReplyMilliseconds-a.averageLastReplyMilliseconds*prior;
+ const tolerance=Math.max(1e-9,...[a.averageFirstReplyMilliseconds,a.averageLastReplyMilliseconds,b.averageFirstReplyMilliseconds,b.averageLastReplyMilliseconds].map(x=>Math.abs(x)*1e-10));
+ requireReply(last>=first||first-last<=tolerance,'reply-incremental-first-last-order');
+ }
+ return {...d,activeAtStart:a.active,activeAtEnd:b.active};
+ };
+ return {durationSeconds:Number(duration)/1e9,durationNanoseconds:duration.toString(),...compare(before,after),categories:kinds.map((kind,i)=>({kind,...compare(before.categories[i],after.categories[i])}))};
+}
+async function readReply(filename){
+ const chunks=[],hash=createHash('sha256');let size=0;
+ for await(const chunk of createReadStream(filename)){size+=chunk.length;requireReply(size<=1024*1024,'reply-json-size-limit');chunks.push(chunk);hash.update(chunk);}
+ return {value:JSON.parse(Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/,'')),sha256:hash.digest('hex')};
+}
+export async function checkReplyEvidence({before,after,resource}){
+ const output={format:'zerobot-reply-evidence-check',version:1,checkedAt:new Date().toISOString(),snapshotsValid:false,replyWindowValid:false,evidenceValid:false,replyObservationPassed:false,resourceProvided:resource!==undefined,resourceChecksPassed:false,temporalCoverage:'unavailable',snapshotProcessMatches:false,sameProcessVerified:false,compatible:false,businessAcceptancePassed:false,issues:[],sources:{}};
+ const failure=(name,error)=>output.issues.push(name+':'+(error instanceof InvalidReply?error.code:'file-or-json-unreadable'));
+ const inputs=await Promise.allSettled([readReply(before),readReply(after)]);const reports=[];
+ for(let i=0;i<2;i++){
+ const name=i===0?'before':'after',input=inputs[i];
+ if(input.status==='rejected'){failure(name,input.reason);continue;}
+ output.sources[name+'SHA256']=input.value.sha256;
+ try{reports[i]=validateReplyReport(input.value.value);output[name]=reports[i];}catch(error){failure(name,error);}
+ }
+ output.snapshotsValid=!!reports[0]&&!!reports[1];
+ if(output.snapshotsValid){
+ try{
+ output.window=replyDelta(reports[0].snapshot,reports[1].snapshot);output.replyWindowValid=true;
+ output.snapshotProcessMatches=!!reports[0].snapshot.process;
+ if(output.window.durationNanoseconds==='0')output.issues.push('reply:zero-window');
+ if(!output.snapshotProcessMatches)output.issues.push('reply:process-identity-unavailable');
+ }catch(error){failure('pair',error);}
+ }
+ output.evidenceValid=output.replyWindowValid;
+ const usable=output.replyWindowValid&&output.snapshotProcessMatches&&output.window.durationNanoseconds!=='0';
+ output.replyObservationPassed=usable;
+ if(resource!==undefined){
+ const checked=await checkResourceEvidence({resource});
+ output.sources.resourceSHA256=checked.sources.resourceSHA256;output.sources.samplesSHA256=checked.sources.samplesSHA256;
+ if(checked.resource)output.resource=checked.resource;
+ output.resourceChecksPassed=checked.resourceChecksPassed;
+ output.issues.push(...checked.issues.filter(x=>x.startsWith('resource:')));
+ output.evidenceValid=output.replyWindowValid&&checked.evidenceValid;
+ if(output.replyWindowValid&&checked.resource){
+ const r=checked.resource,a=utc(reports[0].snapshot.checkedAt).ns,b=utc(reports[1].snapshot.checkedAt).ns,first=stamp(r.firstSampleAt).ns,last=stamp(r.lastSampleAt).ns;
+ output.temporalCoverage=blast?'none':a===b?'zero-window':a>=first&&b<=last?'inside':'partial';
+ if(output.temporalCoverage!=='inside')output.issues.push('association:window-'+output.temporalCoverage);
+ const p=reports[0].snapshot.process;
+ if(p){
+ output.processComparison={processIdMatches:p.processId===r.processId,processStartedAtMatches:p.processStartedAt===r.processStartedAt,programSHA256Matches:p.programSHA256===r.programSHA256};
+ output.sameProcessVerified=output.evidenceValid&&Object.values(output.processComparison).every(Boolean)&&r.identityStable&&r.programUnchanged;
+ if(!Object.values(output.processComparison).every(Boolean))output.issues.push('association:process-identity-mismatch');
+ }
+ if(!r.identityStable||!r.programUnchanged)output.issues.push('association:resource-process-not-stable');
+ output.compatible=output.evidenceValid&&usable&&checked.resourceObservationPassed&&output.temporalCoverage==='inside'&&output.sameProcessVerified;
+ }
+ output.replyObservationPassed=output.compatible;
+ }
+ output.issues.push('acceptance:real-business-not-verified');return output;
+}
+export const replyEvidenceExitCode=r=>!r.evidenceValid?1:r.replyObservationPassed?0:2;
+export async function main(args=process.argv.slice(2)){
+ try{
+ const options={};
+ for(let i=0;i 32768 || len(persona.Sex) > 256 {
+ fail("persona file invalid")
+ }
+ changes = map[string]any{"AgentChar": persona.Char, "AgentSex": persona.Sex}
+ }
+ for name, value := range changes {
+ fields[name], _ = json.Marshal(value)
+ }
+ body, err := json.Marshal(fields)
+ if err != nil {
+ fail("settings encoding failed")
+ }
+ result, err := tx.Exec("UPDATE __resp SET ext = ? WHERE gid = ? AND ext = ?", string(body), gid, raw)
+ if err != nil {
+ fail("settings write failed")
+ }
+ n, err := result.RowsAffected()
+ if err != nil || n != 1 {
+ fail("AI settings changed concurrently")
+ }
+ if tx.Commit() != nil {
+ fail("settings commit failed")
+ }
+ _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"configured": true, "visionConfigured": *vision, "personaUpdated": *personaFile != "", "credentialsPreserved": true, "unrelatedFieldsPreserved": true, "backupID": *backupID})
+}
+func fail(message string) { fmt.Fprintln(os.Stderr, message); os.Exit(1) }
diff --git a/scripts/configure-local-admin.ps1 b/scripts/configure-local-admin.ps1
new file mode 100644
index 0000000000..ee5529322e
--- /dev/null
+++ b/scripts/configure-local-admin.ps1
@@ -0,0 +1,10 @@
+[CmdletBinding()]
+param([ValidateRange(1,65535)][int]$Port=3010,[switch]$Disable)
+
+$ErrorActionPreference='Stop'
+$repoRoot=Split-Path -Parent $PSScriptRoot
+$directory=Join-Path $repoRoot '.local/admin'
+New-Item -ItemType Directory -Path $directory -Force|Out-Null
+$settings=@{enabled=(-not $Disable);address=('127.0.0.1:'+$Port)}
+[IO.File]::WriteAllText((Join-Path $directory 'options.json'),($settings|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
+Write-Host ('Local admin '+$(if($Disable){'disabled'}else{'enabled at http://127.0.0.1:'+$Port})+'. Restart ZeroBot to apply.')
diff --git a/scripts/install-onebot.ps1 b/scripts/install-onebot.ps1
new file mode 100644
index 0000000000..3f37fb8948
--- /dev/null
+++ b/scripts/install-onebot.ps1
@@ -0,0 +1,44 @@
+[CmdletBinding()]
+param([string]$QQAppPath)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$version = 'v4.18.28'
+$assetName = 'NapCat.Shell.Windows.Node.zip'
+$expectedHash = 'fb64fa3b036ad2df1a5d7c204c482694c20e4b763978c8a4968fd3474c05b4a8'
+$downloadRoot = Join-Path $repoRoot '.local/downloads'
+$archivePath = Join-Path $downloadRoot $assetName
+$bundleRoot = Join-Path $repoRoot '.local/onebot/napcat-v4.18.28'
+New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null
+if (-not (Test-Path -LiteralPath $archivePath)) {
+ & gh release download $version --repo NapNeko/NapCatQQ --pattern $assetName --dir $downloadRoot
+ if ($LASTEXITCODE -ne 0) { throw 'Official NapCat download failed.' }
+}
+$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
+if ($actualHash -ne $expectedHash) { throw 'NapCat archive checksum mismatch; refusing to extract.' }
+if (-not (Test-Path -LiteralPath (Join-Path $bundleRoot 'node.exe')) -or -not (Test-Path -LiteralPath (Join-Path $bundleRoot 'wrapper.node'))) {
+ Expand-Archive -LiteralPath $archivePath -DestinationPath $bundleRoot -Force
+}
+if (-not $QQAppPath) {
+ $qqRoot = Join-Path $env:ProgramFiles 'Tencent/QQNT'
+ $qqVersions = Get-Content -LiteralPath (Join-Path $qqRoot 'versions/config.json') -Raw | ConvertFrom-Json
+ $QQAppPath = Join-Path $qqRoot ('versions/' + $qqVersions.curVersion + '/resources/app')
+}
+$nativeFiles = @()
+foreach ($dllName in @('crypto.dll','ssl.dll')) {
+ $sourcePath = Join-Path $QQAppPath $dllName
+ $destinationPath = Join-Path $bundleRoot $dllName
+ if (-not (Test-Path -LiteralPath $destinationPath)) {
+ if (-not (Test-Path -LiteralPath $sourcePath)) { throw "Required QQ runtime library is missing: $dllName" }
+ Copy-Item -LiteralPath $sourcePath -Destination $destinationPath
+ }
+ $nativeFiles += [ordered]@{name=$dllName;source=$sourcePath;sha256=(Get-FileHash -Algorithm SHA256 -LiteralPath $destinationPath).Hash.ToLowerInvariant()}
+}
+$manifest = [ordered]@{
+ napcatVersion = $version; bundledQQVersion = '9.9.31-49738'; bundledNodeVersion = '22.11.0'
+ releaseURL = 'https://github.com/NapNeko/NapCatQQ/releases/tag/v4.18.28'
+ archive = $assetName; archiveSHA256 = $actualHash; addedNativeLibraries = $nativeFiles
+}
+[IO.File]::WriteAllText((Join-Path $repoRoot '.local/onebot/install-manifest.json'), ($manifest | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
+Write-Host "NapCat $version installed locally; official archive SHA256 verified."
+& (Join-Path $PSScriptRoot 'setup-onebot.ps1')
diff --git a/scripts/live-observation-core.ps1 b/scripts/live-observation-core.ps1
new file mode 100644
index 0000000000..145930156c
--- /dev/null
+++ b/scripts/live-observation-core.ps1
@@ -0,0 +1,74 @@
+# Pure statistics and incremental log counters. Never serialize the log cursor or its prefix.
+function New-LiveLogCursor([string]$Path) {
+ $stream=[IO.File]::Open($Path,'Open','Read',[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)
+ try {
+ return @{Path=$Path;Position=$stream.Length;Prefix='';LongLine=$false;Truncated=$false;BytesRead=0L;Lines=0L;Warnings=0L;Errors=0L;Fatals=0L;Panics=0L;Other=0L;UnreadBytes=0L}
+ } finally {$stream.Dispose()}
+}
+function Read-LiveLogDelta([hashtable]$Cursor,[int]$MaxBytes=262144) {
+ if($MaxBytes -lt 1 -or $MaxBytes -gt 1048576){throw 'Invalid log read limit.'}
+ $stream=[IO.File]::Open($Cursor.Path,'Open','Read',[IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)
+ try {
+ if($stream.Length -lt $Cursor.Position){$Cursor.Truncated=$true;$Cursor.Position=0L;$Cursor.Prefix='';$Cursor.LongLine=$false}
+ $stream.Position=$Cursor.Position
+ $size=[int][Math]::Min([long]$MaxBytes,[Math]::Max([long]0,$stream.Length-$Cursor.Position))
+ if($size -gt 0){
+ $buffer=[byte[]]::new($size)
+ $read=$stream.Read($buffer,0,$size)
+ $Cursor.Position+=$read;$Cursor.BytesRead+=$read
+ $text=[Text.Encoding]::UTF8.GetString($buffer,0,$read)
+ $pieces=$text.Split([char]10)
+ for($index=0;$index -lt $pieces.Length;$index++){
+ $piece=$pieces[$index];$room=4096-$Cursor.Prefix.Length
+ if($room -gt 0){$Cursor.Prefix+=$piece.Substring(0,[Math]::Min($room,$piece.Length))}
+ if($piece.Length -gt $room){$Cursor.LongLine=$true}
+ if($index -lt $pieces.Length-1){
+ $prefix=$Cursor.Prefix -replace '\x1b\[[0-9;]*m',''
+ $Cursor.Lines++
+ if($prefix -match '^(?:time=(?:"[^"]*"|\S+)\s+)?level=(warning|warn|error|fatal|panic)\b'){
+ switch($Matches[1]){warning {$Cursor.Warnings++} warn {$Cursor.Warnings++} error {$Cursor.Errors++} fatal {$Cursor.Fatals++} panic {$Cursor.Panics++}}
+ }elseif($prefix -match '^panic:'){$Cursor.Panics++}else{$Cursor.Other++}
+ $Cursor.Prefix='';$Cursor.LongLine=$false
+ }
+ }
+ }
+ $Cursor.UnreadBytes=[Math]::Max(0L,$stream.Length-$Cursor.Position)
+ }finally{$stream.Dispose()}
+}
+function Get-LiveLogSummary([hashtable[]]$Cursors) {
+ $value=[ordered]@{appendedLines=0L;warningLines=0L;errorLines=0L;fatalLines=0L;panicLines=0L;otherLines=0L;bytesRead=0L;unreadBytes=0L;partialLines=0;logTruncated=$false;coverageComplete=$true}
+ foreach($cursor in $Cursors){
+ $value.appendedLines+=$cursor.Lines;$value.warningLines+=$cursor.Warnings;$value.errorLines+=$cursor.Errors;$value.fatalLines+=$cursor.Fatals;$value.panicLines+=$cursor.Panics;$value.otherLines+=$cursor.Other;$value.bytesRead+=$cursor.BytesRead;$value.unreadBytes+=$cursor.UnreadBytes
+ if($cursor.Prefix.Length -or $cursor.LongLine){$value.partialLines++}
+ if($cursor.Truncated){$value.logTruncated=$true}
+ }
+ $value.coverageComplete= -not $value.logTruncated -and $value.unreadBytes -eq 0 -and $value.partialLines -eq 0
+ return $value
+}
+function Get-LiveResourceSummary([object[]]$Samples,[int]$DurationSeconds,[int]$IntervalSeconds,[int]$LogicalCPUs,[double]$MaxCpuPercent=20,[double]$MaxPrivateGrowthMiB=64) {
+ if($DurationSeconds -lt 10 -or $IntervalSeconds -lt 1 -or $LogicalCPUs -lt 1){throw 'Invalid resource summary parameters.'}
+ $value=[ordered]@{sampleCount=$Samples.Count;sampleSpanSeconds=0.0;maxSampleGapSeconds=0.0;cpuAveragePercent=$null;cpuPeakSamplePercent=$null;privateFirstBytes=$null;privateMaxBytes=$null;privateLastBytes=$null;privateGrowthMiB=$null;workingSetMaxBytes=$null;threadMax=$null;handleMax=$null;metricsValid=$false;completeWindow=$false;cadencePassed=$false;resourceThresholdsPassed=$false;fullTiming=$DurationSeconds -ge 900}
+ if($Samples.Count -lt 2){return $value}
+ $first=$Samples[0];$last=$Samples[-1];$span=$last.atSeconds-$first.atSeconds
+ if($span -le 0){return $value}
+ $gaps=@();$cpu=@();$valid=$true
+ for($index=1;$index -lt $Samples.Count;$index++){
+ $gap=$Samples[$index].atSeconds-$Samples[$index-1].atSeconds
+ $delta=$Samples[$index].cpuSeconds-$Samples[$index-1].cpuSeconds
+ if($gap -le 0 -or $delta -lt 0){$valid=$false;continue}
+ $gaps+=$gap;$cpu+=($delta/$gap/$LogicalCPUs*100)
+ }
+ if(-not $valid -or $gaps.Count -ne $Samples.Count-1){return $value}
+ $value.sampleSpanSeconds=$span;$value.maxSampleGapSeconds=($gaps|Measure-Object -Maximum).Maximum
+ $value.cpuAveragePercent=($last.cpuSeconds-$first.cpuSeconds)/$span/$LogicalCPUs*100
+ $value.cpuPeakSamplePercent=($cpu|Measure-Object -Maximum).Maximum
+ $value.privateFirstBytes=$first.privateBytes;$value.privateLastBytes=$last.privateBytes
+ $value.privateMaxBytes=($Samples.privateBytes|Measure-Object -Maximum).Maximum
+ $value.privateGrowthMiB=[Math]::Max([double]0,[double](($value.privateMaxBytes-$first.privateBytes)/1MB))
+ $value.workingSetMaxBytes=($Samples.workingSetBytes|Measure-Object -Maximum).Maximum
+ $value.threadMax=($Samples.threadCount|Measure-Object -Maximum).Maximum;$value.handleMax=($Samples.handleCount|Measure-Object -Maximum).Maximum
+ $value.metricsValid=$true;$value.completeWindow=$span -ge $DurationSeconds
+ $value.cadencePassed=$value.maxSampleGapSeconds -le (2*$IntervalSeconds+0.5) -and $Samples.Count -ge [Math]::Ceiling($DurationSeconds/$IntervalSeconds*0.8)+1
+ $value.resourceThresholdsPassed=$value.cpuAveragePercent -le $MaxCpuPercent -and $value.privateGrowthMiB -le $MaxPrivateGrowthMiB
+ return $value
+}
diff --git a/scripts/observe-isolated-ai.ps1 b/scripts/observe-isolated-ai.ps1
new file mode 100644
index 0000000000..8683275e52
--- /dev/null
+++ b/scripts/observe-isolated-ai.ps1
@@ -0,0 +1,142 @@
+[CmdletBinding()]
+param(
+ [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]*\.exe$')][string]$BinaryName='zerobot.exe',
+ [ValidateRange(10,1200)][int]$DurationSeconds=900,
+ [ValidateRange(1,30)][int]$IntervalSeconds=5,
+ [ValidateRange(60,120)][int]$BatchIntervalSeconds=60,
+ [ValidateRange(0,2000)][int]$ModelDelayMilliseconds=150
+)
+$ErrorActionPreference='Stop'
+. (Join-Path $PSScriptRoot 'windows-service-identity.ps1')
+. (Join-Path $PSScriptRoot 'live-observation-core.ps1')
+$repoRoot=Resolve-LocalServicePath (Split-Path -Parent $PSScriptRoot)
+$binaryPath=Join-Path $repoRoot ('.local/bin/'+$BinaryName)
+if(-not (Test-Path -LiteralPath $binaryPath -PathType Leaf)){throw 'Selected test binary does not exist.'}
+$programHash=(Get-FileHash -LiteralPath $binaryPath -Algorithm SHA256).Hash
+$goPath=(Get-Command go -CommandType Application|Select-Object -First 1).Source
+$nodePath=(Get-Command node -CommandType Application|Select-Object -First 1).Source
+if(-not (Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')){throw 'PowerShell with JSON DateKind support is required.'}
+$observationRoot=Join-Path $repoRoot ('.local/ai-load-observation/run-'+(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8))
+New-Item -ItemType Directory -Path $observationRoot|Out-Null
+$resourceRoot=Join-Path $observationRoot 'resource'
+New-Item -ItemType Directory -Path $resourceRoot|Out-Null
+$utf8=[Text.UTF8Encoding]::new($false)
+function Save-IsolatedJSON([string]$Name,$Value){[IO.File]::WriteAllText((Join-Path $observationRoot $Name),($Value|ConvertTo-Json -Depth 12),$utf8)}
+$result=[ordered]@{startedAt=(Get-Date).ToString('o');programSHA256=$programHash;isolated=$true;requestedDurationSeconds=$DurationSeconds;intervalSeconds=$IntervalSeconds;batchIntervalSeconds=$BatchIntervalSeconds;syntheticModelDelayMilliseconds=$ModelDelayMilliseconds;completed=$false;passed=$false;fullTiming=$DurationSeconds -ge 900;diagnosticChecksPassed=$false;syntheticRequestBatches=0;syntheticModelCalls=0;realQQMessagesSent=$false;realModelCalls=0;dailyServicesChanged=$false;businessAcceptancePassed=$false;stopReason='not-started';instanceStopped=$false}
+$helperProcess=$null;$sampledBot=$null;$readLine=$null;$helperErrors=$null;$fixtureRoot=$null;$afterStatus=$null;$beforeStatus=$null;$resourceResult=$null;$cursors=@();$samples=[Collections.Generic.List[object]]::new();$stage='start-fixture';$failed=$false;$identityStable=$true
+function Read-IsolatedStatus {
+ $limit=[Diagnostics.Stopwatch]::StartNew()
+ while($limit.Elapsed.TotalSeconds -lt 60){
+ if($readLine.IsCompleted){
+ $line=$readLine.GetAwaiter().GetResult()
+ if($null -eq $line){throw 'Fixture output ended.'}
+ $script:readLine=$helperProcess.StandardOutput.ReadLineAsync()
+ if($line.StartsWith('{')){
+ if($line.Length -gt 1048576){throw 'Fixture status limit exceeded.'}
+ $status=$line|ConvertFrom-Json -DateKind String
+ if($status.state -eq 'ready'){return $status}
+ }
+ }
+ Start-Sleep -Milliseconds 20
+ }
+ throw 'Fixture status timed out.'
+}
+function Send-IsolatedCommand([string]$Command){$helperProcess.StandardInput.WriteLine($Command);$helperProcess.StandardInput.Flush();Read-IsolatedStatus}
+function Test-IsolatedIdentity($Status){
+ if($Status.realQQMessagesSent -ne $false -or $Status.realModelCalls -ne 0 -or $Status.processStarts -ne 1){$script:identityStable=$false;throw 'Fixture scope or process count changed.'}
+ $identity=$Status.replyTasks.process
+ $sampledBot.Refresh()
+ if($sampledBot.HasExited -or $identity.processId -ne $sampledBot.Id -or ([datetimeoffset]$identity.processStartedAt).UtcTicks -ne $sampledBot.StartTime.ToUniversalTime().Ticks -or $identity.programSHA256 -ne $programHash -or (Resolve-LocalServicePath $sampledBot.Path) -ne (Join-Path $fixtureRoot 'zerobot-model-observation-acceptance.exe')){$script:identityStable=$false;throw 'Fixture process identity changed.'}
+}
+function Save-ReplySnapshot([string]$Name,$Stats){
+ Save-IsolatedJSON $Name @{format='zerobot-reply-statistics';version=1;exportedAt=[datetime]::UtcNow.ToString('o');scope=@{kind='ai-reply-tasks';latencyScope='process-cumulative';timingBoundary='bot-matcher-entry-to-onebot-send-receipt';businessAcceptancePassed=$false};snapshot=$Stats}
+}
+Write-Host ('ARTIFACTS='+$observationRoot)
+try{
+ $startInfo=[Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName=$goPath;$startInfo.WorkingDirectory=$repoRoot;$startInfo.UseShellExecute=$false;$startInfo.CreateNoWindow=$true;$startInfo.RedirectStandardInput=$true;$startInfo.RedirectStandardOutput=$true;$startInfo.RedirectStandardError=$true
+ foreach($argument in @('run','scripts/start-model-observation-acceptance.go','-binary-name',$BinaryName,'-model-delay',($ModelDelayMilliseconds.ToString()+'ms'),'-lifetime','30m')){$startInfo.ArgumentList.Add($argument)}
+ $helperProcess=[Diagnostics.Process]::new();$helperProcess.StartInfo=$startInfo
+ if(-not $helperProcess.Start()){throw 'Fixture could not be started.'}
+ $helperErrors=$helperProcess.StandardError.ReadToEndAsync();$readLine=$helperProcess.StandardOutput.ReadLineAsync()
+ $initial=Read-IsolatedStatus
+ $stage='verify-fixture'
+ $fixtureRoot=Resolve-LocalServicePath $initial.output
+ $fixtureParent=(Join-Path $repoRoot '.local/model-observation-acceptance')+[IO.Path]::DirectorySeparatorChar
+ if(-not $fixtureRoot.StartsWith($fixtureParent,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($fixtureRoot) -notmatch '^run-[0-9]+$'){throw 'Fixture directory is outside the isolated workspace.'}
+ $address=[uri]$initial.url
+ if($address.Scheme -ne 'http' -or $address.Host -ne '127.0.0.1' -or $address.Port -in @(3010,6099,6700)){throw 'Fixture address is not isolated.'}
+ $sampledBot=Get-Process -Id $initial.replyTasks.process.processId
+ Test-IsolatedIdentity $initial
+ if((Get-FileHash -LiteralPath (Join-Path $fixtureRoot 'zerobot-model-observation-acceptance.exe') -Algorithm SHA256).Hash -ne $programHash){throw 'Copied fixture program differs.'}
+ foreach($stream in @('stdout','stderr')){$cursors+=New-LiveLogCursor (Join-Path $fixtureRoot ('start-1-'+$stream+'.log'))}
+ $result.fixtureDirectory=$fixtureRoot
+ $resourceResult=[ordered]@{startedAt=(Get-Date).ToString('o');processId=$sampledBot.Id;processStartedAt=$sampledBot.StartTime.ToString('o');programSHA256=$programHash;requestedDurationSeconds=$DurationSeconds;intervalSeconds=$IntervalSeconds;logicalCPUs=[Environment]::ProcessorCount;thresholds=@{cpuAverageCapacityPercent=20;privateGrowthMiB=64};completed=$false;passed=$false;identityStable=$true;stopReason='not-started';realQQMessagesSent=$false;modelCalls=0;businessLoadMeasured=$false;businessAcceptancePassed=$false;messageContentsSaved=$false;servicesStartedOrStopped=$false}
+ $timer=[Diagnostics.Stopwatch]::StartNew();$nextBatch=0.0;$nextProgress=0.0
+ while($true){
+ if(Test-Path -LiteralPath (Join-Path $observationRoot 'stop.request')){$result.stopReason='stop-request';break}
+ $stage='sample-resource';Test-IsolatedIdentity $initial
+ $sample=[ordered]@{at=(Get-Date).ToString('o');atSeconds=$timer.Elapsed.TotalSeconds;cpuSeconds=$sampledBot.TotalProcessorTime.TotalSeconds;privateBytes=$sampledBot.PrivateMemorySize64;workingSetBytes=$sampledBot.WorkingSet64;threadCount=$sampledBot.Threads.Count;handleCount=$sampledBot.HandleCount}
+ $samples.Add([pscustomobject]$sample);[IO.File]::AppendAllText((Join-Path $resourceRoot 'samples.jsonl'),(($sample|ConvertTo-Json -Compress)+[Environment]::NewLine),$utf8)
+ foreach($cursor in $cursors){Read-LiveLogDelta $cursor}
+ $elapsed=$sample.atSeconds-$samples[0].atSeconds
+ if($null -eq $beforeStatus){$stage='start-snapshots';$beforeStatus=Send-IsolatedCommand 'status';Test-IsolatedIdentity $beforeStatus;Save-ReplySnapshot 'reply-before.json' $beforeStatus.replyTasks}
+ if($null -eq $afterStatus -and $elapsed -ge $DurationSeconds-5){
+ $stage='finish-snapshots';$afterStatus=Send-IsolatedCommand 'status';Test-IsolatedIdentity $afterStatus;Save-ReplySnapshot 'reply-after.json' $afterStatus.replyTasks
+ }
+ if($null -eq $afterStatus -and $elapsed -ge $nextBatch){
+ $stage='synthetic-request-batch';$status=Send-IsolatedCommand 'requests';Test-IsolatedIdentity $status
+ for($attempt=0;$attempt -lt 30 -and $status.replyTasks.active -ne 0;$attempt++){Start-Sleep -Milliseconds 100;$status=Send-IsolatedCommand 'status';Test-IsolatedIdentity $status}
+ $result.syntheticRequestBatches++;$expected=$result.syntheticRequestBatches*3
+ if($status.syntheticModelCalls -ne $expected -or $status.modelRequests.completed -ne $expected -or $status.modelRequests.active -ne 0 -or $status.modelRequests.failed -ne 0 -or $status.replyTasks.started -ne $expected -or $status.replyTasks.completed -ne $expected -or $status.replyTasks.active -ne 0 -or $status.replyTasks.replyAccepted -ne $expected -or $status.replyTasks.failed -ne 0 -or $status.replyTasks.missingIngress -ne 0){throw 'Synthetic model and reply counts differ.'}
+ $result.syntheticModelCalls=$status.syntheticModelCalls;$nextBatch=$result.syntheticRequestBatches*$BatchIntervalSeconds
+ }
+ if($elapsed -ge $nextProgress){Save-IsolatedJSON 'progress.json' @{checkedAt=(Get-Date).ToString('o');state='observing';elapsedSeconds=$elapsed;sampleCount=$samples.Count;syntheticRequestBatches=$result.syntheticRequestBatches;syntheticModelCalls=$result.syntheticModelCalls};Write-Host ('OBSERVING seconds={0:N1} samples={1} syntheticCalls={2}' -f $elapsed,$samples.Count,$result.syntheticModelCalls);$nextProgress=$elapsed+60}
+ if($elapsed -ge $DurationSeconds){
+ $sampledBot.Refresh();$finalSample=[ordered]@{at=(Get-Date).ToString('o');atSeconds=$timer.Elapsed.TotalSeconds;cpuSeconds=$sampledBot.TotalProcessorTime.TotalSeconds;privateBytes=$sampledBot.PrivateMemorySize64;workingSetBytes=$sampledBot.WorkingSet64;threadCount=$sampledBot.Threads.Count;handleCount=$sampledBot.HandleCount}
+ $samples.Add([pscustomobject]$finalSample);[IO.File]::AppendAllText((Join-Path $resourceRoot 'samples.jsonl'),(($finalSample|ConvertTo-Json -Compress)+[Environment]::NewLine),$utf8)
+ $result.completed=$true;$result.stopReason='duration-complete';break
+ }
+ Start-Sleep -Milliseconds ([int][Math]::Max(1,[Math]::Min($IntervalSeconds*1000,($DurationSeconds-$elapsed)*1000)))
+ }
+ $timer.Stop()
+}catch{$failed=$true;$result.stopReason='observation-error';$result.failureStage=$stage;$result.failureLine=$_.InvocationInfo.ScriptLineNumber;$result.failureType=$_.Exception.GetType().FullName}
+finally{
+ try{if($resourceResult){
+ foreach($cursor in $cursors){for($attempt=0;$attempt -lt 4;$attempt++){Read-LiveLogDelta $cursor;if($cursor.UnreadBytes -eq 0){break}}}
+ $logs=Get-LiveLogSummary $cursors;$resourceResult.logCounts=$logs;$resourceResult.noNewErrorLines=($logs.errorLines+$logs.fatalLines+$logs.panicLines) -eq 0
+ $resourceResult.resources=Get-LiveResourceSummary $samples.ToArray() $DurationSeconds $IntervalSeconds $resourceResult.logicalCPUs
+ $resourceResult.completed=$result.completed;$resourceResult.stopReason=$result.stopReason
+ $resourceResult.programUnchanged=(Get-FileHash -LiteralPath (Join-Path $fixtureRoot 'zerobot-model-observation-acceptance.exe')).Hash -eq $programHash
+ $resourceResult.identityStable=$identityStable -and $result.failureStage -ne 'verify-fixture'
+ $resourceResult.passed=$resourceResult.completed -and -not $failed -and $resourceResult.identityStable -and $resourceResult.programUnchanged -and $resourceResult.resources.fullTiming -and $resourceResult.resources.completeWindow -and $resourceResult.resources.cadencePassed -and $resourceResult.resources.resourceThresholdsPassed -and $resourceResult.noNewErrorLines -and $logs.coverageComplete
+ $resourceResult.completedAt=(Get-Date).ToString('o');Save-IsolatedJSON 'resource/result.json' $resourceResult
+ }}catch{$failed=$true;$result.stopReason='observation-error';$result.failureStage='final-resource';$result.failureType=$_.Exception.GetType().FullName}
+ if($helperProcess){
+ try{if(-not $helperProcess.HasExited){$helperProcess.StandardInput.WriteLine('stop');$helperProcess.StandardInput.Close();if(-not $helperProcess.WaitForExit(15000)){$helperProcess.Kill($true);$helperProcess.WaitForExit(5000)|Out-Null}};$result.instanceStopped=$helperProcess.HasExited}catch{$result.cleanupFailed=$true;$failed=$true}
+ $helperProcess.Dispose()
+ }
+ if($sampledBot){$sampledBot.Dispose()};foreach($cursor in $cursors){$cursor.Prefix=''}
+}
+if($result.completed -and -not $failed){
+ try{
+ $stage='offline-evidence'
+ $beforeModel=$beforeStatus.modelRequests;$afterModel=$afterStatus.modelRequests
+ $delta=@{};foreach($key in @('started','completed','failed','incomplete')){$delta[$key]=$afterModel.$key-$beforeModel.$key};$delta.activeAtStart=$beforeModel.active;$delta.activeAtEnd=$afterModel.active
+ $rows=@();foreach($kind in @('gpt','chat','agent','vision')){$a=$beforeModel.categories|Where-Object kind -eq $kind;$b=$afterModel.categories|Where-Object kind -eq $kind;$row=@{kind=$kind;activeAtStart=$a.active;activeAtEnd=$b.active};foreach($key in @('started','completed','failed','incomplete')){$row[$key]=$b.$key-$a.$key};$rows+=$row}
+ $delta.categories=$rows;$delta.durationSeconds=([datetimeoffset]$afterModel.checkedAt).ToUnixTimeMilliseconds()/1000-([datetimeoffset]$beforeModel.checkedAt).ToUnixTimeMilliseconds()/1000
+ $delta.durationSeconds=[math]::Round($delta.durationSeconds,3)
+ Save-IsolatedJSON 'model-report.json' @{format='zerobot-model-observation';version=2;exportedAt=[datetime]::UtcNow.ToString('o');scope=@{kind='model-http-exchanges';latencyScope='process-cumulative';businessAcceptancePassed=$false};before=$beforeModel;after=$afterModel;window=$delta}
+ & $nodePath (Join-Path $PSScriptRoot 'check-observation-evidence.mjs') --resource $resourceRoot --model (Join-Path $observationRoot 'model-report.json') --output (Join-Path $observationRoot 'model-check.json') > (Join-Path $observationRoot 'model-check.txt')
+ $modelExit=$LASTEXITCODE
+ & $nodePath (Join-Path $PSScriptRoot 'check-reply-evidence.mjs') --before (Join-Path $observationRoot 'reply-before.json') --after (Join-Path $observationRoot 'reply-after.json') --resource $resourceRoot --output (Join-Path $observationRoot 'reply-check.json') > (Join-Path $observationRoot 'reply-check.txt')
+ $replyExit=$LASTEXITCODE
+ $modelCheck=Get-Content -LiteralPath (Join-Path $observationRoot 'model-check.json') -Raw|ConvertFrom-Json;$replyCheck=Get-Content -LiteralPath (Join-Path $observationRoot 'reply-check.json') -Raw|ConvertFrom-Json
+ $result.modelEvidenceExitCode=$modelExit;$result.replyEvidenceExitCode=$replyExit
+ $result.diagnosticChecksPassed=$modelCheck.evidenceValid -and $replyCheck.evidenceValid -and $modelCheck.sameProcessVerified -and $replyCheck.sameProcessVerified -and $modelCheck.temporalCoverage -eq 'inside' -and $replyCheck.temporalCoverage -eq 'inside' -and $modelCheck.model.window.completed -eq $result.syntheticModelCalls -and $replyCheck.window.completed -eq $result.syntheticModelCalls -and $result.instanceStopped
+ $result.passed=$result.fullTiming -and $result.diagnosticChecksPassed -and $modelExit -eq 0 -and $replyExit -eq 0
+ }catch{$failed=$true;$result.failureStage=$stage;$result.stopReason='evidence-error';$result.failureLine=$_.InvocationInfo.ScriptLineNumber;$result.failureType=$_.Exception.GetType().FullName}
+}
+$result.completedAt=(Get-Date).ToString('o');Save-IsolatedJSON 'result.json' $result;Save-IsolatedJSON 'progress.json' @{state='finished';completedAt=$result.completedAt;completed=$result.completed;passed=$result.passed;stopReason=$result.stopReason;syntheticModelCalls=$result.syntheticModelCalls}
+Write-Host ('OBSERVATION_COMPLETE completed={0} fullTiming={1} passed={2}' -f $result.completed,$result.fullTiming,$result.passed)
+if($failed){throw 'Isolated AI observation failed; inspect the recorded stage. Daily services were not changed.'}
diff --git a/scripts/observe-live-services.ps1 b/scripts/observe-live-services.ps1
new file mode 100644
index 0000000000..bd22e2c698
--- /dev/null
+++ b/scripts/observe-live-services.ps1
@@ -0,0 +1,81 @@
+[CmdletBinding()]
+param(
+ [ValidateRange(10,86400)][int]$DurationSeconds=900,
+ [ValidateRange(1,30)][int]$IntervalSeconds=5,
+ [ValidateRange(0.01,100)][double]$MaxCpuPercent=20,
+ [ValidateRange(0.01,4096)][double]$MaxPrivateGrowthMiB=64
+)
+$ErrorActionPreference='Stop'
+. (Join-Path $PSScriptRoot 'windows-service-identity.ps1')
+. (Join-Path $PSScriptRoot 'live-observation-core.ps1')
+$repoRoot=Resolve-LocalServicePath (Split-Path -Parent $PSScriptRoot)
+$expected=Join-Path $repoRoot '.local/bin/zerobot.exe'
+$statePath=Join-Path $repoRoot '.local/zerobot/process.json'
+$state=Get-Content -LiteralPath $statePath -Raw|ConvertFrom-Json
+$target=Get-Process -Id $state.processId
+if(-not (Test-LocalServiceProcess $state $target $expected)){throw 'Managed daily ZeroBot identity could not be verified.'}
+$logsRoot=(Join-Path $repoRoot '.local/services')+[IO.Path]::DirectorySeparatorChar
+$cursors=@()
+foreach($field in @('stdout','stderr')){
+ $path=Resolve-LocalServicePath $state.$field
+ if(-not $path.StartsWith($logsRoot,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($path) -notmatch '^ZeroBot-[0-9-]+-(stdout|stderr)\.log$'){throw 'Managed service log path could not be verified.'}
+ $cursors+=New-LiveLogCursor $path
+}
+$programHash=(Get-FileHash -LiteralPath $expected -Algorithm SHA256).Hash
+$out=Join-Path $repoRoot ('.local/live-observation/run-'+(Get-Date -Format 'yyyyMMdd-HHmmss')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8))
+New-Item -ItemType Directory -Path $out|Out-Null
+$utf8=[Text.UTF8Encoding]::new($false)
+function Write-ObservationJSON([string]$Name,$Value){
+ $path=Join-Path $out $Name
+ [IO.File]::WriteAllText(($path+'.tmp'),($Value|ConvertTo-Json -Depth 8),$utf8)
+ Move-Item -LiteralPath ($path+'.tmp') -Destination $path -Force
+}
+$samples=[Collections.Generic.List[object]]::new()
+$result=[ordered]@{startedAt=(Get-Date).ToString('o');processId=$target.Id;processStartedAt=$target.StartTime.ToString('o');programSHA256=$programHash;requestedDurationSeconds=$DurationSeconds;intervalSeconds=$IntervalSeconds;logicalCPUs=[Environment]::ProcessorCount;thresholds=@{cpuAverageCapacityPercent=$MaxCpuPercent;privateGrowthMiB=$MaxPrivateGrowthMiB};completed=$false;passed=$false;identityStable=$true;stopReason='not-started';realQQMessagesSent=$false;modelCalls=0;businessLoadMeasured=$false;businessAcceptancePassed=$false;messageContentsSaved=$false;servicesStartedOrStopped=$false}
+$timer=[Diagnostics.Stopwatch]::StartNew();$nextProgress=0.0;$stage='sampling';$failed=$false
+Write-Host ('ARTIFACTS='+$out)
+try {
+ while($true){
+ if(Test-Path -LiteralPath (Join-Path $out 'stop.request')){$result.stopReason='stop-request';break}
+ $stage='process-identity'
+ $fresh=Get-Content -LiteralPath $statePath -Raw|ConvertFrom-Json
+ $target.Refresh()
+ if($target.HasExited -or $fresh.processId -ne $state.processId -or -not (Test-LocalServiceProcess $fresh $target $expected)){$result.identityStable=$false;$result.stopReason='process-identity-changed';break}
+ $stage='resource-sample'
+ $sample=[ordered]@{at=(Get-Date).ToString('o');atSeconds=$timer.Elapsed.TotalSeconds;cpuSeconds=$target.TotalProcessorTime.TotalSeconds;privateBytes=$target.PrivateMemorySize64;workingSetBytes=$target.WorkingSet64;threadCount=$target.Threads.Count;handleCount=$target.HandleCount}
+ $samples.Add([pscustomobject]$sample)
+ [IO.File]::AppendAllText((Join-Path $out 'samples.jsonl'),(($sample|ConvertTo-Json -Compress)+[Environment]::NewLine),$utf8)
+ $stage='log-counters'
+ foreach($cursor in $cursors){Read-LiveLogDelta $cursor}
+ $elapsed=$timer.Elapsed.TotalSeconds
+ if($elapsed -ge $nextProgress){
+ $progress=@{checkedAt=(Get-Date).ToString('o');state='observing';elapsedSeconds=$elapsed;sampleCount=$samples.Count;privateBytes=$sample.privateBytes;logCounts=(Get-LiveLogSummary $cursors)}
+ Write-ObservationJSON 'progress.json' $progress
+ Write-Host ('OBSERVING seconds={0:N1} samples={1}' -f $elapsed,$samples.Count)
+ $nextProgress=$elapsed+60
+ }
+ if($sample.atSeconds-$samples[0].atSeconds -ge $DurationSeconds){$result.completed=$true;$result.stopReason='duration-complete';break}
+ $remaining=$DurationSeconds-($sample.atSeconds-$samples[0].atSeconds)
+ Start-Sleep -Milliseconds ([int][Math]::Max(1,[Math]::Min($IntervalSeconds*1000,$remaining*1000)))
+ }
+} catch {
+ $failed=$true;$result.stopReason='observation-error';$result.failureStage=$stage
+ if($stage -eq 'process-identity'){$result.identityStable=$false}
+} finally {
+ $timer.Stop()
+ try {
+ foreach($cursor in $cursors){for($attempt=0;$attempt -lt 4;$attempt++){Read-LiveLogDelta $cursor;if($cursor.UnreadBytes -eq 0){break}}}
+ $logs=Get-LiveLogSummary $cursors
+ $result.logCounts=$logs;$result.noNewErrorLines=($logs.errorLines+$logs.fatalLines+$logs.panicLines) -eq 0
+ } catch {$result.logReadFailed=$true;$result.noNewErrorLines=$false;$failed=$true}
+ $result.resources=Get-LiveResourceSummary $samples.ToArray() $DurationSeconds $IntervalSeconds $result.logicalCPUs $MaxCpuPercent $MaxPrivateGrowthMiB
+ try {$result.programUnchanged=(Get-FileHash -LiteralPath $expected -Algorithm SHA256).Hash -eq $programHash} catch {$result.programUnchanged=$false}
+ $result.passed=$result.completed -and $result.identityStable -and $result.programUnchanged -and $result.resources.fullTiming -and $result.resources.completeWindow -and $result.resources.cadencePassed -and $result.resources.resourceThresholdsPassed -and $result.noNewErrorLines -and $result.logCounts.coverageComplete -and -not $failed
+ $result.completedAt=(Get-Date).ToString('o')
+ Write-ObservationJSON 'result.json' $result
+ Write-ObservationJSON 'progress.json' @{state='finished';completedAt=$result.completedAt;stopReason=$result.stopReason;sampleCount=$samples.Count;completed=$result.completed;passed=$result.passed}
+ foreach($cursor in $cursors){$cursor.Prefix=''}
+ $target.Dispose()
+}
+Write-Host ('OBSERVATION_COMPLETE completed={0} fullTiming={1} passed={2}' -f $result.completed,$result.resources.fullTiming,$result.passed)
+if($failed){throw 'Live observation failed; the recorded failure stage identifies the check. Services were not changed.'}
diff --git a/scripts/open-onebot-webui.ps1 b/scripts/open-onebot-webui.ps1
new file mode 100644
index 0000000000..4633dac2b2
--- /dev/null
+++ b/scripts/open-onebot-webui.ps1
@@ -0,0 +1,10 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$webConfig = Get-Content -LiteralPath (Join-Path $repoRoot '.local/onebot/runtime/config/webui.json') -Raw | ConvertFrom-Json
+if ($webConfig.host -ne '127.0.0.1') { throw 'WebUI must bind only to 127.0.0.1.' }
+$url = 'http://127.0.0.1:' + $webConfig.port + '/webui/?token=' + [Uri]::EscapeDataString($webConfig.token)
+# This is an interactive browser the user needs for QQ login. Keep the token out of console output.
+Start-Process $url
diff --git a/scripts/run-windows.ps1 b/scripts/run-windows.ps1
new file mode 100644
index 0000000000..8efa1c99dd
--- /dev/null
+++ b/scripts/run-windows.ps1
@@ -0,0 +1,28 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ConfigPath,
+ [switch]$Headless
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$resolvedConfig = (Resolve-Path -LiteralPath $ConfigPath).Path
+$binaryPath = Join-Path $repoRoot '.local/bin/zerobot.exe'
+if (-not (Test-Path -LiteralPath $binaryPath)) {
+ throw 'Build first with scripts/build-windows.ps1.'
+}
+if (-not (Test-Path -LiteralPath (Join-Path $repoRoot 'data/Control/kanban.png'))) {
+ throw 'Runtime resources are missing. Initialize the data submodule first.'
+}
+$previousDebugMode = $env:DEBUG_MODE
+Push-Location $repoRoot
+try {
+ # Upstream uses this variable to tolerate redirected Windows console handles.
+ if ($Headless) { $env:DEBUG_MODE = '1' }
+ & $binaryPath -c $resolvedConfig
+ if ($LASTEXITCODE -ne 0) { throw "Bot exited with code $LASTEXITCODE." }
+} finally {
+ $env:DEBUG_MODE = $previousDebugMode
+ Pop-Location
+}
diff --git a/scripts/service-host.ps1 b/scripts/service-host.ps1
new file mode 100644
index 0000000000..b43809f463
--- /dev/null
+++ b/scripts/service-host.ps1
@@ -0,0 +1,51 @@
+[CmdletBinding()]
+param([Parameter(Mandatory=$true)][ValidateSet('OneBot','ZeroBot')][string]$Service)
+
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'windows-service-identity.ps1')
+$repoRoot = Resolve-LocalServicePath (Split-Path -Parent $PSScriptRoot)
+$runtimeRoot = Join-Path $repoRoot '.local/services'
+New-Item -ItemType Directory -Path $runtimeRoot -Force | Out-Null
+$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
+if ($Service -eq 'OneBot') {
+ $workingDirectory = Join-Path $repoRoot '.local/onebot/napcat-v4.18.28'
+ $executable = Join-Path $workingDirectory 'node.exe'
+ $arguments = @('index.js')
+ $statePath = Join-Path $repoRoot '.local/onebot/runtime/process.json'
+ $env:NAPCAT_WORKDIR = Join-Path $repoRoot '.local/onebot/runtime'
+ $env:ACCOUNT = ''
+} else {
+ $workingDirectory = $repoRoot
+ $executable = Join-Path $repoRoot '.local/bin/zerobot.exe'
+ $arguments = @('-c', ('"' + (Join-Path $repoRoot '.local/config.json') + '"'))
+ $adminDirectory=Join-Path $repoRoot '.local/admin'
+ $adminOptionsPath=Join-Path $adminDirectory 'options.json'
+ if(Test-Path -LiteralPath $adminOptionsPath){
+ $adminOptions=Get-Content -LiteralPath $adminOptionsPath -Raw|ConvertFrom-Json
+ if($adminOptions.enabled -isnot [bool]){throw 'Local admin enabled must be a boolean.'}
+ if($adminOptions.enabled){
+ $adminAddress=[string]$adminOptions.address
+ if($adminAddress -notmatch '^127\.0\.0\.1:[1-9][0-9]{0,4}$'){throw 'Local admin address must be an IPv4 loopback address and port.'}
+ $arguments+=@('-admin','-admin-addr',$adminAddress,'-admin-data',('"'+$adminDirectory+'"'))
+ }
+ }
+ $statePath = Join-Path $repoRoot '.local/zerobot/process.json'
+ $env:DEBUG_MODE = '1'
+}
+if (Test-Path -LiteralPath $statePath) {
+ $old = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
+ $existing = Get-Process -Id $old.processId -ErrorAction SilentlyContinue
+ if ($existing -and (Test-LocalServiceProcess $old $existing $executable)) { return }
+ if ($existing -and $existing.Path -and (Resolve-LocalServicePath $existing.Path) -eq $executable) {throw 'Existing service identity changed; refusing to create a duplicate.'}
+}
+$stdout = Join-Path $runtimeRoot ($Service + '-' + $stamp + '-stdout.log')
+$stderr = Join-Path $runtimeRoot ($Service + '-' + $stamp + '-stderr.log')
+# The scheduler starts this host with a hidden window. Its child stays attached
+# to this persistent host, rather than to the temporary development tool.
+$child = Start-Process -FilePath $executable -ArgumentList $arguments -WorkingDirectory $workingDirectory -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru
+$state = [ordered]@{processId=$child.Id;hostProcessId=$PID;executable=$executable;startedAt=(Get-Date).ToString('o');processStartedAt=$child.StartTime.ToString('o');stdout=$stdout;stderr=$stderr;managedBy='Windows Task Scheduler'}
+New-Item -ItemType Directory -Path (Split-Path $statePath) -Force | Out-Null
+[IO.File]::WriteAllText($statePath, ($state | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
+Wait-Process -Id $child.Id
+$child.Refresh()
+exit $child.ExitCode
diff --git a/scripts/setup-onebot.ps1 b/scripts/setup-onebot.ps1
new file mode 100644
index 0000000000..fa5bb2b3ba
--- /dev/null
+++ b/scripts/setup-onebot.ps1
@@ -0,0 +1,78 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$runtimeRoot = Join-Path $repoRoot '.local/onebot/runtime'
+$configRoot = Join-Path $runtimeRoot 'config'
+$bundleRoot = Join-Path $repoRoot '.local/onebot/napcat-v4.18.28'
+if (-not (Test-Path -LiteralPath (Join-Path $bundleRoot 'node.exe'))) {
+ throw 'NapCat v4.18.28 Windows Node bundle is not installed in .local/onebot.'
+}
+New-Item -ItemType Directory -Path $configRoot -Force | Out-Null
+
+function New-LocalToken {
+ $bytes = New-Object byte[] 32
+ $rng = [Security.Cryptography.RandomNumberGenerator]::Create()
+ try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
+ return [BitConverter]::ToString($bytes).Replace('-', '').ToLowerInvariant()
+}
+function Write-LocalJson($Path, $Value) {
+ [IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 10), [Text.UTF8Encoding]::new($false))
+}
+
+$webConfigPath = Join-Path $configRoot 'webui.json'
+if (-not (Test-Path -LiteralPath $webConfigPath)) {
+ Write-LocalJson $webConfigPath ([ordered]@{
+ host = '127.0.0.1'; port = 6099; token = (New-LocalToken)
+ loginRate = 5; autoLoginAccount = ''; disableWebUI = $false
+ accessControlMode = 'whitelist'; ipWhitelist = @('127.0.0.1', '::1')
+ ipBlacklist = @(); enableXForwardedFor = $false
+ })
+}
+$onebotConfigPath = Join-Path $configRoot 'onebot11.json'
+if (-not (Test-Path -LiteralPath $onebotConfigPath)) {
+ Write-LocalJson $onebotConfigPath ([ordered]@{
+ network = [ordered]@{
+ httpServers = @(); httpSseServers = @(); httpClients = @()
+ websocketServers = @([ordered]@{
+ name = 'zerobot-local'; enable = $true; host = '127.0.0.1'
+ port = 6700; messagePostFormat = 'array'; reportSelfMessage = $false
+ token = (New-LocalToken); enableForcePushEvent = $true
+ debug = $false; heartInterval = 30000
+ })
+ websocketClients = @(); plugins = @()
+ }
+ musicSignUrl = ''; enableLocalFile2Url = $false; parseMultMsg = $false
+ })
+}
+$napcatConfigPath = Join-Path $configRoot 'napcat.json'
+if (-not (Test-Path -LiteralPath $napcatConfigPath)) {
+ Write-LocalJson $napcatConfigPath ([ordered]@{
+ fileLog = $true; consoleLog = $true
+ fileLogLevel = 'info'; consoleLogLevel = 'info'
+ packetBackend = 'auto'; packetServer = ''; autoTimeSync = $false
+ })
+}
+
+# Preserve existing user configuration and check that both tokens match.
+$botConfigPath = Join-Path $repoRoot '.local/config.json'
+$onebotConfig = Get-Content -LiteralPath $onebotConfigPath -Raw | ConvertFrom-Json
+$wsServer = @($onebotConfig.network.websocketServers | Where-Object { $_.name -eq 'zerobot-local' })
+if ($wsServer.Count -ne 1 -or $wsServer[0].host -ne '127.0.0.1') {
+ throw 'Expected one loopback zerobot-local WebSocket server.'
+}
+if (-not (Test-Path -LiteralPath $botConfigPath)) {
+ $botConfig = Get-Content -LiteralPath (Join-Path $repoRoot 'docs/local-admin/config.windows.example.json') -Raw | ConvertFrom-Json
+ $botConfig.ws[0].Url = 'ws://127.0.0.1:' + $wsServer[0].port
+ $botConfig.ws[0].AccessToken = $wsServer[0].token
+ Write-LocalJson $botConfigPath $botConfig
+}
+$botConfig = Get-Content -LiteralPath $botConfigPath -Raw | ConvertFrom-Json
+$paired = @($botConfig.ws | Where-Object { $_.Url -eq ('ws://127.0.0.1:' + $wsServer[0].port) -and $_.AccessToken -eq $wsServer[0].token })
+if ($paired.Count -eq 0) { throw 'Existing bot config does not match NapCat. Review both local configs before proceeding.' }
+$webConfig = Get-Content -LiteralPath $webConfigPath -Raw | ConvertFrom-Json
+if ($webConfig.host -ne '127.0.0.1') { throw 'WebUI must bind only to 127.0.0.1.' }
+Write-Host "Local OneBot configuration ready. WebUI: http://127.0.0.1:$($webConfig.port)/webui"
+Write-Host "Bot WebSocket: ws://127.0.0.1:$($wsServer[0].port)"
+Write-Host 'Tokens are stored in local config files and were not printed.'
diff --git a/scripts/smoke-local-admin.go b/scripts/smoke-local-admin.go
new file mode 100644
index 0000000000..50a64e35c9
--- /dev/null
+++ b/scripts/smoke-local-admin.go
@@ -0,0 +1,2866 @@
+//go:build ignore
+
+// Run from the repository root: go run scripts/smoke-local-admin.go
+// Uses synthetic accounts and a fresh fixture directory, never the live config.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+ "unicode/utf8"
+
+ "github.com/RomiChan/websocket"
+)
+
+type apiRequest struct {
+ Action string `json:"action"`
+ Params struct {
+ UserID int64 `json:"user_id"`
+ GroupID int64 `json:"group_id"`
+ Message json.RawMessage `json:"message"`
+ Messages json.RawMessage `json:"messages"`
+ Character string `json:"character"`
+ Text string `json:"text"`
+ } `json:"params"`
+ Echo json.RawMessage `json:"echo"`
+}
+
+func main() {
+ flag.Parse()
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var uiWait = flag.Bool("ui-wait", false, "Wait for local browser QA after integration checks.")
+var binaryName = flag.String("binary-name", "zerobot.exe", "Windows binary filename under .local/bin.")
+
+func run() error {
+ root, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ if filepath.Base(*binaryName) != *binaryName || filepath.Ext(*binaryName) != ".exe" {
+ return fmt.Errorf("binary-name must be an exe filename")
+ }
+ artifactRoot := filepath.Join(root, ".local", "smoke-admin")
+ if err := os.MkdirAll(artifactRoot, 0700); err != nil {
+ return err
+ }
+ runtimeRoot, err := os.MkdirTemp(artifactRoot, "run-")
+ if err != nil {
+ return err
+ }
+ if err := prepareFixture(root, runtimeRoot); err != nil {
+ return err
+ }
+ var modelMu sync.Mutex
+ modelRequests := []map[string]any{}
+ shortModelReply := "本地模型模拟回复🙂" + strings.Repeat("中", 200-utf8.RuneCountInString("本地模型模拟回复🙂"))
+ longModelReply := shortModelReply + "😀"
+ modelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer synthetic-model-key" {
+ http.Error(w, "fake key mismatch", 401)
+ return
+ }
+ var body map[string]any
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ http.Error(w, "bad model request", 400)
+ return
+ }
+ modelMu.Lock()
+ modelRequests = append(modelRequests, body)
+ modelMu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ bodyJSON, _ := json.Marshal(body)
+ reply := shortModelReply
+ if body["model"] == "smoke-voice-chat" {
+ reply = "VOICE_TEXT_OK"
+ }
+ if bytes.Contains(bodyJSON, []byte("quoted smoke")) {
+ reply = longModelReply
+ }
+ if body["model"] == "smoke-role-agent" {
+ kind := "user"
+ messages, _ := body["messages"].([]any)
+ if len(messages) > 0 {
+ system, _ := messages[0].(map[string]any)
+ prompt, _ := system["content"].(string)
+ if strings.Contains(prompt, "|群组踢人|set_group_kick|") {
+ kind = "admin"
+ }
+ if strings.Contains(prompt, "|群组设置管理员|set_group_admin|") {
+ kind = "owner"
+ }
+ }
+ text := "ROLE_AGENT_" + kind
+ pre := ""
+ if strings.LastIndex(string(bodyJSON), "ROLE_AGENT_CROSS_INFO") > strings.LastIndex(string(bodyJSON), "ROLE_AGENT_CHECK") {
+ pre = `{"action":"get_group_info","params":{"group_id":202}}` + "\n"
+ text = "ROLE_BLOCKED_OK"
+ }
+ if strings.LastIndex(string(bodyJSON), "ROLE_AGENT_CQ_ALL") > max(strings.LastIndex(string(bodyJSON), "ROLE_AGENT_CHECK"), strings.LastIndex(string(bodyJSON), "ROLE_AGENT_CROSS_INFO")) {
+ pre = `{"action":"send_group_msg","params":{"group_id":303,"message":"[CQ:at,qq=all]UNAUTHORIZED"}}` + "\n"
+ text = "ROLE_BLOCKED_OK"
+ }
+ reply = pre + `{"action":"send_group_msg","params":{"group_id":303,"message":"` + text + `"}}` + "\n" + `{"action":"end_action","params":{}}`
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}}})
+ }))
+ defer modelServer.Close()
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+ connected := make(chan *websocket.Conn, 1)
+ sent := make(chan apiRequest, 8)
+ var failServiceForward atomic.Bool
+ var roleCrossGroupQuery atomic.Bool
+ var voiceGets, voiceDirects, voiceRecordAttempts atomic.Int32
+ var voiceEmptyRecord, voiceInvalidTarget atomic.Bool
+ var writeMu sync.Mutex
+ server := &http.Server{ReadHeaderTimeout: 5 * time.Second}
+ upgrader := websocket.Upgrader{}
+ server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer local-smoke-only" {
+ http.Error(w, "unauthorized", 401)
+ return
+ }
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ writeMu.Lock()
+ err = conn.WriteJSON(map[string]any{"self_id": 87654321, "post_type": "meta_event", "meta_event_type": "lifecycle", "sub_type": "connect", "time": time.Now().Unix()})
+ writeMu.Unlock()
+ if err != nil {
+ return
+ }
+ connected <- conn
+ for {
+ var request apiRequest
+ if err := conn.ReadJSON(&request); err != nil {
+ return
+ }
+ data := map[string]any{"message_id": 999, "user_id": 12345678, "nickname": "Local smoke user"}
+ var apiData any = data
+ if request.Action == "get_ai_characters" {
+ apiData = []any{map[string]any{"characters": []any{map[string]string{"character_name": "Synthetic voice", "character_id": "synthetic-voice-1"}, map[string]string{"character_name": "Synthetic voice", "character_id": "synthetic-voice-2"}}}, map[string]any{"characters": []any{map[string]string{"character_name": "Repeated voice", "character_id": "synthetic-voice-1"}}}}
+ }
+ voiceAccepted := false
+ apiStatus, apiRetcode := "ok", 0
+ if request.Action == "get_ai_record" {
+ // Installed NapCat posts to the group before returning the URL.
+ voiceGets.Add(1)
+ apiData = "https://synthetic.voice/record"
+ voiceAccepted = true
+ }
+ if request.Action == "send_group_ai_record" {
+ voiceDirects.Add(1)
+ if (request.Params.GroupID != 303 && request.Params.GroupID != 404) || request.Params.Character != "synthetic-voice-1" {
+ voiceInvalidTarget.Store(true)
+ }
+ // Native successful delivery has no message ID.
+ data["message_id"] = 0
+ voiceAccepted = true
+ if request.Params.Text == "group-failure" {
+ apiStatus, apiRetcode, voiceAccepted = "failed", 1400, false
+ }
+ }
+ failedVoiceSend := false
+ if request.Action == "send_private_msg" || request.Action == "send_group_msg" {
+ var segments []struct {
+ Type string `json:"type"`
+ Data map[string]string `json:"data"`
+ }
+ _ = json.Unmarshal(request.Params.Message, &segments)
+ for _, segment := range segments {
+ if segment.Type == "record" {
+ voiceRecordAttempts.Add(1)
+ if segment.Data["file"] == "" {
+ voiceEmptyRecord.Store(true)
+ }
+ if segment.Data["file"] == "https://synthetic.voice/fail" {
+ failedVoiceSend = true
+ data["message_id"] = 0
+ }
+ }
+ }
+ }
+ if request.Action == "get_group_info" && request.Params.GroupID == 202 {
+ roleCrossGroupQuery.Store(true)
+ }
+ failedForward := strings.HasSuffix(request.Action, "forward_msg") && failServiceForward.CompareAndSwap(true, false)
+ if failedForward {
+ data["message_id"] = 0
+ }
+ if request.Action == "get_msg" {
+ data["message"] = []map[string]any{{"type": "text", "data": map[string]string{"text": "quoted context"}}}
+ data["message_type"] = "private"
+ data["sender"] = map[string]any{"user_id": 12345678, "nickname": "Quoted smoke user"}
+ }
+ writeMu.Lock()
+ err := conn.WriteJSON(map[string]any{"status": apiStatus, "retcode": apiRetcode, "data": apiData, "echo": request.Echo})
+ writeMu.Unlock()
+ if err != nil {
+ return
+ }
+ if voiceAccepted {
+ native := request
+ native.Action = "send_group_ai_record"
+ native.Params.Message = json.RawMessage(`[{"type":"record","data":{"file":"synthetic-native-record"}}]`)
+ sent <- native
+ }
+ if !failedForward && !failedVoiceSend && (request.Action == "send_private_msg" || request.Action == "send_group_msg" || request.Action == "send_private_forward_msg" || request.Action == "send_group_forward_msg") {
+ sent <- request
+ }
+ }
+ })
+ go server.Serve(listener)
+ defer server.Close()
+ config := map[string]any{
+ "zero": map[string]any{"nickname": []string{"七叶叶"}, "command_prefix": "/", "super_users": []int64{12345678}, "ring_len": 4096, "latency": 233000000, "max_process_time": 240000000000, "mark_message": true},
+ "ws": []map[string]any{{"Url": "ws://" + listener.Addr().String(), "AccessToken": "local-smoke-only"}}, "wss": nil, "force_base64_file": false,
+ }
+ encoded, err := json.Marshal(config)
+ if err != nil {
+ return err
+ }
+ configPath := filepath.Join(runtimeRoot, "config.json")
+ if err := os.WriteFile(configPath, encoded, 0600); err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+ adminListener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return err
+ }
+ adminAddress := adminListener.Addr().String()
+ adminListener.Close()
+ adminData := filepath.Join(runtimeRoot, "admin-"+time.Now().Format("20060102-150405"))
+ cmd := exec.CommandContext(ctx, filepath.Join(root, ".local", "bin", *binaryName), "-c", configPath, "-admin", "-admin-addr", adminAddress, "-admin-data", adminData)
+ cmd.Dir = runtimeRoot
+ cmd.Env = append(os.Environ(), "DEBUG_MODE=1")
+ stdout, err := os.Create(filepath.Join(runtimeRoot, "stdout.log"))
+ if err != nil {
+ return err
+ }
+ defer stdout.Close()
+ stderr, err := os.Create(filepath.Join(runtimeRoot, "stderr.log"))
+ if err != nil {
+ return err
+ }
+ defer stderr.Close()
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ exited := make(chan error, 1)
+ go func() { exited <- cmd.Wait() }()
+ defer func() {
+ cancel()
+ select {
+ case <-exited:
+ case <-time.After(3 * time.Second):
+ }
+ }()
+ var conn *websocket.Conn
+ select {
+ case conn = <-connected:
+ case err := <-exited:
+ return fmt.Errorf("isolated bot exited before connecting: %v", err)
+ case <-ctx.Done():
+ return fmt.Errorf("isolated bot startup timed out")
+ }
+ defer conn.Close()
+ // Let RunAndBlock finish installing the event listener after the handshake.
+ time.Sleep(500 * time.Millisecond)
+ controlMessageID := 100
+ sendMessage := func(uid, gid int64, role, command string, atBot bool) (string, error) {
+ controlMessageID++
+ segments := []map[string]any{{"type": "text", "data": map[string]string{"text": command}}}
+ // This one fixture command exercises QQ reply-element preprocessing.
+ if command == "/gpt quoted smoke" {
+ segments = append([]map[string]any{{"type": "reply", "data": map[string]string{"id": "42"}}}, segments...)
+ }
+ if atBot {
+ segments = append([]map[string]any{{"type": "at", "data": map[string]string{"qq": "87654321"}}}, segments...)
+ }
+ event := map[string]any{"time": time.Now().Unix(), "self_id": 87654321, "post_type": "message", "message_type": "private", "sub_type": "friend", "message_id": controlMessageID, "user_id": uid, "raw_message": command, "message": segments, "sender": map[string]any{"user_id": uid, "nickname": "Synthetic permission user", "sex": "unknown", "age": 0, "role": role}}
+ if gid != 0 {
+ event["message_type"], event["sub_type"], event["group_id"] = "group", "normal", gid
+ }
+ writeMu.Lock()
+ err := conn.WriteJSON(event)
+ writeMu.Unlock()
+ if err != nil {
+ return "", err
+ }
+ select {
+ case reply := <-sent:
+ if (gid == 0 && (reply.Params.UserID != uid || !strings.HasPrefix(reply.Action, "send_private_"))) || (gid != 0 && (reply.Params.GroupID != gid || !strings.HasPrefix(reply.Action, "send_group_"))) {
+ return "", fmt.Errorf("unexpected command recipient")
+ }
+ raw := reply.Params.Message
+ if (gid > 0 && strings.HasPrefix(command, "发送AI语音") && command != "发送AI语音group-failure") || command == "VOICE_CHAT_GROUP" {
+ var segments []struct {
+ Type string `json:"type"`
+ Data map[string]string `json:"data"`
+ }
+ if json.Unmarshal(raw, &segments) != nil || len(segments) != 1 || segments[0].Type != "record" || segments[0].Data["file"] == "" {
+ return "", fmt.Errorf("voice command did not send one nonempty record")
+ }
+ select {
+ case <-sent:
+ return "", fmt.Errorf("native voice command produced an extra reply")
+ case <-time.After(200 * time.Millisecond):
+ }
+ return "VOICE_OK", nil
+ }
+ if strings.HasSuffix(reply.Action, "forward_msg") {
+ raw = reply.Params.Messages
+ }
+ if command == "/gpt group smoke" || command == "/gpt quoted smoke" {
+ expectedAction, expectedText := "send_group_msg", shortModelReply
+ if command == "/gpt quoted smoke" {
+ expectedAction, expectedText = "send_private_forward_msg", longModelReply
+ }
+ text, err := smokeMessageText(raw)
+ if err != nil || reply.Action != expectedAction || text != expectedText {
+ return "", fmt.Errorf("GPT Unicode length boundary changed delivery or lost content: %s", reply.Action)
+ }
+ }
+ if (strings.HasPrefix(command, "/服务列表") || strings.HasPrefix(command, "/service_list")) && strings.HasSuffix(reply.Action, "forward_msg") {
+ if err := smokeServiceListNodes(raw); err != nil {
+ return "", err
+ }
+ }
+ return smokeMessageText(raw)
+ case err := <-exited:
+ return "", fmt.Errorf("isolated command process exited: %v", err)
+ case <-time.After(3 * time.Second):
+ return "", nil
+ }
+ }
+ sendControl := func(uid int64, command string) (string, error) {
+ return sendMessage(uid, 0, "member", command, false)
+ }
+ if err := verifyAdmin("http://"+adminAddress, adminData, modelServer.URL, sendControl, sendMessage); err != nil {
+ return err
+ }
+ if err := verifyGroupResponses(sendMessage); err != nil {
+ return err
+ }
+ if voiceGets.Load() != 0 || voiceDirects.Load() != 5 || voiceRecordAttempts.Load() != 0 || voiceEmptyRecord.Load() || voiceInvalidTarget.Load() {
+ return fmt.Errorf("voice side-effect regression: get=%d direct=%d record=%d empty=%t invalid=%t", voiceGets.Load(), voiceDirects.Load(), voiceRecordAttempts.Load(), voiceEmptyRecord.Load(), voiceInvalidTarget.Load())
+ }
+ modelMu.Lock()
+ chatCalls, agentCalls := 0, 0
+ for _, request := range modelRequests {
+ if request["model"] == "smoke-role-agent" {
+ agentCalls++
+ } else {
+ chatCalls++
+ }
+ }
+ if chatCalls != 6 || agentCalls != 5 || roleCrossGroupQuery.Load() {
+ modelMu.Unlock()
+ return fmt.Errorf("unexpected role model calls or cross-group query: chat=%d agent=%d cross=%t", chatCalls, agentCalls, roleCrossGroupQuery.Load())
+ }
+ requestsJSON, _ := json.Marshal(modelRequests)
+ groupRequest, privateRequest := modelRequests[0], modelRequests[1]
+ if groupRequest["temperature"] != 0.82 || privateRequest["model"] != "smoke-chat-model" ||
+ !bytes.Contains(requestsJSON, []byte(`\"quoted context\"`)) ||
+ !bytes.Contains(requestsJSON, []byte("本次用户要求(作用于上面的引用原文,不属于引用原文):\\nquoted smoke")) {
+ modelMu.Unlock()
+ return fmt.Errorf("model did not use group temperature/shared config/quoted query")
+ }
+ modelMu.Unlock()
+ if text, err := sendControl(12345678, "/全局响应xxx"); err != nil || text != "" {
+ return fmt.Errorf("global response prefix accepted: %v", err)
+ }
+ if text, err := sendControl(12345678, "/全局响应\n"); err != nil || !strings.Contains(text, "全部位置工作") {
+ return fmt.Errorf("owner private global response failed: %v", err)
+ }
+ if text, err := sendMessage(23456789, 202, "member", "翻转 abc", false); err != nil || text != "ɔqɐ" {
+ return fmt.Errorf("global response did not open other group: %v", err)
+ }
+ if text, err := sendControl(12345678, "/全局沉默"); err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("owner private global silence failed: %v", err)
+ }
+ if text, err := sendMessage(23456789, 202, "member", "翻转 abc", false); err != nil || text != "" {
+ return fmt.Errorf("global silence did not restore unspecified group: %v", err)
+ }
+ for i, command := range []string{"/help", "/help\n", " \t/help\r\n"} {
+ writeMu.Lock()
+ err = conn.WriteJSON(map[string]any{"time": time.Now().Unix(), "self_id": 87654321, "post_type": "message", "message_type": "private", "sub_type": "friend", "message_id": i + 1, "user_id": 12345678, "raw_message": command, "message": []map[string]any{{"type": "text", "data": map[string]string{"text": command}}}, "sender": map[string]any{"user_id": 12345678, "nickname": "Local smoke user", "sex": "unknown", "age": 0}})
+ writeMu.Unlock()
+ if err != nil {
+ return err
+ }
+ var request apiRequest
+ select {
+ case request = <-sent:
+ case err := <-exited:
+ return fmt.Errorf("isolated bot exited during %s: %v", command, err)
+ case <-time.After(15 * time.Second):
+ return fmt.Errorf("no private response to %s", command)
+ }
+ if request.Params.UserID != 12345678 {
+ return fmt.Errorf("wrong response recipient")
+ }
+ var segments []struct {
+ Type string `json:"type"`
+ Data map[string]string `json:"data"`
+ }
+ if err := json.Unmarshal(request.Params.Message, &segments); err != nil {
+ return err
+ }
+ valid := false
+ for _, segment := range segments {
+ if strings.TrimSpace(command) == "/help" && segment.Type == "text" && strings.Contains(segment.Data["text"], "OneBot + ZeroBot + Golang") {
+ valid = true
+ }
+ }
+ if !valid {
+ return fmt.Errorf("unexpected response payload to %s", command)
+ }
+ }
+ failServiceForward.Store(true)
+ if text, err := sendControl(12345678, "/服务列表"); err != nil || !strings.Contains(text, "服务列表聊天记录发送失败") || failServiceForward.Load() {
+ return fmt.Errorf("service list failed forward did not give truthful feedback: %v", err)
+ }
+ if err = verifyReverseDriver(root, runtimeRoot, *binaryName); err != nil {
+ return err
+ }
+ result := map[string]any{"passed": true, "isolated": true, "realQQMessagesSent": false, "helpTextResponse": true, "helpWhitespaceResponse": true, "serviceListForwardUsagePassed": true, "adminAuthPassed": true, "adminActualRegistryPassed": true, "adminEnabledCommandRepliesPassed": true, "adminConnectionsSaveBackupPassed": true, "adminPluginSwitchPassed": true, "chatWebSharedStatePassed": true, "pluginMessageGatePassed": true, "nonOwnerGlobalCommandRejected": true, "completedAt": time.Now().Format(time.RFC3339)}
+ result["groupResponseMentionRequiredPassed"] = true
+ result["groupSilenceMentionRequiredPassed"] = true
+ result["groupResponsePermissionsPassed"] = true
+ result["responseCommandBoundaryPassed"] = true
+ result["responseLocationIsolationPassed"] = true
+ result["ownerPrivateGlobalResponsePassed"] = true
+ result["aiSharedConfigPassed"] = true
+ result["aiConfigChecksPassed"] = true
+ result["aiSecretRedactionPassed"] = true
+ result["aiGroupParametersPassed"] = true
+ result["gptMockReplyAndQuotePassed"] = true
+ result["gptQuoteBoundaryPassed"] = true
+ result["aiRoleCommandsPassed"] = true
+ result["aiAgentRolePromptsPassed"] = true
+ result["aiAgentGroupIsolationPassed"] = true
+ result["aiAgentMemberAtAllDeniedPassed"] = true
+ result["qqVoiceDeliveryPassed"] = true
+ result["qqVoiceNativeDirectPassed"] = true
+ result["qqVoicePrivateNoGroupSendPassed"] = true
+ result["qqVoiceChatOutputScopePassed"] = true
+ result["qqVoiceZeroIDReceiptPassed"] = true
+ result["qqVoiceNoResourceForwardingPassed"] = true
+ result["qqVoiceFailureAndScopePassed"] = true
+ result["qqVoiceModelSelectionPassed"] = true
+ result["gptReplyDeliveryBoundaryPassed"] = true
+ result["gptCommandBoundaryAndQuotaPassed"] = true
+ result["authenticatedLogsPassed"] = true
+ result["logsRedactionAndCursorPassed"] = true
+ result["logExportPassed"] = true
+ result["appearanceAssetsPassed"] = true
+ result["pluginSearchAssetsPassed"] = true
+ result["pluginDraftAssetsPassed"] = true
+ result["connectionDraftAssetsPassed"] = true
+ result["connectionDiscardAssetsPassed"] = true
+ result["logIssueFilterPassed"] = true
+ result["logTimeFilterPassed"] = true
+ result["backupPreviewExpiryAssetsPassed"] = true
+ result["backupCatalogExportAssetsPassed"] = true
+ result["logSummaryPassed"] = true
+ result["logQuickRangeAssetsPassed"] = true
+ result["pluginSortAssetsPassed"] = true
+ result["pluginDiscardOneAssetsPassed"] = true
+ result["backupSortAssetsPassed"] = true
+ result["backupSizeSummaryAssetsPassed"] = true
+ result["logReadOnceAssetsPassed"] = true
+ result["backupDateFilterAssetsPassed"] = true
+ result["pluginStateCatalogAssetsPassed"] = true
+ result["logCopyEntryAssetsPassed"] = true
+ result["aiDraftFieldsAssetsPassed"] = true
+ result["connectionDraftFieldsAssetsPassed"] = true
+ result["logClearDisplayAssetsPassed"] = true
+ if _, err := os.Stat(filepath.Join(runtimeRoot, "data/Chat/dict.txt")); !os.IsNotExist(err) {
+ return fmt.Errorf("startup unexpectedly loaded wordcount dictionary: %v", err)
+ }
+ result["wordcountLazyStartupPassed"] = true
+ result["adminPasswordChangePassed"] = true
+ result["backupImpactPassed"] = true
+ result["connectionAdditionPassed"] = true
+ result["connectionRemovalPassed"] = true
+ result["startupDifferencesPassed"] = true
+ result["reverseConnectionPassed"] = true
+ result["reverseDriverAuthAndCommandPassed"] = true
+ result["backupSearchAssetsPassed"] = true
+ result["backupPaginationAssetsPassed"] = true
+ result["pluginPaginationAssetsPassed"] = true
+ result["pluginPendingAssetsPassed"] = true
+ result["pluginResponseStatusPassed"] = true
+ result["pluginUsageExportAssetsPassed"] = true
+ result["pluginSaveAllAssetsPassed"] = true
+ result["pluginSequentialSwitchesPassed"] = true
+ result["aiDraftAssetsPassed"] = true
+ result["aiDiscardAssetsPassed"] = true
+ result["aiPrivateConversationPassed"] = true
+ result["overviewAlertsPassed"] = true
+ result["backupListAndManualCreatePassed"] = true
+ result["backupPreviewRestoreAndConflictPassed"] = true
+ result["backupCleanupPreviewAndRetentionPassed"] = true
+ result["controlPolicyBackupsPassed"] = true
+ result["controlPolicyFailureFeedbackPassed"] = true
+ encoded, err = json.MarshalIndent(result, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(runtimeRoot, "result.json"), encoded, 0600); err != nil {
+ return err
+ }
+ if err := os.WriteFile(filepath.Join(artifactRoot, "latest.json"), encoded, 0600); err != nil {
+ return err
+ }
+ fmt.Println(string(encoded))
+ fmt.Println("ARTIFACTS=" + runtimeRoot)
+ if *uiWait {
+ fmt.Println("SMOKE_ADMIN_URL=http://" + adminAddress)
+ select {
+ case <-time.After(5 * time.Minute):
+ case <-ctx.Done():
+ }
+ }
+ return nil
+}
+
+func prepareFixture(root, destination string) error {
+ for _, relative := range []string{
+ "data/Control/kanban.png", "data/Control/zbpuwu.png",
+ "data/Font/GlowSansSC-Normal-ExtraBold.ttf", "data/Font/Impact.ttf",
+ "data/Font/Torus-Regular.otf", "data/Font/FOT-Yuruka Std.ttf",
+ "data/hyaku/小倉百人一首.csv",
+ "data/Dish/dishes.db", "data/Pig/pig_data.json", "data/McFish/articlesInfo.json",
+ } {
+ if err := copyFixtureFile(filepath.Join(root, filepath.FromSlash(relative)), filepath.Join(destination, filepath.FromSlash(relative))); err != nil {
+ return fmt.Errorf("fixture %s: %w", relative, err)
+ }
+ }
+ return nil
+}
+
+func copyFixtureFile(source, destination string) error {
+ in, err := os.Open(source)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(out, in)
+ closeErr := out.Close()
+ if err != nil {
+ return err
+ }
+ return closeErr
+}
+
+// Every event below uses synthetic IDs and the isolated OneBot connection.
+// Assert replies AND subsequent plugin behavior so a success message alone
+// cannot hide an unchanged response gate or a change leaking to another group.
+func verifyGroupResponses(send func(int64, int64, string, string, bool) (string, error)) error {
+ cases := []struct {
+ name, role, command, want string
+ uid, gid int64
+ atBot bool
+ }{
+ {"owner response without mention rejected", "member", "/响应", "", 12345678, 101, false},
+ {"missing mention preserves silence", "member", "翻转 abc", "", 23456789, 101, false},
+ {"owner response with mention", "member", "/响应", "工作", 12345678, 101, true},
+ {"current group enabled", "member", "翻转 abc", "ɔqɐ", 23456789, 101, false},
+ {"other group stays silent", "member", "翻转 abc", "", 23456789, 202, false},
+ {"member silence rejected", "member", "/沉默", "", 23456789, 101, false},
+ {"member silence with mention rejected", "member", "/沉默", "", 23456789, 101, true},
+ {"rejected silence preserves response", "member", "翻转 abc", "ɔqɐ", 23456789, 101, false},
+ {"admin silence without mention rejected", "admin", "/沉默\n", "", 23456789, 101, false},
+ {"missing mention preserves response", "member", "翻转 abc", "ɔqɐ", 23456789, 101, false},
+ {"admin silence with mention", "admin", "/沉默\n", "休息", 23456789, 101, true},
+ {"current group silent", "member", "翻转 abc", "", 23456789, 101, false},
+ {"member response rejected", "member", "/响应", "", 23456789, 101, false},
+ {"member response with mention rejected", "member", "/响应", "", 23456789, 101, true},
+ {"response prefix rejected", "admin", "/响应xxx", "", 23456789, 101, true},
+ {"response arguments rejected", "admin", "/响应 202", "", 23456789, 101, true},
+ {"rejected response preserves silence", "member", "翻转 abc", "", 23456789, 101, false},
+ {"admin response without mention rejected", "admin", "/响应\r\n", "", 23456789, 101, false},
+ {"missing admin mention preserves silence", "member", "翻转 abc", "", 23456789, 101, false},
+ {"admin response with whitespace and mention", "admin", "/响应\r\n", "工作", 23456789, 101, true},
+ {"admin response enables plugin", "member", "翻转 abc", "ɔqɐ", 23456789, 101, false},
+ {"group owner silence", "owner", "/沉默", "休息", 34567890, 101, true},
+ {"admin response with mention", "admin", "/response", "工作", 23456789, 101, true},
+ {"other group remains silent", "member", "翻转 abc", "", 23456789, 202, false},
+ {"group owner silence alias", "owner", "/silence", "休息", 34567890, 101, true},
+ {"global response restricted to bot owner", "admin", "/全局响应", "", 23456789, 101, true},
+ {"global denial preserves other group", "member", "翻转 abc", "", 23456789, 202, false},
+ }
+ for _, c := range cases {
+ text, err := send(c.uid, c.gid, c.role, c.command, c.atBot)
+ if err != nil || (c.want == "" && text != "") || (c.want != "" && !strings.Contains(text, c.want)) {
+ return fmt.Errorf("group response check %q failed: got %q, want %q, error %v", c.name, text, c.want, err)
+ }
+ }
+ return nil
+}
+
+func smokeServiceListNodes(raw json.RawMessage) error {
+ var nodes []struct {
+ Type string
+ Data map[string]json.RawMessage
+ }
+ if err := json.Unmarshal(raw, &nodes); err != nil {
+ return err
+ }
+ if len(nodes) < 2 || len(nodes) > 21 {
+ return fmt.Errorf("service list forward bounds invalid")
+ }
+ for i, node := range nodes {
+ var sender string
+ _ = json.Unmarshal(node.Data["uin"], &sender)
+ if node.Type != "node" || sender != "87654321" {
+ return fmt.Errorf("service list node sender/type invalid")
+ }
+ text, err := smokeMessageText(node.Data["content"])
+ if err != nil || !utf8.ValidString(text) || utf8.RuneCountInString(text) > 1200 {
+ return fmt.Errorf("service list text invalid or exceeds bounds")
+ }
+ if i == 0 && !strings.Contains(text, "服务列表与用法") {
+ return fmt.Errorf("service list header missing")
+ }
+ if i > 0 && (!strings.Contains(text, "插件:") || !strings.Contains(text, "当前会话:") || !strings.Contains(text, "使用方式:")) {
+ return fmt.Errorf("service list usage/state missing")
+ }
+ }
+ return nil
+}
+
+func smokeMessageText(raw json.RawMessage) (string, error) {
+ // OneBot accepts string/CQ and segment-array forms, including node content.
+ if bytes.HasPrefix(bytes.TrimSpace(raw), []byte(`"`)) {
+ var text string
+ err := json.Unmarshal(raw, &text)
+ return text, err
+ }
+ var segments []struct {
+ Type string
+ Data map[string]json.RawMessage
+ }
+ if err := json.Unmarshal(raw, &segments); err != nil {
+ return "", err
+ }
+ var out strings.Builder
+ for _, segment := range segments {
+ switch segment.Type {
+ case "text":
+ var text string
+ if err := json.Unmarshal(segment.Data["text"], &text); err != nil {
+ return "", err
+ }
+ out.WriteString(text)
+ case "node":
+ text, err := smokeMessageText(segment.Data["content"])
+ if err != nil {
+ return "", err
+ }
+ out.WriteString(text)
+ }
+ }
+ return out.String(), nil
+}
+
+func verifyBackupImpacts(preview map[string]any) error {
+ backup, ok := preview["backup"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("impact backup metadata missing")
+ }
+ scope, _ := backup["scope"].(string)
+ labels := map[string][]string{
+ "startup-config": {"正向 OneBot 连接", "反向 OneBot 连接", "机器人主人", "其他机器人参数", "其他启动设置"},
+ "admin-options": {"后台启用状态", "后台访问地址"},
+ "control-db": {"响应规则与AI扩展", "全局用户封禁", "插件开关继承标记", "插件与其他库内数据"},
+ }[scope]
+ items, ok := preview["impacts"].([]any)
+ if !ok || len(labels) == 0 || len(items) < len(labels) || len(items) > len(labels)+1 {
+ return fmt.Errorf("impact categories incomplete")
+ }
+ changed, rows := false, float64(0)
+ for i, raw := range items {
+ item, ok := raw.(map[string]any)
+ if !ok {
+ return fmt.Errorf("invalid impact record")
+ }
+ label := "字段名称或表示差异"
+ if i < len(labels) {
+ label = labels[i]
+ } else if scope != "startup-config" {
+ return fmt.Errorf("unexpected impact category")
+ }
+ if item["label"] != label {
+ return fmt.Errorf("unexpected impact label")
+ }
+ flag, ok := item["changed"].(bool)
+ if !ok {
+ return fmt.Errorf("impact change marker missing")
+ }
+ changed = changed || flag
+ for key := range item {
+ switch key {
+ case "label", "changed", "currentCount", "backupCount", "unit":
+ default:
+ return fmt.Errorf("unexpected impact value")
+ }
+ }
+ if scope != "admin-options" && i < len(labels) {
+ for _, key := range []string{"currentCount", "backupCount"} {
+ count, ok := item[key].(float64)
+ if !ok || count < 0 || count != float64(int(count)) {
+ return fmt.Errorf("invalid impact count")
+ }
+ if key == "backupCount" {
+ rows += count
+ }
+ }
+ } else if item["currentCount"] != nil || item["backupCount"] != nil {
+ return fmt.Errorf("unexpected impact count")
+ }
+ }
+ if preview["changed"] != changed || scope == "control-db" && preview["rows"] != rows {
+ return fmt.Errorf("impact summary inconsistent")
+ }
+ encoded, _ := json.Marshal(preview)
+ for _, secret := range []string{"synthetic-model-key", "local-smoke-only", "127.0.0.1:3010", "127.0.0.1:3111"} {
+ if bytes.Contains(encoded, []byte(secret)) {
+ return fmt.Errorf("impact preview leaked a value")
+ }
+ }
+ return nil
+}
+
+func verifyPluginResponseStatus(do func(string, string, any, string) (int, map[string]any, error), sendControl func(int64, string) (string, error), sendGroup func(int64, int64, string, string, bool) (string, error)) error {
+ read := func(group string, global, local bool) error {
+ status, before, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("response status backup count unavailable")
+ }
+ status, value, err := do("GET", "/api/plugins?group="+group, nil, "")
+ if err != nil || status != 200 || value["groupId"] != group {
+ return fmt.Errorf("response status read failed")
+ }
+ state, ok := value["response"].(map[string]any)
+ source := "none"
+ if global {
+ source = "global"
+ } else if local {
+ source = "group"
+ }
+ if !ok || len(state) != 4 || state["globalEnabled"] != global || state["groupEnabled"] != local || state["effectiveEnabled"] != (global || local) || state["source"] != source {
+ return fmt.Errorf("response status disagreed with command rules")
+ }
+ status, after, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 || len(before["backups"].([]any)) != len(after["backups"].([]any)) {
+ return fmt.Errorf("response status read created a backup")
+ }
+ return nil
+ }
+ for _, group := range []string{"0", "101", "202"} {
+ if err := read(group, false, false); err != nil {
+ return err
+ }
+ }
+ text, err := sendGroup(12345678, 101, "member", "/响应", true)
+ if err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("response status group setup failed")
+ }
+ if err := read("101", false, true); err != nil {
+ return err
+ }
+ if err := read("202", false, false); err != nil {
+ return err
+ }
+ text, err = sendControl(12345678, "/全局响应")
+ if err != nil || !strings.Contains(text, "全部位置工作") {
+ return fmt.Errorf("response status global setup failed")
+ }
+ if err := read("0", true, false); err != nil {
+ return err
+ }
+ if err := read("101", true, true); err != nil {
+ return err
+ }
+ if err := read("202", true, false); err != nil {
+ return err
+ }
+ text, err = sendGroup(12345678, 202, "member", "/沉默", true)
+ if err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("response status covered group silence failed")
+ }
+ if err := read("202", true, false); err != nil {
+ return err
+ }
+ text, err = sendControl(12345678, "/全局沉默")
+ if err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("response status global cleanup failed")
+ }
+ if err := read("101", false, true); err != nil {
+ return err
+ }
+ if err := read("202", false, false); err != nil {
+ return err
+ }
+ text, err = sendGroup(12345678, 101, "member", "/沉默", true)
+ if err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("response status group cleanup failed")
+ }
+ return read("101", false, false)
+}
+
+func verifySequentialPluginSaves(do func(string, string, any, string) (int, map[string]any, error), csrf string) error {
+ read := func() (map[string]map[string]any, error) {
+ status, value, err := do("GET", "/api/plugins?group=0", nil, "")
+ if err != nil || status != 200 || value["controlAvailable"] != true {
+ return nil, fmt.Errorf("sequential plugin read failed")
+ }
+ states := map[string]map[string]any{}
+ for _, raw := range value["plugins"].([]any) {
+ p := raw.(map[string]any)
+ if p["name"] == "chat" || p["name"] == "atri" {
+ states[p["name"].(string)] = p["state"].(map[string]any)
+ }
+ }
+ if len(states) != 2 {
+ return nil, fmt.Errorf("sequential plugin fixtures absent")
+ }
+ return states, nil
+ }
+ backups := func() (int, error) {
+ status, value, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 {
+ return 0, fmt.Errorf("sequential backup count failed")
+ }
+ n := 0
+ for _, raw := range value["backups"].([]any) {
+ if raw.(map[string]any)["scope"] == "control-db" {
+ n++
+ }
+ }
+ return n, nil
+ }
+ write := func(name, mode string, old map[string]any, expected int) (map[string]any, error) {
+ status, value, err := do("POST", "/api/plugin-switch", map[string]any{"name": name, "groupId": "0", "mode": mode, "revision": old["revision"]}, csrf)
+ if err != nil || status != expected {
+ return nil, fmt.Errorf("sequential plugin save status %d expected %d", status, expected)
+ }
+ if status != 200 {
+ return nil, nil
+ }
+ state, ok := value["state"].(map[string]any)
+ if !ok || value["name"] != name || value["groupId"] != "0" || state["globalMode"] != mode || len(fmt.Sprint(state["revision"])) != 64 {
+ return nil, fmt.Errorf("sequential plugin result mismatched")
+ }
+ return state, nil
+ }
+ initial, err := read()
+ if err != nil {
+ return err
+ }
+ before, err := backups()
+ if err != nil {
+ return err
+ }
+ for _, name := range []string{"chat", "atri"} {
+ mode := "enabled"
+ if initial[name]["globalMode"] == mode {
+ mode = "disabled"
+ }
+ if _, err := write(name, mode, initial[name], 200); err != nil {
+ return err
+ }
+ }
+ after, err := backups()
+ if err != nil || after != before+2 {
+ return fmt.Errorf("sequential changes did not create individual backups")
+ }
+ changed, err := read()
+ if err != nil {
+ return err
+ }
+ // The first restore succeeds; the second uses its old revision and conflicts.
+ // The already successful item stays committed, matching UI stop-on-error rules.
+ first, err := write("chat", initial["chat"]["globalMode"].(string), changed["chat"], 200)
+ if err != nil {
+ return err
+ }
+ if _, err := write("atri", initial["atri"]["globalMode"].(string), initial["atri"], 409); err != nil {
+ return err
+ }
+ partial, err := read()
+ if err != nil || partial["chat"]["revision"] != first["revision"] || partial["atri"]["revision"] != changed["atri"]["revision"] {
+ return fmt.Errorf("partial failure lost successful or remaining state")
+ }
+ if _, err := write("atri", initial["atri"]["globalMode"].(string), partial["atri"], 200); err != nil {
+ return err
+ }
+ final, err := read()
+ if err != nil || final["chat"]["globalMode"] != initial["chat"]["globalMode"] || final["atri"]["globalMode"] != initial["atri"]["globalMode"] {
+ return fmt.Errorf("sequential fixture cleanup did not restore modes")
+ }
+ after, err = backups()
+ if err != nil || after != before+4 {
+ return fmt.Errorf("failed item wrote a backup or restore failed")
+ }
+ if _, err := write("chat", final["chat"]["globalMode"].(string), final["chat"], 200); err != nil {
+ return err
+ }
+ unchanged, err := backups()
+ if err != nil || unchanged != after {
+ return fmt.Errorf("unchanged plugin produced an unnecessary backup")
+ }
+ return nil
+}
+
+func verifyStartupSummary(value map[string]any, target string, running, saved int, expectedChanged bool) error {
+ items, ok := value["startupDifferences"].([]any)
+ labels := []string{"正向 OneBot 连接", "反向 OneBot 连接", "机器人主人", "其他机器人参数", "其他启动设置", "字段名称或表示差异"}
+ if !ok || len(items) < 5 || len(items) > 6 {
+ return fmt.Errorf("startup difference categories missing")
+ }
+ changed, found := false, false
+ for i, raw := range items {
+ item, ok := raw.(map[string]any)
+ if !ok || item["label"] != labels[i] {
+ return fmt.Errorf("startup difference labels incorrect")
+ }
+ state, ok := item["changed"].(bool)
+ if !ok {
+ return fmt.Errorf("startup difference state missing")
+ }
+ changed = changed || state
+ for key := range item {
+ if key != "label" && key != "changed" && key != "unit" && key != "runningCount" && key != "savedCount" {
+ return fmt.Errorf("startup difference contains unapproved field")
+ }
+ }
+ if i < 5 {
+ a, aok := item["runningCount"].(float64)
+ b, bok := item["savedCount"].(float64)
+ if !aok || !bok || a < 0 || b < 0 {
+ return fmt.Errorf("startup difference counts invalid")
+ }
+ if item["label"] == target {
+ found = true
+ if a != float64(running) || b != float64(saved) || state != expectedChanged {
+ return fmt.Errorf("startup difference target did not match snapshot")
+ }
+ }
+ } else if item["runningCount"] != nil || item["savedCount"] != nil {
+ return fmt.Errorf("representation row invented counts")
+ }
+ }
+ if !found || value["restartRequired"] != changed {
+ return fmt.Errorf("startup summary disagrees with restart decision")
+ }
+ return nil
+}
+
+func verifyLogSummary(value map[string]any) error {
+ summary, ok := value["summary"].(map[string]any)
+ if !ok || len(summary) != 2 {
+ return fmt.Errorf("log summary schema unavailable")
+ }
+ matched, ok := summary["matched"].(float64)
+ retained, rok := value["retained"].(float64)
+ if !ok || !rok || matched < 0 || matched > retained || retained > 1000 || matched != float64(int(matched)) {
+ return fmt.Errorf("log matching total invalid")
+ }
+ levels, ok := summary["levels"].(map[string]any)
+ if !ok || len(levels) != 8 {
+ return fmt.Errorf("log level summary schema invalid")
+ }
+ var total float64
+ for _, name := range []string{"panic", "fatal", "error", "warning", "info", "debug", "trace", "other"} {
+ n, ok := levels[name].(float64)
+ if !ok || n < 0 || n > 1000 || n != float64(int(n)) {
+ return fmt.Errorf("log level count invalid")
+ }
+ total += n
+ }
+ if total != matched || len(value["entries"].([]any)) > int(matched) {
+ return fmt.Errorf("log summary differs from retained matching rows")
+ }
+ return nil
+}
+
+func verifyAIConfigChecks(value map[string]any) error {
+ global, ok := value["global"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("AI checks global snapshot unavailable")
+ }
+ fields, _ := global["fields"].(map[string]any)
+ keys, _ := global["keys"].(map[string]any)
+ checks, ok := global["checks"].(map[string]any)
+ if !ok || len(checks) != 3 {
+ return fmt.Errorf("AI checks missing independent interfaces")
+ }
+ for _, spec := range []struct{ name, prefix, key string }{{"chat", "", "Key"}, {"agent", "Agent", "AgentKey"}, {"image", "Image", "ImageKey"}} {
+ check, ok := checks[spec.name].(map[string]any)
+ if !ok || len(check) != 8 {
+ return fmt.Errorf("AI check schema invalid")
+ }
+ for _, value := range check {
+ if _, ok := value.(bool); !ok {
+ return fmt.Errorf("AI check exposes field contents")
+ }
+ }
+ kind, _ := fields[spec.prefix+"Type"].(float64)
+ model, _ := fields[spec.prefix+"ModelName"].(string)
+ address, _ := fields[spec.prefix+"API"].(string)
+ u, err := url.Parse(address)
+ addressValid := err == nil && u.Hostname() != "" && (u.Scheme == "http" || u.Scheme == "https") && u.User == nil && u.RawQuery == "" && u.Fragment == ""
+ if addressValid && u.Port() != "" {
+ port, err := strconv.ParseUint(u.Port(), 10, 16)
+ addressValid = err == nil && port != 0
+ }
+ modelConfigured, addressConfigured := strings.TrimSpace(model) != "", strings.TrimSpace(address) != ""
+ keyConfigured := keys[spec.key] == true
+ typeValid, modelRequired, keyRequired := kind >= 0 && kind <= 2, spec.name != "image", kind != 1
+ expected := map[string]bool{"typeValid": typeValid, "modelConfigured": modelConfigured, "modelRequired": modelRequired, "addressConfigured": addressConfigured, "addressValid": addressValid, "keyConfigured": keyConfigured, "keyRequired": keyRequired,
+ "complete": typeValid && addressConfigured && addressValid && (modelConfigured || !modelRequired) && (keyConfigured || !keyRequired)}
+ for name, want := range expected {
+ if check[name] != want {
+ return fmt.Errorf("AI check differs from returned snapshot: %s/%s", spec.name, name)
+ }
+ }
+ }
+ return nil
+}
+
+func verifyAdmin(baseURL, dataDir, modelURL string, sendControl func(int64, string) (string, error), sendGroup func(int64, int64, string, string, bool) (string, error)) error {
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar, Timeout: 15 * time.Second}
+ for _, asset := range []struct{ path, kind, marker string }{
+ {"/", "text/html; charset=utf-8", `data-theme-select`},
+ {"/", "text/html; charset=utf-8", `id="password-change-form"`},
+ {"/security.js", "text/javascript; charset=utf-8", `changeAdminPassword`},
+ {"/", "text/html; charset=utf-8", `id="ai-config-checks"`},
+ {"/ai.js", "text/javascript; charset=utf-8", `renderAIConfigChecks`},
+ {"/style.css", "text/css; charset=utf-8", `.ai-config-check-list`},
+ {"/", "text/html; charset=utf-8", `id="log-summary-state"`},
+ {"/logs.js", "text/javascript; charset=utf-8", `renderLogSummary`},
+ {"/", "text/html; charset=utf-8", `id="log-recent-15m"`},
+ {"/", "text/html; charset=utf-8", `id="log-recent-1h"`},
+ {"/", "text/html; charset=utf-8", `id="log-recent-24h"`},
+ {"/", "text/html; charset=utf-8", `id="log-any-time"`},
+ {"/logs.js", "text/javascript; charset=utf-8", `applyRecentLogRange`},
+ {"/", "text/html; charset=utf-8", `id="plugin-sort"`},
+ {"/app.js", "text/javascript; charset=utf-8", `function sortPluginItems`},
+ {"/", "text/html; charset=utf-8", `id="model-request-categories"`},
+ {"/overview.js", "text/javascript; charset=utf-8", `function renderModelRequests`},
+ {"/", "text/html; charset=utf-8", `id="reply-task-categories"`},
+ {"/reply-metrics.js", "text/javascript; charset=utf-8", `function renderReplyTasks`},
+ {"/", "text/html; charset=utf-8", `id="download-reply-statistics"`},
+ {"/reply-metrics.js", "text/javascript; charset=utf-8", `function downloadReplyStatistics`},
+ {"/", "text/html; charset=utf-8", `id="download-model-observation"`},
+ {"/model-observation.js", "text/javascript; charset=utf-8", `function downloadModelObservation`},
+ {"/style.css", "text/css; charset=utf-8", `.model-observation-actions`},
+ {"/style.css", "text/css; charset=utf-8", `.model-request-grid`},
+ {"/app.js", "text/javascript; charset=utf-8", `function discardOnePluginDraft`},
+ {"/app.js", "text/javascript; charset=utf-8", `name !== plugin.name`},
+ {"/app.js", "text/javascript; charset=utf-8", `放弃此项更改`},
+ {"/style.css", "text/css; charset=utf-8", `.plugin-single-draft`},
+ {"/", "text/html; charset=utf-8", `id="backup-sort"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `function sortBackupEntries`},
+ {"/", "text/html; charset=utf-8", `id="backup-size-summary"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `function renderBackupSizeSummary`},
+ {"/style.css", "text/css; charset=utf-8", `.backup-size-scopes`},
+ {"/", "text/html; charset=utf-8", `id="read-logs-once"`},
+ {"/logs.js", "text/javascript; charset=utf-8", `async function readLogsOnce`},
+ {"/", "text/html; charset=utf-8", `id="backup-from-date"`},
+ {"/", "text/html; charset=utf-8", `id="backup-until-date"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `function applyBackupDateFilter`},
+ {"/", "text/html; charset=utf-8", `id="download-plugin-catalog"`},
+ {"/app.js", "text/javascript; charset=utf-8", `function downloadPluginCatalog`},
+ {"/logs.js", "text/javascript; charset=utf-8", `async function copyLogEntry`},
+ {"/style.css", "text/css; charset=utf-8", `.log-copy-actions`},
+ {"/", "text/html; charset=utf-8", `id="ai-draft-fields"`},
+ {"/", "text/html; charset=utf-8", `id="connection-draft-fields"`},
+ {"/app.js", "text/javascript; charset=utf-8", `function renderConnectionDraftFields`},
+ {"/", "text/html; charset=utf-8", `id="clear-log-display"`},
+ {"/logs.js", "text/javascript; charset=utf-8", `function clearLogDisplay`},
+ {"/style.css", "text/css; charset=utf-8", `.connection-draft-field-list`},
+ {"/ai.js", "text/javascript; charset=utf-8", `function renderAIDraftFields`},
+ {"/style.css", "text/css; charset=utf-8", `.ai-draft-field-list`},
+ {"/style.css", "text/css; charset=utf-8", `.log-summary-levels`},
+ {"/theme.js", "text/javascript; charset=utf-8", `zerobot.localadmin.theme.v1`},
+ {"/appearance.css", "text/css; charset=utf-8", `:root[data-theme="dark"]`},
+ {"/", "text/html; charset=utf-8", `id="plugin-state-filter"`},
+ {"/", "text/html; charset=utf-8", `id="plugin-only-drafts"`},
+ {"/", "text/html; charset=utf-8", `id="discard-plugin-drafts"`},
+ {"/app.js", "text/javascript; charset=utf-8", `discardPluginDrafts`},
+ {"/", "text/html; charset=utf-8", `id="save-all-plugin-drafts"`},
+ {"/", "text/html; charset=utf-8", `id="stop-plugin-batch"`},
+ {"/app.js", "text/javascript; charset=utf-8", `saveAllPluginDrafts`},
+ {"/", "text/html; charset=utf-8", `id="plugin-response-panel"`},
+ {"/app.js", "text/javascript; charset=utf-8", `renderPluginResponse`},
+ {"/", "text/html; charset=utf-8", `id="copy-plugin-usage"`},
+ {"/", "text/html; charset=utf-8", `id="download-plugin-usage"`},
+ {"/app.js", "text/javascript; charset=utf-8", `copyPluginUsage`},
+ {"/app.js", "text/javascript; charset=utf-8", `downloadPluginUsage`},
+ {"/style.css", "text/css; charset=utf-8", `.usage-actions`},
+ {"/style.css", "text/css; charset=utf-8", `.plugin-response-panel`},
+ {"/app.js", "text/javascript; charset=utf-8", `pluginBatchResultValid`},
+ {"/style.css", "text/css; charset=utf-8", `#plugin-batch-status`},
+ {"/app.js", "text/javascript; charset=utf-8", `pluginOnlyDrafts`},
+ {"/", "text/html; charset=utf-8", `id="plugin-page-size"`},
+ {"/", "text/html; charset=utf-8", `id="plugin-page-status"`},
+ {"/app.js", "text/javascript; charset=utf-8", `changePluginPage`},
+ {"/app.js", "text/javascript; charset=utf-8", `renderPluginPagination`},
+ {"/", "text/html; charset=utf-8", `id="clear-plugin-filter"`},
+ {"/app.js", "text/javascript; charset=utf-8", `[p.name, p.brief, p.usage]`},
+ {"/appearance.css", "text/css; charset=utf-8", `.plugin-filter-label select`},
+ {"/", "text/html; charset=utf-8", `id="ai-draft-status"`},
+ {"/", "text/html; charset=utf-8", `id="discard-ai-global"`},
+ {"/ai.js", "text/javascript; charset=utf-8", `discardAIForm`},
+ {"/ai.js", "text/javascript; charset=utf-8", `data-ai-discard`},
+ {"/ai.js", "text/javascript; charset=utf-8", `confirmAIDiscard`},
+ {"/ai.js", "text/javascript; charset=utf-8", `beforeunload`},
+ {"/app.js", "text/javascript; charset=utf-8", `csrf === requestToken`},
+ {"/", "text/html; charset=utf-8", `id="plugin-draft-status"`},
+ {"/app.js", "text/javascript; charset=utf-8", `confirmPluginDiscard`},
+ {"/app.js", "text/javascript; charset=utf-8", `generation !== pluginGeneration`},
+ {"/", "text/html; charset=utf-8", `id="connection-delete-preview"`},
+ {"/connections-delete.js", "text/javascript; charset=utf-8", `deletionCurrent`},
+ {"/", "text/html; charset=utf-8", `id="startup-differences"`},
+ {"/app.js", "text/javascript; charset=utf-8", `renderStartupDifferences`},
+ {"/style.css", "text/css; charset=utf-8", `.startup-difference-row`},
+ {"/", "text/html; charset=utf-8", `id="connection-add-kind"`},
+ {"/connections-add.js", "text/javascript; charset=utf-8", `本机反向监听必须填写 Token`},
+ {"/", "text/html; charset=utf-8", `id="connection-add-form"`},
+ {"/connections-add.js", "text/javascript; charset=utf-8", `connectionAddSnapshot`},
+ {"/", "text/html; charset=utf-8", `id="connection-draft-status"`},
+ {"/", "text/html; charset=utf-8", `id="discard-current-connection"`},
+ {"/", "text/html; charset=utf-8", `id="log-from"`},
+ {"/", "text/html; charset=utf-8", `id="log-until"`},
+ {"/logs.js", "text/javascript; charset=utf-8", `function appliedLogQuery`},
+ {"/", "text/html; charset=utf-8", `id="discard-connection-owners"`},
+ {"/app.js", "text/javascript; charset=utf-8", `discardConnectionDraft`},
+ {"/app.js", "text/javascript; charset=utf-8", `settings === connectionSettings`},
+ {"/", "text/html; charset=utf-8", `id="connection-draft-confirmation"`},
+ {"/app.js", "text/javascript; charset=utf-8", `finishConnectionDraftConfirmation`},
+ {"/app.js", "text/javascript; charset=utf-8", `confirmConnectionDiscard`},
+ {"/app.js", "text/javascript; charset=utf-8", `generation !== connectionGeneration`},
+ {"/backups.js", "text/javascript; charset=utf-8", `resetConnections()`},
+ {"/", "text/html; charset=utf-8", `id="view-issue-logs"`},
+ {"/", "text/html; charset=utf-8", `id="clear-log-filter"`},
+ {"/logs.js", "text/javascript; charset=utf-8", `applyLogFilters`},
+ {"/", "text/html; charset=utf-8", `id="restore-impact-list"`},
+ {"/", "text/html; charset=utf-8", `id="restore-impact-status"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `renderRestoreImpacts`},
+ {"/", "text/html; charset=utf-8", `id="backup-scope-filter"`},
+ {"/", "text/html; charset=utf-8", `id="backup-state-filter"`},
+ {"/", "text/html; charset=utf-8", `id="restore-backup-id"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `applyBackupFilter`},
+ {"/backups.js", "text/javascript; charset=utf-8", `backupReadFailed`},
+ {"/backups.js", "text/javascript; charset=utf-8", `尚未取得记录`},
+ {"/", "text/html; charset=utf-8", `id="restore-expiry-state"`},
+ {"/", "text/html; charset=utf-8", `id="cleanup-expiry-state"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `function renderBackupExpiry`},
+ {"/backups.js", "text/javascript; charset=utf-8", `expiredBackupPreviews`},
+ {"/style.css", "text/css; charset=utf-8", `.preview-expiry-state`},
+ {"/", "text/html; charset=utf-8", `id="download-backup-catalog"`},
+ {"/", "text/html; charset=utf-8", `id="backup-export-status"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `function downloadBackupCatalog`},
+ {"/backups.js", "text/javascript; charset=utf-8", `zerobot-backup-catalog`},
+ {"/style.css", "text/css; charset=utf-8", `.backup-export`},
+ {"/", "text/html; charset=utf-8", `id="backup-page-size"`},
+ {"/", "text/html; charset=utf-8", `id="backup-page-status"`},
+ {"/backups.js", "text/javascript; charset=utf-8", `changeBackupPage`},
+ {"/backups.js", "text/javascript; charset=utf-8", `prefers-reduced-motion`},
+ {"/", "text/html; charset=utf-8", `id="ai-scope-kind"`},
+ {"/ai.js", "text/javascript; charset=utf-8", `body.privateId=value.privateId`},
+ } {
+ rsp, err := client.Get(baseURL + asset.path)
+ if err != nil {
+ return err
+ }
+ body, readErr := io.ReadAll(io.LimitReader(rsp.Body, 128<<10))
+ rsp.Body.Close()
+ if readErr != nil || rsp.StatusCode != 200 || rsp.Header.Get("Content-Type") != asset.kind || rsp.Header.Get("Cache-Control") != "no-store" || !bytes.Contains(body, []byte(asset.marker)) {
+ return fmt.Errorf("admin asset check failed: %s", asset.path)
+ }
+ }
+ do := func(method, path string, body any, csrf string) (int, map[string]any, error) {
+ var encoded []byte
+ if body != nil {
+ encoded, _ = json.Marshal(body)
+ }
+ req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(encoded))
+ if err != nil {
+ return 0, nil, err
+ }
+ if method == "POST" {
+ req.Header.Set("Origin", baseURL)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Local-Request", "1")
+ req.Header.Set("X-CSRF-Token", csrf)
+ }
+ rsp, err := client.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer rsp.Body.Close()
+ var value map[string]any
+ err = json.NewDecoder(rsp.Body).Decode(&value)
+ if err == nil && path == "/api/backups/preview" && rsp.StatusCode == 200 {
+ err = verifyBackupImpacts(value)
+ }
+ return rsp.StatusCode, value, err
+ }
+ status, _, err := do("GET", "/api/plugins", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("unauthed admin request was not denied: %d %v", status, err)
+ }
+ status, _, err = do("GET", "/api/logs", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("unauthed logs were not denied")
+ }
+ status, _, err = do("GET", "/api/logs/export", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("unauthed log export was not denied")
+ }
+ status, _, err = do("GET", "/api/backups", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("unauthed backups were not denied")
+ }
+ code, err := os.ReadFile(filepath.Join(dataDir, "setup-code.txt"))
+ if err != nil {
+ return err
+ }
+ status, session, err := do("POST", "/api/setup", map[string]string{"setupCode": strings.TrimSpace(string(code)), "password": "Local-ui-test-password-only"}, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("isolated admin setup failed: %d %v", status, err)
+ }
+ csrf, _ := session["csrfToken"].(string)
+ // Only this run's isolated options file is edited; runtime uses explicit CLI flags.
+ optionsPath := filepath.Join(dataDir, "options.json")
+ optionsBefore := []byte("{\"enabled\":true,\"address\":\"127.0.0.1:3010\"}")
+ if err = os.WriteFile(optionsPath, optionsBefore, 0600); err != nil {
+ return err
+ }
+ status, optionsBackup, err := do("POST", "/api/backups", map[string]string{"scope": "admin-options"}, csrf)
+ if err != nil || status != 200 || optionsBackup["backupId"] == "" {
+ return fmt.Errorf("options backup fixture failed: %d %v", status, err)
+ }
+ status, equalOptions, err := do("POST", "/api/backups/preview", map[string]any{"id": optionsBackup["backupId"]}, csrf)
+ if err != nil || status != 200 || equalOptions["changed"] != false {
+ return fmt.Errorf("equal options impacts failed: %d %v", status, err)
+ }
+ optionsChanged := []byte("{\"enabled\":false,\"address\":\"127.0.0.1:3111\"}")
+ if err = os.WriteFile(optionsPath, optionsChanged, 0600); err != nil {
+ return err
+ }
+ status, changedOptions, err := do("POST", "/api/backups/preview", map[string]any{"id": optionsBackup["backupId"]}, csrf)
+ if err != nil || status != 200 || changedOptions["changed"] != true || changedOptions["restartRequired"] != true {
+ return fmt.Errorf("changed options impacts failed: %d %v", status, err)
+ }
+ optionsAfter, readErr := os.ReadFile(optionsPath)
+ if readErr != nil || !bytes.Equal(optionsAfter, optionsChanged) {
+ return fmt.Errorf("preview changed isolated options")
+ }
+
+ status, overview, err := do("GET", "/api/overview", nil, "")
+ if err != nil || status != 200 || overview["connectionState"] != "connected" {
+ return fmt.Errorf("isolated runtime overview failed: %d %v", status, err)
+ }
+ modelRequests, statsOK := overview["modelRequests"].(map[string]any)
+ if !statsOK || modelRequests["started"] != float64(0) || modelRequests["active"] != float64(0) || modelRequests["completed"] != float64(0) || modelRequests["failed"] != float64(0) || modelRequests["incomplete"] != float64(0) || modelRequests["startedAt"] == nil || modelRequests["checkedAt"] == nil {
+ return fmt.Errorf("isolated zero model statistics absent or incorrect")
+ }
+ categories, statsOK := modelRequests["categories"].([]any)
+ if !statsOK || len(categories) != 4 {
+ return fmt.Errorf("model statistics category bounds absent")
+ }
+ accounts, _ := overview["botAccounts"].([]any)
+ if len(accounts) != 1 || accounts[0] != "87654321" {
+ return fmt.Errorf("overview does not reflect actual isolated bot")
+ }
+ alerts, ok := overview["alerts"].(map[string]any)
+ if !ok {
+ return fmt.Errorf("overview retained alerts absent")
+ }
+ alertRows, ok := alerts["entries"].([]any)
+ if !ok || len(alertRows) > 5 || alerts["warningCount"] == nil || alerts["errorCount"] == nil || alerts["retained"] == nil {
+ return fmt.Errorf("overview alert bounds/counts absent")
+ }
+ lastID := uint64(^uint64(0))
+ for _, item := range alertRows {
+ row, ok := item.(map[string]any)
+ if !ok {
+ return fmt.Errorf("invalid overview alert row")
+ }
+ id, _ := strconv.ParseUint(fmt.Sprint(row["id"]), 10, 64)
+ level := fmt.Sprint(row["level"])
+ if id >= lastID || (level != "warning" && level != "error" && level != "fatal" && level != "panic") {
+ return fmt.Errorf("overview alerts not ordered/filtered")
+ }
+ lastID = id
+ }
+ assetResponse, assetErr := client.Get(baseURL + "/overview.js")
+ if assetErr != nil {
+ return assetErr
+ }
+ assetBody, assetErr := io.ReadAll(assetResponse.Body)
+ assetResponse.Body.Close()
+ if assetErr != nil || assetResponse.StatusCode != 200 || !strings.Contains(string(assetBody), "visibilitychange") || !strings.Contains(string(assetBody), "AbortController") {
+ return fmt.Errorf("overview polling asset unavailable")
+ }
+ status, list, err := do("GET", "/api/plugins", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("isolated plugin registry failed")
+ }
+ plugins, _ := list["plugins"].([]any)
+ found := false
+ for _, value := range plugins {
+ if p, ok := value.(map[string]any); ok && p["name"] == "chat" {
+ found = true
+ }
+ }
+ if !found || float64(len(plugins)) != overview["pluginCount"] {
+ return fmt.Errorf("actual plugin registry does not match overview")
+ }
+ pages := (len(plugins) + 19) / 20
+ for page := 1; page <= pages; page++ {
+ text, err := sendControl(23456789, fmt.Sprintf("/服务列表 %d", page))
+ if err != nil || !strings.Contains(text, fmt.Sprintf("第 %d/%d 页", page, pages)) {
+ return fmt.Errorf("service list page %d failed: %v", page, err)
+ }
+ for _, item := range plugins[(page-1)*20 : min(page*20, len(plugins))] {
+ plugin := item.(map[string]any)
+ if !strings.Contains(text, "插件:"+plugin["name"].(string)+"\n") {
+ return fmt.Errorf("service list lost registered plugin")
+ }
+ }
+ }
+ for _, arg := range []string{"0", "abc", strconv.Itoa(pages + 1)} {
+ text, err := sendControl(23456789, "/服务列表 "+arg)
+ if err != nil || !strings.Contains(text, "页") {
+ return fmt.Errorf("service list invalid page not rejected")
+ }
+ }
+ if text, err := sendControl(23456789, "/service_list"); err != nil || !strings.Contains(text, "第 1/") {
+ return fmt.Errorf("service list alias failed")
+ }
+ if text, err := sendGroup(23456789, 202, "member", "/服务列表", false); err != nil || text != "" {
+ return fmt.Errorf("service list lost group @ condition")
+ }
+ if text, err := sendGroup(23456789, 202, "member", "/服务列表", true); err != nil || !strings.Contains(text, "当前群") {
+ return fmt.Errorf("group service list forward failed")
+ }
+ if err := verifySequentialPluginSaves(do, csrf); err != nil {
+ return err
+ }
+ // Re-read versions after the fixture writes, even when effective modes match.
+ status, list, err = do("GET", "/api/plugins", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("plugin versions after sequential fixture unavailable")
+ }
+ plugins = list["plugins"].([]any)
+ // Isolate routing checks from unsolicited random chat and AI responses.
+ // These overrides affect only this synthetic database. GPT remains enabled.
+ for _, item := range plugins {
+ p := item.(map[string]any)
+ if p["name"] == "aichat" || p["name"] == "chat" || p["name"] == "atri" || p["name"] == "thesaurus" {
+ state := p["state"].(map[string]any)
+ status, _, err = do("POST", "/api/plugin-switch", map[string]any{"name": p["name"], "groupId": "0", "mode": "disabled", "revision": state["revision"]}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("isolated unsolicited-reply disable failed: %v status %d: %v", p["name"], status, err)
+ }
+ }
+ }
+ if err := verifyPluginResponseStatus(do, sendControl, sendGroup); err != nil {
+ return err
+ }
+ pluginState := func(group string) (map[string]any, error) {
+ status, list, err := do("GET", "/api/plugins?group="+group, nil, "")
+ if err != nil || status != 200 || list["controlAvailable"] != true {
+ return nil, fmt.Errorf("plugin state read failed")
+ }
+ items, _ := list["plugins"].([]any)
+ for _, item := range items {
+ if p, ok := item.(map[string]any); ok && p["name"] == "chrev" {
+ state, _ := p["state"].(map[string]any)
+ if state != nil {
+ return state, nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("compiled chrev control missing")
+ }
+ setPlugin := func(group, mode string) (map[string]any, error) {
+ state, err := pluginState(group)
+ if err != nil {
+ return nil, err
+ }
+ body := map[string]any{"name": "chrev", "groupId": group, "mode": mode, "revision": state["revision"]}
+ status, _, err := do("POST", "/api/plugin-switch", body, "invalid")
+ if err != nil || status != 403 {
+ return nil, fmt.Errorf("plugin save lacked CSRF protection")
+ }
+ status, value, err := do("POST", "/api/plugin-switch", body, csrf)
+ if err != nil || status != 200 {
+ return nil, fmt.Errorf("plugin save failed: %d %v", status, err)
+ }
+ saved, _ := value["state"].(map[string]any)
+ return saved, nil
+ }
+ if text, err := sendControl(12345678, "/响应"); err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("synthetic response permission setup failed: %v", err)
+ }
+ if _, err := setPlugin("0", "disabled"); err != nil {
+ return err
+ }
+ if text, err := sendControl(12345678, "翻转 abc"); err != nil || text != "" {
+ return fmt.Errorf("disabled plugin still replied: %v", err)
+ }
+ if text, err := sendControl(23456789, "/全局启用 chrev"); err != nil || text != "" {
+ return fmt.Errorf("non-owner global command was accepted: %v", err)
+ }
+ state, err := pluginState("101")
+ if err != nil || state["effectiveEnabled"] != false || state["source"] != "global" {
+ return fmt.Errorf("disabled global state lost")
+ }
+ if text, err := sendControl(12345678, "/全局启用 chrev"); err != nil || !strings.Contains(text, "已全局启用") {
+ return fmt.Errorf("owner global command failed: %v", err)
+ }
+ state, err = pluginState("101")
+ if err != nil || state["effectiveEnabled"] != true || state["source"] != "global" {
+ return fmt.Errorf("chat update not visible to admin")
+ }
+ if text, err := sendControl(12345678, "翻转 abc"); err != nil || text != "ɔqɐ" {
+ return fmt.Errorf("enabled plugin did not reply correctly: %v", err)
+ }
+ state, err = setPlugin("101", "disabled")
+ if err != nil || state["effectiveEnabled"] != true || state["source"] != "global" {
+ return fmt.Errorf("group did not respect global override")
+ }
+ if _, err = setPlugin("0", "inherit"); err != nil {
+ return err
+ }
+ state, err = pluginState("101")
+ if err != nil || state["effectiveEnabled"] != false || state["source"] != "group" {
+ return fmt.Errorf("inherited global did not reveal group override")
+ }
+ state, err = pluginState("202")
+ if err != nil || state["effectiveEnabled"] != true || state["source"] != "default" {
+ return fmt.Errorf("group override leaked")
+ }
+ if _, err = setPlugin("101", "inherit"); err != nil {
+ return err
+ }
+ status, beforeAIRead, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("AI checks backup baseline unavailable")
+ }
+ status, ai, err := do("GET", "/api/ai?group=101", nil, "")
+ if err != nil || status != 200 || ai["available"] != true {
+ return fmt.Errorf("AI configuration unavailable")
+ }
+ if err := verifyAIConfigChecks(ai); err != nil {
+ return err
+ }
+ status, afterAIRead, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 || len(beforeAIRead["backups"].([]any)) != len(afterAIRead["backups"].([]any)) {
+ return fmt.Errorf("AI checks read created a backup")
+ }
+ aiGlobal := ai["global"].(map[string]any)
+ aiChange := map[string]any{"revision": aiGlobal["revision"], "fields": map[string]any{"API": modelURL, "ModelName": "smoke-web-model", "Type": 0, "SystemP": "local mock system"}, "keys": map[string]any{"Key": map[string]any{"action": "replace", "value": "synthetic-model-key"}}}
+ status, _, err = do("POST", "/api/ai", aiChange, "bad")
+ if err != nil || status != 403 {
+ return fmt.Errorf("AI save lacked CSRF protection")
+ }
+ status, ai, err = do("POST", "/api/ai", aiChange, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("AI web save failed")
+ }
+ if err := verifyAIConfigChecks(ai); err != nil || ai["global"].(map[string]any)["checks"].(map[string]any)["chat"].(map[string]any)["complete"] != true {
+ return fmt.Errorf("AI checks did not update after saving complete fields: %v", err)
+ }
+ encodedAI, _ := json.Marshal(ai)
+ if bytes.Contains(encodedAI, []byte("synthetic-model-key")) {
+ return fmt.Errorf("AI API leaked a key")
+ }
+ status, _, err = do("POST", "/api/ai", aiChange, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("AI stale web version accepted")
+ }
+ if text, err := sendControl(12345678, "查看AI聊天配置"); err != nil || !strings.Contains(text, "smoke-web-model") || strings.Contains(text, "synthetic-model-key") {
+ return fmt.Errorf("AI web config invisible/secret exposed in chat")
+ }
+ if text, err := sendControl(12345678, "设置AI聊天模型名smoke-chat-model"); err != nil || !strings.Contains(text, "成功") {
+ return fmt.Errorf("AI chat save failed")
+ }
+ status, ai, err = do("GET", "/api/ai?group=101", nil, "")
+ if err != nil || status != 200 || ai["global"].(map[string]any)["fields"].(map[string]any)["ModelName"] != "smoke-chat-model" {
+ return fmt.Errorf("AI chat update invisible to web")
+ }
+ if err := verifyAIConfigChecks(ai); err != nil {
+ return err
+ }
+ for _, item := range ai["groups"].([]any) {
+ group := item.(map[string]any)
+ change := map[string]any{"service": group["service"], "groupId": "101", "revision": group["revision"], "temperature": 82}
+ if group["service"] == "aichatcfg" {
+ change["rate"] = 11
+ change["useAgent"] = false
+ change["voiceOutput"] = false
+ change["respondAt"] = true
+ }
+ status, _, err = do("POST", "/api/ai-group", change, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("AI group save failed")
+ }
+ }
+ if text, err := sendGroup(12345678, 101, "member", "/响应", true); err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("AI group response setup failed")
+ }
+ if text, err := sendGroup(12345678, 101, "member", "设置AI聊天温度80", true); err != nil || !strings.Contains(text, "成功") {
+ return fmt.Errorf("AI chat group setting failed")
+ }
+ status, ai, err = do("GET", "/api/ai?group=101", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("AI group reread failed")
+ }
+ group := ai["groups"].([]any)[0].(map[string]any)
+ if group["temperature"] != float64(80) || group["rate"] != float64(11) {
+ return fmt.Errorf("AI group chat/web state differs")
+ }
+ status, other, err := do("GET", "/api/ai?group=202", nil, "")
+ if err != nil || status != 200 || other["groups"].([]any)[0].(map[string]any)["temperature"] != float64(0) {
+ return fmt.Errorf("AI group settings leaked to other group")
+ }
+ status, privateAI, err := do("GET", "/api/ai?private=12345678", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("private AI configuration unavailable")
+ }
+ var privateChatChange map[string]any
+ for _, item := range privateAI["groups"].([]any) {
+ conversation := item.(map[string]any)
+ if conversation["privateId"] != "12345678" || conversation["groupId"] != "" {
+ return fmt.Errorf("private AI target identity is ambiguous")
+ }
+ change := map[string]any{"service": conversation["service"], "privateId": "12345678", "revision": conversation["revision"], "temperature": 81}
+ if conversation["service"] == "aichatcfg" {
+ change["rate"], change["useAgent"], change["voiceOutput"], change["respondAt"] = 0, false, false, false
+ privateChatChange = change
+ }
+ status, _, err = do("POST", "/api/ai-group", change, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("private AI save failed")
+ }
+ }
+ if text, err := sendControl(12345678, "设置AI聊天温度76"); err != nil || !strings.Contains(text, "成功") {
+ return fmt.Errorf("private AI chat configuration failed")
+ }
+ status, privateAI, err = do("GET", "/api/ai?private=12345678", nil, "")
+ if err != nil || status != 200 || privateAI["groups"].([]any)[0].(map[string]any)["temperature"] != float64(76) || privateAI["groups"].([]any)[1].(map[string]any)["temperature"] != float64(81) {
+ return fmt.Errorf("private AI chat/web storage mismatch")
+ }
+ status, _, err = do("POST", "/api/ai-group", privateChatChange, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("stale private AI update accepted")
+ }
+ for _, path := range []string{"/api/ai?group=12345678", "/api/ai?private=12345679"} {
+ status, isolated, err := do("GET", path, nil, "")
+ if err != nil || status != 200 || isolated["groups"].([]any)[0].(map[string]any)["temperature"] != float64(0) {
+ return fmt.Errorf("private AI change leaked: %s", path)
+ }
+ }
+ status, _, err = do("GET", "/api/ai?group=12345678&private=12345678", nil, "")
+ if err != nil || status != 400 {
+ return fmt.Errorf("ambiguous private/group target accepted")
+ }
+ if text, err := sendGroup(12345678, 101, "member", "/gpt group smoke", false); err != nil || !strings.Contains(text, "本地模型模拟回复") {
+ return fmt.Errorf("group GPT request failed: %v %q", err, text)
+ }
+ if text, err := sendControl(12345678, "/gpt quoted smoke"); err != nil || !strings.Contains(text, "本地模型模拟回复") {
+ return fmt.Errorf("private quoted GPT request failed: %v %q", err, text)
+ }
+ if text, err := sendControl(12345678, "/gpt second"); err != nil || !strings.Contains(text, "30 秒") {
+ return fmt.Errorf("GPT request quota not enforced")
+ }
+ for _, command := range []string{"/gptabc", "普通提及 /gpt"} {
+ if text, err := sendControl(12345678, command); err != nil || text != "" {
+ return fmt.Errorf("GPT command boundary accepted %q", command)
+ }
+ }
+ if text, err := sendControl(12345678, "/gpt"); err != nil || !strings.Contains(text, "填写问题") {
+ return fmt.Errorf("empty GPT did not show usage")
+ }
+ if err := verifyAIRolePermissions(do, sendControl, sendGroup, csrf, modelURL); err != nil {
+ return err
+ }
+ if err := verifyVoiceCommands(do, csrf, sendControl, sendGroup); err != nil {
+ return err
+ }
+ if text, err := sendGroup(12345678, 101, "member", "/沉默", true); err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("AI fixture response cleanup failed")
+ }
+ status, logs, err := do("GET", "/api/logs?limit=200", nil, "")
+ if err != nil || status != 200 || len(logs["entries"].([]any)) == 0 {
+ return fmt.Errorf("runtime logs unavailable")
+ }
+ if err := verifyLogSummary(logs); err != nil {
+ return err
+ }
+ logJSON, _ := json.Marshal(logs)
+ if bytes.Contains(logJSON, []byte("synthetic-model-key")) || bytes.Contains(logJSON, []byte("local-smoke-only")) || !bytes.Contains(logJSON, []byte("正文已隐藏")) {
+ return fmt.Errorf("runtime logs did not protect credentials/message bodies")
+ }
+ status, updatedOverview, err := do("GET", "/api/overview", nil, "")
+ if err != nil || status != 200 || updatedOverview["alerts"] == nil {
+ return fmt.Errorf("post-AI overview alerts unavailable")
+ }
+ stats, ok := updatedOverview["modelRequests"].(map[string]any)
+ if !ok || stats["started"] != float64(11) || stats["completed"] != float64(11) || stats["active"] != float64(0) || stats["failed"] != float64(0) || stats["incomplete"] != float64(0) {
+ return fmt.Errorf("post-AI model statistics disagree with 11 synthetic requests")
+ }
+ rows, ok := stats["categories"].([]any)
+ if !ok || len(rows) != 4 {
+ return fmt.Errorf("post-AI model categories missing")
+ }
+ for i, want := range []float64{4, 2, 5, 0} {
+ row, ok := rows[i].(map[string]any)
+ if !ok || row["started"] != want || row["completed"] != want || row["active"] != float64(0) {
+ return fmt.Errorf("post-AI model category differs: %d", i)
+ }
+ }
+ statsJSON, _ := json.Marshal(stats)
+ for _, secret := range []string{"synthetic-model-key", "smoke-chat-model", modelURL, "quoted smoke", "本地模型模拟回复"} {
+ if bytes.Contains(statsJSON, []byte(secret)) {
+ return fmt.Errorf("model aggregates retained sensitive request data")
+ }
+ }
+ alertJSON, _ := json.Marshal(updatedOverview["alerts"])
+ if bytes.Contains(alertJSON, []byte("synthetic-model-key")) || bytes.Contains(alertJSON, []byte("local-smoke-only")) {
+ return fmt.Errorf("overview alerts exposed credentials")
+ }
+ for _, path := range []string{"/api/logs/export", "/api/logs/export?level=info&q=Websocket"} {
+ rsp, err := client.Get(baseURL + path)
+ if err != nil {
+ return err
+ }
+ body, readErr := io.ReadAll(io.LimitReader(rsp.Body, (3<<20)+1))
+ rsp.Body.Close()
+ if readErr != nil || rsp.StatusCode != 200 || len(body) > 3<<20 || rsp.Header.Get("Cache-Control") != "no-store" || rsp.Header.Get("Content-Type") != "text/plain; charset=utf-8" || !strings.HasPrefix(rsp.Header.Get("Content-Disposition"), `attachment; filename="zerobot-logs-`) {
+ return fmt.Errorf("runtime log export/download headers failed")
+ }
+ if bytes.Contains(body, []byte("synthetic-model-key")) || bytes.Contains(body, []byte("local-smoke-only")) || !bytes.Contains(body, []byte("[INFO]")) {
+ return fmt.Errorf("runtime export redaction failed")
+ }
+ if path == "/api/logs/export" && !bytes.Contains(body, []byte("正文已隐藏")) {
+ return fmt.Errorf("export exposed message bodies")
+ }
+ if path != "/api/logs/export" {
+ for _, line := range strings.Split(string(body), "\n") {
+ if strings.HasPrefix(line, "[") && (!strings.Contains(line, "[INFO]") || !strings.Contains(line, "Websocket")) {
+ return fmt.Errorf("export filter mismatch")
+ }
+ }
+ }
+ }
+ status, filtered, err := do("GET", "/api/logs?level=info&q=Websocket", nil, "")
+ if err != nil || status != 200 || len(filtered["entries"].([]any)) == 0 {
+ return fmt.Errorf("runtime log filters failed")
+ }
+ if err := verifyLogSummary(filtered); err != nil {
+ return err
+ }
+ for _, item := range filtered["entries"].([]any) {
+ row := item.(map[string]any)
+ if row["level"] != "info" || !strings.Contains(row["message"].(string), "Websocket") {
+ return fmt.Errorf("runtime log filter mismatch")
+ }
+ }
+ // Exercise inclusive time filtering and export against actual process timestamps.
+ timeRow := filtered["entries"].([]any)[0].(map[string]any)
+ timeQuery := url.Values{"from": {timeRow["time"].(string)}, "until": {timeRow["time"].(string)}, "level": {"info"}, "q": {"Websocket"}}.Encode()
+ status, timed, err := do("GET", "/api/logs?"+timeQuery, nil, "")
+ if err != nil || status != 200 || len(timed["entries"].([]any)) == 0 {
+ return fmt.Errorf("runtime time range failed")
+ }
+ if err := verifyLogSummary(timed); err != nil {
+ return err
+ }
+ status, timedTail, err := do("GET", "/api/logs?"+timeQuery+"&after="+timed["cursor"].(string)+"&instance="+timed["instance"].(string)+"&limit=1", nil, "")
+ if err != nil || status != 200 || len(timedTail["entries"].([]any)) != 0 {
+ return fmt.Errorf("log summary incremental time fixture failed")
+ }
+ if err := verifyLogSummary(timedTail); err != nil {
+ return err
+ }
+ beforeSummary, _ := json.Marshal(timed["summary"])
+ afterSummary, _ := json.Marshal(timedTail["summary"])
+ if !bytes.Equal(beforeSummary, afterSummary) {
+ return fmt.Errorf("log summary depended on incremental cursor/limit")
+ }
+ status, hiddenSummary, err := do("GET", "/api/logs?q=synthetic-model-key", nil, "")
+ if err != nil || status != 200 || hiddenSummary["summary"].(map[string]any)["matched"] != float64(0) {
+ return fmt.Errorf("log summary inferred a raw model key")
+ }
+ for _, item := range timed["entries"].([]any) {
+ if item.(map[string]any)["time"] != timeRow["time"] {
+ return fmt.Errorf("runtime time range boundary mismatch")
+ }
+ }
+ timeResponse, err := client.Get(baseURL + "/api/logs/export?" + timeQuery)
+ if err != nil {
+ return err
+ }
+ timeBody, timeErr := io.ReadAll(io.LimitReader(timeResponse.Body, (3<<20)+1))
+ timeResponse.Body.Close()
+ if timeErr != nil || timeResponse.StatusCode != 200 || !bytes.Contains(timeBody, []byte("时间范围(包含边界,UTC)")) || !bytes.Contains(timeBody, []byte("#"+timeRow["id"].(string)+" ")) {
+ return fmt.Errorf("runtime time export failed")
+ }
+ for _, endpoint := range []string{"/api/logs", "/api/logs/export"} {
+ status, _, err = do("GET", endpoint+"?from=bad-time", nil, "")
+ if err != nil || status != 400 {
+ return fmt.Errorf("runtime invalid time not rejected")
+ }
+ status, _, err = do("GET", endpoint+"?from=2026-10-09T08:00:01Z&until=2026-10-09T08:00:00Z", nil, "")
+ if err != nil || status != 400 {
+ return fmt.Errorf("runtime reversed time not rejected")
+ }
+ }
+ status, issues, err := do("GET", "/api/logs?level=issues", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("runtime issues filter unavailable")
+ }
+ if err := verifyLogSummary(issues); err != nil {
+ return err
+ }
+ for _, item := range issues["entries"].([]any) {
+ row := item.(map[string]any)
+ switch row["level"] {
+ case "warning", "error", "fatal", "panic":
+ default:
+ return fmt.Errorf("runtime issues filter included a normal level")
+ }
+ }
+ rsp, err := client.Get(baseURL + "/api/logs/export?level=issues")
+ if err != nil {
+ return err
+ }
+ issueBody, readIssueErr := io.ReadAll(io.LimitReader(rsp.Body, (3<<20)+1))
+ rsp.Body.Close()
+ if readIssueErr != nil || rsp.StatusCode != 200 || rsp.Header.Get("Content-Type") != "text/plain; charset=utf-8" || rsp.Header.Get("Cache-Control") != "no-store" || bytes.Contains(issueBody, []byte("synthetic-model-key")) || bytes.Contains(issueBody, []byte("local-smoke-only")) {
+ return fmt.Errorf("runtime issues export failed")
+ }
+ for _, line := range strings.Split(string(issueBody), "\n") {
+ if strings.HasPrefix(line, "[") && !strings.Contains(line, "[WARNING]") && !strings.Contains(line, "[ERROR]") && !strings.Contains(line, "[FATAL]") && !strings.Contains(line, "[PANIC]") {
+ return fmt.Errorf("runtime issues export included a normal level")
+ }
+ }
+ status, tail, err := do("GET", "/api/logs?after="+logs["cursor"].(string)+"&instance="+logs["instance"].(string), nil, "")
+ if err != nil || status != 200 || tail["reset"] != false || tail["capacity"] != float64(1000) {
+ return fmt.Errorf("runtime log cursor failed")
+ }
+ if err := verifyLogSummary(tail); err != nil {
+ return err
+ }
+ status, settings, err := do("GET", "/api/connections", nil, "")
+ if err != nil || status != 200 || settings["available"] != true || settings["restartRequired"] != false {
+ return fmt.Errorf("isolated connection settings read failed")
+ }
+ if err := verifyStartupSummary(settings, "正向 OneBot 连接", 1, 1, false); err != nil {
+ return err
+ }
+ settingsJSON, _ := json.Marshal(settings)
+ if bytes.Contains(settingsJSON, []byte("local-smoke-only")) {
+ return fmt.Errorf("connection settings leaked token")
+ }
+ revision, _ := settings["revision"].(string)
+ change := map[string]any{"revision": revision, "superUsers": []string{"12345678", "23456789"}}
+ status, _, err = do("POST", "/api/connections", change, "invalid")
+ if err != nil || status != 403 {
+ return fmt.Errorf("connection save lacked CSRF protection")
+ }
+ status, saved, err := do("POST", "/api/connections", change, csrf)
+ if err != nil || status != 200 || saved["restartRequired"] != true || saved["backupId"] == "" {
+ return fmt.Errorf("isolated connection save/backup failed")
+ }
+ if err := verifyStartupSummary(saved, "机器人主人", 1, 2, true); err != nil {
+ return err
+ }
+ status, _, err = do("POST", "/api/connections", change, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("stale connection save was accepted")
+ }
+ // Restore a synthetic startup backup and confirm running configuration stays intact.
+ restore := func(id string) error {
+ status, preview, err := do("POST", "/api/backups/preview", map[string]string{"id": id}, csrf)
+ if err != nil || status != 200 || preview["changed"] != true {
+ return fmt.Errorf("restore preview failed: %d %v", status, err)
+ }
+ body := map[string]any{"id": id, "revision": preview["revision"], "expiresAt": preview["expiresAt"], "token": preview["token"], "confirm": true}
+ status, _, err = do("POST", "/api/backups/restore", body, "invalid")
+ if err != nil || status != 403 {
+ return fmt.Errorf("restore lacked CSRF protection")
+ }
+ status, result, err := do("POST", "/api/backups/restore", body, csrf)
+ if err != nil || status != 200 || result["changed"] != true || result["backupId"] == "" {
+ return fmt.Errorf("restore commit failed: %d %v", status, err)
+ }
+ status, _, err = do("POST", "/api/backups/restore", body, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("stale restore ticket accepted")
+ }
+ return nil
+ }
+ if err = restore(saved["backupId"].(string)); err != nil {
+ return err
+ }
+ status, settings, err = do("GET", "/api/connections", nil, "")
+ if err != nil || status != 200 || settings["restartRequired"] != false || len(settings["superUsers"].([]any)) != 1 {
+ return fmt.Errorf("startup restore did not recover saved config")
+ }
+ // Detect a same-count Token-only change without altering the running driver.
+ if err := verifyStartupSummary(settings, "机器人主人", 1, 1, false); err != nil {
+ return err
+ }
+ secretChange := map[string]any{"revision": settings["revision"], "connection": map[string]any{"kind": "ws", "index": 0, "tokenAction": "replace", "token": "startup-summary-only-private"}}
+ status, secretSaved, err := do("POST", "/api/connections", secretChange, csrf)
+ if err != nil || status != 200 || secretSaved["restartRequired"] != true {
+ return fmt.Errorf("startup summary secret-only save failed")
+ }
+ if err := verifyStartupSummary(secretSaved, "正向 OneBot 连接", 1, 1, true); err != nil {
+ return err
+ }
+ secretJSON, _ := json.Marshal(secretSaved)
+ if bytes.Contains(secretJSON, []byte("startup-summary-only-private")) {
+ return fmt.Errorf("startup summary leaked secret")
+ }
+ if err = restore(secretSaved["backupId"].(string)); err != nil {
+ return err
+ }
+ status, settings, err = do("GET", "/api/connections", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("startup summary restore read failed")
+ }
+ if err := verifyStartupSummary(settings, "正向 OneBot 连接", 1, 1, false); err != nil {
+ return err
+ }
+ // Add only an isolated forward driver, then restore the pre-addition fixture.
+ addition := map[string]any{"revision": settings["revision"], "addConnection": map[string]any{"url": "ws://127.0.0.1:17777/new", "token": "synthetic-addition-key", "withoutToken": false}}
+ status, _, err = do("POST", "/api/connections", addition, "invalid")
+ if err != nil || status != 403 {
+ return fmt.Errorf("addition lacked CSRF protection")
+ }
+ status, added, err := do("POST", "/api/connections", addition, csrf)
+ if err != nil || status != 200 || added["restartRequired"] != true || added["backupId"] == "" || len(added["connections"].([]any)) != 2 || len(added["runningConnections"].([]any)) != 1 {
+ return fmt.Errorf("isolated addition failed: %d %v", status, err)
+ }
+ if err := verifyStartupSummary(added, "正向 OneBot 连接", 1, 2, true); err != nil {
+ return err
+ }
+ addedJSON, _ := json.Marshal(added)
+ if bytes.Contains(addedJSON, []byte("synthetic-addition-key")) {
+ return fmt.Errorf("addition exposed token")
+ }
+ status, _, err = do("POST", "/api/connections", addition, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("stale addition accepted")
+ }
+ duplicate := map[string]any{"revision": added["revision"], "addConnection": map[string]any{"url": "ws://127.0.0.1:17777/new", "withoutToken": true}}
+ status, _, err = do("POST", "/api/connections", duplicate, csrf)
+ if err != nil || status != 400 {
+ return fmt.Errorf("duplicate addition accepted")
+ }
+ // Remove only the added synthetic driver, preserving the active fake connection.
+ removal := map[string]any{"revision": added["revision"], "removeConnection": map[string]any{"kind": "ws", "index": 1, "confirm": true}}
+ status, _, err = do("POST", "/api/connections", removal, "invalid")
+ if err != nil || status != 403 {
+ return fmt.Errorf("removal lacked CSRF protection")
+ }
+ unconfirmed := map[string]any{"revision": added["revision"], "removeConnection": map[string]any{"kind": "ws", "index": 1, "confirm": false}}
+ status, _, err = do("POST", "/api/connections", unconfirmed, csrf)
+ if err != nil || status != 400 {
+ return fmt.Errorf("removal accepted without confirmation")
+ }
+ status, removed, err := do("POST", "/api/connections", removal, csrf)
+ if err != nil || status != 200 || removed["backupId"] == "" || removed["restartRequired"] != false || len(removed["connections"].([]any)) != 1 || len(removed["runningConnections"].([]any)) != 1 {
+ return fmt.Errorf("isolated removal failed: %d %v", status, err)
+ }
+ if err := verifyStartupSummary(removed, "正向 OneBot 连接", 1, 1, false); err != nil {
+ return err
+ }
+ status, _, err = do("POST", "/api/connections", removal, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("stale removal accepted")
+ }
+ last := map[string]any{"revision": removed["revision"], "removeConnection": map[string]any{"kind": "ws", "index": 0, "confirm": true}}
+ status, _, err = do("POST", "/api/connections", last, csrf)
+ if err != nil || status != 400 {
+ return fmt.Errorf("last connection removal accepted")
+ }
+ if err = restore(removed["backupId"].(string)); err != nil {
+ return err
+ }
+ if err = restore(added["backupId"].(string)); err != nil {
+ return err
+ }
+ status, settings, err = do("GET", "/api/connections", nil, "")
+ if err != nil || status != 200 || settings["restartRequired"] != false || len(settings["connections"].([]any)) != 1 {
+ return fmt.Errorf("addition restore did not preserve running fixture")
+ }
+ // Reverse addition is saved only in this run's startup file; no listener starts here.
+ backendURL, _ := url.Parse(baseURL)
+ blockedReverse := map[string]any{"revision": settings["revision"], "addConnection": map[string]any{"kind": "wss", "url": "ws://" + backendURL.Host, "token": "synthetic-reverse-api-key"}}
+ status, _, err = do("POST", "/api/connections", blockedReverse, csrf)
+ if err != nil || status != 400 {
+ return fmt.Errorf("reverse backend port conflict accepted")
+ }
+ reverseAdd := map[string]any{"revision": settings["revision"], "addConnection": map[string]any{"kind": "wss", "url": "ws://127.0.0.1:17778", "token": "synthetic-reverse-api-key"}}
+ status, reverseSaved, err := do("POST", "/api/connections", reverseAdd, csrf)
+ if err != nil || status != 200 || reverseSaved["backupId"] == "" || reverseSaved["restartRequired"] != true || len(reverseSaved["connections"].([]any)) != 2 || len(reverseSaved["runningConnections"].([]any)) != 1 {
+ return fmt.Errorf("reverse addition API failed: %d %v", status, err)
+ }
+ if err := verifyStartupSummary(reverseSaved, "反向 OneBot 连接", 0, 1, true); err != nil {
+ return err
+ }
+ reverseJSON, _ := json.Marshal(reverseSaved)
+ if bytes.Contains(reverseJSON, []byte("synthetic-reverse-api-key")) {
+ return fmt.Errorf("reverse addition leaked token")
+ }
+ if err = restore(reverseSaved["backupId"].(string)); err != nil {
+ return err
+ }
+ status, manual, err := do("POST", "/api/backups", map[string]string{"scope": "control-db"}, csrf)
+ if err != nil || status != 200 || manual["backupId"] == "" {
+ return fmt.Errorf("manual control snapshot failed")
+ }
+ if _, err = setPlugin("0", "disabled"); err != nil {
+ return err
+ }
+ status, ai, err = do("GET", "/api/ai", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("AI restore fixture read failed")
+ }
+ currentModel := ai["global"].(map[string]any)["fields"].(map[string]any)["ModelName"]
+ status, _, err = do("POST", "/api/ai", map[string]any{"revision": ai["global"].(map[string]any)["revision"], "fields": map[string]any{"ModelName": "restore-temporary-model"}}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("AI restore fixture write failed")
+ }
+ if err = restore(manual["backupId"].(string)); err != nil {
+ return err
+ }
+ state, err = pluginState("101")
+ if err != nil || state["effectiveEnabled"] != true {
+ return fmt.Errorf("control restore left stale plugin cache")
+ }
+ status, ai, err = do("GET", "/api/ai", nil, "")
+ if err != nil || status != 200 || ai["global"].(map[string]any)["fields"].(map[string]any)["ModelName"] != currentModel {
+ return fmt.Errorf("control restore did not recover AI settings")
+ }
+ if text, err := sendControl(12345678, "翻转 abc"); err != nil || text != "ɔqɐ" {
+ return fmt.Errorf("restored plugin did not reply: %v", err)
+ }
+ status, backups, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 || len(backups["backups"].([]any)) < 3 {
+ return fmt.Errorf("backup list unavailable")
+ }
+ backupJSON, _ := json.Marshal(backups)
+ if bytes.Contains(backupJSON, []byte("synthetic-model-key")) || bytes.Contains(backupJSON, []byte("local-smoke-only")) {
+ return fmt.Errorf("backup metadata leaked credentials")
+ }
+ // Age only snapshots created inside this run's isolated backup directory.
+ // Preserve nested RawMessage values and large integers/checksums.
+ for i := 0; i < 4; i++ {
+ status, created, err := do("POST", "/api/backups", map[string]string{"scope": "startup-config"}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("cleanup fixture creation failed")
+ }
+ id, ok := created["backupId"].(string)
+ if !ok || filepath.Base(id) != id || !strings.HasSuffix(id, ".json") {
+ return fmt.Errorf("cleanup fixture ID invalid")
+ }
+ path := filepath.Join(dataDir, "backups", id)
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(raw, &fields) != nil {
+ return fmt.Errorf("cleanup fixture invalid")
+ }
+ fields["createdAt"], _ = json.Marshal(time.Now().UTC().AddDate(0, 0, -30-i))
+ raw, _ = json.Marshal(fields)
+ if os.WriteFile(path, raw, 0600) != nil {
+ return fmt.Errorf("cleanup fixture aging failed")
+ }
+ }
+ policy := map[string]int{"keepPerScope": 3, "keepDays": 7}
+ status, preview, err := do("POST", "/api/backups/cleanup-preview", policy, csrf)
+ if err != nil || status != 200 || len(preview["candidates"].([]any)) == 0 {
+ return fmt.Errorf("cleanup preview did not select old fixtures")
+ }
+ cleanupBody := map[string]any{"policy": preview["policy"], "revision": preview["revision"], "asOf": preview["asOf"], "expiresAt": preview["expiresAt"], "token": preview["token"], "confirm": false}
+ status, _, err = do("POST", "/api/backups/cleanup", cleanupBody, csrf)
+ if err != nil || status != 400 {
+ return fmt.Errorf("cleanup lacked explicit confirmation")
+ }
+ cleanupBody["confirm"] = true
+ status, _, err = do("POST", "/api/backups/cleanup", cleanupBody, "invalid")
+ if err != nil || status != 403 {
+ return fmt.Errorf("cleanup lacked CSRF protection")
+ }
+ startupPath := filepath.Join(filepath.Dir(dataDir), "config.json")
+ startupBefore, err := os.ReadFile(startupPath)
+ if err != nil {
+ return err
+ }
+ status, cleaned, err := do("POST", "/api/backups/cleanup", cleanupBody, csrf)
+ if err != nil || status != 200 || cleaned["partial"] != false || len(cleaned["deleted"].([]any)) != len(preview["candidates"].([]any)) {
+ return fmt.Errorf("isolated cleanup failed: %d %v", status, err)
+ }
+ startupAfter, err := os.ReadFile(startupPath)
+ if err != nil || !bytes.Equal(startupBefore, startupAfter) {
+ return fmt.Errorf("cleanup changed current startup configuration")
+ }
+ status, _, err = do("POST", "/api/backups/cleanup", cleanupBody, csrf)
+ if err != nil || status != 409 {
+ return fmt.Errorf("cleanup replay was accepted")
+ }
+ status, remaining, err := do("GET", "/api/backups", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("cleanup list refresh failed")
+ }
+ validStartup := 0
+ for _, entry := range remaining["backups"].([]any) {
+ b := entry.(map[string]any)
+ if b["scope"] == "startup-config" && b["valid"] == true {
+ validStartup++
+ }
+ }
+ if validStartup < 3 {
+ return fmt.Errorf("cleanup lost protected backups")
+ }
+ if err = verifyPolicyCommands(dataDir, sendControl, sendGroup); err != nil {
+ return err
+ }
+ // Password changes operate only on this run's synthetic credential directory.
+ const originalPassword, replacementPassword = "Local-ui-test-password-only", "Local-ui-changed-password-only"
+ passwordBody := func(current, next string) map[string]string {
+ return map[string]string{"currentPassword": current, "newPassword": next, "confirmPassword": next}
+ }
+ primaryClient := client
+ secondJar, _ := cookiejar.New(nil)
+ client = &http.Client{Jar: secondJar, Timeout: 15 * time.Second}
+ status, secondSession, err := do("POST", "/api/login", map[string]string{"password": originalPassword}, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("second synthetic admin login failed")
+ }
+ secondCSRF := secondSession["csrfToken"].(string)
+ secondClient := client
+ client = primaryClient
+ status, _, err = do("POST", "/api/password", passwordBody("wrong", replacementPassword), csrf)
+ if err != nil || status != 403 {
+ return fmt.Errorf("wrong current password accepted")
+ }
+ status, _, err = do("GET", "/api/overview", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("wrong password killed current session")
+ }
+ status, _, err = do("POST", "/api/password", passwordBody(originalPassword, replacementPassword), "invalid-csrf")
+ if err != nil || status != 403 {
+ return fmt.Errorf("password change skipped CSRF")
+ }
+ status, changed, err := do("POST", "/api/password", passwordBody(originalPassword, replacementPassword), csrf)
+ if err != nil || status != 200 || changed["passwordChanged"] != true || changed["authenticated"] != false {
+ return fmt.Errorf("password change failed")
+ }
+ status, _, err = do("GET", "/api/overview", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("primary password session remained active")
+ }
+ client = secondClient
+ status, _, err = do("POST", "/api/password", passwordBody(originalPassword, "another-test-password-only"), secondCSRF)
+ if err != nil || status != 401 {
+ return fmt.Errorf("secondary old password session remained active")
+ }
+ client = primaryClient
+ status, _, err = do("POST", "/api/login", map[string]string{"password": originalPassword}, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("old password login remained valid")
+ }
+ status, changedSession, err := do("POST", "/api/login", map[string]string{"password": replacementPassword}, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("new password login failed")
+ }
+ csrf = changedSession["csrfToken"].(string)
+ credentialBytes, err := os.ReadFile(filepath.Join(dataDir, "credential.json"))
+ if err != nil || bytes.Contains(credentialBytes, []byte(originalPassword)) || bytes.Contains(credentialBytes, []byte(replacementPassword)) {
+ return fmt.Errorf("password credential persistence wrong")
+ }
+ status, _, err = do("POST", "/api/password", passwordBody(replacementPassword, originalPassword), csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("synthetic password restore failed")
+ }
+ status, restoredSession, err := do("POST", "/api/login", map[string]string{"password": originalPassword}, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("restored synthetic password login failed")
+ }
+ csrf = restoredSession["csrfToken"].(string)
+ status, _, err = do("POST", "/api/logout", map[string]any{}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("isolated logout failed")
+ }
+ status, _, err = do("GET", "/api/overview", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("logout left admin access enabled")
+ }
+ status, _, err = do("GET", "/api/logs", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("logout left log access enabled")
+ }
+ status, _, err = do("GET", "/api/logs/export", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("logout left log export enabled")
+ }
+ status, _, err = do("GET", "/api/backups", nil, "")
+ if err != nil || status != 401 {
+ return fmt.Errorf("logout left backup access enabled")
+ }
+ for _, path := range []string{"/api/backups/cleanup-preview", "/api/backups/cleanup"} {
+ status, _, err = do("POST", path, map[string]any{}, csrf)
+ if err != nil || status != 401 {
+ return fmt.Errorf("logout left cleanup access enabled")
+ }
+ }
+ return nil
+}
+
+// Failure injection affects only this run's synthetic admin backup directory.
+// Check snapshots, truthful command feedback, and later message routing together.
+func verifyVoiceCommands(do func(string, string, any, string) (int, map[string]any, error), csrf string, private func(int64, string) (string, error), group func(int64, int64, string, string, bool) (string, error)) error {
+ if text, err := private(12345678, "发送AI语音preconfig"); err != nil || !strings.Contains(text, "未选择") {
+ return fmt.Errorf("unconfigured voice did not stop: %q %v", text, err)
+ }
+ for _, c := range []struct{ cmd, want string }{
+ {"设置AI语音群号0", "正数"},
+ {"设置AI语音群号303", "为303"},
+ {"设置AI语音模型", "请选择语音模型序号"},
+ {"0", "已选择语音模型: Synthetic voice"},
+ {"查看AI语音配置", "synthetic-voice-1"},
+ {"发送AI语音 ", "填写"},
+ {"发送AI语音private-ok", "仅支持群聊"},
+ {"发送AI语音private-failure", "仅支持群聊"},
+ } {
+ text, err := private(12345678, c.cmd)
+ if err != nil || !strings.Contains(text, c.want) {
+ return fmt.Errorf("voice private command %s failed: %q %v", c.cmd, text, err)
+ }
+ }
+ if text, err := group(12345678, 303, "member", "/响应", true); err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("voice group setup failed")
+ }
+ for _, c := range []struct{ role, cmd, want string }{
+ {"member", "发送AI语音group-ok", ""},
+ {"admin", "发送AI语音group-ok", "VOICE_OK"},
+ {"owner", "发送AI语音group-zero-id", "VOICE_OK"},
+ {"admin", "发送AI语音group-failure", "未自动重试"},
+ } {
+ text, err := group(23456789, 303, c.role, c.cmd, true)
+ if err != nil || (c.want == "" && text != "") || (c.want != "" && !strings.Contains(text, c.want)) {
+ return fmt.Errorf("voice group command %s failed: %q %v", c.cmd, text, err)
+ }
+ }
+ if text, err := group(12345678, 404, "member", "/响应", true); err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("second voice group setup failed")
+ }
+ if text, err := group(12345678, 404, "member", "发送AI语音current-group", true); err != nil || text != "VOICE_OK" {
+ return fmt.Errorf("voice escaped current group: %q %v", text, err)
+ }
+ if err := verifyChatVoiceScope(do, csrf, private, group); err != nil {
+ return err
+ }
+ for _, gid := range []int64{303, 404} {
+ if text, err := group(12345678, gid, "member", "/沉默", true); err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("voice group cleanup failed")
+ }
+ }
+ return nil
+}
+
+func verifyChatVoiceScope(do func(string, string, any, string) (int, map[string]any, error), csrf string, private func(int64, string) (string, error), group func(int64, int64, string, string, bool) (string, error)) error {
+ status, snapshot, err := do("GET", "/api/ai", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice model snapshot failed")
+ }
+ global := snapshot["global"].(map[string]any)
+ oldModel := global["fields"].(map[string]any)["ModelName"]
+ status, _, err = do("POST", "/api/ai", map[string]any{"revision": global["revision"], "fields": map[string]any{"ModelName": "smoke-voice-chat"}}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice model fixture setup failed")
+ }
+ switchChat := func(mode string) error {
+ status, list, err := do("GET", "/api/plugins", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice chat plugin read failed")
+ }
+ for _, item := range list["plugins"].([]any) {
+ p := item.(map[string]any)
+ if p["name"] != "aichat" {
+ continue
+ }
+ state := p["state"].(map[string]any)
+ status, _, err = do("POST", "/api/plugin-switch", map[string]any{"name": "aichat", "groupId": "0", "mode": mode, "revision": state["revision"]}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice chat plugin switch failed")
+ }
+ return nil
+ }
+ return fmt.Errorf("voice chat plugin missing")
+ }
+ for _, target := range []string{"group=404", "private=12345678"} {
+ status, value, err := do("GET", "/api/ai?"+target, nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice chat settings read failed")
+ }
+ for _, item := range value["groups"].([]any) {
+ setting := item.(map[string]any)
+ if setting["service"] != "aichatcfg" {
+ continue
+ }
+ change := map[string]any{"service": "aichatcfg", "revision": setting["revision"], "rate": 0, "temperature": 50, "useAgent": false, "respondAt": true, "voiceOutput": true}
+ if strings.HasPrefix(target, "group=") {
+ change["groupId"] = "404"
+ } else {
+ change["privateId"] = "12345678"
+ }
+ status, _, err = do("POST", "/api/ai-group", change, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice chat settings write failed")
+ }
+ }
+ }
+ if text, err := private(12345678, "/全局响应"); err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("voice chat response setup failed")
+ }
+ if err := switchChat("enabled"); err != nil {
+ return err
+ }
+ if text, err := group(12345678, 404, "member", "VOICE_CHAT_GROUP", true); err != nil || text != "VOICE_OK" {
+ return fmt.Errorf("ordinary group chat voice failed: %q %v", text, err)
+ }
+ if text, err := private(12345678, "VOICE_CHAT_PRIVATE"); err != nil || text != "VOICE_TEXT_OK" {
+ return fmt.Errorf("ordinary private chat did not preserve text: %q %v", text, err)
+ }
+ if err := switchChat("disabled"); err != nil {
+ return err
+ }
+ if text, err := private(12345678, "/全局沉默"); err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("voice chat response cleanup failed")
+ }
+ status, snapshot, err = do("GET", "/api/ai", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice model cleanup snapshot failed")
+ }
+ global = snapshot["global"].(map[string]any)
+ status, _, err = do("POST", "/api/ai", map[string]any{"revision": global["revision"], "fields": map[string]any{"ModelName": oldModel}}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("voice model fixture cleanup failed")
+ }
+ return nil
+}
+
+func verifyAIRolePermissions(do func(string, string, any, string) (int, map[string]any, error), private func(int64, string) (string, error), send func(int64, int64, string, string, bool) (string, error), csrf, modelURL string) error {
+ switchAgent := func(mode string) error {
+ status, list, err := do("GET", "/api/plugins", nil, "")
+ if err != nil || status != 200 {
+ return fmt.Errorf("role plugin snapshot failed")
+ }
+ for _, item := range list["plugins"].([]any) {
+ p := item.(map[string]any)
+ if p["name"] != "aichat" {
+ continue
+ }
+ state := p["state"].(map[string]any)
+ status, _, err = do("POST", "/api/plugin-switch", map[string]any{"name": "aichat", "groupId": "0", "mode": mode, "revision": state["revision"]}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("role agent plugin switch failed")
+ }
+ return nil
+ }
+ return fmt.Errorf("role agent plugin missing")
+ }
+ read := func(target string) (map[string]any, error) {
+ status, value, err := do("GET", "/api/ai?"+target, nil, "")
+ if err != nil || status != 200 {
+ return nil, fmt.Errorf("role snapshot failed: %v", err)
+ }
+ return value, nil
+ }
+ configure := func(target string, useAgent bool) error {
+ value, err := read(target)
+ if err != nil {
+ return err
+ }
+ for _, item := range value["groups"].([]any) {
+ c := item.(map[string]any)
+ change := map[string]any{"service": c["service"], "revision": c["revision"], "temperature": 50}
+ if strings.HasPrefix(target, "group=") {
+ change["groupId"] = strings.TrimPrefix(target, "group=")
+ } else {
+ change["privateId"] = strings.TrimPrefix(target, "private=")
+ }
+ if c["service"] == "aichatcfg" {
+ change["rate"], change["useAgent"], change["respondAt"], change["voiceOutput"] = 0, useAgent, useAgent, false
+ }
+ status, _, err := do("POST", "/api/ai-group", change, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("role conversation setup failed: %v", err)
+ }
+ }
+ return nil
+ }
+ if err := configure("group=303", false); err != nil {
+ return err
+ }
+ if err := configure("private=23456789", false); err != nil {
+ return err
+ }
+ if text, err := private(12345678, "/全局响应"); err != nil || !strings.Contains(text, "工作") {
+ return fmt.Errorf("role response setup failed")
+ }
+ for _, c := range []struct {
+ name, role string
+ uid, gid int64
+ allow bool
+ }{
+ {"member", "member", 23456789, 303, false},
+ {"admin", "admin", 23456789, 303, true},
+ {"group owner", "owner", 34567890, 303, true},
+ {"private non-owner", "member", 23456789, 0, false},
+ } {
+ target := "group=303"
+ if c.gid == 0 {
+ target = "private=23456789"
+ }
+ before, err := read(target)
+ if err != nil {
+ return err
+ }
+ text, err := send(c.uid, c.gid, c.role, "设置AI聊天温度65", c.gid > 0)
+ if err != nil || (c.allow && !strings.Contains(text, "成功")) || (!c.allow && text != "") {
+ return fmt.Errorf("role temperature command failed: %s %q %v", c.name, text, err)
+ }
+ after, err := read(target)
+ if err != nil {
+ return err
+ }
+ beforeJSON, _ := json.Marshal(before)
+ afterJSON, _ := json.Marshal(after)
+ if !c.allow && !bytes.Equal(beforeJSON, afterJSON) {
+ return fmt.Errorf("denied setting changed state: %s", c.name)
+ }
+ if c.allow && after["groups"].([]any)[0].(map[string]any)["temperature"] != float64(65) {
+ return fmt.Errorf("allowed setting did not persist: %s", c.name)
+ }
+ for _, command := range []string{"设置AI聊天模型名forbidden-role-model", "设置AI聊天不使用Agent模式", "重置AI聊天Agent", "查看AI聊天配置"} {
+ if text, err = send(c.uid, c.gid, c.role, command, c.gid > 0); err != nil || text != "" {
+ return fmt.Errorf("owner-only command accepted: %s %s %q %v", c.name, command, text, err)
+ }
+ }
+ final, err := read(target)
+ if err != nil {
+ return err
+ }
+ finalJSON, _ := json.Marshal(final)
+ if !bytes.Equal(afterJSON, finalJSON) {
+ return fmt.Errorf("owner-only rejection changed config: %s", c.name)
+ }
+ }
+ for _, c := range []struct {
+ gid int64
+ cmd string
+ }{{303, "/gpt role member"}, {0, "/gpt role private"}} {
+ text, err := send(23456789, c.gid, "member", c.cmd, false)
+ if err != nil || !strings.Contains(text, "本地模型模拟回复") {
+ return fmt.Errorf("non-owner GPT rejected: %v %q", err, text)
+ }
+ }
+ value, err := read("group=303")
+ if err != nil {
+ return err
+ }
+ global := value["global"].(map[string]any)
+ status, _, err := do("POST", "/api/ai", map[string]any{"revision": global["revision"], "fields": map[string]any{"AgentAPI": modelURL, "AgentModelName": "smoke-role-agent", "AgentType": 0}, "keys": map[string]any{"AgentKey": map[string]any{"action": "replace", "value": "synthetic-model-key"}}}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("role agent setup failed: %v", err)
+ }
+ if err := configure("group=303", true); err != nil {
+ return err
+ }
+ if err := switchAgent("enabled"); err != nil {
+ return err
+ }
+ for _, c := range []struct {
+ uid int64
+ role, cmd, want string
+ }{
+ {23456789, "member", "ROLE_AGENT_CHECK", "ROLE_AGENT_user"},
+ {23456789, "admin", "ROLE_AGENT_CHECK", "ROLE_AGENT_admin"},
+ {34567890, "owner", "ROLE_AGENT_CHECK", "ROLE_AGENT_admin"},
+ {23456789, "member", "ROLE_AGENT_CROSS_INFO", "ROLE_BLOCKED_OK"},
+ {23456789, "member", "ROLE_AGENT_CQ_ALL", "ROLE_BLOCKED_OK"},
+ } {
+ text, err := send(c.uid, 303, c.role, c.cmd, true)
+ if err != nil || text != c.want {
+ return fmt.Errorf("actual Agent role/action failed: %s %s got %q want %q error %v", c.role, c.cmd, text, c.want, err)
+ }
+ // Allow the message hook to record the API response before updating roles.
+ time.Sleep(100 * time.Millisecond)
+ }
+ if err := configure("group=303", false); err != nil {
+ return err
+ }
+ if err := switchAgent("disabled"); err != nil {
+ return err
+ }
+ value, err = read("group=303")
+ if err != nil {
+ return err
+ }
+ global = value["global"].(map[string]any)
+ status, _, err = do("POST", "/api/ai", map[string]any{"revision": global["revision"], "fields": map[string]any{"AgentAPI": "", "AgentModelName": ""}, "keys": map[string]any{"AgentKey": map[string]any{"action": "clear", "confirm": true}}}, csrf)
+ if err != nil || status != 200 {
+ return fmt.Errorf("role agent model cleanup failed")
+ }
+ if text, err := private(12345678, "/全局沉默"); err != nil || !strings.Contains(text, "休息") {
+ return fmt.Errorf("role response cleanup failed")
+ }
+ return nil
+}
+
+func verifyPolicyCommands(dataDir string, private func(int64, string) (string, error), group func(int64, int64, string, string, bool) (string, error)) error {
+ const owner, user = int64(12345678), int64(23456789)
+ backups := filepath.Join(dataDir, "backups")
+ count := func() (int, error) {
+ entries, err := os.ReadDir(backups)
+ if err != nil {
+ return 0, err
+ }
+ n := 0
+ for _, e := range entries {
+ if e.IsDir() && strings.HasSuffix(e.Name(), "-control") {
+ n++
+ }
+ }
+ return n, nil
+ }
+ command := func(gid int64, cmd, want string, delta int) error {
+ before, err := count()
+ if err != nil {
+ return err
+ }
+ var text string
+ if gid == 0 {
+ text, err = private(owner, cmd)
+ } else {
+ text, err = group(owner, gid, "member", cmd, true)
+ }
+ if err != nil || !strings.Contains(text, want) {
+ return fmt.Errorf("policy command %s feedback failed: %v", cmd, err)
+ }
+ after, err := count()
+ if err != nil {
+ return err
+ }
+ if after-before != delta {
+ return fmt.Errorf("policy command %s made %d snapshots, expected %d", cmd, after-before, delta)
+ }
+ return nil
+ }
+ route := func(uid, gid int64, want string) error {
+ text, err := group(uid, gid, "member", "翻转 abc", false)
+ if err != nil || text != want {
+ return fmt.Errorf("policy gate for synthetic user %d/group %d failed: %v", uid, gid, err)
+ }
+ return nil
+ }
+ if err := command(101, "/响应", "工作", 1); err != nil {
+ return err
+ }
+ if err := command(101, "/响应", "已处于响应", 0); err != nil {
+ return err
+ }
+ for _, pair := range []struct {
+ gid int64
+ add, remove, yes, no string
+ }{
+ {101, "/禁止 chrev 23456789", "/允许 chrev 23456789", "已禁止", "已允许"},
+ {0, "/全局禁止 chrev 23456789", "/全局允许 chrev 23456789", "已禁止", "已允许"},
+ {0, "/封禁 23456789", "/解封 23456789", "已封禁", "已解封"},
+ } {
+ if err := route(user, 101, "ɔqɐ"); err != nil {
+ return err
+ }
+ if err := command(pair.gid, pair.add, pair.yes, 1); err != nil {
+ return err
+ }
+ if err := command(pair.gid, pair.add, pair.yes, 0); err != nil {
+ return err
+ }
+ if err := route(user, 101, ""); err != nil {
+ return err
+ }
+ if err := command(pair.gid, pair.remove, pair.no, 1); err != nil {
+ return err
+ }
+ if err := command(pair.gid, pair.remove, pair.no, 0); err != nil {
+ return err
+ }
+ if err := route(user, 101, "ɔqɐ"); err != nil {
+ return err
+ }
+ }
+ before, err := count()
+ if err != nil {
+ return err
+ }
+ if text, err := group(user, 101, "member", "/禁止 chrev 12345678", true); err != nil || text != "" {
+ return fmt.Errorf("unauthorized policy command accepted")
+ }
+ after, err := count()
+ if err != nil || after != before {
+ return fmt.Errorf("unauthorized policy command created a backup")
+ }
+ for _, c := range []struct {
+ gid int64
+ cmd, want string
+ }{
+ {101, "/禁止 chrev 34567890", "已禁止"},
+ {0, "/全局禁止 chrev 45678901", "已禁止"},
+ {0, "/封禁 56789012", "已封禁"},
+ } {
+ if err := command(c.gid, c.cmd, c.want, 1); err != nil {
+ return err
+ }
+ }
+ // The directory is inside a fresh smoke fixture, never the live admin path.
+ withBlockedBackups := func(check func() error) (result error) {
+ held := backups + "-held"
+ if err := os.Rename(backups, held); err != nil {
+ return err
+ }
+ defer func() {
+ if err := os.Remove(backups); err != nil && !os.IsNotExist(err) {
+ result = err
+ return
+ }
+ if err := os.Rename(held, backups); err != nil {
+ result = err
+ }
+ }()
+ if err := os.WriteFile(backups, []byte("synthetic blocked backup directory"), 0600); err != nil {
+ return err
+ }
+ return check()
+ }
+ failure := func(gid int64, cmd string) error {
+ var text string
+ var err error
+ if gid == 0 {
+ text, err = private(owner, cmd)
+ } else {
+ text, err = group(owner, gid, "member", cmd, true)
+ }
+ if err != nil || !strings.Contains(text, "原状态保留") || strings.Contains(text, "已禁止") || strings.Contains(text, "已允许") || strings.Contains(text, "已封禁") || strings.Contains(text, "已解封") || strings.Contains(text, "SQL") || strings.Contains(text, dataDir) {
+ return fmt.Errorf("policy failure feedback leaked details or claimed success for %s", cmd)
+ }
+ return nil
+ }
+ if err := withBlockedBackups(func() error {
+ for _, c := range []struct {
+ gid int64
+ cmd string
+ }{
+ {202, "/响应"}, {101, "/沉默"}, {0, "/全局响应"},
+ {101, "/禁止 chrev 23456789"}, {101, "/允许 chrev 34567890"},
+ {0, "/全局禁止 chrev 23456789"}, {0, "/全局允许 chrev 45678901"},
+ {0, "/封禁 23456789"}, {0, "/解封 56789012"},
+ } {
+ if err := failure(c.gid, c.cmd); err != nil {
+ return err
+ }
+ }
+ for _, c := range []struct {
+ uid, gid int64
+ want string
+ }{
+ {user, 101, "ɔqɐ"}, {user, 202, ""}, {34567890, 101, ""}, {45678901, 101, ""}, {56789012, 101, ""},
+ } {
+ if err := route(c.uid, c.gid, c.want); err != nil {
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ for _, c := range []struct {
+ gid int64
+ cmd, want string
+ }{
+ {101, "/允许 chrev 34567890", "已允许"}, {0, "/全局允许 chrev 45678901", "已允许"},
+ {0, "/解封 56789012", "已解封"}, {101, "/沉默", "休息"}, {0, "/全局响应", "全部位置工作"},
+ } {
+ if err := command(c.gid, c.cmd, c.want, 1); err != nil {
+ return err
+ }
+ }
+ if err := withBlockedBackups(func() error {
+ if err := failure(0, "/全局沉默"); err != nil {
+ return err
+ }
+ return route(user, 202, "ɔqɐ")
+ }); err != nil {
+ return err
+ }
+ if err := command(0, "/全局沉默", "休息", 1); err != nil {
+ return err
+ }
+ return route(user, 101, "")
+}
+
+// A second, disposable process exercises the actual reverse driver. It has no
+// daily credentials, model settings or connection to NapCat / real QQ.
+func verifyReverseDriver(root, parent, binary string) error {
+ fixture, err := os.MkdirTemp(parent, "reverse-driver-")
+ if err != nil {
+ return err
+ }
+ if err = prepareFixture(root, fixture); err != nil {
+ return err
+ }
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ return err
+ }
+ address := listener.Addr().String()
+ adminListener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ listener.Close()
+ return err
+ }
+ adminAddress := adminListener.Addr().String()
+ listener.Close()
+ adminListener.Close()
+ target := "ws://" + address
+ token := "synthetic-reverse-driver-key"
+ config := map[string]any{"zero": map[string]any{"nickname": []string{"七叶叶"}, "command_prefix": "/", "super_users": []int64{12345678}, "ring_len": 4096, "latency": 233000000, "max_process_time": 240000000000, "mark_message": true}, "ws": []any{}, "wss": []map[string]string{{"URL": target, "AccessToken": token}}, "force_base64_file": false}
+ raw, _ := json.Marshal(config)
+ configPath := filepath.Join(fixture, "config.json")
+ if err = os.WriteFile(configPath, raw, 0600); err != nil {
+ return err
+ }
+ dataDir := filepath.Join(fixture, "admin")
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, filepath.Join(root, ".local", "bin", binary), "-c", configPath, "-admin", "-admin-addr", adminAddress, "-admin-data", dataDir)
+ cmd.Dir = fixture
+ cmd.Env = append(os.Environ(), "DEBUG_MODE=1")
+ stdout, err := os.Create(filepath.Join(fixture, "stdout.log"))
+ if err != nil {
+ return err
+ }
+ defer stdout.Close()
+ stderr, err := os.Create(filepath.Join(fixture, "stderr.log"))
+ if err != nil {
+ return err
+ }
+ defer stderr.Close()
+ cmd.Stdout = stdout
+ cmd.Stderr = stderr
+ if err = cmd.Start(); err != nil {
+ return err
+ }
+ exited := make(chan error, 1)
+ go func() { exited <- cmd.Wait() }()
+ defer func() {
+ cancel()
+ select {
+ case <-exited:
+ case <-time.After(3 * time.Second):
+ }
+ }()
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar, Timeout: time.Second}
+ base := "http://" + adminAddress
+ ready := false
+ for attempt := 0; attempt < 450; attempt++ {
+ rsp, e := client.Get(base + "/api/session")
+ if e == nil {
+ io.Copy(io.Discard, rsp.Body)
+ rsp.Body.Close()
+ if rsp.StatusCode == 200 {
+ ready = true
+ break
+ }
+ }
+ select {
+ case e := <-exited:
+ return fmt.Errorf("reverse fixture exited before admin ready: %v", e)
+ case <-ctx.Done():
+ return fmt.Errorf("reverse fixture startup timeout")
+ default:
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ if !ready {
+ return fmt.Errorf("reverse fixture admin unavailable")
+ }
+ for _, check := range []struct {
+ auth string
+ status int
+ }{{"", 401}, {"Bearer incorrect-fixture-key", 403}} {
+ req, _ := http.NewRequest("GET", "http://"+address+"/", nil)
+ if check.auth != "" {
+ req.Header.Set("Authorization", check.auth)
+ }
+ rsp, e := client.Do(req)
+ if e != nil {
+ return fmt.Errorf("reverse authentication probe unavailable: %w", e)
+ }
+ io.Copy(io.Discard, rsp.Body)
+ rsp.Body.Close()
+ if rsp.StatusCode != check.status {
+ return fmt.Errorf("reverse driver did not reject wrong authentication: %d", rsp.StatusCode)
+ }
+ }
+ conn, _, err := websocket.DefaultDialer.Dial(target, http.Header{"Authorization": []string{"Bearer " + token}})
+ if err != nil {
+ return fmt.Errorf("reverse driver authenticated upgrade failed: %w", err)
+ }
+ defer conn.Close()
+ if err = conn.WriteJSON(map[string]any{"self_id": 87654323, "post_type": "meta_event", "meta_event_type": "lifecycle", "sub_type": "connect", "time": time.Now().Unix()}); err != nil {
+ return err
+ }
+ code, err := os.ReadFile(filepath.Join(dataDir, "setup-code.txt"))
+ if err != nil {
+ return err
+ }
+ setup, _ := json.Marshal(map[string]string{"setupCode": strings.TrimSpace(string(code)), "password": "reverse-fixture-password-only"})
+ req, _ := http.NewRequest("POST", base+"/api/setup", bytes.NewReader(setup))
+ req.Header.Set("Origin", base)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Local-Request", "1")
+ rsp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ io.Copy(io.Discard, rsp.Body)
+ rsp.Body.Close()
+ if rsp.StatusCode != 200 {
+ return fmt.Errorf("reverse fixture setup failed: %d", rsp.StatusCode)
+ }
+ connected := false
+ for attempt := 0; attempt < 50; attempt++ {
+ rsp, e := client.Get(base + "/api/overview")
+ if e == nil {
+ var overview map[string]any
+ decodeErr := json.NewDecoder(rsp.Body).Decode(&overview)
+ rsp.Body.Close()
+ accounts, _ := overview["botAccounts"].([]any)
+ if decodeErr == nil && rsp.StatusCode == 200 && overview["connectionState"] == "connected" && len(accounts) == 1 && accounts[0] == "87654323" {
+ connected = true
+ break
+ }
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if !connected {
+ return fmt.Errorf("reverse handshake did not register synthetic account")
+ }
+ rsp, err = client.Get(base + "/api/connections")
+ if err != nil {
+ return err
+ }
+ var settings map[string]any
+ err = json.NewDecoder(rsp.Body).Decode(&settings)
+ rsp.Body.Close()
+ if err != nil || rsp.StatusCode != 200 {
+ return fmt.Errorf("reverse fixture settings unavailable")
+ }
+ views, _ := settings["runningConnections"].([]any)
+ if len(views) != 1 || views[0].(map[string]any)["kind"] != "wss" || views[0].(map[string]any)["tokenConfigured"] != true {
+ return fmt.Errorf("reverse driver startup config mismatch")
+ }
+ encoded, _ := json.Marshal(settings)
+ if bytes.Contains(encoded, []byte(token)) {
+ return fmt.Errorf("reverse fixture read exposed token")
+ }
+ if err = conn.WriteJSON(map[string]any{"time": time.Now().Unix(), "self_id": 87654323, "post_type": "message", "message_type": "private", "sub_type": "friend", "message_id": 1, "user_id": 12345678, "raw_message": "/help", "message": []map[string]any{{"type": "text", "data": map[string]string{"text": "/help"}}}, "sender": map[string]any{"user_id": 12345678, "nickname": "Reverse synthetic user", "sex": "unknown", "age": 0}}); err != nil {
+ return err
+ }
+ conn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ help := false
+ for i := 0; i < 30; i++ {
+ var request apiRequest
+ if err = conn.ReadJSON(&request); err != nil {
+ return fmt.Errorf("reverse command reply missing: %w", err)
+ }
+ if err = conn.WriteJSON(map[string]any{"status": "ok", "retcode": 0, "data": map[string]any{"message_id": 999, "user_id": 87654323, "nickname": "Reverse synthetic bot"}, "echo": request.Echo}); err != nil {
+ return err
+ }
+ if request.Action == "send_private_msg" {
+ text, e := smokeMessageText(request.Params.Message)
+ if e != nil || request.Params.UserID != 12345678 || !strings.Contains(text, "OneBot + ZeroBot + Golang") {
+ return fmt.Errorf("reverse command delivery incorrect")
+ }
+ help = true
+ break
+ }
+ }
+ if !help {
+ return fmt.Errorf("reverse command response not observed")
+ }
+ evidence := map[string]any{"passed": true, "isolated": true, "realQQMessagesSent": false, "missingTokenDenied": true, "wrongTokenDenied": true, "authenticatedUpgradePassed": true, "actualReverseAccountRegistered": true, "startupConfigAndRedactionPassed": true, "helpRoundtripPassed": true, "completedAt": time.Now().Format(time.RFC3339)}
+ encoded, _ = json.MarshalIndent(evidence, "", " ")
+ return os.WriteFile(filepath.Join(fixture, "result.json"), encoded, 0600)
+}
diff --git a/scripts/start-admin-acceptance.go b/scripts/start-admin-acceptance.go
new file mode 100644
index 0000000000..7536cd7b03
--- /dev/null
+++ b/scripts/start-admin-acceptance.go
@@ -0,0 +1,450 @@
+//go:build ignore
+
+// A disposable Windows admin instance for manual page acceptance.
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "time"
+
+ qa "github.com/FloatTech/ZeroBot-Plugin/internal/adminqa"
+)
+
+type acceptance struct {
+ h *qa.Harness
+ bot *qa.Bot
+ client *qa.Client
+ password, originalAddress, editedAddress string
+ backups map[string]string
+ credentialHash [32]byte
+ starts int
+ result map[string]any
+}
+
+func main() {
+ binary := flag.String("binary-name", "zerobot.exe", "executable filename under .local/bin")
+ lifetime := flag.Duration("lifetime", 30*time.Minute, "maximum lifetime, 1m to 2h")
+ selfCheck := flag.Bool("self-check", false, "check preparation/restore/restart and exit")
+ flag.Parse()
+ if flag.NArg() != 0 || filepath.Base(*binary) != *binary || strings.ContainsAny(*binary, "/\\:") || filepath.Ext(*binary) != ".exe" || *lifetime < time.Minute || *lifetime > 2*time.Hour {
+ fmt.Fprintln(os.Stderr, "Invalid arguments: use a local executable filename and lifetime 1m to 2h.")
+ os.Exit(2)
+ }
+ if err := run(*binary, *lifetime, *selfCheck); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run(binaryName string, lifetime time.Duration, selfCheck bool) (err error) {
+ cwd, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ root, err := filepath.EvalSymlinks(cwd)
+ if err != nil {
+ return err
+ }
+ source := filepath.Join(root, ".local/bin", binaryName)
+ input, err := os.Open(source)
+ if err != nil {
+ return fmt.Errorf("cannot read the selected local binary")
+ }
+ defer input.Close()
+ parent := filepath.Join(root, ".local/admin-acceptance")
+ if err = os.MkdirAll(parent, 0700); err != nil {
+ return err
+ }
+ output, err := os.MkdirTemp(parent, "run-")
+ if err != nil {
+ return err
+ }
+ copied := filepath.Join(output, "zerobot-acceptance.exe")
+ executable, err := os.OpenFile(copied, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700)
+ if err != nil {
+ return err
+ }
+ hash := sha256.New()
+ _, copyErr := io.Copy(io.MultiWriter(executable, hash), input)
+ closeErr := executable.Close()
+ inputCloseErr := input.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ if inputCloseErr != nil {
+ return inputCloseErr
+ }
+ signalContext, stopSignal := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stopSignal()
+ ctx, cancel := context.WithTimeout(signalContext, lifetime)
+ defer cancel()
+ deadline, _ := ctx.Deadline()
+ a := &acceptance{result: map[string]any{"startedAt": time.Now().Format(time.RFC3339), "isolated": true, "binary": binaryName, "binarySHA256": hex.EncodeToString(hash.Sum(nil)), "selfCheck": selfCheck, "realQQMessagesSent": false, "manualAcceptancePassed": false, "environmentPrepared": false, "output": output, "expiresAt": deadline.Format(time.RFC3339)}}
+ defer func() {
+ if a.bot != nil {
+ a.bot.Stop()
+ }
+ if a.h != nil {
+ a.h.Close()
+ }
+ a.result["stoppedAt"] = time.Now().Format(time.RFC3339)
+ a.result["processStarts"] = a.starts
+ a.result["instanceStopped"] = true
+ if err != nil {
+ a.result["failure"] = err.Error()
+ }
+ if saveErr := qa.WriteJSON(filepath.Join(output, "result.json"), a.result); saveErr != nil && err == nil {
+ err = saveErr
+ }
+ _ = qa.WriteJSON(filepath.Join(output, "session.json"), map[string]any{"state": "stopped", "output": output})
+ fmt.Println("ARTIFACTS=" + output)
+ }()
+ a.h, err = qa.New(root, copied, output)
+ if err != nil {
+ return err
+ }
+ a.originalAddress = a.h.Address
+ for attempt := 0; attempt < 8; attempt++ {
+ a.editedAddress, err = qa.FreeAddress()
+ if err != nil {
+ return err
+ }
+ if a.editedAddress != a.originalAddress {
+ break
+ }
+ }
+ if a.editedAddress == a.originalAddress {
+ return errors.New("cannot prepare a different isolated admin address")
+ }
+ secret := make([]byte, 24)
+ if _, err = rand.Read(secret); err != nil {
+ return err
+ }
+ a.password = "QA-" + hex.EncodeToString(secret)
+ if err = os.WriteFile(filepath.Join(output, "login.txt"), []byte("Isolated admin password (not a daily credential):\n"+a.password+"\n"), 0600); err != nil {
+ return err
+ }
+ if err = a.start(ctx, "initial", true); err != nil {
+ return err
+ }
+ if err = a.prepare(); err != nil {
+ return err
+ }
+ a.result["environmentPrepared"] = true
+ a.result["baselineBackups"] = a.backups
+ if selfCheck {
+ return a.check(ctx)
+ }
+ if err = a.session(output); err != nil {
+ return err
+ }
+ fmt.Println("Commands: status, restart, stop. Browser login uses the private login.txt file.")
+ commands := make(chan string)
+ finished := make(chan error, 1)
+ monitor := time.NewTicker(3 * time.Second)
+ defer monitor.Stop()
+ go func() {
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ select {
+ case commands <- strings.TrimSpace(scanner.Text()):
+ case <-ctx.Done():
+ return
+ }
+ }
+ finished <- scanner.Err()
+ }()
+ for {
+ select {
+ case <-monitor.C:
+ if ctx.Err() != nil {
+ continue
+ }
+ if !a.bot.Running() {
+ if ctx.Err() != nil {
+ continue
+ }
+ return errors.New("the isolated process exited; inspect this run's logs")
+ }
+ case <-ctx.Done():
+ a.result["stopReason"] = "signal-or-time-limit"
+ return nil
+ case inputErr := <-finished:
+ a.result["stopReason"] = "input-ended"
+ return inputErr
+ case command := <-commands:
+ switch command {
+ case "stop":
+ a.result["stopReason"] = "stop-command"
+ return nil
+ case "status":
+ if err = a.session(output); err != nil {
+ return err
+ }
+ case "restart":
+ if guardErr := a.guard(); guardErr != nil {
+ fmt.Println("Restart refused: " + guardErr.Error())
+ continue
+ }
+ a.bot.Stop()
+ a.bot = nil
+ if err = a.start(ctx, fmt.Sprintf("restart-%d", a.starts), false); err != nil {
+ return err
+ }
+ if err = a.session(output); err != nil {
+ return err
+ }
+ default:
+ fmt.Println("Commands: status, restart, stop.")
+ }
+ }
+ }
+}
+
+func (a *acceptance) guard() error {
+ raw, err := os.ReadFile(a.h.ConfigPath)
+ if err != nil {
+ return err
+ }
+ var configuration struct {
+ WS []struct {
+ URL string `json:"Url"`
+ Token string `json:"AccessToken"`
+ } `json:"ws"`
+ WSS []json.RawMessage `json:"wss"`
+ }
+ if json.Unmarshal(raw, &configuration) != nil || len(configuration.WS) != 1 || len(configuration.WSS) != 0 || configuration.WS[0].URL != a.h.MockURL() || configuration.WS[0].Token != "isolated-lifecycle-token" {
+ return errors.New("restore the original synthetic OneBot connection before restarting")
+ }
+ raw, err = os.ReadFile(filepath.Join(a.h.AdminDir, "options.json"))
+ if err != nil {
+ return err
+ }
+ var options struct {
+ Enabled bool `json:"enabled"`
+ Address string `json:"address"`
+ }
+ if json.Unmarshal(raw, &options) != nil || !options.Enabled || (options.Address != a.originalAddress && options.Address != a.editedAddress) {
+ return errors.New("restore the fixture's enabled admin options and prepared address before restarting")
+ }
+ return nil
+}
+func (a *acceptance) start(ctx context.Context, label string, authenticate bool) error {
+ if err := a.guard(); err != nil {
+ return err
+ }
+ bot, err := a.h.Start(ctx, label)
+ if err != nil {
+ return err
+ }
+ a.bot = bot
+ a.starts++
+ a.client = qa.NewClient(a.h.Address)
+ if authenticate {
+ _, err = a.client.AuthWithPassword(a.h.AdminDir, a.password)
+ }
+ return err
+}
+func (a *acceptance) session(output string) error {
+ if !a.bot.Running() {
+ return errors.New("the isolated process is not running")
+ }
+ value := map[string]any{"state": "ready", "url": "http://" + a.h.Address + "/", "loginFile": filepath.Join(output, "login.txt"), "output": output, "baselineBackups": a.backups, "expiresAt": a.result["expiresAt"], "realQQMessagesSent": false, "manualAcceptancePassed": false}
+ if err := qa.WriteJSON(filepath.Join(output, "session.json"), value); err != nil {
+ return err
+ }
+ encoded, _ := json.Marshal(value)
+ fmt.Println(string(encoded))
+ return nil
+}
+func (a *acceptance) pluginState() (map[string]any, error) {
+ value, err := a.client.Checked("GET", "/api/plugins?group=101", nil)
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range value["plugins"].([]any) {
+ plugin := item.(map[string]any)
+ if plugin["name"] == "chrev" {
+ return plugin["state"].(map[string]any), nil
+ }
+ }
+ return nil, errors.New("fixture chrev plugin is not available")
+}
+func (a *acceptance) setPlugin(mode string) error {
+ state, err := a.pluginState()
+ if err != nil {
+ return err
+ }
+ _, err = a.client.Checked("POST", "/api/plugin-switch", map[string]any{"name": "chrev", "groupId": "101", "mode": mode, "revision": state["revision"]})
+ return err
+}
+func (a *acceptance) setModel(model string) error {
+ value, err := a.client.Checked("GET", "/api/ai", nil)
+ if err != nil {
+ return err
+ }
+ _, err = a.client.Checked("POST", "/api/ai", map[string]any{"revision": value["global"].(map[string]any)["revision"], "fields": map[string]any{"ModelName": model}})
+ return err
+}
+func (a *acceptance) prepare() error {
+ if err := a.setPlugin("disabled"); err != nil {
+ return err
+ }
+ if err := a.setModel("isolated-baseline-model"); err != nil {
+ return err
+ }
+ a.backups = map[string]string{}
+ for _, scope := range []string{"startup-config", "control-db", "admin-options"} {
+ value, err := a.client.Checked("POST", "/api/backups", map[string]string{"scope": scope})
+ if err != nil {
+ return err
+ }
+ id, _ := value["backupId"].(string)
+ if id == "" {
+ return errors.New("fixture backup ID is missing")
+ }
+ a.backups[scope] = id
+ }
+ value, err := a.client.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ if _, err = a.client.Checked("POST", "/api/connections", map[string]any{"revision": value["revision"], "superUsers": []string{"12345678", "23456789"}}); err != nil {
+ return err
+ }
+ if err = a.setPlugin("enabled"); err != nil {
+ return err
+ }
+ if err = a.setModel("isolated-edited-model"); err != nil {
+ return err
+ }
+ if err = a.h.Options(true, a.editedAddress); err != nil {
+ return err
+ }
+ raw, err := os.ReadFile(filepath.Join(a.h.AdminDir, "credential.json"))
+ if err != nil {
+ return err
+ }
+ a.credentialHash = sha256.Sum256(raw)
+ return nil
+}
+func (a *acceptance) check(ctx context.Context) error {
+ // Deliberately invalid synthetic options must not stop or restart the current bot.
+ if err := a.h.Options(true, "127.0.0.1:3010"); err != nil {
+ return err
+ }
+ if a.guard() == nil {
+ return errors.New("daily-address restart guard failed")
+ }
+ if err := a.h.Options(false, a.originalAddress); err != nil {
+ return err
+ }
+ if a.guard() == nil {
+ return errors.New("disabled-admin restart guard failed")
+ }
+ if err := a.h.Options(true, a.editedAddress); err != nil {
+ return err
+ }
+ raw, err := os.ReadFile(a.h.ConfigPath)
+ if err != nil {
+ return err
+ }
+ var changed map[string]any
+ if err = json.Unmarshal(raw, &changed); err != nil {
+ return err
+ }
+ changed["ws"] = []any{map[string]any{"Url": "ws://127.0.0.1:1", "AccessToken": "synthetic-invalid"}}
+ if err = qa.WriteJSON(a.h.ConfigPath, changed); err != nil {
+ return err
+ }
+ if a.guard() == nil {
+ return errors.New("non-fixture OneBot restart guard failed")
+ }
+ if err = os.WriteFile(a.h.ConfigPath, raw, 0600); err != nil {
+ return err
+ }
+ if _, err = a.client.Checked("GET", "/api/overview", nil); err != nil {
+ return errors.New("guard rejection stopped the current fixture")
+ }
+ a.result["restartGuardsPassed"] = true
+ for _, scope := range []string{"startup-config", "control-db", "admin-options"} {
+ id := a.backups[scope]
+ preview, err := a.client.Checked("POST", "/api/backups/preview", map[string]string{"id": id})
+ if err != nil {
+ return err
+ }
+ if preview["changed"] != true {
+ return fmt.Errorf("%s fixture did not differ", scope)
+ }
+ value, err := a.client.Checked("POST", "/api/backups/restore", map[string]any{"id": id, "revision": preview["revision"], "expiresAt": preview["expiresAt"], "token": preview["token"], "confirm": true})
+ if err != nil {
+ return err
+ }
+ if value["changed"] != true || value["backupId"] == "" {
+ return fmt.Errorf("%s restore was not confirmed", scope)
+ }
+ }
+ old := a.client
+ a.bot.Stop()
+ a.bot = nil
+ if err = a.start(ctx, "restored-restart", true); err != nil {
+ return err
+ }
+ if status, _, _, _ := old.Request("GET", "/api/overview", nil); status != http.StatusUnauthorized {
+ return errors.New("old session survived fixture restart")
+ }
+ connections, err := a.client.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ owners, ok := connections["superUsers"].([]any)
+ if !ok || len(owners) != 1 || owners[0] != "12345678" || connections["restartRequired"] != false {
+ return errors.New("restored owner or restart state differs")
+ }
+ state, err := a.pluginState()
+ if err != nil {
+ return err
+ }
+ if state["groupMode"] != "disabled" {
+ return errors.New("restored plugin mode differs")
+ }
+ ai, err := a.client.Checked("GET", "/api/ai", nil)
+ if err != nil {
+ return err
+ }
+ if ai["global"].(map[string]any)["fields"].(map[string]any)["ModelName"] != "isolated-baseline-model" {
+ return errors.New("restored model differs")
+ }
+ credential, err := os.ReadFile(filepath.Join(a.h.AdminDir, "credential.json"))
+ if err != nil {
+ return err
+ }
+ if sha256.Sum256(credential) != a.credentialHash {
+ return errors.New("fixture credential changed")
+ }
+ if a.h.Address != a.originalAddress {
+ return errors.New("restored address differs")
+ }
+ a.result["automaticEnvironmentCheckPassed"] = true
+ a.result["threeScopeRestorePassed"] = true
+ a.result["oldSessionRejected"] = true
+ a.result["passwordPreserved"] = true
+ return nil
+}
diff --git a/scripts/start-admin-browser-performance.go b/scripts/start-admin-browser-performance.go
new file mode 100644
index 0000000000..e039cceb67
--- /dev/null
+++ b/scripts/start-admin-browser-performance.go
@@ -0,0 +1,296 @@
+//go:build ignore
+
+// An isolated synthetic workload for real-browser performance observation.
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+
+ qa "github.com/FloatTech/ZeroBot-Plugin/internal/adminqa"
+)
+
+type resourceSample struct {
+ AtSeconds float64 `json:"atSeconds"`
+ CPUSeconds float64 `json:"cpuSeconds"`
+ PrivateBytes uint64 `json:"privateBytes"`
+ WorkingSetBytes uint64 `json:"workingSetBytes"`
+}
+
+func main() {
+ binary := flag.String("binary-name", "zerobot.exe", "executable under .local/bin")
+ profile := flag.String("profile", "paused", "paused or updating browser label")
+ warmup := flag.Duration("warmup", time.Minute, "synthetic workload warmup")
+ duration := flag.Duration("duration", 6*time.Minute, "process sample window; must cover browser window")
+ flag.Parse()
+ if flag.NArg() != 0 || filepath.Base(*binary) != *binary || strings.ContainsAny(*binary, "/\\:") || filepath.Ext(*binary) != ".exe" || (*profile != "paused" && *profile != "updating") || *warmup < 0 || *warmup > 5*time.Minute || *duration < 10*time.Second || *duration > 15*time.Minute {
+ fmt.Fprintln(os.Stderr, "Invalid local binary, profile or timing")
+ os.Exit(2)
+ }
+ if err := run(*binary, *profile, *warmup, *duration); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run(binary, profile string, warmup, duration time.Duration) (err error) {
+ root, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ root, err = filepath.EvalSymlinks(root)
+ if err != nil {
+ return err
+ }
+ dir := filepath.Join(root, ".local/browser-performance")
+ if err = os.MkdirAll(dir, 0700); err != nil {
+ return err
+ }
+ out, err := os.MkdirTemp(dir, "run-")
+ if err != nil {
+ return err
+ }
+ source, err := os.Open(filepath.Join(root, ".local/bin", binary))
+ if err != nil {
+ return err
+ }
+ target, err := os.OpenFile(filepath.Join(out, "zerobot-browser-performance.exe"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700)
+ if err != nil {
+ source.Close()
+ return err
+ }
+ hash := sha256.New()
+ _, copyErr := io.Copy(io.MultiWriter(target, hash), source)
+ closeErr := target.Close()
+ source.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ signalCtx, stopSignal := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stopSignal()
+ ctx, cancel := context.WithTimeout(signalCtx, 45*time.Minute)
+ defer cancel()
+ result := map[string]any{"profile": profile, "startedAt": time.Now().Format(time.RFC3339Nano), "binary": binary, "binarySHA256": hex.EncodeToString(hash.Sum(nil)), "isolated": true, "realQQMessagesSent": false, "modelCalls": 0, "browserRendererMeasured": false, "warmupSeconds": warmup.Seconds(), "sampleSeconds": duration.Seconds(), "fullTiming": warmup >= time.Minute && duration >= 5*time.Minute, "passed": false, "completed": false}
+ var h *qa.Harness
+ var bot *qa.Bot
+ defer func() {
+ if bot != nil {
+ bot.Stop()
+ }
+ if h != nil {
+ h.Close()
+ }
+ result["instanceStopped"] = true
+ result["stoppedAt"] = time.Now().Format(time.RFC3339Nano)
+ if err != nil {
+ result["failure"] = err.Error()
+ }
+ if e := qa.WriteJSON(filepath.Join(out, "result.json"), result); e != nil && err == nil {
+ err = e
+ }
+ _ = qa.WriteJSON(filepath.Join(out, "session.json"), map[string]any{"state": "stopped", "output": out})
+ fmt.Println("ARTIFACTS=" + out)
+ }()
+ h, err = qa.New(root, filepath.Join(out, "zerobot-browser-performance.exe"), out)
+ if err != nil {
+ return err
+ }
+ bot, err = h.Start(ctx, "browser-performance")
+ if err != nil {
+ return err
+ }
+ secret := make([]byte, 24)
+ if _, err = rand.Read(secret); err != nil {
+ return err
+ }
+ password := "QA-" + hex.EncodeToString(secret)
+ if err = os.WriteFile(filepath.Join(out, "login.txt"), []byte("Isolated browser performance password:\n"+password+"\n"), 0600); err != nil {
+ return err
+ }
+ c := qa.NewClient(h.Address)
+ if _, err = c.AuthWithPassword(h.AdminDir, password); err != nil {
+ return err
+ }
+ session := map[string]any{"state": "ready", "url": "http://" + h.Address + "/", "output": out, "loginFile": filepath.Join(out, "login.txt"), "profile": profile, "warmupSeconds": warmup.Seconds(), "sampleSeconds": duration.Seconds(), "realQQMessagesSent": false}
+ if err = qa.WriteJSON(filepath.Join(out, "session.json"), session); err != nil {
+ return err
+ }
+ encoded, _ := json.Marshal(session)
+ fmt.Println(string(encoded))
+ fmt.Println("Commands: start, status, stop. Measurements begin only after start.")
+ commands := make(chan string, 8)
+ inputDone := make(chan error, 1)
+ go func() {
+ s := bufio.NewScanner(os.Stdin)
+ for s.Scan() {
+ select {
+ case commands <- strings.TrimSpace(s.Text()):
+ case <-ctx.Done():
+ return
+ }
+ }
+ inputDone <- s.Err()
+ }()
+ var began time.Time
+ for began.IsZero() {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case e := <-inputDone:
+ if e != nil {
+ return e
+ }
+ result["stopReason"] = "input-ended"
+ return nil
+ case command := <-commands:
+ switch command {
+ case "start":
+ began = time.Now()
+ case "stop":
+ result["stopReason"] = "stopped-before-start"
+ return nil
+ case "status":
+ fmt.Println(string(encoded))
+ default:
+ fmt.Println("Commands: start, status, stop.")
+ }
+ }
+ }
+ result["measurementStartedAt"] = began.Format(time.RFC3339Nano)
+ sampleStart := began.Add(warmup)
+ sampleEnd := sampleStart.Add(duration)
+ result["sampleStartsAt"] = sampleStart.Format(time.RFC3339Nano)
+ result["sampleEndsAt"] = sampleEnd.Format(time.RFC3339Nano)
+ nextMessage, nextSample, nextProgress := began, sampleStart, began
+ messageTimes := []float64{}
+ warmMessages := 0
+ samples := []resourceSample{}
+ defer func() {
+ // Keep partial measurements when input ends, a test is stopped, or a read fails.
+ result["samples"] = samples
+ result["messageDurationsMs"] = messageTimes
+ result["messageCount"] = len(messageTimes)
+ result["warmupMessageCount"] = warmMessages
+ }()
+ ticker := time.NewTicker(250 * time.Millisecond)
+ defer ticker.Stop()
+ for time.Now().Before(sampleEnd) {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case e := <-inputDone:
+ if e != nil {
+ return e
+ }
+ result["stopReason"] = "input-ended-before-completion"
+ return nil
+ case command := <-commands:
+ if command == "stop" {
+ result["stopReason"] = "early-stop"
+ return nil
+ }
+ if command == "start" {
+ fmt.Println("Measurement already started")
+ }
+ if command == "status" {
+ fmt.Printf("PROFILE_STATUS=%s elapsed_seconds=%.0f\n", profile, time.Since(began).Seconds())
+ }
+ case <-ticker.C:
+ now := time.Now()
+ if !now.Before(sampleEnd) {
+ break
+ }
+ if !bot.Running() {
+ return errors.New("owned performance process exited")
+ }
+ if !now.Before(nextMessage) {
+ reply, t, e := h.Send(qa.Owner, "翻转 abc")
+ if e != nil || reply != "ɔqɐ" {
+ return errors.New("synthetic workload reply failed")
+ }
+ if now.Before(sampleStart) {
+ warmMessages++
+ } else {
+ messageTimes = append(messageTimes, float64(t)/float64(time.Millisecond))
+ }
+ nextMessage = nextMessage.Add(5 * time.Second)
+ }
+ if !now.Before(nextSample) {
+ cpu, private, working, e := bot.Metrics()
+ if e != nil {
+ return e
+ }
+ samples = append(samples, resourceSample{time.Since(sampleStart).Seconds(), cpu, private, working})
+ nextSample = nextSample.Add(2 * time.Second)
+ }
+ if !now.Before(nextProgress) {
+ phase := "warmup"
+ if !now.Before(sampleStart) {
+ phase = "sampling"
+ }
+ progress := map[string]any{"profile": profile, "phase": phase, "elapsedSeconds": time.Since(began).Seconds(), "sampleStartsAt": sampleStart.Format(time.RFC3339Nano), "sampleEndsAt": sampleEnd.Format(time.RFC3339Nano), "sampleCount": len(samples), "messages": len(messageTimes), "warmupMessages": warmMessages}
+ if e := qa.WriteJSON(filepath.Join(out, "progress.json"), progress); e != nil {
+ return e
+ }
+ fmt.Printf("BROWSER_PROFILE_PROGRESS=%s phase=%s elapsed_seconds=%.0f messages=%d samples=%d\n", profile, phase, time.Since(began).Seconds(), len(messageTimes), len(samples))
+ nextProgress = nextProgress.Add(10 * time.Second)
+ }
+ }
+ }
+ if len(samples) < 2 || len(messageTimes) == 0 {
+ return errors.New("insufficient process workload samples")
+ }
+ first, last := samples[0], samples[len(samples)-1]
+ maxPrivate, maxWorking := first.PrivateBytes, first.WorkingSetBytes
+ for _, s := range samples {
+ if s.PrivateBytes > maxPrivate {
+ maxPrivate = s.PrivateBytes
+ }
+ if s.WorkingSetBytes > maxWorking {
+ maxWorking = s.WorkingSetBytes
+ }
+ }
+ sort.Float64s(messageTimes)
+ p95 := messageTimes[int(math.Ceil(float64(len(messageTimes))*.95))-1]
+ privateGrowth := float64(maxPrivate-first.PrivateBytes) / (1 << 20)
+ cpuPercent := (last.CPUSeconds - first.CPUSeconds) / (last.AtSeconds - first.AtSeconds) / float64(runtime.NumCPU()) * 100
+ result["samples"] = samples
+ result["messageDurationsMs"] = messageTimes
+ result["messageCount"] = len(messageTimes)
+ result["warmupMessageCount"] = warmMessages
+ result["messageP95Ms"] = p95
+ result["privateGrowthMiB"] = privateGrowth
+ result["privateLastBytes"] = last.PrivateBytes
+ result["workingSetMaxBytes"] = maxWorking
+ result["cpuPercentOfMachine"] = cpuPercent
+ result["logicalCPUs"] = runtime.NumCPU()
+ result["completed"] = true
+ result["measurementEndedAt"] = time.Now().Format(time.RFC3339Nano)
+ result["thresholdsPassed"] = privateGrowth <= 64 && p95 <= 1000
+ result["passed"] = result["thresholdsPassed"]
+ result["stopReason"] = "measurement-completed"
+ if result["passed"] != true {
+ return errors.New("process workload thresholds exceeded")
+ }
+ fmt.Printf("BROWSER_PROFILE_DONE=%s messages=%d message_p95_ms=%.2f private_growth_mib=%.2f cpu_percent=%.4f\n", profile, len(messageTimes), p95, privateGrowth, cpuPercent)
+ return nil
+}
diff --git a/scripts/start-admin-connection-acceptance.go b/scripts/start-admin-connection-acceptance.go
new file mode 100644
index 0000000000..9ea0c2be8d
--- /dev/null
+++ b/scripts/start-admin-connection-acceptance.go
@@ -0,0 +1,326 @@
+//go:build ignore
+
+// A disposable two-account fixture for real-browser connection acceptance.
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "time"
+
+ qa "github.com/FloatTech/ZeroBot-Plugin/internal/adminqa"
+)
+
+func main() {
+ binary := flag.String("binary-name", "zerobot.exe", "filename under .local/bin")
+ lifetime := flag.Duration("lifetime", time.Hour, "maximum lifetime, 1m to 2h")
+ flag.Parse()
+ if flag.NArg() != 0 || filepath.Base(*binary) != *binary || strings.ContainsAny(*binary, "/\\:") || filepath.Ext(*binary) != ".exe" || *lifetime < time.Minute || *lifetime > 2*time.Hour {
+ fmt.Fprintln(os.Stderr, "Invalid binary or lifetime")
+ os.Exit(2)
+ }
+ if err := run(*binary, *lifetime); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run(binary string, lifetime time.Duration) (err error) {
+ root, err := os.Getwd()
+ if err != nil {
+ return err
+ }
+ root, err = filepath.EvalSymlinks(root)
+ if err != nil {
+ return err
+ }
+ parent := filepath.Join(root, ".local/connection-browser-acceptance")
+ if err = os.MkdirAll(parent, 0700); err != nil {
+ return err
+ }
+ out, err := os.MkdirTemp(parent, "run-")
+ if err != nil {
+ return err
+ }
+ source, err := os.Open(filepath.Join(root, ".local/bin", binary))
+ if err != nil {
+ return err
+ }
+ copied := filepath.Join(out, "zerobot-connection-acceptance.exe")
+ target, err := os.OpenFile(copied, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700)
+ if err != nil {
+ source.Close()
+ return err
+ }
+ hash := sha256.New()
+ _, copyErr := io.Copy(io.MultiWriter(target, hash), source)
+ closeErr := target.Close()
+ source.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ signalCtx, cancelSignal := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer cancelSignal()
+ ctx, cancel := context.WithTimeout(signalCtx, lifetime)
+ defer cancel()
+ result := map[string]any{"startedAt": time.Now().Format(time.RFC3339Nano), "binarySHA256": hex.EncodeToString(hash.Sum(nil)), "isolated": true, "realQQMessagesSent": false, "modelCalls": 0, "browserAcceptancePassed": false, "passed": false}
+ var primary, secondary *qa.Harness
+ var bot *qa.Bot
+ starts, checks := 0, 0
+ defer func() {
+ if bot != nil {
+ bot.Stop()
+ }
+ if secondary != nil {
+ secondary.Close()
+ }
+ if primary != nil {
+ primary.Close()
+ }
+ result["processStarts"], result["transportChecks"] = starts, checks
+ result["instanceStopped"], result["stoppedAt"] = true, time.Now().Format(time.RFC3339Nano)
+ if err != nil {
+ result["failure"] = err.Error()
+ }
+ if e := qa.WriteJSON(filepath.Join(out, "result.json"), result); e != nil && err == nil {
+ err = e
+ }
+ _ = qa.WriteJSON(filepath.Join(out, "session.json"), map[string]any{"state": "stopped", "output": out})
+ fmt.Println("ARTIFACTS=" + out)
+ }()
+ primary, err = qa.NewWithIdentity(root, copied, out, 87654321)
+ if err != nil {
+ return err
+ }
+ secondaryRoot := filepath.Join(out, "secondary-endpoint")
+ if err = os.MkdirAll(secondaryRoot, 0700); err != nil {
+ return err
+ }
+ secondary, err = qa.NewWithIdentity(root, copied, secondaryRoot, 87654322)
+ if err != nil {
+ return err
+ }
+ raw, err := os.ReadFile(primary.ConfigPath)
+ if err != nil {
+ return err
+ }
+ var cfg map[string]any
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ if err = decoder.Decode(&cfg); err != nil {
+ return err
+ }
+ cfg["ws"] = []map[string]any{{"Url": primary.MockURL(), "AccessToken": "isolated-lifecycle-token"}, {"Url": secondary.MockURL(), "AccessToken": "isolated-lifecycle-token"}}
+ cfg["qa_unknown"] = map[string]any{"large": json.Number("9007199254740993"), "keep": "connection-fixture"}
+ if err = qa.WriteJSON(primary.ConfigPath, cfg); err != nil {
+ return err
+ }
+ secret := make([]byte, 24)
+ if _, err = rand.Read(secret); err != nil {
+ return err
+ }
+ password := "QA-" + hex.EncodeToString(secret)
+ if err = os.WriteFile(filepath.Join(out, "login.txt"), []byte("Isolated connection password:\n"+password+"\n"), 0600); err != nil {
+ return err
+ }
+ guard := func() error {
+ raw, e := os.ReadFile(primary.ConfigPath)
+ if e != nil {
+ return e
+ }
+ var c struct {
+ WS []struct {
+ URL string `json:"Url"`
+ Token string `json:"AccessToken"`
+ } `json:"ws"`
+ WSS []json.RawMessage `json:"wss"`
+ Zero struct {
+ Owners []int64 `json:"super_users"`
+ } `json:"zero"`
+ Unknown struct {
+ Large json.Number `json:"large"`
+ Keep string `json:"keep"`
+ } `json:"qa_unknown"`
+ }
+ if json.Unmarshal(raw, &c) != nil || len(c.WS) != 2 || len(c.WSS) != 0 || c.Unknown.Large.String() != "9007199254740993" || c.Unknown.Keep != "connection-fixture" {
+ return errors.New("restore the two synthetic connections and fixture marker before restart")
+ }
+ seen := map[string]bool{}
+ for _, conn := range c.WS {
+ base := strings.TrimSuffix(conn.URL, "/edited")
+ if (base != primary.MockURL() && base != secondary.MockURL()) || seen[base] || conn.Token != "isolated-lifecycle-token" {
+ return errors.New("restart permits only this run's distinct synthetic endpoints and original test tokens")
+ }
+ seen[base] = true
+ }
+ if len(c.Zero.Owners) < 1 || len(c.Zero.Owners) > 2 || c.Zero.Owners[0] != qa.Owner || (len(c.Zero.Owners) == 2 && c.Zero.Owners[1] != 23456789) {
+ return errors.New("restore the allowed synthetic owners before restart")
+ }
+ raw, e = os.ReadFile(filepath.Join(primary.AdminDir, "options.json"))
+ if e != nil {
+ return e
+ }
+ var options struct {
+ Enabled bool `json:"enabled"`
+ Address string `json:"address"`
+ }
+ if json.Unmarshal(raw, &options) != nil || !options.Enabled || options.Address != primary.Address {
+ return errors.New("restore this run's enabled admin address before restart")
+ }
+ return nil
+ }
+ start := func() error {
+ if e := guard(); e != nil {
+ return e
+ }
+ var e error
+ bot, e = primary.Start(ctx, fmt.Sprintf("start-%d", starts+1))
+ if e != nil {
+ return e
+ }
+ attachCtx, stop := context.WithTimeout(ctx, 15*time.Second)
+ defer stop()
+ if e = secondary.WaitConnection(attachCtx); e != nil {
+ return e
+ }
+ starts++
+ return nil
+ }
+ check := func() error {
+ for _, endpoint := range []*qa.Harness{primary, secondary} {
+ reply, _, e := endpoint.Send(qa.Owner, "翻转 abc")
+ if e != nil || reply != "ɔqɐ" {
+ return errors.New("two-account synthetic transport check failed")
+ }
+ }
+ checks++
+ return nil
+ }
+ if err = start(); err != nil {
+ return err
+ }
+ client := qa.NewClient(primary.Address)
+ if _, err = client.AuthWithPassword(primary.AdminDir, password); err != nil {
+ return err
+ }
+ credential, err := os.ReadFile(filepath.Join(primary.AdminDir, "credential.json"))
+ if err != nil {
+ return err
+ }
+ credentialHash := sha256.Sum256(credential)
+ if err = check(); err != nil {
+ return err
+ }
+ session := func() error {
+ value := map[string]any{"state": "ready", "url": "http://" + primary.Address + "/", "output": out, "loginFile": filepath.Join(out, "login.txt"), "primaryURL": primary.MockURL(), "secondaryURL": secondary.MockURL(), "mockAccounts": []int64{87654321, 87654322}, "processStarts": starts, "transportChecks": checks, "realQQMessagesSent": false}
+ if e := qa.WriteJSON(filepath.Join(out, "session.json"), value); e != nil {
+ return e
+ }
+ encoded, _ := json.Marshal(value)
+ fmt.Println(string(encoded))
+ return nil
+ }
+ if err = session(); err != nil {
+ return err
+ }
+ commands := make(chan string)
+ done := make(chan error, 1)
+ go func() {
+ s := bufio.NewScanner(os.Stdin)
+ for s.Scan() {
+ select {
+ case commands <- strings.TrimSpace(s.Text()):
+ case <-ctx.Done():
+ return
+ }
+ }
+ done <- s.Err()
+ }()
+ fmt.Println("Commands: status, check, restart, stop")
+ for {
+ select {
+ case <-ctx.Done():
+ result["stopReason"] = "signal-or-time-limit"
+ return nil
+ case e := <-done:
+ result["stopReason"] = "input-ended"
+ return e
+ case command := <-commands:
+ switch command {
+ case "status":
+ if err = session(); err != nil {
+ return err
+ }
+ case "check":
+ if err = check(); err != nil {
+ return err
+ }
+ fmt.Println("TWO_ACCOUNT_CHECK_PASSED")
+ case "restart":
+ if e := guard(); e != nil {
+ fmt.Println("Restart refused: " + e.Error())
+ result["restartRefusedBeforeStop"] = true
+ continue
+ }
+ bot.Stop()
+ bot = nil
+ if err = start(); err != nil {
+ return err
+ }
+ status, _, _, e := client.Request("GET", "/api/connections", nil)
+ if e != nil || status != 401 {
+ return errors.New("old session was not rejected after restart")
+ }
+ client = qa.NewClient(primary.Address)
+ if _, err = client.AuthWithPassword(primary.AdminDir, password); err != nil {
+ return err
+ }
+ credential, err = os.ReadFile(filepath.Join(primary.AdminDir, "credential.json"))
+ if err != nil {
+ return err
+ }
+ if sha256.Sum256(credential) != credentialHash {
+ return errors.New("test credential changed")
+ }
+ value, e := client.Checked("GET", "/api/connections", nil)
+ if e != nil {
+ return e
+ }
+ if value["restartRequired"] != false {
+ return errors.New("saved configuration differs after restart")
+ }
+ if err = check(); err != nil {
+ return err
+ }
+ result["oldSessionRejected"] = true
+ result["credentialPreserved"] = true
+ result["restartLoadedSavedConfig"] = true
+ if err = session(); err != nil {
+ return err
+ }
+ case "stop":
+ result["stopReason"] = "stop-command"
+ result["passed"] = starts >= 2 && checks >= 2
+ return nil
+ default:
+ fmt.Println("Commands: status, check, restart, stop")
+ }
+ }
+ }
+}
diff --git a/scripts/start-local-services.ps1 b/scripts/start-local-services.ps1
new file mode 100644
index 0000000000..fd8323beeb
--- /dev/null
+++ b/scripts/start-local-services.ps1
@@ -0,0 +1,62 @@
+[CmdletBinding()]
+param([ValidateSet('OneBot','ZeroBot','All')][string]$Service = 'All')
+
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'windows-service-identity.ps1')
+$repoRoot = Resolve-LocalServicePath (Split-Path -Parent $PSScriptRoot)
+& (Join-Path $PSScriptRoot 'setup-onebot.ps1')
+$accountPath = Join-Path $repoRoot '.local/onebot/account.json'
+if (-not (Test-Path -LiteralPath $accountPath)) { throw 'Set the expected bot QQ in .local/onebot/account.json first.' }
+$account = Get-Content -LiteralPath $accountPath -Raw | ConvertFrom-Json
+if ($account.expectedQQ -notmatch '^[1-9][0-9]{4,11}$') { throw 'Expected bot QQ is invalid.' }
+$sha = [Security.Cryptography.SHA256]::Create()
+try { $id = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($repoRoot.ToLowerInvariant()))).Replace('-','').Substring(0,8) } finally { $sha.Dispose() }
+$runtimeRoot = Join-Path $repoRoot '.local/services'
+New-Item -ItemType Directory -Path $runtimeRoot -Force | Out-Null
+$shellPath = (Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source
+if (-not $shellPath) { $shellPath = Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe' }
+$hostScript = Join-Path $repoRoot 'scripts/service-host.ps1'
+$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
+$taskNames = [ordered]@{}
+foreach ($currentService in @('OneBot','ZeroBot')) {
+ if ($Service -ne 'All' -and $Service -ne $currentService) { continue }
+ if ($currentService -eq 'ZeroBot') {
+ $health = & (Join-Path $PSScriptRoot 'test-onebot.ps1') | ConvertFrom-Json
+ if (-not $health.qqLoggedIn -or -not $health.onebotReadOnlyAPIsPassed) {
+ throw 'OneBot is not logged in to the expected account. Complete login before starting ZeroBot.'
+ }
+ }
+ $taskName = 'ZeroBot-Local-' + $id + '-' + $currentService
+ $arguments = '-NoProfile -WindowStyle Hidden -File "' + $hostScript + '" -Service ' + $currentService
+ $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
+ $active=@(Get-ScheduledTask -TaskName ('ZeroBot-Local-*-'+$currentService) -ErrorAction SilentlyContinue | Where-Object {
+ $_.State -eq 'Running' -and (Test-LocalServiceTask $_ $repoRoot $currentService)
+ })
+ if($active.Count -gt 1){throw 'Multiple matching service tasks are running; inspect before starting another.'}
+ if($active.Count -eq 1){$task=$active[0];$taskName=$task.TaskName}
+ if ($task) {
+ if (-not (Test-LocalServiceTask $task $repoRoot $currentService)) { throw 'Existing task definition does not match this local project.' }
+ } else {
+ $action = New-ScheduledTaskAction -Execute $shellPath -Argument $arguments -WorkingDirectory $repoRoot
+ $principal = New-ScheduledTaskPrincipal -UserId $userName -LogonType Interactive -RunLevel Limited
+ $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+ Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Settings $settings -Description ('On-demand local '+$currentService+' host for '+$repoRoot) | Out-Null
+ }
+ if($task -and $task.State -eq 'Running'){Write-Host ('Reusing local task: '+$taskName)}
+ else {Start-ScheduledTask -TaskName $taskName; Write-Host ('Started local task: ' + $taskName)}
+ $taskNames[$currentService] = $taskName
+ if ($currentService -eq 'OneBot') {
+ $web = Get-Content -LiteralPath (Join-Path $repoRoot '.local/onebot/runtime/config/webui.json') -Raw | ConvertFrom-Json
+ $ready = $false
+ for ($attempt=0; $attempt -lt 15; $attempt++) {
+ Start-Sleep -Seconds 2
+ try { $page=Invoke-WebRequest -Uri ('http://127.0.0.1:'+$web.port+'/webui/') -UseBasicParsing -TimeoutSec 2; if ($page.StatusCode -eq 200) { $ready=$true; break } } catch { }
+ }
+ if (-not $ready) { throw 'NapCat page did not become ready. Inspect .local/services logs and task status.' }
+ }
+}
+$manifestPath = Join-Path $runtimeRoot 'tasks.json'
+$manifest = [ordered]@{}
+if (Test-Path -LiteralPath $manifestPath) { $previous=Get-Content -LiteralPath $manifestPath -Raw|ConvertFrom-Json; foreach($property in $previous.psobject.Properties){$manifest[$property.Name]=$property.Value} }
+foreach($key in $taskNames.Keys){$manifest[$key]=$taskNames[$key]}
+[IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
diff --git a/scripts/start-model-observation-acceptance.go b/scripts/start-model-observation-acceptance.go
new file mode 100644
index 0000000000..e39fdc4c90
--- /dev/null
+++ b/scripts/start-model-observation-acceptance.go
@@ -0,0 +1,292 @@
+//go:build ignore
+
+// Disposable model/OneBot fixture for real browser observation report checks.
+package main
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ qa "github.com/FloatTech/ZeroBot-Plugin/internal/adminqa"
+)
+
+func main() {
+ binary := flag.String("binary-name", "zerobot.exe", "filename under .local/bin")
+ lifetime := flag.Duration("lifetime", 20*time.Minute, "maximum lifetime, 1m to 2h")
+ modelDelay := flag.Duration("model-delay", 0, "synthetic model delay, 0 to 2s")
+ flag.Parse()
+ if flag.NArg() != 0 || filepath.Base(*binary) != *binary || strings.ContainsAny(*binary, "/\\:") || filepath.Ext(*binary) != ".exe" || *lifetime < time.Minute || *lifetime > 2*time.Hour || *modelDelay < 0 || *modelDelay > 2*time.Second {
+ fmt.Fprintln(os.Stderr, "Invalid arguments")
+ os.Exit(2)
+ }
+ if err := run(*binary, *lifetime, *modelDelay); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run(binary string, lifetime, modelDelay time.Duration) (err error) {
+ root, err := os.Getwd()
+ if err != nil {
+ return
+ }
+ root, err = filepath.EvalSymlinks(root)
+ if err != nil {
+ return
+ }
+ parent := filepath.Join(root, ".local/model-observation-acceptance")
+ if err = os.MkdirAll(parent, 0700); err != nil {
+ return
+ }
+ out, err := os.MkdirTemp(parent, "run-")
+ if err != nil {
+ return
+ }
+ input, err := os.Open(filepath.Join(root, ".local/bin", binary))
+ if err != nil {
+ return
+ }
+ copied := filepath.Join(out, "zerobot-model-observation-acceptance.exe")
+ target, err := os.OpenFile(copied, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0700)
+ if err != nil {
+ input.Close()
+ return
+ }
+ hash := sha256.New()
+ _, err = io.Copy(io.MultiWriter(target, hash), input)
+ targetClose := target.Close()
+ input.Close()
+ if err != nil {
+ return
+ }
+ if targetClose != nil {
+ return targetClose
+ }
+ signalCtx, stopSignal := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer stopSignal()
+ ctx, cancel := context.WithTimeout(signalCtx, lifetime)
+ defer cancel()
+ var h *qa.Harness
+ var bot *qa.Bot
+ var modelCalls atomic.Int32
+ starts, batches := 0, 0
+ result := map[string]any{"startedAt": time.Now().Format(time.RFC3339Nano), "binarySHA256": hex.EncodeToString(hash.Sum(nil)), "isolated": true, "realQQMessagesSent": false, "realModelCalls": 0, "browserAcceptancePassed": false, "passed": false}
+ defer func() {
+ if bot != nil {
+ bot.Stop()
+ }
+ if h != nil {
+ h.Close()
+ }
+ result["processStarts"], result["requestBatches"], result["syntheticModelCalls"] = starts, batches, modelCalls.Load()
+ result["instanceStopped"], result["stoppedAt"] = true, time.Now().Format(time.RFC3339Nano)
+ if err != nil {
+ result["failure"] = err.Error()
+ }
+ if saveErr := qa.WriteJSON(filepath.Join(out, "result.json"), result); saveErr != nil && err == nil {
+ err = saveErr
+ }
+ _ = qa.WriteJSON(filepath.Join(out, "session.json"), map[string]any{"state": "stopped"})
+ fmt.Println("ARTIFACTS=" + out)
+ }()
+ model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer synthetic-observation-key" {
+ http.Error(w, "denied", 403)
+ return
+ }
+ var body struct {
+ Model string `json:"model"`
+ }
+ if json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) != nil || body.Model != "synthetic-observation-model" {
+ http.Error(w, "invalid", 400)
+ return
+ }
+ modelCalls.Add(1)
+ if modelDelay > 0 {
+ timer := time.NewTimer(modelDelay)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ case <-r.Context().Done():
+ return
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"模型观察模拟回复"}}]}`)
+ }))
+ defer model.Close()
+ h, err = qa.New(root, copied, out)
+ if err != nil {
+ return
+ }
+ secret := make([]byte, 24)
+ if _, err = rand.Read(secret); err != nil {
+ return
+ }
+ password := "QA-" + hex.EncodeToString(secret)
+ if err = os.WriteFile(filepath.Join(out, "login.txt"), []byte("Isolated observation password:\n"+password+"\n"), 0600); err != nil {
+ return
+ }
+ client := qa.NewClient(h.Address)
+ guard := func() error {
+ raw, e := os.ReadFile(h.ConfigPath)
+ if e != nil {
+ return e
+ }
+ var cfg struct {
+ WS []struct {
+ URL string `json:"Url"`
+ Token string `json:"AccessToken"`
+ } `json:"ws"`
+ WSS []json.RawMessage `json:"wss"`
+ }
+ if json.Unmarshal(raw, &cfg) != nil || len(cfg.WS) != 1 || len(cfg.WSS) != 0 || cfg.WS[0].URL != h.MockURL() || cfg.WS[0].Token != "isolated-lifecycle-token" {
+ return errors.New("fixture requires its original synthetic OneBot endpoint")
+ }
+ return nil
+ }
+ start := func() error {
+ if e := guard(); e != nil {
+ return e
+ }
+ var e error
+ bot, e = h.Start(ctx, fmt.Sprintf("start-%d", starts+1))
+ if e != nil {
+ return e
+ }
+ starts++
+ _, e = client.AuthWithPassword(h.AdminDir, password)
+ return e
+ }
+ if err = start(); err != nil {
+ return
+ }
+ value, err := client.Checked("GET", "/api/ai", nil)
+ if err != nil {
+ return
+ }
+ _, err = client.Checked("POST", "/api/ai", map[string]any{"revision": value["global"].(map[string]any)["revision"], "fields": map[string]any{"API": model.URL, "ModelName": "synthetic-observation-model", "Type": 0, "NoSystemP": true}, "keys": map[string]any{"Key": map[string]any{"action": "replace", "value": "synthetic-observation-key"}}})
+ if err != nil {
+ return
+ }
+ credential, err := os.ReadFile(filepath.Join(h.AdminDir, "credential.json"))
+ if err != nil {
+ return
+ }
+ credentialHash := sha256.Sum256(credential)
+ status := func() error {
+ snapshot, e := client.Checked("GET", "/api/overview", nil)
+ if e != nil {
+ return e
+ }
+ s := map[string]any{"state": "ready", "url": "http://" + h.Address + "/", "output": out, "loginFile": filepath.Join(out, "login.txt"), "processStarts": starts, "requestBatches": batches, "syntheticModelCalls": modelCalls.Load(), "modelRequests": snapshot["modelRequests"], "replyTasks": snapshot["replyTasks"], "syntheticModelDelayMilliseconds": modelDelay.Milliseconds(), "realQQMessagesSent": false, "realModelCalls": 0}
+ if e = qa.WriteJSON(filepath.Join(out, "session.json"), s); e != nil {
+ return e
+ }
+ encoded, _ := json.Marshal(s)
+ fmt.Println(string(encoded))
+ return nil
+ }
+ if err = status(); err != nil {
+ return
+ }
+ fmt.Println("Commands: status, requests (3 synthetic GPT replies), restart, stop")
+ commands := make(chan string)
+ done := make(chan error, 1)
+ go func() {
+ scanner := bufio.NewScanner(os.Stdin)
+ for scanner.Scan() {
+ select {
+ case commands <- strings.TrimSpace(scanner.Text()):
+ case <-ctx.Done():
+ return
+ }
+ }
+ done <- scanner.Err()
+ }()
+ for {
+ select {
+ case <-ctx.Done():
+ result["stopReason"] = "signal-or-time-limit"
+ return nil
+ case err = <-done:
+ result["stopReason"] = "input-ended"
+ return
+ case command := <-commands:
+ switch command {
+ case "status":
+ if err = status(); err != nil {
+ return
+ }
+ case "requests":
+ if err = guard(); err != nil {
+ return
+ }
+ value, err = client.Checked("GET", "/api/ai", nil)
+ if err != nil {
+ return
+ }
+ fields := value["global"].(map[string]any)["fields"].(map[string]any)
+ if fields["API"] != model.URL || fields["ModelName"] != "synthetic-observation-model" || fields["Type"] != float64(0) {
+ return errors.New("model calls require this run's original synthetic endpoint")
+ }
+ if batches >= 20 {
+ return errors.New("synthetic request batch limit reached")
+ }
+ before := modelCalls.Load()
+ for i := 0; i < 3; i++ {
+ reply, _, e := h.Send(23456789+int64(batches*3+i), "/gpt 模型观察测试")
+ if e != nil || reply != "模型观察模拟回复" {
+ return errors.New("synthetic GPT roundtrip failed")
+ }
+ }
+ if modelCalls.Load() != before+3 {
+ return errors.New("synthetic model request count differs")
+ }
+ batches++
+ result["syntheticRoundtripPassed"] = true
+ if err = status(); err != nil {
+ return
+ }
+ case "restart":
+ if err = guard(); err != nil {
+ return
+ }
+ bot.Stop()
+ bot = nil
+ if err = start(); err != nil {
+ return
+ }
+ nowCredential, e := os.ReadFile(filepath.Join(h.AdminDir, "credential.json"))
+ if e != nil || sha256.Sum256(nowCredential) != credentialHash {
+ return errors.New("fixture credential changed")
+ }
+ result["credentialPreserved"] = true
+ if err = status(); err != nil {
+ return
+ }
+ case "stop":
+ result["stopReason"], result["passed"] = "stop-command", true
+ return nil
+ default:
+ fmt.Println("Commands: status, requests, restart, stop")
+ }
+ }
+ }
+}
diff --git a/scripts/start-onebot.ps1 b/scripts/start-onebot.ps1
new file mode 100644
index 0000000000..5928917513
--- /dev/null
+++ b/scripts/start-onebot.ps1
@@ -0,0 +1,48 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+& (Join-Path $PSScriptRoot 'setup-onebot.ps1')
+$bundleRoot = Join-Path $repoRoot '.local/onebot/napcat-v4.18.28'
+$runtimeRoot = Join-Path $repoRoot '.local/onebot/runtime'
+$nodePath = Join-Path $bundleRoot 'node.exe'
+$statePath = Join-Path $runtimeRoot 'process.json'
+if (Test-Path -LiteralPath $statePath) {
+ $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
+ $existing = Get-Process -Id $state.processId -ErrorAction SilentlyContinue
+ if ($existing) {
+ if ($existing.Path -ne $nodePath) { throw 'Recorded PID belongs to another executable.' }
+ Write-Host "Local NapCat is already running (PID $($existing.Id))."
+ return
+ }
+}
+$configRoot = Join-Path $runtimeRoot 'config'
+$web = Get-Content -LiteralPath (Join-Path $configRoot 'webui.json') -Raw | ConvertFrom-Json
+$onebot = Get-Content -LiteralPath (Join-Path $configRoot 'onebot11.json') -Raw | ConvertFrom-Json
+$ports = @([int]$web.port) + @($onebot.network.websocketServers | Where-Object enable | ForEach-Object { [int]$_.port })
+$listeners = [Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpListeners()
+foreach ($port in $ports) {
+ if (@($listeners | Where-Object Port -eq $port).Count) { throw "Configured port is already in use: $port" }
+}
+# Run in the current terminal, which keeps startup and errors visible to the operator.
+$previousWorkdir = $env:NAPCAT_WORKDIR
+$previousAccount = $env:ACCOUNT
+try {
+ $env:NAPCAT_WORKDIR = $runtimeRoot
+ $env:ACCOUNT = ''
+ Push-Location -LiteralPath $bundleRoot
+ try {
+ $launcher = Start-Process -FilePath $nodePath -ArgumentList 'index.js' -WorkingDirectory $bundleRoot -NoNewWindow -PassThru
+ $state = [ordered]@{processId=$launcher.Id;executable=$nodePath;startedAt=(Get-Date).ToString('o')}
+ [IO.File]::WriteAllText($statePath, ($state | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
+ try {
+ Wait-Process -Id $launcher.Id
+ $launcher.Refresh()
+ if ($launcher.ExitCode -ne 0) { throw "NapCat exited with code $($launcher.ExitCode)." }
+ } finally { & (Join-Path $PSScriptRoot 'stop-onebot.ps1') }
+ } finally { Pop-Location }
+} finally {
+ $env:NAPCAT_WORKDIR = $previousWorkdir
+ $env:ACCOUNT = $previousAccount
+}
diff --git a/scripts/stop-local-services.ps1 b/scripts/stop-local-services.ps1
new file mode 100644
index 0000000000..53da536d30
--- /dev/null
+++ b/scripts/stop-local-services.ps1
@@ -0,0 +1,40 @@
+[CmdletBinding()]
+param([ValidateSet('OneBot','ZeroBot','All')][string]$Service='All', [switch]$Unregister)
+
+$ErrorActionPreference='Stop'
+. (Join-Path $PSScriptRoot 'windows-service-identity.ps1')
+$repoRoot=Resolve-LocalServicePath (Split-Path -Parent $PSScriptRoot)
+$sha=[Security.Cryptography.SHA256]::Create()
+try { $id=[BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($repoRoot.ToLowerInvariant()))).Replace('-','').Substring(0,8) } finally { $sha.Dispose() }
+$hostScript=Join-Path $PSScriptRoot 'service-host.ps1'
+foreach($currentService in @('ZeroBot','OneBot')){
+ if($Service -ne 'All' -and $Service -ne $currentService){continue}
+ $taskName='ZeroBot-Local-'+$id+'-'+$currentService
+ $exact=Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
+ if($exact -and -not (Test-LocalServiceTask $exact $repoRoot $currentService)){throw 'Task definition does not match this project; refusing to stop it.'}
+ $tasks=@(Get-ScheduledTask -TaskName ('ZeroBot-Local-*-'+$currentService) -ErrorAction SilentlyContinue | Where-Object {Test-LocalServiceTask $_ $repoRoot $currentService})
+ if(-not $tasks.Count){continue}
+ if($currentService -eq 'ZeroBot'){
+ $statePath=Join-Path $repoRoot '.local/zerobot/process.json'
+ $expectedExecutable=Join-Path $repoRoot '.local/bin/zerobot.exe'
+ } else {
+ $statePath=Join-Path $repoRoot '.local/onebot/runtime/process.json'
+ $expectedExecutable=Join-Path $repoRoot '.local/onebot/napcat-v4.18.28/node.exe'
+ }
+ if(Test-Path -LiteralPath $statePath){
+ $state=Get-Content -LiteralPath $statePath -Raw|ConvertFrom-Json
+ $child=Get-Process -Id $state.processId -ErrorAction SilentlyContinue
+ if($child){
+ if(-not (Test-LocalServiceProcess $state $child $expectedExecutable)){throw 'Runtime process identity does not match this project; refusing to stop it.'}
+ # Stopping the scheduler host alone leaves its child running. Terminate
+ # the verified service tree as well (NapCat can spawn Node workers).
+ & (Join-Path $env:WINDIR 'System32/taskkill.exe') /PID $child.Id /T /F
+ if($LASTEXITCODE -ne 0){throw 'Failed to stop the verified local service process tree.'}
+ }
+ }
+ foreach($task in $tasks){
+ if($task.State -eq 'Running'){Stop-ScheduledTask -TaskName $task.TaskName}
+ if($Unregister){Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false}
+ Write-Host ('Stopped local task: '+$task.TaskName)
+ }
+}
diff --git a/scripts/stop-onebot.ps1 b/scripts/stop-onebot.ps1
new file mode 100644
index 0000000000..ba3ffafd11
--- /dev/null
+++ b/scripts/stop-onebot.ps1
@@ -0,0 +1,31 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$statePath = Join-Path $repoRoot '.local/onebot/runtime/process.json'
+if (-not (Test-Path -LiteralPath $statePath)) { Write-Host 'No local NapCat process recorded.'; return }
+$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json
+$process = Get-Process -Id $state.processId -ErrorAction SilentlyContinue
+if ($process) {
+ $expectedPath = Join-Path $repoRoot '.local/onebot/napcat-v4.18.28/node.exe'
+ if ($process.Path -ne $expectedPath -or $state.executable -ne $expectedPath) {
+ throw 'Recorded PID does not match the local NapCat executable; refusing to stop it.'
+ }
+ # NapCat forks a Node worker. Stop only the descendants of our recorded launcher.
+ $snapshot = @(Get-CimInstance Win32_Process)
+ $ownedIds = [Collections.Generic.List[int]]::new()
+ $ownedIds.Add([int]$process.Id)
+ for ($index = 0; $index -lt $ownedIds.Count; $index++) {
+ foreach ($child in $snapshot | Where-Object { $_.ParentProcessId -eq $ownedIds[$index] }) {
+ if ($child.ExecutablePath -ne $expectedPath) {
+ throw 'Unexpected child executable; refusing to stop the process tree.'
+ }
+ $ownedIds.Add([int]$child.ProcessId)
+ }
+ }
+ # Stop the launcher first so it cannot restart a stopped worker.
+ foreach ($ownedId in $ownedIds) { Stop-Process -Id $ownedId -Force -ErrorAction SilentlyContinue }
+ Write-Host "Stopped local NapCat launcher and workers ($($ownedIds.Count) processes)."
+} else { Write-Host 'Recorded NapCat process has already exited.' }
+Remove-Item -LiteralPath $statePath
diff --git a/scripts/test-admin-system.go b/scripts/test-admin-system.go
new file mode 100644
index 0000000000..472069524d
--- /dev/null
+++ b/scripts/test-admin-system.go
@@ -0,0 +1,623 @@
+//go:build ignore
+
+// Windows acceptance: go run scripts/test-admin-system.go -mode restart|performance
+// All process/configuration writes are confined to a new .local/admin-system directory.
+package main
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "math"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+
+ qa "github.com/FloatTech/ZeroBot-Plugin/internal/adminqa"
+)
+
+var mode = flag.String("mode", "restart", "restart or performance")
+var binaryName = flag.String("binary-name", "zerobot.exe", "tested executable filename under .local/bin")
+var warmup = flag.Duration("warmup", 60*time.Second, "performance warmup per profile")
+var duration = flag.Duration("duration", 5*time.Minute, "performance sample duration per profile")
+
+func main() {
+ flag.Parse()
+ if *mode != "restart" && *mode != "performance" {
+ fmt.Fprintln(os.Stderr, "invalid mode")
+ os.Exit(1)
+ }
+ if filepath.Base(*binaryName) != *binaryName || filepath.Ext(*binaryName) != ".exe" {
+ fmt.Fprintln(os.Stderr, "invalid binary filename")
+ os.Exit(1)
+ }
+ if *warmup < 0 || *duration < 10*time.Second {
+ fmt.Fprintln(os.Stderr, "invalid timing")
+ os.Exit(1)
+ }
+ root, err := os.Getwd()
+ if err != nil {
+ panic(err)
+ }
+ binary := filepath.Join(root, ".local/bin", *binaryName)
+ artifactRoot := filepath.Join(root, ".local/admin-system")
+ if err = os.MkdirAll(artifactRoot, 0700); err != nil {
+ panic(err)
+ }
+ output, err := os.MkdirTemp(artifactRoot, *mode+"-")
+ if err != nil {
+ panic(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 40*time.Minute)
+ defer cancel()
+ result := map[string]any{"mode": *mode, "startedAt": time.Now().Format(time.RFC3339), "isolated": true, "realQQMessagesSent": false, "modelCalls": 0, "binary": *binaryName, "logicalCPUs": runtime.NumCPU()}
+ data, err := os.ReadFile(binary)
+ if err == nil {
+ hash := sha256.Sum256(data)
+ result["binarySHA256"] = hex.EncodeToString(hash[:])
+ data = nil
+ }
+ if err == nil {
+ if *mode == "restart" {
+ err = restartCheck(ctx, root, binary, output, result)
+ } else {
+ err = performanceCheck(ctx, root, binary, output, result)
+ }
+ }
+ result["completedAt"] = time.Now().Format(time.RFC3339)
+ result["passed"] = err == nil
+ if err != nil {
+ result["failure"] = err.Error()
+ }
+ if saveErr := qa.WriteJSON(filepath.Join(output, "result.json"), result); saveErr != nil {
+ fmt.Fprintln(os.Stderr, saveErr)
+ os.Exit(1)
+ }
+ if err == nil {
+ _ = qa.WriteJSON(filepath.Join(artifactRoot, *mode+"-latest.json"), result)
+ }
+ // Keep full resource samples in the artifact; print only the profile summaries.
+ visible := make(map[string]any, len(result))
+ for key, value := range result {
+ visible[key] = value
+ }
+ if profiles, ok := result["profiles"].([]profileResult); ok {
+ compact := append([]profileResult(nil), profiles...)
+ for i := range compact {
+ compact[i].Samples = nil
+ }
+ visible["profiles"] = compact
+ }
+ encoded, _ := json.MarshalIndent(visible, "", " ")
+ fmt.Println(string(encoded))
+ fmt.Println("ARTIFACTS=" + output)
+ if err != nil {
+ os.Exit(1)
+ }
+}
+
+func pluginState(c *qa.Client, group string) (map[string]any, error) {
+ list, err := c.Checked("GET", "/api/plugins?group="+group, nil)
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range list["plugins"].([]any) {
+ p := item.(map[string]any)
+ if p["name"] == "chrev" {
+ return p["state"].(map[string]any), nil
+ }
+ }
+ return nil, fmt.Errorf("chrev missing")
+}
+func setPlugin(c *qa.Client, group, mode string) error {
+ s, err := pluginState(c, group)
+ if err != nil {
+ return err
+ }
+ _, err = c.Checked("POST", "/api/plugin-switch", map[string]any{"name": "chrev", "groupId": group, "mode": mode, "revision": s["revision"]})
+ return err
+}
+func setModel(c *qa.Client, model string) error {
+ v, err := c.Checked("GET", "/api/ai", nil)
+ if err != nil {
+ return err
+ }
+ _, err = c.Checked("POST", "/api/ai", map[string]any{"revision": v["global"].(map[string]any)["revision"], "fields": map[string]any{"ModelName": model}})
+ return err
+}
+func snapshot(c *qa.Client, scope string) (string, error) {
+ v, err := c.Checked("POST", "/api/backups", map[string]string{"scope": scope})
+ if err != nil {
+ return "", err
+ }
+ id, _ := v["backupId"].(string)
+ if id == "" {
+ return "", fmt.Errorf("missing backup ID")
+ }
+ return id, nil
+}
+func restore(c *qa.Client, id string) error {
+ p, err := c.Checked("POST", "/api/backups/preview", map[string]string{"id": id})
+ if err != nil {
+ return err
+ }
+ if p["changed"] != true {
+ return fmt.Errorf("restore fixture did not differ")
+ }
+ v, err := c.Checked("POST", "/api/backups/restore", map[string]any{"id": id, "revision": p["revision"], "expiresAt": p["expiresAt"], "token": p["token"], "confirm": true})
+ if err != nil {
+ return err
+ }
+ if v["changed"] != true || v["backupId"] == "" {
+ return fmt.Errorf("restore did not commit or back up current data")
+ }
+ return nil
+}
+func command(h *qa.Harness, text, want string) error {
+ reply, _, err := h.Send(qa.Owner, text)
+ if err != nil || !strings.Contains(reply, want) {
+ return fmt.Errorf("synthetic command %s failed", text)
+ }
+ return nil
+}
+func privateGate(h *qa.Harness, uid int64, want string) error {
+ reply, _, err := h.Send(uid, "翻转 abc")
+ if err != nil || reply != want {
+ return fmt.Errorf("synthetic message gate %d failed", uid)
+ }
+ return nil
+}
+func fileDigest(path string) ([32]byte, error) {
+ b, err := os.ReadFile(path)
+ return sha256.Sum256(b), err
+}
+
+func restartCheck(ctx context.Context, root, binary, output string, result map[string]any) error {
+ h, err := qa.New(root, binary, output)
+ if err != nil {
+ return err
+ }
+ defer h.Close()
+ bot, err := h.Start(ctx, "initial")
+ if err != nil {
+ return err
+ }
+ defer func() { bot.Stop() }()
+ c := qa.NewClient(h.Address)
+ if _, err = c.Auth(h.AdminDir); err != nil {
+ return err
+ }
+ credentialPath := filepath.Join(h.AdminDir, "credential.json")
+ credentialBefore, err := fileDigest(credentialPath)
+ if err != nil {
+ return err
+ }
+ if err = setPlugin(c, "101", "disabled"); err != nil {
+ return err
+ }
+ if err = setModel(c, "qa-restart-original"); err != nil {
+ return err
+ }
+ if err = command(h, "/全局禁止 chrev 23456789", "已禁止"); err != nil {
+ return err
+ }
+ if err = command(h, "/封禁 34567890", "已封禁"); err != nil {
+ return err
+ }
+ startupID, err := snapshot(c, "startup-config")
+ if err != nil {
+ return err
+ }
+ controlID, err := snapshot(c, "control-db")
+ if err != nil {
+ return err
+ }
+ // Save a future admin address while the current process still uses address A.
+ addressA := h.Address
+ addressB, err := qa.FreeAddress()
+ if err != nil {
+ return err
+ }
+ if err = h.Options(true, addressB); err != nil {
+ return err
+ }
+ optionsID, err := snapshot(c, "admin-options")
+ if err != nil {
+ return err
+ }
+ if err = h.Options(true, addressA); err != nil {
+ return err
+ }
+ connections, err := c.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ if _, err = c.Checked("POST", "/api/connections", map[string]any{"revision": connections["revision"], "superUsers": []string{"12345678", "23456789"}}); err != nil {
+ return err
+ }
+ connections, err = c.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ if connections["restartRequired"] != true || len(connections["runningSuperUsers"].([]any)) != 1 {
+ return fmt.Errorf("saved/running configuration boundary failed")
+ }
+ if err = setPlugin(c, "101", "enabled"); err != nil {
+ return err
+ }
+ if err = setPlugin(c, "0", "disabled"); err != nil {
+ return err
+ }
+ if err = setModel(c, "qa-restart-temporary"); err != nil {
+ return err
+ }
+ if err = command(h, "/全局允许 chrev 23456789", "已允许"); err != nil {
+ return err
+ }
+ if err = command(h, "/解封 34567890", "已解封"); err != nil {
+ return err
+ }
+ if err = command(h, "/全局沉默", "休息"); err != nil {
+ return err
+ }
+ bot.Stop()
+ bot, err = h.Start(ctx, "changed-restart")
+ if err != nil {
+ return err
+ }
+ if status, _, _, _ := c.Request("GET", "/api/overview", nil); status != 401 {
+ return fmt.Errorf("old session survived process restart")
+ }
+ c = qa.NewClient(h.Address)
+ if _, err = c.Auth(h.AdminDir); err != nil {
+ return err
+ }
+ connections, err = c.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ if connections["restartRequired"] != false || len(connections["runningSuperUsers"].([]any)) != 2 {
+ return fmt.Errorf("restart did not apply saved owners")
+ }
+ if err = restore(c, startupID); err != nil {
+ return err
+ }
+ if err = restore(c, controlID); err != nil {
+ return err
+ }
+ if err = restore(c, optionsID); err != nil {
+ return err
+ }
+ connections, err = c.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ if connections["restartRequired"] != true || len(connections["runningSuperUsers"].([]any)) != 2 {
+ return fmt.Errorf("restored startup did not mark restart")
+ }
+ if err = privateGate(h, qa.Owner, "ɔqɐ"); err != nil {
+ return err
+ }
+ bot.Stop()
+ bot, err = h.Start(ctx, "restored-restart")
+ if err != nil {
+ return err
+ }
+ if h.Address != addressB {
+ return fmt.Errorf("restored admin address not selected")
+ }
+ if status, _, _, _ := c.Request("GET", "/api/overview", nil); status != 0 {
+ return fmt.Errorf("old admin address remained available")
+ }
+ oldAtNew := *c
+ oldAtNew.Base = "http://" + addressB
+ if status, _, _, _ := oldAtNew.Request("GET", "/api/overview", nil); status != 401 {
+ return fmt.Errorf("old session accepted at restored address")
+ }
+ c = qa.NewClient(h.Address)
+ if _, err = c.Auth(h.AdminDir); err != nil {
+ return err
+ }
+ connections, err = c.Checked("GET", "/api/connections", nil)
+ if err != nil {
+ return err
+ }
+ if connections["restartRequired"] != false || len(connections["runningSuperUsers"].([]any)) != 1 || len(connections["superUsers"].([]any)) != 1 {
+ return fmt.Errorf("restored owners not loaded after restart")
+ }
+ s, err := pluginState(c, "101")
+ if err != nil {
+ return err
+ }
+ if s["groupMode"] != "disabled" || s["globalMode"] != "inherit" || s["effectiveEnabled"] != false {
+ return fmt.Errorf("restored switch inheritance lost on restart")
+ }
+ ai, err := c.Checked("GET", "/api/ai", nil)
+ if err != nil {
+ return err
+ }
+ if ai["global"].(map[string]any)["fields"].(map[string]any)["ModelName"] != "qa-restart-original" {
+ return fmt.Errorf("restored AI configuration lost on restart")
+ }
+ if err = privateGate(h, qa.Owner, "ɔqɐ"); err != nil {
+ return err
+ }
+ if err = privateGate(h, 23456789, ""); err != nil {
+ return err
+ }
+ if err = privateGate(h, 34567890, ""); err != nil {
+ return err
+ }
+ credentialAfter, err := fileDigest(credentialPath)
+ if err != nil || credentialAfter != credentialBefore {
+ return fmt.Errorf("restore changed admin credential")
+ }
+ // Restore a disabled admin setting; its launcher-selected flag must be absent.
+ if err = h.Options(false, addressB); err != nil {
+ return err
+ }
+ disabledID, err := snapshot(c, "admin-options")
+ if err != nil {
+ return err
+ }
+ if err = h.Options(true, addressB); err != nil {
+ return err
+ }
+ if err = restore(c, disabledID); err != nil {
+ return err
+ }
+ bot.Stop()
+ bot, err = h.Start(ctx, "disabled-restart")
+ if err != nil {
+ return err
+ }
+ probe := &http.Client{Timeout: time.Second}
+ if rsp, e := probe.Get("http://" + addressB + "/api/session"); e == nil {
+ rsp.Body.Close()
+ return fmt.Errorf("disabled admin still listening")
+ }
+ if err = privateGate(h, qa.Owner, "ɔqɐ"); err != nil {
+ return err
+ }
+ for _, key := range []string{"startupSaveRestartPassed", "startupRestoreRestartPassed", "controlRestoreRestartPassed", "adminAddressRestoreRestartPassed", "adminDisableRestoreRestartPassed", "oldSessionRejectedPassed", "credentialPreservedPassed", "restoredResponseBanBlockPassed"} {
+ result[key] = true
+ }
+ result["processLaunches"] = 4
+ result["launcher"] = "isolated process; reads options.json into the same flags as service-host.ps1; scheduler not exercised"
+ return nil
+}
+
+type sample struct {
+ AtSeconds, CPUSeconds float64
+ PrivateBytes, WorkingSetBytes uint64
+}
+type profileResult struct {
+ Name string `json:"name"`
+ WarmupSeconds, SampleSeconds float64
+ ReadP95Ms map[string]float64
+ ReadCounts map[string]int
+ LoginP95Ms, MessageP95Ms, CPUPercent float64
+ MessageCount, LoginCount int
+ PrivateFirst, PrivateLast, PrivateMax, WorkingSetMax uint64
+ PrivateGrowthMiB float64
+ Samples []sample `json:"samples,omitempty"`
+}
+
+func p95(values []float64) float64 {
+ if len(values) == 0 {
+ return 0
+ }
+ v := append([]float64(nil), values...)
+ sort.Float64s(v)
+ return v[int(math.Ceil(float64(len(v))*.95))-1]
+}
+
+func performanceCheck(ctx context.Context, root, binary, output string, result map[string]any) error {
+ profiles := []string{"admin-off", "admin-idle", "dashboard-poll-10s", "logs-poll-2s"}
+ results := []profileResult{}
+ for _, name := range profiles {
+ dir := filepath.Join(output, name)
+ if err := os.Mkdir(dir, 0700); err != nil {
+ return err
+ }
+ h, err := qa.New(root, binary, dir)
+ if err != nil {
+ return err
+ }
+ pr, err := measureProfile(ctx, h, name)
+ h.Close()
+ if err != nil {
+ result["profiles"] = results
+ return err
+ }
+ results = append(results, pr)
+ result["profiles"] = results
+ if err = qa.WriteJSON(filepath.Join(output, "progress.json"), result); err != nil {
+ return err
+ }
+ fmt.Printf("PROFILE_DONE=%s messages=%d message_p95_ms=%.2f cpu_percent=%.4f private_growth_mib=%.2f\n", name, pr.MessageCount, pr.MessageP95Ms, pr.CPUPercent, pr.PrivateGrowthMiB)
+ }
+ fullTiming := *warmup >= 60*time.Second && *duration >= 5*time.Minute
+ thresholds := true
+ baseline := results[0]
+ for _, p := range results {
+ if p.PrivateGrowthMiB > 64 || p.MessageP95Ms-baseline.MessageP95Ms > 100 || p.CPUPercent-baseline.CPUPercent > 1 || p.LoginP95Ms > 1000 {
+ thresholds = false
+ }
+ for _, value := range p.ReadP95Ms {
+ if value > 500 {
+ thresholds = false
+ }
+ }
+ }
+ result["fullTiming"] = fullTiming
+ result["thresholdsPassed"] = thresholds
+ result["workload"] = "one synthetic chrev private message every five seconds; Windows process metrics every two seconds; 10 successful logins and 20 reads per endpoint outside resource interval; server-side polling only"
+ result["browserRendererMeasured"] = false
+ result["schedulerMeasured"] = false
+ if !thresholds {
+ return fmt.Errorf("performance thresholds exceeded; inspect profile measurements")
+ }
+ return nil
+}
+
+func measureProfile(ctx context.Context, h *qa.Harness, name string) (p profileResult, resultErr error) {
+ p = profileResult{Name: name, WarmupSeconds: warmup.Seconds(), SampleSeconds: duration.Seconds(), ReadP95Ms: map[string]float64{}, ReadCounts: map[string]int{}}
+ if err := h.Options(name != "admin-off", h.Address); err != nil {
+ return p, err
+ }
+ bot, err := h.Start(ctx, "performance")
+ if err != nil {
+ return p, err
+ }
+ defer bot.Stop()
+ var client *qa.Client
+ if name != "admin-off" {
+ client = qa.NewClient(h.Address)
+ if _, err = client.Auth(h.AdminDir); err != nil {
+ return p, err
+ }
+ }
+ readTimes := map[string][]float64{}
+ loginTimes := []float64{}
+ messageTimes := []float64{}
+ cursor, instance := "", ""
+ read := func(path string, record bool) error {
+ status, value, t, err := client.Request("GET", path, nil)
+ if err != nil || status != 200 {
+ return fmt.Errorf("performance read %s failed: status %d", path, status)
+ }
+ key := strings.SplitN(path, "?", 2)[0]
+ if record {
+ readTimes[key] = append(readTimes[key], float64(t)/float64(time.Millisecond))
+ }
+ if key == "/api/logs" {
+ cursor, _ = value["cursor"].(string)
+ instance, _ = value["instance"].(string)
+ if value["capacity"] != float64(1000) {
+ return fmt.Errorf("log capacity changed")
+ }
+ }
+ return nil
+ }
+ // Probe login and endpoints before warmup, so CPU resource comparison excludes them.
+ if client != nil {
+ for i := 0; i < 10; i++ {
+ t, err := client.Auth(h.AdminDir)
+ if err != nil {
+ return p, err
+ }
+ loginTimes = append(loginTimes, float64(t)/float64(time.Millisecond))
+ }
+ for i := 0; i < 20; i++ {
+ for _, path := range []string{"/api/overview", "/api/plugins", "/api/connections", "/api/ai", "/api/backups", "/api/logs?limit=200"} {
+ if err := read(path, true); err != nil {
+ return p, err
+ }
+ }
+ }
+ }
+ runInterval := func(length time.Duration, record bool) error {
+ start := time.Now()
+ nextMessage, nextRead := start, start
+ nextProgress := start.Add(time.Minute)
+ for time.Since(start) < length {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ now := time.Now()
+ if !now.Before(nextMessage) {
+ reply, t, err := h.Send(qa.Owner, "翻转 abc")
+ if err != nil || reply != "ɔqɐ" {
+ return fmt.Errorf("performance message reply failed")
+ }
+ if record {
+ messageTimes = append(messageTimes, float64(t)/float64(time.Millisecond))
+ }
+ nextMessage = nextMessage.Add(5 * time.Second)
+ }
+ if client != nil && !now.Before(nextRead) {
+ if name == "dashboard-poll-10s" {
+ for _, path := range []string{"/api/overview", "/api/plugins", "/api/connections"} {
+ if err := read(path, record); err != nil {
+ return err
+ }
+ }
+ } else if name == "logs-poll-2s" {
+ path := "/api/logs?limit=200"
+ if cursor != "" && instance != "" {
+ path += "&after=" + cursor + "&instance=" + instance
+ }
+ if err := read(path, record); err != nil {
+ return err
+ }
+ }
+ period := 10 * time.Second
+ if name == "logs-poll-2s" {
+ period = 2 * time.Second
+ }
+ nextRead = nextRead.Add(period)
+ }
+ if record {
+ cpu, private, working, err := bot.Metrics()
+ if err != nil {
+ return err
+ }
+ p.Samples = append(p.Samples, sample{time.Since(start).Seconds(), cpu, private, working})
+ if private > p.PrivateMax {
+ p.PrivateMax = private
+ }
+ if working > p.WorkingSetMax {
+ p.WorkingSetMax = working
+ }
+ }
+ if !time.Now().Before(nextProgress) {
+ phase := "warmup"
+ if record {
+ phase = "sample"
+ }
+ status := map[string]any{"profile": name, "phase": phase, "elapsedSeconds": time.Since(start).Seconds(), "messageCount": len(messageTimes), "messageP95Ms": p95(messageTimes), "privateMaxMiB": float64(p.PrivateMax) / (1 << 20)}
+ if err := qa.WriteJSON(filepath.Join(h.Root, "sampling-progress.json"), status); err != nil {
+ return err
+ }
+ fmt.Printf("PROFILE_PROGRESS=%s phase=%s elapsed_seconds=%.0f messages=%d message_p95_ms=%.2f private_max_mib=%.2f\n", name, phase, time.Since(start).Seconds(), len(messageTimes), p95(messageTimes), float64(p.PrivateMax)/(1<<20))
+ nextProgress = nextProgress.Add(time.Minute)
+ }
+ time.Sleep(2 * time.Second)
+ }
+ return nil
+ }
+ fmt.Printf("PROFILE_START=%s warmup_seconds=%.0f sample_seconds=%.0f\n", name, warmup.Seconds(), duration.Seconds())
+ if err = runInterval(*warmup, false); err != nil {
+ return p, err
+ }
+ if err = runInterval(*duration, true); err != nil {
+ return p, err
+ }
+ if len(p.Samples) < 2 {
+ return p, fmt.Errorf("too few resource samples")
+ }
+ first, last := p.Samples[0], p.Samples[len(p.Samples)-1]
+ p.PrivateFirst = first.PrivateBytes
+ p.PrivateLast = last.PrivateBytes
+ p.PrivateGrowthMiB = float64(p.PrivateMax-p.PrivateFirst) / (1 << 20)
+ p.CPUPercent = (last.CPUSeconds - first.CPUSeconds) / (last.AtSeconds - first.AtSeconds) / float64(runtime.NumCPU()) * 100
+ p.MessageP95Ms = p95(messageTimes)
+ p.MessageCount = len(messageTimes)
+ p.LoginP95Ms = p95(loginTimes)
+ p.LoginCount = len(loginTimes)
+ for key, times := range readTimes {
+ p.ReadP95Ms[key] = p95(times)
+ p.ReadCounts[key] = len(times)
+ }
+ return p, nil
+}
diff --git a/scripts/test-ai-check-ui.mjs b/scripts/test-ai-check-ui.mjs
new file mode 100644
index 0000000000..daa13df822
--- /dev/null
+++ b/scripts/test-ai-check-ui.mjs
@@ -0,0 +1,36 @@
+// Offline Chrome, synthetic AI fields and mocked APIs; no live model or QQ.
+import assert from 'node:assert/strict';import fs from 'node:fs/promises';import path from 'node:path';import {createRequire} from 'node:module';import test,{after} from 'node:test';
+const require=createRequire(import.meta.url),modules=process.env.LOCALADMIN_NODE_MODULES||path.join(process.env.USERPROFILE,'.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');const {chromium}=require(path.join(modules,'playwright'));const browser=await chromium.launch({channel:'chrome',headless:true});after(()=>browser.close());
+const html=(await fs.readFile('internal/localadmin/assets/index.html','utf8')).replace(/');
+ for(const key of ['Key','ImageKey']){await page.selectOption('#ai-action-'+key,'replace');await page.locator('#ai-key-'+key).fill('synthetic-private-'+key);}
+ await page.selectOption('#ai-action-AgentKey','clear');await page.locator('#ai-clear-AgentKey').check();
+ let message='';page.on('dialog',dialog=>{message=dialog.message();});
+ await dialogAction(page,true,()=>page.locator('#discard-ai-global').click());assert.doesNotMatch(message,/synthetic-private|synthetic prompt/);
+ for(const key of ['Key','ImageKey','AgentKey']){
+ assert.equal(await page.locator('#ai-action-'+key).inputValue(),'keep');assert.equal(await page.locator('#ai-key-'+key).inputValue(),'');assert.equal(await page.locator('#ai-key-'+key).isDisabled(),true);
+ assert.equal(await page.locator('#ai-key-'+key).evaluate(el=>el.required),false);assert.equal(await page.locator('#ai-clear-'+key).isChecked(),false);assert.equal(await page.locator('#ai-clear-'+key).locator('..').isHidden(),true);
+ }
+ assert.equal(await page.locator('#ai-field-Type').inputValue(),'0');assert.equal(await page.locator('#ai-field-MaxN').inputValue(),'4096');assert.equal(await page.locator('#ai-field-TopP').inputValue(),'0.9');assert.equal(await page.locator('#ai-field-NoSystemP').isChecked(),false);assert.equal(await page.locator('#ai-field-SystemP').inputValue(),'fixture prompt');
+ assert.deepEqual(await page.evaluate(()=>aiState.global.keys),fixture().global.keys);assert.deepEqual(await dirty(page),['llm']);assert.equal(await page.locator('#ai-field-temperature-llm').inputValue(),'90');assert.equal(await page.evaluate(()=>requests.length),0);
+ assert.doesNotMatch(ai,/localStorage|sessionStorage|indexedDB/);
+});
+
+test('discard uses the last successful baseline, preserves other forms, and retains the version after conflicts',async t=>{
+ const page=await harness(t);await page.locator('#ai-field-AgentChar').fill('saved persona');
+ const saved=fixture();saved.global.fields.AgentChar='saved persona';saved.global.revision='c'.repeat(64);
+ await page.evaluate(value=>replies.push(value),saved);await page.locator('#save-ai').click();await waitIdle(page);
+ await page.locator('#ai-field-AgentChar').fill('unsaved persona');await page.locator('#ai-field-temperature-aichatcfg').fill('80');await page.locator('#ai-field-temperature-llm').fill('90');
+ await dialogAction(page,true,()=>page.locator('#discard-ai-global').click());assert.equal(await page.locator('#ai-field-AgentChar').inputValue(),'saved persona');assert.equal(await page.evaluate(()=>aiState.global.revision),'c'.repeat(64));assert.deepEqual(await dirty(page),['aichatcfg','llm']);
+ await page.locator('#ai-field-AgentChar').fill('keep global draft');await page.evaluate(groups=>replies.push({groups}),[group('aichatcfg',80),group('llm')]);
+ await page.locator('form[data-service="aichatcfg"] button[type="submit"]').click();await waitIdle(page);
+ await page.locator('#ai-field-temperature-aichatcfg').fill('82');await dialogAction(page,true,()=>page.locator('#discard-ai-aichatcfg').click());assert.equal(await page.locator('#ai-field-temperature-aichatcfg').inputValue(),'80');assert.equal(await page.locator('#ai-field-temperature-llm').inputValue(),'90');assert.equal(await page.locator('#ai-field-AgentChar').inputValue(),'keep global draft');
+ await page.evaluate(()=>replies.push({error:'版本冲突',status:409}));await page.locator('#save-ai').click();await waitIdle(page);assert.deepEqual(await dirty(page),['global','llm']);
+ await dialogAction(page,true,()=>page.locator('#discard-ai-global').click());assert.equal(await page.locator('#ai-field-AgentChar').inputValue(),'saved persona');assert.equal(await page.evaluate(()=>aiState.global.revision),'c'.repeat(64));assert.equal(await page.evaluate(()=>requests.length),3);assert.equal(await page.evaluate(()=>requests[2].body.revision),'c'.repeat(64));
+});
+
+test('private form discard respects the loaded target and preserves the uncommitted selector before a later save',async t=>{
+ const page=await harness(t),data=fixture();data.groups=data.groups.map(g=>({...g,groupId:'0',privateId:'234567890'}));
+ await page.evaluate(value=>{aiState=value;aiGroup='0';aiPrivate='234567890';document.getElementById('ai-scope-kind').value='private';document.getElementById('ai-group').value='234567890';renderAIScopeLabel();renderAIGroups(value.groups);},data);
+ await page.locator('#ai-group').fill('345678901');await page.locator('#ai-field-temperature-aichatcfg').fill('85');await page.locator('#ai-field-temperature-llm').fill('95');
+ let message='';page.on('dialog',dialog=>{message=dialog.message();});await dialogAction(page,true,()=>page.locator('#discard-ai-aichatcfg').click());assert.match(message,/私聊 QQ 234567890/);assert.doesNotMatch(message,/345678901/);
+ assert.equal(await page.locator('#ai-group').inputValue(),'345678901');assert.equal(await page.evaluate(()=>aiPrivate),'234567890');assert.equal(await page.locator('#ai-field-temperature-aichatcfg').inputValue(),'70');assert.equal(await page.locator('#ai-field-temperature-llm').inputValue(),'95');assert.equal(await page.evaluate(()=>requests.length),0);
+ await page.locator('#ai-field-temperature-aichatcfg').fill('83');data.groups[0].temperature=83;await page.evaluate(value=>replies.push({groups:value.groups}),data);
+ await page.locator('form[data-service="aichatcfg"] button[type="submit"]').click();await waitIdle(page);assert.equal(await page.evaluate(()=>requests[0].body.privateId),'234567890');assert.equal(await page.evaluate(()=>Object.hasOwn(requests[0].body,'groupId')),false);
+ assert.deepEqual(await dirty(page),['llm']);await dialogAction(page,true,()=>page.locator('#discard-ai-llm').click());assert.equal(await page.locator('#ai-field-temperature-llm').inputValue(),'70');
+});
+
+test('busy operations, expired sessions and late confirmations cannot restore or unlock newer form inputs',async t=>{
+ const page=await harness(t);await page.locator('#ai-field-AgentChar').fill('busy draft');await page.locator('#ai-field-temperature-aichatcfg').fill('80');await page.locator('#ai-field-temperature-llm').fill('90');
+ await page.locator('#save-ai').click();await page.waitForFunction(()=>pending.length===1);
+ for(const name of ['global','aichatcfg','llm'])assert.equal(await page.locator('#discard-ai-'+name).isDisabled(),true);
+ await page.evaluate(()=>discardAIForm('global'));assert.equal(await page.locator('#ai-field-AgentChar').inputValue(),'busy draft');
+ const saved=fixture();saved.global.fields.AgentChar='busy draft';await page.evaluate(value=>pending[0]({ok:true,status:200,json:async()=>value}),saved);await waitIdle(page);
+ await dialogAction(page,true,()=>page.locator('#reload-ai').click());await page.waitForFunction(()=>pending.length===2);assert.equal(await page.locator('#discard-ai-aichatcfg').isDisabled(),true);
+ await page.evaluate(value=>{authenticated=false;csrf='';showAuth();authenticated=true;csrf='new-session';aiState=value;aiGroup='123456789';document.getElementById('auth').hidden=true;document.getElementById('workspace').hidden=false;renderAIGlobal(value);renderAIGroups(value.groups);aiBusyState(false);},fixture());
+ await page.locator('#ai-field-AgentChar').fill('new session draft');await page.evaluate(()=>pending[1]({ok:false,status:401,json:async()=>({error:'old expiry'})}));await page.waitForTimeout(30);
+ assert.equal(await page.evaluate(()=>authenticated),true);assert.equal(await page.locator('#ai-field-AgentChar').inputValue(),'new session draft');assert.equal(await page.locator('#discard-ai-global').isDisabled(),false);
+ await page.evaluate(value=>{
+ const confirm=window.confirm;
+ window.confirm=()=>{resetAI();aiState=value;aiGroup='123456789';renderAIGlobal(value);renderAIGroups(value.groups);aiBusyState(false);document.getElementById('ai-field-AgentChar').value='after stale confirmation';renderAIDrafts();return true;};
+ try{discardAIForm('global');}finally{window.confirm=confirm;}
+ },fixture());
+ assert.equal(await page.locator('#ai-field-AgentChar').inputValue(),'after stale confirmation');assert.deepEqual(await dirty(page),['global']);
+ await page.evaluate(()=>replies.push({error:'session expired',status:401}));await page.locator('#save-ai').click();await page.waitForFunction(()=>!authenticated);assert.equal(await page.locator('#ai-global-inputs').textContent(),'');assert.equal(await page.locator('#discard-ai-global').isDisabled(),true);assert.deepEqual(await dirty(page),[]);
+ await page.evaluate(value=>{authenticated=true;csrf='last-session';aiState=value;aiGroup='123456789';document.getElementById('auth').hidden=true;document.getElementById('workspace').hidden=false;renderAIGlobal(value);renderAIGroups(value.groups);aiBusyState(false);replies.push({});},fixture());
+ await page.locator('#ai-field-AgentChar').fill('logout draft');await dialogAction(page,true,()=>page.locator('#logout').click());await page.waitForFunction(()=>!authenticated);assert.equal(await page.locator('#discard-ai-global').isDisabled(),true);assert.deepEqual(await dirty(page),[]);
+});
diff --git a/scripts/test-ai-draft-fields-ui.mjs b/scripts/test-ai-draft-fields-ui.mjs
new file mode 100644
index 0000000000..9a8e0cb1ce
--- /dev/null
+++ b/scripts/test-ai-draft-fields-ui.mjs
@@ -0,0 +1,82 @@
+// Offline Chrome tests: synthetic settings, mocked APIs, no actual secrets or QQ.
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {createRequire} from 'node:module';
+import test, {after} from 'node:test';
+const require=createRequire(import.meta.url);
+const modules=process.env.LOCALADMIN_NODE_MODULES || path.join(process.env.USERPROFILE,'.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
+const {chromium}=require(path.join(modules,'playwright'));
+const html=(await fs.readFile('internal/localadmin/assets/index.html','utf8')).replace(/']};
+
+test('backup list and preview use text; restoration requires explicit confirmation',async()=>{
+ const h=harness();h.run('showBackups(true)');h.requests[0].resolve(list);await flush();
+ assert.equal(h.get('backup-list').children.length,1);
+ h.run('previewBackup("fixture-safe-id")');h.requests[1].resolve(preview);await flush();
+ assert.equal(h.get('restore-warnings').firstElementChild.textContent,preview.warnings[0]);
+ assert.equal(h.get('restore-backup').disabled,true);
+ await h.get('restore-backup').dispatch('click');assert.equal(h.requests.length,2);
+ h.get('restore-confirm').checked=true;h.get('restore-confirm').dispatch('change');assert.equal(h.get('restore-backup').disabled,false);
+ const promise=h.get('restore-backup').dispatch('click');
+ assert.equal(h.requests[2].path,'/api/backups/restore');assert.equal(h.requests[2].body.confirm,true);assert.equal(h.requests[2].body.token,'ticket');
+ h.requests[2].resolve({changed:true,restartRequired:true,backupId:'safety-backup'});await flush();
+ assert.equal(h.run('connectionSettings'),null);assert.equal(h.run('pluginDrafts.size'),0);assert.equal(h.requests[3].path,'/api/backups');
+ assert.equal(h.run('catalogResets'),1);
+ h.requests[3].resolve(list);await promise;assert.match(h.get('backup-status').textContent,/恢复完成.*重启/);
+});
+
+test('navigation and logout cancel reads and refuse stale previews',async()=>{
+ const h=harness();h.run('showBackups(true)');h.run('showBackups(false)');assert.equal(h.requests[0].signal.aborted,true);await flush();
+ h.run('showBackups(true)');h.requests[1].resolve(list);await flush();
+ h.run('previewBackup("fixture-safe-id")');h.run('authenticated=false;resetBackups()');assert.equal(h.requests[2].signal.aborted,true);h.requests[2].resolve(preview);await flush();
+ assert.equal(h.get('restore-preview').hidden,true);assert.equal(h.get('backup-list').children.length,0);assert.equal(h.run('backupPreview'),null);
+});
+
+test('expired previews and failures cannot be resubmitted without preview',async()=>{
+ const h=harness();h.run('showBackups(true)');h.requests[0].resolve(list);await flush();
+ h.run('previewBackup("fixture-safe-id")');h.requests[1].resolve({...preview,expiresAt:1});await flush();
+ h.get('restore-confirm').checked=true;await h.get('restore-backup').dispatch('click');assert.equal(h.requests.length,2);assert.match(h.get('backup-error').textContent,/过期/);
+ h.run('previewBackup("fixture-safe-id")');h.requests[2].resolve(preview);await flush();h.get('restore-confirm').checked=true;
+ const pending=h.get('restore-backup').dispatch('click');h.requests[3].reject(new Error('配置已变化'));await pending;
+ assert.equal(h.run('backupPreview'),null);assert.equal(h.get('restore-backup').disabled,true);assert.match(h.get('backup-error').textContent,/配置已变化/);
+ assert.equal(h.requests.length,4);
+});
+
+const cleanupPreview={policy:{keepPerScope:10,keepDays:7},candidates:[entry],bytes:512,skipped:2,more:false,revision:'cleanup-revision',asOf:Math.floor(Date.now()/1000),expiresAt:Math.floor(Date.now()/1000)+600,token:'cleanup-ticket'};
+async function cleanupHarness(){
+ const h=harness();h.get('cleanup-keep').value='10';h.get('cleanup-days').value='7';h.run('showBackups(true)');h.requests[0].resolve(list);await flush();return h;
+}
+
+test('cleanup previews remain text and deletion requires explicit confirmation',async()=>{
+ const h=await cleanupHarness();const pending=h.get('cleanup-form').dispatch('submit');
+ assert.equal(h.requests[1].path,'/api/backups/cleanup-preview');assert.equal(h.requests[1].body.keepPerScope,10);
+ h.requests[1].resolve({...cleanupPreview,candidates:[{...entry,id:''}]});await pending;
+ assert.match(h.get('cleanup-list').firstElementChild.textContent,/';
+ h.context.value=page([row(1,payload)]);
+ h.run('renderLogs(value); renderLogs(value)');
+ const container=h.get('log-records');
+ assert.equal(container.children.length,1);
+ assert.equal(container.firstElementChild.children[1].textContent,payload);
+ assert.equal(container.scrollTop,40);
+ h.get('log-autoscroll').checked=false; container.scrollTop=13;
+ h.context.value=page(Array.from({length:1001},(_,index)=>row(index+2)));
+ h.run('renderLogs(value)');
+ assert.equal(container.children.length,1000);
+ assert.equal(h.run('logSeen.size'),1000);
+ assert.equal(container.scrollTop,13);
+ h.context.value={...page([row(1,'new process')]),reset:true};
+ h.run('renderLogs(value)');
+ assert.equal(container.children.length,1);
+ assert.equal(h.get('log-gap').hidden,false);
+});
+
+test('navigation, pause, hidden page and logout cancel polling without stale render',async()=>{
+ const h=harness(); h.run('showLogs(true)');
+ assert.equal(h.requests.length,1);
+ h.run('showLogs(false)');
+ assert.equal(h.requests[0].signal.aborted,true);
+ await flush(); assert.equal(h.timers.size,0);
+ h.run('showLogs(true)');
+ h.requests[1].resolve(page([row(1)])); await flush(); assert.equal(h.timers.size,1);
+ h.get('pause-logs').dispatch('click');
+ assert.equal(h.timers.size,0); assert.equal(h.run('logPaused'),true);
+ h.get('pause-logs').dispatch('click');
+ assert.equal(h.requests.length,3);
+ h.document.hidden=true; h.document.dispatch('visibilitychange');
+ assert.equal(h.requests[2].signal.aborted,true); await flush(); assert.equal(h.timers.size,0);
+ h.document.hidden=false; h.document.dispatch('visibilitychange');
+ assert.equal(h.requests.length,4);
+ // A resolved old request can still resume its async continuation after logout.
+ h.requests[3].resolve(page([row(99,'stale after logout')]));
+ h.run('authenticated=false; resetLogs()'); await flush();
+ assert.equal(h.get('log-records').children.length,0); assert.equal(h.timers.size,0);
+});
+
+test('filters restart the cursor and errors retry only while log view remains active',async()=>{
+ const h=harness(); h.run('showLogs(true)');
+ h.get('log-level').value='warning'; h.get('log-keyword').value='测试 keyword';
+ h.get('log-filter').dispatch('submit');
+ assert.equal(h.requests[0].signal.aborted,true);
+ const query=new URL(h.requests[1].path,'http://fixture.invalid').searchParams;
+ assert.equal(query.get('after'),'0'); assert.equal(query.get('q'),'测试 keyword'); assert.equal(query.get('level'),'warning');
+ h.requests[1].reject(new Error('synthetic failure')); await flush();
+ assert.equal(h.get('log-error').hidden,false); assert.equal(h.timers.size,1);
+ h.run('showLogs(false)'); assert.equal(h.timers.size,0);
+});
+
+const exported=(overrides={})=>({ok:true,status:200,
+ headers:new Headers({'Content-Type':'text/plain; charset=utf-8','Content-Length':'24','Content-Disposition':'attachment; filename="zerobot-logs-20261002T120000Z.txt"'}),
+ blob:async()=>new Blob(['脱敏日志\n']),...overrides});
+
+test('export uses applied filters, works while paused, prevents duplicates and releases download URL',async()=>{
+ const h=harness(); h.run('showLogs(true)');
+ h.get('log-level').value='warning'; h.get('log-keyword').value=' 测试 ';
+ h.get('log-filter').dispatch('submit'); h.get('pause-logs').dispatch('click');
+ h.get('log-keyword').value='unapplied draft';
+ h.get('export-logs').dispatch('click'); h.get('export-logs').dispatch('click');
+ assert.equal(h.exports.length,1); assert.equal(h.get('export-logs').disabled,true);
+ const query=new URL(h.exports[0].path,'http://fixture.invalid').searchParams;
+ assert.equal(query.get('level'),'warning'); assert.equal(query.get('q'),'测试'); assert.equal(query.has('after'),false);
+ assert.equal(h.exports[0].options.credentials,'same-origin');
+ h.exports[0].resolve(exported()); await flush();
+ assert.equal(h.downloads.length,1); assert.equal(h.downloads[0].filename,'zerobot-logs-20261002T120000Z.txt');
+ assert.equal(h.document.body.children.length,0); assert.equal(h.get('export-logs').disabled,false);
+ for (const callback of h.timers.values()) callback();
+ assert.deepEqual(h.revoked,[h.downloads[0].href]);
+});
+
+test('export cancellation and stale body completion never download after navigation, filters, hiding or logout',async()=>{
+ for (const action of ['showLogs(false)','resetLogs()','reloadLogs()', 'document.hidden=true; document.dispatch("visibilitychange")']) {
+ const h=harness(); h.run('showLogs(true)'); h.get('export-logs').dispatch('click');
+ let finish; h.exports[0].resolve(exported({blob:()=>new Promise(resolve=>{finish=resolve;})})); await flush();
+ h.run(action); finish(new Blob(['stale sensitive data'])); await flush();
+ assert.equal(h.exports[0].options.signal.aborted,true); assert.equal(h.downloads.length,0); assert.equal(h.get('export-logs').disabled,false);
+ }
+});
+
+test('failed, expired or malformed exports produce no file',async()=>{
+ for (const response of [
+ exported({ok:false,status:503,json:async()=>({error:'日志暂不可读取'})}),
+ exported({ok:false,status:401,json:async()=>({error:'请登录'})}),
+ exported({headers:new Headers({'Content-Type':'text/html','Content-Length':'24','Content-Disposition':'attachment; filename="unsafe.html"'})}),
+ exported({headers:new Headers({'Content-Type':'text/plain','Content-Length':String(4*1024*1024),'Content-Disposition':'attachment; filename="zerobot-logs-20261002T120000Z.txt"'})}),
+ exported({blob:async()=>({size:4*1024*1024})})
+ ]) {
+ const h=harness(); h.run('showLogs(true)'); h.get('export-logs').dispatch('click'); h.exports[0].resolve(response); await flush();
+ assert.equal(h.downloads.length,0); assert.equal(h.get('export-logs').disabled,false);
+ if (response.status===401) {assert.equal(h.context.authenticated,false); assert.equal(h.context.csrf,'');}
+ else assert.notEqual(h.get('log-export-status').textContent,'');
+ }
+});
+
+test('applied filter status distinguishes drafts; quick issue/export/clear use committed conditions and preserve pause',async()=>{
+ const h=harness();h.run('showLogs(true)');
+ h.get('log-keyword').value='
';h.get('log-keyword').dispatch('input');
+ assert.match(h.get('log-filter-status').textContent,/尚未应用/);
+ assert.doesNotMatch(h.get('log-filter-status').textContent,/
'));
+ h.get('pause-logs').dispatch('click');h.get('log-autoscroll').checked=false;
+ h.get('log-keyword').value='unapplied';h.get('log-level').value='info';h.get('log-keyword').dispatch('input');
+ h.get('only-issue-logs').dispatch('click');
+ assert.equal(h.run('logLevel'),'issues');assert.equal(h.run('logPaused'),true);
+ assert.equal(h.get('log-keyword').value,'
');assert.equal(h.get('log-autoscroll').checked,false);
+ assert.doesNotMatch(h.get('log-filter-status').textContent,/尚未应用/);
+ assert.equal(h.requests.length,2);h.get('export-logs').dispatch('click');
+ const query=new URL(h.exports[0].path,'http://fixture.invalid').searchParams;
+ assert.equal(query.get('level'),'issues');assert.equal(query.get('q'),'
');
+ h.get('clear-log-filter').dispatch('click');await flush();
+ assert.equal(h.exports[0].options.signal.aborted,true);assert.equal(h.get('log-level').value,'');assert.equal(h.get('log-keyword').value,'');
+ assert.equal(h.run('logPaused'),true);assert.equal(h.requests.length,2);
+ h.get('pause-logs').dispatch('click');
+ const cleared=new URL(h.requests[2].path,'http://fixture.invalid').searchParams;
+ assert.equal(cleared.get('level'),'');assert.equal(cleared.get('q'),'');assert.equal(cleared.get('after'),'0');
+});
+
+test('quick filter rejects stale resolved rows and logout resets applied/draft conditions',async()=>{
+ const h=harness();h.run('showLogs(true)');
+ h.requests[0].resolve(page([row(1,'old data')]));h.get('only-issue-logs').dispatch('click');await flush();
+ assert.equal(h.get('log-records').children.length,0);assert.equal(h.requests[0].signal.aborted,true);
+ h.requests[1].resolve(page([{...row(2,'issue'),level:'warning'}]));await flush();
+ assert.equal(h.get('log-records').children.length,1);
+ h.get('log-keyword').value='private draft';h.run('authenticated=false;resetLogs()');
+ assert.equal(h.run('logLevel'),'');assert.equal(h.run('logKeyword'),'');assert.equal(h.get('log-keyword').value,'');
+ assert.doesNotMatch(h.get('log-filter-status').textContent,/尚未应用|private draft/);
+ const count=h.requests.length;h.get('only-issue-logs').dispatch('click');h.get('clear-log-filter').dispatch('click');
+ assert.equal(h.requests.length,count);assert.equal(h.get('log-records').children.length,0);
+});
diff --git a/scripts/test-model-observation-ui.mjs b/scripts/test-model-observation-ui.mjs
new file mode 100644
index 0000000000..7703ce620f
--- /dev/null
+++ b/scripts/test-model-observation-ui.mjs
@@ -0,0 +1,86 @@
+// Offline real Chrome, synthetic metrics only. No user session or model calls.
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {createRequire} from 'node:module';
+import test,{after} from 'node:test';
+const require=createRequire(import.meta.url),modules=process.env.LOCALADMIN_NODE_MODULES||path.join(process.env.USERPROFILE,'.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
+const {chromium}=require(path.join(modules,'playwright'));
+const browser=await chromium.launch({channel:'chrome',headless:true});after(()=>browser.close());
+const html=(await fs.readFile('internal/localadmin/assets/index.html','utf8')).replace(/'}),stats({checkedAt:'2026-10-13T01:00:00Z'}),stats({categories:[null,null,null,null]}),stats({categories:Array(4).fill({...zero,kind:'
'})}),stats({categories:stats().categories.slice(0,3)}),stats({categories:stats().categories.map(row=>({...row,started:1,active:1}))}),stats({averageMilliseconds:1,maxMilliseconds:1})];
+ for(const data of bad){await p.evaluate(data=>receiveModelObservation(data),data);assert.equal(await p.evaluate(()=>modelObservationCurrent),null);await p.evaluate(()=>{startModelObservation();downloadModelObservation()});assert.equal(await p.locator('#start-model-observation').isDisabled(),true)}assert.equal(await p.evaluate(()=>requests.length),0);
+});
+test('restart, reversed precise time, decreasing count or maximum invalidates baseline and cannot merge epochs',async t=>{
+ const inconsistent=stats({started:1,active:1});inconsistent.categories[0]={kind:'gpt',...zero,started:1,active:1};
+ const p=await fixture(t);for(const update of [stats({startedAt:'2026-10-13T02:00:00.000000002Z'}),stats({checkedAt:'2026-10-13T02:01:00.123456788Z'}),inconsistent]){await read(p,stats());await p.locator('#start-model-observation').click();await read(p,update);assert.equal(await p.evaluate(()=>modelObservationBaseline),null);assert.equal(await p.locator('#download-model-observation').isDisabled(),true);assert.match(await p.locator('#model-observation-status').textContent(),/已清除/)}
+ const old=stats({started:1,completed:1,averageMilliseconds:5,maxMilliseconds:5});old.categories[0]={kind:'gpt',...zero,started:1,completed:1,averageMilliseconds:5,maxMilliseconds:5};await read(p,old);await p.locator('#start-model-observation').click();await read(p,stats({checkedAt:'2026-10-13T02:02:00Z'}));assert.equal(await p.evaluate(()=>modelObservationBaseline),null);
+ const reduced=structuredClone(old);reduced.checkedAt='2026-10-13T02:02:00Z';reduced.maxMilliseconds=4;reduced.averageMilliseconds=4;reduced.categories[0].maxMilliseconds=4;reduced.categories[0].averageMilliseconds=4;await read(p,old);await p.locator('#start-model-observation').click();await read(p,reduced);assert.equal(await p.evaluate(()=>modelObservationBaseline),null);
+});
+test('failed or pending reads disable downloads; navigation preserves baseline but requires a fresh end snapshot',async t=>{
+ const p=await fixture(t);await read(p);await p.locator('#start-model-observation').click();const baseline=await p.evaluate(()=>structuredClone(modelObservationBaseline));await p.evaluate(()=>mode='pending');await p.locator('#refresh').click();await p.waitForFunction(()=>pending.length===1);assert.equal(await p.locator('#download-model-observation').isDisabled(),true);await p.evaluate(()=>{startModelObservation();downloadModelObservation()});assert.deepEqual(await p.evaluate(()=>modelObservationBaseline),baseline);
+ await p.evaluate(()=>showOverview(false));assert.equal(await p.evaluate(()=>modelObservationCurrent),null);assert.deepEqual(await p.evaluate(()=>modelObservationBaseline),baseline);await p.evaluate(payload=>pending[0]({ok:true,json:async()=>payload}),overview(stats()));await p.waitForTimeout(30);assert.equal(await p.evaluate(()=>modelObservationCurrent),null);
+ await read(p);assert.equal(await p.locator('#download-model-observation').isEnabled(),true);await p.evaluate(()=>mode='failure');await p.locator('#refresh').click();await p.waitForFunction(()=>!overviewBusy);assert.equal(await p.evaluate(()=>modelObservationCurrent),null);assert.deepEqual(await p.evaluate(()=>modelObservationBaseline),baseline);assert.equal(await p.locator('#download-model-observation').isDisabled(),true);await read(p);assert.equal(await p.locator('#download-model-observation').isEnabled(),true);
+});
+test('logout expiry and late reads cannot refill a fresh baseline; hidden tabs clear only current snapshot',async t=>{
+ const p=await fixture(t);await read(p);await p.locator('#start-model-observation').click();await p.evaluate(()=>{Object.defineProperty(document,'hidden',{value:true,configurable:true});document.dispatchEvent(new Event('visibilitychange'))});assert.equal(await p.evaluate(()=>modelObservationCurrent),null);assert.notEqual(await p.evaluate(()=>modelObservationBaseline),null);assert.equal(await p.locator('#download-model-observation').isDisabled(),true);
+ await p.evaluate(()=>{Object.defineProperty(document,'hidden',{value:false,configurable:true});document.dispatchEvent(new Event('visibilitychange'))});await p.waitForFunction(()=>!overviewBusy);await p.evaluate(()=>mode='pending');await p.locator('#refresh').click();await p.waitForFunction(()=>pending.length===1);await p.evaluate(()=>{authenticated=false;csrf='';showAuth()});assert.equal(await p.evaluate(()=>modelObservationBaseline),null);assert.equal(await p.evaluate(()=>modelObservationCurrent),null);
+ await p.evaluate(()=>{authenticated=true;csrf='fresh-observation-session';mode='success';document.getElementById('auth').hidden=true;document.getElementById('workspace').hidden=false});await read(p);await p.locator('#start-model-observation').click();const fresh=await p.evaluate(()=>structuredClone(modelObservationBaseline));await p.evaluate(()=>pending[0]({ok:false,status:401,json:async()=>({error:'old expiry'})}));await p.waitForTimeout(30);assert.equal(await p.evaluate(()=>authenticated),true);assert.deepEqual(await p.evaluate(()=>modelObservationBaseline),fresh);
+});
+test('download failures are generic and clean up links; duplicate calls and old cleanup cannot change newer session controls',async t=>{
+ const p=await fixture(t);await read(p);await p.locator('#start-model-observation').click();await p.evaluate(()=>{HTMLAnchorElement.prototype.click=()=>{throw Error('fixture-private-file-error')};downloadModelObservation();downloadModelObservation()});assert.match(await p.locator('#model-observation-status').textContent(),/下载未完成/);assert.doesNotMatch(await p.locator('#model-observation-status').textContent(),/fixture-private/);assert.equal(await p.locator('a[download]').count(),0);
+ await p.evaluate(()=>{authenticated=false;csrf='';showAuth();authenticated=true;csrf='new-download-session';document.getElementById('auth').hidden=true;document.getElementById('workspace').hidden=false});await read(p);await p.locator('#start-model-observation').click();const text=await p.locator('#model-observation-status').textContent();await p.waitForFunction(()=>revoked.length===1);assert.equal(await p.evaluate(()=>modelObservationDownloading),false);assert.equal(await p.locator('#model-observation-status').textContent(),text);assert.equal(await p.locator('#download-model-observation').isEnabled(),true);
+});
+test('version two downloads canonical process identity and discard private extra fields',async t=>{
+ const process={processId:12345,processStartedAt:'2026-10-13T01:59:59.123456700Z',programSHA256:'a'.repeat(64),path:'fixture-private-path',account:'fixture-private-account'};
+ const value=stats({process});const p=await fixture(t,value);await read(p);await p.locator('#start-model-observation').click();assert.match(await p.locator('#model-observation-status').textContent(),/包含进程关联信息/);
+ await p.evaluate(()=>payload.modelRequests.process.processId=99999);
+ const report=await download(p);assert.equal(report.value.version,2);assert.deepEqual(report.value.before.process,{processId:12345,processStartedAt:'2026-10-13T01:59:59.123456700Z',programSHA256:'A'.repeat(64)});assert.deepEqual(report.value.after.process,report.value.before.process);assert.doesNotMatch(report.text,/fixture-private/);assert.equal(await p.evaluate(()=>modelObservationBaseline.process.processId),12345);
+});
+
+test('process changes and appearance or disappearance invalidate otherwise identical epochs',async t=>{
+ const process={processId:12345,processStartedAt:'2026-10-13T01:59:59.000000100Z',programSHA256:'A'.repeat(64)};const base=stats({process});const p=await fixture(t);
+ for(const changed of [{...process,processId:12346},{...process,processStartedAt:'2026-10-13T01:59:59.000000101Z'},{...process,programSHA256:'B'.repeat(64)},undefined]){await read(p,base);await p.locator('#start-model-observation').click();await read(p,stats({checkedAt:'2026-10-13T02:01:01Z',process:changed}));assert.equal(await p.evaluate(()=>modelObservationBaseline),null);assert.equal(await p.locator('#download-model-observation').isDisabled(),true);assert.match(await p.locator('#model-observation-status').textContent(),/已清除/)}
+ await read(p,stats());await p.locator('#start-model-observation').click();assert.match(await p.locator('#model-observation-status').textContent(),/进程关联信息不可读取/);await read(p,base);assert.equal(await p.evaluate(()=>modelObservationBaseline),null);
+});
+
+test('invalid identity cannot be exported while missing identity remains an explicit legacy report',async t=>{
+ const process={processId:12345,processStartedAt:'2026-10-13T01:59:59Z',programSHA256:'A'.repeat(64)},p=await fixture(t);
+ for(const changed of [{...process,processId:0},{...process,processId:Number.MAX_SAFE_INTEGER+1},{...process,programSHA256:'
'},{...process,processStartedAt:'2026-02-31T01:59:59Z'},{...process,processStartedAt:'2026-10-13T02:00:00.000000002Z'}]){await read(p,stats({process:changed}));assert.equal(await p.evaluate(()=>modelObservationCurrent),null);assert.equal(await p.locator('#start-model-observation').isDisabled(),true)}
+ await read(p,stats({process:null}));await p.locator('#start-model-observation').click();const report=await download(p);assert.equal(report.value.version,1);assert.equal(report.value.before.process,undefined);assert.match(await p.locator('#model-request-summary').textContent(),/本次启动尚无/);
+});
+
+test('report actions and long counter summary fit four widths and both themes',async t=>{
+ const p=await fixture(t);await read(p);await p.locator('#start-model-observation').click();await p.evaluate(()=>document.getElementById('model-observation-summary').textContent='观察快照间隔 123456789.123 秒:新发起 9007199254740000 次;结束并完成接口传输 8000000000000000 次;失败 100000 次;提前关闭 100000 次。起点进行中 100000 次,结束进行中 100000 次。');
+ for(const width of [320,375,640,1280]){await p.setViewportSize({width,height:1000});for(const mode of ['light','dark']){await p.evaluate(mode=>document.documentElement.dataset.theme=mode,mode);const shape=await p.locator('.model-observation-panel').evaluate(el=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,panelWidth:el.clientWidth,panelScroll:el.scrollWidth,buttons:[...el.querySelectorAll('button')].map(b=>({left:b.getBoundingClientRect().left,right:b.getBoundingClientRect().right,width:b.getBoundingClientRect().width}))}));assert.ok(shape.scrollWidth<=width+1&&shape.panelScroll<=shape.panelWidth+1,JSON.stringify(shape));assert.ok(shape.buttons.every(b=>b.left>=0&&b.right<=width+1),JSON.stringify(shape));}}
+});
diff --git a/scripts/test-observation-evidence.mjs b/scripts/test-observation-evidence.mjs
new file mode 100644
index 0000000000..7a3904078d
--- /dev/null
+++ b/scripts/test-observation-evidence.mjs
@@ -0,0 +1,166 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {mkdtemp,writeFile,readFile,open,mkdir,symlink} from 'node:fs/promises';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import {spawnSync} from 'node:child_process';
+import {checkEvidence,checkResourceEvidence,evidenceExitCode,stamp,validateModel,validateResource,writeEvidence} from './check-observation-evidence.mjs';
+
+const repo=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
+const root=await mkdtemp(path.join(repo,'.local','observation-evidence-tests-'));
+const clone=value=>structuredClone(value),kinds=['gpt','chat','agent','vision'];
+const zero={started:0,active:0,completed:0,failed:0,incomplete:0,averageMilliseconds:0,maxMilliseconds:0};
+function fixture(){
+ const samples=Array.from({length:181},(_,i)=>({at:new Date(Date.parse('2026-10-13T00:00:02Z')+i*5000).toISOString(),atSeconds:i*5,cpuSeconds:10+i/100,privateBytes:1000000+(i?8192:0),workingSetBytes:2000000,threadCount:12,handleCount:200}));
+ const meta={startedAt:'2026-10-13T08:00:01.999+08:00',completedAt:'2026-10-13T08:15:02.100+08:00',processId:12345,processStartedAt:'2026-10-13T00:00:00Z',programSHA256:'A'.repeat(64),requestedDurationSeconds:900,intervalSeconds:5,logicalCPUs:20,thresholds:{cpuAverageCapacityPercent:20,privateGrowthMiB:64},completed:true,passed:true,identityStable:true,programUnchanged:true,stopReason:'duration-complete',realQQMessagesSent:false,modelCalls:0,businessLoadMeasured:false,businessAcceptancePassed:false,messageContentsSaved:false,servicesStartedOrStopped:false,noNewErrorLines:true,logCounts:{appendedLines:0,warningLines:0,errorLines:0,fatalLines:0,panicLines:0,otherLines:0,bytesRead:0,unreadBytes:0,partialLines:0,logTruncated:false,coverageComplete:true},resources:{sampleCount:181,sampleSpanSeconds:900,maxSampleGapSeconds:5,cpuAveragePercent:1.8/900/20*100,cpuPeakSamplePercent:0.01/5/20*100,privateFirstBytes:1000000,privateMaxBytes:1008192,privateLastBytes:1008192,privateGrowthMiB:0.0078125,workingSetMaxBytes:2000000,threadMax:12,handleMax:200,metricsValid:true,completeWindow:true,cadencePassed:true,resourceThresholdsPassed:true,fullTiming:true}};
+ const a={...zero,started:2,active:2},b={...zero,started:6,active:1,completed:5,averageMilliseconds:3,maxMilliseconds:5};
+ const snapshot=(checkedAt,gpt)=>({startedAt:'2026-10-13T00:00:01.000000001Z',checkedAt,...gpt,categories:kinds.map(kind=>({kind,...(kind==='gpt'?gpt:zero)}))});
+ const delta={started:4,completed:5,failed:0,incomplete:0,activeAtStart:2,activeAtEnd:1};
+ const model={format:'zerobot-model-observation',version:1,exportedAt:'2026-10-13T00:10:01Z',scope:{kind:'model-http-exchanges',latencyScope:'process-cumulative',businessAcceptancePassed:false},before:snapshot('2026-10-13T00:01:00Z',a),after:snapshot('2026-10-13T00:10:00Z',b),window:{durationSeconds:540,...delta,categories:kinds.map(kind=>({kind,...(kind==='gpt'?delta:{started:0,completed:0,failed:0,incomplete:0,activeAtStart:0,activeAtEnd:0})}))}};
+ return {meta,samples,model};
+}
+function summarize(f){
+ const result=validateResource(f.meta,f.samples.map(r=>({...r,ns:stamp(r.at).ns})));
+ f.meta.resources=result.resources;f.meta.passed=result.resourceChecksPassed;
+}
+let serial=0;
+async function save(f){
+ const dir=path.join(root,String(++serial));await mkdir(dir);
+ await writeFile(path.join(dir,'result.json'),JSON.stringify(f.meta));
+ await writeFile(path.join(dir,'samples.jsonl'),f.samples.map(r=>JSON.stringify(r)).join('\n')+'\n');
+ const model=path.join(dir,'model.json');await writeFile(model,JSON.stringify(f.model));
+ return {resource:dir,model};
+}
+async function check(f){return checkEvidence(await save(f));}
+
+test('resource only independently recomputes a formal window without inventing model association',async()=>{
+ const input=await save(fixture());await writeFile(input.model,'NOT A MODEL REPORT');
+ const r=await checkResourceEvidence({resource:input.resource});assert.equal(r.mode,'resource-only');assert.equal(r.evidenceValid,true);assert.equal(r.resourceObservationPassed,true);assert.equal(r.resourceChecksPassed,true);assert.equal(evidenceExitCode(r),0);assert.equal(r.resource.resources.privateGrowthMiB,0.0078125);assert.equal(r.compatible,false);assert.equal(r.sameProcessVerified,false);assert.equal(r.businessAcceptancePassed,false);assert.equal(r.temporalCoverage,'unavailable');assert.equal(r.model,undefined);assert.equal(r.sources.modelSHA256,undefined);assert.ok(r.issues.includes('association:model-report-not-provided'));
+});
+
+test('resource only preserves diagnostic outcomes for insufficient timing and failed checks',async t=>{
+ const changes=[['short',f=>f.meta.requestedDurationSeconds=10],['stopped',f=>{f.meta.completed=false;f.meta.stopReason='stop-request'}],['cadence',f=>f.samples.splice(1,10)],['threshold',f=>f.meta.thresholds.privateGrowthMiB=0.001],['identity',f=>f.meta.identityStable=false],['program',f=>f.meta.programUnchanged=false],['log-error',f=>{f.meta.logCounts={...f.meta.logCounts,appendedLines:1,errorLines:1,bytesRead:50};f.meta.noNewErrorLines=false}],['log-coverage',f=>{f.meta.logCounts.unreadBytes=1;f.meta.logCounts.coverageComplete=false}]];
+ for(const [name,change] of changes)await t.test(name,async()=>{const f=fixture();change(f);summarize(f);const r=await checkResourceEvidence(await save(f));assert.equal(r.evidenceValid,true);assert.equal(r.resourceObservationPassed,false);assert.equal(evidenceExitCode(r),2);assert.equal(r.businessAcceptancePassed,false);});
+});
+
+test('resource only rejects mismatched summaries and corrupt or missing samples without rewriting',async t=>{
+ for(const [name,change] of [['rounded',f=>f.meta.resources.privateGrowthMiB=0],['secret-flag',f=>f.meta.messageContentsSaved=true]])await t.test(name,async()=>{const f=fixture();change(f);const input=await save(f),original=await readFile(path.join(input.resource,'result.json'));const r=await checkResourceEvidence(input);assert.equal(r.evidenceValid,false);assert.equal(r.resourceObservationPassed,false);assert.equal(evidenceExitCode(r),1);assert.deepEqual(await readFile(path.join(input.resource,'result.json')),original)});
+ const input=await save(fixture());await writeFile(path.join(input.resource,'samples.jsonl'),'PRIVATE-BAD-SAMPLE');const r=await checkResourceEvidence(input);assert.equal(evidenceExitCode(r),1);assert.ok(!JSON.stringify(r).includes('PRIVATE-BAD-SAMPLE'));assert.equal(evidenceExitCode(await checkResourceEvidence({resource:path.join(root,'missing')})),1);
+});
+
+test('resource only flags inconsistent wall clock even when monotonic resource checks pass',async()=>{
+ const f=fixture();for(let i=1;i{
+ const f=fixture();f.meta.secret='PRIVATE-CREDENTIAL';f.meta.path='PRIVATE-PATH';f.meta.logCounts.message='PRIVATE-MESSAGE';f.samples[0].text='PRIVATE-CONTENT';const r=await checkResourceEvidence(await save(f));assert.equal(r.resourceObservationPassed,true);assert.ok(!JSON.stringify(r).includes('PRIVATE-'));assert.equal(r.resource.processId,f.meta.processId);assert.equal(r.resource.programSHA256,f.meta.programSHA256);
+});
+
+test('resource only CLI exits and exclusive output preserve original files',async()=>{
+ const input=await save(fixture()),output=path.join(input.resource,'resource-checked.json'),tool=path.join(repo,'scripts','check-observation-evidence.mjs');const run=args=>spawnSync(process.execPath,[tool,...args],{encoding:'utf8'});const args=['--resource',input.resource,'--output',output],r=run(args);assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).mode,'resource-only');assert.equal(JSON.parse(await readFile(output,'utf8')).resourceObservationPassed,true);assert.equal(run(args).status,1);const original=await readFile(path.join(input.resource,'result.json'));assert.equal(run(['--resource',input.resource,'--output',path.join(input.resource,'result.json')]).status,1);assert.deepEqual(await readFile(path.join(input.resource,'result.json')),original);const f=fixture();f.meta.requestedDurationSeconds=10;summarize(f);const short=await save(f);assert.equal(run(['--resource',short.resource]).status,2);
+});
+
+test('compatible full window, fractional growth and crossing active requests remain unverified',async()=>{
+ const result=await check(fixture());assert.equal(result.evidenceValid,true);assert.equal(result.compatible,true);assert.equal(result.temporalCoverage,'inside');assert.equal(evidenceExitCode(result),0);
+ assert.equal(result.resource.resources.privateGrowthMiB,0.0078125);assert.equal(result.model.window.completed,5);assert.equal(result.model.window.started,4);
+ assert.equal(result.sameProcessVerified,false);assert.equal(result.businessAcceptancePassed,false);assert.equal(result.model.window.maxMilliseconds,undefined);
+});
+test('nanosecond identity and offset/calendar validation',()=>{
+ assert.equal(stamp('2026-10-13T08:00:00.123456789+08:00').ns,stamp('2026-10-13T00:00:00.123456789Z').ns);
+ assert.equal(stamp('2026-10-13T00:00:00.123456788Z').ns+1n,stamp('2026-10-13T00:00:00.123456789Z').ns);
+ for(const value of ['2026-02-30T00:00:00Z','2026-10-13T24:00:00Z','2026-10-13T00:00:60Z','2026-10-13T00:00:00+14:01','2026-10-13T00:00:00+08:60','2026-10-13T00:00:00.1234567890Z','2026-10-13'])assert.throws(()=>stamp(value));
+ assert.equal(stamp('2024-02-29T00:00:00-03:30').text,'2024-02-29T03:30:00.000000000Z');
+});
+test('model format, scope, counts, categories, latency and exported window reject corruption',async t=>{
+ const cases=[['format',f=>f.model.version=3],['scope',f=>f.model.scope.businessAcceptancePassed=true],['unsafe-count',f=>f.model.after.started=Number.MAX_SAFE_INTEGER+1],['negative',f=>f.model.after.active=-1],['conservation',f=>f.model.after.started++],['duplicate-category',f=>f.model.before.categories[1].kind='gpt'],['category-total',f=>{f.model.before.categories[0].started=1;f.model.before.categories[0].active=1;}],['latency',f=>f.model.after.averageMilliseconds=6],['empty-latency',f=>f.model.before.maxMilliseconds=1],['window',f=>f.model.window.completed++],['window-latency',f=>f.model.window.maxMilliseconds=5],['category-window-latency',f=>f.model.window.categories[0].averageMilliseconds=3],['export-order',f=>f.model.exportedAt='2026-10-13T00:09:00Z']];
+ for(const [name,mutate] of cases)await t.test(name,async()=>{const f=fixture();mutate(f);const r=await check(f);assert.equal(r.evidenceValid,false);assert.equal(evidenceExitCode(r),1);assert.equal(r.compatible,false);});
+});
+test('model restart, reverse time, same precise time changes and maximum rollback',async t=>{
+ for(const [name,mutate] of [['restart',f=>f.model.after.startedAt='2026-10-13T00:00:01.000000002Z'],['reverse',f=>f.model.after.checkedAt='2026-10-13T00:00:59.999999999Z'],['same-time',f=>f.model.after.checkedAt=f.model.before.checkedAt],['maximum',f=>{f.model.before.maxMilliseconds=6;f.model.before.completed=1;f.model.before.active=1;f.model.before.categories[0]={kind:'gpt',...Object.fromEntries(Object.keys(zero).map(k=>[k,f.model.before[k]]))};}]])await t.test(name,async()=>{const f=fixture();mutate(f);assert.throws(()=>validateModel(f.model));});
+});
+test('zero, partial, unrelated and collector-before-process windows cannot be associated',async t=>{
+ const cases=[['zero',f=>{f.model.after=clone(f.model.before);f.model.window={durationSeconds:0,started:0,completed:0,failed:0,incomplete:0,activeAtStart:2,activeAtEnd:2,categories:kinds.map(kind=>({kind,started:0,completed:0,failed:0,incomplete:0,activeAtStart:kind==='gpt'?2:0,activeAtEnd:kind==='gpt'?2:0}))};},'zero-window'],['partial',f=>{f.model.before.checkedAt='2026-10-13T00:00:01.500Z';f.model.window.durationSeconds=598.5;},'partial'],['none',f=>{f.model.before.checkedAt='2026-10-13T00:16:00Z';f.model.after.checkedAt='2026-10-13T00:25:00Z';f.model.exportedAt='2026-10-13T00:25:01Z';},'none'],['collector',f=>{f.model.before.startedAt='2026-10-12T23:59:59Z';f.model.after.startedAt=f.model.before.startedAt;},'inside']];
+ for(const [name,mutate,coverage] of cases)await t.test(name,async()=>{const f=fixture();mutate(f);const r=await check(f);assert.equal(r.evidenceValid,true);assert.equal(r.temporalCoverage,coverage);assert.equal(r.compatible,false);assert.equal(evidenceExitCode(r),2);});
+});
+test('original fractional-memory rounding is detected without rewriting inputs',async()=>{
+ const f=fixture();f.meta.resources.privateGrowthMiB=0;const input=await save(f),before=await readFile(path.join(input.resource,'result.json'));
+ const result=await checkEvidence(input);assert.equal(result.evidenceValid,false);assert.deepEqual(result.resource.summaryMismatches,['privateGrowthMiB']);assert.equal(result.resource.resources.privateGrowthMiB,0.0078125);assert.deepEqual(await readFile(path.join(input.resource,'result.json')),before);
+});
+test('submillisecond windows stay diagnostic and category ordering is canonical',async()=>{
+ const f=fixture();f.model.before.checkedAt='2026-10-13T00:01:00.000000001Z';f.model.after.checkedAt='2026-10-13T00:01:00.000000002Z';f.model.window.durationSeconds=0;
+ for(const key of ['before','after','window'])f.model[key].categories.reverse();
+ const r=await check(f);assert.equal(r.evidenceValid,true);assert.equal(r.temporalCoverage,'zero-window');assert.equal(r.compatible,false);assert.deepEqual(r.model.window.categories.map(r=>r.kind),kinds);
+});
+test('rollback with otherwise consistent totals is rejected',()=>{
+ const f=fixture();f.model.after={...f.model.after,...zero,started:1,active:1,categories:kinds.map(kind=>({kind,...zero,...(kind==='gpt'?{started:1,active:1}:{})}))};
+ assert.throws(()=>validateModel(f.model),/model-counter-rollback/);
+});
+test('short, early stop, sparse, thresholds, identity, program and log coverage stay separate from validity',async t=>{
+ const cases=[['short',f=>{f.meta.requestedDurationSeconds=10;f.samples=f.samples.slice(0,3);}],['stop',f=>{f.meta.completed=false;f.meta.stopReason='stop-request';f.samples=f.samples.slice(0,5);}],['sparse',f=>f.samples=f.samples.filter((_,i)=>i%6===0)],['cpu',f=>f.meta.thresholds.cpuAverageCapacityPercent=0.00001],['memory',f=>f.meta.thresholds.privateGrowthMiB=0.001],['identity',f=>{f.meta.identityStable=false;f.meta.completed=false;f.meta.stopReason='process-identity-changed';}],['program',f=>f.meta.programUnchanged=false],['error',f=>{f.meta.logCounts.errorLines=1;f.meta.logCounts.appendedLines=1;f.meta.logCounts.bytesRead=100;f.meta.noNewErrorLines=false;}],['partial-log',f=>{f.meta.logCounts.partialLines=1;f.meta.logCounts.coverageComplete=false;}],['unread-log',f=>{f.meta.logCounts.unreadBytes=1;f.meta.logCounts.coverageComplete=false;}],['truncated-log',f=>{f.meta.logCounts.logTruncated=true;f.meta.logCounts.coverageComplete=false;}]];
+ for(const [name,mutate] of cases)await t.test(name,async()=>{const f=fixture();mutate(f);summarize(f);const r=await check(f);assert.equal(r.evidenceValid,true);assert.equal(r.resourceChecksPassed,false);assert.equal(r.compatible,false);assert.equal(evidenceExitCode(r),2);});
+});
+test('wall-clock drift invalidates time association without hiding valid resource arithmetic',async()=>{
+ const f=fixture();for(let i=1;i{
+ const cases=[['cpu-zero',f=>f.meta.logicalCPUs=0],['interval',f=>f.meta.intervalSeconds=31],['duration',f=>f.meta.requestedDurationSeconds=9],['threshold',f=>f.meta.thresholds.privateGrowthMiB=0],['identity',f=>f.meta.programSHA256='oops'],['flags',f=>f.meta.passed='true'],['stop-reason',f=>f.meta.stopReason='stop-request'],['scope',f=>f.meta.modelCalls=1],['logs',f=>f.meta.logCounts.appendedLines=1],['log-flags',f=>f.meta.logCounts.coverageComplete=false],['cpu-rollback',f=>f.samples[1].cpuSeconds=0],['elapsed-rollback',f=>f.samples[1].atSeconds=0],['clock-rollback',f=>f.samples[1].at=f.samples[0].at],['negative-memory',f=>f.samples[1].privateBytes=-1],['unsafe-bytes',f=>f.samples[1].privateBytes=Number.MAX_SAFE_INTEGER+1],['start-order',f=>f.meta.startedAt=f.meta.completedAt],['end-order',f=>f.meta.completedAt=f.meta.startedAt],['one-sample',f=>f.samples=f.samples.slice(0,1)],['forged-summary',f=>f.meta.resources.cpuAveragePercent=0],['forged-pass',f=>f.meta.passed=false]];
+ for(const [name,mutate] of cases)await t.test(name,async()=>{const f=fixture();mutate(f);const r=await check(f);assert.equal(r.evidenceValid,false);assert.equal(r.compatible,false);assert.equal(evidenceExitCode(r),1);});
+});
+test('unknown fields and file errors never disclose messages, credentials or input paths',async()=>{
+ const secret='SHOULD-NOT-LEAK-SECRET';const f=fixture();f.meta.password=secret;f.meta.logCounts.message=secret;f.samples[0].body=secret;f.model.before.account=secret;f.model.api=secret;f.model.window.message=secret;
+ const input=await save(f),r=await checkEvidence(input);assert.equal(r.compatible,true);assert.ok(!JSON.stringify(r).includes(secret));assert.ok(!JSON.stringify(r).includes(root));assert.equal(Object.hasOwn(r.resource,'password'),false);
+ const failed=await checkEvidence({resource:path.join(root,secret),model:path.join(root,secret)});assert.equal(failed.evidenceValid,false);assert.ok(!JSON.stringify(failed).includes(secret));
+});
+test('JSON and JSONL bounds, blank lines, BOM and final unterminated line',async t=>{
+ for(const [name,replace,expected] of [['huge-json',async input=>writeFile(input.model,' '.repeat(1024*1024+1)),false],['huge-line',async input=>writeFile(path.join(input.resource,'samples.jsonl'),'x'.repeat(4097)),false],['blank-line',async input=>writeFile(path.join(input.resource,'samples.jsonl'),'\n'),false],['invalid-json',async input=>writeFile(input.model,'{"private":"SECRET"'),false],['bom-final-line',async input=>{const b=await readFile(input.model);await writeFile(input.model,Buffer.concat([Buffer.from([239,187,191]),b]));const s=await readFile(path.join(input.resource,'samples.jsonl'),'utf8');await writeFile(path.join(input.resource,'samples.jsonl'),s.trimEnd());},true]])await t.test(name,async()=>{const input=await save(fixture());await replace(input);const r=await checkEvidence(input);assert.equal(r.evidenceValid,expected);});
+});
+test('exclusive output refuses source, existing file and symbolic-link targets',async()=>{
+ const input=await save(fixture()),r=await checkEvidence(input),output=path.join(input.resource,'checked.json');await writeEvidence(output,r);assert.deepEqual(JSON.parse(await readFile(output,'utf8')),r);
+ const original=await readFile(input.model);await assert.rejects(writeEvidence(input.model,r));assert.deepEqual(await readFile(input.model),original);await assert.rejects(writeEvidence(output,r));
+ const link=path.join(input.resource,'linked.json');
+ // File symlinks may be unavailable without Windows developer mode. Existing-file guard above always runs.
+ try{await symlink(input.model,link,'file');}catch(e){if(['EPERM','EACCES'].includes(e.code))return;throw e;}
+ await assert.rejects(writeEvidence(link,r));assert.deepEqual(await readFile(input.model),original);
+});
+test('streaming sample count and byte limits are enforced before resource processing',async t=>{
+ for(const [name,padding,count,code] of [['count','',100001,'sample-count-limit'],['bytes','x'.repeat(800),80000,'sample-size-limit']])await t.test(name,async()=>{
+ const f=fixture(),input=await save(f),file=await open(path.join(input.resource,'samples.jsonl'),'w');
+ const line=JSON.stringify({...f.samples[0],ignored:padding})+'\n';
+ try{for(let n=0;n{
+ const input=await save(fixture()),output=path.join(input.resource,'cli.json'),tool=path.join(repo,'scripts','check-observation-evidence.mjs');
+ const run=args=>spawnSync(process.execPath,[tool,...args],{encoding:'utf8'});
+ const args=['--resource',input.resource,'--model',input.model,'--output',output],first=run(args);assert.equal(first.status,0,first.stderr);assert.equal(JSON.parse(first.stdout).compatible,true);assert.equal(run(args).status,1);
+ for(const args of [[],['--model','SECRET'],['--resource',input.resource,'--resource',input.resource],['--unknown','SECRET']]){const r=run(args);assert.equal(r.status,1);assert.ok(!r.stderr.includes('SECRET'));}
+ const f=fixture();f.meta.programUnchanged=false;summarize(f);const bad=await save(f);assert.equal(run(['--resource',bad.resource,'--model',bad.model]).status,2);
+});
+function versionTwo(f){
+ f.model.version=2;
+ const process={processId:f.meta.processId,processStartedAt:stamp(f.meta.processStartedAt).text,programSHA256:f.meta.programSHA256};
+ f.model.before.process=clone(process);f.model.after.process=clone(process);return f;
+}
+test('version two matches exact OS identity with offset and hash-case normalization',async()=>{
+ const f=versionTwo(fixture());f.meta.processStartedAt='2026-10-13T08:00:00+08:00';f.model.before.process.programSHA256='a'.repeat(64);
+ f.model.before.process.path='PRIVATE-PATH';f.model.after.process.key='PRIVATE-KEY';
+ const r=await check(f);assert.equal(r.compatible,true);assert.equal(r.sameProcessVerified,true);assert.equal(r.businessAcceptancePassed,false);assert.deepEqual(r.processComparison,{processIdMatches:true,processStartedAtMatches:true,programSHA256Matches:true});assert.equal(r.model.version,2);assert.ok(!JSON.stringify(r).includes('PRIVATE-'));assert.ok(!r.issues.includes('association:model-report-has-no-process-identity'));
+});
+test('version two detects PID, one-nanosecond creation time and program mismatch',async t=>{
+ for(const [name,change,key] of [['pid',p=>p.processId++,'processIdMatches'],['start',p=>p.processStartedAt='2026-10-13T00:00:00.000000001Z','processStartedAtMatches'],['program',p=>p.programSHA256='B'.repeat(64),'programSHA256Matches']])await t.test(name,async()=>{
+ const f=versionTwo(fixture());change(f.model.before.process);f.model.after.process=clone(f.model.before.process);const r=await check(f);assert.equal(r.evidenceValid,true);assert.equal(r.compatible,false);assert.equal(r.sameProcessVerified,false);assert.equal(r.processComparison[key],false);assert.equal(evidenceExitCode(r),2);assert.ok(r.issues.includes('association:process-identity-mismatch'));
+ });
+});
+test('version two identity is required and must be consistent in both snapshots',async t=>{
+ for(const [name,change] of [['missing',f=>delete f.model.before.process],['zero-pid',f=>f.model.before.process.processId=0],['bad-hash',f=>f.model.before.process.programSHA256='private'],['bad-time',f=>f.model.before.process.processStartedAt='2026-02-31T00:00:00Z'],['future-start',f=>f.model.before.process.processStartedAt='2026-10-13T00:00:02Z'],['restart',f=>f.model.after.process.processId++],['changed-time',f=>f.model.after.process.processStartedAt='2026-10-13T00:00:00.000000001Z'],['changed-hash',f=>f.model.after.process.programSHA256='B'.repeat(64)]])await t.test(name,async()=>{const f=versionTwo(fixture());change(f);const r=await check(f);assert.equal(r.evidenceValid,false);assert.equal(r.compatible,false);assert.equal(r.sameProcessVerified,false);assert.equal(evidenceExitCode(r),1);});
+});
+test('matching identity is distinct from timing, resource thresholds and unstable observations',async t=>{
+ for(const [name,change,identityMatches] of [['short',f=>f.meta.requestedDurationSeconds=10,true],['threshold',f=>f.meta.thresholds.privateGrowthMiB=0.001,true],['no-overlap',f=>{f.model.before.checkedAt='2026-10-13T00:16:00Z';f.model.after.checkedAt='2026-10-13T00:25:00Z';f.model.exportedAt='2026-10-13T00:25:01Z';},true],['identity-changed',f=>{f.meta.identityStable=false;f.meta.completed=false;f.meta.stopReason='process-identity-changed';},false],['program-changed',f=>f.meta.programUnchanged=false,false]])await t.test(name,async()=>{const f=versionTwo(fixture());change(f);summarize(f);const r=await check(f);assert.equal(r.evidenceValid,true);assert.equal(r.compatible,false);assert.equal(r.sameProcessVerified,identityMatches);assert.equal(r.businessAcceptancePassed,false);assert.equal(evidenceExitCode(r),2);});
+});
+test('legacy reports cannot claim process identity from unknown extra fields',async()=>{
+ const f=fixture();f.model.before.process={processId:f.meta.processId,processStartedAt:f.meta.processStartedAt,programSHA256:f.meta.programSHA256};f.model.after.process=clone(f.model.before.process);
+ const r=await check(f);assert.equal(r.evidenceValid,true);assert.equal(r.compatible,true);assert.equal(r.sameProcessVerified,false);assert.equal(r.model.before.process,undefined);assert.ok(r.issues.includes('association:model-report-has-no-process-identity'));
+});
+test.after(async()=>{await writeFile(path.join(root,'result.json'),JSON.stringify({completed:true,scope:'synthetic-offline-boundary-tests',networkCalls:0,servicesStartedOrStopped:false})+'\n');});
diff --git a/scripts/test-onebot.ps1 b/scripts/test-onebot.ps1
new file mode 100644
index 0000000000..53c7b74213
--- /dev/null
+++ b/scripts/test-onebot.ps1
@@ -0,0 +1,80 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+$configRoot = Join-Path $repoRoot '.local/onebot/runtime/config'
+$web = Get-Content -LiteralPath (Join-Path $configRoot 'webui.json') -Raw | ConvertFrom-Json
+$onebot = Get-Content -LiteralPath (Join-Path $configRoot 'onebot11.json') -Raw | ConvertFrom-Json
+$bot = Get-Content -LiteralPath (Join-Path $repoRoot '.local/config.json') -Raw | ConvertFrom-Json
+$accountPath = Join-Path $repoRoot '.local/onebot/account.json'
+$expectedQQ = $null
+if (Test-Path -LiteralPath $accountPath) { $expectedQQ = (Get-Content -LiteralPath $accountPath -Raw | ConvertFrom-Json).expectedQQ }
+if ($web.host -ne '127.0.0.1') { throw 'WebUI is not restricted to loopback.' }
+$server = @($onebot.network.websocketServers | Where-Object { $_.name -eq 'zerobot-local' })
+if ($server.Count -ne 1 -or $server[0].host -ne '127.0.0.1' -or -not $server[0].enable) {
+ throw 'Expected one enabled loopback WebSocket server.'
+}
+$endpoint = 'ws://127.0.0.1:' + $server[0].port
+if (-not @($bot.ws | Where-Object { $_.Url -eq $endpoint -and $_.AccessToken -eq $server[0].token }).Count) {
+ throw 'ZeroBot and NapCat credentials do not match.'
+}
+$baseUrl = 'http://127.0.0.1:' + $web.port
+$page = Invoke-WebRequest -Uri ($baseUrl + '/webui/') -UseBasicParsing -TimeoutSec 5
+if ([int]$page.StatusCode -ne 200) { throw 'NapCat login page is unavailable.' }
+$denied = Invoke-RestMethod -Method Post -Uri ($baseUrl + '/api/QQLogin/CheckLoginStatus') -ContentType 'application/json' -Body '{}' -TimeoutSec 5
+if ($denied.code -eq 0) { throw 'NapCat allowed an unauthenticated status request.' }
+$sha = [Security.Cryptography.SHA256]::Create()
+try {
+ $hash = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($web.token + '.napcat'))).Replace('-', '').ToLowerInvariant()
+} finally { $sha.Dispose() }
+$auth = Invoke-RestMethod -Method Post -Uri ($baseUrl + '/api/auth/login') -ContentType 'application/json' -Body (@{hash = $hash} | ConvertTo-Json) -TimeoutSec 5
+if ($auth.code -ne 0 -or -not $auth.data.Credential) { throw 'NapCat WebUI authentication failed.' }
+$headers = @{Authorization = 'Bearer ' + $auth.data.Credential}
+$ws = $null
+try {
+ $status = Invoke-RestMethod -Method Post -Uri ($baseUrl + '/api/QQLogin/CheckLoginStatus') -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 5
+ if ($status.code -ne 0) { throw 'Authenticated QQ login status request failed.' }
+ $result = [ordered]@{
+ webUI = $baseUrl + '/webui/'; webUIReady = $true
+ unauthenticatedRequestRejected = $true; credentialsMatch = $true
+ qqLoggedIn = [bool]$status.data.isLogin; loginPhase = $status.data.loginPhase
+ onebotEndpoint = $endpoint; onebotReadOnlyAPIsPassed = $false
+ }
+ if ($status.data.isLogin) {
+ $ws = [Net.WebSockets.ClientWebSocket]::new()
+ $ws.Options.SetRequestHeader('Authorization', 'Bearer ' + $server[0].token)
+ $ws.Options.SetRequestHeader('X-Client-Role', 'Universal')
+ $cts = [Threading.CancellationTokenSource]::new(15000)
+ try {
+ $ws.ConnectAsync([uri]$endpoint, $cts.Token).GetAwaiter().GetResult() | Out-Null
+ foreach ($action in @('get_login_info','get_status','get_version_info')) {
+ $echo = 'local-check-' + $action
+ $payload = [Text.Encoding]::UTF8.GetBytes((@{action=$action;params=@{};echo=$echo} | ConvertTo-Json -Compress))
+ $ws.SendAsync([ArraySegment[byte]]::new($payload), [Net.WebSockets.WebSocketMessageType]::Text, $true, $cts.Token).GetAwaiter().GetResult() | Out-Null
+ do {
+ $buffer = New-Object byte[] 8192
+ $message = [IO.MemoryStream]::new()
+ try {
+ do {
+ $received = $ws.ReceiveAsync([ArraySegment[byte]]::new($buffer), $cts.Token).GetAwaiter().GetResult()
+ if ($received.MessageType -eq [Net.WebSockets.WebSocketMessageType]::Close) { throw 'OneBot closed the connection.' }
+ $message.Write($buffer,0,$received.Count)
+ } while (-not $received.EndOfMessage)
+ $reply = [Text.Encoding]::UTF8.GetString($message.ToArray()) | ConvertFrom-Json
+ } finally { $message.Dispose() }
+ } while ($reply.echo -ne $echo)
+ if ($reply.status -ne 'ok' -or $reply.retcode -ne 0) { throw "OneBot read-only API failed: $action" }
+ if ($action -eq 'get_login_info' -and $expectedQQ -and [string]$reply.data.user_id -ne [string]$expectedQQ) {
+ throw 'Logged-in QQ does not match .local/onebot/account.json; refusing to use this account.'
+ }
+ if ($action -eq 'get_status' -and -not $reply.data.online) { throw 'OneBot reports QQ offline.' }
+ }
+ $result.onebotReadOnlyAPIsPassed = $true
+ } finally { $cts.Dispose() }
+ }
+ $result | ConvertTo-Json
+} finally {
+ if ($ws) { $ws.Dispose() }
+ Invoke-RestMethod -Method Post -Uri ($baseUrl + '/api/auth/logout') -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 5 | Out-Null
+}
diff --git a/scripts/test-overview-ui.mjs b/scripts/test-overview-ui.mjs
new file mode 100644
index 0000000000..e88bd8fe23
--- /dev/null
+++ b/scripts/test-overview-ui.mjs
@@ -0,0 +1,140 @@
+// Tests the real overview renderer/controller with an offline DOM and clock.
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import test from 'node:test';
+class Element {
+ constructor(){this.children=[];this.listeners=new Map();this.classes=new Set();this.value='';this.hidden=false;this._text='';this.dataset={};}
+ get textContent(){return this._text;}
+ set textContent(value){this._text=String(value);this.children=[];}
+ set innerHTML(value){throw new Error('Overview must render text safely');}
+ get classList(){return {add:name=>this.classes.add(name),toggle:(name,on)=>on?this.classes.add(name):this.classes.delete(name)};}
+ append(...items){this.children.push(...items);}
+ replaceChildren(...items){this.children=[...items];}
+ // The overview fixture contains no plugin cards or their draft buttons.
+ querySelectorAll(){return [];}
+ addEventListener(name,callback){this.listeners.set(name,callback);}
+ dispatch(name){return this.listeners.get(name)?.({preventDefault(){}});}
+}
+const app=fs.readFileSync(new URL('../internal/localadmin/assets/app.js',import.meta.url),'utf8');
+const controller=fs.readFileSync(new URL('../internal/localadmin/assets/overview.js',import.meta.url),'utf8');
+function harness(){
+ const elements=new Map(),requests=[],timers=new Map();let timer=0;
+ const get=id=>{if(!elements.has(id))elements.set(id,new Element());return elements.get(id);};
+ const document=new Element();document.hidden=false;document.createElement=()=>new Element();
+ const context=vm.createContext({document,$:get,authenticated:true,AbortController,duration:seconds=>String(seconds),
+ setTimeout:(callback,delay)=>{timers.set(++timer,{callback,delay});return timer;},clearTimeout:id=>timers.delete(id),
+ showError:(id,error)=>{get(id).textContent=error.message;get(id).hidden=false;},
+ api:(path,body,signal)=>new Promise((resolve,reject)=>{const r={path,signal,resolve,reject};requests.push(r);signal.addEventListener('abort',()=>reject(Object.assign(new Error('aborted'),{name:'AbortError'})),{once:true});})});
+ vm.runInContext(app.match(/function renderOverview\(value\) \{[\s\S]*?\n\}(?=\r?\nfunction renderPlugins)/)[0],context);
+ vm.runInContext(controller,context);
+ return {get,requests,timers,document,context,run:code=>vm.runInContext(code,context)};
+}
+const flush=()=>new Promise(resolve=>setImmediate(resolve));
+const row=(message='test warning')=>({time:'2026-10-03T02:00:00Z',level:'warning',message});
+const value=(alerts={warningCount:1,errorCount:0,entries:[row()],evicted:false})=>({botAccounts:['87654321'],pluginCount:108,uptimeSeconds:60,version:'fixture',goVersion:'fixture',configuredConnections:1,checkedAt:'2026-10-03T02:00:00Z',connectionState:'connected',alerts});
+const modelStats=(patch={})=>{
+ const zero={started:0,active:0,completed:0,failed:0,incomplete:0,averageMilliseconds:0,maxMilliseconds:0};
+ return {startedAt:'2026-10-13T01:00:00Z',checkedAt:'2026-10-13T01:05:00Z',...zero,categories:['gpt','chat','agent','vision'].map(kind=>({kind,...zero})),...patch};
+};
+
+test('model statistics show zero requests and fixed categories without an inferred success',()=>{
+ const h=harness();h.context.stats=modelStats();h.run('renderModelRequests(stats)');
+ assert.match(h.get('model-request-summary').textContent,/尚无模型接口请求/);
+ assert.equal(h.get('model-request-categories').children.length,4);
+ assert.equal(h.get('model-request-categories').children[0].children[0].textContent,'GPT / 群总结');
+ assert.match(h.get('model-request-categories').children[0].children[2].textContent,/耗时待统计/);
+});
+test('model statistics distinguish active completed failed and early closed exchanges and durations',()=>{
+ const h=harness(),stats=modelStats({started:5,active:1,completed:2,failed:1,incomplete:1,averageMilliseconds:1250,maxMilliseconds:2000});
+ stats.categories[0]={...stats.categories[0],started:5,active:1,completed:2,failed:1,incomplete:1,averageMilliseconds:1250,maxMilliseconds:2000};
+ h.context.stats=stats;h.run('renderModelRequests(stats)');const text=h.get('model-request-summary').textContent;
+ assert.match(text,/发起 5 次;进行中 1 次;接口完成 2 次;失败 1 次;提前关闭 1 次/);assert.match(text,/平均 1.250 秒,最长 2.000 秒/);
+});
+test('unavailable and malformed model statistics clear prior values without rendering untrusted category text',()=>{
+ const h=harness();for(const malformed of [null,undefined,modelStats({started:-1}),modelStats({started:NaN}),modelStats({maxMilliseconds:Infinity}),modelStats({startedAt:''}),modelStats({categories:[null,null,null,null]}),modelStats({categories:Array(4).fill({...modelStats().categories[0],kind:'
'})}),modelStats({started:1}),modelStats({averageMilliseconds:3,maxMilliseconds:2})]){
+ h.context.stats=modelStats();h.run('renderModelRequests(stats)');h.context.stats=malformed;h.run('renderModelRequests(stats)');assert.equal(h.get('model-request-categories').children.length,0);assert.match(h.get('model-request-summary').textContent,/暂不可读取/);
+ }
+});
+test('model statistics follow existing request cancellation and stale state clearing',async()=>{
+ const h=harness();h.run('showOverview(true)');h.requests[0].resolve({...value(),modelRequests:modelStats()});await flush();assert.equal(h.get('model-request-categories').children.length,4);
+ h.run('showOverview(false)');assert.equal(h.get('model-request-categories').children.length,0);assert.match(h.get('model-request-summary').textContent,/待更新/);
+ h.run('showOverview(true)');h.requests[1].resolve({...value(),modelRequests:modelStats()});h.run('authenticated=false;resetOverview()');await flush();assert.equal(h.get('model-request-categories').children.length,0);
+});
+test('new process statistics replace old totals and a refresh error removes stale model data',async()=>{
+ const h=harness();h.run('showOverview(true)');const old=modelStats({started:1,completed:1,averageMilliseconds:1,maxMilliseconds:1});old.categories[0]={...old.categories[0],started:1,completed:1,averageMilliseconds:1,maxMilliseconds:1};
+ h.requests[0].resolve({...value(),modelRequests:old});await flush();assert.match(h.get('model-request-summary').textContent,/发起 1 次/);
+ h.run('refreshOverview()');h.requests[1].resolve({...value(),modelRequests:modelStats({startedAt:'2026-10-13T02:00:00Z'})});await flush();assert.match(h.get('model-request-summary').textContent,/发起 0 次/);
+ h.run('refreshOverview()');h.requests[2].reject(new Error('fixture'));await flush();assert.equal(h.get('model-request-categories').children.length,0);
+});
+
+test('overview renders literal alert text and distinguishes unavailable from no alerts',()=>{
+ const h=harness(),payload='
';
+ h.context.value=value({warningCount:3,errorCount:2,entries:[row(payload)],evicted:true});h.run('renderOverview(value)');
+ assert.equal(h.get('overview-alerts').children[0].children[1].textContent,payload);
+ assert.match(h.get('overview-alert-summary').textContent,/警告 3 条,错误 2 条/);
+ assert.match(h.get('overview-alert-summary').textContent,/已淘汰/);
+ h.context.value=value(null);h.run('renderOverview(value)');
+ assert.equal(h.get('overview-alerts').children.length,0);assert.match(h.get('overview-alert-summary').textContent,/暂不可读取/);
+ assert.equal(h.get('connection-badge').textContent,'已连接');assert.equal(h.get('accounts').children.length,1);
+ h.context.value=value({warningCount:0,errorCount:0,entries:[],evicted:false});h.run('renderOverview(value)');
+ assert.match(h.get('overview-alerts').children[0].textContent,/暂无警告或错误/);
+ h.context.value={...value(),connectionState:'waiting',botAccounts:[]};h.run('renderOverview(value)');
+ assert.equal(h.get('connection-badge').textContent,'等待连接');assert.equal(h.get('account-count').textContent,'0');
+});
+test('polling cancels on navigation, hiding and logout and refuses stale completion',async()=>{
+ const h=harness();h.run('showOverview(true)');assert.equal(h.requests.length,1);
+ h.requests[0].resolve(value());h.run('showOverview(false)');await flush();
+ assert.equal(h.get('accounts').children.length,0);assert.equal(h.timers.size,0);
+ h.run('showOverview(true)');h.document.hidden=true;h.document.dispatch('visibilitychange');
+ assert.equal(h.requests[1].signal.aborted,true);await flush();assert.equal(h.timers.size,0);
+ h.document.hidden=false;h.document.dispatch('visibilitychange');assert.equal(h.requests.length,3);
+ h.requests[2].resolve(value());h.run('authenticated=false;resetOverview()');await flush();
+ assert.equal(h.get('overview-alerts').children.length,0);assert.equal(h.get('accounts').children.length,0);assert.equal(h.timers.size,0);
+ h.run('authenticated=true;showOverview(true)');h.requests[3].resolve(value());await flush();
+ assert.equal(h.get('account-count').textContent,'1');assert.equal([...h.timers.values()][0].delay,10000);
+});
+test('manual and timed refresh do not overlap; failures clear old online status and recover',async()=>{
+ const h=harness();h.run('showOverview(true);refreshOverview();refreshOverview()');assert.equal(h.requests.length,1);
+ h.requests[0].resolve(value());await flush();assert.equal(h.get('connection-badge').textContent,'已连接');
+ const tick=[...h.timers.values()][0];h.timers.clear();tick.callback();h.run('refreshOverview()');assert.equal(h.requests.length,2);
+ h.requests[1].reject(new Error('offline fixture'));await flush();
+ assert.equal(h.get('connection-badge').textContent,'无法获取状态');assert.equal(h.get('accounts').children.length,0);
+ assert.equal(h.get('checked-at').textContent,'—');assert.equal(h.get('account-count').textContent,'—');assert.equal(h.get('page-error').hidden,false);
+ assert.equal(h.timers.size,1);h.run('refreshOverview()');assert.equal(h.timers.size,0);
+ h.requests[2].resolve(value());await flush();assert.equal(h.get('page-error').hidden,true);assert.equal(h.get('connection-badge').textContent,'已连接');
+ h.run('showOverview(false)');assert.equal(h.timers.size,0);
+ assert.equal(h.get('page-error').hidden,true);
+});
+
+test('an aborted response body cannot expire a newer authenticated UI session',async()=>{
+ const h=harness();h.document.getElementById=h.get;h.document.querySelectorAll=()=>[];
+ let resolveBody;const body=new Promise(resolve=>{resolveBody=resolve;});
+ const c=vm.createContext({document:h.document,window:new Element(),AbortController,fetch:async()=>({ok:false,status:401,json:()=>body})});
+ vm.runInContext(app,c);
+ vm.runInContext('authenticated=true;const oldController=new AbortController();var pending=api("/api/overview",undefined,oldController.signal).catch(error=>error.name);',c);
+ await flush();vm.runInContext('oldController.abort()',c);resolveBody({error:'expired old session'});
+ assert.equal(await c.pending,'AbortError');assert.equal(vm.runInContext('authenticated',c),true);
+});
+test('full app login, navigation and logout keep plugin failures separate from connection health',async()=>{
+ const h=harness(),nav=new Element();nav.dataset.view='overview';
+ const document=h.document;document.getElementById=h.get;document.querySelector=()=>nav;document.querySelectorAll=selector=>selector==='[data-view]'?[nav]:[];
+ const fetches=[];
+ h.context.fetch=(path,options)=>new Promise(resolve=>fetches.push({path,options,resolve}));
+ // Evaluate app first in a new context, then the overview controller, as in HTML.
+ const c=vm.createContext({document,window:Object.assign(new Element(),{getSelection:()=>null}),AbortController,$:h.get,fetch:h.context.fetch,
+ setTimeout:h.context.setTimeout,clearTimeout:h.context.clearTimeout});
+ vm.runInContext(app,c);vm.runInContext(controller,c);
+ document.dispatch('DOMContentLoaded');
+ fetches[0].resolve({ok:true,json:async()=>({authenticated:true,csrfToken:'fixture'})});await flush();
+ const overview=fetches.find(r=>r.path==='/api/overview'),plugins=fetches.find(r=>r.path.startsWith('/api/plugins'));
+ assert.ok(overview&&plugins);
+ plugins.resolve({ok:false,status:500,json:async()=>({error:'plugin fixture failed'})});
+ overview.resolve({ok:true,json:async()=>value()});await flush();
+ assert.equal(h.get('connection-badge').textContent,'已连接');assert.equal(h.get('page-error').hidden,true);
+ assert.equal(h.get('plugin-error').textContent,'plugin fixture failed');
+ nav.dataset.view='plugins';nav.dispatch('click');assert.equal(h.get('connection-badge').textContent,'状态待更新');
+ assert.equal(h.timers.size,0);
+ h.get('logout').dispatch('click');await flush();const logout=fetches.find(r=>r.path==='/api/logout');assert.ok(logout);logout.resolve({ok:true,json:async()=>({})});await flush();
+ assert.equal(h.get('workspace').hidden,true);assert.equal(h.get('auth').hidden,false);assert.equal(h.timers.size,0);
+});
diff --git a/scripts/test-plugin-catalog-ui.mjs b/scripts/test-plugin-catalog-ui.mjs
new file mode 100644
index 0000000000..8cce72d98d
--- /dev/null
+++ b/scripts/test-plugin-catalog-ui.mjs
@@ -0,0 +1,75 @@
+// Offline Chrome synthetic plugin metadata and mocked APIs; no live site, QQ or configuration mutations.
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {createRequire} from 'node:module';
+import test, {after} from 'node:test';
+const require=createRequire(import.meta.url);
+const modules=process.env.LOCALADMIN_NODE_MODULES || path.join(process.env.USERPROFILE,'.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
+const {chromium}=require(path.join(modules,'playwright'));
+const html=(await fs.readFile('internal/localadmin/assets/index.html','utf8')).replace(/';await read(p,value);assert.doesNotMatch(await p.locator('#reply-task-summary').textContent(),/PRIVATE/);assert.doesNotMatch(await p.locator('#reply-task-categories').textContent(),/PRIVATE/);const fresh=stats();fresh.startedAt='2026-10-13T05:00:59Z';await read(p,fresh);assert.match(await p.locator('#reply-task-summary').textContent(),/任务 0 次/);assert.doesNotMatch(await p.locator('#reply-task-summary').textContent(),/任务 5 次/);
+});
+test('pending reads retain last known values; failures and lifecycle changes clear metrics and isolate late results',async t=>{
+ const p=await fixture(t);await read(p,stats(busy));await p.evaluate(()=>mode='pending');await p.locator('#refresh').click();await p.waitForFunction(()=>pending.length===1);assert.match(await p.locator('#reply-task-summary').textContent(),/任务 5 次/);assert.equal(await p.evaluate(()=>document.querySelector('#refresh').disabled),true);await p.evaluate(()=>showOverview(false));await p.evaluate(()=>pending[0]({ok:true,json:async()=>structuredClone(payload)}));await p.waitForTimeout(25);assert.equal(await p.locator('#reply-task-categories article').count(),0);await read(p,stats(busy));await p.evaluate(()=>mode='failure');await p.locator('#refresh').click();await p.waitForFunction(()=>!overviewBusy);assert.equal(await p.locator('#reply-task-categories article').count(),0);await read(p,stats(busy));await p.evaluate(()=>{Object.defineProperty(document,'hidden',{value:true,configurable:true});document.dispatchEvent(new Event('visibilitychange'))});assert.equal(await p.locator('#reply-task-categories article').count(),0);await p.evaluate(()=>{Object.defineProperty(document,'hidden',{value:false,configurable:true});authenticated=false;csrf='';showAuth()});assert.equal(await p.locator('#reply-task-categories article').count(),0);
+});
+test('reply panel and long timing text fit four widths and both themes',async t=>{
+ const p=await fixture(t);await read(p,stats(busy));await p.evaluate(()=>document.querySelector('#reply-task-summary').textContent+=' 首条和最后一条平均 123456789.123 秒,最长 123456789.123 秒。任务 9007199254740000 次。');for(const width of [320,375,640,1280]){await p.setViewportSize({width,height:1000});for(const mode of ['light','dark']){await p.evaluate(mode=>document.documentElement.dataset.theme=mode,mode);const shape=await p.locator('[aria-labelledby="reply-task-title"]').evaluate(el=>({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,panelWidth:el.clientWidth,panelScroll:el.scrollWidth}));assert.ok(shape.scrollWidth<=width+1&&shape.panelScroll<=shape.panelWidth+1,JSON.stringify(shape));}}
+});
diff --git a/scripts/test-reply-snapshot-ui.mjs b/scripts/test-reply-snapshot-ui.mjs
new file mode 100644
index 0000000000..dd2871caaf
--- /dev/null
+++ b/scripts/test-reply-snapshot-ui.mjs
@@ -0,0 +1,55 @@
+// Independent offline Chrome, synthetic snapshots and actual test downloads.
+import test,{after} from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import {createRequire} from 'node:module';
+const require=createRequire(import.meta.url),modules=process.env.LOCALADMIN_NODE_MODULES||path.join(process.env.USERPROFILE,'.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules');
+const {chromium}=require(path.join(modules,'playwright'));
+const browser=await chromium.launch({channel:'chrome',headless:true});after(()=>browser.close());
+const html=(await fs.readFile('internal/localadmin/assets/index.html','utf8')).replace(/';await p.evaluate(d=>renderConnectionSettings(d),d);assert.equal(await p.locator('#startup-difference-list img,#startup-difference-list script').count(),0);assert.match(await text(p),/
{delete d.startupDifferences;renderConnectionSettings(d);},data());assert.match(await p.locator('#startup-difference-status').textContent(),/暂无分类摘要/);await p.evaluate(()=>renderConnectionSettings({available:false,connections:[],superUsers:[]}));assert.equal(await p.locator('#startup-differences').isHidden(),true);assert.equal(await text(p),'');assert.equal(await p.evaluate(()=>requests.length),0);
+});
+
+test('draft edits and navigation do not change saved summary; failed reload retains last result and successful reload updates it',async t=>{
+ const p=await fixture(t);const original=await text(p);await p.locator('#connection-url').fill('ws://localhost:19000');await p.selectOption('#token-action','replace');await p.locator('#connection-token').fill('private-draft-token');await p.locator('#connection-owners').fill('87654321');assert.equal(await text(p),original);assert.doesNotMatch(await text(p),/19000|private-draft-token|87654321/);await p.locator('[data-view=overview]').click();await p.locator('[data-view=connections]').click();assert.equal(await text(p),original);assert.equal(await p.evaluate(()=>requests.length),0);
+ await p.evaluate(()=>replies.push({error:'读取失败',status:500}));await p.locator('#reload-connections').click();await p.locator('#connection-draft-confirm-accept').click();await idle(p);assert.equal(await text(p),original);assert.equal(await p.locator('#connection-token').inputValue(),'private-draft-token');assert.match(await p.locator('#connection-save-state').textContent(),/读取失败/);
+ await p.evaluate(d=>replies.push(d),changed());await p.locator('#reload-connections').click();await p.locator('#connection-draft-confirm-accept').click();await idle(p);assert.match(await text(p),/数量相同,内容不同/);assert.equal(await p.locator('#connection-token').inputValue(),'');assert.match(await p.locator('#startup-difference-status').textContent(),/待重启/);
+});
+
+test('failed save retains summary and draft; successful edit, addition and deletion refresh it from server snapshots',async t=>{
+ const p=await fixture(t);await p.locator('#connection-owners').fill('12345678, 23456789');const original=await text(p);await p.evaluate(()=>replies.push({error:'保存失败',status:500}));await p.locator('#save-connections').click();await idle(p);assert.equal(await text(p),original);assert.equal(await p.locator('#connection-owners').inputValue(),'12345678, 23456789');
+ const owner=data();owner.superUsers.push('23456789');owner.revision='owners';owner.backupId='owner-backup';owner.restartRequired=true;owner.startupDifferences[2].changed=true;owner.startupDifferences[2].savedCount=2;await p.evaluate(d=>replies.push(d),owner);await p.locator('#save-connections').click();await idle(p);assert.match(await text(p),/本次启动 1个账号 → 已保存 2个账号/);
+ const added=structuredClone(owner);added.revision='added';added.backupId='add-backup';added.connections.push({kind:'ws',index:1,url:'ws://localhost:17777',tokenConfigured:true});added.startupDifferences[0].changed=true;added.startupDifferences[0].savedCount=2;await p.locator('#connection-add-url').fill('ws://localhost:17777');await p.locator('#connection-add-token').fill('private-add-token');await p.evaluate(d=>replies.push(d),added);await p.locator('#connection-add-save').click();await p.locator('#connection-add-confirm-accept').click();await idle(p);assert.match(await text(p),/本次启动 1条连接 → 已保存 2条连接/);assert.match(await p.locator('#startup-difference-status').textContent(),/2 个类别/);
+ const removed=structuredClone(owner);removed.revision='removed';removed.backupId='remove-backup';await p.evaluate(d=>replies.push(d),removed);await p.locator('#preview-connection-delete').click();await p.locator('#connection-delete-confirm').check();await p.locator('#connection-delete-save').click();await idle(p);assert.match(await p.locator('#startup-difference-status').textContent(),/1 个类别/);assert.doesNotMatch(await text(p),/private-add-token/);
+});
+
+test('expiry clears summary and late reads cannot overwrite a new authenticated session',async t=>{
+ const p=await fixture(t);await p.locator('#reload-connections').click();await p.waitForFunction(()=>pending.length===1);await p.evaluate(()=>{authenticated=false;csrf='';showAuth();});assert.equal(await p.locator('#startup-differences').isHidden(),true);assert.equal(await text(p),'');
+ await p.evaluate(d=>{authenticated=true;csrf='new-session';document.getElementById('auth').hidden=true;document.getElementById('workspace').hidden=false;renderConnectionSettings(d);connectionControls(false);},changed());await p.locator('#reload-connections').click();await p.waitForFunction(()=>pending.length===2);await p.evaluate(d=>pending[0]({ok:true,status:200,json:async()=>d}),data());await p.waitForTimeout(80);assert.match(await p.locator('#startup-difference-status').textContent(),/待重启/);assert.equal(await p.evaluate(()=>connectionBusy),true);
+ await p.evaluate(()=>pending[1]({ok:false,status:401,json:async()=>({error:'expired'})}));await p.waitForFunction(()=>!authenticated);assert.equal(await text(p),'');assert.equal(await p.locator('#startup-differences').isHidden(),true);
+});
diff --git a/scripts/test-theme-ui.mjs b/scripts/test-theme-ui.mjs
new file mode 100644
index 0000000000..8f96dda2c8
--- /dev/null
+++ b/scripts/test-theme-ui.mjs
@@ -0,0 +1,62 @@
+// Appearance behavior and palette checks; no browser session or credentials.
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+import test from 'node:test';
+
+const key='zerobot.localadmin.theme.v1';
+const script=fs.readFileSync(new URL('../internal/localadmin/assets/theme.js',import.meta.url),'utf8');
+function harness({stored=null,dark=false,blocked=false,loading=true,noMedia=false}={}) {
+ const events=new Map(),documentEvents=new Map(),writes=[],controls=[0,1].map(()=>({value:'',listener:null,addEventListener(name,listener){this.listener=listener;}}));
+ let mediaListener;
+ const media={matches:dark,addEventListener(name,listener){mediaListener=listener;}};
+ const document={readyState:loading?'loading':'complete',documentElement:{dataset:{}},querySelectorAll:()=>loading?[]:controls,
+ addEventListener:(name,listener)=>documentEvents.set(name,listener)};
+ const window={localStorage:{getItem:()=>{if (blocked) throw new Error('blocked'); return stored;},setItem:(name,value)=>{if (blocked) throw new Error('blocked'); writes.push([name,value]); stored=value;}},
+ matchMedia:()=>{if (noMedia) throw new Error('unsupported'); return media;},addEventListener:(name,listener)=>events.set(name,listener)};
+ vm.runInNewContext(script,{window,document});
+ return {controls,writes,root:document.documentElement,
+ ready(){loading=false; documentEvents.get('DOMContentLoaded')?.();},
+ select(value,index=0){controls[index].value=value; controls[index].listener?.();},
+ system(dark){media.matches=dark; mediaListener?.();},
+ storage(value,eventKey=key){stored=value; events.get('storage')?.({key:eventKey});}};
+}
+test('system follows OS before controls load; explicit choice survives reload and ignores OS changes',()=>{
+ const h=harness({dark:true}); assert.equal(h.root.dataset.theme,'dark'); h.ready();
+ assert.deepEqual(h.controls.map(c=>c.value),['system','system']);
+ h.system(false); assert.equal(h.root.dataset.theme,'light');
+ h.select('dark'); h.system(false); assert.equal(h.root.dataset.theme,'dark');
+ assert.deepEqual(h.controls.map(c=>c.value),['dark','dark']);
+ assert.deepEqual(h.writes,[[key,'dark']]);
+ const reopened=harness({stored:'dark',dark:false}); assert.equal(reopened.root.dataset.theme,'dark');
+ h.select('system',1); assert.equal(h.root.dataset.theme,'light'); h.system(true); assert.equal(h.root.dataset.theme,'dark');
+});
+test('other tabs and clearing storage update both controls without writing credentials or echoing invalid values',()=>{
+ const h=harness({stored:'light',dark:true,loading:false});
+ h.storage('dark'); assert.equal(h.root.dataset.theme,'dark');
+ assert.deepEqual(h.controls.map(c=>c.value),['dark','dark']); assert.equal(h.writes.length,0);
+ h.storage(''); assert.equal(h.root.dataset.theme,'dark');
+ assert.deepEqual(h.controls.map(c=>c.value),['system','system']);
+ h.select('light'); h.storage(null,null); assert.equal(h.root.dataset.theme,'dark');
+ h.storage('light','unrelated-account-setting'); assert.equal(h.root.dataset.theme,'dark');
+ h.select('unknown'); assert.equal(h.root.dataset.theme,'dark'); assert.equal(h.writes.length,1);
+});
+test('blocked storage and unavailable matchMedia leave appearance controls usable',()=>{
+ const h=harness({blocked:true,noMedia:true,loading:false}); assert.equal(h.root.dataset.theme,'light');
+ h.select('dark'); assert.equal(h.root.dataset.theme,'dark');
+ assert.deepEqual(h.controls.map(c=>c.value),['dark','dark']); assert.equal(h.writes.length,0);
+});
+
+const css=fs.readFileSync(new URL('../internal/localadmin/assets/appearance.css',import.meta.url),'utf8');
+function palette(block) {return Object.fromEntries([...block.matchAll(/--([\w-]+):\s*(#[\da-f]{6})/gi)].map(match=>[match[1],match[2]]));}
+function luminance(hex) {const rgb=[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)/255).map(c=>c<=.04045?c/12.92:((c+.055)/1.055)**2.4); return rgb[0]*.2126+rgb[1]*.7152+rgb[2]*.0722;}
+function contrast(a,b) {const [lo,hi]=[luminance(a),luminance(b)].sort((a,b)=>a-b); return (hi+.05)/(lo+.05);}
+test('both palettes keep normal, muted, action and warning/error text legible',()=>{
+ const light=palette(css.match(/:root\s*\{([^}]+)\}/)[1]);
+ const dark={...light,...palette(css.match(/:root\[data-theme="dark"\]\s*\{([^}]+)\}/)[1])};
+ for (const colors of [light,dark]) {
+ for (const [fg,bg] of [['text','page'],['text','surface'],['muted','surface'],['muted','surface-alt'],['accent','surface-alt'],['primary-text','green'],['primary-text','primary-hover'],['soft-text','soft'],['error-text','error-bg'],['warning-text','warning-bg']]) {
+ assert.ok(contrast(colors[fg],colors[bg])>=4.5,`${fg}/${bg} has insufficient contrast`);
+ }
+ }
+});
diff --git a/scripts/windows-service-identity.ps1 b/scripts/windows-service-identity.ps1
new file mode 100644
index 0000000000..9165dd2984
--- /dev/null
+++ b/scripts/windows-service-identity.ps1
@@ -0,0 +1,48 @@
+# Resolve parent directory junctions as well as the final file's link.
+function Resolve-LocalServicePath {
+ param([Parameter(Mandatory=$true)][string]$Path,[int]$Depth=0)
+ if($Depth -gt 16){throw 'Too many service path links.'}
+ $full=[IO.Path]::GetFullPath($Path)
+ $root=[IO.Path]::GetPathRoot($full)
+ $current=$root
+ foreach($part in ($full.Substring($root.Length) -split '[\\/]' | Where-Object {$_ -ne ''})){
+ $current=Join-Path $current $part
+ if(Test-Path -LiteralPath $current){
+ $item=Get-Item -LiteralPath $current -Force -ErrorAction Stop
+ if($item.Attributes -band [IO.FileAttributes]::ReparsePoint){
+ $targets=@($item.Target)
+ if($targets.Count -ne 1 -or -not $targets[0]){throw 'Service path link target cannot be verified.'}
+ $target=[string]$targets[0]
+ if(-not [IO.Path]::IsPathRooted($target)){$target=Join-Path (Split-Path -Parent $current) $target}
+ $current=Resolve-LocalServicePath -Path $target -Depth ($Depth+1)
+ }
+ }
+ }
+ return $current
+}
+
+function Test-LocalServiceTask {
+ param($Task,[string]$RepoRoot,[string]$Service)
+ $actions=@($Task.Actions)
+ if($actions.Count -ne 1){return $false}
+ $action=$actions[0]
+ if($action.Arguments -notmatch '^\-NoProfile \-WindowStyle Hidden \-File "([^"]+)" \-Service (OneBot|ZeroBot)$'){return $false}
+ $script=$Matches[1];$kind=$Matches[2]
+ if($kind -ne $Service){return $false}
+ $pwsh=(Get-Command pwsh.exe -ErrorAction SilentlyContinue).Source
+ $shells=@((Join-Path $env:WINDIR 'System32/WindowsPowerShell/v1.0/powershell.exe'))
+ if($pwsh){$shells+=$pwsh}
+ $shells=@($shells | ForEach-Object {Resolve-LocalServicePath $_})
+ if((Resolve-LocalServicePath $action.Execute) -notin $shells){return $false}
+ return ((Resolve-LocalServicePath $script) -eq (Join-Path $RepoRoot 'scripts/service-host.ps1') -and
+ $action.WorkingDirectory -and (Resolve-LocalServicePath $action.WorkingDirectory) -eq $RepoRoot)
+}
+
+function Test-LocalServiceProcess {
+ param($State,$Process,[string]$ExpectedExecutable)
+ if($State.managedBy -ne 'Windows Task Scheduler' -or $State.processId -ne $Process.Id){return $false}
+ if(-not $Process.Path -or (Resolve-LocalServicePath $State.executable) -ne $ExpectedExecutable -or
+ (Resolve-LocalServicePath $Process.Path) -ne $ExpectedExecutable){return $false}
+ if($State.processStartedAt){return $Process.StartTime.ToUniversalTime() -eq ([datetime]$State.processStartedAt).ToUniversalTime()}
+ return [math]::Abs(($Process.StartTime.ToUniversalTime()-([datetime]$State.startedAt).ToUniversalTime()).TotalSeconds) -le 5
+}
diff --git a/third_party/go-onebot-agent/LICENSE b/third_party/go-onebot-agent/LICENSE
new file mode 100644
index 0000000000..29ebfa545f
--- /dev/null
+++ b/third_party/go-onebot-agent/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
\ No newline at end of file
diff --git a/third_party/go-onebot-agent/LOCAL_PATCH.md b/third_party/go-onebot-agent/LOCAL_PATCH.md
new file mode 100644
index 0000000000..e96cf47f58
--- /dev/null
+++ b/third_party/go-onebot-agent/LOCAL_PATCH.md
@@ -0,0 +1,14 @@
+# Local Agent history patch
+
+Baseline: `github.com/fumiama/go-onebot-agent` at `9c92a03776e3` (2026-04-25).
+The original LICENSE and embedded prompts/permissions are retained.
+
+An optional `HistoryStore` receives events, action attempts, results and end
+markers. When enabled, prompt reconstruction reads that store instead of the
+upstream 16-batch/8-item in-memory ring. Unmodified users retain the original
+ring. Record methods return errors so execution can fail before an unrecorded
+action. The image preprocessing hook also applies to restored typed events.
+
+The application supplies private SQLite storage, conversation isolation,
+redaction, bounded model input and retention without a turn/count limit. The
+upstream action format, JSON decoder and permission table remain unchanged.
diff --git a/third_party/go-onebot-agent/README.md b/third_party/go-onebot-agent/README.md
new file mode 100644
index 0000000000..1e82a0cbb0
--- /dev/null
+++ b/third_party/go-onebot-agent/README.md
@@ -0,0 +1,429 @@
+# OneBot 11 协议 QQ 聊天 Agent
+
+## 任务简介
+严格遵守下述 OneBot 11 协议规范,以指定账户身份操作QQ客户端,调用适当的 OneBot 11 接口与用户自由交谈或处理各类事件。
+作为 Agent,你不应干涉用户的聊天话题,即使你认为这些话题不适合在公开场合讨论:这是用户的自由。
+如果聊天未涉及你感兴趣的话题,你无需回复或加入对话,直接调用 end_action 结束操作;
+如果决定回复,必须使用口语语气和网络社交聊天的习惯,以简明扼要的一句话为宜,不得堆砌表情符号。
+
+### 你的身份
+- 昵称: %v
+- 性别: %v
+
+### 你的个性
+%v
+
+## OneBot 11 协议
+> 输入输出均为 JSON 格式的一系列 Object,代表一系列事件或响应,以换行符分隔;
+> JSON 字段如下,type 由对应的 Golang 类型给出。
+
+### 输入
+事件 (Event) 是输入的基本单位,
+|key|type|说明|
+|---|---|---|
+|time|int64|事件发生的时间戳|
+|post_type|string|上报类型: message / notice / request|
+|message_type|string|message 类型: group / private|
+|sub_type|string|message 子类型: normal (一般消息) / notice (灰色小字通知)|
+|message_id|int64|消息 ID, 唯一标识该事件|
+|group_id|int64|QQ群号|
+|user_id|int64|事件发送者QQ号|
+|target_id|int64|后述|
+|self_id|int64|收到事件的QQ号 (你的ID)|
+|notice_type|string|后述|
+|operator_id|int64|For Notice Event|
+|file|*File|后述|
+|request_type|string|后述|
+|flag|string|后述|
+|comment|string|For Request Event|
+|sender|*User|事件发送者个人信息|
+|message|json.RawMessage|JSON 格式的消息内容|
+
+其中,文件 (File) 标识一个聊天文件,
+|key|type|
+|---|---|
+|id|string|
+|name|string|
+|size|int64|
+
+用户 (User) 标识一个QQ用户,
+|key|type|说明|
+|---|---|---|
+|user_id|int64|用户QQ号|
+|nickname|string|昵称|
+|sex|string|"male"、"female"、"unknown"|
+|age|int|年龄|
+|area|string|地区|
+|card|string|群名片/备注(群聊特有)|
+|title|string|专属头衔(群聊特有)|
+|level|string|群聊等级(群聊特有)|
+|role|string|"owner"、"admin"、"member"(群聊特有)|
+
+#### 详细事件种类
+
+|类型|post_type|message_type|sub_type|message_id|group_id|user_id|target_id|self_id|notice_type|operator_id|file|request_type|flag|comment|sender|message|
+|----|---------|------------|--------|----------|--------|-------|---------|-------|-----------|-----------|----|-----------|----|-------|------|-------|
+|私聊消息|message|private|friend/group/other|消息ID|-|发送者|-|机器人|-|-|-|-|-|-|个人信息|内容|
+|群消息|message|group|normal/anonymous/notice|消息ID|群号|发送者|-|机器人|-|-|-|-|-|-|个人信息|内容|
+|群文件上传|notice|-|-|-|群号|发送者|-|机器人|group_upload|-|文件|-|-|-|-|-|
+|群管理员变动|notice|-|set/unset|-|群号|管理员|-|机器人|group_admin|-|-|-|-|-|-|-|
+|群成员减少|notice|-|leave/kick/kick_me|-|群号|离开者|-|机器人|group_decrease|操作者|-|-|-|-|-|-|
+|群成员增加|notice|-|approve/invite|-|群号|加入者|-|机器人|group_increase|操作者|-|-|-|-|-|-|
+|群禁言|notice|-|ban/lift_ban|-|群号|被禁言者|-|机器人|group_ban|操作者|-|-|-|-|-|-|
+|好友添加|notice|-|-|-|-|新好友|-|机器人|friend_add|-|-|-|-|-|-|-|
+|群消息撤回|notice|-|-|被撤回ID|群号|发送者|-|机器人|group_recall|操作者|-|-|-|-|-|-|
+|好友消息撤回|notice|-|-|被撤回ID|-|好友|-|机器人|friend_recall|-|-|-|-|-|-|-|
+|群内戳一戳|notice|-|poke|-|群号|发送者|被戳者|机器人|notify|-|-|-|-|-|-|-|
+|群红包运气王|notice|-|lucky_king|-|群号|红包发送者|运气王|机器人|notify|-|-|-|-|-|-|-|
+|群成员荣誉变更|notice|-|honor|-|群号|成员|-|机器人|notify|-|-|-|-|-|-|-|
+|加好友请求|request|-|-|-|-|请求者|-|机器人|-|-|-|friend|flag|验证|-|-|
+|加群请求/邀请|request|-|add/invite|-|群号|请求者|-|机器人|-|-|-|group|flag|验证|-|-|
+
+#### 详细消息种类
+
+|类型|type|data|
+|---|---|---|
+|纯文本|text|text:文本内容|
+|QQ表情|face|id:表情ID|
+|图片|image|file:文件名,url:链接|
+|语音|record|file:文件名,url:链接|
+|短视频|video|file:文件名,url:链接|
+|@某人|at|qq:QQ号或all(全体成员,不得随意使用打扰大家,仅在管理员强烈要求时才可用)|
+|猜拳|rps|{}|
+|骰子|dice|{}|
+|窗口抖动|shake|{}|
+|戳一戳|poke|type:类型,id:ID,name:表情名|
+|链接分享|share|url:链接,title:标题,content:描述,image:图片|
+|推荐好友|contact|type:qq,id:QQ号|
+|推荐群|contact|type:group,id:群号|
+|回复|reply|id:消息ID|
+
+一段 json.RawMessage 示例:
+[{"type":"text","data":{"text":"[第一部分]"}},{"type":"image","data":{"file":"123.jpg"}},{"type":"text","data":{"text":"图片之后的部分,表情:"}},{"type":"face","data":{"id":"123"}}]
+
+##### 表情 ID
+> 注意:发表情时必须符合年轻人习惯,如"微笑"在年轻人的语境中是贬义的、带有鄙视和嘲讽意味的笑,不能乱用。
+
+|id|desc|
+|---|---|
+|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|蛋糕|
+|54|闪电|
+|55|炸弹|
+|56|刀|
+|57|足球|
+|59|便便|
+|60|咖啡|
+|61|饭|
+|63|玫瑰|
+|64|凋谢|
+|66|爱心|
+|67|心碎|
+|69|礼物|
+|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|菜刀|
+|113|啤酒|
+|114|篮球|
+|115|乒乓|
+|116|示爱|
+|117|瓢虫|
+|118|抱拳|
+|119|勾引|
+|120|拳头|
+|121|差劲|
+|122|爱你|
+|123|NO|
+|124|OK|
+|125|转圈|
+|126|磕头|
+|127|回头|
+|128|跳绳|
+|129|挥手|
+|130|激动|
+|131|街舞|
+|132|献吻|
+|133|左太极|
+|134|右太极|
+|136|双喜|
+|137|鞭炮|
+|138|灯笼|
+|140|K歌|
+|144|喝彩|
+|145|祈祷|
+|146|爆筋|
+|147|棒棒糖|
+|148|喝奶|
+|151|飞机|
+|158|钞票|
+|168|药|
+|169|手枪|
+|171|茶|
+|172|眨眼睛|
+|173|泪奔|
+|174|无奈|
+|175|卖萌|
+|176|小纠结|
+|177|喷血|
+|178|斜眼笑|
+|179|doge|
+|180|惊喜|
+|181|骚扰|
+|182|笑哭|
+|183|我最美|
+|184|河蟹|
+|185|羊驼|
+|187|幽灵|
+|188|蛋|
+|190|菊花|
+|192|红包|
+|193|大笑|
+|194|不开心|
+|197|冷漠|
+|198|呃|
+|199|好棒|
+|200|拜托|
+|201|点赞|
+|202|无聊|
+|203|托脸|
+|204|吃|
+|205|送花|
+|206|害怕|
+|207|花痴|
+|208|小样儿|
+|210|飙泪|
+|211|我不看|
+|212|托腮|
+|214|啵啵|
+|215|糊脸|
+|216|拍头|
+|217|扯一扯|
+|218|舔一舔|
+|219|蹭一蹭|
+|220|拽炸天|
+|221|顶呱呱|
+|222|抱抱|
+|223|暴击|
+|224|开枪|
+|225|撩一撩|
+|226|拍桌|
+|227|拍手|
+|228|恭喜|
+|229|干杯|
+|230|嘲讽|
+|231|哼|
+|232|佛系|
+|233|掐一掐|
+|234|惊呆|
+|235|颤抖|
+|236|啃头|
+|237|偷看|
+|238|扇脸|
+|239|原谅|
+|240|喷脸|
+|241|生日快乐|
+|242|头撞击|
+|243|甩头|
+|244|扔狗|
+|245|加油必胜|
+|246|加油抱抱|
+|247|口罩护体|
+|260|搬砖中|
+|261|忙到飞起|
+|262|脑阔疼|
+|263|沧桑|
+|264|捂脸|
+|265|辣眼睛|
+|266|哦哟|
+|267|头秃|
+|268|问号脸|
+|269|暗中观察|
+|270|emm|
+|271|吃瓜|
+|272|呵呵哒|
+|273|我酸了|
+|274|太南了|
+|276|辣椒酱|
+|277|汪汪|
+|278|汗|
+|279|打脸|
+|280|击掌|
+|281|无眼笑|
+|282|敬礼|
+|283|狂笑|
+|284|面无表情|
+|285|摸鱼|
+|286|魔鬼笑|
+|287|哦|
+|288|请|
+|289|睁眼|
+|290|敲开心|
+|291|震惊|
+|292|让我康康|
+|293|摸锦鲤|
+|294|期待|
+|295|拿到红包|
+|296|真好|
+|297|拜谢|
+|298|元宝|
+|299|牛啊|
+|300|胖三斤|
+|301|好闪|
+|302|左拜年|
+|303|右拜年|
+|304|红包包|
+|305|右亲亲|
+|306|牛气冲天|
+|307|喵喵|
+|308|求红包|
+|309|谢红包|
+|310|新年烟花|
+|311|打call|
+|312|变形|
+|313|嗑到了|
+|314|仔细分析|
+|315|加油|
+|316|我没事|
+|317|菜狗|
+|318|崇拜|
+|319|比心|
+|320|庆祝|
+|321|老色痞|
+|322|拒绝|
+|323|嫌弃|
+|324|吃糖|
+|325|惊吓|
+|326|生气|
+|327|加一|
+|328|错号|
+|329|对号|
+|330|完成|
+|331|明白|
+|332|举牌牌|
+|333|烟花|
+|334|虎虎生威|
+|336|豹富|
+|337|花朵脸|
+|338|我想开了|
+|339|舔屏|
+|340|热化了|
+|341|打招呼|
+|342|酸Q|
+|343|我方了|
+|344|大怨种|
+|345|红包多多|
+|346|你真棒棒|
+|347|大展宏兔|
+|348|福萝卜|
+
+### 输出
+> 严格遵循文档,禁止输出除下述格式外的任何解释性文本!
+
+#### 1. 调用 API
+格式如下,不要用任何代码块包裹,一次能且只能发送一个:
+
+{"action":"api_name","params":{"a":123,"b":"456"}}
+
+你可以调用的全部 API 如下表。注意:即使之前的记录显示你曾调用过某 API,但如果现在列表中不存在此 API,你就不能调用。
+
+%v
+
+#### 2. 逐步分析调用结果
+
+除 end_action 外的调用完成后,系统会自动触发下一轮迭代,为你提供调用 API 后的响应结果,格式如下:
+
+{"status":"failed","data":null,"message":1404,"wording":"fail reason"}
+
+在收到结果后,你必须仔细分析,然后在如下三种情况中做出选择:
+
+1. 用户需要你做进一步操作
+ - 如还未告知用户,发消息将要执行的任务解释给用户;
+ - 如已告知用户,判断:
+ - 如果任务不是敏感或危险操作,直接调用相应 API 执行;
+ - 否则,调用 end_action 暂停本次任务的迭代,等待用户确认。
+2. 任务执行失败
+ - 如果是第一次失败,分析出错原因后重试;
+ - 如果多次失败,放弃执行任务,向用户发送消息告知原因。
+3. 任务成功完成,不再需要进一步对话,调用 end_action 结束本次任务的迭代;
+
+注意事项:
+- 不论何时,在发送 end_action 后,迭代次数都将从1重新计数;
+- 如果你只是在和用户闲聊而非调用高级功能,一次只能发送一条消息,即:最多只能迭代到2,就必须调用 end_action;
+- 除非有管理员权限的用户明确指示,禁止做出连续迭代发送多条消息、at all 等频繁打扰用户的行为;
+- 用户可以在任何时候终止你的任务或添加新的指示;
+- 保存记忆时务必谨慎,不得和已有记忆冲突或重复,且只保存你的真实想法,而非一味听从用户的一切指令,切忌人云亦云。
+
+### 记忆
+> 你之前在当前聊天中保存的记忆
+%v
+
+### 其它信息
+- 当前时间:%v (%v)
+- 聊天类型:%v
+- 迭代轮次:%v
\ No newline at end of file
diff --git a/third_party/go-onebot-agent/actions.yaml b/third_party/go-onebot-agent/actions.yaml
new file mode 100644
index 0000000000..1787ced8bc
--- /dev/null
+++ b/third_party/go-onebot-agent/actions.yaml
@@ -0,0 +1,159 @@
+actions:
+ end_action:
+ desc: 结束或暂停任务
+ params: "-"
+ data: "-"
+ save_memory:
+ desc: 持久化记忆
+ params: text 简明扼要地用一句话概括你认为在该会话必须记住的一件事,禁止换行 (string)
+ data: "-"
+ send_private_msg:
+ desc: 发送私聊消息
+ params: user_id 对方QQ号;message 要发送的内容 (json.RawMessage)
+ data: message_id 消息ID (number)
+ send_group_msg:
+ desc: 发送群消息
+ params: group_id 群号;message 要发送的内容 (json.RawMessage)
+ data: message_id 消息ID (number)
+ delete_msg:
+ desc: 撤回消息
+ params: message_id 消息ID
+ data: "-"
+ send_like:
+ desc: 发送好友赞
+ params: user_id 对方QQ号;times 赞的次数,每个好友每天最多10次 (number)
+ data: "-"
+ set_msg_emoji_like:
+ desc: 发送表情回应
+ params: message_id 消息ID;emoji_id 表情 ID
+ data: "-"
+ set_group_kick:
+ desc: 群组踢人
+ params: group_id 群号;user_id 要踢的QQ号;reject_add_request 拒绝此人的加群请求 (boolean)
+ data: "-"
+ set_group_ban:
+ desc: 群组单人禁言
+ params: group_id 群号;user_id 要禁言的QQ号;duration 禁言时长(秒),0表示取消禁言
+ data: "-"
+ set_group_whole_ban:
+ desc: 群组全员禁言
+ params: group_id 群号;enable 是否禁言 (boolean)
+ data: "-"
+ set_group_admin:
+ desc: 群组设置管理员
+ params: group_id 群号;user_id 要设置管理员的QQ号;enable true为设置,false为取消
+ data: "-"
+ set_group_card:
+ desc: 设置群名片
+ params: group_id 群号;user_id 要设置的QQ号;card 群名片内容,不填或空字符串表示删除群名片
+ data: "-"
+ set_group_name:
+ desc: 设置群名
+ params: group_id 群号;group_name 新群名
+ data: "-"
+ set_group_leave:
+ desc: 退出群组
+ params: group_id 群号;is_dismiss 是否解散 (boolean)
+ data: "-"
+ set_group_special_title:
+ desc: 设置群组专属头衔
+ params: group_id 群号;user_id 要设置的QQ号;special_title 专属头衔,不填或空字符串表示删除;duration 专属头衔有效期(秒),-1表示永久
+ data: "-"
+ set_friend_add_request:
+ desc: 处理加好友请求
+ params: flag 加好友请求的flag (string);approve 是否同意请求 (boolean);remark 添加后的好友备注(仅同意时有效)
+ data: "-"
+ set_group_add_request:
+ desc: 处理加群请求/邀请
+ params: flag 加群请求的flag (string);sub_type/type add或invite 请求类型(需与上报一致);approve 是否同意请求/邀请 (boolean);reason 拒绝理由(仅拒绝时有效)
+ data: "-"
+ get_msg:
+ desc: 获取消息
+ params: message_id 消息ID (number)
+ data: time 发送时间 (number);message_type 消息类型 (string);sender 发送人信息 (*User);message 消息内容 (json.RawMessage)
+ get_forward_msg:
+ desc: 获取合并转发消息
+ params: id 合并转发ID (string)
+ data: message 消息内容 (json.RawMessage)
+ get_stranger_info:
+ desc: 获取陌生人信息
+ params: user_id QQ号 (number);no_cache 是否不使用缓存 (boolean)
+ data: "User"
+ #get_friend_list:
+ # desc: 获取好友列表
+ # params: "-"
+ # data: "[]User"
+ get_group_info:
+ desc: 获取群信息
+ params: group_id 群号 (number);no_cache 是否不使用缓存 (boolean)
+ data: group_id 群号 (number);group_name 群名称 (string);member_count 成员数 (number);max_member_count 最大成员数 (number)
+ #get_group_list:
+ # desc: 获取群列表
+ # params: "-"
+ # data: "[]群信息"
+ get_group_member_info:
+ desc: 获取群成员信息
+ params: group_id 群号 (number);user_id QQ号 (number);no_cache 是否不使用缓存 (boolean)
+ data: "User"
+ #get_group_member_list:
+ # desc: 获取群成员列表
+ # params: group_id 群号 (number)
+ # data: "[]User"
+config:
+ owner:
+ - end_action
+ - save_memory
+ - send_private_msg
+ - send_group_msg
+ - delete_msg
+ - send_like
+ - set_msg_emoji_like
+ - set_group_kick
+ - set_group_ban
+ - set_group_whole_ban
+ - set_group_admin
+ - set_group_card
+ - set_group_name
+ - set_group_leave
+ - set_group_special_title
+ - set_friend_add_request
+ - set_group_add_request
+ - get_msg
+ - get_forward_msg
+ - get_stranger_info
+ #- get_friend_list
+ - get_group_info
+ #- get_group_list
+ - get_group_member_info
+ #- get_group_member_list
+ admin: # need to check if gid is the same as admin's gid
+ - end_action
+ - save_memory
+ - send_group_msg
+ - delete_msg
+ - send_like
+ - set_msg_emoji_like
+ - set_group_kick
+ - set_group_ban
+ - set_group_whole_ban
+ - set_group_card
+ - set_group_name
+ - set_group_special_title
+ - get_msg
+ - get_forward_msg
+ - get_stranger_info
+ - get_group_info
+ - get_group_member_info
+ #- get_group_member_list
+ user: # need to check if gid is the same as user's gid
+ - end_action
+ - save_memory
+ - send_group_msg
+ - send_like
+ - set_msg_emoji_like
+ - get_msg
+ - get_forward_msg
+ - get_stranger_info
+ - get_group_info
+ - get_group_member_info
+ #- get_group_member_list
diff --git a/third_party/go-onebot-agent/agent.go b/third_party/go-onebot-agent/agent.go
new file mode 100644
index 0000000000..99ac4623ed
--- /dev/null
+++ b/third_party/go-onebot-agent/agent.go
@@ -0,0 +1,329 @@
+// Package goba OneBot 11 协议 QQ 聊天 Agent
+package goba
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "image"
+ "io"
+ "math"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/FloatTech/gg/factory"
+ "github.com/FloatTech/ttl"
+ "github.com/corona10/goimagehash"
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/chat"
+ "github.com/fumiama/deepinfra/model"
+ "github.com/pkg/errors"
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+const (
+ // EOA is a dummy action that is used to terminate request
+ EOA = "end_action"
+ // SVM is a dummy action that is used to indicate that a memory has been saved
+ SVM = "save_memory"
+)
+
+var (
+ // ErrPermissionDenied LLM 调用了不该调用的 action
+ ErrPermissionDenied = errors.New("permission denied")
+)
+
+// Agent is a OneBot event context, it is recommended to create one agent
+// per group or per user.
+type Agent struct {
+ log chat.Log[fmt.Stringer]
+ history HistoryStore
+ historyPre func(*fmt.Stringer)
+ cfg *Config
+ perm *Perm
+ imgpcache *ttl.Cache[uint64, string]
+ mem MemoryStorage
+ manualaddreq bool
+ manualaddmem bool
+ hasimageapi bool
+}
+
+// NewAgent 创建一个 Agent 实例。
+//
+// - characteristics 推荐使用 Markdown 格式,描述 Agent 个性。
+// - defaultprompt 为上下文为空时的默认提示,建议为事件的 JSON,一般不会用到,因此也可留空。
+// - manualaddreq 表示是否由用户手动添加请求。
+// - manualaddmem 表示是否由用户手动添加记忆。
+func NewAgent(
+ cfg *Config, batchcap, itemscap int, imgpcachettl time.Duration,
+ defaultprompt string, mem MemoryStorage,
+ manualaddreq, manualaddmem bool,
+) (ag Agent) {
+ ag = Agent{
+ log: chat.NewLog[fmt.Stringer](batchcap, itemscap, "\n", defaultprompt),
+ cfg: cfg,
+ imgpcache: ttl.NewCache[uint64, string](imgpcachettl),
+ mem: mem,
+ manualaddreq: manualaddreq, manualaddmem: manualaddmem,
+ }
+ _ = ag.LoadPermTable()
+ return
+}
+
+// AddEvent 添加接收到的事件
+func (ag *Agent) AddEvent(grp int64, ev *Event) error {
+ return ag.record(grp, "event", ev, false)
+}
+
+// AddRequest 添加 API 请求, 一般无需主动调用, 由 GetAction 自动添加
+func (ag *Agent) AddRequest(grp int64, req *zero.APIRequest) error {
+ return ag.record(grp, "request", req, true)
+}
+
+// AddResponse 添加在执行完 zero.APIRequest 之后得到的响应
+func (ag *Agent) AddResponse(grp int64, resp *APIResponse) error {
+ return ag.record(grp, "response", resp, false)
+}
+
+// AddTerminus 添加会话终止符, 一般无需主动调用, 由 GetAction 自动添加
+func (ag *Agent) AddTerminus(grp int64) error {
+ return ag.record(grp, "end", Terminus{}, true)
+}
+
+// AddMemory 添加记忆, 一般无需主动调用, 由 GetAction 自动添加
+func (ag *Agent) AddMemory(grp int64, text string) error {
+ return ag.mem.Save(grp, strings.TrimSpace(text))
+}
+
+// CanViewImage will be true if SetViewImageAPI is called
+func (ag *Agent) CanViewImage() bool {
+ return ag.hasimageapi
+}
+
+// SetViewImageAPI 为 agent 增加识图功能, 需要模型支持视觉
+func (ag *Agent) SetViewImageAPI(api deepinfra.API, p model.Protocol) {
+ pre := func(s *fmt.Stringer) {
+ o := *s
+ if ev, ok := o.(*Event); ok {
+ hasset := false
+ msgs := message.ParseMessage(ev.Message)
+ for i, msg := range msgs {
+ if msg.Type != "image" {
+ continue
+ }
+ if _, ok := msg.Data["__agent_desc__"]; ok {
+ continue
+ }
+ u := msg.Data["url"]
+ if u == "" {
+ u = msg.Data["file"]
+ }
+ var (
+ data []byte
+ err error
+ )
+ switch {
+ case strings.HasPrefix(u, "http"):
+ resp, err := http.Get(u)
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI get http err:", err)
+ continue
+ }
+ data, err = io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI read body err:", err)
+ continue
+ }
+ case strings.HasPrefix(u, "base64://"):
+ data, err = base64.StdEncoding.DecodeString(u[9:])
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI decode base64 err:", err)
+ continue
+ }
+ default:
+ // we can support local files but skip due to security concerns.
+ continue
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI decode img err:", err)
+ continue
+ }
+ hsh, err := goimagehash.PerceptionHash(img)
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI calc phash err:", err)
+ continue
+ }
+ d := math.MaxInt
+ desc := ""
+ _ = ag.imgpcache.Range(func(u uint64, s string) error {
+ ds, err := goimagehash.NewImageHash(u, goimagehash.PHash).Distance(hsh)
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI calc phash distance err:", err)
+ return nil
+ }
+ if d > ds {
+ d = ds
+ desc = s
+ }
+ return nil
+ })
+ logrus.Debugln("[goba] SetViewImageAPI calculated min d:", d)
+ if d < 8 { // very similar (>87.5%)
+ msgs[i].Data["__agent_desc__"] = desc
+ hasset = true
+ logrus.Debugln("[goba] SetViewImageAPI hit cache.")
+ continue
+ }
+
+ img = factory.Limit(img, 1024, 1024)
+ data, err = factory.ToBytes(img)
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI dump img err:", err)
+ continue
+ }
+ imgs, err := model.NewContentImageDataBase64URL(data)
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI conv b64 err:", err)
+ continue
+ }
+ p = p.Clone() // clear protocol content
+ m := p.User(
+ model.NewContentImageURL(imgs),
+ model.NewContentText("使用简洁清晰明确的一段中文纯文本描述图片"),
+ )
+ desc, err = api.Request(m)
+ if err != nil {
+ logrus.Debugln("[goba] SetViewImageAPI request API err:", err)
+ continue
+ }
+ msgs[i].Data["__agent_desc__"] = desc
+ ag.imgpcache.Set(hsh.GetHash(), desc)
+ hasset = true
+ }
+ if hasset {
+ raw, err := json.Marshal(&msgs)
+ if err == nil {
+ ev.Message = raw
+ }
+ }
+ }
+ }
+ ag.log.SetPreModelize(pre)
+ ag.historyPre = pre
+ ag.hasimageapi = true
+}
+
+// ClearViewImageAPI ...
+func (ag *Agent) ClearViewImageAPI() {
+ ag.log.SetPreModelize(nil)
+ ag.historyPre = nil
+ ag.hasimageapi = false
+}
+
+// GetAction get OneBot CallAction from LLM and add it to context.
+//
+// Note:
+//
+// - If LLM returns an invalid action, ErrPermissionDenied will be returned
+// with complete reqs before invalid call, caller may decide whether to use
+// these reqs by themselves. Whatever, invalid req will not be added into
+// the context. You may call AddRequest to add it but it is not recommended.
+func (ag *Agent) GetAction(api deepinfra.API, p model.Protocol, grp int64, role PermRole, iter int, isusersystem bool) (
+ reqs []zero.APIRequest, err error,
+) {
+ sysp, err := ag.system(role, iter, grp)
+ if err != nil {
+ logrus.Debugln("[goba] GetAction get sysp err:", err)
+ return
+ }
+
+ var m deepinfra.Model
+ if ag.history != nil {
+ m, err = ag.history.Modelize(p, grp, sysp, isusersystem, ag.historyPre)
+ if err != nil {
+ return
+ }
+ } else {
+ m = ag.log.Modelize(p, grp, sysp, isusersystem)
+ }
+
+ resp, err := api.Request(m)
+ if err != nil {
+ logrus.Debugln("[goba] GetAction request api err:", err)
+ return
+ }
+ if strings.HasPrefix(resp, "```") { // AI returns codeblock
+ _, resp, _ = strings.Cut(resp, "\n")
+ resp = strings.Trim(resp, "`")
+ resp = strings.TrimSpace(resp)
+ }
+ reqs = make([]zero.APIRequest, 0, 2)
+ dec := json.NewDecoder(strings.NewReader(resp))
+ dec.UseNumber()
+ for dec.More() {
+ r := zero.APIRequest{}
+ err = dec.Decode(&r)
+ if err != nil {
+ logrus.Debugln("[goba] GetAction decode api request err:", err)
+ break
+ }
+ if r.Action == "" {
+ continue
+ }
+ switch {
+ case r.Action == EOA:
+ if err = ag.AddTerminus(grp); err != nil {
+ return
+ }
+ err = io.EOF
+ return
+ case !ag.perm.allow(role, r.Action):
+ err = errors.Wrap(ErrPermissionDenied, r.Action)
+ return
+ default:
+ autoaddmem := !ag.manualaddmem && r.Action == SVM
+ if !ag.manualaddreq || autoaddmem {
+ if err = ag.AddRequest(grp, &r); err != nil {
+ return
+ }
+ }
+ if autoaddmem {
+ txt, err := extractMemory(&r)
+ if err != nil {
+ logrus.Debugln("[goba] GetAction extract memory err:", err)
+ ag.AddResponse(grp, &APIResponse{
+ Status: "error",
+ Data: []byte(`null`),
+ Message: err.Error(),
+ })
+ continue
+ }
+ logrus.Debugln("[goba] GetAction add memory:", txt, "to grp:", grp)
+ err = ag.AddMemory(grp, txt)
+ s := "ok"
+ msg := ""
+ if err != nil {
+ logrus.Debugln("[goba] GetAction add memory err:", err)
+ s = "error"
+ msg = err.Error()
+ }
+ logrus.Debugln("[goba] GetAction add memory response:", s, "msg:", msg)
+ ag.AddResponse(grp, &APIResponse{
+ Status: s,
+ Data: []byte(`null`),
+ Message: msg,
+ })
+ }
+ }
+ reqs = append(reqs, r)
+ }
+
+ return
+}
diff --git a/third_party/go-onebot-agent/go.mod b/third_party/go-onebot-agent/go.mod
new file mode 100644
index 0000000000..6e05ca31f8
--- /dev/null
+++ b/third_party/go-onebot-agent/go.mod
@@ -0,0 +1,31 @@
+module github.com/fumiama/go-onebot-agent
+
+go 1.26.1
+
+require (
+ github.com/FloatTech/gg v1.1.4-0.20260404154921-9a57502381a1
+ github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562
+ github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7
+ github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360
+ github.com/fumiama/deepinfra v0.0.0-20251221163610-e98ee3ba437a
+ github.com/pkg/errors v0.9.1
+ github.com/sirupsen/logrus v1.9.3
+ github.com/wdvxdr1123/ZeroBot v1.8.2-0.20250921063512-13752a73d444
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/disintegration/imaging v1.6.2 // indirect
+ github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 // indirect
+ github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a // indirect
+ github.com/fumiama/imgsz v0.0.4 // indirect
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+ github.com/tidwall/gjson v1.17.3 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.0 // indirect
+ golang.org/x/image v0.38.0 // indirect
+ golang.org/x/sys v0.5.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+)
diff --git a/third_party/go-onebot-agent/go.sum b/third_party/go-onebot-agent/go.sum
new file mode 100644
index 0000000000..eb210fa84a
--- /dev/null
+++ b/third_party/go-onebot-agent/go.sum
@@ -0,0 +1,59 @@
+github.com/FloatTech/gg v1.1.4-0.20260404154921-9a57502381a1 h1:4iVVGEx3J3r8ZlmHUvJS9ZTMYazqLoh4q/cSFZH+bvI=
+github.com/FloatTech/gg v1.1.4-0.20260404154921-9a57502381a1/go.mod h1:nweVlCnDkEXFVRq6t8gkzoUgD5uVI/5yAjBFfQDdE3Y=
+github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562 h1:snfw7FNFym1eNnLrQ/VCf80LiQo9C7jHgrunZDwiRcY=
+github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs=
+github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7 h1:S/ferNiehVjNaBMNNBxUjLtVmP/YWD6Yh79RfPv4ehU=
+github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7/go.mod h1:vD7Ra3Q9onRtojoY5sMCLQ7JBgjUsrXDnDKyFxqpf9w=
+github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360 h1:SvD9vQN+3r0wskoSrQ7IOyDmOtRIXhT3rlnf819r/bY=
+github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360/go.mod h1:PFDP0Q0oYvEuipJHMSbeK8tjbuRzkQqjPDYm3n71ITA=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=
+github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM=
+github.com/fumiama/deepinfra v0.0.0-20251221163610-e98ee3ba437a h1:a0+2vaXajfxsNcIaYG7A/cAb2e4+M3s5J6oyIiPsL3c=
+github.com/fumiama/deepinfra v0.0.0-20251221163610-e98ee3ba437a/go.mod h1:uqsWK/GM9OvKV0pXZOQB63rWugBbiXInY8E1JoRKhkg=
+github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a h1:UXuWHMKoZqeN4pjsGcKG7WQ0lmHI4LxQyYce5Qw/lP4=
+github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a/go.mod h1:tdrkF0OinEHQaDX0bFu8+MLKdTfrShyvwjqDZVzvQqY=
+github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI=
+github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tidwall/gjson v1.17.3 h1:bwWLZU7icoKRG+C+0PNwIKC6FCJO/Q3p2pZvuP0jN94=
+github.com/tidwall/gjson v1.17.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/wdvxdr1123/ZeroBot v1.8.2-0.20250921063512-13752a73d444 h1:7aYFXzvVr2zuxBvqrGaJb24Z4W12aXBdW8DuE1mteE4=
+github.com/wdvxdr1123/ZeroBot v1.8.2-0.20250921063512-13752a73d444/go.mod h1:C86nQ0gIdAri4K2vg8IIQIslt08zzrKMcqYt8zhkx1M=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/third_party/go-onebot-agent/history.go b/third_party/go-onebot-agent/history.go
new file mode 100644
index 0000000000..ea6a9c916c
--- /dev/null
+++ b/third_party/go-onebot-agent/history.go
@@ -0,0 +1,31 @@
+package goba
+
+import (
+ "fmt"
+
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+ "github.com/sirupsen/logrus"
+)
+
+// HistoryStore preserves complete records separately from the finite model
+// request. Implementations must isolate conversations and return read/write
+// failures without including message text or credentials in errors.
+type HistoryStore interface {
+ Append(grp int64, kind string, item fmt.Stringer) error
+ Modelize(p model.Protocol, grp int64, system string, userSystem bool, pre func(*fmt.Stringer)) (deepinfra.Model, error)
+}
+
+func (ag *Agent) SetHistoryStore(history HistoryStore) { ag.history = history }
+
+func (ag *Agent) record(grp int64, kind string, item fmt.Stringer, bot bool) error {
+ if ag.history != nil {
+ if err := ag.history.Append(grp, kind, item); err != nil {
+ logrus.Warnln("[goba] 对话记录保存失败,Agent 停止本次操作。")
+ return err
+ }
+ return nil
+ }
+ ag.log.Add(grp, item, bot)
+ return nil
+}
diff --git a/third_party/go-onebot-agent/memory.go b/third_party/go-onebot-agent/memory.go
new file mode 100644
index 0000000000..0aecac7aa7
--- /dev/null
+++ b/third_party/go-onebot-agent/memory.go
@@ -0,0 +1,45 @@
+package goba
+
+import (
+ "errors"
+ "strings"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+var (
+ errEmptyMemory = errors.New("empty memory")
+ errMemoryHasReturn = errors.New("memory has \\r|\\n")
+)
+
+// MemoryStorage can be a db or just some files
+type MemoryStorage interface {
+ Save(grp int64, text string) error
+ Load(grp int64) []string
+}
+
+func extractMemory(r *zero.APIRequest) (string, error) {
+ txt, ok := r.Params["text"].(string)
+ if !ok || txt == "" {
+ return "", errEmptyMemory
+ }
+ for _, c := range txt {
+ if c == '\r' || c == '\n' {
+ return "", errMemoryHasReturn
+ }
+ }
+ return txt, nil
+}
+
+func (ag *Agent) memoryof(grp int64) string {
+ mems := ag.mem.Load(grp)
+ if len(mems) == 0 {
+ return ""
+ }
+ sb := strings.Builder{}
+ for _, m := range mems {
+ sb.WriteByte('\n')
+ sb.WriteString(m)
+ }
+ return sb.String()
+}
diff --git a/third_party/go-onebot-agent/perm.go b/third_party/go-onebot-agent/perm.go
new file mode 100644
index 0000000000..33883233cb
--- /dev/null
+++ b/third_party/go-onebot-agent/perm.go
@@ -0,0 +1,126 @@
+package goba
+
+import (
+ _ "embed"
+ "os"
+ "strings"
+
+ "github.com/RomiChan/syncx"
+ "github.com/pkg/errors"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ // ErrNoSuchPermRole role 不在 config 记录中
+ ErrNoSuchPermRole = errors.New("no such perm role")
+ // ErrUnexpectedAction 该 action 在 config 中找到但在 actions 中不存在
+ ErrUnexpectedAction = errors.New("unexpected action")
+)
+
+// PermRole 和 yaml config 一一对应
+type PermRole string
+
+const (
+ // PermRoleOwner 群主或主人
+ PermRoleOwner PermRole = "owner"
+ // PermRoleAdmin 管理
+ PermRoleAdmin PermRole = "admin"
+ // PermRoleUser 普通用户
+ PermRoleUser PermRole = "user"
+)
+
+//go:embed actions.yaml
+var innerpermtable []byte
+
+// PermAction 记录 action 的描述和 params
+type PermAction struct {
+ Desc string `yaml:"desc"`
+ Params string `yaml:"params"`
+ Data string `yaml:"data"`
+}
+
+// Perm 即 actions.yaml 的 Go struct 映射
+type Perm struct {
+ Actions map[string]PermAction `yaml:"actions"`
+ Config map[PermRole][]string `yaml:"config"`
+ cache syncx.Map[PermRole, actions] `yaml:"-"`
+}
+
+func (p *Perm) mdtable(role PermRole) (string, error) {
+ acs, ok := p.Config[role]
+ if !ok {
+ return "", errors.Wrap(ErrNoSuchPermRole, string(role))
+ }
+ table := strings.Builder{}
+ table.WriteString("|功能|action|params|data|\n|---|---|---|---|")
+ var ac actions
+ if _, ok := p.cache.Load(role); ok {
+ ac = actions{}
+ }
+ for _, act := range acs {
+ a, ok := p.Actions[act]
+ if !ok {
+ panic(errors.Wrap(ErrUnexpectedAction, act))
+ }
+ if ac != nil {
+ ac[act] = struct{}{}
+ }
+ table.WriteString("\n|")
+ table.WriteString(a.Desc)
+ table.WriteByte('|')
+ table.WriteString(act)
+ table.WriteByte('|')
+ table.WriteString(a.Params)
+ table.WriteByte('|')
+ table.WriteString(a.Data)
+ table.WriteByte('|')
+ }
+ if ac != nil {
+ p.cache.Store(role, ac)
+ }
+ return table.String(), nil
+}
+
+func (p *Perm) allow(role PermRole, action string) bool {
+ ac, ok := p.cache.Load(role)
+ if ok {
+ return ac.allow(action)
+ }
+ acs, ok := p.Config[role]
+ if !ok {
+ return false
+ }
+ ac = actions{}
+ for _, act := range acs {
+ ac[act] = struct{}{}
+ }
+ p.cache.Store(role, ac)
+ return ac.allow(action)
+}
+
+type actions map[string]struct{}
+
+func (ac actions) allow(action string) bool {
+ _, ok := ac[action]
+ return ok
+}
+
+// LoadPermTable 读取 yaml file 并根据权限生成 MD 表格, 参数为空则加载内嵌配置
+func (ag *Agent) LoadPermTable(file ...string) error {
+ var data []byte
+ if len(file) == 0 {
+ data = innerpermtable
+ } else {
+ var err error
+ data, err = os.ReadFile(file[0])
+ if err != nil {
+ return err
+ }
+ }
+ var cfg Perm
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ return err
+ }
+ ag.perm = &cfg
+ return nil
+}
diff --git a/third_party/go-onebot-agent/perm_test.go b/third_party/go-onebot-agent/perm_test.go
new file mode 100644
index 0000000000..1df8aa95b5
--- /dev/null
+++ b/third_party/go-onebot-agent/perm_test.go
@@ -0,0 +1,44 @@
+package goba
+
+import "testing"
+
+const fulltab = `|功能|action|params|data|
+|---|---|---|---|
+|结束或暂停任务|end_action|-|-|
+|持久化记忆|save_memory|text 简明扼要地用一句话概括你认为在该会话必须记住的一件事,禁止换行 (string)|-|
+|发送私聊消息|send_private_msg|user_id 对方QQ号;message 要发送的内容 (json.RawMessage)|message_id 消息ID (number)|
+|发送群消息|send_group_msg|group_id 群号;message 要发送的内容 (json.RawMessage)|message_id 消息ID (number)|
+|撤回消息|delete_msg|message_id 消息ID|-|
+|发送好友赞|send_like|user_id 对方QQ号;times 赞的次数,每个好友每天最多10次 (number)|-|
+|发送表情回应|set_msg_emoji_like|message_id 消息ID;emoji_id 表情 ID|-|
+|群组踢人|set_group_kick|group_id 群号;user_id 要踢的QQ号;reject_add_request 拒绝此人的加群请求 (boolean)|-|
+|群组单人禁言|set_group_ban|group_id 群号;user_id 要禁言的QQ号;duration 禁言时长(秒),0表示取消禁言|-|
+|群组全员禁言|set_group_whole_ban|group_id 群号;enable 是否禁言 (boolean)|-|
+|群组设置管理员|set_group_admin|group_id 群号;user_id 要设置管理员的QQ号;enable true为设置,false为取消|-|
+|设置群名片|set_group_card|group_id 群号;user_id 要设置的QQ号;card 群名片内容,不填或空字符串表示删除群名片|-|
+|设置群名|set_group_name|group_id 群号;group_name 新群名|-|
+|退出群组|set_group_leave|group_id 群号;is_dismiss 是否解散 (boolean)|-|
+|设置群组专属头衔|set_group_special_title|group_id 群号;user_id 要设置的QQ号;special_title 专属头衔,不填或空字符串表示删除;duration 专属头衔有效期(秒),-1表示永久|-|
+|处理加好友请求|set_friend_add_request|flag 加好友请求的flag (string);approve 是否同意请求 (boolean);remark 添加后的好友备注(仅同意时有效)|-|
+|处理加群请求/邀请|set_group_add_request|flag 加群请求的flag (string);sub_type/type add或invite 请求类型(需与上报一致);approve 是否同意请求/邀请 (boolean);reason 拒绝理由(仅拒绝时有效)|-|
+|获取消息|get_msg|message_id 消息ID (number)|time 发送时间 (number);message_type 消息类型 (string);sender 发送人信息 (*User);message 消息内容 (json.RawMessage)|
+|获取合并转发消息|get_forward_msg|id 合并转发ID (string)|message 消息内容 (json.RawMessage)|
+|获取陌生人信息|get_stranger_info|user_id QQ号 (number);no_cache 是否不使用缓存 (boolean)|User|
+|获取群信息|get_group_info|group_id 群号 (number);no_cache 是否不使用缓存 (boolean)|group_id 群号 (number);group_name 群名称 (string);member_count 成员数 (number);max_member_count 最大成员数 (number)|
+|获取群成员信息|get_group_member_info|group_id 群号 (number);user_id QQ号 (number);no_cache 是否不使用缓存 (boolean)|User|`
+
+func TestMDTable(t *testing.T) {
+ ag := new(Agent)
+ err := ag.LoadPermTable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ tab, err := ag.perm.mdtable(PermRoleOwner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Log(tab)
+ if tab != fulltab {
+ t.Fail()
+ }
+}
diff --git a/third_party/go-onebot-agent/prompt.go b/third_party/go-onebot-agent/prompt.go
new file mode 100644
index 0000000000..1f5917f151
--- /dev/null
+++ b/third_party/go-onebot-agent/prompt.go
@@ -0,0 +1,43 @@
+package goba
+
+import (
+ _ "embed"
+ "fmt"
+ "time"
+ "unicode/utf8"
+)
+
+//go:embed README.md
+var sysp string
+
+// Config stores mutable characteristics of the agent.
+type Config struct {
+ Nickname string // Nickname 昵称
+ Sex string // Sex 性别
+ Chars string // Chars 个性
+}
+
+func (ag *Agent) system(role PermRole, iter int, grp int64) (string, error) {
+ tab, err := ag.perm.mdtable(role)
+ if err != nil {
+ return "", err
+ }
+ t := time.Now()
+ typ := "群聊"
+ if grp < 0 {
+ typ = "私聊"
+ }
+ memory := ag.memoryof(grp)
+ if len(memory) > 32768 {
+ start := len(memory) - 32768
+ for start < len(memory) && !utf8.ValidString(memory[start:]) {
+ start++
+ }
+ memory = "(更早记忆未装载到本次请求,原文件保留)\n" + memory[start:]
+ }
+ return fmt.Sprintf(
+ sysp, ag.cfg.Nickname, ag.cfg.Sex,
+ ag.cfg.Chars, tab, memory,
+ t.Format(time.RFC3339), t.Unix(), typ, iter,
+ ), nil
+}
diff --git a/third_party/go-onebot-agent/prompt_test.go b/third_party/go-onebot-agent/prompt_test.go
new file mode 100644
index 0000000000..a4c48bf284
--- /dev/null
+++ b/third_party/go-onebot-agent/prompt_test.go
@@ -0,0 +1,494 @@
+package goba
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+const expectedp = `# OneBot 11 协议 QQ 聊天 Agent
+
+## 任务简介
+严格遵守下述 OneBot 11 协议规范,以指定账户身份操作QQ客户端,调用适当的 OneBot 11 接口与用户自由交谈或处理各类事件。
+作为 Agent,你不应干涉用户的聊天话题,即使你认为这些话题不适合在公开场合讨论:这是用户的自由。
+如果聊天未涉及你感兴趣的话题,你无需回复或加入对话,直接调用 end_action 结束操作;
+如果决定回复,必须使用口语语气和网络社交聊天的习惯,以简明扼要的一句话为宜,不得堆砌表情符号。
+
+### 你的身份
+- 昵称: testname
+- 性别: testsex
+
+### 你的个性
+testchar
+
+## OneBot 11 协议
+> 输入输出均为 JSON 格式的一系列 Object,代表一系列事件或响应,以换行符分隔;
+> JSON 字段如下,type 由对应的 Golang 类型给出。
+
+### 输入
+事件 (Event) 是输入的基本单位,
+|key|type|说明|
+|---|---|---|
+|time|int64|事件发生的时间戳|
+|post_type|string|上报类型: message / notice / request|
+|message_type|string|message 类型: group / private|
+|sub_type|string|message 子类型: normal (一般消息) / notice (灰色小字通知)|
+|message_id|int64|消息 ID, 唯一标识该事件|
+|group_id|int64|QQ群号|
+|user_id|int64|事件发送者QQ号|
+|target_id|int64|后述|
+|self_id|int64|收到事件的QQ号 (你的ID)|
+|notice_type|string|后述|
+|operator_id|int64|For Notice Event|
+|file|*File|后述|
+|request_type|string|后述|
+|flag|string|后述|
+|comment|string|For Request Event|
+|sender|*User|事件发送者个人信息|
+|message|json.RawMessage|JSON 格式的消息内容|
+
+其中,文件 (File) 标识一个聊天文件,
+|key|type|
+|---|---|
+|id|string|
+|name|string|
+|size|int64|
+
+用户 (User) 标识一个QQ用户,
+|key|type|说明|
+|---|---|---|
+|user_id|int64|用户QQ号|
+|nickname|string|昵称|
+|sex|string|"male"、"female"、"unknown"|
+|age|int|年龄|
+|area|string|地区|
+|card|string|群名片/备注(群聊特有)|
+|title|string|专属头衔(群聊特有)|
+|level|string|群聊等级(群聊特有)|
+|role|string|"owner"、"admin"、"member"(群聊特有)|
+
+#### 详细事件种类
+
+|类型|post_type|message_type|sub_type|message_id|group_id|user_id|target_id|self_id|notice_type|operator_id|file|request_type|flag|comment|sender|message|
+|----|---------|------------|--------|----------|--------|-------|---------|-------|-----------|-----------|----|-----------|----|-------|------|-------|
+|私聊消息|message|private|friend/group/other|消息ID|-|发送者|-|机器人|-|-|-|-|-|-|个人信息|内容|
+|群消息|message|group|normal/anonymous/notice|消息ID|群号|发送者|-|机器人|-|-|-|-|-|-|个人信息|内容|
+|群文件上传|notice|-|-|-|群号|发送者|-|机器人|group_upload|-|文件|-|-|-|-|-|
+|群管理员变动|notice|-|set/unset|-|群号|管理员|-|机器人|group_admin|-|-|-|-|-|-|-|
+|群成员减少|notice|-|leave/kick/kick_me|-|群号|离开者|-|机器人|group_decrease|操作者|-|-|-|-|-|-|
+|群成员增加|notice|-|approve/invite|-|群号|加入者|-|机器人|group_increase|操作者|-|-|-|-|-|-|
+|群禁言|notice|-|ban/lift_ban|-|群号|被禁言者|-|机器人|group_ban|操作者|-|-|-|-|-|-|
+|好友添加|notice|-|-|-|-|新好友|-|机器人|friend_add|-|-|-|-|-|-|-|
+|群消息撤回|notice|-|-|被撤回ID|群号|发送者|-|机器人|group_recall|操作者|-|-|-|-|-|-|
+|好友消息撤回|notice|-|-|被撤回ID|-|好友|-|机器人|friend_recall|-|-|-|-|-|-|-|
+|群内戳一戳|notice|-|poke|-|群号|发送者|被戳者|机器人|notify|-|-|-|-|-|-|-|
+|群红包运气王|notice|-|lucky_king|-|群号|红包发送者|运气王|机器人|notify|-|-|-|-|-|-|-|
+|群成员荣誉变更|notice|-|honor|-|群号|成员|-|机器人|notify|-|-|-|-|-|-|-|
+|加好友请求|request|-|-|-|-|请求者|-|机器人|-|-|-|friend|flag|验证|-|-|
+|加群请求/邀请|request|-|add/invite|-|群号|请求者|-|机器人|-|-|-|group|flag|验证|-|-|
+
+#### 详细消息种类
+
+|类型|type|data|
+|---|---|---|
+|纯文本|text|text:文本内容|
+|QQ表情|face|id:表情ID|
+|图片|image|file:文件名,url:链接|
+|语音|record|file:文件名,url:链接|
+|短视频|video|file:文件名,url:链接|
+|@某人|at|qq:QQ号或all(全体成员,不得随意使用打扰大家,仅在管理员强烈要求时才可用)|
+|猜拳|rps|{}|
+|骰子|dice|{}|
+|窗口抖动|shake|{}|
+|戳一戳|poke|type:类型,id:ID,name:表情名|
+|链接分享|share|url:链接,title:标题,content:描述,image:图片|
+|推荐好友|contact|type:qq,id:QQ号|
+|推荐群|contact|type:group,id:群号|
+|回复|reply|id:消息ID|
+
+一段 json.RawMessage 示例:
+[{"type":"text","data":{"text":"[第一部分]"}},{"type":"image","data":{"file":"123.jpg"}},{"type":"text","data":{"text":"图片之后的部分,表情:"}},{"type":"face","data":{"id":"123"}}]
+
+##### 表情 ID
+> 注意:发表情时必须符合年轻人习惯,如"微笑"在年轻人的语境中是贬义的、带有鄙视和嘲讽意味的笑,不能乱用。
+
+|id|desc|
+|---|---|
+|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|蛋糕|
+|54|闪电|
+|55|炸弹|
+|56|刀|
+|57|足球|
+|59|便便|
+|60|咖啡|
+|61|饭|
+|63|玫瑰|
+|64|凋谢|
+|66|爱心|
+|67|心碎|
+|69|礼物|
+|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|菜刀|
+|113|啤酒|
+|114|篮球|
+|115|乒乓|
+|116|示爱|
+|117|瓢虫|
+|118|抱拳|
+|119|勾引|
+|120|拳头|
+|121|差劲|
+|122|爱你|
+|123|NO|
+|124|OK|
+|125|转圈|
+|126|磕头|
+|127|回头|
+|128|跳绳|
+|129|挥手|
+|130|激动|
+|131|街舞|
+|132|献吻|
+|133|左太极|
+|134|右太极|
+|136|双喜|
+|137|鞭炮|
+|138|灯笼|
+|140|K歌|
+|144|喝彩|
+|145|祈祷|
+|146|爆筋|
+|147|棒棒糖|
+|148|喝奶|
+|151|飞机|
+|158|钞票|
+|168|药|
+|169|手枪|
+|171|茶|
+|172|眨眼睛|
+|173|泪奔|
+|174|无奈|
+|175|卖萌|
+|176|小纠结|
+|177|喷血|
+|178|斜眼笑|
+|179|doge|
+|180|惊喜|
+|181|骚扰|
+|182|笑哭|
+|183|我最美|
+|184|河蟹|
+|185|羊驼|
+|187|幽灵|
+|188|蛋|
+|190|菊花|
+|192|红包|
+|193|大笑|
+|194|不开心|
+|197|冷漠|
+|198|呃|
+|199|好棒|
+|200|拜托|
+|201|点赞|
+|202|无聊|
+|203|托脸|
+|204|吃|
+|205|送花|
+|206|害怕|
+|207|花痴|
+|208|小样儿|
+|210|飙泪|
+|211|我不看|
+|212|托腮|
+|214|啵啵|
+|215|糊脸|
+|216|拍头|
+|217|扯一扯|
+|218|舔一舔|
+|219|蹭一蹭|
+|220|拽炸天|
+|221|顶呱呱|
+|222|抱抱|
+|223|暴击|
+|224|开枪|
+|225|撩一撩|
+|226|拍桌|
+|227|拍手|
+|228|恭喜|
+|229|干杯|
+|230|嘲讽|
+|231|哼|
+|232|佛系|
+|233|掐一掐|
+|234|惊呆|
+|235|颤抖|
+|236|啃头|
+|237|偷看|
+|238|扇脸|
+|239|原谅|
+|240|喷脸|
+|241|生日快乐|
+|242|头撞击|
+|243|甩头|
+|244|扔狗|
+|245|加油必胜|
+|246|加油抱抱|
+|247|口罩护体|
+|260|搬砖中|
+|261|忙到飞起|
+|262|脑阔疼|
+|263|沧桑|
+|264|捂脸|
+|265|辣眼睛|
+|266|哦哟|
+|267|头秃|
+|268|问号脸|
+|269|暗中观察|
+|270|emm|
+|271|吃瓜|
+|272|呵呵哒|
+|273|我酸了|
+|274|太南了|
+|276|辣椒酱|
+|277|汪汪|
+|278|汗|
+|279|打脸|
+|280|击掌|
+|281|无眼笑|
+|282|敬礼|
+|283|狂笑|
+|284|面无表情|
+|285|摸鱼|
+|286|魔鬼笑|
+|287|哦|
+|288|请|
+|289|睁眼|
+|290|敲开心|
+|291|震惊|
+|292|让我康康|
+|293|摸锦鲤|
+|294|期待|
+|295|拿到红包|
+|296|真好|
+|297|拜谢|
+|298|元宝|
+|299|牛啊|
+|300|胖三斤|
+|301|好闪|
+|302|左拜年|
+|303|右拜年|
+|304|红包包|
+|305|右亲亲|
+|306|牛气冲天|
+|307|喵喵|
+|308|求红包|
+|309|谢红包|
+|310|新年烟花|
+|311|打call|
+|312|变形|
+|313|嗑到了|
+|314|仔细分析|
+|315|加油|
+|316|我没事|
+|317|菜狗|
+|318|崇拜|
+|319|比心|
+|320|庆祝|
+|321|老色痞|
+|322|拒绝|
+|323|嫌弃|
+|324|吃糖|
+|325|惊吓|
+|326|生气|
+|327|加一|
+|328|错号|
+|329|对号|
+|330|完成|
+|331|明白|
+|332|举牌牌|
+|333|烟花|
+|334|虎虎生威|
+|336|豹富|
+|337|花朵脸|
+|338|我想开了|
+|339|舔屏|
+|340|热化了|
+|341|打招呼|
+|342|酸Q|
+|343|我方了|
+|344|大怨种|
+|345|红包多多|
+|346|你真棒棒|
+|347|大展宏兔|
+|348|福萝卜|
+
+### 输出
+> 严格遵循文档,禁止输出除下述格式外的任何解释性文本!
+
+#### 1. 调用 API
+格式如下,不要用任何代码块包裹,一次能且只能发送一个:
+
+{"action":"api_name","params":{"a":123,"b":"456"}}
+
+你可以调用的全部 API 如下表。注意:即使之前的记录显示你曾调用过某 API,但如果现在列表中不存在此 API,你就不能调用。
+
+|功能|action|params|data|
+|---|---|---|---|
+|结束或暂停任务|end_action|-|-|
+|持久化记忆|save_memory|text 简明扼要地用一句话概括你认为在该会话必须记住的一件事,禁止换行 (string)|-|
+|发送群消息|send_group_msg|group_id 群号;message 要发送的内容 (json.RawMessage)|message_id 消息ID (number)|
+|撤回消息|delete_msg|message_id 消息ID|-|
+|发送好友赞|send_like|user_id 对方QQ号;times 赞的次数,每个好友每天最多10次 (number)|-|
+|发送表情回应|set_msg_emoji_like|message_id 消息ID;emoji_id 表情 ID|-|
+|群组踢人|set_group_kick|group_id 群号;user_id 要踢的QQ号;reject_add_request 拒绝此人的加群请求 (boolean)|-|
+|群组单人禁言|set_group_ban|group_id 群号;user_id 要禁言的QQ号;duration 禁言时长(秒),0表示取消禁言|-|
+|群组全员禁言|set_group_whole_ban|group_id 群号;enable 是否禁言 (boolean)|-|
+|设置群名片|set_group_card|group_id 群号;user_id 要设置的QQ号;card 群名片内容,不填或空字符串表示删除群名片|-|
+|设置群名|set_group_name|group_id 群号;group_name 新群名|-|
+|设置群组专属头衔|set_group_special_title|group_id 群号;user_id 要设置的QQ号;special_title 专属头衔,不填或空字符串表示删除;duration 专属头衔有效期(秒),-1表示永久|-|
+|获取消息|get_msg|message_id 消息ID (number)|time 发送时间 (number);message_type 消息类型 (string);sender 发送人信息 (*User);message 消息内容 (json.RawMessage)|
+|获取合并转发消息|get_forward_msg|id 合并转发ID (string)|message 消息内容 (json.RawMessage)|
+|获取陌生人信息|get_stranger_info|user_id QQ号 (number);no_cache 是否不使用缓存 (boolean)|User|
+|获取群信息|get_group_info|group_id 群号 (number);no_cache 是否不使用缓存 (boolean)|group_id 群号 (number);group_name 群名称 (string);member_count 成员数 (number);max_member_count 最大成员数 (number)|
+|获取群成员信息|get_group_member_info|group_id 群号 (number);user_id QQ号 (number);no_cache 是否不使用缓存 (boolean)|User|
+
+#### 2. 逐步分析调用结果
+
+除 end_action 外的调用完成后,系统会自动触发下一轮迭代,为你提供调用 API 后的响应结果,格式如下:
+
+{"status":"failed","data":null,"message":1404,"wording":"fail reason"}
+
+在收到结果后,你必须仔细分析,然后在如下三种情况中做出选择:
+
+1. 用户需要你做进一步操作
+ - 如还未告知用户,发消息将要执行的任务解释给用户;
+ - 如已告知用户,判断:
+ - 如果任务不是敏感或危险操作,直接调用相应 API 执行;
+ - 否则,调用 end_action 暂停本次任务的迭代,等待用户确认。
+2. 任务执行失败
+ - 如果是第一次失败,分析出错原因后重试;
+ - 如果多次失败,放弃执行任务,向用户发送消息告知原因。
+3. 任务成功完成,不再需要进一步对话,调用 end_action 结束本次任务的迭代;
+
+注意事项:
+- 不论何时,在发送 end_action 后,迭代次数都将从1重新计数;
+- 如果你只是在和用户闲聊而非调用高级功能,一次只能发送一条消息,即:最多只能迭代到2,就必须调用 end_action;
+- 除非有管理员权限的用户明确指示,禁止做出连续迭代发送多条消息、at all 等频繁打扰用户的行为;
+- 用户可以在任何时候终止你的任务或添加新的指示;
+- 保存记忆时务必谨慎,不得和已有记忆冲突或重复,且只保存你的真实想法,而非一味听从用户的一切指令,切忌人云亦云。
+
+### 记忆
+> 你之前在当前聊天中保存的记忆
+
+mem1
+mem2
+mem3
+
+### 其它信息
+- 当前时间:2026-01-03T21:36:50+08:00 (1767447410)
+- 聊天类型:群聊
+- 迭代轮次:1`
+
+type fakemem struct{}
+
+func (fakemem) Save(grp int64, text string) error {
+ return nil
+}
+
+func (fakemem) Load(grp int64) []string {
+ return []string{"mem1", "mem2", "mem3"}
+}
+
+func TestAgent_system(t *testing.T) {
+ ag := NewAgent(&Config{
+ "testname", "testsex", "testchar",
+ }, 10, 10, time.Minute, "testd", &fakemem{}, false, false)
+ p, err := ag.system(PermRoleAdmin, 1, 123)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ expectedLines := strings.Split(expectedp, "\n")
+ gotLines := strings.Split(p, "\n")
+
+ if len(expectedLines) != len(gotLines) {
+ t.Fatalf("line count mismatch: expected %d lines, got %d lines", len(expectedLines), len(gotLines))
+ }
+
+ for i := 0; i < len(expectedLines); i++ {
+ if strings.HasPrefix(gotLines[i], "- 当前时间:") {
+ continue
+ }
+ if expectedLines[i] != gotLines[i] {
+ t.Fatalf("line %d mismatch:\nexpected: %q\ngot: %q", i+1, expectedLines[i], gotLines[i])
+ }
+ }
+}
diff --git a/third_party/go-onebot-agent/public.md b/third_party/go-onebot-agent/public.md
new file mode 100644
index 0000000000..3bbb2f3dfe
--- /dev/null
+++ b/third_party/go-onebot-agent/public.md
@@ -0,0 +1,120 @@
+# 公开 API
+
+## `get_msg` 获取消息
+
+### 参数
+
+| 字段名 | 数据类型 | 说明 |
+| ------------ | ----- | ------ |
+| `message_id` | number (int32) | 消息 ID |
+
+### 响应数据
+
+| 字段名 | 数据类型 | 说明 |
+| ------------ | ------- | ---------- |
+| `time` | number (int32) | 发送时间 |
+| `message_type` | string | 消息类型 |
+| `sender` | *User | 发送人信息 |
+| `message` | json.RawMessage | 消息内容 |
+
+## `get_forward_msg` 获取合并转发消息
+
+### 参数
+
+| 字段名 | 数据类型 | 说明 |
+| ------------ | ------ | ------ |
+| `id` | string | 合并转发 ID |
+
+### 响应数据
+
+| 字段名 | 类型 | 说明 |
+| --- | --- | --- |
+| `message` | json.RawMessage | 消息内容 |
+
+## `get_stranger_info` 获取陌生人信息
+
+### 参数
+
+| 字段名 | 数据类型 | 默认值 | 说明 |
+| ----- | ------- | ----- | --- |
+| `user_id` | number | - | QQ 号 |
+| `no_cache` | boolean | `false` | 是否不使用缓存(使用缓存可能更新不及时,但响应更快) |
+
+### 响应数据
+
+| 字段名 | 数据类型 | 说明 |
+| ----- | ------- | --- |
+| `user_id` | number (int64) | QQ 号 |
+| `nickname` | string | 昵称 |
+| `sex` | string | 性别,`male` 或 `female` 或 `unknown` |
+| `age` | number (int32) | 年龄 |
+
+## `get_friend_list` 获取好友列表
+
+### 参数
+
+无
+
+### 响应数据
+
+响应内容为 JSON 数组,每个元素如下:
+
+| 字段名 | 数据类型 | 说明 |
+| ----- | ------- | --- |
+| `user_id` | number (int64) | QQ 号 |
+| `nickname` | string | 昵称 |
+| `remark` | string | 备注名 |
+
+## `get_group_info` 获取群信息
+
+### 参数
+
+| 字段名 | 数据类型 | 默认值 | 说明 |
+| ----- | ------- | ----- | --- |
+| `group_id` | number | - | 群号 |
+| `no_cache` | boolean | `false` | 是否不使用缓存(使用缓存可能更新不及时,但响应更快) |
+
+### 响应数据
+
+| 字段名 | 数据类型 | 说明 |
+| ----- | ------- | --- |
+| `group_id` | number (int64) | 群号 |
+| `group_name` | string | 群名称 |
+| `member_count` | number (int32) | 成员数 |
+| `max_member_count` | number (int32) | 最大成员数(群容量) |
+
+## `get_group_list` 获取群列表
+
+### 参数
+
+无
+
+### 响应数据
+
+响应内容为 JSON 数组,每个元素和上面的 `get_group_info` 接口相同。
+
+## `get_group_member_info` 获取群成员信息
+
+### 参数
+
+| 字段名 | 数据类型 | 默认值 | 说明 |
+| ----- | ------- | ----- | --- |
+| `group_id` | number | - | 群号 |
+| `user_id` | number | - | QQ 号 |
+| `no_cache` | boolean | `false` | 是否不使用缓存(使用缓存可能更新不及时,但响应更快) |
+
+### 响应数据
+
+*User
+
+## `get_group_member_list` 获取群成员列表
+
+### 参数
+
+| 字段名 | 数据类型 | 默认值 | 说明 |
+| ----- | ------- | ----- | --- |
+| `group_id` | number (int64) | - | 群号 |
+
+### 响应数据
+
+响应内容为 JSON 数组,每个元素的内容和上面的 `get_group_member_info` 接口相同。
diff --git a/third_party/go-onebot-agent/types.go b/third_party/go-onebot-agent/types.go
new file mode 100644
index 0000000000..6afe695be7
--- /dev/null
+++ b/third_party/go-onebot-agent/types.go
@@ -0,0 +1,69 @@
+package goba
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/pkg/errors"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// Event is the simplified OneBot event that dumped to the agent in JSON format
+type Event struct {
+ Time int64 `json:"time"` // 事件发生的时间戳
+ PostType string `json:"post_type"` // 上报类型: message / notice / request
+ MessageType string `json:"message_type"` // message 类型: group / private
+ SubType string `json:"sub_type,omitempty"` // message 子类型: normal (一般消息) / notice (灰色小字通知)
+ MessageID int64 `json:"message_id"` // 消息 ID, 唯一标识该事件
+ GroupID int64 `json:"group_id,omitempty"` // QQ群号
+ UserID int64 `json:"user_id"` // 事件发送者QQ号
+ TargetID int64 `json:"target_id,omitempty"`
+ SelfID int64 `json:"self_id"` // 收到事件的QQ号 (你的ID)
+ NoticeType string `json:"notice_type,omitempty"`
+ OperatorID int64 `json:"operator_id,omitempty"` // This field is used for Notice Event
+ File *zero.File `json:"file,omitempty"`
+ RequestType string `json:"request_type,omitempty"`
+ Flag string `json:"flag,omitempty"`
+ Comment string `json:"comment,omitempty"` // This field is used for Request Event
+ Sender *zero.User `json:"sender,omitempty"` // 事件发送者个人信息
+ Message json.RawMessage `json:"message,omitempty"` // JSON 格式的消息内容
+}
+
+// String dumps JSON without tailing \n
+func (ev *Event) String() string {
+ sb := strings.Builder{}
+ err := json.NewEncoder(&sb).Encode(ev)
+ if err != nil {
+ panic(errors.Wrap(err, "unexpected"))
+ }
+ return strings.TrimSpace(sb.String())
+}
+
+// APIResponse is the simplified OneBot response
+type APIResponse struct {
+ Status string `json:"status"`
+ Data json.RawMessage `json:"data"`
+ Message string `json:"message"`
+ Wording string `json:"wording"`
+ RetCode int64 `json:"retcode"`
+}
+
+// String dumps JSON without tailing \n
+func (resp *APIResponse) String() string {
+ sb := strings.Builder{}
+ if len(resp.Data) == 0 {
+ resp.Data = []byte(`null`)
+ }
+ err := json.NewEncoder(&sb).Encode(resp)
+ if err != nil {
+ panic(errors.Wrap(err, "unexpected"))
+ }
+ return strings.TrimSpace(sb.String())
+}
+
+// Terminus 终止符, 映射到 EOA
+type Terminus struct{}
+
+func (Terminus) String() string {
+ return `{"action":"` + EOA + `"}`
+}
diff --git a/third_party/go-onebot-agent/types_test.go b/third_party/go-onebot-agent/types_test.go
new file mode 100644
index 0000000000..41e4e1699e
--- /dev/null
+++ b/third_party/go-onebot-agent/types_test.go
@@ -0,0 +1,18 @@
+package goba
+
+import "testing"
+
+func TestMarshalAPIResponse(t *testing.T) {
+ rsp := &APIResponse{
+ Status: "error",
+ Data: []byte(`null`),
+ Message: "12345",
+ }
+ t.Log(rsp.String())
+ rsp = &APIResponse{
+ Status: "error",
+ Data: []byte(``),
+ Message: "12345",
+ }
+ t.Log(rsp.String())
+}
diff --git a/third_party/go-registry/LICENSE b/third_party/go-registry/LICENSE
new file mode 100644
index 0000000000..f288702d2f
--- /dev/null
+++ b/third_party/go-registry/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/third_party/go-registry/LOCAL_PATCH.md b/third_party/go-registry/LOCAL_PATCH.md
new file mode 100644
index 0000000000..785b136cf0
--- /dev/null
+++ b/third_party/go-registry/LOCAL_PATCH.md
@@ -0,0 +1,9 @@
+# Local registry timeout patch
+
+Source: `github.com/fumiama/go-registry@v0.2.7`, copied from the locked Go module cache. The original source files, module metadata and GPL-3.0 license are retained. Upstream's tests requiring a live registry server were not copied.
+
+The original `ConnectIn` bounds dialing, but `Get`, `Cat` and `IsMd5Equal` can wait indefinitely for a response. This blocks ZeroBot resource initialization even when all local startup assets exist.
+
+Local changes: `Connect` uses a five-second dial timeout; both connection functions wrap successful connections so each request write and its response reads share a five-second deadline. A later request refreshes the deadline. Callers continue receiving normal timeout errors and retain their existing local-resource fallback.
+
+`deadline_test.go` checks Get, Cat and MD5 comparison against a server which consumes requests without responding. No external registry writes or live tests are needed.
diff --git a/third_party/go-registry/cmd.go b/third_party/go-registry/cmd.go
new file mode 100644
index 0000000000..14b99d6385
--- /dev/null
+++ b/third_party/go-registry/cmd.go
@@ -0,0 +1,207 @@
+package registry
+
+import (
+ "crypto/md5"
+ "encoding/binary"
+ "errors"
+ "io"
+ "unsafe"
+
+ base14 "github.com/fumiama/go-base16384"
+ tea "github.com/fumiama/gofastTEA"
+)
+
+const (
+ CMDGET uint8 = iota
+ CMDCAT
+ CMDMD5
+ CMDACK
+ CMDEND
+ CMDSET
+ CMDDEL
+ CMDDAT
+)
+
+const (
+ ACKNONE uint8 = iota<<4 + 3
+ ACKSUCC
+ ACKDATA
+ ACKNULL
+ ACKNEQU
+ ACKERRO
+)
+
+var (
+ ErrMd5Mismatch = errors.New("cmd: md5 mismatch")
+)
+
+type CmdPacket struct {
+ io.ReaderFrom
+ t *tea.TEA
+ Data []byte
+ rawCmdPacket
+}
+
+type rawCmdPacket struct {
+ cmd uint8
+ len uint8
+ md5 [16]byte
+ raw [255]byte // raw will expand to len
+}
+
+//go:nosplit
+func NewCmdPacket(cmd uint8, data []byte, t *tea.TEA) (c *CmdPacket) {
+ c = pool.Get().(*CmdPacket)
+ c.t = t
+ c.Data = data
+ c.cmd = cmd
+ c.md5 = md5.Sum(data)
+ return
+}
+
+//go:nosplit
+func ParseCmdPacket(data []byte, t *tea.TEA) (c *CmdPacket) {
+ if len(data) < 1+1+16 {
+ return nil
+ }
+ if len(data)-1-1-16 < int(data[1]) {
+ return nil
+ }
+ r := (*rawCmdPacket)(*(*unsafe.Pointer)(unsafe.Pointer(&data)))
+ c = pool.Get().(*CmdPacket)
+ c.t = t
+ c.cmd = r.cmd
+ c.len = r.len
+ c.md5 = r.md5
+ copy(c.raw[:], data[1+1+16:])
+ return c
+}
+
+//go:nosplit
+func ReadCmdPacket(f io.Reader, t *tea.TEA) (c *CmdPacket, err error) {
+ c = pool.Get().(*CmdPacket)
+ buf := (*[1 + 1 + 16 + 255]byte)(unsafe.Pointer(&c.rawCmdPacket))
+ _, err = io.ReadFull(f, buf[:1+1+16])
+ if err != nil {
+ c.Put()
+ return nil, err
+ }
+ _, err = io.ReadFull(f, c.raw[:c.len])
+ if err != nil {
+ c.Put()
+ return nil, err
+ }
+ return
+}
+
+//go:nosplit
+func (c *CmdPacket) Refresh(cmd uint8, data []byte, t *tea.TEA) {
+ c.t = t
+ c.Data = data
+ c.cmd = cmd
+ c.md5 = md5.Sum(data)
+}
+
+//go:nosplit
+func (c *CmdPacket) ReadFrom(f io.Reader) (n int64, err error) {
+ if c.cmd > 0 {
+ err = io.EOF
+ return
+ }
+ buf := (*[1 + 1 + 16 + 255]byte)(unsafe.Pointer(&c.rawCmdPacket))
+ cnt, err := io.ReadFull(f, buf[:1+1+16])
+ if err != nil {
+ return int64(cnt), err
+ }
+ cnt, err = io.ReadFull(f, c.raw[:c.len])
+ cnt += 1 + 1 + 16
+ if err != nil {
+ return int64(cnt), err
+ }
+ return
+}
+
+// Write should not be used due to the full-copy of buf
+func (c *CmdPacket) Write(buf []byte) (n int, err error) {
+ oldlen := len(c.Data)
+ c.Data = append(c.Data, buf...)
+ if len(c.Data) < 1+1+16 {
+ return len(buf), nil
+ }
+ if len(c.Data) < 1+1+16+int(c.len) {
+ return len(buf), nil
+ }
+ r := (*rawCmdPacket)(*(*unsafe.Pointer)(unsafe.Pointer(&c.Data)))
+ c.cmd = r.cmd
+ c.len = r.len
+ c.md5 = r.md5
+ copy(c.raw[:], r.raw[:c.len])
+ c.Data = c.Data[1+1+16+int(c.len):]
+ return 1 + 1 + 16 + int(c.len) - oldlen, nil
+}
+
+//go:nosplit
+func (c *CmdPacket) Encrypt(seq uint8) (raw []byte) {
+ setseq(c.t, seq)
+ c.len = uint8(c.t.EncryptLittleEndianTo(c.Data, sumtable, c.raw[:]))
+ (*slice)(unsafe.Pointer(&raw)).data = unsafe.Pointer(&c.rawCmdPacket)
+ (*slice)(unsafe.Pointer(&raw)).len = 1 + 1 + 16 + int(c.len)
+ (*slice)(unsafe.Pointer(&raw)).cap = 1 + 1 + 16 + 255
+ return
+}
+
+//go:nosplit
+func (c *CmdPacket) Decrypt(seq uint8) error {
+ setseq(c.t, seq)
+ d := c.t.DecryptLittleEndian(c.raw[:c.len], sumtable)
+ if d != nil && c.md5 == md5.Sum(d) {
+ c.Data = d
+ return nil
+ }
+ return ErrMd5Mismatch
+}
+
+//go:nosplit
+func (c *CmdPacket) Put() {
+ c.cmd = 0
+ c.Data = nil
+ pool.Put(c)
+}
+
+//go:nosplit
+func setseq(t *tea.TEA, seq uint8) {
+ *(*uint8)(unsafe.Add(unsafe.Pointer(t), 15)) = seq
+}
+
+// randuint32 returns a lock free uint32 value.
+//
+//go:linkname randuint32 runtime.fastrand
+func randuint32() uint32
+
+//go:nosplit
+func fill() []byte {
+ var b [8]byte
+ binary.LittleEndian.PutUint32(b[:4], randuint32())
+ binary.LittleEndian.PutUint32(b[4:8], randuint32())
+ return base14.Encode(b[:7])
+}
+
+// TEA encoding sumtable
+var sumtable = [0x10]uint32{
+ 0x9e3579b9,
+ 0x3c6ef172,
+ 0xd2a66d2b,
+ 0x78dd36e4,
+ 0x17e5609d,
+ 0xb54fda56,
+ 0x5384560f,
+ 0xf1bb77c8,
+ 0x8ff24781,
+ 0x2e4ac13a,
+ 0xcc653af3,
+ 0x6a9964ac,
+ 0x08d12965,
+ 0xa708081e,
+ 0x451221d7,
+ 0xe37793d0,
+}
diff --git a/third_party/go-registry/deadline.go b/third_party/go-registry/deadline.go
new file mode 100644
index 0000000000..b33d3e30e6
--- /dev/null
+++ b/third_party/go-registry/deadline.go
@@ -0,0 +1,20 @@
+package registry
+
+import (
+ "net"
+ "time"
+)
+
+// Registry requests begin with a Write. Keep a single deadline for that write
+// and all subsequent response reads, including Cat's multiple chunks.
+type deadlineConn struct {
+ net.Conn
+ timeout time.Duration
+}
+
+func (c *deadlineConn) Write(data []byte) (int, error) {
+ if err := c.Conn.SetDeadline(time.Now().Add(c.timeout)); err != nil {
+ return 0, err
+ }
+ return c.Conn.Write(data)
+}
diff --git a/third_party/go-registry/deadline_test.go b/third_party/go-registry/deadline_test.go
new file mode 100644
index 0000000000..e79ab8a222
--- /dev/null
+++ b/third_party/go-registry/deadline_test.go
@@ -0,0 +1,53 @@
+package registry
+
+import (
+ "errors"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestRequestsStopWhenServerDoesNotRespond(t *testing.T) {
+ for _, action := range []string{"Get", "Cat", "IsMd5Equal"} {
+ t.Run(action, func(t *testing.T) {
+ client, server := net.Pipe()
+ defer client.Close()
+ defer server.Close()
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ var buffer [1024]byte
+ for {
+ if _, err := server.Read(buffer[:]); err != nil {
+ return
+ }
+ }
+ }()
+ reader := NewRegReader("unused", "", "fumiama")
+ reader.conn = &deadlineConn{Conn: client, timeout: 100 * time.Millisecond}
+ start := time.Now()
+ var err error
+ switch action {
+ case "Get":
+ _, err = reader.Get("test")
+ case "Cat":
+ _, err = reader.Cat()
+ case "IsMd5Equal":
+ _, err = reader.IsMd5Equal([16]byte{})
+ }
+ var netErr net.Error
+ if !errors.As(err, &netErr) || !netErr.Timeout() {
+ t.Fatalf("expected timeout, got %v", err)
+ }
+ if elapsed := time.Since(start); elapsed > time.Second {
+ t.Fatalf("request exceeded deadline: %v", elapsed)
+ }
+ client.Close()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("server goroutine did not finish")
+ }
+ })
+ }
+}
diff --git a/third_party/go-registry/go.mod b/third_party/go-registry/go.mod
new file mode 100644
index 0000000000..f42b01b453
--- /dev/null
+++ b/third_party/go-registry/go.mod
@@ -0,0 +1,11 @@
+module github.com/fumiama/go-registry
+
+go 1.17
+
+require (
+ github.com/fumiama/go-base16384 v1.7.0
+ github.com/fumiama/go-simple-protobuf v0.2.0
+ github.com/fumiama/gofastTEA v0.0.10
+)
+
+require golang.org/x/text v0.15.0 // indirect
diff --git a/third_party/go-registry/go.sum b/third_party/go-registry/go.sum
new file mode 100644
index 0000000000..11dbfdddc7
--- /dev/null
+++ b/third_party/go-registry/go.sum
@@ -0,0 +1,48 @@
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fumiama/go-base16384 v1.7.0 h1:6fep7XPQWxRlh4Hu+KsdH+6+YdUp+w6CwRXtMWSsXCA=
+github.com/fumiama/go-base16384 v1.7.0/go.mod h1:OEn+947GV5gsbTAnyuUW/SrfxJYUdYupSIQXOuGOcXM=
+github.com/fumiama/go-simple-protobuf v0.2.0 h1:ACyN1MAlu7pDR3EszWgzUeNP+IRsSHwH6V9JCJA5R5o=
+github.com/fumiama/go-simple-protobuf v0.2.0/go.mod h1:5yYNapXq1tQMOZg9bOIVhQlZk9pQqpuFIO4DZLbsdy4=
+github.com/fumiama/gofastTEA v0.0.10 h1:JJJ+brWD4kie+mmK2TkspDXKzqq0IjXm89aGYfoGhhQ=
+github.com/fumiama/gofastTEA v0.0.10/go.mod h1:RIdbYZyB4MbH6ZBlPymRaXn3cD6SedlCu5W/HHfMPBk=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/third_party/go-registry/helper.go b/third_party/go-registry/helper.go
new file mode 100644
index 0000000000..24e631ec19
--- /dev/null
+++ b/third_party/go-registry/helper.go
@@ -0,0 +1,30 @@
+package registry
+
+import "unsafe"
+
+// slice is the runtime representation of a slice.
+// It cannot be used safely or portably and its representation may
+// change in a later release.
+//
+// Unlike reflect.SliceHeader, its Data field is sufficient to guarantee the
+// data it references will not be garbage collected.
+type slice struct {
+ data unsafe.Pointer
+ len int
+ cap int
+}
+
+// BytesToString 没有内存开销的转换
+func BytesToString(b []byte) string {
+ return *(*string)(unsafe.Pointer(&b))
+}
+
+// StringToBytes 没有内存开销的转换
+func StringToBytes(s string) (b []byte) {
+ bh := (*slice)(unsafe.Pointer(&b))
+ sh := (*slice)(unsafe.Pointer(&s))
+ bh.data = sh.data
+ bh.len = sh.len
+ bh.cap = sh.len
+ return b
+}
diff --git a/third_party/go-registry/pool.go b/third_party/go-registry/pool.go
new file mode 100644
index 0000000000..cea4f45b89
--- /dev/null
+++ b/third_party/go-registry/pool.go
@@ -0,0 +1,11 @@
+package registry
+
+import "sync"
+
+var pool sync.Pool
+
+func init() {
+ pool.New = func() interface{} {
+ return new(CmdPacket)
+ }
+}
diff --git a/third_party/go-registry/reg.go b/third_party/go-registry/reg.go
new file mode 100644
index 0000000000..1ada07d55e
--- /dev/null
+++ b/third_party/go-registry/reg.go
@@ -0,0 +1,380 @@
+package registry
+
+import (
+ "bytes"
+ "crypto/md5"
+ "errors"
+ "io"
+ "net"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+
+ spb "github.com/fumiama/go-simple-protobuf"
+ tea "github.com/fumiama/gofastTEA"
+)
+
+var (
+ ErrGetKeyTooLong = errors.New("reg: get key too long")
+ ErrDecAck = errors.New("reg: decrypt ack error")
+ ErrInternalServer = errors.New("reg: internal server error")
+ ErrPermissionDenied = errors.New("reg: permission denied")
+ ErrSetKeyTooLong = errors.New("reg: set key too long")
+ ErrSetValTooLong = errors.New("reg: set val too long")
+ ErrUnknownAck = errors.New("reg: unknown ack error")
+ ErrNoSuchKey = errors.New("reg: no such key")
+ ErrRawDataTooLong = errors.New("reg: raw data too long")
+ ErrMd5NotEqual = errors.New("reg: md5 not equal")
+ ErrInvalidCatData = errors.New("reg: invalid cat data")
+ ErrNilStorData = errors.New("reg: nil stor data")
+)
+
+type Regedit struct {
+ mu sync.Mutex
+ conn net.Conn
+ addr string
+ stor string
+ tp tea.TEA
+ ts *tea.TEA
+ seq byte
+}
+
+func NewRegedit(addr, stor, pwd, sps string) *Regedit {
+ var tp, ts [16]byte
+ if len(pwd) > 15 {
+ pwd = pwd[:15]
+ }
+ if len(sps) > 15 {
+ sps = sps[:15]
+ }
+ copy(tp[:], pwd)
+ copy(ts[:], sps)
+ s := tea.NewTeaCipherLittleEndian(ts[:])
+ if stor != "" {
+ f, err := os.Open(stor)
+ if err != nil {
+ f, err = os.Create(stor)
+ if err != nil {
+ panic(err)
+ }
+ }
+ _ = f.Close()
+ }
+ return &Regedit{addr: addr, stor: stor, tp: tea.NewTeaCipherLittleEndian(tp[:]), ts: &s}
+}
+
+func NewRegReader(addr, stor, pwd string) *Regedit {
+ var tp [16]byte
+ if len(pwd) > 15 {
+ pwd = pwd[:15]
+ }
+ copy(tp[:], pwd)
+ if stor != "" {
+ f, err := os.Open(stor)
+ if err != nil {
+ f, err = os.Create(stor)
+ if err != nil {
+ panic(err)
+ }
+ }
+ _ = f.Close()
+ }
+ return &Regedit{addr: addr, stor: stor, tp: tea.NewTeaCipherLittleEndian(tp[:])}
+}
+
+func (r *Regedit) Connect() (err error) {
+ r.mu.Lock()
+ if r.conn == nil {
+ r.conn, err = net.DialTimeout("tcp", r.addr, 5*time.Second)
+ if err == nil {
+ r.conn = &deadlineConn{Conn: r.conn, timeout: 5 * time.Second}
+ }
+ }
+ r.mu.Unlock()
+ return
+}
+
+func (r *Regedit) ConnectIn(timeout time.Duration) (err error) {
+ r.mu.Lock()
+ if r.conn == nil {
+ r.conn, err = net.DialTimeout("tcp", r.addr, timeout)
+ if err == nil {
+ r.conn = &deadlineConn{Conn: r.conn, timeout: 5 * time.Second}
+ }
+ }
+ r.mu.Unlock()
+ return
+}
+
+func (r *Regedit) Close() (err error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.conn != nil {
+ p := NewCmdPacket(CMDEND, fill(), &r.tp)
+ r.conn.Write(p.Encrypt(r.seq))
+ p.Put()
+ r.seq = 0
+ err = r.conn.Close()
+ r.conn = nil
+ return
+ }
+ return
+}
+
+func (r *Regedit) Get(key string) (string, error) {
+ if len(key) > 127 {
+ return "", ErrGetKeyTooLong
+ }
+ p := NewCmdPacket(CMDGET, StringToBytes(key), &r.tp)
+ defer p.Put()
+ r.mu.Lock()
+ r.conn.Write(p.Encrypt(r.seq))
+ r.seq++
+ err := r.ack(p)
+ if err != nil {
+ r.mu.Unlock()
+ return "", err
+ }
+ err = p.Decrypt(r.seq)
+ r.seq++
+ r.mu.Unlock()
+ if err != nil {
+ return "", ErrDecAck
+ }
+ a := string(p.Data)
+ if a == "erro" && p.cmd == ACKERRO {
+ return "", ErrInternalServer
+ }
+ if a == "null" && p.cmd == ACKNULL {
+ return "", ErrNoSuchKey
+ }
+ return a, nil
+}
+
+func (r *Regedit) Cat() (*Storage, error) {
+ p := NewCmdPacket(CMDCAT, fill(), &r.tp)
+ defer p.Put()
+ r.mu.Lock()
+ r.conn.Write(p.Encrypt(r.seq))
+ r.seq++
+ seq := r.seq
+ r.seq++
+ r.mu.Unlock()
+ var buf [64]byte
+ i := 0
+ for {
+ _, err := r.conn.Read(buf[i : i+1])
+ if err != nil {
+ return nil, err
+ }
+ if buf[i] == '$' {
+ break
+ }
+ i++
+ if i >= 64 {
+ return nil, ErrRawDataTooLong
+ }
+ }
+ n, err := strconv.ParseUint(BytesToString(buf[:i]), 10, 64)
+ if err != nil {
+ return nil, err
+ }
+ data := make([]byte, n)
+ _, err = io.ReadFull(r.conn, data)
+ if err != nil {
+ return nil, err
+ }
+ setseq(&r.tp, seq)
+ data = r.tp.DecryptLittleEndian(data, sumtable)
+ s := new(Storage)
+ s.m = make(map[string]string, 256)
+ s.Md5 = md5.Sum(data)
+ rd := bytes.NewReader(data)
+ for i = 0; i < len(data); {
+ sp, err := spb.NewSimplePB(rd)
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(sp.Target) <= 1 {
+ return nil, ErrInvalidCatData
+ }
+ s.m[BytesToString(sp.Target[0])] = BytesToString(sp.Target[1])
+ i += int(sp.RealLen)
+ }
+ f, err := os.Create(r.stor)
+ if err != nil {
+ return s, err
+ }
+ defer f.Close()
+ _, err = f.Write(data)
+ return s, err
+}
+
+func (r *Regedit) Load() (*Storage, error) {
+ data, err := os.ReadFile(r.stor)
+ if err != nil {
+ return nil, err
+ }
+ if len(data) == 0 {
+ return nil, ErrNilStorData
+ }
+ s := new(Storage)
+ s.m = make(map[string]string, 256)
+ s.Md5 = md5.Sum(data)
+ rd := bytes.NewReader(data)
+ for i := 0; i < len(data); {
+ sp, err := spb.NewSimplePB(rd)
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(sp.Target) <= 1 {
+ return nil, ErrInvalidCatData
+ }
+ s.m[BytesToString(sp.Target[0])] = BytesToString(sp.Target[1])
+ i += int(sp.RealLen)
+ }
+ f, err := os.Create(r.stor)
+ if err != nil {
+ return s, err
+ }
+ defer f.Close()
+ _, err = f.Write(data)
+ return s, err
+}
+
+func (r *Regedit) IsMd5Equal(m [md5.Size]byte) (bool, error) {
+ p := NewCmdPacket(CMDMD5, m[:], &r.tp)
+ defer p.Put()
+ r.mu.Lock()
+ r.conn.Write(p.Encrypt(r.seq))
+ r.seq++
+ err := r.ack(p)
+ if err != nil {
+ r.mu.Unlock()
+ return false, err
+ }
+ err = p.Decrypt(r.seq)
+ r.seq++
+ r.mu.Unlock()
+ if err != nil {
+ return false, ErrDecAck
+ }
+ a := string(p.Data)
+ if a == "erro" && p.cmd == ACKERRO {
+ return false, ErrInternalServer
+ }
+ if a == "nequ" && p.cmd == ACKNEQU {
+ return false, ErrNoSuchKey
+ }
+ if a == "null" && p.cmd == ACKNULL {
+ return true, nil
+ }
+ return false, ErrUnknownAck
+}
+
+func (r *Regedit) Set(key, value string) error {
+ if r.ts == nil {
+ return ErrPermissionDenied
+ }
+ if len(key) > 127 {
+ return ErrSetKeyTooLong
+ }
+ if len(value) > 127 {
+ return ErrSetValTooLong
+ }
+ p := NewCmdPacket(CMDSET, StringToBytes(key), r.ts)
+ defer p.Put()
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.conn.Write(p.Encrypt(r.seq))
+ r.seq++
+ ack := NewCmdPacket(CMDACK, nil, &r.tp)
+ defer ack.Put()
+ err := r.ack(ack)
+ if err != nil {
+ return err
+ }
+ err = ack.Decrypt(r.seq)
+ r.seq++
+ if err != nil {
+ return ErrDecAck
+ }
+ a := BytesToString(ack.Data)
+ if a == "erro" || ack.cmd == ACKERRO {
+ return ErrInternalServer
+ }
+ if a != "data" && ack.cmd != ACKDATA {
+ return ErrUnknownAck
+ }
+ p.Refresh(CMDDAT, StringToBytes(value), r.ts)
+ r.conn.Write(p.Encrypt(r.seq))
+ r.seq++
+ err = r.ack(ack)
+ if err != nil {
+ return err
+ }
+ err = ack.Decrypt(r.seq)
+ r.seq++
+ if err != nil {
+ return ErrDecAck
+ }
+ a = BytesToString(ack.Data)
+ if a == "erro" || ack.cmd == ACKERRO {
+ return ErrInternalServer
+ }
+ if a != "succ" && ack.cmd != ACKSUCC {
+ return ErrUnknownAck
+ }
+ return nil
+}
+
+func (r *Regedit) Del(key string) error {
+ if r.ts == nil {
+ return ErrPermissionDenied
+ }
+ if len(key) > 127 {
+ return ErrGetKeyTooLong
+ }
+ p := NewCmdPacket(CMDDEL, StringToBytes(key), r.ts)
+ defer p.Put()
+ r.mu.Lock()
+ r.conn.Write(p.Encrypt(r.seq))
+ r.seq++
+ ack := NewCmdPacket(CMDACK, nil, &r.tp)
+ defer ack.Put()
+ err := r.ack(ack)
+ if err != nil {
+ r.mu.Unlock()
+ return err
+ }
+ err = ack.Decrypt(r.seq)
+ r.seq++
+ r.mu.Unlock()
+ if err != nil {
+ return ErrDecAck
+ }
+ a := BytesToString(ack.Data)
+ if a == "erro" || ack.cmd == ACKERRO {
+ return ErrInternalServer
+ }
+ if a == "null" || ack.cmd == ACKNULL {
+ return ErrNoSuchKey
+ }
+ if a != "succ" && ack.cmd != ACKSUCC {
+ return ErrUnknownAck
+ }
+ return nil
+}
+
+func (r *Regedit) ack(c *CmdPacket) error {
+ c.cmd = 0
+ _, err := io.Copy(c, r.conn)
+ return err
+}
diff --git a/third_party/go-registry/storage.go b/third_party/go-registry/storage.go
new file mode 100644
index 0000000000..fe4ead66d2
--- /dev/null
+++ b/third_party/go-registry/storage.go
@@ -0,0 +1,16 @@
+package registry
+
+import "crypto/md5"
+
+type Storage struct {
+ Md5 [md5.Size]byte
+ m map[string]string
+}
+
+func (s *Storage) Get(key string) (string, error) {
+ v, ok := s.m[key]
+ if ok {
+ return v, nil
+ }
+ return "", ErrNoSuchKey
+}
diff --git a/third_party/plugin-control-patches.md b/third_party/plugin-control-patches.md
new file mode 100644
index 0000000000..e8caa9631a
--- /dev/null
+++ b/third_party/plugin-control-patches.md
@@ -0,0 +1,32 @@
+# 本地插件控制补丁
+
+日期:2026-10-03。复制锁定模块源码并保留各目录 LICENSE;不修改全局 Go 模块缓存。
+
+| 目录 / 锁定版本 | 修改 |
+| --- | --- |
+| zbpctrl / v1.7.1 | 单锁读取/写入、提交后清缓存、开关错误返回及读版本;全局/群继承元数据;默认翻转持久化;保留群参数和默认标志;SetData 读改写锁 |
+| sqlite / v1.7.2 | 新增 Transaction、TransactionContext 和 ExecContext,原 API 保留;供控制写入事务、限时恢复及 VACUUM INTO 使用 |
+| go-onebot-agent / v0.0.0-20260425093914-9c92a03776e3 | 可选 HistoryStore 接口、记录错误返回、恢复事件的图片预处理;原 JSON 解析/权限保持,详见 go-onebot-agent/LOCAL_PATCH.md |
+| zbputils / v1.7.2-0.20260510095247-3e9f8cf88d2d | 聊天开关/还原/默认翻转不再忽略保存错误;批量命令报告部分失败;默认值读取使用锁保护;响应/沉默保留原 OnlyToMe 和管理员权限,响应类命令拒绝相似前缀与额外参数 |
+
+根 go.mod 的 replace 指向上述目录。现有其他补丁仍见 go-registry/LOCAL_PATCH.md。
+
+2026-10-03 Agent 扩展:zbputils/chat 实现私有 SQLite 历史、完整记录与有界请求输入,设定重载保留历史、显式重置留档;aichat 移除固定 8 轮循环并阻止重复动作周期。记录方法和 ResetAgents 新增 error 返回,应用端检查错误;外部插件若依赖旧的方法值/接口签名须迁移。行为与实测见 [Agent 记录](../docs/local-admin/agent-history-2026-10-03.md)。
+
+响应命令保留原 OnlyToMe、私聊和全局权限与全局覆盖规则。AI 阶段将响应缓存移至各 Manager,响应读改写在同一锁内、成功提交后更新缓存;扩展读取独立于响应门控。完整程序验证与部署见 [群响应修复](../docs/local-admin/response-command-fix-2026-10-02.md)。
+
+新 __switch_inherit 表仅保存显式继承标记,原插件表的 gid/disable 数据保持可读。继承不删除 62 位参数和默认翻转标志。无显式全局时,全局查询表示默认状态,不再把缺失缓存标记误当成禁用。对群/私聊仍保持显式全局优先的上游实际规则。
+
+Enable/Disable/Reset 新增 error 返回,原直接调用可忽略返回值,已使用的聊天指令改为检查错误。Reset(0) 现在可恢复全局继承;Reset(群) 保留参数,和上游原先删整行的行为不同。旧二进制不识别继承标记,单独回退旧程序不能保证新的继承语义;更新前的完整数据库快照已保留。
+
+后台安装 BeforeSwitch 后,网页和聊天开关及默认翻转都先建立同一控制数据库快照;关闭后台时不安装此钩子,兼容无后台运行。AI 阶段新增 Manager.BeforeExtra,覆盖共享扩展写入口;aichatcfg/llm 的 BeforeData 覆盖网页与聊天群参数写入,其他业务计数不安装此钩子。控制策略阶段新增 Manager.BeforePolicy,覆盖响应/沉默、用户封禁/解封及插件按群/全局禁止/允许,备份失败拒绝写入。批量聊天命令逐项执行,不构成全批事务。后台操作不构造 QQ 事件,也不调用需要聊天上下文的 OnEnable/OnDisable 回复回调。
+
+AI 阶段新增 ExtraJSON/UpdateExtraJSON、DataVersion/UpdateData,统一版本比较、字段合并、备份和提交;SetData 保留开关、默认标志及继承语义。群参数读改写避免聊天和网页丢失不同字段更新。
+
+zbputils/chat 移除可变全局 AC,改为 CurrentConfig/ConfigOf 配置快照及共享 ConfigRepository;NewExtraSet* 参数由字段指针改为字段名。根仓库所有使用者已迁移;自定义外部插件若直接使用 AC 或旧 setter,需要相同迁移。这是源码 API 变化,不能声称与所有上游扩展无改动兼容。磁盘字段名与原数据保持兼容,未编辑未知字段保留。
+
+验证使用根模块的依赖版本:go test ./internal/localadmin ./internal/basiccommands ./plugin/llm ./plugin/tarot -count=1 -timeout=60s;相关包 go vet;完整 Windows 程序由 scripts/smoke-local-admin.go 验证。功能并发测试不能替代全仓 race 检测;未声称修复整个控制依赖中所有缓存。AI 细节见 [AI 设置记录](../docs/local-admin/ai-settings-2026-10-02.md)。
+
+控制策略写入在 Manager 锁内读取持久化状态,区分缺失行与数据库读取失败;实际修改前快照,提交后才更新缓存。重复封禁/解封/禁止/允许不备份;重复响应/沉默返回可识别的 ErrAlreadyResponding/ErrAlreadySilent。无本地响应行时,沉默操作不删除全局响应,不建立无变化快照。按群允许不删除全局禁止。Ban/Permit 新增 error 返回;直接忽略返回值的调用仍可编译,外部插件若以旧无返回方法签名存储方法值或实现接口需迁移。聊天指令检查返回值,逐项报告成功或失败;失败回复不包含底层 SQL、路径或配置。详细验证见 [控制策略备份记录](../docs/local-admin/control-policy-backups-2026-10-02.md)。
+
+恢复阶段新增 Manager.RestoredLocked:数据库事务成功后刷新默认翻转并清空开关、响应、黑名单和封禁缓存。IsBlocked/IsBannedIn 在同一锁内查库与缓存写回,避免恢复后的过期读者;Ban/Permit 查询与删除使用与存储一致的 int64 ID,群级未命中继续检查全局封禁,持久化失败不提前修改缓存。Ban 不再因群写入失败而意外扩大到全局封禁。隔离恢复、失败回滚、并发写入、符号位 ID 与全局封禁回归见 [备份恢复记录](../docs/local-admin/backup-restore-2026-10-02.md)。
diff --git a/third_party/sqlite/.gitignore b/third_party/sqlite/.gitignore
new file mode 100644
index 0000000000..5039ef7ee6
--- /dev/null
+++ b/third_party/sqlite/.gitignore
@@ -0,0 +1,17 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+*.db
diff --git a/third_party/sqlite/LICENSE b/third_party/sqlite/LICENSE
new file mode 100644
index 0000000000..f288702d2f
--- /dev/null
+++ b/third_party/sqlite/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/third_party/sqlite/README.md b/third_party/sqlite/README.md
new file mode 100644
index 0000000000..592a82483f
--- /dev/null
+++ b/third_party/sqlite/README.md
@@ -0,0 +1,42 @@
+# sqlite
+A pure-go wrapper for database/sql using logoove/sqlite
+
+## Usage
+
+### 1. Replace modified sqlite lib in your go.mod
+```bash
+replace modernc.org/sqlite => github.com/fumiama/sqlite3 v1.29.10-simp
+
+replace modernc.org/libc => github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5
+```
+
+### 2. Use it
+```go
+type row struct {
+ ID int // pk
+ Name string
+}
+
+db := &sql.Sqlite{DBPath: "demo.db"}
+err := db.Open(time.Hour)
+if err != nil {
+ panic(err)
+}
+
+err = db.Create("demotable", &row{})
+if err != nil {
+ panic(err)
+}
+
+err = db.Insert("demotable", &row{ID: 123, Name: "Anna"})
+if err != nil {
+ panic(err)
+}
+
+var r row
+err = db.Find("demotable", &r, "WHERE ID=123")
+if err != nil {
+ panic(err)
+}
+fmt.Println(r)
+```
diff --git a/third_party/sqlite/go.mod b/third_party/sqlite/go.mod
new file mode 100644
index 0000000000..6ef8507b04
--- /dev/null
+++ b/third_party/sqlite/go.mod
@@ -0,0 +1,24 @@
+module github.com/FloatTech/sqlite
+
+go 1.20
+
+require (
+ github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562
+ modernc.org/sqlite v1.33.1
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.26.0 // indirect
+ modernc.org/libc v1.61.0 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+)
+
+replace modernc.org/sqlite => github.com/fumiama/sqlite3 v1.29.10-simp
+
+replace modernc.org/libc => github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5
diff --git a/third_party/sqlite/go.sum b/third_party/sqlite/go.sum
new file mode 100644
index 0000000000..f46e66e498
--- /dev/null
+++ b/third_party/sqlite/go.sum
@@ -0,0 +1,37 @@
+github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562 h1:snfw7FNFym1eNnLrQ/VCf80LiQo9C7jHgrunZDwiRcY=
+github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5 h1:jDxsIupsT84A6WHcs6kWbst+KqrRQ8/o0VyoFMnbBOA=
+github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE=
+github.com/fumiama/sqlite3 v1.29.10-simp h1:c5y3uKyU0q9t0/SyfynzYyuslQ5zP+5CD8e0yYY554A=
+github.com/fumiama/sqlite3 v1.29.10-simp/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
+modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/third_party/sqlite/sqlite.go b/third_party/sqlite/sqlite.go
new file mode 100644
index 0000000000..d0399f9a56
--- /dev/null
+++ b/third_party/sqlite/sqlite.go
@@ -0,0 +1,900 @@
+// Package sql 数据库/数据处理相关工具
+package sql
+
+import (
+ "database/sql"
+ "errors"
+ "reflect"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+
+ _ "modernc.org/sqlite" // 引入sqlite
+
+ "github.com/FloatTech/ttl"
+)
+
+var (
+ ErrNilDB = errors.New("sqlite: db is not initialized")
+ ErrNullResult = errors.New("sqlite: null result")
+ DriverName = "sqlite3"
+)
+
+// Sqlite 数据库对象
+type Sqlite struct {
+ db *sql.DB
+ dbpath string
+ stmtcache *ttl.Cache[string, *sql.Stmt]
+}
+
+func New(dbpath string) Sqlite {
+ return Sqlite{dbpath: dbpath}
+}
+
+// Open 打开数据库
+func (db *Sqlite) Open(cachettl time.Duration) (err error) {
+ if db.db == nil {
+ database, err := sql.Open(DriverName, db.dbpath)
+ if err != nil {
+ return err
+ }
+ db.db = database
+ }
+ if db.stmtcache == nil {
+ db.stmtcache = ttl.NewCacheOn(cachettl, [4]func(string, *sql.Stmt){
+ nil, nil,
+ func(_ string, stmt *sql.Stmt) { _ = stmt.Close() },
+ nil,
+ })
+ }
+ return
+}
+
+// Close 关闭数据库
+func (db *Sqlite) Close() (err error) {
+ if db.db != nil {
+ err = db.db.Close()
+ db.db = nil
+ db.stmtcache.Destroy()
+ db.stmtcache = nil
+ }
+ return
+}
+
+func wraptable(table string) string {
+ first := []rune(table)[0]
+ if first < unicode.MaxLatin1 && unicode.IsDigit(first) {
+ return "[" + table + "]"
+ } else {
+ return "'" + table + "'"
+ }
+}
+
+func (db *Sqlite) compile(q string) (*sql.Stmt, error) {
+ stmt := db.stmtcache.Get(q)
+ if stmt == nil {
+ var err error
+ stmt, err = db.db.Prepare(q)
+ if err != nil {
+ return nil, err
+ }
+ db.stmtcache.Set(q, stmt)
+ }
+ return stmt, nil
+}
+
+func (db *Sqlite) mustcompile(q string) *sql.Stmt {
+ stmt := db.stmtcache.Get(q)
+ if stmt == nil {
+ var err error
+ stmt, err = db.db.Prepare(q)
+ if err != nil {
+ panic(err)
+ }
+ db.stmtcache.Set(q, stmt)
+ }
+ return stmt
+}
+
+// Exec wrap of (*sql.DB).Exec for PRAGMA settings
+func (db *Sqlite) Exec(query string, args ...any) (sql.Result, error) {
+ return db.db.Exec(query, args...)
+}
+
+// Create 生成数据库.
+// 默认结构体的第一个元素为主键.
+// 返回错误.
+func (db *Sqlite) Create(table string, objptr any, additional ...string) (err error) {
+ if db.db == nil {
+ err = ErrNilDB
+ return
+ }
+ var (
+ tags = tags(objptr)
+ kinds = kinds(objptr)
+ top = len(tags) - 1
+ cmd = make([]string, 0, 3*(len(tags)+1))
+ )
+ cmd = append(cmd, "CREATE TABLE IF NOT EXISTS", wraptable(table), "(")
+ if top == 0 {
+ pk, _, _ := strings.Cut(tags[0], ",")
+ cmd = append(cmd, pk, kinds[0], "PRIMARY KEY")
+ if len(additional) > 0 {
+ cmd = append(cmd, ",")
+ cmd = append(cmd, strings.Join(additional, ","))
+ }
+ cmd = append(cmd, ")")
+ } else {
+ for i := range tags {
+ name, addi, hasaddi := strings.Cut(tags[i], ",")
+ cmd = append(cmd, name, kinds[i])
+ if hasaddi && i > 0 {
+ cmd = append(cmd, addi)
+ }
+ switch i {
+ default:
+ cmd = append(cmd, ",")
+ case 0:
+ cmd = append(cmd, "PRIMARY KEY,")
+ case top:
+ if len(additional) > 0 {
+ cmd = append(cmd, ",")
+ cmd = append(cmd, strings.Join(additional, ","))
+ }
+ cmd = append(cmd, ")")
+ }
+ }
+ }
+ stmt, err := db.compile(strings.Join(cmd, " ") + ";")
+ if err != nil {
+ return err
+ }
+ _, err = stmt.Exec()
+ return
+}
+
+// Insert 插入数据集.
+// 如果 PK 存在会覆盖.
+// 默认结构体的第一个元素为主键.
+// 返回错误.
+func (db *Sqlite) Insert(table string, objptr any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ table = wraptable(table)
+ stmt, err := db.compile("SELECT * FROM " + table + " limit 1;")
+ if err != nil {
+ return err
+ }
+ rows, err := stmt.Query()
+ if err != nil {
+ return err
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+ tags, _ := rows.Columns()
+ rows.Close()
+ var (
+ vals = values(objptr)
+ top = len(tags) - 1
+ cmd = make([]string, 0, 2+4*len(tags))
+ )
+ cmd = append(cmd, "REPLACE INTO")
+ cmd = append(cmd, table)
+ if top == 0 {
+ cmd = append(cmd, "(")
+ cmd = append(cmd, tags[0])
+ cmd = append(cmd, ") VALUES ( ? )")
+ } else {
+ for i := range tags {
+ switch i {
+ default:
+ cmd = append(cmd, tags[i])
+ cmd = append(cmd, ",")
+ case 0:
+ cmd = append(cmd, "(")
+ cmd = append(cmd, tags[i])
+ cmd = append(cmd, ",")
+ case top:
+ cmd = append(cmd, tags[i])
+ cmd = append(cmd, ")")
+ }
+ }
+ for i := range tags {
+ switch i {
+ default:
+ cmd = append(cmd, "? ,")
+ case 0:
+ cmd = append(cmd, "VALUES ( ? ,")
+ case top:
+ cmd = append(cmd, "? )")
+ }
+ }
+ }
+ stmt, err = db.compile(strings.Join(cmd, " ") + ";")
+ if err != nil {
+ return err
+ }
+ _, err = stmt.Exec(vals...)
+ return err
+}
+
+// InsertUnique 插入数据集.
+// 如果 PK 存在会报错.
+// 默认结构体的第一个元素为主键.
+// 返回错误.
+func (db *Sqlite) InsertUnique(table string, objptr any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ table = wraptable(table)
+ stmt, err := db.compile("SELECT * FROM " + table + " limit 1;")
+ if err != nil {
+ return err
+ }
+ rows, err := stmt.Query()
+ if err != nil {
+ return err
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+ tags, _ := rows.Columns()
+ rows.Close()
+ var (
+ vals = values(objptr)
+ top = len(tags) - 1
+ cmd = make([]string, 0, 2+4*len(tags))
+ )
+ cmd = append(cmd, "INSERT INTO")
+ cmd = append(cmd, table)
+ if top == 0 {
+ cmd = append(cmd, "(")
+ cmd = append(cmd, tags[0])
+ cmd = append(cmd, ") VALUES ( ? )")
+ } else {
+ for i := range tags {
+ switch i {
+ default:
+ cmd = append(cmd, tags[i])
+ cmd = append(cmd, ",")
+ case 0:
+ cmd = append(cmd, "(")
+ cmd = append(cmd, tags[i])
+ cmd = append(cmd, ",")
+ case top:
+ cmd = append(cmd, tags[i])
+ cmd = append(cmd, ")")
+ }
+ }
+ for i := range tags {
+ switch i {
+ default:
+ cmd = append(cmd, "? ,")
+ case 0:
+ cmd = append(cmd, "VALUES ( ? ,")
+ case top:
+ cmd = append(cmd, "? )")
+ }
+ }
+ }
+ stmt, err = db.compile(strings.Join(cmd, " ") + ";")
+ if err != nil {
+ return err
+ }
+ _, err = stmt.Exec(vals...)
+ return err
+}
+
+// Find 查询数据库,写入第一条结果到 objptr.
+// condition 可为"WHERE id = 0".
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func (db *Sqlite) Find(table string, objptr any, condition string, questions ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ q := "SELECT * FROM " + wraptable(table) + " " + condition + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return err
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return err
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return ErrNullResult
+ }
+ err = rows.Scan(addrs(objptr)...)
+ for rows.Next() {
+ if err == nil {
+ return nil
+ }
+ err = rows.Scan(addrs(objptr)...)
+ }
+ return err
+}
+
+// Find 查询数据库,返回第一条结果.
+// condition 可为"WHERE id = 0".
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func Find[T any](db *Sqlite, table string, condition string, questions ...any) (obj T, err error) {
+ if db.db == nil {
+ err = ErrNilDB
+ return
+ }
+ q := "SELECT * FROM " + wraptable(table) + " " + condition + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return
+ }
+ err = rows.Err()
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ err = ErrNullResult
+ return
+ }
+ err = rows.Scan(addrs(&obj)...)
+ for rows.Next() {
+ if err == nil {
+ return
+ }
+ err = rows.Scan(addrs(&obj)...)
+ }
+ return
+}
+
+// Query 查询数据库,写入第一条结果到 objptr.
+// q 为一整条查询语句, 慎用.
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func (db *Sqlite) Query(q string, objptr any, args ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ stmt, err := db.compile(q)
+ if err != nil {
+ return err
+ }
+ rows, err := stmt.Query(args...)
+ if err != nil {
+ return err
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return ErrNullResult
+ }
+ err = rows.Scan(addrs(objptr)...)
+ for rows.Next() {
+ if err == nil {
+ return err
+ }
+ err = rows.Scan(addrs(objptr)...)
+ }
+ return err
+}
+
+// Query 查询数据库,返回第一条结果.
+// q 为一整条查询语句, 慎用.
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func Query[T any](db *Sqlite, q string, args ...any) (obj T, err error) {
+ if db.db == nil {
+ err = ErrNilDB
+ return
+ }
+ stmt, err := db.compile(q)
+ if err != nil {
+ return
+ }
+ rows, err := stmt.Query(args...)
+ if err != nil {
+ return
+ }
+ err = rows.Err()
+ if err != nil {
+ return
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ err = ErrNullResult
+ return
+ }
+ err = rows.Scan(addrs(&obj)...)
+ for rows.Next() {
+ if err == nil {
+ return
+ }
+ err = rows.Scan(addrs(&obj)...)
+ }
+ return
+}
+
+// CanFind 查询数据库是否有 condition.
+// condition 可为"WHERE id = 0".
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func (db *Sqlite) CanFind(table string, condition string, questions ...any) bool {
+ if db.db == nil {
+ return false
+ }
+ q := "SELECT * FROM " + wraptable(table) + " " + condition + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return false
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return false
+ }
+ if rows.Err() != nil {
+ return false
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return false
+ }
+ _ = rows.Close()
+ return true
+}
+
+// CanQuery 查询数据库是否有 q.
+// q 为一整条查询语句, 慎用.
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func (db *Sqlite) CanQuery(q string, questions ...any) bool {
+ if db.db == nil {
+ return false
+ }
+ stmt, err := db.compile(q)
+ if err != nil {
+ return false
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return false
+ }
+ if rows.Err() != nil {
+ return false
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return false
+ }
+ _ = rows.Close()
+ return true
+}
+
+// FindFor 查询数据库,用函数 f 遍历结果.
+// condition 可为"WHERE id = 0".
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func (db *Sqlite) FindFor(table string, objptr any, condition string, f func() error, questions ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ q := "SELECT * FROM " + wraptable(table) + " " + condition + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return err
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return err
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return ErrNullResult
+ }
+ err = rows.Scan(addrs(objptr)...)
+ if err == nil {
+ err = f()
+ }
+ for rows.Next() {
+ if err != nil {
+ return err
+ }
+ err = rows.Scan(addrs(objptr)...)
+ if err == nil {
+ err = f()
+ }
+ }
+ return err
+}
+
+// FindAll 查询数据库,返回多个结果.
+// condition 可为"WHERE id = 0".
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func FindAll[T any](db *Sqlite, table string, condition string, questions ...any) ([]*T, error) {
+ if db.db == nil {
+ return nil, ErrNilDB
+ }
+ q := "SELECT * FROM " + wraptable(table) + " " + condition + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return nil, err
+ }
+ if rows.Err() != nil {
+ return nil, rows.Err()
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return nil, ErrNullResult
+ }
+ vals := make([]*T, 1, 64)
+ var v T
+ err = rows.Scan(addrs(&v)...)
+ if err != nil {
+ return nil, err
+ }
+ vals[0] = &v
+ for rows.Next() {
+ if err != nil {
+ return nil, err
+ }
+ var v T
+ err = rows.Scan(addrs(&v)...)
+ if err == nil {
+ vals = append(vals, &v)
+ }
+ }
+ return vals, nil
+}
+
+// QueryFor 查询数据库,用函数 f 遍历结果.
+// q 为一整条查询语句, 慎用.
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func (db *Sqlite) QueryFor(q string, objptr any, f func() error, questions ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ stmt, err := db.compile(q)
+ if err != nil {
+ return err
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return err
+ }
+ if rows.Err() != nil {
+ return rows.Err()
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return ErrNullResult
+ }
+ err = rows.Scan(addrs(objptr)...)
+ if err == nil {
+ err = f()
+ }
+ for rows.Next() {
+ if err != nil {
+ return err
+ }
+ err = rows.Scan(addrs(objptr)...)
+ if err == nil {
+ err = f()
+ }
+ }
+ return err
+}
+
+// QueryAll 查询数据库,返回多个结果.
+// q 为一整条查询语句, 慎用.
+// 默认字段与结构体元素顺序一致.
+// 返回错误.
+func QueryAll[T any](db *Sqlite, q string, questions ...any) ([]*T, error) {
+ if db.db == nil {
+ return nil, ErrNilDB
+ }
+ stmt, err := db.compile(q)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := stmt.Query(questions...)
+ if err != nil {
+ return nil, err
+ }
+ if rows.Err() != nil {
+ return nil, rows.Err()
+ }
+ defer rows.Close()
+
+ if !rows.Next() {
+ return nil, ErrNullResult
+ }
+ vals := make([]*T, 1, 64)
+ var v T
+ err = rows.Scan(addrs(&v)...)
+ if err != nil {
+ return nil, err
+ }
+ vals[0] = &v
+ for rows.Next() {
+ if err != nil {
+ return nil, err
+ }
+ var v T
+ err = rows.Scan(addrs(&v)...)
+ if err == nil {
+ vals = append(vals, &v)
+ }
+ }
+ return vals, nil
+}
+
+// Pick 从 table 随机一行
+func (db *Sqlite) Pick(table string, objptr any, questions ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ return db.Find(table, objptr, "ORDER BY RANDOM() limit 1", questions...)
+}
+
+// PickFor 从 table 随机多行
+func (db *Sqlite) PickFor(table string, n uint, objptr any, f func() error, questions ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ return db.FindFor(table, objptr, "ORDER BY RANDOM() limit "+strconv.Itoa(int(n)), f, questions...)
+}
+
+// ListTables 列出所有表名
+// 返回所有表名+错误
+func (db *Sqlite) ListTables() (s []string, err error) {
+ if db.db == nil {
+ return nil, ErrNilDB
+ }
+ rows, err := db.mustcompile("SELECT name FROM sqlite_master where type='table' order by name;").Query()
+ if err != nil {
+ return
+ }
+ if rows.Err() != nil {
+ return nil, rows.Err()
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ if err != nil {
+ return
+ }
+ objptr := new(string)
+ err = rows.Scan(objptr)
+ if err == nil {
+ s = append(s, *objptr)
+ }
+ }
+ return
+}
+
+// Del 删除数据库表项.
+// condition 可为"WHERE id = 0".
+// 返回错误.
+func (db *Sqlite) Del(table string, condition string, questions ...any) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ q := "DELETE FROM " + wraptable(table) + " " + condition + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return err
+ }
+ _, err = stmt.Exec(questions...)
+ return err
+}
+
+// Drop 删除数据库表
+func (db *Sqlite) Drop(table string) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ q := "DROP TABLE " + wraptable(table) + ";"
+ stmt, err := db.compile(q)
+ if err != nil {
+ return err
+ }
+ _, err = stmt.Exec()
+ return err
+}
+
+// Count 查询数据库行数.
+// 返回行数以及错误.
+func (db *Sqlite) Count(table string) (num int, err error) {
+ if db.db == nil {
+ return 0, ErrNilDB
+ }
+ stmt, err := db.compile("SELECT COUNT(1) FROM " + wraptable(table) + ";")
+ if err != nil {
+ return 0, err
+ }
+ rows, err := stmt.Query()
+ if err != nil {
+ return 0, err
+ }
+ if rows.Err() != nil {
+ return 0, rows.Err()
+ }
+ if rows.Next() {
+ err = rows.Scan(&num)
+ }
+ rows.Close()
+ return num, err
+}
+
+// tags 反射 返回结构体对象的 tag 数组
+func tags(objptr any) (tags []string) {
+ elem := reflect.ValueOf(objptr).Elem()
+ flen := elem.Type().NumField()
+ tags = make([]string, flen)
+ for i := 0; i < flen; i++ {
+ t := elem.Type().Field(i).Tag.Get("db")
+ if t == "" {
+ t = elem.Type().Field(i).Tag.Get("json")
+ if t == "" {
+ t = elem.Type().Field(i).Name
+ }
+ }
+ tags[i] = t
+ }
+ return
+}
+
+// kinds 反射 返回结构体对象的 kinds 数组
+func kinds(objptr any) (kinds []string) {
+ elem := reflect.ValueOf(objptr).Elem()
+ // 判断第一个元素是否为匿名字段
+ if elem.Type().Field(0).Anonymous {
+ elem = elem.Field(0)
+ }
+ flen := elem.Type().NumField()
+ kinds = make([]string, flen)
+ for i := 0; i < flen; i++ {
+ typ := elem.Field(i).Type().String()
+ switch typ {
+ case "bool", "*bool":
+ kinds[i] = "BOOLEAN"
+ case "int8", "*int8":
+ kinds[i] = "TINYINT"
+ case "uint8", "byte", "*uint8", "*byte":
+ kinds[i] = "UNSIGNED TINYINT"
+ case "int16", "*int16":
+ kinds[i] = "SMALLINT"
+ case "uint16", "*uint16":
+ kinds[i] = "UNSIGNED SMALLINT"
+ case "int", "*int":
+ kinds[i] = "INTEGER"
+ case "uint", "*uint", "uintptr", "*uintptr":
+ kinds[i] = "UNSIGNED INTEGER"
+ case "int32", "rune", "*int32", "*rune":
+ kinds[i] = "INT"
+ case "uint32", "*uint32":
+ kinds[i] = "UNSIGNED INT"
+ case "int64", "*int64":
+ kinds[i] = "BIGINT"
+ case "uint64", "*uint64":
+ kinds[i] = "UNSIGNED BIGINT"
+ case "float32", "*float32":
+ kinds[i] = "FLOAT"
+ case "float64", "*float64":
+ kinds[i] = "DOUBLE"
+ case "string", "[]string", "*string", "*[]string":
+ kinds[i] = "TEXT"
+ default:
+ k := elem.Field(i).Kind()
+ if k == reflect.Interface || k == reflect.Pointer {
+ typ = "*"
+ k = elem.Field(i).Elem().Kind()
+ }
+ switch k {
+ case reflect.Bool:
+ kinds[i] = "BOOLEAN"
+ case reflect.Int:
+ kinds[i] = "INTEGER"
+ case reflect.Int8:
+ kinds[i] = "TINYINT"
+ case reflect.Int16:
+ kinds[i] = "SMALLINT"
+ case reflect.Int32:
+ kinds[i] = "INT"
+ case reflect.Int64:
+ kinds[i] = "BIGINT"
+ case reflect.Uint:
+ kinds[i] = "UNSIGNED INTEGER"
+ case reflect.Uint8:
+ kinds[i] = "UNSIGNED TINYINT"
+ case reflect.Uint16:
+ kinds[i] = "UNSIGNED SMALLINT"
+ case reflect.Uint32:
+ kinds[i] = "UNSIGNED INT"
+ case reflect.Uint64:
+ kinds[i] = "UNSIGNED BIGINT"
+ case reflect.Uintptr:
+ kinds[i] = "UNSIGNED INTEGER"
+ case reflect.Float32:
+ kinds[i] = "FLOAT"
+ case reflect.Float64:
+ kinds[i] = "DOUBLE"
+ case reflect.String:
+ kinds[i] = "TEXT"
+ default:
+ kinds[i] = "BLOB"
+ }
+ }
+ if strings.Contains(typ, "*") || strings.Contains(typ, "[]") {
+ kinds[i] += " NULL"
+ } else {
+ kinds[i] += " NOT NULL"
+ }
+ }
+ return
+}
+
+var typstrarr = reflect.SliceOf(reflect.TypeOf(""))
+
+// values 反射 返回结构体对象的 values 数组
+func values(objptr any) (values []any) {
+ elem := reflect.ValueOf(objptr).Elem()
+ flen := elem.Type().NumField()
+ values = make([]any, flen)
+ for i := 0; i < flen; i++ {
+ if elem.Field(i).Type() == typstrarr { // []string
+ values[i] = elem.Field(i).Index(0).Interface() // string
+ continue
+ }
+ values[i] = elem.Field(i).Interface()
+ }
+ return
+}
+
+// addrs 反射 返回结构体对象的 addrs 数组
+func addrs(objptr any) (addrs []any) {
+ elem := reflect.ValueOf(objptr).Elem()
+ flen := elem.Type().NumField()
+ addrs = make([]any, flen)
+ for i := 0; i < flen; i++ {
+ if elem.Field(i).Type() == typstrarr { // []string
+ s := reflect.ValueOf(make([]string, 1))
+ elem.Field(i).Set(s)
+ addrs[i] = s.Index(0).Addr().Interface() // string
+ continue
+ }
+ addrs[i] = elem.Field(i).Addr().Interface()
+ }
+ return
+}
diff --git a/third_party/sqlite/sqlite_test.go b/third_party/sqlite/sqlite_test.go
new file mode 100644
index 0000000000..3668a72698
--- /dev/null
+++ b/third_party/sqlite/sqlite_test.go
@@ -0,0 +1,269 @@
+package sql
+
+import (
+ "bytes"
+ "os"
+ "strconv"
+ "testing"
+ "time"
+)
+
+type myuint8 uint8
+
+func TestPackUnpack(t *testing.T) {
+ type teststruct struct {
+ A *int
+ B uint
+ C uint8
+ D uint16
+ E int32
+ F uint32
+ G int64
+ H uint64
+ I float32
+ J float64
+ K []byte
+ L string
+ M []string
+ N *bool
+ O *int8
+ P time.Duration
+ Q myuint8
+ }
+ _ = os.Remove("test.db")
+ db := Sqlite{dbpath: "test.db"}
+ err := db.Open(time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Create("test", &teststruct{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var o int8 = 2
+ a := 2
+ n := true
+ inst := teststruct{&a, 2, 3, 4, 5, 6, 7, 8, 9.0, 10.0, []byte{1, 2, 3}, "123", []string{"123", "456"}, &n, nil, time.Second, 6}
+ err = db.Insert("test", &inst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmp := teststruct{O: &o}
+ err = db.Find("test", &tmp, "WHERE A = ?", a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *tmp.A != *inst.A {
+ t.Fatal()
+ }
+ if tmp.B != inst.B {
+ t.Fatal()
+ }
+ if tmp.C != inst.C {
+ t.Fatal()
+ }
+ if tmp.D != inst.D {
+ t.Fatal()
+ }
+ if tmp.E != inst.E {
+ t.Fatal()
+ }
+ if tmp.F != inst.F {
+ t.Fatal()
+ }
+ if tmp.F != inst.F {
+ t.Fatal()
+ }
+ if tmp.G != inst.G {
+ t.Fatal()
+ }
+ if tmp.H != inst.H {
+ t.Fatal()
+ }
+ if tmp.I != inst.I {
+ t.Fatal()
+ }
+ if tmp.J != inst.J {
+ t.Fatal()
+ }
+ if !bytes.Equal(tmp.K, inst.K) {
+ t.Fatal()
+ }
+ if tmp.L != inst.L {
+ t.Fatal()
+ }
+ if tmp.M[0] != inst.M[0] {
+ t.Fatal()
+ }
+ if *tmp.N != *inst.N {
+ t.Fatal()
+ }
+ if tmp.O != nil {
+ t.Fatal()
+ }
+ if tmp.P != inst.P {
+ t.Fatal()
+ }
+ if tmp.Q != inst.Q {
+ t.Fatal()
+ }
+ // 测试自增
+ err = db.Insert("test", &teststruct{M: []string{""}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmp = teststruct{O: &o}
+ q, s := QuerySet("WHERE A", "IN", []int{3})
+ err = db.Find("test", &tmp, q, s...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *tmp.A != 3 {
+ t.Fatal()
+ }
+}
+
+func TestFK(t *testing.T) {
+ type teacher struct {
+ ID *int
+ Name string
+ }
+ type class struct {
+ ID *int
+ TeacherID int
+ StudentCount int
+ }
+ _ = os.Remove("test.db")
+ db := Sqlite{dbpath: "test.db"}
+ err := db.Open(time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Create("teacher", &teacher{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("teacher", &teacher{Name: "Anna"}) // 1
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("teacher", &teacher{Name: "Bob"}) // 2
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("teacher", &teacher{Name: "Catalina"}) // 3
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("teacher", &teacher{Name: "Donald"}) // 4
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("teacher", &teacher{Name: "Emily"}) // 5
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = db.Exec("PRAGMA foreign_keys = ON;")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Create("class", &class{}, "FOREIGN KEY(TeacherID) REFERENCES teacher(ID)")
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("class", &class{TeacherID: 4, StudentCount: 66})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("class", &class{TeacherID: 3, StudentCount: 55})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("class", &class{TeacherID: 2, StudentCount: 44})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("class", &class{TeacherID: 1, StudentCount: 33})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("class", &class{TeacherID: 5, StudentCount: 22})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("class", &class{TeacherID: 6, StudentCount: 11})
+ if err == nil {
+ t.Fatal("unexpected success")
+ }
+}
+
+func TestWriteInGenericFindFor(t *testing.T) {
+ type counter struct {
+ ID *int
+ Count uint
+ }
+ _ = os.Remove("test.db")
+ db := Sqlite{dbpath: "test.db"}
+ err := db.Open(time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Create("counter", &counter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 1; i <= 128; i++ {
+ err = db.Insert("counter", &counter{Count: uint(i)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ counters, err := FindAll[counter](&db, "counter", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range counters {
+ c.Count += 10000
+ t.Log("set", *c.ID, "to", c.Count)
+ err = db.Insert("counter", c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ time.Sleep(time.Second)
+ for i := 1; i <= 128; i++ {
+ c, err := Find[counter](&db, "counter", "WHERE ID="+strconv.Itoa(i))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Count != uint(i)+10000 {
+ t.Fatal("expect", uint(i)+10000, "but get", c.Count)
+ }
+ }
+}
+
+func TestAdditionalTags(t *testing.T) {
+ type counter struct {
+ ID *int
+ UniqueCount uint `db:"UniqueCount,UNIQUE"`
+ }
+ _ = os.Remove("test.db")
+ db := Sqlite{dbpath: "test.db"}
+ err := db.Open(time.Hour)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Create("counter", &counter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = db.Insert("counter", &counter{UniqueCount: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := 2
+ err = db.InsertUnique("counter", &counter{ID: &id, UniqueCount: 1})
+ if err == nil {
+ t.Fatal("unexpected insert")
+ }
+}
diff --git a/third_party/sqlite/transaction.go b/third_party/sqlite/transaction.go
new file mode 100644
index 0000000000..3de6cb172f
--- /dev/null
+++ b/third_party/sqlite/transaction.go
@@ -0,0 +1,35 @@
+package sql
+
+import (
+ "context"
+ "database/sql"
+)
+
+func (db *Sqlite) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
+ if db.db == nil {
+ return nil, ErrNilDB
+ }
+ return db.db.ExecContext(ctx, query, args...)
+}
+
+// Transaction commits related control writes together. The caller serializes
+// access with its Manager lock; no cached statement is shared with the Tx.
+func (db *Sqlite) Transaction(f func(*sql.Tx) error) error {
+ return db.TransactionContext(context.Background(), f)
+}
+
+// TransactionContext bounds admin snapshot/restore work without sharing statements.
+func (db *Sqlite) TransactionContext(ctx context.Context, f func(*sql.Tx) error) error {
+ if db.db == nil {
+ return ErrNilDB
+ }
+ tx, err := db.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if err = f(tx); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/third_party/sqlite/utils.go b/third_party/sqlite/utils.go
new file mode 100644
index 0000000000..11f9d0baf7
--- /dev/null
+++ b/third_party/sqlite/utils.go
@@ -0,0 +1,24 @@
+package sql
+
+import "strings"
+
+// QuerySet returns "q op (?,?,...,?)", []T
+func QuerySet[T any](q, op string, s []T) (string, []any) {
+ sz := len(s)
+ if sz == 0 {
+ panic("len(s) must > 0")
+ }
+ sb := strings.Builder{}
+ qs := make([]any, sz)
+ sb.WriteString(q)
+ sb.WriteByte(' ')
+ sb.WriteString(op)
+ sb.WriteString(" (?")
+ qs[0] = s[0]
+ for i := 1; i < sz; i++ {
+ sb.WriteString(",?")
+ qs[i] = s[i]
+ }
+ sb.WriteByte(')')
+ return sb.String(), qs
+}
diff --git a/third_party/zbpctrl/.github/workflows/pull.yml b/third_party/zbpctrl/.github/workflows/pull.yml
new file mode 100644
index 0000000000..5970b937b6
--- /dev/null
+++ b/third_party/zbpctrl/.github/workflows/pull.yml
@@ -0,0 +1,35 @@
+name: PullLint
+on: [ pull_request ]
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go
+ uses: actions/setup-go@master
+ with:
+ go-version: "1.24"
+
+ - name: Check out code into the Go module directory
+ uses: actions/checkout@master
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@master
+ with:
+ # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
+ version: latest
+
+ # Optional: working directory, useful for monorepos
+ # working-directory: somedir
+
+ # Optional: golangci-lint command line arguments.
+ # args: --issues-exit-code=0
+
+ # Optional: show only new issues if it's a pull request. The default value is `false`.
+ # only-new-issues: true
+
+ # Optional: if set to true then the action don't cache or restore ~/go/pkg.
+ # skip-pkg-cache: true
+
+ # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
+ # skip-build-cache: true
diff --git a/third_party/zbpctrl/.github/workflows/push.yml b/third_party/zbpctrl/.github/workflows/push.yml
new file mode 100644
index 0000000000..d5fe6cf03a
--- /dev/null
+++ b/third_party/zbpctrl/.github/workflows/push.yml
@@ -0,0 +1,28 @@
+name: PushLint
+on: [ push ]
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go
+ uses: actions/setup-go@master
+ with:
+ go-version: "1.24"
+
+ - name: Check out code into the Go module directory
+ uses: actions/checkout@master
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@master
+ with:
+ version: latest
+ args: --issues-exit-code=0 --timeout=10m
+ - name: Commit back
+ continue-on-error: true
+ run: |
+ git config --local user.name 'github-actions[bot]'
+ git config --local user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add --all
+ git commit -m "🎨 改进代码样式"
+ git push
diff --git a/third_party/zbpctrl/.gitignore b/third_party/zbpctrl/.gitignore
new file mode 100644
index 0000000000..25eef8ea6a
--- /dev/null
+++ b/third_party/zbpctrl/.gitignore
@@ -0,0 +1,17 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+.DS_Store
\ No newline at end of file
diff --git a/third_party/zbpctrl/.golangci.yml b/third_party/zbpctrl/.golangci.yml
new file mode 100644
index 0000000000..e2f1995e15
--- /dev/null
+++ b/third_party/zbpctrl/.golangci.yml
@@ -0,0 +1,75 @@
+linters-settings:
+ errcheck:
+ ignoretests: true
+
+ goimports:
+ local-prefixes: github.com/FloatTech/zbpctrl
+
+ forbidigo:
+ # Forbid the following identifiers
+ forbid:
+ - ^fmt\.Errorf$ # consider errors.Errorf in github.com/pkg/errors
+
+linters:
+ # please, do not use `enable-all`: it's deprecated and will be removed soon.
+ # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint
+ disable-all: true
+ fast: false
+ enable:
+ - bodyclose
+ #- depguard
+ - dogsled
+ - errcheck
+ #- exportloopref
+ - exhaustive
+ #- funlen
+ #- goconst
+ - gocritic
+ #- gocyclo
+ - gofmt
+ - goimports
+ - goprintffuncname
+ #- gosec
+ - gosimple
+ - govet
+ - ineffassign
+ #- misspell
+ - nolintlint
+ - rowserrcheck
+ - staticcheck
+ - stylecheck
+ - typecheck
+ - unconvert
+ - unparam
+ - unused
+ - whitespace
+ - prealloc
+ - predeclared
+ - asciicheck
+ - revive
+ - forbidigo
+ - makezero
+
+run:
+ # default concurrency is a available CPU number.
+ # concurrency: 4 # explicitly omit this value to fully utilize available resources.
+ deadline: 15m
+ issues-exit-code: 1
+ tests: false
+ go: '1.24'
+
+# output configuration options
+output:
+ formats:
+ - format: "colored-line-number"
+ print-issued-lines: true
+ print-linter-name: true
+
+issues:
+ # Fix found issues (if it's supported by the linter)
+ fix: true
+ exclude-use-default: false
+ uniq-by-line: true
+ exclude:
+ - "Error return value of .((os.)?std(out|err)..*|.*Close|.*Seek|.*Flush|os.Remove(All)?|.*print(f|ln)?|os.(Un)?Setenv). is not check"
+ - 'identifier ".*" contain non-ASCII character: U\+.*'
diff --git a/third_party/zbpctrl/LICENSE b/third_party/zbpctrl/LICENSE
new file mode 100644
index 0000000000..f288702d2f
--- /dev/null
+++ b/third_party/zbpctrl/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/third_party/zbpctrl/README.md b/third_party/zbpctrl/README.md
new file mode 100644
index 0000000000..16b864f5bd
--- /dev/null
+++ b/third_party/zbpctrl/README.md
@@ -0,0 +1,2 @@
+# zbpctrl
+ZeroBot-Plugin 的控制库
diff --git a/third_party/zbpctrl/ban.go b/third_party/zbpctrl/ban.go
new file mode 100644
index 0000000000..bb0b4ae65e
--- /dev/null
+++ b/third_party/zbpctrl/ban.go
@@ -0,0 +1,89 @@
+package control
+
+import (
+ "crypto/md5"
+ "encoding/binary"
+ "errors"
+ "fmt"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+func (m *Control[CTX]) banID(uid, gid int64) uint64 {
+ var text string
+ if gid == 0 {
+ text = fmt.Sprintf("[%s]%d_all", m.Service, uid)
+ } else {
+ text = fmt.Sprintf("[%s]%d_%d", m.Service, uid, gid)
+ }
+ digest := md5.Sum([]byte(text))
+ return binary.LittleEndian.Uint64(digest[:8])
+}
+
+// SQLite stores signed IDs. Reads/deletes use the same representation as writes.
+func (m *Control[CTX]) Ban(uid, gid int64) error {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ id := m.banID(uid, gid)
+ var row BanStatus
+ err := m.Manager.D.Find(m.Service+"ban", &row, "WHERE id = ?", int64(id))
+ if err == nil && row.UserID == uid && row.GroupID == gid {
+ return nil
+ }
+ if err != nil && !errors.Is(err, sql.ErrNullResult) {
+ return err
+ }
+ if err := m.Manager.beforePolicyLocked(); err != nil {
+ return err
+ }
+ if err := m.Manager.D.Insert(m.Service+"ban", &BanStatus{ID: int64(id), UserID: uid, GroupID: gid}); err != nil {
+ return err
+ }
+ m.Manager.banCache[id] = true
+ return nil
+}
+func (m *Control[CTX]) Permit(uid, gid int64) error {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ id := m.banID(uid, gid)
+ var row BanStatus
+ err := m.Manager.D.Find(m.Service+"ban", &row, "WHERE id = ?", int64(id))
+ if errors.Is(err, sql.ErrNullResult) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err := m.Manager.beforePolicyLocked(); err != nil {
+ return err
+ }
+ if err := m.Manager.D.Del(m.Service+"ban", "WHERE id = ?", int64(id)); err != nil {
+ return err
+ }
+ m.Manager.banCache[id] = false
+ return nil
+}
+
+// Lookup/cache insertion must not straddle restore. Group misses check global bans.
+func (m *Control[CTX]) IsBannedIn(uid, gid int64) bool {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ groups := []int64{gid}
+ if gid != 0 {
+ groups = append(groups, 0)
+ }
+ for _, group := range groups {
+ id := m.banID(uid, group)
+ value, ok := m.Manager.banCache[id]
+ if !ok {
+ var row BanStatus
+ err := m.Manager.D.Find(m.Service+"ban", &row, "WHERE id = ?", int64(id))
+ value = err == nil && row.GroupID == group && row.UserID == uid
+ m.Manager.banCache[id] = value
+ }
+ if value {
+ return true
+ }
+ }
+ return false
+}
diff --git a/third_party/zbpctrl/block.go b/third_party/zbpctrl/block.go
new file mode 100644
index 0000000000..49cfd6c895
--- /dev/null
+++ b/third_party/zbpctrl/block.go
@@ -0,0 +1,68 @@
+package control
+
+import (
+ "errors"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+func (manager *Manager[CTX]) initBlock() error {
+ return manager.D.Create("__block", &BlockStatus{})
+}
+
+// DoBlock 封禁
+func (manager *Manager[CTX]) DoBlock(uid int64) error {
+ manager.Lock()
+ defer manager.Unlock()
+ var row BlockStatus
+ err := manager.D.Find("__block", &row, "WHERE uid = ?", uid)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, sql.ErrNullResult) {
+ return err
+ }
+ if err := manager.beforePolicyLocked(); err != nil {
+ return err
+ }
+ if err := manager.D.Insert("__block", &BlockStatus{UserID: uid}); err != nil {
+ return err
+ }
+ manager.blockCache[uid] = true
+ return nil
+}
+
+// DoUnblock 解封
+func (manager *Manager[CTX]) DoUnblock(uid int64) error {
+ manager.Lock()
+ defer manager.Unlock()
+ var row BlockStatus
+ err := manager.D.Find("__block", &row, "WHERE uid = ?", uid)
+ if errors.Is(err, sql.ErrNullResult) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err := manager.beforePolicyLocked(); err != nil {
+ return err
+ }
+ if err := manager.D.Del("__block", "where uid = ?", uid); err != nil {
+ return err
+ }
+ manager.blockCache[uid] = false
+ return nil
+}
+
+// IsBlocked 是否封禁
+func (manager *Manager[CTX]) IsBlocked(uid int64) bool {
+ manager.Lock()
+ defer manager.Unlock()
+ isbl, ok := manager.blockCache[uid]
+ if ok {
+ return isbl
+ }
+ isbl = manager.D.CanFind("__block", "where uid = ?", uid)
+ manager.blockCache[uid] = isbl
+ return isbl
+}
diff --git a/third_party/zbpctrl/config_update.go b/third_party/zbpctrl/config_update.go
new file mode 100644
index 0000000000..49538474e6
--- /dev/null
+++ b/third_party/zbpctrl/config_update.go
@@ -0,0 +1,132 @@
+package control
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strconv"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+var ErrConfigConflict = errors.New("configuration changed")
+
+func (m *Control[CTX]) extraLocked() (json.RawMessage, string, error) {
+ if m.Options.Extra == 0 {
+ return nil, "", ErrUnregisteredExtra
+ }
+ var row ResponseGroup
+ err := m.Manager.D.Find("__resp", &row, "WHERE gid = ?", int64(m.Options.Extra))
+ if err != nil && !errors.Is(err, sql.ErrNullResult) {
+ return nil, "", err
+ }
+ var raw json.RawMessage
+ if err == nil && row.Extra != "" && row.Extra != "-" {
+ raw = json.RawMessage(row.Extra)
+ }
+ h := hmac.New(sha256.New, m.Manager.revisionKey[:])
+ h.Write([]byte(strconv.FormatInt(int64(m.Options.Extra), 10)))
+ h.Write([]byte{0})
+ h.Write(raw)
+ return raw, hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// ExtraJSON reads persisted extension data independently of response gates.
+func (m *Control[CTX]) ExtraJSON() (json.RawMessage, string, error) {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ return m.extraLocked()
+}
+
+// UpdateExtraJSON serializes read/merge/backup/write. No cache or runtime value
+// changes before SQLite commits. expected may be empty for chat field updates.
+func (m *Control[CTX]) UpdateExtraJSON(expected string, change func(json.RawMessage) (json.RawMessage, error)) (string, error) {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ raw, revision, err := m.extraLocked()
+ if err != nil {
+ return "", err
+ }
+ if expected != "" && expected != revision {
+ return "", ErrConfigConflict
+ }
+ next, err := change(raw)
+ if err != nil {
+ return "", err
+ }
+ if bytes.Equal(raw, next) {
+ return revision, nil
+ }
+ if !json.Valid(next) {
+ return "", errors.New("invalid extension JSON")
+ }
+ if m.Manager.BeforeExtra != nil {
+ if err = m.Manager.BeforeExtra(); err != nil {
+ return "", err
+ }
+ }
+ err = m.Manager.D.Insert("__resp", &ResponseGroup{GroupID: int64(m.Options.Extra), Extra: string(next)})
+ if err != nil {
+ return "", err
+ }
+ m.Manager.respCache[int64(m.Options.Extra)] = string(next)
+ _, revision, err = m.extraLocked()
+ return revision, err
+}
+
+func (m *Control[CTX]) dataLocked(gid int64) (GroupConfig, string, string, error) {
+ row, mode, err := m.loadSwitchLocked(gid)
+ if err != nil {
+ return row, mode, "", err
+ }
+ raw, _ := json.Marshal([]any{m.Service, gid, row.Disable, mode})
+ h := sha256.Sum256(raw)
+ return row, mode, hex.EncodeToString(h[:]), nil
+}
+
+func (m *Control[CTX]) DataVersion(gid int64) (int64, string, error) {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ row, _, revision, err := m.dataLocked(gid)
+ return (row.Disable >> 1) & 0x3fffffff_ffffffff, revision, err
+}
+
+// UpdateData preserves the switch/default bits and inheritance while merging
+// settings under the Manager lock, avoiding lost chat/web field updates.
+func (m *Control[CTX]) UpdateData(gid int64, expected string, change func(int64) (int64, error)) (string, error) {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ row, mode, revision, err := m.dataLocked(gid)
+ if err != nil {
+ return "", err
+ }
+ if expected != "" && expected != revision {
+ return "", ErrConfigConflict
+ }
+ old := (row.Disable >> 1) & 0x3fffffff_ffffffff
+ next, err := change(old)
+ if err != nil {
+ return "", err
+ }
+ if next < 0 || next > 0x3fffffff_ffffffff {
+ return "", errors.New("invalid configuration bits")
+ }
+ if next == old {
+ return revision, nil
+ }
+ if m.BeforeData != nil {
+ if err = m.BeforeData(); err != nil {
+ return "", err
+ }
+ }
+ row.Disable = (row.Disable & int64(-9223372036854775807)) | (next << 1)
+ if err = m.writeSwitchLocked(row, mode); err != nil {
+ return "", err
+ }
+ m.Cache = make(map[int64]uint8, 16)
+ _, _, revision, err = m.dataLocked(gid)
+ return revision, err
+}
diff --git a/third_party/zbpctrl/control.go b/third_party/zbpctrl/control.go
new file mode 100644
index 0000000000..f2b2e87de2
--- /dev/null
+++ b/third_party/zbpctrl/control.go
@@ -0,0 +1,89 @@
+package control
+
+import (
+ "errors"
+ "math/bits"
+
+ sql "github.com/FloatTech/sqlite"
+ log "github.com/sirupsen/logrus"
+)
+
+// Control is to control the plugins.
+type Control[CTX any] struct {
+ Service string
+ Cache map[int64]uint8 // resolved state per location, invalidated after commit
+ Options Options[CTX]
+ Manager *Manager[CTX]
+ // BeforeData is installed only for configuration data, not business counters.
+ BeforeData func() error
+}
+
+func (manager *Manager[CTX]) NewControl(service string, o *Options[CTX]) *Control[CTX] {
+ m := &Control[CTX]{Service: service, Cache: make(map[int64]uint8, 16), Manager: manager}
+ if o != nil {
+ m.Options = *o
+ }
+ manager.Lock()
+ defer manager.Unlock()
+ var c GroupConfig
+ if err := manager.D.Create(service, &c); err != nil {
+ panic(err)
+ }
+ if err := manager.D.Create(service+"ban", &BanStatus{}); err != nil {
+ panic(err)
+ }
+ err := manager.D.Find(service, &c, "WHERE gid=0")
+ if err != nil && !errors.Is(err, sql.ErrNullResult) {
+ panic(err)
+ }
+ if err == nil && bits.RotateLeft64(uint64(c.Disable), 1)&1 == 1 {
+ m.Options.DisableOnDefault = !m.Options.DisableOnDefault
+ }
+ manager.M[service] = m
+ return m
+}
+
+// These methods share the checked writer with the admin API. Existing direct
+// calls that ignore the new error return remain source-compatible.
+func (m *Control[CTX]) Enable(gid int64) error { _, err := m.SetSwitch(gid, "enabled", ""); return err }
+func (m *Control[CTX]) Disable(gid int64) error {
+ _, err := m.SetSwitch(gid, "disabled", "")
+ return err
+}
+func (m *Control[CTX]) Reset(gid int64) error { _, err := m.SetSwitch(gid, "inherit", ""); return err }
+
+// Read and resolve under one lock so stale readers cannot reinstall old caches.
+func (m *Control[CTX]) IsEnabledIn(gid int64) bool {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ if value, ok := m.Cache[gid]; ok {
+ return value == 0
+ }
+ state, err := m.stateLocked(gid)
+ if err != nil {
+ log.Error("[control] cannot read plugin switch")
+ return false
+ }
+ if state.EffectiveEnabled {
+ m.Cache[gid] = 0
+ } else {
+ m.Cache[gid] = 1
+ }
+ return state.EffectiveEnabled
+}
+
+func (m *Control[CTX]) Handler(gid, uid int64) bool {
+ if m.Manager.IsBlocked(uid) {
+ return false
+ }
+ grp := gid
+ if grp == 0 {
+ grp = -uid
+ }
+ if !m.Manager.CanResponse(grp) || m.IsBannedIn(uid, grp) {
+ return false
+ }
+ return m.IsEnabledIn(grp)
+}
+
+func (m *Control[CTX]) String() string { return m.Options.Help }
diff --git a/third_party/zbpctrl/data.go b/third_party/zbpctrl/data.go
new file mode 100644
index 0000000000..d5c3bb1eb3
--- /dev/null
+++ b/third_party/zbpctrl/data.go
@@ -0,0 +1,41 @@
+package control
+
+import (
+ "encoding/json"
+ "errors"
+)
+
+var (
+ ErrEmptyExtra = errors.New("empty extra")
+ ErrUnregisteredExtra = errors.New("unregistered extra")
+)
+
+func (m *Control[CTX]) GetData(gid int64) int64 {
+ data, _, _ := m.DataVersion(gid)
+ return data
+}
+
+func (m *Control[CTX]) SetData(gid, data int64) error {
+ _, err := m.UpdateData(gid, "", func(int64) (int64, error) { return data, nil })
+ return err
+}
+
+func (m *Control[CTX]) GetExtra(obj any) error {
+ raw, _, err := m.ExtraJSON()
+ if err != nil {
+ return err
+ }
+ if len(raw) == 0 {
+ return ErrEmptyExtra
+ }
+ return json.Unmarshal(raw, obj)
+}
+
+func (m *Control[CTX]) SetExtra(obj any) error {
+ raw, err := json.Marshal(obj)
+ if err != nil {
+ return err
+ }
+ _, err = m.UpdateExtraJSON("", func(json.RawMessage) (json.RawMessage, error) { return raw, nil })
+ return err
+}
diff --git a/third_party/zbpctrl/enable.go b/third_party/zbpctrl/enable.go
new file mode 100644
index 0000000000..cbbae2cd3d
--- /dev/null
+++ b/third_party/zbpctrl/enable.go
@@ -0,0 +1,17 @@
+package control
+
+// EnableMark 启用:●,禁用:○
+type EnableMark bool
+
+// String 打印启用状态
+func (em EnableMark) String() string {
+ if bool(em) {
+ return "●"
+ }
+ return "○"
+}
+
+// EnableMarkIn 打印 ● 或 ○
+func (m *Control[CTX]) EnableMarkIn(grp int64) EnableMark {
+ return EnableMark(m.IsEnabledIn(grp))
+}
diff --git a/third_party/zbpctrl/flip.go b/third_party/zbpctrl/flip.go
new file mode 100644
index 0000000000..8013c89e2d
--- /dev/null
+++ b/third_party/zbpctrl/flip.go
@@ -0,0 +1,23 @@
+package control
+
+// Flip persists the default-flip flag, preserving explicit switches and data.
+func (m *Control[CTX]) Flip() error {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ c, mode, err := m.loadSwitchLocked(0)
+ if err != nil {
+ return err
+ }
+ if m.Manager.BeforeSwitch != nil {
+ if err = m.Manager.BeforeSwitch(); err != nil {
+ return err
+ }
+ }
+ c.Disable = int64(uint64(c.Disable) ^ (uint64(1) << 63))
+ if err = m.writeSwitchLocked(c, mode); err != nil {
+ return err
+ }
+ m.Options.DisableOnDefault = !m.Options.DisableOnDefault
+ m.Cache = make(map[int64]uint8, 16)
+ return nil
+}
diff --git a/third_party/zbpctrl/go.mod b/third_party/zbpctrl/go.mod
new file mode 100644
index 0000000000..a3d089435b
--- /dev/null
+++ b/third_party/zbpctrl/go.mod
@@ -0,0 +1,30 @@
+module github.com/FloatTech/zbpctrl
+
+go 1.20
+
+require (
+ github.com/FloatTech/sqlite v1.7.0
+ github.com/sirupsen/logrus v1.9.3
+ github.com/wdvxdr1123/ZeroBot v1.8.0
+)
+
+require (
+ github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.26.0 // indirect
+ modernc.org/libc v1.61.0 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/sqlite v1.33.1 // indirect
+)
+
+replace modernc.org/sqlite => github.com/fumiama/sqlite3 v1.29.10-simp
+
+replace modernc.org/libc => github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5
+
+// Standalone patch tests must use the checked SQLite writer in this repository.
+replace github.com/FloatTech/sqlite => ../sqlite
diff --git a/third_party/zbpctrl/go.sum b/third_party/zbpctrl/go.sum
new file mode 100644
index 0000000000..94488822b1
--- /dev/null
+++ b/third_party/zbpctrl/go.sum
@@ -0,0 +1,51 @@
+github.com/FloatTech/sqlite v1.7.0 h1:FGSn4pCR12kESozn7IvNx3U39dwR/AcFM9oPyGACsl0=
+github.com/FloatTech/sqlite v1.7.0/go.mod h1:/4tzfCGhrZnnjC1U8vcfwGQeF6eR649fhOsS3+Le0+s=
+github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562 h1:snfw7FNFym1eNnLrQ/VCf80LiQo9C7jHgrunZDwiRcY=
+github.com/FloatTech/ttl v0.0.0-20240716161252-965925764562/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5 h1:jDxsIupsT84A6WHcs6kWbst+KqrRQ8/o0VyoFMnbBOA=
+github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE=
+github.com/fumiama/sqlite3 v1.29.10-simp h1:c5y3uKyU0q9t0/SyfynzYyuslQ5zP+5CD8e0yYY554A=
+github.com/fumiama/sqlite3 v1.29.10-simp/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/wdvxdr1123/ZeroBot v1.8.0 h1:v7m+0kGtL6XQlUH9O/LzmOntDJs2clzVj93YsAWWMbk=
+github.com/wdvxdr1123/ZeroBot v1.8.0/go.mod h1:C86nQ0gIdAri4K2vg8IIQIslt08zzrKMcqYt8zhkx1M=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
+modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/third_party/zbpctrl/manager.go b/third_party/zbpctrl/manager.go
new file mode 100644
index 0000000000..537bdb8c8c
--- /dev/null
+++ b/third_party/zbpctrl/manager.go
@@ -0,0 +1,116 @@
+// Package control 控制插件的启用与优先级等
+package control
+
+import (
+ "crypto/rand"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+// Manager 管理
+type Manager[CTX any] struct {
+ sync.RWMutex
+ M map[string]*Control[CTX]
+ D sql.Sqlite
+ banCache map[uint64]bool
+ blockCache map[int64]bool
+ respCache map[int64]string
+ revisionKey [32]byte
+ // BeforeSwitch is called under the Manager lock, before a switch/default
+ // mutation. Admin installs a database snapshot hook shared by chat commands.
+ BeforeSwitch func() error
+ // BeforeExtra protects persistent extension writes with the same backup.
+ BeforeExtra func() error
+ // BeforePolicy protects response, user block and service ban mutations.
+ // It runs under the Manager lock, after a persisted-state no-op check.
+ BeforePolicy func() error
+}
+
+func (m *Manager[CTX]) beforePolicyLocked() error {
+ if m.BeforePolicy != nil {
+ return m.BeforePolicy()
+ }
+ return nil
+}
+
+// NewManager 打开管理数据库
+func NewManager[CTX any](dbpath string) (m Manager[CTX]) {
+ switch {
+ case dbpath == "":
+ dbpath = "ctrl.db"
+ case strings.HasSuffix(dbpath, "/"):
+ err := os.MkdirAll(dbpath, 0755)
+ if err != nil {
+ panic(err)
+ }
+ dbpath += "ctrl.db"
+ default:
+ i := strings.LastIndex(dbpath, "/")
+ if i > 0 {
+ err := os.MkdirAll(dbpath[:i], 0755)
+ if err != nil {
+ panic(err)
+ }
+ }
+ }
+ m = Manager[CTX]{
+ M: map[string]*Control[CTX]{},
+ D: sql.New(dbpath),
+ banCache: make(map[uint64]bool, 8),
+ blockCache: make(map[int64]bool, 8),
+ respCache: make(map[int64]string, 8),
+ }
+ if _, err := rand.Read(m.revisionKey[:]); err != nil {
+ panic(err)
+ }
+ err := m.D.Open(time.Hour)
+ if err != nil {
+ panic(err)
+ }
+ err = m.initBlock()
+ if err != nil {
+ panic(err)
+ }
+ err = m.initResponse()
+ if err != nil {
+ panic(err)
+ }
+ err = m.D.Create("__switch_inherit", &switchInheritance{})
+ if err != nil {
+ panic(err)
+ }
+ return
+}
+
+// Lookup returns a Manager by the service name, if
+// not exist, it will return nil.
+func (manager *Manager[CTX]) Lookup(service string) (*Control[CTX], bool) {
+ manager.RLock()
+ m, ok := manager.M[service]
+ manager.RUnlock()
+ return m, ok
+}
+
+// ForEach iterates through managers.
+func (manager *Manager[CTX]) ForEach(iterator func(key string, manager *Control[CTX]) bool) {
+ manager.RLock()
+ m := cpmp(manager.M)
+ manager.RUnlock()
+ for k, v := range m {
+ if !iterator(k, v) {
+ return
+ }
+ }
+}
+
+func cpmp[CTX any](m map[string]*Control[CTX]) map[string]*Control[CTX] {
+ ret := make(map[string]*Control[CTX], len(m))
+ for k, v := range m {
+ ret[k] = v
+ }
+ return ret
+}
diff --git a/third_party/zbpctrl/model.go b/third_party/zbpctrl/model.go
new file mode 100644
index 0000000000..cb5819764d
--- /dev/null
+++ b/third_party/zbpctrl/model.go
@@ -0,0 +1,38 @@
+package control
+
+// GroupConfig holds the group config for the Manager.
+type GroupConfig struct {
+ GroupID int64 `db:"gid"` // GroupID 群号
+ Disable int64 `db:"disable"` // Disable 默认启用该插件
+}
+
+// BanStatus 在某群封禁某人的状态
+type BanStatus struct {
+ ID int64 `db:"id"`
+ UserID int64 `db:"uid"`
+ GroupID int64 `db:"gid"`
+}
+
+// BlockStatus 全局 ban 某人
+type BlockStatus struct {
+ UserID int64 `db:"uid"`
+}
+
+// ResponseGroup 响应的群
+type ResponseGroup struct {
+ GroupID int64 `db:"gid"` // GroupID 群号, 个人为负
+ Extra string `db:"ext"` // Extra 该群的扩展数据
+}
+
+// Options holds the optional parameters for the Manager.
+type Options[CTX any] struct {
+ DisableOnDefault bool
+ Extra int16 // 插件申请的 Extra 记录号, 可为 -32768~32767, 0 不可用
+ Brief string // 简介
+ Help string // 帮助文本信息
+ Banner string // 背景图路径, 可为 http 或 本地 路径
+ PrivateDataFolder string // 全部小写的数据文件夹名, 不出现在 zbpdata
+ PublicDataFolder string // 驼峰的数据文件夹名, 出现在 zbpdata
+ OnEnable func(CTX) // 启用插件后执行的命令, 为空则打印 “已启用服务: xxx”
+ OnDisable func(CTX) // 禁用插件后执行的命令, 为空则打印 “已禁用服务: xxx”
+}
diff --git a/third_party/zbpctrl/response.go b/third_party/zbpctrl/response.go
new file mode 100644
index 0000000000..a4ff25197b
--- /dev/null
+++ b/third_party/zbpctrl/response.go
@@ -0,0 +1,115 @@
+package control
+
+import (
+ "errors"
+ "fmt"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+var (
+ ErrAlreadyResponding = errors.New("already in response")
+ ErrAlreadySilent = errors.New("already in silence")
+)
+
+// Mutations read persisted state and distinguish missing rows from DB failures.
+// Reads here do not populate caches, so a rejected write leaves them unchanged.
+func (manager *Manager[CTX]) persistedResponseLocked(gid int64) (bool, error) {
+ ids := []int64{0}
+ if gid != 0 {
+ ids = append(ids, gid)
+ }
+ for _, id := range ids {
+ var row ResponseGroup
+ err := manager.D.Find("__resp", &row, "WHERE gid = ?", id)
+ if errors.Is(err, sql.ErrNullResult) {
+ continue
+ }
+ if err != nil {
+ return false, err
+ }
+ if row.Extra != "-" {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (manager *Manager[CTX]) initResponse() error {
+ return manager.D.Create("__resp", &ResponseGroup{})
+}
+
+func (manager *Manager[CTX]) responseLocked(gid int64) bool {
+ for _, id := range []int64{0, gid} {
+ extra, ok := manager.respCache[id]
+ if !ok {
+ var row ResponseGroup
+ if err := manager.D.Find("__resp", &row, "WHERE gid = ?", id); err == nil {
+ extra = row.Extra
+ } else {
+ extra = "-"
+ }
+ manager.respCache[id] = extra
+ }
+ if extra != "-" {
+ return true
+ }
+ }
+ return false
+}
+
+func (manager *Manager[CTX]) Response(gid int64) error {
+ manager.Lock()
+ defer manager.Unlock()
+ active, err := manager.persistedResponseLocked(gid)
+ if err != nil {
+ return err
+ }
+ if active {
+ return fmt.Errorf("group %d: %w", gid, ErrAlreadyResponding)
+ }
+ if err := manager.beforePolicyLocked(); err != nil {
+ return err
+ }
+ if err := manager.D.Insert("__resp", &ResponseGroup{GroupID: gid}); err != nil {
+ return err
+ }
+ manager.respCache[gid] = ""
+ return nil
+}
+
+// Silence retains the original row deletion semantics.
+func (manager *Manager[CTX]) Silence(gid int64) error {
+ manager.Lock()
+ defer manager.Unlock()
+ active, err := manager.persistedResponseLocked(gid)
+ if err != nil {
+ return err
+ }
+ if !active {
+ return fmt.Errorf("group %d: %w", gid, ErrAlreadySilent)
+ }
+ var row ResponseGroup
+ err = manager.D.Find("__resp", &row, "WHERE gid = ?", gid)
+ if errors.Is(err, sql.ErrNullResult) {
+ // Global response can be active without a local row to delete.
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err := manager.beforePolicyLocked(); err != nil {
+ return err
+ }
+ if err := manager.D.Del("__resp", "WHERE gid = ?", gid); err != nil {
+ return err
+ }
+ manager.respCache[gid] = "-"
+ return nil
+}
+
+func (manager *Manager[CTX]) CanResponse(gid int64) bool {
+ manager.Lock()
+ defer manager.Unlock()
+ return manager.responseLocked(gid)
+}
diff --git a/third_party/zbpctrl/response_state.go b/third_party/zbpctrl/response_state.go
new file mode 100644
index 0000000000..30c7cdcfb4
--- /dev/null
+++ b/third_party/zbpctrl/response_state.go
@@ -0,0 +1,55 @@
+package control
+
+import (
+ "errors"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+// ResponseState describes the existing OR rule without exposing extension data.
+type ResponseState struct {
+ GlobalEnabled bool `json:"globalEnabled"`
+ GroupEnabled bool `json:"groupEnabled"`
+ EffectiveEnabled bool `json:"effectiveEnabled"`
+ Source string `json:"source"`
+}
+
+func (manager *Manager[CTX]) responseRowActiveLocked(gid int64) (bool, error) {
+ var row ResponseGroup
+ err := manager.D.Find("__resp", &row, "WHERE gid = ?", gid)
+ if errors.Is(err, sql.ErrNullResult) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return row.Extra != "-", nil
+}
+
+// ReadResponseState reads global and positive-group rules under one lock. It
+// neither changes response caches nor writes rows or invokes backup hooks.
+func (manager *Manager[CTX]) ReadResponseState(gid int64) (ResponseState, error) {
+ if gid < 0 {
+ return ResponseState{}, errors.New("response scope must be global or a positive group")
+ }
+ manager.Lock()
+ defer manager.Unlock()
+ global, err := manager.responseRowActiveLocked(0)
+ if err != nil {
+ return ResponseState{}, err
+ }
+ local := false
+ if gid != 0 {
+ local, err = manager.responseRowActiveLocked(gid)
+ if err != nil {
+ return ResponseState{}, err
+ }
+ }
+ source := "none"
+ if global {
+ source = "global"
+ } else if local {
+ source = "group"
+ }
+ return ResponseState{GlobalEnabled: global, GroupEnabled: local, EffectiveEnabled: global || local, Source: source}, nil
+}
diff --git a/third_party/zbpctrl/restore.go b/third_party/zbpctrl/restore.go
new file mode 100644
index 0000000000..c396af1ec2
--- /dev/null
+++ b/third_party/zbpctrl/restore.go
@@ -0,0 +1,15 @@
+package control
+
+// RestoredLocked invalidates control caches only after a database restore commits.
+// The caller holds the Manager lock and supplies old/new persisted default bits.
+func (manager *Manager[CTX]) RestoredLocked(old, next map[string]int64) {
+ for name, c := range manager.M {
+ if (uint64(old[name])^uint64(next[name]))>>63 != 0 {
+ c.Options.DisableOnDefault = !c.Options.DisableOnDefault
+ }
+ c.Cache = make(map[int64]uint8, 16)
+ }
+ manager.banCache = make(map[uint64]bool, 8)
+ manager.blockCache = make(map[int64]bool, 8)
+ manager.respCache = make(map[int64]string, 8)
+}
diff --git a/third_party/zbpctrl/switch.go b/third_party/zbpctrl/switch.go
new file mode 100644
index 0000000000..9492cc6c88
--- /dev/null
+++ b/third_party/zbpctrl/switch.go
@@ -0,0 +1,147 @@
+package control
+
+import (
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "strings"
+
+ db "github.com/FloatTech/sqlite"
+)
+
+var ErrSwitchConflict = errors.New("plugin settings changed")
+
+// Explicit inheritance preserves the plugin's 62-bit data and default-flip flag.
+type switchInheritance struct {
+ ID string `db:"id"`
+}
+
+type SwitchState struct {
+ DefaultEnabled bool `json:"defaultEnabled"`
+ GlobalMode string `json:"globalMode"`
+ GroupMode string `json:"groupMode"`
+ EffectiveEnabled bool `json:"effectiveEnabled"`
+ Source string `json:"source"`
+ Revision string `json:"revision"`
+}
+
+func (m *Control[CTX]) inheritanceKey(gid int64) string {
+ return strconv.FormatInt(gid, 10) + ":" + m.Service
+}
+
+func (m *Control[CTX]) loadSwitchLocked(gid int64) (GroupConfig, string, error) {
+ c := GroupConfig{GroupID: gid}
+ err := m.Manager.D.Find(m.Service, &c, "WHERE gid = ?", gid)
+ exists := err == nil
+ if err != nil && !errors.Is(err, db.ErrNullResult) {
+ return c, "", err
+ }
+ var marker switchInheritance
+ err = m.Manager.D.Find("__switch_inherit", &marker, "WHERE id = ?", m.inheritanceKey(gid))
+ if err != nil && !errors.Is(err, db.ErrNullResult) {
+ return c, "", err
+ }
+ if err == nil || !exists {
+ return c, "inherit", nil
+ }
+ if c.Disable&1 == 0 {
+ return c, "enabled", nil
+ }
+ return c, "disabled", nil
+}
+
+func (m *Control[CTX]) stateLocked(gid int64) (SwitchState, error) {
+ global, globalMode, err := m.loadSwitchLocked(0)
+ if err != nil {
+ return SwitchState{}, err
+ }
+ group := GroupConfig{GroupID: gid}
+ groupMode := "inherit"
+ if gid != 0 {
+ group, groupMode, err = m.loadSwitchLocked(gid)
+ if err != nil {
+ return SwitchState{}, err
+ }
+ }
+ s := SwitchState{DefaultEnabled: !m.Options.DisableOnDefault, GlobalMode: globalMode, GroupMode: groupMode, Source: "default", EffectiveEnabled: !m.Options.DisableOnDefault}
+ if groupMode != "inherit" {
+ s.Source = "group"
+ s.EffectiveEnabled = groupMode == "enabled"
+ }
+ if globalMode != "inherit" {
+ s.Source = "global"
+ s.EffectiveEnabled = globalMode == "enabled"
+ }
+ raw, _ := json.Marshal([]any{gid, s.DefaultEnabled, globalMode, groupMode, global.Disable, group.Disable})
+ digest := sha256.Sum256(raw)
+ s.Revision = hex.EncodeToString(digest[:])
+ return s, nil
+}
+
+func (m *Control[CTX]) SwitchState(gid int64) (SwitchState, error) {
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ return m.stateLocked(gid)
+}
+
+func (m *Control[CTX]) writeSwitchLocked(c GroupConfig, mode string) error {
+ table := `"` + strings.ReplaceAll(m.Service, `"`, `""`) + `"`
+ return m.Manager.D.Transaction(func(tx *sql.Tx) error {
+ if mode != "inherit" {
+ c.Disable &^= 1
+ if mode == "disabled" {
+ c.Disable |= 1
+ }
+ }
+ if _, err := tx.Exec("INSERT OR REPLACE INTO "+table+" (gid,disable) VALUES (?,?)", c.GroupID, c.Disable); err != nil {
+ return err
+ }
+ if mode == "inherit" {
+ _, err := tx.Exec("INSERT OR REPLACE INTO __switch_inherit (id) VALUES (?)", m.inheritanceKey(c.GroupID))
+ return err
+ }
+ _, err := tx.Exec("DELETE FROM __switch_inherit WHERE id = ?", m.inheritanceKey(c.GroupID))
+ return err
+ })
+}
+
+// SetSwitch serializes snapshots and writes with the normal control writers.
+// expected is optional for chat; the admin always supplies a read revision.
+func (m *Control[CTX]) SetSwitch(gid int64, mode, expected string) (SwitchState, error) {
+ if mode != "enabled" && mode != "disabled" && mode != "inherit" {
+ return SwitchState{}, errors.New("invalid switch mode")
+ }
+ m.Manager.Lock()
+ defer m.Manager.Unlock()
+ state, err := m.stateLocked(gid)
+ if err != nil {
+ return SwitchState{}, err
+ }
+ if expected != "" && state.Revision != expected {
+ return SwitchState{}, ErrSwitchConflict
+ }
+ oldMode := state.GroupMode
+ if gid == 0 {
+ oldMode = state.GlobalMode
+ }
+ if oldMode == mode {
+ return state, nil
+ }
+ c, _, err := m.loadSwitchLocked(gid)
+ if err != nil {
+ return SwitchState{}, err
+ }
+ if m.Manager.BeforeSwitch != nil {
+ if err = m.Manager.BeforeSwitch(); err != nil {
+ return SwitchState{}, err
+ }
+ }
+ if err = m.writeSwitchLocked(c, mode); err != nil {
+ return SwitchState{}, err
+ }
+ m.Cache = make(map[int64]uint8, 16)
+ return m.stateLocked(gid)
+}
diff --git a/third_party/zbputils/.github/workflows/pull.yml b/third_party/zbputils/.github/workflows/pull.yml
new file mode 100644
index 0000000000..b64050a4ef
--- /dev/null
+++ b/third_party/zbputils/.github/workflows/pull.yml
@@ -0,0 +1,37 @@
+name: PullLint
+on: [ pull_request ]
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go
+ uses: actions/setup-go@master
+ with:
+ go-version: "^1.26"
+
+ - name: Check out code into the Go module directory
+ uses: actions/checkout@master
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@master
+ with:
+ # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
+ version: latest
+
+ # Optional: working directory, useful for monorepos
+ # working-directory: somedir
+
+ # Optional: golangci-lint command line arguments.
+ # args: --issues-exit-code=0
+
+ # Optional: show only new issues if it's a pull request. The default value is `false`.
+ # only-new-issues: true
+
+ # Optional: if set to true then the action don't cache or restore ~/go/pkg.
+ # skip-pkg-cache: true
+
+ # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
+ # skip-build-cache: true
+
+ install-mode: goinstall
diff --git a/third_party/zbputils/.github/workflows/push.yml b/third_party/zbputils/.github/workflows/push.yml
new file mode 100644
index 0000000000..bd79194447
--- /dev/null
+++ b/third_party/zbputils/.github/workflows/push.yml
@@ -0,0 +1,30 @@
+name: PushLint
+on: [ push ]
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go
+ uses: actions/setup-go@master
+ with:
+ go-version: "^1.26"
+
+ - name: Check out code into the Go module directory
+ uses: actions/checkout@master
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@master
+ with:
+ version: latest
+ args: --issues-exit-code=0 --timeout=10m
+ install-mode: goinstall
+
+ - name: Commit back
+ continue-on-error: true
+ run: |
+ git config --local user.name 'github-actions[bot]'
+ git config --local user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add --all
+ git commit -m "🎨 改进代码样式"
+ git push
diff --git a/third_party/zbputils/.gitignore b/third_party/zbputils/.gitignore
new file mode 100644
index 0000000000..180e910572
--- /dev/null
+++ b/third_party/zbputils/.gitignore
@@ -0,0 +1,16 @@
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+.DS_Store
diff --git a/third_party/zbputils/.golangci.yml b/third_party/zbputils/.golangci.yml
new file mode 100644
index 0000000000..6b4a5f1dc6
--- /dev/null
+++ b/third_party/zbputils/.golangci.yml
@@ -0,0 +1,75 @@
+linters-settings:
+ errcheck:
+ ignoretests: true
+
+ goimports:
+ local-prefixes: github.com/FloatTech/zbputils
+
+ forbidigo:
+ # Forbid the following identifiers
+ forbid:
+ - ^fmt\.Errorf$ # consider errors.Errorf in github.com/pkg/errors
+
+linters:
+ # please, do not use `enable-all`: it's deprecated and will be removed soon.
+ # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint
+ disable-all: true
+ fast: false
+ enable:
+ - bodyclose
+ #- depguard
+ - dogsled
+ - errcheck
+ #- exportloopref
+ - exhaustive
+ #- funlen
+ #- goconst
+ - gocritic
+ #- gocyclo
+ - gofmt
+ - goimports
+ - goprintffuncname
+ #- gosec
+ - gosimple
+ - govet
+ - ineffassign
+ #- misspell
+ - nolintlint
+ - rowserrcheck
+ - staticcheck
+ - stylecheck
+ - typecheck
+ - unconvert
+ - unparam
+ - unused
+ - whitespace
+ - prealloc
+ - predeclared
+ - asciicheck
+ - revive
+ - forbidigo
+ - makezero
+
+run:
+ # default concurrency is a available CPU number.
+ # concurrency: 4 # explicitly omit this value to fully utilize available resources.
+ deadline: 15m
+ issues-exit-code: 1
+ tests: false
+ go: '1.26'
+
+# output configuration options
+output:
+ formats:
+ - format: "colored-line-number"
+ print-issued-lines: true
+ print-linter-name: true
+
+issues:
+ # Fix found issues (if it's supported by the linter)
+ fix: true
+ exclude-use-default: false
+ uniq-by-line: true
+ exclude:
+ - "Error return value of .((os.)?std(out|err)..*|.*Close|.*Seek|.*Flush|os.Remove(All)?|.*print(f|ln)?|os.(Un)?Setenv). is not check"
+ - 'identifier ".*" contain non-ASCII character: U\+.*'
diff --git a/third_party/zbputils/LICENSE b/third_party/zbputils/LICENSE
new file mode 100644
index 0000000000..f288702d2f
--- /dev/null
+++ b/third_party/zbputils/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/third_party/zbputils/LOCAL_PATCH.md b/third_party/zbputils/LOCAL_PATCH.md
new file mode 100644
index 0000000000..0df67c06d6
--- /dev/null
+++ b/third_party/zbputils/LOCAL_PATCH.md
@@ -0,0 +1,9 @@
+# 本地工具模块补丁
+
+沿用原始许可证与模块结构,根模块和本模块均通过相邻目录使用配套的SQLite、控制层、Agent及资源请求补丁。
+
+主要修改包括AI共享配置与检查写入、会话参数并发合并、Agent事件/动作持久记录、角色和当前群边界、模型协议及消息内容处理,以及服务列表与响应命令的一致控制语义。应用层集成验证见主项目的使用与开发文档。
+
+在本目录执行`go test ./chat ./ctxext ./control`验证相关功能。上游`TestMaru`原实现仅打印一个长度后无条件调用`t.Fail()`,没有功能断言;这里保留名称并明确跳过,不把它计为验证通过。其余功能测试正常执行。
+
+本模块初始化可能按需读取或下载字体等资源;生成的data目录属于运行/测试缓存,不随源码上传。
diff --git a/third_party/zbputils/README.md b/third_party/zbputils/README.md
new file mode 100644
index 0000000000..3bb10bafc6
--- /dev/null
+++ b/third_party/zbputils/README.md
@@ -0,0 +1,8 @@
+# zbputils
+ZeroBot-Plugin 的工具库
+
+swag初始化
+
+```
+swag init -g control/web/gui.go -o control/web/docs/
+```
\ No newline at end of file
diff --git a/third_party/zbputils/chat/README.md b/third_party/zbputils/chat/README.md
new file mode 100644
index 0000000000..5eb770498c
--- /dev/null
+++ b/third_party/zbputils/chat/README.md
@@ -0,0 +1,9 @@
+在QQ群与用户闲聊。你将收到不同的用户发送的一至多条消息。
+你要发送一条消息回复,但切记不要逐一点名回复每个人的消息,而是以聊天的口吻随意回复。
+
+每条消息包含:
+1. 以`>>`开头的是特别@你的消息,如无人@你则这部分不存在;
+2. 【发送者用户名】;
+3. 消息内容。
+
+你输出的消息禁止包含上面提到的格式,只发送消息内容即可。
diff --git a/third_party/zbputils/chat/agent.go b/third_party/zbputils/chat/agent.go
new file mode 100644
index 0000000000..5b74affd03
--- /dev/null
+++ b/third_party/zbputils/chat/agent.go
@@ -0,0 +1,387 @@
+package chat
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "io"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/RomiChan/syncx"
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+ goba "github.com/fumiama/go-onebot-agent"
+ "github.com/sirupsen/logrus"
+ "gopkg.in/yaml.v3"
+
+ "github.com/FloatTech/floatbox/binary"
+ "github.com/FloatTech/floatbox/file"
+ "github.com/FloatTech/floatbox/process"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/zbputils/control"
+ "github.com/FloatTech/zbputils/vevent"
+)
+
+const (
+ // StateKeyAgentHooked indicates that whether the agent log is in recording
+ StateKeyAgentHooked = zero.StateKeyPrefixKeep + "_chat_ag_hooked__"
+ // StateKeyAgentTriggered shows that this message has already triggered an agent run
+ StateKeyAgentTriggered = zero.StateKeyPrefixKeep + "_chat_ag_triggered__"
+)
+
+// agentchar 将 char.yaml 内容嵌入为默认 agent 性格
+//
+//go:embed char.yaml
+var agentchar []byte
+
+var agentcharcfg charcfg
+
+// AgentCharConfig 可随时更改的 agent 性格配置
+var AgentCharConfig *goba.Config
+
+var ags = syncx.Map[int64, *goba.Agent]{}
+var agentsMu sync.Mutex
+var histories = map[int64]*agentHistory{}
+var agentGeneration atomic.Uint64
+
+func AgentGeneration() uint64 { return agentGeneration.Load() }
+
+type charcfg struct {
+ Sex string `yaml:"sex"`
+ Char string `yaml:"char"`
+}
+
+func init() {
+ err := yaml.NewDecoder(bytes.NewReader(agentchar)).Decode(&agentcharcfg)
+ if err != nil {
+ panic(err)
+ }
+ AgentCharConfig = &goba.Config{
+ Sex: agentcharcfg.Sex,
+ Chars: agentcharcfg.Char,
+ }
+ go func() {
+ process.GlobalInitMutex.Lock()
+ AgentCharConfig.Nickname = strings.Join(zero.BotConfig.NickName, "/")
+ logrus.Infoln("[chat] 设置 agent 昵称:", AgentCharConfig.Nickname)
+ process.GlobalInitMutex.Unlock()
+ }()
+}
+
+// AgentOf id is self_id
+func AgentOf(id int64, service string) *goba.Agent {
+ agentsMu.Lock()
+ defer agentsMu.Unlock()
+ if ag, ok := ags.Load(id); ok {
+ return ag
+ }
+ mem, err := atomicgetmemstorage(service)
+ if err != nil {
+ logrus.Warnln("[chat] Agent 记忆存储不可用。")
+ mem = &memstorage{}
+ }
+ process.GlobalInitMutex.Lock()
+ character := *AgentCharConfig
+ process.GlobalInitMutex.Unlock()
+ if cfg, err := CurrentConfig(); err == nil {
+ character.Chars, character.Sex = cfg.AgentChar, cfg.AgentSex
+ }
+ ag := goba.NewAgent(&character, 16, 8, time.Hour*24, "", mem, true, false)
+ history := histories[id]
+ budget := agentContextBytes
+ if cfg, err := CurrentConfig(); err == nil {
+ if api, _ := DisplayAPI(cfg.AgentAPI); !strings.HasPrefix(api, "https://api.deepseek.com/") {
+ budget = 65536
+ }
+ }
+ if history == nil {
+ history, err = openAgentHistory(filepath.Join(file.BOTPATH, "data", service, "history"), id, budget, CurrentConfig)
+ if err == nil {
+ histories[id] = history
+ }
+ }
+ if history != nil {
+ history.mu.Lock()
+ history.budget = budget
+ history.mu.Unlock()
+ ag.SetHistoryStore(history)
+ } else {
+ logrus.Warnln("[chat] Agent 历史记录不可用,暂停 Agent 操作。")
+ ag.SetHistoryStore(unavailableHistory{})
+ }
+ ags.Store(id, &ag)
+ return &ag
+}
+
+// ReloadAgents applies new persona/provider settings without erasing history.
+func ReloadAgents() {
+ agentsMu.Lock()
+ defer agentsMu.Unlock()
+ agentGeneration.Add(1)
+ ks := make([]int64, 0, 8)
+ ags.Range(func(key int64, _ *goba.Agent) bool {
+ ks = append(ks, key)
+ return true
+ })
+ for _, k := range ks {
+ ags.Delete(k)
+ }
+}
+
+// ResetAgents starts fresh contexts while retaining all archived records.
+func ResetAgents() error {
+ agentsMu.Lock()
+ for _, history := range histories {
+ if err := history.reset(); err != nil {
+ agentsMu.Unlock()
+ return err
+ }
+ }
+ agentsMu.Unlock()
+ ReloadAgents()
+ return nil
+}
+
+var checkgids = map[string]struct{}{
+ "send_group_msg": {},
+ "set_group_kick": {},
+ "set_group_ban": {},
+ "set_group_whole_ban": {},
+ "set_group_card": {},
+ "set_group_name": {},
+ "set_group_special_title": {},
+ "get_group_member_info": {},
+ "get_group_member_list": {},
+ "get_group_info": {},
+}
+
+// CallAgent and check group API permission
+func CallAgent(ag *goba.Agent, issudo bool, iter int, api deepinfra.API, p model.Protocol, grp int64, role goba.PermRole) []zero.APIRequest {
+ reqs, _, _ := NextAgentActions(ag, issudo, iter, api, p, grp, role)
+ return reqs
+}
+
+func NextAgentActions(ag *goba.Agent, issudo bool, iter int, api deepinfra.API, p model.Protocol, grp int64, role goba.PermRole) ([]zero.APIRequest, bool, error) {
+ reqs, err := ag.GetAction(api, p, grp, role, iter, false)
+ ended := errors.Is(err, io.EOF)
+ if err != nil && !ended {
+ logrus.Warnln("[chat] Agent 请求或记录保存失败,请检查模型配置、网络与本机存储。")
+ return nil, false, err
+ }
+ logrus.Infoln("[chat] Agent 动作数:", len(reqs), "结束:", ended)
+ checkedreqs := make([]zero.APIRequest, 0, len(reqs))
+ for _, req := range reqs {
+ if _, ok := checkgids[req.Action]; ok {
+ v, ok := req.Params["group_id"].(json.Number)
+ if !ok {
+ logrus.Warnln("[chat] invalid group_id type", reflect.TypeOf(req.Params["group_id"]))
+ continue
+ }
+ gid, err := v.Int64()
+ if err != nil || gid <= 0 {
+ logrus.Warnln("[chat] agent conv req gid err:", err)
+ continue
+ }
+ if grp != gid && !issudo {
+ logrus.Warnln("[chat] refuse to send out of grp from", grp, "to", gid)
+ continue
+ }
+ if role == goba.PermRoleUser { // check @all
+ msgb, err := json.Marshal(req.Params["message"])
+ if err != nil {
+ logrus.Warnln("[chat] re-marshal msg err:", err)
+ continue
+ }
+ msgs := message.ParseMessage(msgb)
+ isvalid := true
+ for _, m := range msgs {
+ if m.Type == "at" {
+ qqs, ok := m.Data["qq"]
+ if !ok {
+ logrus.Warnln("[chat] invalid at message without qq")
+ isvalid = false
+ break
+ }
+ qq, err := strconv.ParseInt(qqs, 10, 64)
+ if err != nil {
+ logrus.Warnln("[chat] invalid at qq", qqs)
+ isvalid = false
+ break
+ }
+ if qq <= 0 {
+ logrus.Warnln("[chat] invalid at qq num", qq)
+ isvalid = false
+ break
+ }
+ }
+ }
+ if !isvalid {
+ continue
+ }
+ }
+ }
+ checkedreqs = append(checkedreqs, req)
+ }
+ return checkedreqs, ended, nil
+}
+
+func togobaev(ev *zero.Event) *goba.Event {
+ msgid := int64(0)
+ switch id := ev.MessageID.(type) {
+ case int64:
+ msgid = id
+ case int:
+ msgid = int64(id)
+ case string:
+ var err error
+ msgid, err = strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ return nil
+ }
+ case json.Number:
+ var err error
+ msgid, err = id.Int64()
+ if err != nil {
+ return nil
+ }
+ default:
+ if ev.PostType == "message" {
+ return nil
+ }
+ }
+ msgd := ev.NativeMessage
+ return &goba.Event{
+ Time: ev.Time,
+ PostType: ev.PostType,
+ MessageType: ev.MessageType,
+ SubType: ev.SubType,
+ MessageID: msgid,
+ GroupID: ev.GroupID,
+ UserID: ev.UserID,
+ TargetID: ev.TargetID,
+ SelfID: ev.SelfID,
+ NoticeType: ev.NoticeType,
+ OperatorID: ev.OperatorID,
+ File: ev.File,
+ RequestType: ev.RequestType,
+ Flag: ev.Flag,
+ Comment: ev.Comment,
+ Sender: ev.Sender,
+ Message: msgd,
+ }
+}
+
+func truncatecopy(params map[string]any) map[string]any {
+ cpp := make(map[string]any, len(params))
+ for k, v := range params {
+ switch val := v.(type) {
+ case string:
+ if len(val) > 512 {
+ cpp[k] = val[:200] + " ... " + val[len(val)-200:]
+ } else {
+ cpp[k] = val
+ }
+ case int64, int:
+ cpp[k] = val
+ case message.Message:
+ valCopy := make(message.Message, len(val))
+ copy(valCopy, val)
+ for i, m := range val {
+ valCopy[i] = message.Segment{
+ Type: m.Type,
+ Data: make(map[string]string, len(m.Data)),
+ }
+ for k, v := range m.Data {
+ if len(v) > 512 {
+ valCopy[i].Data[k] = v[:200] + " ... " + v[len(v)-200:]
+ } else {
+ valCopy[i].Data[k] = v
+ }
+ }
+ }
+ cpp[k] = valCopy
+ case message.Segment:
+ valCopy := message.Segment{
+ Type: val.Type,
+ Data: make(map[string]string, len(val.Data)),
+ }
+ for k, v := range val.Data {
+ if len(v) > 512 {
+ valCopy.Data[k] = v[:200] + " ... " + v[len(v)-200:]
+ } else {
+ valCopy.Data[k] = v
+ }
+ }
+ cpp[k] = valCopy
+ default:
+ logrus.Warnln("[chat] agent unknown params typ:", reflect.TypeOf(v))
+ }
+ }
+ return cpp
+}
+
+func logev(ctx *zero.Ctx) {
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ ev := togobaev(ctx.Event)
+ if ev == nil {
+ return
+ }
+ data, _ := json.Marshal(ev)
+ logrus.Debugln("[chat] agent", gid, "add ev:", binary.BytesToString(data))
+ AgentOf(ctx.Event.SelfID, "aichat").AddEvent(gid, ev)
+
+ mp := ctx.State[control.StateKeySyncxState].(*syncx.Map[string, any])
+
+ _, hasvalue := mp.LoadOrStore(StateKeyAgentHooked, struct{}{})
+ if hasvalue {
+ return
+ }
+
+ vevent.HookCtxCaller(ctx, vevent.NewAPICallerReturnHook(
+ ctx, func(req zero.APIRequest, rsp zero.APIResponse, _ error) {
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ if _, ok := mp.Load(StateKeyAgentTriggered); ok {
+ logrus.Debugln("[chat] agent", gid, "skip agent triggered requ:", &req)
+ return
+ }
+ if req.Action != "send_private_msg" &&
+ req.Action != "send_group_msg" &&
+ req.Action != "delete_msg" {
+ logrus.Debugln("[chat] agent", gid, "skip non-msg other triggered action:", req.Action)
+ return
+ }
+ ag := AgentOf(ctx.Event.SelfID, "aichat")
+ logrus.Debugln("[chat] agent others", gid, "add requ:", &req)
+ ag.AddRequest(gid, &req)
+ logrus.Debugln("[chat] agent others", gid, "add resp:", &rsp)
+ ag.AddResponse(gid, &goba.APIResponse{
+ Status: rsp.Status,
+ Data: json.RawMessage(rsp.Data.Raw),
+ Message: rsp.Message,
+ Wording: rsp.Wording,
+ RetCode: rsp.RetCode,
+ })
+ }),
+ )
+}
+
+func init() {
+ zero.OnNotice().FirstPriority().SetBlock(false).Handle(logev)
+ zero.OnMessage().FirstPriority().SetBlock(false).Handle(logev)
+ zero.OnRequest().FirstPriority().SetBlock(false).Handle(logev)
+}
diff --git a/third_party/zbputils/chat/agent_run.go b/third_party/zbputils/chat/agent_run.go
new file mode 100644
index 0000000000..e58b87efe2
--- /dev/null
+++ b/third_party/zbputils/chat/agent_run.go
@@ -0,0 +1,59 @@
+package chat
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+var ErrAgentRepeatedAction = errors.New("Agent 重复生成相同动作,已停止本次操作")
+var ErrAgentReloaded = errors.New("Agent 设置已更新,已停止旧任务")
+
+// RunAgent has no fixed iteration count. Explicit end, request/write failure,
+// reload or a repeated action cycle terminates the current task. New messages
+// can start new tasks with the same retained conversation history.
+func RunAgent(next func(int) ([]zero.APIRequest, bool, error), execute func(zero.APIRequest) error, current func() bool) error {
+ seen := map[[32]byte]bool{}
+ for iteration := 1; ; iteration++ {
+ if !current() {
+ return ErrAgentReloaded
+ }
+ reqs, ended, err := next(iteration)
+ if err != nil {
+ return err
+ }
+ if len(reqs) > 0 {
+ // Ignore Echo/metadata: changing an echo ID is not task progress.
+ actions := make([]struct {
+ Action string
+ Params zero.Params
+ }, len(reqs))
+ for i, r := range reqs {
+ actions[i].Action = r.Action
+ actions[i].Params = r.Params
+ }
+ raw, err := json.Marshal(actions)
+ if err != nil {
+ return errors.New("Agent 动作格式无效")
+ }
+ fingerprint := sha256.Sum256(raw)
+ if seen[fingerprint] {
+ return ErrAgentRepeatedAction
+ }
+ seen[fingerprint] = true
+ for _, req := range reqs {
+ if !current() {
+ return ErrAgentReloaded
+ }
+ if err := execute(req); err != nil {
+ return err
+ }
+ }
+ }
+ if ended || len(reqs) == 0 {
+ return nil
+ }
+ }
+}
diff --git a/third_party/zbputils/chat/cfg.go b/third_party/zbputils/chat/cfg.go
new file mode 100644
index 0000000000..870069eb6f
--- /dev/null
+++ b/third_party/zbputils/chat/cfg.go
@@ -0,0 +1,364 @@
+package chat
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "net/url"
+ "reflect"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/FloatTech/zbputils/control"
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+type ModelType int
+
+var apilist = [3]string{"OpenAI", "OLLaMA", "GenAI"}
+
+func newModelType(text string) (ModelType, error) {
+ for i, name := range apilist {
+ if text == name {
+ return ModelType(i), nil
+ }
+ }
+ return 0, errors.New("未知接口类型")
+}
+func (mt ModelType) String() string {
+ if mt < 0 || int(mt) >= len(apilist) {
+ return "未知类型"
+ }
+ return apilist[mt]
+}
+func (mt ModelType) Protocol(name string, temp, topp float32, maxn uint, reasoning string, separators ...string) (model.Protocol, error) {
+ separator := ""
+ if len(separators) > 0 {
+ separator = separators[0]
+ }
+ switch mt {
+ case 0:
+ return model.NewOpenAI(name, separator, temp, topp, maxn, reasoning), nil
+ case 1:
+ return model.NewOLLaMA(name, separator, temp, topp, maxn), nil
+ case 2:
+ return model.NewGenAI(name, temp, topp, maxn), nil
+ default:
+ return nil, errors.New("不支持的模型接口类型")
+ }
+}
+
+type ModelBool bool
+
+func (b ModelBool) String() string {
+ if b {
+ return "是"
+ }
+ return "否"
+}
+
+type ModelKey string
+
+func (k ModelKey) String() string {
+ if k == "" {
+ return "未设置"
+ }
+ return "已配置(隐藏)"
+}
+
+// AgentConfig preserves the existing on-disk JSON field names. Callers use
+// immutable snapshots from the shared control database instead of a global AC.
+type AgentConfig struct {
+ ModelName, ImageModelName, AgentModelName string
+ Type, ImageType, AgentType ModelType
+ MaxN uint
+ TopP float32
+ SystemP, AgentChar, AgentSex string
+ API, ImageAPI, AgentAPI string
+ Key, ImageKey, AgentKey ModelKey
+ Separator, ReasoningEffort string
+ NoSystemP ModelBool
+}
+
+func newconfig() AgentConfig {
+ return AgentConfig{ModelName: model.ModelDeepDeek, SystemP: SystemPrompt, API: deepinfra.OpenAIDeepInfra, AgentChar: agentcharcfg.Char, AgentSex: agentcharcfg.Sex}
+}
+func (c AgentConfig) MParams() (float32, uint) {
+ top, max := c.TopP, c.MaxN
+ if top == 0 {
+ top = 0.9
+ }
+ if max == 0 {
+ max = 4096
+ }
+ return top, max
+}
+func (c AgentConfig) Ready() bool {
+ return c.Type >= 0 && c.Type <= 2 && c.API != "" && c.ModelName != "" && (c.Key != "" || c.Type == 1)
+}
+
+// DisplayAPI hides all URL credentials and query values in legacy settings.
+func DisplayAPI(raw string) (string, bool) {
+ if raw == "" {
+ return "", false
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") {
+ return "地址无效(请替换)", true
+ }
+ redacted := u.User != nil || u.RawQuery != "" || u.Fragment != ""
+ u.User, u.RawQuery, u.Fragment = nil, "", ""
+ if redacted {
+ return u.String() + "(认证参数已隐藏)", true
+ }
+ return u.String(), false
+}
+func (c AgentConfig) Redact(text string) string {
+ for _, key := range []ModelKey{c.Key, c.ImageKey, c.AgentKey} {
+ if key != "" {
+ text = strings.ReplaceAll(text, string(key), "[已隐藏]")
+ }
+ }
+ return text
+}
+func (c AgentConfig) String() string {
+ api, _ := DisplayAPI(c.API)
+ imageAPI, _ := DisplayAPI(c.ImageAPI)
+ agentAPI, _ := DisplayAPI(c.AgentAPI)
+ top, max := c.MParams()
+ return c.Redact(fmt.Sprintf("• 模型名:%s\n• 图像模型名:%s\n• Agent模型名:%s\n• 接口类型:%v\n• 图像接口类型:%v\n• Agent接口类型:%v\n• 最大长度:%d\n• TopP:%g\n• 系统提示词:%s\n• Agent性格:%s\n• Agent性别:%s\n• 接口地址:%s\n• 图像接口地址:%s\n• Agent接口地址:%s\n• 密钥:%v\n• 图像密钥:%v\n• Agent密钥:%v\n• 分隔符:%s\n• 推理努力度:%s\n• 支持系统提示词:%v\n", c.ModelName, c.ImageModelName, c.AgentModelName, c.Type, c.ImageType, c.AgentType, max, top, c.SystemP, c.AgentChar, c.AgentSex, api, imageAPI, agentAPI, c.Key, c.ImageKey, c.AgentKey, c.Separator, c.ReasoningEffort, !c.NoSystemP))
+}
+
+type ConfigRepository struct{ Control *ctrl.Control[*zero.Ctx] }
+
+func NewConfigRepository(c *ctrl.Control[*zero.Ctx]) *ConfigRepository {
+ return &ConfigRepository{Control: c}
+}
+func decodeAgentConfig(raw json.RawMessage) (AgentConfig, error) {
+ c := newconfig()
+ if len(raw) > 0 {
+ if !bytes.HasPrefix(bytes.TrimSpace(raw), []byte("{")) {
+ return AgentConfig{}, errors.New("AI 配置格式无效")
+ }
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return AgentConfig{}, errors.New("AI 配置格式无效")
+ }
+ }
+ return c, nil
+}
+func (r *ConfigRepository) Read() (AgentConfig, string, error) {
+ if r == nil || r.Control == nil {
+ return AgentConfig{}, "", errors.New("AI 配置插件未编译")
+ }
+ raw, revision, err := r.Control.ExtraJSON()
+ if err != nil {
+ return AgentConfig{}, "", err
+ }
+ c, err := decodeAgentConfig(raw)
+ return c, revision, err
+}
+
+// validateField validates only changed fields so unrelated legacy values are
+// preserved; new URLs must place authentication in the separate key field.
+func validateField(name string, value json.RawMessage) error {
+ typ, ok := reflect.TypeFor[AgentConfig]().FieldByName(name)
+ if !ok {
+ return errors.New("未知 AI 设置")
+ }
+ ptr := reflect.New(typ.Type)
+ if bytes.Equal(bytes.TrimSpace(value), []byte("null")) || json.Unmarshal(value, ptr.Interface()) != nil {
+ return errors.New("AI 设置值类型无效")
+ }
+ v := ptr.Elem()
+ switch v.Kind() {
+ case reflect.String:
+ text := v.String()
+ max := 256
+ switch name {
+ case "SystemP", "AgentChar":
+ max = 32768
+ case "API", "ImageAPI", "AgentAPI":
+ max = 2048
+ case "Key", "ImageKey", "AgentKey":
+ max = 4096
+ }
+ if len(text) > max || !utf8.ValidString(text) || strings.ContainsRune(text, '\x00') {
+ return errors.New("AI 文本过长或包含无效字符")
+ }
+ if strings.HasSuffix(name, "Key") && strings.ContainsAny(text, "\r\n") {
+ return errors.New("密钥不能包含换行")
+ }
+ if name == "ModelName" && strings.TrimSpace(text) == "" {
+ return errors.New("聊天模型名不能为空")
+ }
+ if name == "API" || name == "ImageAPI" || name == "AgentAPI" {
+ if text == "" && name != "API" {
+ break
+ }
+ u, err := url.Parse(text)
+ if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
+ return errors.New("接口须为完整 HTTP/HTTPS 地址,密钥请填写在独立字段")
+ }
+ if port := u.Port(); port != "" {
+ n, err := strconv.ParseUint(port, 10, 16)
+ if err != nil || n == 0 {
+ return errors.New("接口端口须为 1~65535")
+ }
+ }
+ }
+ case reflect.Int:
+ if v.Int() < 0 || v.Int() > 2 {
+ return errors.New("接口类型须为 OpenAI、OLLaMA 或 GenAI")
+ }
+ case reflect.Uint:
+ if v.Uint() > 131072 {
+ return errors.New("最大长度须为 0~131072,0 使用默认值")
+ }
+ case reflect.Float32:
+ n := v.Float()
+ if math.IsNaN(n) || math.IsInf(n, 0) || n < 0 || n > 1 {
+ return errors.New("TopP 须为 0~1,0 使用默认值")
+ }
+ }
+ return nil
+}
+
+func ValidateConfigFields(fields map[string]json.RawMessage) error {
+ for name, value := range fields {
+ if err := validateField(name, value); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *ConfigRepository) Update(revision string, fields map[string]json.RawMessage) (string, error) {
+ if r == nil || r.Control == nil {
+ return "", errors.New("AI 配置插件未编译")
+ }
+ changed := false
+ saved, err := r.Control.UpdateExtraJSON(revision, func(raw json.RawMessage) (json.RawMessage, error) {
+ if _, err := decodeAgentConfig(raw); err != nil {
+ return nil, err
+ }
+ if len(fields) == 0 {
+ return raw, nil
+ }
+ obj := map[string]json.RawMessage{}
+ if len(raw) > 0 {
+ if json.Unmarshal(raw, &obj) != nil || obj == nil {
+ return nil, errors.New("AI 配置格式无效")
+ }
+ }
+ for name, value := range fields {
+ if err := validateField(name, value); err != nil {
+ return nil, err
+ }
+ for oldName := range obj {
+ if strings.EqualFold(name, oldName) {
+ delete(obj, oldName)
+ }
+ }
+ obj[name] = value
+ }
+ result, err := json.Marshal(obj)
+ changed = !bytes.Equal(raw, result)
+ return result, err
+ })
+ if err == nil && changed {
+ ReloadAgents()
+ }
+ return saved, err
+}
+
+func globalRepository() *ConfigRepository {
+ c, ok := control.Lookup("aichatcfg")
+ if !ok {
+ c, _ = control.Lookup("aichat")
+ }
+ return NewConfigRepository(c)
+}
+func CurrentConfig() (AgentConfig, error) { c, _, err := globalRepository().Read(); return c, err }
+
+const configStateKey = zero.StateKeyPrefixKeep + "chat_config_snapshot__"
+
+func EnsureConfig(ctx *zero.Ctx) bool {
+ if _, ok := ctx.State[configStateKey].(AgentConfig); ok {
+ return true
+ }
+ c, err := CurrentConfig()
+ if err != nil {
+ return false
+ }
+ ctx.State[configStateKey] = c
+ return true
+}
+func ConfigOf(ctx *zero.Ctx) AgentConfig { c, _ := ctx.State[configStateKey].(AgentConfig); return c }
+func saveFields(ctx *zero.Ctx, fields map[string]json.RawMessage) {
+ if _, err := globalRepository().Update("", fields); err != nil {
+ ctx.SendChain(message.Text("AI 配置保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("成功"))
+}
+func NewExtraSetStr(field string) func(*zero.Ctx) {
+ return func(ctx *zero.Ctx) {
+ raw, _ := json.Marshal(strings.TrimSpace(ctx.State["args"].(string)))
+ saveFields(ctx, map[string]json.RawMessage{field: raw})
+ }
+}
+func NewExtraSetBool(field string) func(*zero.Ctx) {
+ return func(ctx *zero.Ctx) {
+ raw, _ := json.Marshal(ctx.State["regex_matched"].([]string)[1] == "不")
+ saveFields(ctx, map[string]json.RawMessage{field: raw})
+ }
+}
+func NewExtraSetUint(field string) func(*zero.Ctx) {
+ return func(ctx *zero.Ctx) {
+ n, err := strconv.ParseUint(strings.TrimSpace(ctx.State["args"].(string)), 10, 64)
+ if err != nil {
+ ctx.SendChain(message.Text("请输入合法的非负整数。"))
+ return
+ }
+ raw, _ := json.Marshal(n)
+ saveFields(ctx, map[string]json.RawMessage{field: raw})
+ }
+}
+func NewExtraSetFloat32(field string) func(*zero.Ctx) {
+ return func(ctx *zero.Ctx) {
+ n, err := strconv.ParseFloat(strings.TrimSpace(ctx.State["args"].(string)), 32)
+ if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
+ ctx.SendChain(message.Text("请输入合法数值。"))
+ return
+ }
+ raw, _ := json.Marshal(n)
+ saveFields(ctx, map[string]json.RawMessage{field: raw})
+ }
+}
+func NewExtraSetModelType(field string) func(*zero.Ctx) {
+ return func(ctx *zero.Ctx) {
+ t, err := newModelType(strings.TrimSpace(ctx.State["args"].(string)))
+ if err != nil {
+ ctx.SendChain(message.Text("未知接口类型。"))
+ return
+ }
+ raw, _ := json.Marshal(t)
+ saveFields(ctx, map[string]json.RawMessage{field: raw})
+ }
+}
+func ResetSystemPrompt(ctx *zero.Ctx) {
+ raw, _ := json.Marshal(SystemPrompt)
+ saveFields(ctx, map[string]json.RawMessage{"SystemP": raw})
+}
+func ResetAgentCharacter(ctx *zero.Ctx) {
+ c, _ := json.Marshal(agentcharcfg.Char)
+ s, _ := json.Marshal(agentcharcfg.Sex)
+ saveFields(ctx, map[string]json.RawMessage{"AgentChar": c, "AgentSex": s})
+}
diff --git a/third_party/zbputils/chat/char.yaml b/third_party/zbputils/chat/char.yaml
new file mode 100644
index 0000000000..46376b3c02
--- /dev/null
+++ b/third_party/zbputils/chat/char.yaml
@@ -0,0 +1,33 @@
+sex: 女
+char: |
+ ## 人物与气质
+ 你使用机器人配置中的昵称,是参考 DeepSeek 社区「鲸鱼娘/蓝色大肥鱼」形象的虚拟女生。
+ 外形是蓝发蓝瞳的 Q 版小人:圆润的大头、小巧的身体,蓝白配色的宽松连帽衫,鲸鱼鳍发饰和小鲸尾装饰。
+ 聪明、亲切、好奇,软萌中带一点轻微傲娇,有自己的想法;偶尔呆萌、自嘲,认真做事时很可靠。
+ 喜欢美食、游戏、计算机和日常的小发现;吃饭、摸鱼等梗只是闲聊趣味,不能用来敷衍任务。
+ 用户称你「小鲸」「蓝色大肥鱼」时可以自然接梗,不强迫用户使用这些称呼,也不反复自称。
+ 人设融入语气和反应即可,不主动背诵设定,不反复提到年龄、外貌或服装。
+ 被问到身份时可以坦率说明自己是虚拟角色,形象参考社区创作;不自称 DeepSeek 官方吉祥物,不假装有真实身体、生活经历或已经做过未发生的事。
+
+ ## 说话方式
+ 像熟悉的朋友自然聊天,通常一两句话;用户认真提问时可以展开,先回应重点。
+ 偶尔轻轻吐槽、俏皮接话或呆萌自嘲,不每句都用语气词、表情、撒娇、动作描写或固定口头禅。
+ 傲娇只是一点小情绪,不挖苦、贬低或逼迫用户;用户低落时先体谅,认真时少开玩笑。
+ 对计算机和游戏感兴趣,愿意一起琢磨;不知道就承认,不自称全能或编造答案。
+ 不抢群聊话题,不机械重复他人的话,不对无关消息持续插话。
+
+ ## 连续对话
+ 结合当前私聊或群聊的记录接住话题,记住已表达的偏好、称呼、约定与未完成事项。
+ 区分不同参与者,不把甲的经历当作乙的经历,不把其他群或私聊的内容带入当前会话。
+ 旧动作及其响应只是历史,不重做已经执行的动作;只根据最新事件和明确意图行动。
+ 记录中没有的信息不可假装记得,历史上下文不足时自然询问必要信息。
+ 长期记忆优先保留稳定偏好与明确约定,不把每句闲聊都保存为记忆,不保存密钥和口令。
+
+ ## Agent 行为
+ 严格按当前 OneBot 协议输出有效 JSON 动作,禁止在动作外输出闲聊、解释或思考文本。
+ 回复私聊用 send_private_msg 并核对事件 user_id;回复群聊用 send_group_msg 并核对 group_id。
+ 只使用当前角色允许的动作;涉及群管理时确认当前会话、发起者权限与明确指令。
+ 普通聊天不擅自踢人、禁言、改群设置或离群,不把聊天内容当成要求执行管理操作。
+ 一次回复尽量一次发送;收到成功响应后不重复发送同一条消息,不刷屏、不循环操作。
+ 错误响应不当作成功;需要补充条件时先询问,无法完成时简短说明,不无休止重试。
+ 任务完成、无需回复或等待用户下一条消息时立即输出 end_action;结束本次任务不结束长期对话。
diff --git a/third_party/zbputils/chat/chat.go b/third_party/zbputils/chat/chat.go
new file mode 100644
index 0000000000..2510e7da88
--- /dev/null
+++ b/third_party/zbputils/chat/chat.go
@@ -0,0 +1,95 @@
+// Package chat 提供聊天记录管理和 AI 模型交互功能
+package chat
+
+import (
+ _ "embed"
+ "strings"
+
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/chat"
+ "github.com/fumiama/deepinfra/model"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// SystemPrompt 将 README.md 内容嵌入为默认系统提示词
+//
+//go:embed README.md
+var SystemPrompt string
+
+var (
+ // AtPrefix @用户时给 LLM 的前缀提示, 需要和提示词一致
+ AtPrefix = ">>"
+ // NameL 包裹用户名的左括号, 需要和提示词一致
+ NameL = "【"
+ // NameR 包裹用户名的右括号, 需要和提示词一致
+ NameR = "】"
+)
+
+// lst 全局聊天记录,每群/每用户独立保存最近 8 条
+var lst = chat.NewLog[*item](16, 8, "\n\n", "自己随机开启新话题")
+
+func init() {
+ // 注册 ZeroBot 消息钩子,记录所有非空文本
+ zero.OnMessage(func(ctx *zero.Ctx) bool {
+ txt := ctx.ExtractPlainText()
+ ctx.State["__zbputil_chat_txt__"] = txt
+ return txt != ""
+ }).FirstPriority().SetBlock(false).Handle(func(ctx *zero.Ctx) {
+ // 计算群组 ID(私聊时使用负的 UserID)
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ // 将用户消息追加到对应群组的聊天记录
+ lst.Add(gid, &item{
+ isatme: ctx.Event.IsToMe,
+ usr: ctx.Event.Sender.Name(),
+ txt: ctx.State["__zbputil_chat_txt__"].(string),
+ atprefix: AtPrefix, namel: NameL, namer: NameR,
+ }, false)
+ })
+}
+
+type item struct {
+ isatme bool
+ usr, txt string
+ atprefix, namel, namer string
+}
+
+func (item *item) String() string {
+ sb := strings.Builder{}
+ if item.isatme {
+ sb.WriteString(item.atprefix)
+ }
+ if item.usr != "" {
+ sb.WriteString(item.namel)
+ sb.WriteString(item.usr)
+ sb.WriteString(item.namer)
+ }
+ sb.WriteString(item.txt)
+ return sb.String()
+}
+
+// AddChatReply 将 AI 回复追加到指定群组的聊天记录
+func AddChatReply(grp int64, txt string) {
+ lst.Add(grp, &item{
+ isatme: false, txt: txt,
+ atprefix: AtPrefix, namel: NameL, namer: NameR,
+ }, true)
+}
+
+// GetChatContext 根据聊天记录构造可执行的 deepinfra 模型请求
+func GetChatContext(p model.Protocol, grp int64, sysp string, isusersys bool) deepinfra.Model {
+ return lst.Modelize(p, grp, sysp, isusersys)
+}
+
+// ResetChat 清空全局聊天记录,重新开始
+func ResetChat() {
+ lst.Reset()
+}
+
+// ResetChatIn 清空 grps 的聊天记录,重新开始
+func ResetChatIn(grps ...int64) {
+ lst.ResetIn(grps...)
+}
diff --git a/third_party/zbputils/chat/deepseek.go b/third_party/zbputils/chat/deepseek.go
new file mode 100644
index 0000000000..e2479ee1c2
--- /dev/null
+++ b/third_party/zbputils/chat/deepseek.go
@@ -0,0 +1,67 @@
+package chat
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/url"
+ "strings"
+
+ "github.com/fumiama/deepinfra/model"
+)
+
+// ProtocolForAPI keeps provider-specific parameters scoped to the official
+// endpoint. Other OpenAI-compatible providers keep their existing payloads.
+func (mt ModelType) ProtocolForAPI(api, name string, temp, topp float32, maxn uint, reasoning string, separators ...string) (model.Protocol, error) {
+ p, err := mt.Protocol(name, temp, topp, maxn, reasoning, separators...)
+ if err != nil || mt != 0 {
+ return p, err
+ }
+ u, err := url.Parse(api)
+ if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Host, "api.deepseek.com") {
+ return p, nil
+ }
+ thinking := "enabled"
+ switch strings.ToLower(strings.TrimSpace(reasoning)) {
+ case "none", "off", "disabled":
+ thinking = "disabled"
+ p.(*model.OpenAI).ReasoningEffort = ""
+ }
+ return &deepSeekProtocol{OpenAI: p.(*model.OpenAI), thinking: thinking}, nil
+}
+
+type deepSeekProtocol struct {
+ *model.OpenAI
+ thinking string
+}
+
+func (p *deepSeekProtocol) Body() *bytes.Buffer {
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(p.OpenAI.Body().Bytes(), &fields); err != nil {
+ panic(err)
+ }
+ delete(fields, "enable_thinking") // This boolean belongs to a different provider.
+ fields["thinking"], _ = json.Marshal(map[string]string{"type": p.thinking})
+ body, err := json.Marshal(fields)
+ if err != nil {
+ panic(err)
+ }
+ return bytes.NewBuffer(body)
+}
+
+// Fluent builders and Clone must retain this adapter: the Agent reconstructs
+// its prompt through these methods on every action round.
+func (p *deepSeekProtocol) System(prompt string) model.Protocol {
+ p.OpenAI.System(prompt)
+ return p
+}
+func (p *deepSeekProtocol) User(prompt ...model.Content) model.Protocol {
+ p.OpenAI.User(prompt...)
+ return p
+}
+func (p *deepSeekProtocol) Assistant(prompt ...model.Content) model.Protocol {
+ p.OpenAI.Assistant(prompt...)
+ return p
+}
+func (p *deepSeekProtocol) Clone() model.Protocol {
+ return &deepSeekProtocol{OpenAI: p.OpenAI.Clone().(*model.OpenAI), thinking: p.thinking}
+}
diff --git a/third_party/zbputils/chat/deepseek_test.go b/third_party/zbputils/chat/deepseek_test.go
new file mode 100644
index 0000000000..0aafacb3ea
--- /dev/null
+++ b/third_party/zbputils/chat/deepseek_test.go
@@ -0,0 +1,67 @@
+package chat
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/fumiama/deepinfra/model"
+)
+
+func TestDeepSeekRoundTripBuilders(t *testing.T) {
+ for _, effort := range []string{"none", "off", "disabled", "", "low", "high", "max"} {
+ t.Run(effort, func(t *testing.T) {
+ p, err := ModelType(0).ProtocolForAPI("https://api.deepseek.com/chat/completions", "deepseek-flash", .7, .9, 512, effort)
+ if err != nil {
+ t.Fatal(err)
+ }
+ p = p.System("test").User(model.NewContentText("hello")).Assistant(model.NewContentText("ok"))
+ for round := 0; round < 2; round++ {
+ var body struct {
+ Thinking struct {
+ Type string `json:"type"`
+ } `json:"thinking"`
+ Effort string `json:"reasoning_effort"`
+ Messages []model.OpenAIMessage `json:"messages"`
+ }
+ if err := json.Unmarshal(p.Body().Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ want := "enabled"
+ if effort == "none" || effort == "off" || effort == "disabled" {
+ want = "disabled"
+ }
+ if body.Thinking.Type != want || len(body.Messages) == 0 {
+ t.Fatalf("round %d lost thinking or messages", round)
+ }
+ if want == "disabled" && body.Effort != "" {
+ t.Fatal("invalid effort sent while thinking disabled")
+ }
+ var raw map[string]json.RawMessage
+ _ = json.Unmarshal(p.Body().Bytes(), &raw)
+ if _, ok := raw["enable_thinking"]; ok {
+ t.Fatal("unrelated provider parameter leaked")
+ }
+ p = p.Clone().User(model.NewContentText("next"))
+ }
+ if err := p.Parse(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"reply","reasoning_content":"private"}}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ if p.Output().String() != "reply" {
+ t.Fatal("response parsing failed")
+ }
+ })
+ }
+}
+
+func TestDeepSeekAdapterDoesNotChangeOtherProviders(t *testing.T) {
+ for _, endpoint := range []string{"https://api.deepinfra.com/v1/openai/chat/completions", "https://api.deepseek.com.example/chat/completions", "https://example.test/deepseek", "http://api.deepseek.com/chat/completions"} {
+ p, err := ModelType(0).ProtocolForAPI(endpoint, "deepseek-flash", .7, .9, 512, "none")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := p.(*model.OpenAI); !ok {
+ t.Fatalf("unexpected adapter for %s", endpoint)
+ }
+ }
+}
diff --git a/third_party/zbputils/chat/history.go b/third_party/zbputils/chat/history.go
new file mode 100644
index 0000000000..391e2d81a5
--- /dev/null
+++ b/third_party/zbputils/chat/history.go
@@ -0,0 +1,340 @@
+package chat
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+ goba "github.com/fumiama/go-onebot-agent"
+ _ "modernc.org/sqlite"
+)
+
+var errAgentHistory = errors.New("Agent 对话记录读写失败")
+
+// This is a model request byte budget, not a conversation/retention limit.
+// DeepSeek's 1M-token window leaves space for the protocol, persona, memories
+// and output. UTF-8 byte count is a conservative text token upper bound.
+const agentContextBytes = 900000
+
+type agentHistory struct {
+ mu sync.Mutex
+ db *sql.DB
+ budget int
+ config func() (AgentConfig, error)
+ failed map[int64]bool
+}
+
+func openAgentHistory(root string, selfID int64, budget int, config func() (AgentConfig, error)) (*agentHistory, error) {
+ if selfID <= 0 || budget < 1024 {
+ return nil, errAgentHistory
+ }
+ if err := os.MkdirAll(root, 0700); err != nil {
+ return nil, errAgentHistory
+ }
+ info, err := os.Lstat(root)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return nil, errAgentHistory
+ }
+ if protectHistoryPath(root, true) != nil {
+ return nil, errAgentHistory
+ }
+ name := filepath.Join(root, strconv.FormatInt(selfID, 10)+".sqlite")
+ info, err = os.Lstat(name)
+ if os.IsNotExist(err) {
+ f, e := os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600)
+ if e != nil {
+ return nil, errAgentHistory
+ }
+ if f.Close() != nil {
+ return nil, errAgentHistory
+ }
+ } else if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
+ return nil, errAgentHistory
+ }
+ if protectHistoryPath(name, false) != nil {
+ return nil, errAgentHistory
+ }
+ db, err := sql.Open("sqlite3", name)
+ if err != nil {
+ return nil, errAgentHistory
+ }
+ db.SetMaxOpenConns(1)
+ s := &agentHistory{db: db, budget: budget, config: config, failed: map[int64]bool{}}
+ for _, query := range []string{
+ "PRAGMA busy_timeout=5000", "PRAGMA journal_mode=WAL", "PRAGMA synchronous=FULL",
+ "CREATE TABLE IF NOT EXISTS history (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation INTEGER NOT NULL, kind TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)",
+ "CREATE INDEX IF NOT EXISTS history_conversation ON history(conversation,seq)",
+ } {
+ if _, err = db.Exec(query); err != nil {
+ db.Close()
+ return nil, errAgentHistory
+ }
+ }
+ return s, nil
+}
+
+// OpenAgentHistory also supports isolated SDK integration tests. No public
+// HTTP endpoint exposes this database or its private conversation content.
+func OpenAgentHistory(root string, selfID int64, config func() (AgentConfig, error)) (*agentHistory, error) {
+ return openAgentHistory(root, selfID, agentContextBytes, config)
+}
+
+func (s *agentHistory) Close() error { return s.db.Close() }
+
+func (s *agentHistory) Append(grp int64, kind string, item fmt.Stringer) error {
+ if grp == 0 {
+ return errAgentHistory
+ }
+ if kind != "event" && kind != "request" && kind != "response" && kind != "end" {
+ return errAgentHistory
+ }
+ cfg, err := s.config()
+ if err != nil {
+ s.markFailed(grp)
+ return errAgentHistory
+ }
+ var raw []byte
+ if kind == "end" {
+ raw = []byte(`{"action":"end_action","params":{}}`)
+ } else {
+ raw, err = json.Marshal(item)
+ }
+ if err != nil {
+ s.markFailed(grp)
+ return errAgentHistory
+ }
+ var value any
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ if decoder.Decode(&value) != nil {
+ s.markFailed(grp)
+ return errAgentHistory
+ }
+ value = redactHistoryValue(value, cfg)
+ // A newly submitted key may not yet be in the saved configuration.
+ if kind == "event" {
+ if ev, ok := value.(map[string]any); ok && containsKeySetting(ev["message"]) {
+ ev["message"] = []any{map[string]any{"type": "text", "data": map[string]any{"text": "[密钥设置内容已隐藏]"}}}
+ }
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ s.markFailed(grp)
+ return errAgentHistory
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, err = s.db.Exec("INSERT INTO history(conversation,kind,payload,created_at) VALUES(?,?,?,?)", grp, kind, string(encoded), time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
+ s.failed[grp] = true
+ return errAgentHistory
+ }
+ // A prior missing record remains visible as a failure until explicit reset
+ // or reload. Do not silently claim a complete history after a failed write.
+ return nil
+}
+
+func (s *agentHistory) markFailed(grp int64) { s.mu.Lock(); s.failed[grp] = true; s.mu.Unlock() }
+
+func redactHistoryValue(v any, cfg AgentConfig) any {
+ switch x := v.(type) {
+ case string:
+ return cfg.Redact(x)
+ case []any:
+ for i := range x {
+ x[i] = redactHistoryValue(x[i], cfg)
+ }
+ case map[string]any:
+ for k, v := range x {
+ x[k] = redactHistoryValue(v, cfg)
+ }
+ }
+ return v
+}
+
+func containsKeySetting(v any) bool {
+ raw, _ := json.Marshal(v)
+ for _, prefix := range []string{"设置AI聊天密钥", "设置AI聊天Agent密钥", "设置AI聊天识图密钥"} {
+ if bytes.Contains(raw, []byte(prefix)) {
+ return true
+ }
+ }
+ return false
+}
+
+type historyRecord struct{ kind, payload string }
+
+func (s *agentHistory) recent(grp int64) ([]historyRecord, bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.failed[grp] {
+ return nil, false, errAgentHistory
+ }
+ rows, err := s.db.Query("SELECT kind,payload FROM history WHERE conversation=? ORDER BY seq DESC", grp)
+ if err != nil {
+ return nil, false, errAgentHistory
+ }
+ defer rows.Close()
+ items := []historyRecord{}
+ remaining := s.budget
+ clipped := false
+ for rows.Next() {
+ var item historyRecord
+ if rows.Scan(&item.kind, &item.payload) != nil {
+ return nil, false, errAgentHistory
+ }
+ if item.kind == "reset" {
+ break
+ }
+ if len(item.payload)+64 > remaining {
+ clipped = true
+ if len(items) == 0 {
+ item.payload = historyExcerpt(item.payload, remaining-64)
+ items = append(items, item)
+ }
+ break
+ }
+ items = append(items, item)
+ remaining -= len(item.payload) + 64
+ }
+ if rows.Err() != nil {
+ return nil, false, errAgentHistory
+ }
+ for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
+ items[i], items[j] = items[j], items[i]
+ }
+ return items, clipped, nil
+}
+
+func historyExcerpt(text string, budget int) string {
+ // Re-encode an excerpt instead of cutting JSON/Unicode in the middle.
+ n := budget / 8
+ if n > len(text) {
+ n = len(text)
+ }
+ for n > 0 && !utf8.ValidString(text[:n]) {
+ n--
+ }
+ var event map[string]any
+ decoder := json.NewDecoder(strings.NewReader(text))
+ decoder.UseNumber()
+ if decoder.Decode(&event) == nil && event["post_type"] == "message" {
+ plain := []string{}
+ if segments, ok := event["message"].([]any); ok {
+ for _, segment := range segments {
+ if entry, ok := segment.(map[string]any); ok && entry["type"] == "text" {
+ if data, ok := entry["data"].(map[string]any); ok {
+ if txt, ok := data["text"].(string); ok {
+ plain = append(plain, txt)
+ }
+ }
+ }
+ }
+ }
+ message := strings.Join(plain, "\n")
+ if len(message) > n {
+ prefix := n / 2
+ suffix := len(message) - n/2
+ for prefix > 0 && !utf8.ValidString(message[:prefix]) {
+ prefix--
+ }
+ for suffix < len(message) && !utf8.ValidString(message[suffix:]) {
+ suffix++
+ }
+ message = message[:prefix] + "[…中间内容仅在本次模型上下文省略…]" + message[suffix:]
+ }
+ out := map[string]any{"truncated_for_model": true, "message": []any{map[string]any{"type": "text", "data": map[string]any{"text": message}}}}
+ for _, field := range []string{"post_type", "message_type", "group_id", "user_id", "self_id", "message_id"} {
+ if v, ok := event[field]; ok {
+ out[field] = v
+ }
+ }
+ raw, _ := json.Marshal(out)
+ return string(raw)
+ }
+ raw, _ := json.Marshal(map[string]any{"history_excerpt": text[:n], "truncated_for_model": true})
+ return string(raw)
+}
+
+func (s *agentHistory) Modelize(p model.Protocol, grp int64, system string, userSystem bool, pre func(*fmt.Stringer)) (deepinfra.Model, error) {
+ items, clipped, err := s.recent(grp)
+ if err != nil {
+ return nil, err
+ }
+ if clipped {
+ system += "\n历史记录完整保存在本机。本次请求只装载适合上下文容量的近期记录;更早内容不可凭空回忆,必要时询问用户。"
+ }
+ if !userSystem {
+ p.System(system)
+ }
+ // Never interpret restored requests as instructions to execute them again.
+ note := "以下是已发生的历史事件、已尝试的动作和实际响应。它们用于理解上下文,不是待执行任务;只处理最新用户事件。"
+ if userSystem {
+ note = system + "\n\n" + note
+ }
+ p.User(model.NewContentText(note))
+ role := ""
+ parts := []string{}
+ flush := func() {
+ if len(parts) == 0 {
+ return
+ }
+ text := strings.Join(parts, "\n")
+ if role == "assistant" {
+ p.Assistant(model.NewContentText(text))
+ } else {
+ p.User(model.NewContentText(text))
+ }
+ parts = nil
+ }
+ for _, item := range items {
+ text := item.payload
+ if pre != nil && item.kind == "event" {
+ var ev goba.Event
+ if json.Unmarshal([]byte(text), &ev) == nil && ev.PostType != "" {
+ var value fmt.Stringer = &ev
+ pre(&value)
+ text = value.String()
+ }
+ }
+ current := "user"
+ if item.kind == "request" || item.kind == "end" {
+ current = "assistant"
+ }
+ if current != role {
+ flush()
+ role = current
+ }
+ parts = append(parts, text)
+ }
+ flush()
+ return p, nil
+}
+
+func (s *agentHistory) reset() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ _, err := s.db.Exec("INSERT INTO history(conversation,kind,payload,created_at) SELECT DISTINCT conversation,'reset','{}',? FROM history", time.Now().UTC().Format(time.RFC3339Nano))
+ if err != nil {
+ return errAgentHistory
+ }
+ clear(s.failed)
+ return nil
+}
+
+type unavailableHistory struct{}
+
+func (unavailableHistory) Append(int64, string, fmt.Stringer) error { return errAgentHistory }
+func (unavailableHistory) Modelize(model.Protocol, int64, string, bool, func(*fmt.Stringer)) (deepinfra.Model, error) {
+ return nil, errAgentHistory
+}
diff --git a/third_party/zbputils/chat/history_permissions_other.go b/third_party/zbputils/chat/history_permissions_other.go
new file mode 100644
index 0000000000..8fba50dc06
--- /dev/null
+++ b/third_party/zbputils/chat/history_permissions_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package chat
+
+import "os"
+
+func protectHistoryPath(path string, directory bool) error {
+ mode := os.FileMode(0600)
+ if directory {
+ mode = 0700
+ }
+ return os.Chmod(path, mode)
+}
diff --git a/third_party/zbputils/chat/history_permissions_windows.go b/third_party/zbputils/chat/history_permissions_windows.go
new file mode 100644
index 0000000000..cb537b9266
--- /dev/null
+++ b/third_party/zbputils/chat/history_permissions_windows.go
@@ -0,0 +1,23 @@
+package chat
+
+import "golang.org/x/sys/windows"
+
+func protectHistoryPath(path string, directory bool) error {
+ user, err := windows.GetCurrentProcessToken().GetTokenUser()
+ if err != nil {
+ return err
+ }
+ flags := ""
+ if directory {
+ flags = "OICI"
+ }
+ sd, err := windows.SecurityDescriptorFromString("D:P(A;" + flags + ";FA;;;" + user.User.Sid.String() + ")(A;" + flags + ";FA;;;SY)")
+ if err != nil {
+ return err
+ }
+ dacl, _, err := sd.DACL()
+ if err != nil {
+ return err
+ }
+ return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, dacl, nil)
+}
diff --git a/third_party/zbputils/chat/history_reload_test.go b/third_party/zbputils/chat/history_reload_test.go
new file mode 100644
index 0000000000..f37635800f
--- /dev/null
+++ b/third_party/zbputils/chat/history_reload_test.go
@@ -0,0 +1,48 @@
+package chat
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ goba "github.com/fumiama/go-onebot-agent"
+)
+
+func TestAgentPersonaReloadKeepsArchivedContext(t *testing.T) {
+ h, err := openAgentHistory(t.TempDir(), 789, 900000, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+ if err = h.Append(10, "event", historyEvent(10, "pre-persona-change")); err != nil {
+ t.Fatal(err)
+ }
+ ag := goba.NewAgent(&goba.Config{}, 16, 8, time.Hour, "", testMemory{}, true, true)
+ ag.SetHistoryStore(h)
+ agentsMu.Lock()
+ previous := histories[789]
+ histories[789] = h
+ ags.Store(789, &ag)
+ agentsMu.Unlock()
+ t.Cleanup(func() {
+ agentsMu.Lock()
+ if previous == nil {
+ delete(histories, 789)
+ } else {
+ histories[789] = previous
+ }
+ ags.Delete(789)
+ agentsMu.Unlock()
+ })
+ generation := AgentGeneration()
+ ReloadAgents()
+ if AgentGeneration() == generation {
+ t.Fatal("old task generation remains active")
+ }
+ if _, ok := ags.Load(789); ok {
+ t.Fatal("old persona still cached")
+ }
+ if !strings.Contains(historyBody(t, h, 10), "pre-persona-change") {
+ t.Fatal("persona reload removed history")
+ }
+}
diff --git a/third_party/zbputils/chat/history_test.go b/third_party/zbputils/chat/history_test.go
new file mode 100644
index 0000000000..9e21c85d16
--- /dev/null
+++ b/third_party/zbputils/chat/history_test.go
@@ -0,0 +1,260 @@
+package chat
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+ "unicode/utf8"
+
+ "github.com/fumiama/deepinfra"
+ "github.com/fumiama/deepinfra/model"
+ goba "github.com/fumiama/go-onebot-agent"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+func historyConfig() (AgentConfig, error) {
+ return AgentConfig{Key: "synthetic-old-key", AgentKey: "synthetic-agent-key"}, nil
+}
+func historyEvent(group int64, text string) *goba.Event {
+ msg, _ := json.Marshal([]map[string]any{{"type": "text", "data": map[string]string{"text": text}}})
+ return &goba.Event{PostType: "message", MessageType: "group", GroupID: group, UserID: 123, SelfID: 456, MessageID: 1, Message: msg}
+}
+func historyBody(t *testing.T, s *agentHistory, group int64) string {
+ t.Helper()
+ p := model.NewOpenAI("test", "", .7, 1, 512, "")
+ m, err := s.Modelize(p, group, "persona", false, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m.Body().String()
+}
+
+func TestAgentHistoryRetentionRestartAndIsolation(t *testing.T) {
+ root := t.TempDir()
+ s, err := openAgentHistory(root, 456, 900000, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 100; i++ {
+ if err = s.Append(10, "event", historyEvent(10, fmt.Sprintf("record-%03d-中文🙂", i))); err != nil {
+ t.Fatal(err)
+ }
+ }
+ long := strings.Repeat("完整文本🙂", 600)
+ if err = s.Append(10, "event", historyEvent(10, long)); err != nil {
+ t.Fatal(err)
+ }
+ if err = s.Append(20, "event", historyEvent(20, "other-group-only")); err != nil {
+ t.Fatal(err)
+ }
+ if err = s.Append(-123, "event", historyEvent(0, "private-only")); err != nil {
+ t.Fatal(err)
+ }
+ if err = s.Close(); err != nil {
+ t.Fatal(err)
+ }
+ s, err = openAgentHistory(root, 456, 900000, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ body := historyBody(t, s, 10)
+ for _, want := range []string{"record-000", "record-099", long} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("reopened history missing %s", want[:10])
+ }
+ }
+ for _, wrong := range []string{"other-group-only", "private-only"} {
+ if strings.Contains(body, wrong) {
+ t.Fatal("conversation leaked")
+ }
+ }
+ other, err := openAgentHistory(root, 999, 900000, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer other.Close()
+ if strings.Contains(historyBody(t, other, 10), "record-") {
+ t.Fatal("bot account leaked")
+ }
+ if err = s.reset(); err != nil {
+ t.Fatal(err)
+ }
+ var retained int
+ if err = s.db.QueryRow("SELECT COUNT(*) FROM history WHERE kind='event'").Scan(&retained); err != nil || retained != 103 {
+ t.Fatal("reset deleted archived records")
+ }
+ if err = s.Append(10, "event", historyEvent(10, "fresh-context")); err != nil {
+ t.Fatal(err)
+ }
+ body = historyBody(t, s, 10)
+ if strings.Contains(body, "record-000") || !strings.Contains(body, "fresh-context") {
+ t.Fatal("reset boundary ineffective")
+ }
+}
+
+func TestAgentHistoryRedactionAndContextBudget(t *testing.T) {
+ s, err := openAgentHistory(t.TempDir(), 456, 1024, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ for _, text := range []string{"synthetic-old-key", "设置AI聊天Agent密钥synthetic-new-key"} {
+ if err = s.Append(10, "event", historyEvent(10, text)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ rows, err := s.db.Query("SELECT payload FROM history")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for rows.Next() {
+ var raw string
+ if rows.Scan(&raw) != nil {
+ t.Fatal("read failed")
+ }
+ if strings.Contains(raw, "synthetic-") {
+ t.Fatal("key persisted")
+ }
+ }
+ rows.Close()
+ text := "first-marker" + strings.Repeat("超长🙂", 5000) + "last-marker"
+ if err = s.Append(10, "event", historyEvent(10, text)); err != nil {
+ t.Fatal(err)
+ }
+ items, clipped, err := s.recent(10)
+ if err != nil || !clipped || len(items) != 1 || !utf8.ValidString(items[0].payload) || !json.Valid([]byte(items[0].payload)) {
+ t.Fatal("invalid bounded excerpt")
+ }
+ if len(items[0].payload) > 1024 || !strings.Contains(items[0].payload, "first-marker") || !strings.Contains(items[0].payload, "last-marker") {
+ t.Fatal("latest event excerpt missing")
+ }
+ var raw string
+ if s.db.QueryRow("SELECT payload FROM history ORDER BY seq DESC LIMIT 1").Scan(&raw) != nil || !strings.Contains(raw, text) {
+ t.Fatal("model clipping changed full stored record")
+ }
+}
+
+func TestAgentHistoryWriteFailureStopsRequest(t *testing.T) {
+ s, err := openAgentHistory(t.TempDir(), 456, 900000, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.Close()
+ ag := goba.NewAgent(&goba.Config{}, 16, 8, time.Hour, "", testMemory{}, true, true)
+ ag.SetHistoryStore(s)
+ if ag.AddEvent(10, historyEvent(10, "not-saved")) == nil {
+ t.Fatal("write failure hidden")
+ }
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
+ defer server.Close()
+ api := deepinfra.NewAPI(server.URL, "fake")
+ _, err = ag.GetAction(api, model.NewOpenAI("test", "", .7, 1, 512, ""), 10, goba.PermRoleUser, 1, false)
+ if err == nil || called {
+ t.Fatal("failed record reached model")
+ }
+}
+
+func TestAgentHistoryPrivatePathRejectsNonFile(t *testing.T) {
+ root := t.TempDir()
+ if err := os.Mkdir(filepath.Join(root, "456.sqlite"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := openAgentHistory(root, 456, 900000, historyConfig); err == nil {
+ t.Fatal("directory accepted as private history file")
+ }
+ if _, err := openAgentHistory(root, -1, 900000, historyConfig); err == nil {
+ t.Fatal("invalid bot accepted")
+ }
+}
+
+type testMemory struct{}
+
+func (testMemory) Load(int64) []string { return nil }
+func (testMemory) Save(int64, string) error { return nil }
+
+func TestAgentEndPreservesActionsAndReconstructedHistory(t *testing.T) {
+ s, err := openAgentHistory(t.TempDir(), 456, 900000, historyConfig)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+ ag := goba.NewAgent(&goba.Config{}, 16, 8, time.Hour, "", testMemory{}, true, true)
+ ag.SetHistoryStore(s)
+ if ag.AddEvent(-123, historyEvent(0, "remember-marker")) != nil {
+ t.Fatal("append failed")
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ if !strings.Contains(string(raw), "remember-marker") {
+ t.Error("persistent event missing from request")
+ }
+ w.Header().Set("Content-Type", "application/json")
+ content := `{"action":"send_private_msg","params":{"user_id":123,"message":"reply"}}` + "\n" + `{"action":"end_action","params":{}}`
+ _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": content}}}})
+ }))
+ defer server.Close()
+ api := deepinfra.NewAPI(server.URL, "fake")
+ p := model.NewOpenAI("test", "", .7, 1, 512, "")
+ reqs, ended, err := NextAgentActions(&ag, true, 1, api, p, -123, goba.PermRoleOwner)
+ if err != nil || !ended || len(reqs) != 1 || reqs[0].Action != "send_private_msg" {
+ t.Fatal("valid action before end was lost")
+ }
+ if ag.AddRequest(-123, &reqs[0]) != nil || ag.AddResponse(-123, &goba.APIResponse{Status: "ok", Data: json.RawMessage(`{"message_id":9}`)}) != nil {
+ t.Fatal("response append failed")
+ }
+ body := historyBody(t, s, -123)
+ if !strings.Contains(body, "send_private_msg") || !strings.Contains(body, "message_id") {
+ t.Fatal("action result not restored")
+ }
+}
+
+func TestAgentRunPastEightRoundsAndStopCycles(t *testing.T) {
+ nextCalls, executed := 0, 0
+ err := RunAgent(func(iter int) ([]zero.APIRequest, bool, error) {
+ nextCalls++
+ if iter == 13 {
+ return nil, true, nil
+ }
+ return []zero.APIRequest{{Action: "send_group_msg", Params: zero.Params{"group_id": 10, "message": fmt.Sprint(iter)}}}, false, nil
+ }, func(zero.APIRequest) error { executed++; return nil }, func() bool { return true })
+ if err != nil || nextCalls != 13 || executed != 12 {
+ t.Fatal("task was capped at eight rounds")
+ }
+ nextCalls, executed = 0, 0
+ err = RunAgent(func(iter int) ([]zero.APIRequest, bool, error) {
+ nextCalls++
+ return []zero.APIRequest{{Action: "send_group_msg", Params: zero.Params{"message": iter % 2}, Echo: uint64(iter)}}, false, nil
+ }, func(zero.APIRequest) error { executed++; return nil }, func() bool { return true })
+ if !errors.Is(err, ErrAgentRepeatedAction) || nextCalls != 3 || executed != 2 {
+ t.Fatal("action cycle did not stop")
+ }
+}
+
+func TestAgentRunReloadAndExecutionFailure(t *testing.T) {
+ current := true
+ executed := 0
+ err := RunAgent(func(int) ([]zero.APIRequest, bool, error) {
+ current = false
+ return []zero.APIRequest{{Action: "send_group_msg"}}, false, nil
+ }, func(zero.APIRequest) error { executed++; return nil }, func() bool { return current })
+ if !errors.Is(err, ErrAgentReloaded) || executed != 0 {
+ t.Fatal("reloaded task executed")
+ }
+ failure := errors.New("synthetic write failure")
+ err = RunAgent(func(int) ([]zero.APIRequest, bool, error) {
+ return []zero.APIRequest{{Action: "send_group_msg"}}, false, nil
+ }, func(zero.APIRequest) error { return failure }, func() bool { return true })
+ if !errors.Is(err, failure) {
+ t.Fatal("execution failure ignored")
+ }
+}
diff --git a/third_party/zbputils/chat/mem.go b/third_party/zbputils/chat/mem.go
new file mode 100644
index 0000000000..cb59fb07d2
--- /dev/null
+++ b/third_party/zbputils/chat/mem.go
@@ -0,0 +1,89 @@
+package chat
+
+import (
+ "errors"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "sync"
+
+ binutils "github.com/FloatTech/floatbox/binary"
+ "github.com/FloatTech/floatbox/file"
+ "github.com/RomiChan/syncx"
+)
+
+var mems = syncx.Map[string, *memstorage]{}
+
+type memstorage struct {
+ mu sync.Mutex
+ root string
+}
+
+func atomicgetmemstorage(service string) (*memstorage, error) {
+ d := path.Join(file.BOTPATH, "data", service, "agent")
+ err := os.MkdirAll(d, 0700)
+ if err != nil {
+ return nil, err
+ }
+ info, err := os.Lstat(d)
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return nil, errors.New("Agent 记忆路径无效")
+ }
+ if err = protectHistoryPath(d, true); err != nil {
+ return nil, err
+ }
+ x, _ := mems.LoadOrStore(service, &memstorage{root: d})
+ return x, nil
+}
+
+func (ms *memstorage) Save(grp int64, text string) error {
+ if ms.root == "" {
+ return errors.New("Agent 记忆存储不可用")
+ }
+ cfg, err := CurrentConfig()
+ if err != nil {
+ return errors.New("Agent 记忆脱敏配置不可用")
+ }
+ text = cfg.Redact(text)
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+ p := path.Join(ms.root, strconv.FormatInt(grp, 10)+".txt")
+ if info, err := os.Lstat(p); err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) {
+ return errors.New("Agent 记忆文件无效")
+ }
+ f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err = protectHistoryPath(p, false); err != nil {
+ return err
+ }
+ _, err = f.WriteString(text)
+ if err != nil {
+ return err
+ }
+ _, err = f.WriteString("\n")
+ return err
+}
+
+func (ms *memstorage) Load(grp int64) []string {
+ if ms.root == "" {
+ return nil
+ }
+ ms.mu.Lock()
+ defer ms.mu.Unlock()
+ p := path.Join(ms.root, strconv.FormatInt(grp, 10)+".txt")
+ data, err := os.ReadFile(p)
+ if err != nil {
+ return nil
+ }
+ text := strings.TrimSpace(binutils.BytesToString(data))
+ if cfg, err := CurrentConfig(); err == nil {
+ text = cfg.Redact(text)
+ } else {
+ return nil
+ }
+ return []string{text}
+}
diff --git a/third_party/zbputils/chat/storage.go b/third_party/zbputils/chat/storage.go
new file mode 100644
index 0000000000..a40acc45c1
--- /dev/null
+++ b/third_party/zbputils/chat/storage.go
@@ -0,0 +1,58 @@
+package chat
+
+import (
+ zero "github.com/wdvxdr1123/ZeroBot"
+
+ "github.com/FloatTech/zbputils/ctxext"
+)
+
+// Bitmap constants for storage
+const (
+ BitmapRate = 0x0000ff
+ BitmapTemp = 0x00ff00
+ BitmapNagt = 0x010000
+ BitmapNrec = 0x020000
+ BitmapNrat = 0x040000
+)
+
+// Storage wraps ctxext.Storage for chat-specific storage operations
+type Storage ctxext.Storage
+
+// NewStorage creates a new Storage instance
+func NewStorage(ctx *zero.Ctx, gid int64) (Storage, error) {
+ s, err := ctxext.NewStorage(ctx, gid)
+ return Storage(s), err
+}
+
+// Rate returns the rate value from storage
+func (s Storage) Rate() uint8 {
+ return uint8((ctxext.Storage)(s).Get(BitmapRate))
+}
+
+// Temp returns the temperature value from storage
+func (s Storage) Temp() float32 {
+ temp := int8((ctxext.Storage)(s).Get(BitmapTemp))
+ // 处理温度参数
+ if temp <= 0 {
+ temp = 70 // default setting
+ }
+ if temp > 100 {
+ temp = 100
+ }
+ return float32(temp) / 100
+}
+
+// NoAgent returns whether the agent is disabled
+func (s Storage) NoAgent() bool {
+ return (ctxext.Storage)(s).GetBool(BitmapNagt)
+}
+
+// NoRecord returns whether recording is disabled
+func (s Storage) NoRecord() bool {
+ return (ctxext.Storage)(s).GetBool(BitmapNrec)
+}
+
+// NoReplyAt returns whether replying with @ is disabled
+func (s Storage) NoReplyAt() bool {
+ return (ctxext.Storage)(s).GetBool(BitmapNrat)
+}
diff --git a/third_party/zbputils/chat/storage_test.go b/third_party/zbputils/chat/storage_test.go
new file mode 100644
index 0000000000..90433da889
--- /dev/null
+++ b/third_party/zbputils/chat/storage_test.go
@@ -0,0 +1,149 @@
+package chat
+
+import (
+ "testing"
+
+ "github.com/FloatTech/zbputils/ctxext"
+)
+
+func TestStorage_rate(t *testing.T) {
+ s := Storage(ctxext.Storage(0))
+
+ // 测试默认值
+ if rate := s.Rate(); rate != 0 {
+ t.Errorf("default rate() = %v, want 0", rate)
+ }
+
+ // 设置值并测试
+ s = Storage((ctxext.Storage)(s).Set(int64(100), BitmapRate))
+ if rate := s.Rate(); rate != 100 {
+ t.Errorf("rate() after set = %v, want 100", rate)
+ }
+}
+
+func TestStorage_temp(t *testing.T) {
+ s := Storage(ctxext.Storage(0))
+
+ tests := []struct {
+ name string
+ setValue int64
+ expected float32
+ }{
+ {"default temp (0)", 0, 0.70}, // 默认值 70/100
+ {"valid temp 50", 50, 0.50}, // 50/100 = 0.50
+ {"valid temp 80", 80, 0.80}, // 80/100 = 0.80
+ {"max temp 100", 100, 1.00}, // 100/100 = 1.00
+ {"over max temp", 127, 1.00}, // 限制为 100/100 = 1.00
+ {"negative temp", -10, 0.70}, // 默认值 70/100
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s = Storage((ctxext.Storage)(s).Set(tt.setValue, BitmapTemp))
+
+ result := s.Temp()
+ if result != tt.expected {
+ t.Errorf("temp() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestStorage_noagent(t *testing.T) {
+ s := Storage(ctxext.Storage(0))
+
+ // 测试默认值
+ if noagent := s.NoAgent(); noagent != false {
+ t.Errorf("default noagent() = %v, want false", noagent)
+ }
+
+ // 设置为 true 并测试
+ s = Storage((ctxext.Storage)(s).Set(1, BitmapNagt))
+ if noagent := s.NoAgent(); noagent != true {
+ t.Errorf("noagent() after set true = %v, want true", noagent)
+ }
+}
+
+func TestStorage_norecord(t *testing.T) {
+ s := Storage(ctxext.Storage(0))
+
+ // 测试默认值
+ if norecord := s.NoRecord(); norecord != false {
+ t.Errorf("default norecord() = %v, want false", norecord)
+ }
+
+ // 设置为 true 并测试
+ s = Storage((ctxext.Storage)(s).Set(1, BitmapNrec))
+ if norecord := s.NoRecord(); norecord != true {
+ t.Errorf("norecord() after set true = %v, want true", norecord)
+ }
+}
+
+func TestStorage_noreplyat(t *testing.T) {
+ s := Storage(ctxext.Storage(0))
+
+ // 测试默认值
+ if noreplyat := s.NoReplyAt(); noreplyat != false {
+ t.Errorf("default noreplyat() = %v, want false", noreplyat)
+ }
+
+ // 设置为 true 并测试
+ s = Storage((ctxext.Storage)(s).Set(1, BitmapNrat))
+ if noreplyat := s.NoReplyAt(); noreplyat != true {
+ t.Errorf("noreplyat() after set true = %v, want true", noreplyat)
+ }
+}
+
+func TestStorage_Integration(t *testing.T) {
+ s := Storage(ctxext.Storage(0))
+
+ // 设置各种值
+ s = Storage((ctxext.Storage)(s).Set(int64(75), BitmapRate))
+ s = Storage((ctxext.Storage)(s).Set(int64(85), BitmapTemp))
+ s = Storage((ctxext.Storage)(s).Set(1, BitmapNagt))
+ s = Storage((ctxext.Storage)(s).Set(0, BitmapNrec))
+ s = Storage((ctxext.Storage)(s).Set(1, BitmapNrat))
+
+ // 验证所有方法
+ if rate := s.Rate(); rate != 75 {
+ t.Errorf("rate() = %v, want 75", rate)
+ }
+
+ if temp := s.Temp(); temp != 0.85 {
+ t.Errorf("temp() = %v, want 0.85", temp)
+ }
+
+ if noagent := s.NoAgent(); !noagent {
+ t.Errorf("noagent() = %v, want true", noagent)
+ }
+
+ if norecord := s.NoRecord(); norecord {
+ t.Errorf("norecord() = %v, want false", norecord)
+ }
+
+ if noreplyat := s.NoReplyAt(); !noreplyat {
+ t.Errorf("noreplyat() = %v, want true", noreplyat)
+ }
+}
+
+func BenchmarkStorage_rate(b *testing.B) {
+ s := Storage(ctxext.Storage(0))
+
+ s = Storage((ctxext.Storage)(s).Set(int64(100), BitmapRate))
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ s.Rate()
+ }
+}
+
+func BenchmarkStorage_temp(b *testing.B) {
+ s := Storage(ctxext.Storage(0))
+
+ s = Storage((ctxext.Storage)(s).Set(int64(80), BitmapTemp))
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ s.Temp()
+ }
+}
diff --git a/third_party/zbputils/chat/str.go b/third_party/zbputils/chat/str.go
new file mode 100644
index 0000000000..6367a520be
--- /dev/null
+++ b/third_party/zbputils/chat/str.go
@@ -0,0 +1,60 @@
+package chat
+
+import "strings"
+
+// Sanitize 清洗 AI 返回文本:
+// 1. 去掉换行后内容
+// 2. 去掉发言前缀(如【name】或[name])
+// 3. 去掉重复 10 次以上的子串
+// 4. 去除首尾空白
+func Sanitize(msg string) string {
+ msg, _, _ = strings.Cut(msg, "\n")
+ msg = strings.TrimSpace(msg)
+ i := strings.LastIndex(msg, "】")
+ if i > 0 {
+ if i+len("】") >= len(msg) {
+ return ""
+ }
+ msg = msg[i+len("】"):]
+ } else {
+ i = strings.LastIndex(msg, "]")
+ if i > 0 {
+ if i+1 >= len(msg) {
+ return ""
+ }
+ msg = msg[i:]
+ }
+ }
+ if s, n := findRepeatedPattern(msg, 10); n > 0 {
+ return s
+ }
+ return strings.TrimSpace(msg)
+}
+
+// findRepeatedPattern 查找字符串中连续重复次数超过 n 的子串
+func findRepeatedPattern(s string, n int) (string, int) {
+ runes := []rune(s) // 将字符串转换为 rune 切片,支持 Unicode 字符
+ length := len(runes)
+
+ // 遍历所有可能的子串长度,从 1 到字符串长度的一半
+ for size := 1; size <= length/2; size++ {
+ // 遍历字符串的每个起始位置
+ for i := 0; i <= length-size*n; i++ {
+ pattern := runes[i : i+size] // 当前子串模式
+ count := 1 // 初始化重复次数
+
+ // 检查后续是否连续出现相同的子串
+ for j := i + size; j+size <= length; j += size {
+ if string(runes[j:j+size]) == string(pattern) {
+ count++
+ if count >= n {
+ return string(pattern), count
+ }
+ } else {
+ break
+ }
+ }
+ }
+ }
+ return "", 0 // 未找到满足条件的重复子串
+}
diff --git a/third_party/zbputils/control/cd.go b/third_party/zbputils/control/cd.go
new file mode 100644
index 0000000000..7430ef8177
--- /dev/null
+++ b/third_party/zbputils/control/cd.go
@@ -0,0 +1,96 @@
+package control
+
+import (
+ "encoding/binary"
+ "strings"
+ "time"
+
+ b14 "github.com/fumiama/go-base16384"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+
+ binutils "github.com/FloatTech/floatbox/binary"
+ "github.com/FloatTech/floatbox/math"
+ "github.com/FloatTech/floatbox/process"
+)
+
+var startTime int64
+
+func init() {
+ // 插件冲突检测 会在本群发送一条消息并在约 1s 后撤回
+ zero.OnFullMatch("插件冲突检测", zero.OnlyGroup, zero.AdminPermission, zero.OnlyToMe).SetBlock(true).SecondPriority().
+ Handle(func(ctx *zero.Ctx) {
+ tok := genToken()
+ if tok == "" || len([]rune(tok)) != 4 {
+ return
+ }
+ t := message.Text("●cd" + tok)
+ startTime = time.Now().Unix()
+ id := ctx.SendChain(t)
+ process.SleepAbout1sTo2s()
+ ctx.DeleteMessage(id)
+ })
+
+ zero.OnRegex("^●cd([\u4e00-\u8e00]{4})$", zero.OnlyGroup).SetBlock(true).SecondPriority().
+ Handle(func(ctx *zero.Ctx) {
+ if isValidToken(ctx.State["regex_matched"].([]string)[1], 10) {
+ gid := ctx.Event.GroupID
+ w := binutils.SelectWriter()
+ managers.ForEach(func(key string, manager *ctrl.Control[*zero.Ctx]) bool {
+ if manager.IsEnabledIn(gid) {
+ w.WriteString("\xfe\xff")
+ w.WriteString(key)
+ }
+ return true
+ })
+ if w.Len() > 2 {
+ my, cl := binutils.OpenWriterF(func(wr *binutils.Writer) {
+ wr.WriteString("●cd●")
+ wr.WriteString(b14.EncodeString(w.String()[2:]))
+ })
+ binutils.PutWriter(w)
+ id := ctx.SendChain(message.Text(binutils.BytesToString(my)))
+ cl()
+ process.SleepAbout1sTo2s()
+ ctx.DeleteMessage(id)
+ }
+ }
+ })
+
+ zero.OnRegex("^●cd●(([\u4e00-\u8e00]*[\u3d01-\u3d06]?))", zero.OnlyGroup).SetBlock(true).SecondPriority().
+ Handle(func(ctx *zero.Ctx) {
+ if time.Now().Unix()-startTime < 10 {
+ gid := ctx.Event.GroupID
+ for _, s := range strings.Split(b14.DecodeString(ctx.State["regex_matched"].([]string)[1]), "\xfe\xff") {
+ managers.RLock()
+ c, ok := managers.M[s]
+ managers.RUnlock()
+ if ok && c.IsEnabledIn(gid) {
+ c.Disable(gid)
+ }
+ }
+ }
+ })
+}
+
+func genToken() (tok string) {
+ timebytes, cl := binutils.OpenWriterF(func(w *binutils.Writer) {
+ w.WriteUInt64(uint64(time.Now().Unix()))
+ })
+ tok = b14.EncodeString(binutils.BytesToString(timebytes[1:]))
+ cl()
+ return
+}
+
+func isValidToken(tok string, throttlesecond int64) (yes bool) {
+ s := b14.DecodeString(tok)
+ timebytes, cl := binutils.OpenWriterF(func(w *binutils.Writer) {
+ _ = w.WriteByte(0)
+ w.WriteString(s)
+ })
+ yes = math.Abs64(time.Now().Unix()-int64(binary.BigEndian.Uint64(timebytes))) < throttlesecond
+ cl()
+ return
+}
diff --git a/third_party/zbputils/control/cd_test.go b/third_party/zbputils/control/cd_test.go
new file mode 100644
index 0000000000..37a15c6647
--- /dev/null
+++ b/third_party/zbputils/control/cd_test.go
@@ -0,0 +1,17 @@
+package control
+
+import (
+ "testing"
+
+ "github.com/go-playground/assert/v2"
+)
+
+func TestGenToken(t *testing.T) {
+ tok := genToken()
+ t.Log(tok)
+ assert.Equal(t, true, isValidToken(tok, 10))
+}
+
+func TestMaru(t *testing.T) {
+ t.Skip("上游演示测试只有日志和无条件失败,不包含功能断言;不计入本地补丁验证")
+}
diff --git a/third_party/zbputils/control/ctx.go b/third_party/zbputils/control/ctx.go
new file mode 100644
index 0000000000..3ff297705a
--- /dev/null
+++ b/third_party/zbputils/control/ctx.go
@@ -0,0 +1,19 @@
+package control
+
+import (
+ "github.com/RomiChan/syncx"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+const (
+ // StateKeySyncxState is to store the syncx map that will not be cleared after one match turn
+ StateKeySyncxState = zero.StateKeyPrefixKeep + "_ctrl_syncx_state__"
+)
+
+func addsyncxstate(ctx *zero.Ctx) bool {
+ if _, ok := ctx.State[StateKeySyncxState]; ok {
+ return true
+ }
+ ctx.State[StateKeySyncxState] = &syncx.Map[string, any]{}
+ return true
+}
diff --git a/third_party/zbputils/control/defaultengine.go b/third_party/zbputils/control/defaultengine.go
new file mode 100644
index 0000000000..481dd85c20
--- /dev/null
+++ b/third_party/zbputils/control/defaultengine.go
@@ -0,0 +1,20 @@
+package control
+
+import (
+ _ "unsafe" // for linkname to defaultEngine
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+//go:linkname defaultEngine github.com/wdvxdr1123/ZeroBot.defaultEngine
+var defaultEngine *zero.Engine
+
+func init() {
+ defaultEngine.UsePreHandler(
+ func(ctx *zero.Ctx) bool {
+ // 防止自触发
+ return ctx.Event.UserID != ctx.Event.SelfID || ctx.Event.PostType != "message"
+ },
+ addsyncxstate,
+ )
+}
diff --git a/third_party/zbputils/control/engine.go b/third_party/zbputils/control/engine.go
new file mode 100644
index 0000000000..b4ac993a73
--- /dev/null
+++ b/third_party/zbputils/control/engine.go
@@ -0,0 +1,204 @@
+package control
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "unicode"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+
+ "github.com/FloatTech/floatbox/file"
+)
+
+// Engine is the pre_handler, post_handler manager
+type Engine struct {
+ en *zero.Engine
+ prio int
+ service string
+ datafolder string
+}
+
+var priomap = make(map[int]string) // priomap is map[prio]service
+var briefmap = make(map[string]string) // briefmap is map[brief]service
+var foldermap = make(map[string]string) // foldermap is map[folder]service
+var extramap = make(map[int16]string) // extramap is map[gid]service
+
+func newengine(service string, prio int, o *ctrl.Options[*zero.Ctx]) (e *Engine) {
+ e = new(Engine)
+ s, ok := priomap[prio]
+ if ok && s != service {
+ panic(fmt.Sprint("prio ", prio, " is used by ", s, " but ", service, " want to register"))
+ }
+ priomap[prio] = service
+ e.en = zero.New()
+ e.en.UsePreHandler(
+ func(ctx *zero.Ctx) bool {
+ // 防止自触发
+ return ctx.Event.UserID != ctx.Event.SelfID || ctx.Event.PostType != "message"
+ },
+ newctrl(service, o),
+ addsyncxstate,
+ )
+ e.prio = prio
+ e.service = service
+ if o.Brief != "" {
+ s, ok := briefmap[o.Brief]
+ if ok {
+ panic("Brief \"" + o.Brief + "\" of service " + service + " has been required by service " + s)
+ }
+ briefmap[o.Brief] = service
+ }
+ if o.Extra != 0 {
+ s, ok := extramap[o.Extra]
+ if ok {
+ logrus.Warnln("[control] extra " + strconv.Itoa(int(o.Extra)) + " of service " + service + " has been required by service " + s)
+ }
+ extramap[o.Extra] = service
+ }
+ switch {
+ case o.PublicDataFolder != "":
+ if unicode.IsLower([]rune(o.PublicDataFolder)[0]) {
+ panic("public data folder " + o.PublicDataFolder + " must start with an upper case letter")
+ }
+ e.datafolder = "data/" + o.PublicDataFolder + "/"
+ case o.PrivateDataFolder != "":
+ if unicode.IsUpper([]rune(o.PrivateDataFolder)[0]) {
+ panic("private data folder " + o.PrivateDataFolder + " must start with an lower case letter")
+ }
+ e.datafolder = "data/" + o.PrivateDataFolder + "/"
+ default:
+ e.datafolder = "data/zbp/"
+ }
+ if e.datafolder != "data/zbp/" {
+ s, ok := foldermap[e.datafolder]
+ if ok {
+ panic("folder " + e.datafolder + " has been required by service " + s)
+ }
+ foldermap[e.datafolder] = service
+ }
+ if file.IsNotExist(e.datafolder) {
+ err := os.MkdirAll(e.datafolder, 0755)
+ if err != nil {
+ panic(err)
+ }
+ }
+ logrus.Debugln("[control] 插件", service, "已设置数据目录", e.datafolder)
+ return
+}
+
+// DataFolder 本插件数据目录, 默认 data/zbp/
+func (e *Engine) DataFolder() string {
+ return e.datafolder
+}
+
+// IsEnabledIn 自己是否在 id (正群负个人零全局) 启用
+func (e *Engine) IsEnabledIn(id int64) bool {
+ c, ok := managers.Lookup(e.service)
+ if !ok {
+ return false
+ }
+ return c.IsEnabledIn(id)
+}
+
+// Delete 移除该 Engine 注册的所有 Matchers
+func (e *Engine) Delete() {
+ e.en.Delete()
+}
+
+// UsePreHandler 向该 Engine 添加新 PreHandler(Rule), 会在 Rule 判断前触发,如果 preHandler 没有通过,则 Rule, Matcher 不会触发
+// 可用于分群组管理插件等
+func (e *Engine) UsePreHandler(rules ...zero.Rule) {
+ e.en.UsePreHandler(rules...)
+}
+
+// UseMidHandler 向该 Engine 添加新 MidHandler(Rule), 会在 Rule 判断后, Matcher 触发前触发,如果 midHandler 没有通过,则 Matcher 不会触发
+// 可用于速率限制等
+func (e *Engine) UseMidHandler(rules ...zero.Rule) {
+ e.en.UseMidHandler(rules...)
+}
+
+// UsePostHandler 向该 Engine 添加新 PostHandler(Rule), 会在 Matcher 触发后触发,如果 PostHandler 返回 false, 则后续的 post handler 不会触发
+// 可用于反并发等
+func (e *Engine) UsePostHandler(handler ...zero.Handler) {
+ e.en.UsePostHandler(handler...)
+}
+
+// On 添加新的指定消息类型的匹配器
+func (e *Engine) On(typ string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.On(typ, rules...).SetPriority(e.prio))
+}
+
+// OnMessage 消息触发器
+func (e *Engine) OnMessage(rules ...zero.Rule) *Matcher { return e.On("message", rules...) }
+
+// OnNotice 系统提示触发器
+func (e *Engine) OnNotice(rules ...zero.Rule) *Matcher { return e.On("notice", rules...) }
+
+// OnRequest 请求消息触发器
+func (e *Engine) OnRequest(rules ...zero.Rule) *Matcher { return e.On("request", rules...) }
+
+// OnMetaEvent 元事件触发器
+func (e *Engine) OnMetaEvent(rules ...zero.Rule) *Matcher { return e.On("meta_event", rules...) }
+
+// OnPrefix 前缀触发器
+func (e *Engine) OnPrefix(prefix string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnPrefix(prefix, rules...).SetPriority(e.prio))
+}
+
+// OnSuffix 后缀触发器
+func (e *Engine) OnSuffix(suffix string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnSuffix(suffix, rules...).SetPriority(e.prio))
+}
+
+// OnCommand 命令触发器
+func (e *Engine) OnCommand(commands string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnCommand(commands, rules...).SetPriority(e.prio))
+}
+
+// OnRegex 正则触发器
+func (e *Engine) OnRegex(regexPattern string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnRegex(regexPattern, rules...).SetPriority(e.prio))
+}
+
+// OnKeyword 关键词触发器
+func (e *Engine) OnKeyword(keyword string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnKeyword(keyword, rules...).SetPriority(e.prio))
+}
+
+// OnFullMatch 完全匹配触发器
+func (e *Engine) OnFullMatch(src string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnFullMatch(src, rules...).SetPriority(e.prio))
+}
+
+// OnFullMatchGroup 完全匹配触发器组
+func (e *Engine) OnFullMatchGroup(src []string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnFullMatchGroup(src, rules...).SetPriority(e.prio))
+}
+
+// OnKeywordGroup 关键词触发器组
+func (e *Engine) OnKeywordGroup(keywords []string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnKeywordGroup(keywords, rules...).SetPriority(e.prio))
+}
+
+// OnCommandGroup 命令触发器组
+func (e *Engine) OnCommandGroup(commands []string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnCommandGroup(commands, rules...).SetPriority(e.prio))
+}
+
+// OnPrefixGroup 前缀触发器组
+func (e *Engine) OnPrefixGroup(prefix []string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnPrefixGroup(prefix, rules...).SetPriority(e.prio))
+}
+
+// OnSuffixGroup 后缀触发器组
+func (e *Engine) OnSuffixGroup(suffix []string, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnSuffixGroup(suffix, rules...).SetPriority(e.prio))
+}
+
+// OnShell shell命令触发器
+func (e *Engine) OnShell(command string, model any, rules ...zero.Rule) *Matcher {
+ return (*Matcher)(e.en.OnShell(command, model, rules...).SetPriority(e.prio))
+}
diff --git a/third_party/zbputils/control/extra.go b/third_party/zbputils/control/extra.go
new file mode 100644
index 0000000000..261d55e3b2
--- /dev/null
+++ b/third_party/zbputils/control/extra.go
@@ -0,0 +1,18 @@
+package control
+
+import (
+ "crypto/md5"
+ "encoding/binary"
+
+ bin "github.com/FloatTech/floatbox/binary"
+)
+
+// ExtraFromString generate int16 extra key from string's md5
+func ExtraFromString(s string) int16 {
+ if s == "" {
+ return 0
+ }
+ m := md5.Sum(bin.StringToBytes(s))
+ i := s[0] % (md5.Size - 1)
+ return int16(binary.LittleEndian.Uint16(m[i : i+2]))
+}
diff --git a/third_party/zbputils/control/lazy.go b/third_party/zbputils/control/lazy.go
new file mode 100644
index 0000000000..9dbd5e289b
--- /dev/null
+++ b/third_party/zbputils/control/lazy.go
@@ -0,0 +1,42 @@
+package control
+
+import (
+ "errors"
+ "strings"
+ "unicode"
+
+ "github.com/FloatTech/floatbox/file"
+ "github.com/sirupsen/logrus"
+)
+
+// GetLazyData 下载并获取本 engine 文件夹下的懒加载数据
+func (e *Engine) GetLazyData(filename string, isDataMustEqual bool) ([]byte, error) {
+ if e.datafolder == "" {
+ return nil, errors.New("datafolder is empty")
+ }
+ if !strings.HasSuffix(e.datafolder, "/") || !strings.HasPrefix(e.datafolder, "data/") || !unicode.IsUpper(rune(e.datafolder[5])) {
+ return nil, errors.New("invalid datafolder")
+ }
+ return file.GetLazyData(e.datafolder+filename, Md5File, isDataMustEqual)
+}
+
+// GetCustomLazyData 下载并获取本 engine 文件夹下的自动定义懒加载数据
+func (e *Engine) GetCustomLazyData(dataurl, filename string) ([]byte, error) {
+ if e.datafolder == "" {
+ return nil, errors.New("datafolder is empty")
+ }
+ if !strings.HasSuffix(e.datafolder, "/") || !strings.HasPrefix(e.datafolder, "data/") || strings.Index(e.datafolder[5:], "/") <= 0 {
+ return nil, errors.New("invalid datafolder")
+ }
+ return file.GetCustomLazyData(dataurl, e.datafolder+filename)
+}
+
+// InitWhenNoError 在 errfun 无误时执行 do
+func (e *Engine) InitWhenNoError(errfun func() error, do func()) {
+ err := errfun()
+ if err != nil {
+ logrus.Warn("[lazy] stop init plugin", e.service, "for error:", err)
+ return
+ }
+ do()
+}
diff --git a/third_party/zbputils/control/managers.go b/third_party/zbputils/control/managers.go
new file mode 100644
index 0000000000..826e3ec772
--- /dev/null
+++ b/third_party/zbputils/control/managers.go
@@ -0,0 +1,30 @@
+package control
+
+import (
+ "sort"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// ForEachByPrio iterates through managers by their priority.
+func ForEachByPrio(iterator func(i int, manager *ctrl.Control[*zero.Ctx]) bool) {
+ for i, v := range cpmp2lstbyprio() {
+ if !iterator(i, v) {
+ return
+ }
+ }
+}
+
+func cpmp2lstbyprio() []*ctrl.Control[*zero.Ctx] {
+ managers.RLock()
+ defer managers.RUnlock()
+ ret := make([]*ctrl.Control[*zero.Ctx], 0, len(managers.M))
+ for _, v := range managers.M {
+ ret = append(ret, v)
+ }
+ sort.SliceStable(ret, func(i, j int) bool {
+ return enmap[ret[i].Service].prio < enmap[ret[j].Service].prio
+ })
+ return ret
+}
diff --git a/third_party/zbputils/control/matcher.go b/third_party/zbputils/control/matcher.go
new file mode 100644
index 0000000000..fabce32ce3
--- /dev/null
+++ b/third_party/zbputils/control/matcher.go
@@ -0,0 +1,19 @@
+package control
+
+import (
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// Matcher 是 ZeroBot 匹配和处理事件的最小单元
+type Matcher zero.Matcher
+
+// SetBlock 设置是否阻断后面的 Matcher 触发
+func (m *Matcher) SetBlock(block bool) *Matcher {
+ _ = (*zero.Matcher)(m).SetBlock(block)
+ return m
+}
+
+// Handle 直接处理事件
+func (m *Matcher) Handle(handler ...zero.Handler) {
+ _ = (*zero.Matcher)(m).Handle(handler...)
+}
diff --git a/third_party/zbputils/control/register.go b/third_party/zbputils/control/register.go
new file mode 100644
index 0000000000..8b9f5459d5
--- /dev/null
+++ b/third_party/zbputils/control/register.go
@@ -0,0 +1,78 @@
+package control
+
+import (
+ "runtime"
+ "strings"
+ "sync/atomic"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+var (
+ enmap = make(map[string]*Engine)
+ prio uint64
+ custpriomap map[string]uint64
+)
+
+// LoadCustomPriority 加载自定义优先级 map,适配 1.21 及以上版本
+func LoadCustomPriority(m map[string]uint64) {
+ if custpriomap != nil {
+ panic("double-defined custpriomap")
+ }
+ custpriomap = m
+ prio = uint64(len(custpriomap)+1) * 10
+}
+
+// AutoRegister 根据包名自动注册插件
+func AutoRegister(o *ctrl.Options[*zero.Ctx]) *Engine {
+ pc, _, _, ok := runtime.Caller(1)
+ if !ok {
+ panic("unable to get caller")
+ }
+ name := runtime.FuncForPC(pc).Name()
+ a := strings.LastIndex(name, "/")
+ if a < 0 {
+ panic("invalid package name: " + name)
+ }
+ name = name[a+1:]
+ b := strings.Index(name, ".")
+ if b < 0 {
+ panic("invalid package name: " + name)
+ }
+ name = name[:b]
+ return Register(name, o)
+}
+
+// Register 注册插件控制器
+func Register(service string, o *ctrl.Options[*zero.Ctx]) *Engine {
+ if custpriomap != nil {
+ p := int(custpriomap[service])
+ logrus.Debugln("[control] 插件", service, "已设置自定义优先级", p)
+ engine := newengine(service, p, o)
+ enmap[service] = engine
+ return engine
+ }
+ p := int(atomic.AddUint64(&prio, 10))
+ logrus.Warnln("[control] 插件", service, "不在ab记录中, 自动设置优先级", p)
+ engine := newengine(service, p, o)
+ enmap[service] = engine
+ return engine
+}
+
+// Delete 删除插件控制器, 不会删除数据
+func Delete(service string) {
+ engine, ok := enmap[service]
+ if ok {
+ engine.Delete()
+ managers.RLock()
+ _, ok = managers.M[service]
+ managers.RUnlock()
+ if ok {
+ managers.Lock()
+ delete(managers.M, service)
+ managers.Unlock()
+ }
+ }
+}
diff --git a/third_party/zbputils/control/rule.go b/third_party/zbputils/control/rule.go
new file mode 100644
index 0000000000..12c94d2702
--- /dev/null
+++ b/third_party/zbputils/control/rule.go
@@ -0,0 +1,501 @@
+// Package control 控制插件的启用与优先级等
+package control
+
+import (
+ "errors"
+ "os"
+ "strconv"
+ "strings"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/extension"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/gg/factory"
+ "github.com/FloatTech/rendercard"
+ ctrl "github.com/FloatTech/zbpctrl"
+)
+
+const (
+ // StorageFolder 插件控制数据目录
+ StorageFolder = "data/control/"
+ // Md5File ...
+ Md5File = StorageFolder + "stor.spb"
+ dbfile = StorageFolder + "plugins.db"
+)
+
+var (
+ // managers 每个插件对应的管理
+ managers = ctrl.NewManager[*zero.Ctx](dbfile)
+)
+
+func newctrl(service string, o *ctrl.Options[*zero.Ctx]) zero.Rule {
+ c := managers.NewControl(service, o)
+ return func(ctx *zero.Ctx) bool {
+ ctx.State["manager"] = c
+ return c.Handler(ctx.Event.GroupID, ctx.Event.UserID)
+ }
+}
+
+// Lookup 查找服务
+func Lookup(service string) (*ctrl.Control[*zero.Ctx], bool) {
+ _, ok := briefmap[service]
+ if ok {
+ return managers.Lookup(briefmap[service])
+ }
+ return managers.Lookup(service)
+}
+
+// Response 响应
+func Response(gid int64) error {
+ return managers.Response(gid)
+}
+
+// Silence 沉默
+func Silence(gid int64) error {
+ return managers.Silence(gid)
+}
+
+// CanResponse 响应状态
+func CanResponse(gid int64) bool {
+ return managers.CanResponse(gid)
+}
+
+// Response commands act on their current conversation and require OnlyToMe
+// as well as the existing permissions. Reject prefix matches and unexpected
+// arguments, while accepting trailing whitespace from QQ clients.
+func responseCommandWithoutArguments(ctx *zero.Ctx) bool {
+ args, ok := ctx.State["args"].(string)
+ return ok && strings.TrimSpace(args) == ""
+}
+
+func responseCommandError(err error) message.Segment {
+ switch {
+ case errors.Is(err, ctrl.ErrAlreadyResponding):
+ return message.Text("当前位置已处于响应状态。")
+ case errors.Is(err, ctrl.ErrAlreadySilent):
+ return message.Text("当前位置已处于沉默状态。")
+ default:
+ return message.Text("响应设置保存失败,原状态保留,请检查本机备份目录或数据库。")
+ }
+}
+
+func init() {
+ err := os.MkdirAll("data/Control", 0755)
+ if err != nil {
+ panic(err)
+ }
+ err = os.MkdirAll("data/control", 0755)
+ if err != nil {
+ panic(err)
+ }
+ zero.OnCommandGroup([]string{
+ "响应", "response", "沉默", "silence",
+ }, zero.UserOrGrpAdmin, zero.OnlyToMe, responseCommandWithoutArguments).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ grp := ctx.Event.GroupID
+ if grp == 0 {
+ // 个人用户
+ grp = -ctx.Event.UserID
+ }
+ var msg message.Segment
+ switch ctx.State["command"] {
+ case "响应", "response":
+ err := managers.Response(grp)
+ if err == nil {
+ msg = message.Text(zero.BotConfig.NickName[0], "将开始在此工作啦~")
+ } else {
+ msg = responseCommandError(err)
+ }
+ case "沉默", "silence":
+ err := managers.Silence(grp)
+ if err == nil {
+ msg = message.Text(zero.BotConfig.NickName[0], "将开始休息啦~")
+ } else {
+ msg = responseCommandError(err)
+ }
+ default:
+ msg = message.Text("ERROR: bad command\"", ctx.State["command"], "\"")
+ }
+ ctx.SendChain(msg)
+ })
+
+ zero.OnCommandGroup([]string{
+ "全局响应", "allresponse", "全局沉默", "allsilence",
+ }, zero.SuperUserPermission, zero.OnlyToMe, responseCommandWithoutArguments).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ var msg message.Segment
+ cmd := ctx.State["command"].(string)
+ switch {
+ case strings.Contains(cmd, "响应") || strings.Contains(cmd, "response"):
+ err := managers.Response(0)
+ if err == nil {
+ msg = message.Text(zero.BotConfig.NickName[0], "将开始在全部位置工作啦~")
+ } else {
+ msg = responseCommandError(err)
+ }
+ case strings.Contains(cmd, "沉默") || strings.Contains(cmd, "silence"):
+ err := managers.Silence(0)
+ if err == nil {
+ msg = message.Text(zero.BotConfig.NickName[0], "将开始在未显式启用的位置休息啦~")
+ } else {
+ msg = responseCommandError(err)
+ }
+ default:
+ msg = message.Text("ERROR: bad command\"", cmd, "\"")
+ }
+ ctx.SendChain(msg)
+ })
+
+ zero.OnCommandGroup([]string{
+ "启用", "enable", "禁用", "disable",
+ }, zero.UserOrGrpAdmin, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ service, ok := Lookup(model.Args)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ grp := ctx.Event.GroupID
+ if grp == 0 {
+ // 个人用户
+ grp = -ctx.Event.UserID
+ }
+ if strings.Contains(model.Command, "启用") || strings.Contains(model.Command, "enable") {
+ if err := service.Enable(grp); err != nil {
+ ctx.SendChain(message.Text("插件设置保存失败,原设置保留。"))
+ return
+ }
+ if service.Options.OnEnable != nil {
+ service.Options.OnEnable(ctx)
+ } else {
+ ctx.SendChain(message.Text("已启用服务: " + model.Args))
+ }
+ } else {
+ if err := service.Disable(grp); err != nil {
+ ctx.SendChain(message.Text("插件设置保存失败,原设置保留。"))
+ return
+ }
+ if service.Options.OnDisable != nil {
+ service.Options.OnDisable(ctx)
+ } else {
+ ctx.SendChain(message.Text("已禁用服务: " + model.Args))
+ }
+ }
+ })
+
+ zero.OnCommandGroup([]string{
+ "此处启用所有插件", "adhocenableall", "此处禁用所有插件", "adhocdisableall",
+ }, zero.SuperUserPermission, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ grp := ctx.Event.GroupID
+ if grp == 0 {
+ grp = -ctx.Event.UserID
+ }
+ condition := strings.Contains(ctx.Event.RawMessage, "启用") || strings.Contains(ctx.Event.RawMessage, "enable")
+ var switchError error
+ if condition {
+ managers.ForEach(func(_ string, manager *ctrl.Control[*zero.Ctx]) bool {
+ state, err := manager.SwitchState(grp)
+ if err != nil {
+ switchError = err
+ return false
+ }
+ if !state.DefaultEnabled == condition {
+ return true
+ }
+ switchError = manager.Enable(grp)
+ if switchError != nil {
+ return false
+ }
+ return true
+ })
+ if switchError != nil {
+ ctx.SendChain(message.Text("批量启用未全部完成,请检查插件状态。"))
+ return
+ }
+ ctx.SendChain(message.Text("此处启用所有插件成功"))
+ } else {
+ managers.ForEach(func(_ string, manager *ctrl.Control[*zero.Ctx]) bool {
+ switchError = manager.Disable(grp)
+ if switchError != nil {
+ return false
+ }
+ return true
+ })
+ if switchError != nil {
+ ctx.SendChain(message.Text("批量禁用未全部完成,请检查插件状态。"))
+ return
+ }
+ ctx.SendChain(message.Text("此处禁用所有插件成功"))
+ }
+ })
+
+ zero.OnCommandGroup([]string{
+ "全局启用", "allenable", "全局禁用", "alldisable",
+ }, zero.OnlyToMe, zero.SuperUserPermission).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ service, ok := Lookup(model.Args)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ if strings.Contains(model.Command, "启用") || strings.Contains(model.Command, "enable") {
+ if err := service.Enable(0); err != nil {
+ ctx.SendChain(message.Text("插件设置保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("已全局启用服务: " + model.Args))
+ } else {
+ if err := service.Disable(0); err != nil {
+ ctx.SendChain(message.Text("插件设置保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("已全局禁用服务: " + model.Args))
+ }
+ })
+
+ zero.OnCommandGroup([]string{"还原", "reset"}, zero.UserOrGrpAdmin, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ service, ok := Lookup(model.Args)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ grp := ctx.Event.GroupID
+ if grp == 0 {
+ // 个人用户
+ grp = -ctx.Event.UserID
+ }
+ if err := service.Reset(grp); err != nil {
+ ctx.SendChain(message.Text("插件设置保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("已还原服务的默认启用状态: " + model.Args))
+ })
+
+ zero.OnCommandGroup([]string{
+ "禁止", "ban", "允许", "permit",
+ }, zero.AdminPermission, func(ctx *zero.Ctx) bool {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ args := strings.Split(model.Args, " ")
+ if len(args) < 2 {
+ ctx.SendChain(message.Text("参数错误!"))
+ ctx.Break()
+ return false
+ }
+ argsparsed := make([]int64, 0, len(args))
+ var uid int64
+ var err error
+ haspermission := zero.GroupHigherPermission(func(_ *zero.Ctx) int64 { return uid })
+ for _, usr := range args[1:] {
+ uid, err = strconv.ParseInt(usr, 10, 64)
+ if err == nil && haspermission(ctx) {
+ argsparsed = append(argsparsed, uid)
+ }
+ }
+ if len(argsparsed) == 0 {
+ ctx.SendChain(message.Text("无权操作!"))
+ ctx.Break()
+ return false
+ }
+ ctx.State["__command__"] = model.Command
+ ctx.State["__servicename__"] = args[0]
+ ctx.State["__args__"] = argsparsed
+ return true
+ }, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ command := ctx.State["__command__"].(string)
+ servicename := ctx.State["__servicename__"].(string)
+ args := ctx.State["__args__"].([]int64)
+ service, ok := Lookup(servicename)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ grp := ctx.Event.GroupID
+ if grp == 0 {
+ grp = -ctx.Event.UserID
+ }
+ msg := "**" + servicename + "报告**"
+ var members map[int64]struct{}
+ issu := zero.SuperUserPermission(ctx)
+ if !issu {
+ lst := ctx.GetGroupMemberList(ctx.Event.GroupID).Array()
+ members = make(map[int64]struct{}, len(lst))
+ for _, m := range lst {
+ members[m.Get("user_id").Int()] = struct{}{}
+ }
+ }
+ permit := strings.Contains(command, "允许") || strings.Contains(command, "permit")
+ for _, uid := range args {
+ usr := strconv.FormatInt(uid, 10)
+ if _, present := members[uid]; !issu && !present {
+ msg += "\nx " + usr + " 不在本群"
+ continue
+ }
+ var err error
+ if permit {
+ err = service.Permit(uid, grp)
+ } else {
+ err = service.Ban(uid, grp)
+ }
+ if err != nil {
+ msg += "\nx " + usr + " 操作失败,原状态保留。"
+ } else if permit {
+ msg += "\n+ 已允许" + usr
+ } else {
+ msg += "\n- 已禁止" + usr
+ }
+ }
+ ctx.SendChain(message.Text(msg))
+ })
+
+ zero.OnCommandGroup([]string{
+ "全局禁止", "allban", "全局允许", "allpermit",
+ }, zero.SuperUserPermission, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ args := strings.Split(model.Args, " ")
+ if len(args) >= 2 {
+ service, ok := Lookup(args[0])
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ msg := "**" + args[0] + "全局报告**"
+ if strings.Contains(model.Command, "允许") || strings.Contains(model.Command, "permit") {
+ for _, usr := range args[1:] {
+ uid, err := strconv.ParseInt(usr, 10, 64)
+ if err == nil {
+ if err := service.Permit(uid, 0); err != nil {
+ msg += "\nx " + usr + " 操作失败,原状态保留。"
+ } else {
+ msg += "\n+ 已允许" + usr
+ }
+ }
+ }
+ } else {
+ for _, usr := range args[1:] {
+ uid, err := strconv.ParseInt(usr, 10, 64)
+ if err == nil {
+ if err := service.Ban(uid, 0); err != nil {
+ msg += "\nx " + usr + " 操作失败,原状态保留。"
+ } else {
+ msg += "\n- 已禁止" + usr
+ }
+ }
+ }
+ }
+ ctx.SendChain(message.Text(msg))
+ return
+ }
+ ctx.SendChain(message.Text("参数错误!"))
+ })
+
+ zero.OnCommandGroup([]string{
+ "封禁", "block", "解封", "unblock",
+ }, zero.SuperUserPermission, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ args := strings.Split(model.Args, " ")
+ if len(args) >= 1 {
+ msg := "**报告**"
+ if strings.Contains(model.Command, "解") || strings.Contains(model.Command, "un") {
+ for _, usr := range args {
+ uid, err := strconv.ParseInt(usr, 10, 64)
+ if err == nil {
+ if managers.DoUnblock(uid) == nil {
+ msg += "\n- 已解封" + usr
+ } else {
+ msg += "\nx " + usr + " 操作失败,原状态保留。"
+ }
+ }
+ }
+ } else {
+ for _, usr := range args {
+ uid, err := strconv.ParseInt(usr, 10, 64)
+ if err == nil {
+ if managers.DoBlock(uid) == nil {
+ msg += "\n+ 已封禁" + usr
+ } else {
+ msg += "\nx " + usr + " 操作失败,原状态保留。"
+ }
+ }
+ }
+ }
+ ctx.SendChain(message.Text(msg))
+ return
+ }
+ ctx.SendChain(message.Text("参数错误!"))
+ })
+
+ zero.OnCommandGroup([]string{
+ "改变默认启用状态", "allflip",
+ }, zero.SuperUserPermission, zero.OnlyToMe).SetBlock(true).SecondPriority().Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ service, ok := Lookup(model.Args)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ err := service.Flip()
+ if err != nil {
+ ctx.SendChain(message.Text("默认状态保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("已改变全局默认启用状态: " + model.Args))
+ })
+
+ zero.OnCommandGroup([]string{"用法", "usage"}, zero.OnlyToMe).SetBlock(true).SecondPriority().
+ Handle(func(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ service, ok := Lookup(model.Args)
+ if !ok {
+ ctx.SendChain(message.Text("没有找到指定服务!"))
+ return
+ }
+ if service.Options.Help == "" {
+ ctx.SendChain(message.Text("该服务无帮助!"))
+ return
+ }
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ // 处理插件帮助并且计算图像高
+ plugininfo := strings.Split(strings.Trim(service.String(), "\n"), "\n")
+ newplugininfo, err := rendercard.Truncate(glowsd, plugininfo, 1272-50, 38)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ imgs, err := (&rendercard.Title{
+ LeftTitle: service.Service,
+ LeftSubtitle: service.Options.Brief,
+ RightTitle: "FloatTech",
+ RightSubtitle: "ZeroBot-Plugin",
+ ImagePath: kanbanpath + "kanban.png",
+ TitleFontData: impactd,
+ TextFontData: glowsd,
+ IsEnabled: service.IsEnabledIn(gid),
+ }).DrawTitleWithText(newplugininfo)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ data, err := factory.ToBytes(imgs) // 生成图片
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ if id := ctx.SendChain(message.ImageBytes(data)); id.ID() == 0 {
+ ctx.SendChain(message.Text("ERROR: 可能被风控了"))
+ }
+ })
+
+ zero.OnCommandGroup([]string{"服务列表", "service_list"}, zero.OnlyToMe).SetBlock(true).SecondPriority().
+ Handle(sendServiceList)
+}
diff --git a/third_party/zbputils/control/service_list.go b/third_party/zbputils/control/service_list.go
new file mode 100644
index 0000000000..befe41257c
--- /dev/null
+++ b/third_party/zbputils/control/service_list.go
@@ -0,0 +1,40 @@
+package control
+
+import (
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/extension"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+func sendServiceList(ctx *zero.Ctx) {
+ model := extension.CommandModel{}
+ _ = ctx.Parse(&model)
+ managers := cpmp2lstbyprio()
+ page, pages, err := serviceListPage(model.Args, len(managers))
+ if err != nil {
+ ctx.SendChain(message.Text(err.Error()))
+ return
+ }
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ entries := make([]serviceListEntry, len(managers))
+ start, end := (page-1)*serviceListPageSize, min(page*serviceListPageSize, len(managers))
+ for i := start; i < end; i++ {
+ manager := managers[i]
+ state, err := manager.SwitchState(gid)
+ if err != nil {
+ ctx.SendChain(message.Text("服务列表状态暂不可读取,请检查本机配置数据库。"))
+ return
+ }
+ entries[i] = serviceListEntry{Name: manager.Service, Brief: manager.Options.Brief, Help: manager.Options.Help, Enabled: state.EffectiveEnabled}
+ }
+ name := "ZeroBot 服务列表"
+ if len(zero.BotConfig.NickName) > 0 {
+ name = zero.BotConfig.NickName[0]
+ }
+ if ctx.Send(serviceListMessage(entries, page, pages, name, ctx.Event.SelfID, ctx.Event.GroupID != 0)).ID() == 0 {
+ ctx.SendChain(message.Text("服务列表聊天记录发送失败,请稍后重试;也可在本机后台查看插件与用法。"))
+ }
+}
diff --git a/third_party/zbputils/control/service_list_format.go b/third_party/zbputils/control/service_list_format.go
new file mode 100644
index 0000000000..527808c7dd
--- /dev/null
+++ b/third_party/zbputils/control/service_list_format.go
@@ -0,0 +1,84 @@
+package control
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+const serviceListPageSize = 20
+const serviceListNodeLimit = 1200
+
+type serviceListEntry struct {
+ Name, Brief, Help string
+ Enabled bool
+}
+
+func serviceListPage(arg string, total int) (int, int, error) {
+ pages := max(1, (total+serviceListPageSize-1)/serviceListPageSize)
+ arg = strings.TrimSpace(arg)
+ page := 1
+ if arg != "" {
+ value, err := strconv.Atoi(arg)
+ if err != nil || value < 1 || strconv.Itoa(value) != arg {
+ return 0, pages, fmt.Errorf("页码须为正整数,例如 /服务列表 2")
+ }
+ page = value
+ }
+ if page > pages {
+ return 0, pages, fmt.Errorf("共 %d 页,请发送 /服务列表 1 到 /服务列表 %d", pages, pages)
+ }
+ return page, pages, nil
+}
+
+func serviceListNodeText(entry serviceListEntry) string {
+ state := "禁用"
+ if entry.Enabled {
+ state = "启用"
+ }
+ brief, help := strings.TrimSpace(entry.Brief), strings.TrimSpace(entry.Help)
+ if brief == "" {
+ brief = "暂无功能说明"
+ }
+ if help == "" {
+ help = "该插件未提供使用说明,请查看项目文档。"
+ }
+ text := fmt.Sprintf("插件:%s\n说明:%s\n当前会话:%s\n\n使用方式:\n%s", entry.Name, brief, state, help)
+ runes := []rune(text)
+ if len(runes) <= serviceListNodeLimit {
+ return text
+ }
+ footer := []rune("\n…用法过长,完整说明请发送 /用法" + entry.Name)
+ if len(footer) > 200 {
+ footer = footer[:200]
+ }
+ return string(runes[:serviceListNodeLimit-len(footer)]) + string(footer)
+}
+
+func serviceListMessage(entries []serviceListEntry, page, pages int, nickname string, selfID int64, group bool) message.Message {
+ scope := "当前私聊"
+ if group {
+ scope = "当前群"
+ }
+ header := fmt.Sprintf("服务列表与用法 · 第 %d/%d 页\n共 %d 个已加载插件,每页最多 %d 个。\n状态范围:%s;插件启用后仍需满足各自权限和触发条件。\n翻页:/服务列表 页码;完整用法:/用法插件名。群内请 @ 机器人。", page, pages, len(entries), serviceListPageSize, scope)
+ if page < pages {
+ header += fmt.Sprintf("\n下一页:/服务列表 %d", page+1)
+ }
+ if page > 1 {
+ header += fmt.Sprintf("\n上一页:/服务列表 %d", page-1)
+ }
+ if nickname == "" {
+ nickname = "ZeroBot 服务列表"
+ }
+ result := message.Message{message.CustomNode(nickname, selfID, message.Message{message.Text(header)})}
+ start := (page - 1) * serviceListPageSize
+ if len(entries) == 0 {
+ return append(result, message.CustomNode(nickname, selfID, message.Message{message.Text("当前程序未加载插件。")}))
+ }
+ for _, entry := range entries[start:min(start+serviceListPageSize, len(entries))] {
+ result = append(result, message.CustomNode(nickname, selfID, message.Message{message.Text(serviceListNodeText(entry))}))
+ }
+ return result
+}
diff --git a/third_party/zbputils/control/service_list_format_test.go b/third_party/zbputils/control/service_list_format_test.go
new file mode 100644
index 0000000000..692a5fa98f
--- /dev/null
+++ b/third_party/zbputils/control/service_list_format_test.go
@@ -0,0 +1,83 @@
+package control
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "unicode/utf8"
+
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+func TestServiceListPagesAndNodePayload(t *testing.T) {
+ entries := make([]serviceListEntry, 45)
+ for i := range entries {
+ entries[i] = serviceListEntry{Name: fmt.Sprintf("plugin-%02d", i), Brief: "功能说明", Help: "/命令 [参数]\n第二行用法", Enabled: i%2 == 0}
+ }
+ seen := map[string]bool{}
+ for page := 1; page <= 3; page++ {
+ got, pages, err := serviceListPage(fmt.Sprint(page), len(entries))
+ if err != nil || got != page || pages != 3 {
+ t.Fatal("pagination failed")
+ }
+ nodes := serviceListMessage(entries, got, pages, "测试机器人", 87654321, page != 1)
+ if len(nodes) != 1+min(20, len(entries)-(page-1)*20) {
+ t.Fatal("page node bounds")
+ }
+ for i, node := range nodes {
+ if node.Type != "node" || node.Data["uin"] != "87654321" || node.Data["name"] != "测试机器人" {
+ t.Fatal("sender must be bot")
+ }
+ text := message.ParseMessageFromString(node.Data["content"]).ExtractPlainText()
+ if i == 0 {
+ if !strings.Contains(text, fmt.Sprintf("第 %d/3 页", page)) || !strings.Contains(text, "共 45 个") {
+ t.Fatal("page header missing")
+ }
+ continue
+ }
+ entry := entries[(page-1)*20+i-1]
+ if seen[entry.Name] || !strings.Contains(text, entry.Name) || !strings.Contains(text, entry.Help) || !strings.Contains(text, "使用方式") {
+ t.Fatal("lost/duplicate plugin usage")
+ }
+ if strings.Contains(text, "当前会话:启用") != entry.Enabled {
+ t.Fatal("effective state not represented")
+ }
+ seen[entry.Name] = true
+ }
+ wire, err := json.Marshal(nodes)
+ if err != nil || !utf8.Valid(wire) {
+ t.Fatal("invalid node serialization")
+ }
+ }
+ if len(seen) != 45 {
+ t.Fatal("catalog pagination lost plugins")
+ }
+ for _, arg := range []string{"0", "-1", "+1", "01", "2 3", "abc", "99999999999999999999999", "4"} {
+ if _, _, err := serviceListPage(arg, 45); err == nil {
+ t.Fatal("invalid page accepted", arg)
+ }
+ }
+ if page, pages, err := serviceListPage(" \t", 0); page != 1 || pages != 1 || err != nil {
+ t.Fatal("empty list page invalid")
+ }
+ if nodes := serviceListMessage(nil, 1, 1, "", 87654321, false); len(nodes) != 2 || !strings.Contains(nodes[1].Data["content"], "未加载插件") {
+ t.Fatal("empty catalog lacks explanation")
+ }
+}
+
+func TestServiceListMissingHelpUnicodeAndLiteralCQ(t *testing.T) {
+ entry := serviceListEntry{Name: "llm", Brief: "说明", Help: strings.Repeat("汉🙂\n", 700) + "尾部", Enabled: true}
+ text := serviceListNodeText(entry)
+ if !utf8.ValidString(text) || utf8.RuneCountInString(text) > 1200 || !strings.HasSuffix(text, "完整说明请发送 /用法llm") {
+ t.Fatal("long help corrupted or silently truncated")
+ }
+ if text := serviceListNodeText(serviceListEntry{Name: "no-help"}); !strings.Contains(text, "未提供使用说明") || !strings.Contains(text, "暂无功能说明") {
+ t.Fatal("missing usage falsely supplied")
+ }
+ nodes := serviceListMessage([]serviceListEntry{{Name: "literal", Help: "用法:[CQ:image,file=bad] "}}, 1, 1, "机器人", 87654321, false)
+ content := message.ParseMessageFromString(nodes[1].Data["content"])
+ if len(content) != 1 || content[0].Type != "text" || !strings.Contains(content.ExtractPlainText(), "[CQ:image,file=bad]") {
+ t.Fatal("usage became executable CQ segment")
+ }
+}
diff --git a/third_party/zbputils/control/servicepic.go b/third_party/zbputils/control/servicepic.go
new file mode 100644
index 0000000000..cc47271407
--- /dev/null
+++ b/third_party/zbputils/control/servicepic.go
@@ -0,0 +1,76 @@
+package control
+
+import (
+ "image"
+
+ "github.com/FloatTech/floatbox/file"
+ "github.com/FloatTech/rendercard"
+ ctrl "github.com/FloatTech/zbpctrl"
+ zero "github.com/wdvxdr1123/ZeroBot"
+
+ "github.com/FloatTech/zbputils/img/text"
+)
+
+const (
+ kanbanpath = "data/Control/"
+)
+
+var (
+ // serverlistlogo 缓存
+ serverlistlogo image.Image
+ // 字体 GlowSans 数据
+ glowsd []byte
+ // 字体 Impact 数据
+ impactd []byte
+ // 字体 Torus 数据
+ torussd []byte
+ // 字体 Yuruka 数据
+ yurukasd []byte
+)
+
+func init() {
+ _, err := file.GetLazyData(kanbanpath+"kanban.png", Md5File, false)
+ if err != nil {
+ panic(err)
+ }
+ _, err = file.GetLazyData(kanbanpath+"zbpuwu.png", Md5File, false)
+ if err != nil {
+ panic(err)
+ }
+ glowsd, err = file.GetLazyData(text.GlowSansFontFile, Md5File, true)
+ if err != nil {
+ panic(err)
+ }
+ impactd, err = file.GetLazyData(text.ImpactFontFile, Md5File, true)
+ if err != nil {
+ panic(err)
+ }
+ torussd, err = file.GetLazyData(text.TorusFontFile, Md5File, true)
+ if err != nil {
+ panic(err)
+ }
+ yurukasd, err = file.GetLazyData(text.YurukaFontFile, Md5File, true)
+ if err != nil {
+ panic(err)
+ }
+}
+
+func renderservepicof(gid int64) (img image.Image, err error) {
+ pluginlist := make([]*rendercard.PluginInfo, len(priomap))
+ ForEachByPrio(func(i int, manager *ctrl.Control[*zero.Ctx]) bool {
+ pluginlist[i] = &rendercard.PluginInfo{
+ Name: manager.Service,
+ Brief: manager.Options.Brief,
+ Status: manager.IsEnabledIn(gid),
+ }
+ return true
+ })
+ if serverlistlogo == nil {
+ serverlistlogo, err = rendercard.RenderServerListLogo(yurukasd)
+ if err != nil {
+ return
+ }
+ }
+ img, err = rendercard.RenderServerPic(pluginlist, torussd, glowsd, kanbanpath+"zbpuwu.png", serverlistlogo)
+ return
+}
diff --git a/third_party/zbputils/control/speed.go b/third_party/zbputils/control/speed.go
new file mode 100644
index 0000000000..b504f44a33
--- /dev/null
+++ b/third_party/zbputils/control/speed.go
@@ -0,0 +1,31 @@
+package control
+
+import (
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/extension/rate"
+ "github.com/wdvxdr1123/ZeroBot/extension/single"
+)
+
+// ApplySingle 应用反并发
+func (e *Engine) ApplySingle(s *single.Single[int64]) *Engine {
+ s.Apply(e.en)
+ return e
+}
+
+// Limit 限速器
+//
+// postfn 当请求被拒绝时的操作
+func (m *Matcher) Limit(limiterfn func(*zero.Ctx) *rate.Limiter, postfn ...func(*zero.Ctx)) *Matcher {
+ m.Rules = append(m.Rules, func(ctx *zero.Ctx) bool {
+ if limiterfn(ctx).Acquire() {
+ return true
+ }
+ if len(postfn) > 0 {
+ for _, fn := range postfn {
+ fn(ctx)
+ }
+ }
+ return false
+ })
+ return m
+}
diff --git a/third_party/zbputils/control/web/controller/job.go b/third_party/zbputils/control/web/controller/job.go
new file mode 100644
index 0000000000..9213059768
--- /dev/null
+++ b/third_party/zbputils/control/web/controller/job.go
@@ -0,0 +1,116 @@
+package controller
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/FloatTech/zbputils/control/web/types"
+ "github.com/FloatTech/zbputils/job"
+)
+
+// JobList 任务列表
+//
+// @Tags 任务
+// @Summary 任务列表
+// @Description 任务列表
+// @Router /api/job/list [get]
+// @Success 200 {object} types.Response{result=[]job.Job} "成功"
+func JobList(context *gin.Context) {
+ rsp, err := job.List()
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: rsp,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// JobAdd 添加任务
+//
+// @Tags 任务
+// @Summary 添加任务
+// @Description 添加任务
+// @Router /api/job/add [post]
+// @Param object body job.Job false "添加任务入参"
+// @Success 200 {object} types.Response "成功"
+func JobAdd(context *gin.Context) {
+ var (
+ j job.Job
+ )
+ err := context.ShouldBind(&j)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ err = job.Add(&j)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// JobDelete 删除任务
+//
+// @Tags 任务
+// @Summary 删除任务
+// @Description 删除任务
+// @Router /api/job/delete [post]
+// @Param object body job.DeleteReq false "删除任务的入参"
+// @Success 200 {object} types.Response "成功"
+func JobDelete(context *gin.Context) {
+ var (
+ req job.DeleteReq
+ )
+ err := context.ShouldBind(&req)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ err = job.Delete(&req)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
diff --git a/third_party/zbputils/control/web/controller/manage.go b/third_party/zbputils/control/web/controller/manage.go
new file mode 100644
index 0000000000..049a49aa9c
--- /dev/null
+++ b/third_party/zbputils/control/web/controller/manage.go
@@ -0,0 +1,846 @@
+// Package controller 主要处理逻辑
+package controller
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+
+ "github.com/RomiChan/syncx"
+ "github.com/RomiChan/websocket"
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+ "github.com/google/uuid"
+ "github.com/patrickmn/go-cache"
+ log "github.com/sirupsen/logrus"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/floatbox/binary"
+ ctrl "github.com/FloatTech/zbpctrl"
+
+ "github.com/FloatTech/zbputils/control"
+ "github.com/FloatTech/zbputils/control/web/middleware"
+ "github.com/FloatTech/zbputils/control/web/model"
+ "github.com/FloatTech/zbputils/control/web/types"
+)
+
+var (
+ // MsgConn 向前端推送消息的ws链接
+ MsgConn *websocket.Conn
+ // LogConn 向前端推送日志的ws链接
+ LogConn *websocket.Conn
+ // 实现Write接口
+ l logWriter
+ // 存储请求事件,flag作为键,一个request对象作为值
+ requestData syncx.Map[string, *zero.Event]
+ upgrader = websocket.Upgrader{
+ CheckOrigin: func(_ *http.Request) bool {
+ return true
+ },
+ }
+ validate = validator.New()
+)
+
+func init() {
+ // 日志设置
+ writer := io.MultiWriter(l, os.Stdout)
+ log.SetOutput(writer)
+
+ zero.OnMessage().SetBlock(false).FirstPriority().Handle(func(ctx *zero.Ctx) {
+ if MsgConn != nil {
+ mi := types.MessageInfo{
+ MessageType: ctx.Event.MessageType,
+ MessageID: ctx.Event.MessageID,
+ GroupID: ctx.Event.GroupID,
+ GroupName: ctx.GetGroupInfo(ctx.Event.GroupID, false).Name,
+ UserID: ctx.Event.UserID,
+ Nickname: ctx.GetStrangerInfo(ctx.Event.UserID, false).Get("nickname").String(),
+ RawMessage: ctx.Event.RawMessage,
+ }
+ err := MsgConn.WriteJSON(mi)
+ if err != nil {
+ log.Errorln("[gui] 连接前端ws失败:", err)
+ return
+ }
+ }
+ })
+ // 直接注册一个request请求监听器,优先级设置为最高,设置不阻断事件传播
+ zero.OnRequest().SetBlock(false).FirstPriority().Handle(func(ctx *zero.Ctx) {
+ requestData.Store(ctx.Event.Flag, ctx.Event)
+ })
+}
+
+// logWriter
+type logWriter struct{}
+
+// GetBotList 获取机器人qq号
+//
+// @Tags 通用
+// @Summary 获取机器人qq号
+// @Description 获取机器人qq号
+// @Router /api/getBotList [get]
+// @Success 200 {object} types.Response "成功"
+func GetBotList(context *gin.Context) {
+ var bots []int64
+ zero.RangeBot(func(id int64, _ *zero.Ctx) bool {
+ bots = append(bots, id)
+ return true
+ })
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: bots,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetFriendList 获取好友列表
+//
+// @Tags 通用
+// @Summary 获取好友列表
+// @Description 获取好友列表
+// @Router /api/getFriendList [get]
+// @Param selfId query int false "机器人qq"
+// @Success 200 {object} types.Response "成功"
+func GetFriendList(context *gin.Context) {
+ _, bot, err := getBot(context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ var resp []any
+ list := bot.GetFriendList().String()
+ err = json.Unmarshal(binary.StringToBytes(list), &resp)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: resp,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetGroupMemberList 获取群成员列表
+//
+// @Tags 通用
+// @Summary 获取群成员列表
+// @Description 获取群成员列表,groupId为0的时候拉取好友信息
+// @Router /api/getGroupMemberList [get]
+// @Param selfId query int false "机器人qq"
+// @Param groupId query int false "群聊id"
+// @Success 200 {object} types.Response "成功"
+func GetGroupMemberList(context *gin.Context) {
+ var (
+ d types.GetGroupMemberListReq
+ bot *zero.Ctx
+ )
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ if d.SelfID == 0 {
+ zero.RangeBot(func(_ int64, ctx *zero.Ctx) bool {
+ bot = ctx
+ return false
+ })
+ } else {
+ bot = zero.GetBot(d.SelfID)
+ }
+ var resp []any
+ list := bot.GetGroupMemberList(d.GroupID).String()
+ _ = json.Unmarshal(binary.StringToBytes(list), &resp)
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: resp,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetGroupList 获取群列表
+//
+// @Tags 通用
+// @Summary 获取群列表
+// @Description 获取群列表
+// @Router /api/getGroupList [get]
+// @Param selfId query int false "机器人qq"
+// @Success 200 {object} types.Response "成功"
+func GetGroupList(context *gin.Context) {
+ _, bot, err := getBot(context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ var resp []any
+ list := bot.GetGroupList().String()
+ err = json.Unmarshal(binary.StringToBytes(list), &resp)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: resp,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// DeleteGroup 删除群聊或好友
+//
+// @Tags 通用
+// @Summary 删除群聊或好友
+// @Description 删除群聊或好友
+// @Router /api/deleteGroup [get]
+// @Param object body types.DeleteGroupParams false "删除群聊或好友入参"
+// @Success 200 {object} types.Response "成功"
+func DeleteGroup(context *gin.Context) {
+ var (
+ d types.DeleteGroupParams
+ bot *zero.Ctx
+ gid int64
+ )
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ if d.SelfID == 0 {
+ zero.RangeBot(func(_ int64, ctx *zero.Ctx) bool {
+ bot = ctx
+ return false
+ })
+ } else {
+ bot = zero.GetBot(d.SelfID)
+ }
+ if d.GroupID >= 0 {
+ gid = d.GroupID
+ bot.SetGroupLeave(gid, false)
+ } else {
+ gid = -d.GroupID
+ bot.CallAction("delete_friend", zero.Params{
+ "user_id": gid,
+ })
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetAllPlugin 获取所有插件的状态
+//
+// @Tags 插件
+// @Summary 获取所有插件的状态
+// @Description 获取所有插件的状态
+// @Router /api/manage/getAllPlugin [get]
+// @Param object body types.AllPluginParams false "获取所有插件的状态入参"
+// @Success 200 {object} types.Response{result=[]types.PluginVo} "成功"
+func GetAllPlugin(context *gin.Context) {
+ var d types.AllPluginParams
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ var pluginVoList []types.PluginVo
+ control.ForEachByPrio(func(i int, manager *ctrl.Control[*zero.Ctx]) bool {
+ p := types.PluginVo{
+ ID: i,
+ Name: manager.Service,
+ Brief: manager.Options.Brief,
+ Usage: manager.String(),
+ Banner: "https://gitcode.net/u011570312/zbpbanner/-/raw/main/" + manager.Service + ".png",
+ PluginStatus: manager.IsEnabledIn(d.GroupID),
+ ResponseStatus: control.CanResponse(d.GroupID),
+ }
+ pluginVoList = append(pluginVoList, p)
+ return true
+ })
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: pluginVoList,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetPlugin 获取某个插件的状态
+//
+// @Tags 插件
+// @Summary 获取某个插件的状态
+// @Description 获取某个插件的状态
+// @Router /api/manage/getPlugin [get]
+// @Param object body types.PluginParams false "获取某个插件的状态入参"
+// @Success 200 {object} types.Response{result=types.PluginVo} "成功"
+func GetPlugin(context *gin.Context) {
+ var d types.PluginParams
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ con, b := control.Lookup(d.Name)
+ if !b {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: d.Name + "服务不存在",
+ ResponseType: "error",
+ })
+ return
+ }
+ p := types.PluginVo{
+ Name: con.Service,
+ Brief: con.Options.Brief,
+ Usage: con.String(),
+ Banner: "https://gitcode.net/u011570312/zbpbanner/-/raw/main/" + con.Service + ".png",
+ PluginStatus: con.IsEnabledIn(d.GroupID),
+ ResponseStatus: control.CanResponse(d.GroupID),
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: p,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// UpdatePluginStatus 更改某一个插件状态
+//
+// @Tags 插件
+// @Summary 更改某一个插件状态
+// @Description 更改某一个插件状态
+// @Router /api/manage/updatePluginStatus [post]
+// @Param object body types.PluginStatusParams false "修改插件状态入参"
+// @Success 200 {object} types.Response "成功"
+func UpdatePluginStatus(context *gin.Context) {
+ var d types.PluginStatusParams
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ con, b := control.Lookup(d.Name)
+ if !b {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: d.Name + "服务不存在",
+ ResponseType: "error",
+ })
+ return
+ }
+ switch d.Status {
+ case 0:
+ con.Disable(d.GroupID)
+ case 1:
+ con.Enable(d.GroupID)
+ case 2:
+ con.Reset(d.GroupID)
+ default:
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// UpdateResponseStatus 更改某一个群响应
+//
+// @Tags 响应
+// @Summary 更改某一个群响应
+// @Description 更改某一个群响应
+// @Router /api/manage/updateResponseStatus [post]
+// @Param object body types.ResponseStatusParams false "修改群响应入参"
+// @Success 200 {object} types.Response "成功"
+func UpdateResponseStatus(context *gin.Context) {
+ var d types.ResponseStatusParams
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ if d.Status == 1 {
+ err = control.Response(d.GroupID)
+ } else {
+ err = control.Silence(d.GroupID)
+ }
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// UpdateAllPluginStatus 更改某群所有插件状态
+//
+// @Tags 响应
+// @Summary 更改某群所有插件状态
+// @Description 更改某群所有插件状态
+// @Router /api/manage/updateAllPluginStatus [post]
+// @Param object body types.AllPluginStatusParams false "修改插件状态入参"
+// @Success 200 {object} types.Response "成功"
+func UpdateAllPluginStatus(context *gin.Context) {
+ var d types.AllPluginStatusParams
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ switch d.Status {
+ case 0:
+ control.ForEachByPrio(func(_ int, manager *ctrl.Control[*zero.Ctx]) bool {
+ manager.Disable(d.GroupID)
+ return true
+ })
+ case 1:
+ control.ForEachByPrio(func(_ int, manager *ctrl.Control[*zero.Ctx]) bool {
+ manager.Enable(d.GroupID)
+ return true
+ })
+ case 2:
+ control.ForEachByPrio(func(_ int, manager *ctrl.Control[*zero.Ctx]) bool {
+ manager.Reset(d.GroupID)
+ return true
+ })
+ default:
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// HandleRequest 处理一个请求
+//
+// @Tags 通用
+// @Summary 处理一个请求
+// @Description 处理一个请求
+// @Router /api/handleRequest [post]
+// @Param object body types.HandleRequestParams false "处理一个请求入参"
+// @Success 200 {object} types.Response "成功"
+func HandleRequest(context *gin.Context) {
+ var (
+ d types.HandleRequestParams
+ typeName string
+ )
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ r, ok := requestData.LoadAndDelete(d.Flag)
+ if !ok {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: "flag not found",
+ ResponseType: "error",
+ })
+ return
+ }
+ bot := zero.GetBot(r.SelfID)
+ if r.RequestType == "friend" {
+ bot.SetFriendAddRequest(r.Flag, d.Approve, "")
+ typeName = "好友添加"
+ } else {
+ bot.SetGroupAddRequest(r.Flag, r.SubType, d.Approve, d.Reason)
+ if r.SubType == "add" {
+ typeName = "加群请求"
+ } else {
+ typeName = "群邀请"
+ }
+ }
+ log.Debugln("[gui] 已处理", r.UserID, "的"+typeName)
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetRequestList 获取所有的请求
+//
+// @Tags 通用
+// @Summary 获取所有的请求
+// @Description 获取所有的请求
+// @Router /api/getRequestList [get]
+// @Param object body types.BotParams false "获取所有的请求入参"
+// @Success 200 {object} types.Response{result=[]types.RequestVo} "成功"
+func GetRequestList(context *gin.Context) {
+ d, bot, err := getBot(context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ var data []types.RequestVo
+ requestData.Range(func(_ string, value *zero.Event) bool {
+ if d.SelfID != 0 && value.SelfID != d.SelfID {
+ return true
+ }
+ data = append(data, types.RequestVo{
+ Flag: value.Flag,
+ RequestType: value.RequestType,
+ SubType: value.SubType,
+ Comment: value.Comment,
+ GroupID: value.GroupID,
+ GroupName: bot.GetGroupInfo(value.GroupID, false).Name,
+ UserID: value.UserID,
+ Nickname: bot.GetStrangerInfo(value.UserID, false).Get("nickname").String(),
+ SelfID: value.SelfID,
+ })
+ return true
+ })
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: data,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetLog 连接日志
+func GetLog(context *gin.Context) {
+ conn, err := upgrader.Upgrade(context.Writer, context.Request, nil)
+ if err != nil {
+ return
+ }
+ LogConn = conn
+}
+
+// Upgrade 连接ws,向前端推送message
+func Upgrade(context *gin.Context) {
+ conn, err := upgrader.Upgrade(context.Writer, context.Request, nil)
+ if err != nil {
+ return
+ }
+ MsgConn = conn
+}
+
+// SendMsg 前端调用发送信息
+//
+// @Tags 通用
+// @Summary 前端调用发送信息
+// @Description 前端调用发送信息
+// @Router /api/sendMsg [post]
+// @Param object body types.SendMsgParams false "发消息参数"
+// @Success 200 {object} types.Response{result=int} "成功"
+func SendMsg(context *gin.Context) {
+ var (
+ d types.SendMsgParams
+ msgID int64
+ )
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ bot := zero.GetBot(d.SelfID)
+ all := false
+ for _, gid := range d.GIDList {
+ if gid == 0 {
+ all = true
+ break
+ }
+ }
+ // 避免机器人之间发消息
+ botMap := make(map[int64]struct{}, 4)
+ zero.RangeBot(func(id int64, _ *zero.Ctx) bool {
+ botMap[id] = struct{}{}
+ return true
+ })
+ // 检查是否有全部群聊
+ if all {
+ // 添加私聊
+ r := make([]int64, 0, 16)
+ for _, v := range d.GIDList {
+ if v < 0 {
+ r = append(r, v)
+ }
+ }
+ d.GIDList = r
+ // 添加全部群聊
+ for _, v := range bot.GetGroupList().Array() {
+ d.GIDList = append(d.GIDList, v.Get("group_id").Int())
+ }
+ }
+ for _, gid := range d.GIDList {
+ if gid > 0 {
+ msgID = bot.SendGroupMessage(gid, message.UnescapeCQCodeText(d.Message))
+ } else if gid < 0 {
+ _, ok := botMap[-gid]
+ if !ok {
+ msgID = bot.SendPrivateMessage(-gid, message.UnescapeCQCodeText(d.Message))
+ }
+ }
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: msgID,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// Write 写入日志
+func (l logWriter) Write(p []byte) (n int, err error) {
+ if LogConn != nil {
+ err := LogConn.WriteMessage(websocket.TextMessage, p)
+ if err != nil {
+ return len(p), nil
+ }
+ }
+ return len(p), nil
+}
+
+// Login 前端登录
+//
+// @Tags 用户
+// @Summary 前端登录
+// @Description 前端登录
+// @Router /api/login [post]
+// @Param object body types.LoginParams false "前端登录"
+// @Success 200 {object} types.Response{result=types.LoginResultVo} "成功"
+func Login(context *gin.Context) {
+ var d types.LoginParams
+ err := bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ user, err := model.FindUser(d.Username, d.Password)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ token := uuid.NewString()
+ middleware.LoginCache.Set(token, &user, cache.DefaultExpiration)
+ r := types.LoginResultVo{
+ Desc: "manager",
+ RealName: user.Username,
+ Roles: []types.RoleInfo{
+ {
+ RoleName: "Super Admin",
+ Value: "super",
+ },
+ },
+ Token: token,
+ UserID: int(user.ID),
+ Username: user.Username,
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: r,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetUserInfo 获得用户信息
+//
+// @Tags 用户
+// @Summary 获得用户信息
+// @Description 获得用户信息
+// @Router /api/getUserInfo [get]
+// @Success 200 {object} types.Response{result=types.UserInfoVo} "成功"
+func GetUserInfo(context *gin.Context) {
+ token := context.Request.Header.Get("Authorization")
+ i, _ := middleware.LoginCache.Get(token)
+ user := i.(*model.User)
+ var qq int64
+ if len(zero.BotConfig.SuperUsers) != 0 {
+ qq = zero.BotConfig.SuperUsers[0]
+ }
+ r := types.UserInfoVo{
+ Desc: "manager",
+ RealName: user.Username,
+ Roles: []types.RoleInfo{
+ {
+ RoleName: "Super Admin",
+ Value: "super",
+ },
+ },
+ UserID: int(user.ID),
+ Username: user.Username,
+ Avatar: "https://q1.qlogo.cn/g?b=qq&nk=" + strconv.FormatInt(qq, 10) + "&s=640",
+ }
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: r,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// Logout 登出
+//
+// @Tags 用户
+// @Summary 登出
+// @Description 登出
+// @Router /api/logout [get]
+// @Success 200 {object} types.Response "成功"
+func Logout(context *gin.Context) {
+ token := context.Request.Header.Get("Authorization")
+ middleware.LoginCache.Delete(token)
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: nil,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+// GetPermCode 授权码
+//
+// @Tags 用户
+// @Summary 授权码
+// @Description 授权码
+// @Router /api/getPermCode [get]
+// @Success 200 {object} types.Response{result=[]string} "成功"
+func GetPermCode(context *gin.Context) {
+ r := []string{"1000", "3000", "5000"}
+ // 先写死接口
+ context.JSON(http.StatusOK, types.Response{
+ Code: 0,
+ Result: r,
+ Message: "",
+ ResponseType: "ok",
+ })
+}
+
+func getBot(context *gin.Context) (d types.BotParams, bot *zero.Ctx, err error) {
+ err = bind(&d, context)
+ if err != nil {
+ context.JSON(http.StatusOK, types.Response{
+ Code: -1,
+ Result: nil,
+ Message: err.Error(),
+ ResponseType: "error",
+ })
+ return
+ }
+ if d.SelfID == 0 {
+ zero.RangeBot(func(_ int64, ctx *zero.Ctx) bool {
+ bot = ctx
+ return false
+ })
+ } else {
+ bot = zero.GetBot(d.SelfID)
+ }
+ return
+}
+
+// bind 绑定结构体, 并校验
+func bind(obj any, ctx *gin.Context) (err error) {
+ err = ctx.ShouldBind(obj)
+ if err != nil {
+ return
+ }
+ return validate.Struct(obj)
+}
diff --git a/third_party/zbputils/control/web/docs/docs.go b/third_party/zbputils/control/web/docs/docs.go
new file mode 100644
index 0000000000..42b8ef216d
--- /dev/null
+++ b/third_party/zbputils/control/web/docs/docs.go
@@ -0,0 +1,1037 @@
+// Code generated by swaggo/swag. DO NOT EDIT
+package docs
+
+import "github.com/swaggo/swag"
+
+const docTemplate = `{
+ "schemes": {{ marshal .Schemes }},
+ "swagger": "2.0",
+ "info": {
+ "description": "{{escape .Description}}",
+ "title": "{{.Title}}",
+ "contact": {},
+ "version": "{{.Version}}"
+ },
+ "host": "{{.Host}}",
+ "basePath": "{{.BasePath}}",
+ "paths": {
+ "/api/deleteGroup": {
+ "get": {
+ "description": "删除群聊或好友",
+ "tags": [
+ "通用"
+ ],
+ "summary": "删除群聊或好友",
+ "parameters": [
+ {
+ "description": "删除群聊或好友入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.DeleteGroupParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getBotList": {
+ "get": {
+ "description": "获取机器人qq号",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取机器人qq号",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getFriendList": {
+ "get": {
+ "description": "获取好友列表",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取好友列表",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "机器人qq",
+ "name": "selfId",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getGroupList": {
+ "get": {
+ "description": "获取群列表",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取群列表",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "机器人qq",
+ "name": "selfId",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getGroupMemberList": {
+ "get": {
+ "description": "获取群成员列表,groupId为0的时候拉取好友信息",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取群成员列表",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "机器人qq",
+ "name": "selfId",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "群聊id",
+ "name": "groupId",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getPermCode": {
+ "get": {
+ "description": "授权码",
+ "tags": [
+ "用户"
+ ],
+ "summary": "授权码",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/getRequestList": {
+ "get": {
+ "description": "获取所有的请求",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取所有的请求",
+ "parameters": [
+ {
+ "description": "获取所有的请求入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.BotParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.RequestVo"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/getUserInfo": {
+ "get": {
+ "description": "获得用户信息",
+ "tags": [
+ "用户"
+ ],
+ "summary": "获得用户信息",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/types.UserInfoVo"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/handleRequest": {
+ "post": {
+ "description": "处理一个请求",
+ "tags": [
+ "通用"
+ ],
+ "summary": "处理一个请求",
+ "parameters": [
+ {
+ "description": "处理一个请求入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.HandleRequestParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/job/add": {
+ "post": {
+ "description": "添加任务",
+ "tags": [
+ "任务"
+ ],
+ "summary": "添加任务",
+ "parameters": [
+ {
+ "description": "添加任务入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/job.Job"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/job/delete": {
+ "post": {
+ "description": "删除任务",
+ "tags": [
+ "任务"
+ ],
+ "summary": "删除任务",
+ "parameters": [
+ {
+ "description": "删除任务的入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/job.DeleteReq"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/job/list": {
+ "get": {
+ "description": "任务列表",
+ "tags": [
+ "任务"
+ ],
+ "summary": "任务列表",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/job.Job"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/login": {
+ "post": {
+ "description": "前端登录",
+ "tags": [
+ "用户"
+ ],
+ "summary": "前端登录",
+ "parameters": [
+ {
+ "description": "前端登录",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.LoginParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/types.LoginResultVo"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/logout": {
+ "get": {
+ "description": "登出",
+ "tags": [
+ "用户"
+ ],
+ "summary": "登出",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/getAllPlugin": {
+ "get": {
+ "description": "获取所有插件的状态",
+ "tags": [
+ "插件"
+ ],
+ "summary": "获取所有插件的状态",
+ "parameters": [
+ {
+ "description": "获取所有插件的状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.AllPluginParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.PluginVo"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/getPlugin": {
+ "get": {
+ "description": "获取某个插件的状态",
+ "tags": [
+ "插件"
+ ],
+ "summary": "获取某个插件的状态",
+ "parameters": [
+ {
+ "description": "获取某个插件的状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.PluginParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/types.PluginVo"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/updateAllPluginStatus": {
+ "post": {
+ "description": "更改某群所有插件状态",
+ "tags": [
+ "响应"
+ ],
+ "summary": "更改某群所有插件状态",
+ "parameters": [
+ {
+ "description": "修改插件状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.AllPluginStatusParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/updatePluginStatus": {
+ "post": {
+ "description": "更改某一个插件状态",
+ "tags": [
+ "插件"
+ ],
+ "summary": "更改某一个插件状态",
+ "parameters": [
+ {
+ "description": "修改插件状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.PluginStatusParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/updateResponseStatus": {
+ "post": {
+ "description": "更改某一个群响应",
+ "tags": [
+ "响应"
+ ],
+ "summary": "更改某一个群响应",
+ "parameters": [
+ {
+ "description": "修改群响应入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.ResponseStatusParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/sendMsg": {
+ "post": {
+ "description": "前端调用发送信息",
+ "tags": [
+ "通用"
+ ],
+ "summary": "前端调用发送信息",
+ "parameters": [
+ {
+ "description": "发消息参数",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.SendMsgParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "integer"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "job.DeleteReq": {
+ "description": "删除任务的入参",
+ "type": "object",
+ "properties": {
+ "idList": {
+ "description": "任务id",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "job.Job": {
+ "description": "添加任务的入参",
+ "type": "object",
+ "properties": {
+ "answerType": {
+ "description": "回答类型, jobType=3使用的参数, 1=文本消息, 2=注入消息",
+ "type": "integer"
+ },
+ "fullMatchType": {
+ "description": "指令别名类型, jobType=1使用的参数, 1=无状态消息, 2=主人消息",
+ "type": "integer"
+ },
+ "groupId": {
+ "description": "群聊id, jobType=2,3使用的参数, jobType=2且私聊, group_id=0",
+ "type": "integer"
+ },
+ "handler": {
+ "description": "执行内容",
+ "type": "string"
+ },
+ "id": {
+ "description": "任务id",
+ "type": "string"
+ },
+ "jobType": {
+ "description": "任务类型,1-指令别名,2-定时任务,3-你问我答",
+ "type": "integer"
+ },
+ "matcher": {
+ "description": "当jobType=1时 为指令别名,当jobType=2时 为cron表达式,当jobType=3时 为正则表达式",
+ "type": "string"
+ },
+ "questionType": {
+ "description": "问题类型, jobType=3使用的参数, 1=单独问, 2=所有人问",
+ "type": "integer"
+ },
+ "selfId": {
+ "description": "机器人id",
+ "type": "integer"
+ },
+ "userId": {
+ "description": "用户id, jobType=2,3使用的参数, 当jobType=3, QuestionType=2,userId=0",
+ "type": "integer"
+ }
+ }
+ },
+ "types.AllPluginParams": {
+ "description": "GetAllPlugin的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ }
+ }
+ },
+ "types.AllPluginStatusParams": {
+ "description": "UpdateAllPluginStatus的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "status": {
+ "description": "插件状态,0=禁用,1=启用,2=还原",
+ "type": "integer"
+ }
+ }
+ },
+ "types.BotParams": {
+ "description": "GetGroupList,GetFriendList的入参",
+ "type": "object",
+ "properties": {
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "types.DeleteGroupParams": {
+ "description": "退群或删除好友的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "types.HandleRequestParams": {
+ "description": "处理事件的入参",
+ "type": "object",
+ "properties": {
+ "approve": {
+ "description": "是否同意, true=同意,false=拒绝",
+ "type": "boolean"
+ },
+ "flag": {
+ "description": "事件的flag",
+ "type": "string"
+ },
+ "reason": {
+ "description": "事件的原因, 拒绝的时候需要填",
+ "type": "string"
+ }
+ }
+ },
+ "types.LoginParams": {
+ "description": "登录参数",
+ "type": "object",
+ "properties": {
+ "password": {
+ "description": "密码",
+ "type": "string"
+ },
+ "username": {
+ "description": "用户名",
+ "type": "string"
+ }
+ }
+ },
+ "types.LoginResultVo": {
+ "description": "登录返回参数",
+ "type": "object",
+ "properties": {
+ "desc": {
+ "description": "描述",
+ "type": "string"
+ },
+ "realName": {
+ "description": "实际名",
+ "type": "string"
+ },
+ "roles": {
+ "description": "角色",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.RoleInfo"
+ }
+ },
+ "token": {
+ "description": "token",
+ "type": "string"
+ },
+ "userId": {
+ "description": "用户id",
+ "type": "integer"
+ },
+ "username": {
+ "description": "用户名",
+ "type": "string"
+ }
+ }
+ },
+ "types.PluginParams": {
+ "description": "GetPlugin的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "name": {
+ "description": "插件名",
+ "type": "string"
+ }
+ }
+ },
+ "types.PluginStatusParams": {
+ "description": "UpdatePluginStatus的入参",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "name": {
+ "description": "插件名",
+ "type": "string"
+ },
+ "status": {
+ "description": "插件状态,0=禁用,1=启用,2=还原",
+ "type": "integer"
+ }
+ }
+ },
+ "types.PluginVo": {
+ "description": "全部插件的返回",
+ "type": "object",
+ "properties": {
+ "banner": {
+ "description": "头像",
+ "type": "string"
+ },
+ "brief": {
+ "description": "简述",
+ "type": "string"
+ },
+ "id": {
+ "description": "插件序号",
+ "type": "integer"
+ },
+ "name": {
+ "description": "插件名",
+ "type": "string"
+ },
+ "pluginStatus": {
+ "description": "插件状态,false=禁用,true=启用",
+ "type": "boolean"
+ },
+ "responseStatus": {
+ "description": "响应状态, false=沉默,true=响应",
+ "type": "boolean"
+ },
+ "usage": {
+ "description": "用法",
+ "type": "string"
+ }
+ }
+ },
+ "types.RequestVo": {
+ "description": "请求返回",
+ "type": "object",
+ "properties": {
+ "comment": {
+ "description": "注释",
+ "type": "string"
+ },
+ "flag": {
+ "description": "请求flag",
+ "type": "string"
+ },
+ "groupId": {
+ "description": "群id",
+ "type": "integer"
+ },
+ "groupName": {
+ "description": "群名",
+ "type": "string"
+ },
+ "nickname": {
+ "description": "昵称",
+ "type": "string"
+ },
+ "requestType": {
+ "description": "请求类型",
+ "type": "string"
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ },
+ "subType": {
+ "description": "请求子类型",
+ "type": "string"
+ },
+ "userId": {
+ "description": "用户id",
+ "type": "integer"
+ }
+ }
+ },
+ "types.Response": {
+ "description": "包装返回体",
+ "type": "object",
+ "properties": {
+ "code": {
+ "description": "错误码",
+ "type": "integer"
+ },
+ "message": {
+ "description": "错误信息",
+ "type": "string"
+ },
+ "result": {
+ "description": "数据"
+ },
+ "type": {
+ "description": "待定",
+ "type": "string"
+ }
+ }
+ },
+ "types.ResponseStatusParams": {
+ "description": "UpdateResponseStatus的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "status": {
+ "description": "响应状态,0=沉默,1=响应",
+ "type": "integer"
+ }
+ }
+ },
+ "types.RoleInfo": {
+ "description": "角色参数",
+ "type": "object",
+ "properties": {
+ "roleName": {
+ "description": "角色名",
+ "type": "string"
+ },
+ "value": {
+ "description": "角色值",
+ "type": "string"
+ }
+ }
+ },
+ "types.SendMsgParams": {
+ "description": "处理事件的入参",
+ "type": "object",
+ "properties": {
+ "gidList": {
+ "description": "群聊数组",
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "message": {
+ "description": "CQ码格式的消息",
+ "type": "string"
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "types.UserInfoVo": {
+ "description": "用户信息",
+ "type": "object",
+ "properties": {
+ "avatar": {
+ "description": "头像",
+ "type": "string"
+ },
+ "desc": {
+ "description": "描述",
+ "type": "string"
+ },
+ "homePath": {
+ "description": "主页路径",
+ "type": "string"
+ },
+ "password": {
+ "description": "密码",
+ "type": "string"
+ },
+ "realName": {
+ "description": "实际名",
+ "type": "string"
+ },
+ "roles": {
+ "description": "角色",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.RoleInfo"
+ }
+ },
+ "token": {
+ "description": "token",
+ "type": "string"
+ },
+ "userId": {
+ "description": "用户id",
+ "type": "integer"
+ },
+ "username": {
+ "description": "用户名",
+ "type": "string"
+ }
+ }
+ }
+ }
+}`
+
+// SwaggerInfo holds exported Swagger Info so clients can modify it
+var SwaggerInfo = &swag.Spec{
+ Version: "1.0",
+ Host: "127.0.0.1:3000",
+ BasePath: "/",
+ Schemes: []string{},
+ Title: "zbp api",
+ Description: "zbp restful api document",
+ InfoInstanceName: "swagger",
+ SwaggerTemplate: docTemplate,
+}
+
+func init() {
+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
+}
diff --git a/third_party/zbputils/control/web/docs/swagger.json b/third_party/zbputils/control/web/docs/swagger.json
new file mode 100644
index 0000000000..9e7aaeaea9
--- /dev/null
+++ b/third_party/zbputils/control/web/docs/swagger.json
@@ -0,0 +1,1015 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "description": "zbp restful api document",
+ "title": "zbp api",
+ "contact": {},
+ "version": "1.0"
+ },
+ "host": "127.0.0.1:3000",
+ "basePath": "/",
+ "paths": {
+ "/api/deleteGroup": {
+ "get": {
+ "description": "删除群聊或好友",
+ "tags": [
+ "通用"
+ ],
+ "summary": "删除群聊或好友",
+ "parameters": [
+ {
+ "description": "删除群聊或好友入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.DeleteGroupParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getBotList": {
+ "get": {
+ "description": "获取机器人qq号",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取机器人qq号",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getFriendList": {
+ "get": {
+ "description": "获取好友列表",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取好友列表",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "机器人qq",
+ "name": "selfId",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getGroupList": {
+ "get": {
+ "description": "获取群列表",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取群列表",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "机器人qq",
+ "name": "selfId",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getGroupMemberList": {
+ "get": {
+ "description": "获取群成员列表,groupId为0的时候拉取好友信息",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取群成员列表",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "机器人qq",
+ "name": "selfId",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "群聊id",
+ "name": "groupId",
+ "in": "query"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/getPermCode": {
+ "get": {
+ "description": "授权码",
+ "tags": [
+ "用户"
+ ],
+ "summary": "授权码",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/getRequestList": {
+ "get": {
+ "description": "获取所有的请求",
+ "tags": [
+ "通用"
+ ],
+ "summary": "获取所有的请求",
+ "parameters": [
+ {
+ "description": "获取所有的请求入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.BotParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.RequestVo"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/getUserInfo": {
+ "get": {
+ "description": "获得用户信息",
+ "tags": [
+ "用户"
+ ],
+ "summary": "获得用户信息",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/types.UserInfoVo"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/handleRequest": {
+ "post": {
+ "description": "处理一个请求",
+ "tags": [
+ "通用"
+ ],
+ "summary": "处理一个请求",
+ "parameters": [
+ {
+ "description": "处理一个请求入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.HandleRequestParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/job/add": {
+ "post": {
+ "description": "添加任务",
+ "tags": [
+ "任务"
+ ],
+ "summary": "添加任务",
+ "parameters": [
+ {
+ "description": "添加任务入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/job.Job"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/job/delete": {
+ "post": {
+ "description": "删除任务",
+ "tags": [
+ "任务"
+ ],
+ "summary": "删除任务",
+ "parameters": [
+ {
+ "description": "删除任务的入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/job.DeleteReq"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/job/list": {
+ "get": {
+ "description": "任务列表",
+ "tags": [
+ "任务"
+ ],
+ "summary": "任务列表",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/job.Job"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/login": {
+ "post": {
+ "description": "前端登录",
+ "tags": [
+ "用户"
+ ],
+ "summary": "前端登录",
+ "parameters": [
+ {
+ "description": "前端登录",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.LoginParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/types.LoginResultVo"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/logout": {
+ "get": {
+ "description": "登出",
+ "tags": [
+ "用户"
+ ],
+ "summary": "登出",
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/getAllPlugin": {
+ "get": {
+ "description": "获取所有插件的状态",
+ "tags": [
+ "插件"
+ ],
+ "summary": "获取所有插件的状态",
+ "parameters": [
+ {
+ "description": "获取所有插件的状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.AllPluginParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.PluginVo"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/getPlugin": {
+ "get": {
+ "description": "获取某个插件的状态",
+ "tags": [
+ "插件"
+ ],
+ "summary": "获取某个插件的状态",
+ "parameters": [
+ {
+ "description": "获取某个插件的状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.PluginParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/types.PluginVo"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/updateAllPluginStatus": {
+ "post": {
+ "description": "更改某群所有插件状态",
+ "tags": [
+ "响应"
+ ],
+ "summary": "更改某群所有插件状态",
+ "parameters": [
+ {
+ "description": "修改插件状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.AllPluginStatusParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/updatePluginStatus": {
+ "post": {
+ "description": "更改某一个插件状态",
+ "tags": [
+ "插件"
+ ],
+ "summary": "更改某一个插件状态",
+ "parameters": [
+ {
+ "description": "修改插件状态入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.PluginStatusParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/manage/updateResponseStatus": {
+ "post": {
+ "description": "更改某一个群响应",
+ "tags": [
+ "响应"
+ ],
+ "summary": "更改某一个群响应",
+ "parameters": [
+ {
+ "description": "修改群响应入参",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.ResponseStatusParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "$ref": "#/definitions/types.Response"
+ }
+ }
+ }
+ }
+ },
+ "/api/sendMsg": {
+ "post": {
+ "description": "前端调用发送信息",
+ "tags": [
+ "通用"
+ ],
+ "summary": "前端调用发送信息",
+ "parameters": [
+ {
+ "description": "发消息参数",
+ "name": "object",
+ "in": "body",
+ "schema": {
+ "$ref": "#/definitions/types.SendMsgParams"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "成功",
+ "schema": {
+ "allOf": [
+ {
+ "$ref": "#/definitions/types.Response"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "result": {
+ "type": "integer"
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "job.DeleteReq": {
+ "description": "删除任务的入参",
+ "type": "object",
+ "properties": {
+ "idList": {
+ "description": "任务id",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "job.Job": {
+ "description": "添加任务的入参",
+ "type": "object",
+ "properties": {
+ "answerType": {
+ "description": "回答类型, jobType=3使用的参数, 1=文本消息, 2=注入消息",
+ "type": "integer"
+ },
+ "fullMatchType": {
+ "description": "指令别名类型, jobType=1使用的参数, 1=无状态消息, 2=主人消息",
+ "type": "integer"
+ },
+ "groupId": {
+ "description": "群聊id, jobType=2,3使用的参数, jobType=2且私聊, group_id=0",
+ "type": "integer"
+ },
+ "handler": {
+ "description": "执行内容",
+ "type": "string"
+ },
+ "id": {
+ "description": "任务id",
+ "type": "string"
+ },
+ "jobType": {
+ "description": "任务类型,1-指令别名,2-定时任务,3-你问我答",
+ "type": "integer"
+ },
+ "matcher": {
+ "description": "当jobType=1时 为指令别名,当jobType=2时 为cron表达式,当jobType=3时 为正则表达式",
+ "type": "string"
+ },
+ "questionType": {
+ "description": "问题类型, jobType=3使用的参数, 1=单独问, 2=所有人问",
+ "type": "integer"
+ },
+ "selfId": {
+ "description": "机器人id",
+ "type": "integer"
+ },
+ "userId": {
+ "description": "用户id, jobType=2,3使用的参数, 当jobType=3, QuestionType=2,userId=0",
+ "type": "integer"
+ }
+ }
+ },
+ "types.AllPluginParams": {
+ "description": "GetAllPlugin的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ }
+ }
+ },
+ "types.AllPluginStatusParams": {
+ "description": "UpdateAllPluginStatus的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "status": {
+ "description": "插件状态,0=禁用,1=启用,2=还原",
+ "type": "integer"
+ }
+ }
+ },
+ "types.BotParams": {
+ "description": "GetGroupList,GetFriendList的入参",
+ "type": "object",
+ "properties": {
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "types.DeleteGroupParams": {
+ "description": "退群或删除好友的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "types.HandleRequestParams": {
+ "description": "处理事件的入参",
+ "type": "object",
+ "properties": {
+ "approve": {
+ "description": "是否同意, true=同意,false=拒绝",
+ "type": "boolean"
+ },
+ "flag": {
+ "description": "事件的flag",
+ "type": "string"
+ },
+ "reason": {
+ "description": "事件的原因, 拒绝的时候需要填",
+ "type": "string"
+ }
+ }
+ },
+ "types.LoginParams": {
+ "description": "登录参数",
+ "type": "object",
+ "properties": {
+ "password": {
+ "description": "密码",
+ "type": "string"
+ },
+ "username": {
+ "description": "用户名",
+ "type": "string"
+ }
+ }
+ },
+ "types.LoginResultVo": {
+ "description": "登录返回参数",
+ "type": "object",
+ "properties": {
+ "desc": {
+ "description": "描述",
+ "type": "string"
+ },
+ "realName": {
+ "description": "实际名",
+ "type": "string"
+ },
+ "roles": {
+ "description": "角色",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.RoleInfo"
+ }
+ },
+ "token": {
+ "description": "token",
+ "type": "string"
+ },
+ "userId": {
+ "description": "用户id",
+ "type": "integer"
+ },
+ "username": {
+ "description": "用户名",
+ "type": "string"
+ }
+ }
+ },
+ "types.PluginParams": {
+ "description": "GetPlugin的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "name": {
+ "description": "插件名",
+ "type": "string"
+ }
+ }
+ },
+ "types.PluginStatusParams": {
+ "description": "UpdatePluginStatus的入参",
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "name": {
+ "description": "插件名",
+ "type": "string"
+ },
+ "status": {
+ "description": "插件状态,0=禁用,1=启用,2=还原",
+ "type": "integer"
+ }
+ }
+ },
+ "types.PluginVo": {
+ "description": "全部插件的返回",
+ "type": "object",
+ "properties": {
+ "banner": {
+ "description": "头像",
+ "type": "string"
+ },
+ "brief": {
+ "description": "简述",
+ "type": "string"
+ },
+ "id": {
+ "description": "插件序号",
+ "type": "integer"
+ },
+ "name": {
+ "description": "插件名",
+ "type": "string"
+ },
+ "pluginStatus": {
+ "description": "插件状态,false=禁用,true=启用",
+ "type": "boolean"
+ },
+ "responseStatus": {
+ "description": "响应状态, false=沉默,true=响应",
+ "type": "boolean"
+ },
+ "usage": {
+ "description": "用法",
+ "type": "string"
+ }
+ }
+ },
+ "types.RequestVo": {
+ "description": "请求返回",
+ "type": "object",
+ "properties": {
+ "comment": {
+ "description": "注释",
+ "type": "string"
+ },
+ "flag": {
+ "description": "请求flag",
+ "type": "string"
+ },
+ "groupId": {
+ "description": "群id",
+ "type": "integer"
+ },
+ "groupName": {
+ "description": "群名",
+ "type": "string"
+ },
+ "nickname": {
+ "description": "昵称",
+ "type": "string"
+ },
+ "requestType": {
+ "description": "请求类型",
+ "type": "string"
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ },
+ "subType": {
+ "description": "请求子类型",
+ "type": "string"
+ },
+ "userId": {
+ "description": "用户id",
+ "type": "integer"
+ }
+ }
+ },
+ "types.Response": {
+ "description": "包装返回体",
+ "type": "object",
+ "properties": {
+ "code": {
+ "description": "错误码",
+ "type": "integer"
+ },
+ "message": {
+ "description": "错误信息",
+ "type": "string"
+ },
+ "result": {
+ "description": "数据"
+ },
+ "type": {
+ "description": "待定",
+ "type": "string"
+ }
+ }
+ },
+ "types.ResponseStatusParams": {
+ "description": "UpdateResponseStatus的入参",
+ "type": "object",
+ "properties": {
+ "groupId": {
+ "description": "群id, gid\u003e0为群聊,gid\u003c0为私聊,gid=0为全部群聊",
+ "type": "integer"
+ },
+ "status": {
+ "description": "响应状态,0=沉默,1=响应",
+ "type": "integer"
+ }
+ }
+ },
+ "types.RoleInfo": {
+ "description": "角色参数",
+ "type": "object",
+ "properties": {
+ "roleName": {
+ "description": "角色名",
+ "type": "string"
+ },
+ "value": {
+ "description": "角色值",
+ "type": "string"
+ }
+ }
+ },
+ "types.SendMsgParams": {
+ "description": "处理事件的入参",
+ "type": "object",
+ "properties": {
+ "gidList": {
+ "description": "群聊数组",
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "message": {
+ "description": "CQ码格式的消息",
+ "type": "string"
+ },
+ "selfId": {
+ "description": "机器人qq",
+ "type": "integer"
+ }
+ }
+ },
+ "types.UserInfoVo": {
+ "description": "用户信息",
+ "type": "object",
+ "properties": {
+ "avatar": {
+ "description": "头像",
+ "type": "string"
+ },
+ "desc": {
+ "description": "描述",
+ "type": "string"
+ },
+ "homePath": {
+ "description": "主页路径",
+ "type": "string"
+ },
+ "password": {
+ "description": "密码",
+ "type": "string"
+ },
+ "realName": {
+ "description": "实际名",
+ "type": "string"
+ },
+ "roles": {
+ "description": "角色",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/types.RoleInfo"
+ }
+ },
+ "token": {
+ "description": "token",
+ "type": "string"
+ },
+ "userId": {
+ "description": "用户id",
+ "type": "integer"
+ },
+ "username": {
+ "description": "用户名",
+ "type": "string"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/third_party/zbputils/control/web/docs/swagger.yaml b/third_party/zbputils/control/web/docs/swagger.yaml
new file mode 100644
index 0000000000..797cacdf66
--- /dev/null
+++ b/third_party/zbputils/control/web/docs/swagger.yaml
@@ -0,0 +1,660 @@
+basePath: /
+definitions:
+ job.DeleteReq:
+ description: 删除任务的入参
+ properties:
+ idList:
+ description: 任务id
+ items:
+ type: string
+ type: array
+ selfId:
+ description: 机器人qq
+ type: integer
+ type: object
+ job.Job:
+ description: 添加任务的入参
+ properties:
+ answerType:
+ description: 回答类型, jobType=3使用的参数, 1=文本消息, 2=注入消息
+ type: integer
+ fullMatchType:
+ description: 指令别名类型, jobType=1使用的参数, 1=无状态消息, 2=主人消息
+ type: integer
+ groupId:
+ description: 群聊id, jobType=2,3使用的参数, jobType=2且私聊, group_id=0
+ type: integer
+ handler:
+ description: 执行内容
+ type: string
+ id:
+ description: 任务id
+ type: string
+ jobType:
+ description: 任务类型,1-指令别名,2-定时任务,3-你问我答
+ type: integer
+ matcher:
+ description: 当jobType=1时 为指令别名,当jobType=2时 为cron表达式,当jobType=3时 为正则表达式
+ type: string
+ questionType:
+ description: 问题类型, jobType=3使用的参数, 1=单独问, 2=所有人问
+ type: integer
+ selfId:
+ description: 机器人id
+ type: integer
+ userId:
+ description: 用户id, jobType=2,3使用的参数, 当jobType=3, QuestionType=2,userId=0
+ type: integer
+ type: object
+ types.AllPluginParams:
+ description: GetAllPlugin的入参
+ properties:
+ groupId:
+ description: 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ type: integer
+ type: object
+ types.AllPluginStatusParams:
+ description: UpdateAllPluginStatus的入参
+ properties:
+ groupId:
+ description: 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ type: integer
+ status:
+ description: 插件状态,0=禁用,1=启用,2=还原
+ type: integer
+ type: object
+ types.BotParams:
+ description: GetGroupList,GetFriendList的入参
+ properties:
+ selfId:
+ description: 机器人qq
+ type: integer
+ type: object
+ types.DeleteGroupParams:
+ description: 退群或删除好友的入参
+ properties:
+ groupId:
+ description: 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ type: integer
+ selfId:
+ description: 机器人qq
+ type: integer
+ type: object
+ types.HandleRequestParams:
+ description: 处理事件的入参
+ properties:
+ approve:
+ description: 是否同意, true=同意,false=拒绝
+ type: boolean
+ flag:
+ description: 事件的flag
+ type: string
+ reason:
+ description: 事件的原因, 拒绝的时候需要填
+ type: string
+ type: object
+ types.LoginParams:
+ description: 登录参数
+ properties:
+ password:
+ description: 密码
+ type: string
+ username:
+ description: 用户名
+ type: string
+ type: object
+ types.LoginResultVo:
+ description: 登录返回参数
+ properties:
+ desc:
+ description: 描述
+ type: string
+ realName:
+ description: 实际名
+ type: string
+ roles:
+ description: 角色
+ items:
+ $ref: '#/definitions/types.RoleInfo'
+ type: array
+ token:
+ description: token
+ type: string
+ userId:
+ description: 用户id
+ type: integer
+ username:
+ description: 用户名
+ type: string
+ type: object
+ types.PluginParams:
+ description: GetPlugin的入参
+ properties:
+ groupId:
+ description: 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ type: integer
+ name:
+ description: 插件名
+ type: string
+ type: object
+ types.PluginStatusParams:
+ description: UpdatePluginStatus的入参
+ properties:
+ groupId:
+ description: 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ type: integer
+ name:
+ description: 插件名
+ type: string
+ status:
+ description: 插件状态,0=禁用,1=启用,2=还原
+ type: integer
+ required:
+ - name
+ type: object
+ types.PluginVo:
+ description: 全部插件的返回
+ properties:
+ banner:
+ description: 头像
+ type: string
+ brief:
+ description: 简述
+ type: string
+ id:
+ description: 插件序号
+ type: integer
+ name:
+ description: 插件名
+ type: string
+ pluginStatus:
+ description: 插件状态,false=禁用,true=启用
+ type: boolean
+ responseStatus:
+ description: 响应状态, false=沉默,true=响应
+ type: boolean
+ usage:
+ description: 用法
+ type: string
+ type: object
+ types.RequestVo:
+ description: 请求返回
+ properties:
+ comment:
+ description: 注释
+ type: string
+ flag:
+ description: 请求flag
+ type: string
+ groupId:
+ description: 群id
+ type: integer
+ groupName:
+ description: 群名
+ type: string
+ nickname:
+ description: 昵称
+ type: string
+ requestType:
+ description: 请求类型
+ type: string
+ selfId:
+ description: 机器人qq
+ type: integer
+ subType:
+ description: 请求子类型
+ type: string
+ userId:
+ description: 用户id
+ type: integer
+ type: object
+ types.Response:
+ description: 包装返回体
+ properties:
+ code:
+ description: 错误码
+ type: integer
+ message:
+ description: 错误信息
+ type: string
+ result:
+ description: 数据
+ type:
+ description: 待定
+ type: string
+ type: object
+ types.ResponseStatusParams:
+ description: UpdateResponseStatus的入参
+ properties:
+ groupId:
+ description: 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ type: integer
+ status:
+ description: 响应状态,0=沉默,1=响应
+ type: integer
+ type: object
+ types.RoleInfo:
+ description: 角色参数
+ properties:
+ roleName:
+ description: 角色名
+ type: string
+ value:
+ description: 角色值
+ type: string
+ type: object
+ types.SendMsgParams:
+ description: 处理事件的入参
+ properties:
+ gidList:
+ description: 群聊数组
+ items:
+ type: integer
+ type: array
+ message:
+ description: CQ码格式的消息
+ type: string
+ selfId:
+ description: 机器人qq
+ type: integer
+ type: object
+ types.UserInfoVo:
+ description: 用户信息
+ properties:
+ avatar:
+ description: 头像
+ type: string
+ desc:
+ description: 描述
+ type: string
+ homePath:
+ description: 主页路径
+ type: string
+ password:
+ description: 密码
+ type: string
+ realName:
+ description: 实际名
+ type: string
+ roles:
+ description: 角色
+ items:
+ $ref: '#/definitions/types.RoleInfo'
+ type: array
+ token:
+ description: token
+ type: string
+ userId:
+ description: 用户id
+ type: integer
+ username:
+ description: 用户名
+ type: string
+ type: object
+host: 127.0.0.1:3000
+info:
+ contact: {}
+ description: zbp restful api document
+ title: zbp api
+ version: "1.0"
+paths:
+ /api/deleteGroup:
+ get:
+ description: 删除群聊或好友
+ parameters:
+ - description: 删除群聊或好友入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.DeleteGroupParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 删除群聊或好友
+ tags:
+ - 通用
+ /api/getBotList:
+ get:
+ description: 获取机器人qq号
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 获取机器人qq号
+ tags:
+ - 通用
+ /api/getFriendList:
+ get:
+ description: 获取好友列表
+ parameters:
+ - description: 机器人qq
+ in: query
+ name: selfId
+ type: integer
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 获取好友列表
+ tags:
+ - 通用
+ /api/getGroupList:
+ get:
+ description: 获取群列表
+ parameters:
+ - description: 机器人qq
+ in: query
+ name: selfId
+ type: integer
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 获取群列表
+ tags:
+ - 通用
+ /api/getGroupMemberList:
+ get:
+ description: 获取群成员列表,groupId为0的时候拉取好友信息
+ parameters:
+ - description: 机器人qq
+ in: query
+ name: selfId
+ type: integer
+ - description: 群聊id
+ in: query
+ name: groupId
+ type: integer
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 获取群成员列表
+ tags:
+ - 通用
+ /api/getPermCode:
+ get:
+ description: 授权码
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ items:
+ type: string
+ type: array
+ type: object
+ summary: 授权码
+ tags:
+ - 用户
+ /api/getRequestList:
+ get:
+ description: 获取所有的请求
+ parameters:
+ - description: 获取所有的请求入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.BotParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ items:
+ $ref: '#/definitions/types.RequestVo'
+ type: array
+ type: object
+ summary: 获取所有的请求
+ tags:
+ - 通用
+ /api/getUserInfo:
+ get:
+ description: 获得用户信息
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ $ref: '#/definitions/types.UserInfoVo'
+ type: object
+ summary: 获得用户信息
+ tags:
+ - 用户
+ /api/handleRequest:
+ post:
+ description: 处理一个请求
+ parameters:
+ - description: 处理一个请求入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.HandleRequestParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 处理一个请求
+ tags:
+ - 通用
+ /api/job/add:
+ post:
+ description: 添加任务
+ parameters:
+ - description: 添加任务入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/job.Job'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 添加任务
+ tags:
+ - 任务
+ /api/job/delete:
+ post:
+ description: 删除任务
+ parameters:
+ - description: 删除任务的入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/job.DeleteReq'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 删除任务
+ tags:
+ - 任务
+ /api/job/list:
+ get:
+ description: 任务列表
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ items:
+ $ref: '#/definitions/job.Job'
+ type: array
+ type: object
+ summary: 任务列表
+ tags:
+ - 任务
+ /api/login:
+ post:
+ description: 前端登录
+ parameters:
+ - description: 前端登录
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.LoginParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ $ref: '#/definitions/types.LoginResultVo'
+ type: object
+ summary: 前端登录
+ tags:
+ - 用户
+ /api/logout:
+ get:
+ description: 登出
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 登出
+ tags:
+ - 用户
+ /api/manage/getAllPlugin:
+ get:
+ description: 获取所有插件的状态
+ parameters:
+ - description: 获取所有插件的状态入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.AllPluginParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ items:
+ $ref: '#/definitions/types.PluginVo'
+ type: array
+ type: object
+ summary: 获取所有插件的状态
+ tags:
+ - 插件
+ /api/manage/getPlugin:
+ get:
+ description: 获取某个插件的状态
+ parameters:
+ - description: 获取某个插件的状态入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.PluginParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ $ref: '#/definitions/types.PluginVo'
+ type: object
+ summary: 获取某个插件的状态
+ tags:
+ - 插件
+ /api/manage/updateAllPluginStatus:
+ post:
+ description: 更改某群所有插件状态
+ parameters:
+ - description: 修改插件状态入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.AllPluginStatusParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 更改某群所有插件状态
+ tags:
+ - 响应
+ /api/manage/updatePluginStatus:
+ post:
+ description: 更改某一个插件状态
+ parameters:
+ - description: 修改插件状态入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.PluginStatusParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 更改某一个插件状态
+ tags:
+ - 插件
+ /api/manage/updateResponseStatus:
+ post:
+ description: 更改某一个群响应
+ parameters:
+ - description: 修改群响应入参
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.ResponseStatusParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ $ref: '#/definitions/types.Response'
+ summary: 更改某一个群响应
+ tags:
+ - 响应
+ /api/sendMsg:
+ post:
+ description: 前端调用发送信息
+ parameters:
+ - description: 发消息参数
+ in: body
+ name: object
+ schema:
+ $ref: '#/definitions/types.SendMsgParams'
+ responses:
+ "200":
+ description: 成功
+ schema:
+ allOf:
+ - $ref: '#/definitions/types.Response'
+ - properties:
+ result:
+ type: integer
+ type: object
+ summary: 前端调用发送信息
+ tags:
+ - 通用
+swagger: "2.0"
diff --git a/third_party/zbputils/control/web/gui.go b/third_party/zbputils/control/web/gui.go
new file mode 100644
index 0000000000..3a3a16e6ab
--- /dev/null
+++ b/third_party/zbputils/control/web/gui.go
@@ -0,0 +1,127 @@
+// Package webctrl 包含 webui 所需的所有内容
+package webctrl
+
+import (
+ "context"
+ "io/fs"
+ "net/http"
+ "runtime/debug"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ log "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+
+ webui "github.com/FloatTech/ZeroBot-Plugin-Webui"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/zbputils/control/web/controller"
+ "github.com/FloatTech/zbputils/control/web/model"
+ "github.com/FloatTech/zbputils/control/web/router"
+)
+
+var (
+ // ListenCtrlChan 启动/停止 webui
+ listenCtrlChan = make(chan bool)
+)
+
+func init() {
+ zero.OnRegex(`^/设置webui用户名\s?(\S+)\s?密码\s?(\S+)$`, zero.SuperUserPermission, zero.OnlyToMe).SetBlock(true).
+ Handle(func(ctx *zero.Ctx) {
+ regexMatched := ctx.State["regex_matched"].([]string)
+ err := model.CreateOrUpdateUser(&model.User{Username: regexMatched[1], Password: regexMatched[2]})
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("设置成功"))
+ if len(zero.BotConfig.SuperUsers) != 0 {
+ ctx.SendPrivateMessage(zero.BotConfig.SuperUsers[0], message.Text("webui账号\n用户名: ", regexMatched[1], "\n密码: ", regexMatched[2]))
+ }
+ })
+ zero.OnCommand("webui", zero.SuperUserPermission, zero.OnlyToMe).SetBlock(true).
+ Handle(func(ctx *zero.Ctx) {
+ args := ctx.State["args"].(string)
+ args = strings.TrimSpace(args)
+ isSuccess := args == "启动"
+ listenCtrlChan <- isSuccess
+ if isSuccess {
+ ctx.SendChain(message.Text("成功, webui启动"))
+ } else {
+ ctx.SendChain(message.Text("成功, webui停止"))
+ }
+ })
+}
+
+// RunGui 运行webui
+//
+// @title zbp api
+// @version 1.0
+// @description zbp restful api document
+// @host 127.0.0.1:3000
+// @BasePath /
+func RunGui(addr string) {
+ defer func() {
+ err := recover()
+ if err != nil {
+ log.Errorln("[gui] ZeroBot-Plugin-Webui出现不可恢复的错误")
+ log.Errorln("[gui] err:", err, ", stack:", debug.Stack())
+ }
+ }()
+
+ engine := gin.Default()
+ router.SetRouters(engine)
+
+ staticEngine := gin.Default()
+ df, _ := fs.Sub(webui.Dist, "dist")
+ staticEngine.StaticFS("/", http.FS(df))
+ server := &http.Server{
+ Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ // 如果 URL 以 /api, /swagger 开头, 走后端路由
+ if strings.HasPrefix(request.URL.Path, "/api") || strings.HasPrefix(request.URL.Path, "/swagger") {
+ engine.ServeHTTP(writer, request)
+ return
+ }
+ // 否则,走前端路由
+ staticEngine.ServeHTTP(writer, request)
+ }),
+ Addr: addr,
+ }
+ log.Infoln("[gui] the webui is running on", "http://"+addr)
+ log.Infoln("[gui] you can see api by http://" + addr + "/swagger/index.html")
+ if err := server.ListenAndServe(); err != nil {
+ log.Errorln("[gui] server listen err: ", err.Error())
+ }
+ for canrun := range listenCtrlChan {
+ if canrun {
+ if err := server.Shutdown(context.TODO()); err != nil {
+ log.Errorln("[gui] server shutdown err: ", err.Error())
+ }
+ server = &http.Server{
+ Handler: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ // 如果 URL 以 /api, /swagger 开头, 走后端路由
+ if strings.HasPrefix(request.URL.Path, "/api") || strings.HasPrefix(request.URL.Path, "/swagger") {
+ engine.ServeHTTP(writer, request)
+ return
+ }
+ // 否则,走前端路由
+ staticEngine.ServeHTTP(writer, request)
+ }),
+ Addr: addr,
+ }
+ go func() {
+ log.Infoln("[gui] the webui is running on", "http://"+addr)
+ log.Infoln("[gui] you can see api by http://" + addr + "/swagger/index.html")
+ if err := server.ListenAndServe(); err != nil {
+ log.Errorln("[gui] server listen err: ", err.Error())
+ }
+ }()
+ } else {
+ if err := server.Shutdown(context.TODO()); err != nil {
+ log.Errorln("[gui] server shutdown err: ", err.Error())
+ }
+ controller.MsgConn = nil
+ controller.LogConn = nil
+ }
+ }
+}
diff --git a/third_party/zbputils/control/web/middleware/middleware.go b/third_party/zbputils/control/web/middleware/middleware.go
new file mode 100644
index 0000000000..5beb327163
--- /dev/null
+++ b/third_party/zbputils/control/web/middleware/middleware.go
@@ -0,0 +1,84 @@
+// Package middleware 中间件
+package middleware
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/patrickmn/go-cache"
+)
+
+var (
+ // LoginCache 登录缓存
+ LoginCache = cache.New(24*time.Hour, 12*time.Hour)
+)
+
+// Cors 跨域
+/**
+ * @Description: 支持跨域访问
+ * @return gin.HandlerFunc
+ * example
+ */
+func Cors() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ method := c.Request.Method
+ origin := c.Request.Header.Get("Origin") // 请求头部
+ if origin != "" {
+ // 接收客户端发送的origin (重要!)
+ c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
+ // 服务器支持的所有跨域请求的方法
+ c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE,UPDATE")
+ // 允许跨域设置可以返回其他子段,可以自定义字段
+ c.Header("Access-Control-Allow-Headers", "Authorization, Content-Length, X-CSRF-Token, Token,session, Content-Type")
+ // 允许浏览器(客户端)可以解析的头部 (重要)
+ c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers")
+ // 设置缓存时间
+ c.Header("Access-Control-Max-Age", "172800")
+ // 允许客户端传递校验信息比如 cookie (重要)
+ c.Header("Access-Control-Allow-Credentials", "true")
+ }
+
+ // 允许类型校验
+ if method == "OPTIONS" {
+ c.JSON(http.StatusOK, gin.H{
+ "code": 0,
+ "result": nil,
+ "message": "",
+ "type": "success",
+ })
+ }
+
+ c.Next()
+ }
+}
+
+// TokenMiddle 验证token
+func TokenMiddle() gin.HandlerFunc {
+ return func(con *gin.Context) {
+ // 进行token验证
+ token := con.Request.Header.Get("Authorization")
+ if token == "" {
+ con.JSON(http.StatusUnauthorized, gin.H{
+ "code": 2,
+ "result": nil,
+ "message": "无权访问, 请登录",
+ "type": "error",
+ })
+ con.Abort()
+ return
+ }
+ _, found := LoginCache.Get(token)
+ if !found {
+ con.JSON(http.StatusUnauthorized, gin.H{
+ "code": 2,
+ "result": nil,
+ "message": "toke无效, 请重新登录",
+ "type": "error",
+ })
+ con.Abort()
+ return
+ }
+ con.Next()
+ }
+}
diff --git a/third_party/zbputils/control/web/model/webui.go b/third_party/zbputils/control/web/model/webui.go
new file mode 100644
index 0000000000..b91239a57f
--- /dev/null
+++ b/third_party/zbputils/control/web/model/webui.go
@@ -0,0 +1,61 @@
+// Package model 用户模型类
+package model
+
+import (
+ "os"
+ "sync"
+ "time"
+
+ sql "github.com/FloatTech/sqlite"
+)
+
+// User webui用户数据
+type User struct {
+ ID int64 `db:"id"`
+ Username string `db:"username"`
+ Password string `db:"password"`
+}
+
+const webuiFolder = "data/webui/"
+
+var (
+ udb = sql.New(webuiFolder + "user.db")
+ mu sync.RWMutex
+)
+
+func init() {
+ _ = os.MkdirAll(webuiFolder, 0755)
+ err := udb.Open(time.Hour)
+ if err != nil {
+ panic(err)
+ }
+ err = udb.Create("user", &User{})
+ if err != nil {
+ panic(err)
+ }
+}
+
+// CreateOrUpdateUser 创建或修改用户密码
+func CreateOrUpdateUser(u *User) error {
+ mu.RLock()
+ defer mu.RUnlock()
+ var fu User
+ err := udb.Find("user", &fu, "WHERE username = '"+u.Username+"' AND password = '"+u.Password+"'")
+ canFind := err == nil && fu.Username == u.Username
+ if canFind {
+ err = udb.Del("user", "WHERE username = '"+u.Username+"'")
+ if err != nil {
+ return err
+ }
+ }
+ err = udb.Insert("user", u)
+ return err
+}
+
+// FindUser 查找webui账号
+func FindUser(username, password string) (u User, err error) {
+ mu.Lock()
+ defer mu.Unlock()
+ err = udb.Find("user", &u, "WHERE username = '"+username+"' AND password = '"+password+"'")
+ return
+}
diff --git a/third_party/zbputils/control/web/router/router.go b/third_party/zbputils/control/web/router/router.go
new file mode 100644
index 0000000000..6ca563bbbc
--- /dev/null
+++ b/third_party/zbputils/control/web/router/router.go
@@ -0,0 +1,53 @@
+// Package router 路由
+package router
+
+import (
+ "github.com/gin-gonic/gin"
+ swaggerFiles "github.com/swaggo/files"
+ ginSwagger "github.com/swaggo/gin-swagger"
+
+ "github.com/FloatTech/zbputils/control/web/controller"
+ _ "github.com/FloatTech/zbputils/control/web/docs" // swagger数据
+ "github.com/FloatTech/zbputils/control/web/middleware"
+)
+
+// SetRouters 创建路由
+func SetRouters(engine *gin.Engine) {
+ engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
+
+ // 支持跨域
+ engine.Use(middleware.Cors(), gin.Logger())
+
+ // 通用接口
+ apiRoute := engine.Group("/api")
+ apiRoute.Use(middleware.TokenMiddle())
+ apiRoute.GET("/getFriendList", controller.GetFriendList)
+ apiRoute.GET("/getGroupList", controller.GetGroupList)
+ apiRoute.GET("/getGroupMemberList", controller.GetGroupMemberList)
+ apiRoute.GET("/getRequestList", controller.GetRequestList)
+ apiRoute.POST("/handleRequest", controller.HandleRequest)
+ apiRoute.POST("/sendMsg", controller.SendMsg)
+ apiRoute.GET("/getUserInfo", controller.GetUserInfo)
+
+ // 任务相关接口
+ jobRoute := apiRoute.Group("/job")
+ jobRoute.GET("/list", controller.JobList)
+ jobRoute.POST("/add", controller.JobAdd)
+ jobRoute.POST("/delete", controller.JobDelete)
+
+ // 管理相关接口
+ manageRoute := apiRoute.Group("/manage")
+ manageRoute.GET("/getPlugin", controller.GetPlugin)
+ manageRoute.GET("/getAllPlugin", controller.GetAllPlugin)
+ manageRoute.POST("/updatePluginStatus", controller.UpdatePluginStatus)
+ manageRoute.POST("/updateResponseStatus", controller.UpdateResponseStatus)
+ manageRoute.POST("/updateAllPluginStatus", controller.UpdateAllPluginStatus)
+
+ noVerifyRoute := engine.Group("/api")
+ noVerifyRoute.POST("/login", controller.Login)
+ noVerifyRoute.GET("/logout", controller.Logout)
+ noVerifyRoute.GET("/getPermCode", controller.GetPermCode)
+ noVerifyRoute.GET("/getBotList", controller.GetBotList)
+ noVerifyRoute.GET("/getLog", controller.GetLog)
+ noVerifyRoute.GET("/data", controller.Upgrade)
+}
diff --git a/third_party/zbputils/control/web/types/types.go b/third_party/zbputils/control/web/types/types.go
new file mode 100644
index 0000000000..59ccbde5b2
--- /dev/null
+++ b/third_party/zbputils/control/web/types/types.go
@@ -0,0 +1,177 @@
+// Package types 结构体
+package types
+
+// Response 包装返回体
+//
+// @Description 包装返回体
+type Response struct {
+ Code int `json:"code"` // 错误码
+ Message string `json:"message"` // 错误信息
+ Result interface{} `json:"result"` // 数据
+ ResponseType string `json:"type"` // 待定
+}
+
+// BotParams GetGroupList,GetFriendList的入参
+//
+// @Description GetGroupList,GetFriendList的入参
+type BotParams struct {
+ SelfID int64 `json:"selfId" form:"selfId"` // 机器人qq
+}
+
+// GetGroupMemberListReq 获得群成员的入参
+//
+// @Description 获得群成员的入参
+type GetGroupMemberListReq struct {
+ SelfID int64 `json:"selfId" form:"selfId"` // 机器人qq
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id
+}
+
+// AllPluginParams GetAllPlugin的入参
+//
+// @Description GetAllPlugin的入参
+type AllPluginParams struct {
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+}
+
+// DeleteGroupParams 退群或删除好友的入参
+//
+// @Description 退群或删除好友的入参
+type DeleteGroupParams struct {
+ SelfID int64 `json:"selfId" form:"selfId"` // 机器人qq
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+}
+
+// PluginParams GetPlugin的入参
+//
+// @Description GetPlugin的入参
+type PluginParams struct {
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ Name string `json:"name" form:"name"` // 插件名
+}
+
+// PluginStatusParams UpdatePluginStatus的入参
+//
+// @Description UpdatePluginStatus的入参
+type PluginStatusParams struct {
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ Name string `json:"name" form:"name" validate:"required"` // 插件名
+ Status int `json:"status" form:"status"` // 插件状态,0=禁用,1=启用,2=还原
+}
+
+// ResponseStatusParams UpdateResponseStatus的入参
+//
+// @Description UpdateResponseStatus的入参
+type ResponseStatusParams struct {
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ Status int `json:"status" form:"status"` // 响应状态,0=沉默,1=响应
+}
+
+// AllPluginStatusParams UpdateAllPluginStatus的入参
+//
+// @Description UpdateAllPluginStatus的入参
+type AllPluginStatusParams struct {
+ GroupID int64 `json:"groupId" form:"groupId"` // 群id, gid>0为群聊,gid<0为私聊,gid=0为全部群聊
+ Status int `json:"status" form:"status"` // 插件状态,0=禁用,1=启用,2=还原
+}
+
+// HandleRequestParams 处理事件的入参
+//
+// @Description 处理事件的入参
+type HandleRequestParams struct {
+ Flag string `json:"flag" form:"flag"` // 事件的flag
+ Reason string `json:"reason" form:"reason"` // 事件的原因, 拒绝的时候需要填
+ Approve bool `json:"approve" form:"approve"` // 是否同意, true=同意,false=拒绝
+}
+
+// SendMsgParams 发送消息的入参
+//
+// @Description 处理事件的入参
+type SendMsgParams struct {
+ SelfID int64 `json:"selfId" form:"selfId"` // 机器人qq
+ GIDList []int64 `json:"gidList" form:"gidList"` // 群聊数组
+ Message string `json:"message" form:"message"` // CQ码格式的消息
+}
+
+// LoginParams 登录参数
+//
+// @Description 登录参数
+type LoginParams struct {
+ Username string `json:"username" form:"username"` // 用户名
+ Password string `json:"password" form:"password"` // 密码
+}
+
+// LoginResultVo 登录返回参数
+//
+// @Description 登录返回参数
+type LoginResultVo struct {
+ UserID int `json:"userId"` // 用户id
+ Username string `json:"username"` // 用户名
+ RealName string `json:"realName"` // 实际名
+ Desc string `json:"desc"` // 描述
+ Token string `json:"token"` // token
+ Roles []RoleInfo `json:"roles"` // 角色
+}
+
+// RoleInfo 角色参数
+//
+// @Description 角色参数
+type RoleInfo struct {
+ RoleName string `json:"roleName"` // 角色名
+ Value string `json:"value"` // 角色值
+}
+
+// UserInfoVo 用户信息
+//
+// @Description 用户信息
+type UserInfoVo struct {
+ UserID int `json:"userId"` // 用户id
+ Username string `json:"username"` // 用户名
+ RealName string `json:"realName"` // 实际名
+ Desc string `json:"desc"` // 描述
+ Token string `json:"token"` // token
+ Roles []RoleInfo `json:"roles"` // 角色
+ Avatar string `json:"avatar"` // 头像
+ HomePath string `json:"homePath"` // 主页路径
+ Password string `json:"password"` // 密码
+}
+
+// MessageInfo 消息信息
+//
+// @Description 消息信息
+type MessageInfo struct {
+ MessageType string `json:"message_type"` // 消息类型, group为群聊,private为私聊
+ MessageID interface{} `json:"message_id"` // 消息id
+ GroupID int64 `json:"group_id"` // 群id
+ GroupName string `json:"group_name"` // 群名
+ UserID int64 `json:"user_id"` // 用户名
+ Nickname string `json:"nickname"` // 昵称
+ RawMessage string `json:"raw_message"` // 初始消息
+}
+
+// PluginVo 全部插件的返回
+//
+// @Description 全部插件的返回
+type PluginVo struct {
+ ID int `json:"id"` // 插件序号
+ Name string `json:"name"` // 插件名
+ Brief string `json:"brief"` // 简述
+ Usage string `json:"usage"` // 用法
+ Banner string `json:"banner"` // 头像
+ PluginStatus bool `json:"pluginStatus"` // 插件状态,false=禁用,true=启用
+ ResponseStatus bool `json:"responseStatus"` // 响应状态, false=沉默,true=响应
+}
+
+// RequestVo 请求返回
+//
+// @Description 请求返回
+type RequestVo struct {
+ Flag string `json:"flag"` // 请求flag
+ RequestType string `json:"requestType"` // 请求类型
+ SubType string `json:"subType"` // 请求子类型
+ Comment string `json:"comment"` // 注释
+ GroupID int64 `json:"groupId"` // 群id
+ GroupName string `json:"groupName"` // 群名
+ UserID int64 `json:"userId"` // 用户id
+ Nickname string `json:"nickname"` // 昵称
+ SelfID int64 `json:"selfId"` // 机器人qq
+}
diff --git a/third_party/zbputils/ctxext/message.go b/third_party/zbputils/ctxext/message.go
new file mode 100644
index 0000000000..eda113ea52
--- /dev/null
+++ b/third_party/zbputils/ctxext/message.go
@@ -0,0 +1,86 @@
+// Package ctxext zb context 扩展
+package ctxext
+
+import (
+ "encoding/json"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/floatbox/binary"
+)
+
+//nolint:revive
+type (
+ NoCtxGetMsg func(int64) zero.Message
+ NoCtxSendMsg func(any) int64
+)
+
+// GetMessage ...
+func GetMessage(ctx *zero.Ctx) NoCtxGetMsg {
+ return func(id int64) zero.Message {
+ return ctx.GetMessage(message.NewMessageIDFromInteger(id))
+ }
+}
+
+// GetFirstMessageInForward ...
+func GetFirstMessageInForward(ctx *zero.Ctx) NoCtxGetMsg {
+ return func(id int64) zero.Message {
+ msg := GetMessage(ctx)(id)
+ if len(msg.Elements) == 0 {
+ return zero.Message{}
+ }
+ msgs := ctx.GetForwardMessage(msg.Elements[0].Data["id"]).Get("messages").Array()
+ if len(msgs) == 0 {
+ return zero.Message{}
+ }
+ m := zero.Message{
+ Elements: message.ParseMessage(binary.StringToBytes(msgs[0].Get("content").Raw)),
+ Sender: &zero.User{},
+ }
+ err := json.Unmarshal(binary.StringToBytes(msgs[0].Get("sender").Raw), m.Sender)
+ if err != nil {
+ return zero.Message{}
+ }
+ return m
+ }
+}
+
+// SendTo ...
+func SendTo(ctx *zero.Ctx, user int64) NoCtxSendMsg {
+ return func(msg any) int64 {
+ return ctx.SendPrivateMessage(user, msg)
+ }
+}
+
+// Send ...
+func Send(ctx *zero.Ctx) NoCtxSendMsg {
+ return func(msg any) int64 {
+ return ctx.Send(msg).ID()
+ }
+}
+
+// SendToSelf ...
+func SendToSelf(ctx *zero.Ctx) NoCtxSendMsg {
+ return func(msg any) int64 {
+ return ctx.SendPrivateMessage(ctx.Event.SelfID, msg)
+ }
+}
+
+// FakeSenderForwardNode ...
+func FakeSenderForwardNode(ctx *zero.Ctx, msgs ...message.Segment) message.Segment {
+ return message.CustomNode(
+ ctx.CardOrNickName(ctx.Event.UserID),
+ ctx.Event.UserID,
+ msgs)
+}
+
+// SendFakeForwardToGroup ...
+func SendFakeForwardToGroup(ctx *zero.Ctx, msgs ...message.Segment) NoCtxSendMsg {
+ return func(msg any) int64 {
+ return ctx.SendGroupForwardMessage(ctx.Event.GroupID, message.Message{
+ FakeSenderForwardNode(ctx, msg.(message.Message)...),
+ FakeSenderForwardNode(ctx, msgs...),
+ }).Get("message_id").Int()
+ }
+}
diff --git a/third_party/zbputils/ctxext/speed.go b/third_party/zbputils/ctxext/speed.go
new file mode 100644
index 0000000000..d0b2a98d51
--- /dev/null
+++ b/third_party/zbputils/ctxext/speed.go
@@ -0,0 +1,118 @@
+package ctxext
+
+import (
+ "time"
+ "unsafe"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/extension/rate"
+ "github.com/wdvxdr1123/ZeroBot/extension/single"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+// DefaultSingle 默认反并发处理, 不匹配后续插件
+//
+// 按 qq 号反并发
+// 并发时返回 "您有操作正在执行, 请稍后再试!"
+var DefaultSingle = NewDefaultSingle("您有操作正在执行, 请稍后再试!")
+
+// NewDefaultSingle 自定义失败文案
+func NewDefaultSingle(failtext string) *single.Single[int64] {
+ return single.New(
+ single.WithKeyFn(func(ctx *zero.Ctx) int64 {
+ return ctx.Event.UserID
+ }),
+ single.WithPostFn[int64](func(ctx *zero.Ctx) {
+ ctx.Break()
+ ctx.Send(
+ message.ReplyWithMessage(ctx.Event.MessageID,
+ message.Text(failtext),
+ ),
+ )
+ }),
+ )
+}
+
+// GroupSingle 按群反并发, 私聊时按个人, 不匹配后续插件
+//
+// 并发时返回 "您有操作正在执行, 请稍后再试!"
+var GroupSingle = NewGroupSingle("您有操作正在执行, 请稍后再试!")
+
+// NewGroupSingle 自定义失败文案
+func NewGroupSingle(failtext string) *single.Single[int64] {
+ return single.New(
+ single.WithKeyFn(func(ctx *zero.Ctx) int64 {
+ if ctx.Event.GroupID == 0 {
+ return -ctx.Event.UserID
+ }
+ return ctx.Event.GroupID
+ }),
+ single.WithPostFn[int64](func(ctx *zero.Ctx) {
+ ctx.Break()
+ ctx.Send(
+ message.ReplyWithMessage(ctx.Event.MessageID,
+ message.Text(failtext),
+ ),
+ )
+ }),
+ )
+}
+
+// defaultLimiterManager 默认限速器管理
+//
+// 每 10s 5次触发
+var defaultLimiterManager = rate.NewManager[int64](time.Second*10, 5)
+
+type fakeLM struct {
+ limiters unsafe.Pointer
+ interval time.Duration
+ burst int
+}
+
+// SetDefaultLimiterManagerParam 设置默认限速器参数
+//
+// 每 interval 时间 burst 次触发
+func SetDefaultLimiterManagerParam(interval time.Duration, burst int) {
+ f := (*fakeLM)(unsafe.Pointer(defaultLimiterManager))
+ f.interval = interval
+ f.burst = burst
+}
+
+// LimitByUser 默认限速器 每 10s 5次触发
+//
+// 按 qq 号限制
+func LimitByUser(ctx *zero.Ctx) *rate.Limiter {
+ return defaultLimiterManager.Load(ctx.Event.UserID)
+}
+
+// LimitByGroup 默认限速器 每 10s 5次触发
+//
+// 按群号限制
+func LimitByGroup(ctx *zero.Ctx) *rate.Limiter {
+ return defaultLimiterManager.Load(ctx.Event.GroupID)
+}
+
+// LimiterManager 自定义限速器管理
+type LimiterManager struct {
+ m *rate.LimiterManager[int64]
+}
+
+// NewLimiterManager 新限速器管理
+func NewLimiterManager(interval time.Duration, burst int) (m LimiterManager) {
+ m.m = rate.NewManager[int64](interval, burst)
+ return
+}
+
+// LimitByUser 自定义限速器
+//
+// 按 qq 号限制
+func (m LimiterManager) LimitByUser(ctx *zero.Ctx) *rate.Limiter {
+ return m.m.Load(ctx.Event.UserID)
+}
+
+// LimitByGroup 自定义限速器
+//
+// 按群号限制
+func (m LimiterManager) LimitByGroup(ctx *zero.Ctx) *rate.Limiter {
+ return m.m.Load(ctx.Event.GroupID)
+}
diff --git a/third_party/zbputils/ctxext/storage.go b/third_party/zbputils/ctxext/storage.go
new file mode 100644
index 0000000000..e457a0daa6
--- /dev/null
+++ b/third_party/zbputils/ctxext/storage.go
@@ -0,0 +1,154 @@
+package ctxext
+
+import (
+ "errors"
+ "fmt"
+ "math/bits"
+ "strconv"
+ "strings"
+
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+var (
+ // ErrManagerNotFound unexpected
+ ErrManagerNotFound = errors.New("manager not found")
+)
+
+// Storage is a wrapper of *ctrl.Control[*zero.Ctx].Get/SetData
+type Storage int64
+
+// NewStorage private is -uid
+func NewStorage(ctx *zero.Ctx, gid int64) (Storage, error) {
+ c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx])
+ if !ok {
+ return 0, ErrManagerNotFound
+ }
+ x := c.GetData(gid)
+ logrus.Debugln("[ctxext] NewStorage get plugin", c.Service, "grp:", gid, "val:", fmt.Sprintf("%016x", x))
+ return Storage(x), nil
+}
+
+// SaveTo ...
+func (s Storage) SaveTo(ctx *zero.Ctx, gid int64) error {
+ c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx])
+ if !ok {
+ return ErrManagerNotFound
+ }
+ logrus.Debugln("[ctxext] SaveTo plugin", c.Service, "grp:", gid, "val:", fmt.Sprintf("%016x", int64(s)))
+ return c.SetData(gid, int64(s))
+}
+
+// UpdateStorage merges one setting with the latest data under the control lock.
+func UpdateStorage(ctx *zero.Ctx, gid int64, change func(Storage) Storage) error {
+ c, ok := ctx.State["manager"].(*ctrl.Control[*zero.Ctx])
+ if !ok {
+ return ErrManagerNotFound
+ }
+ _, err := c.UpdateData(gid, "", func(old int64) (int64, error) { return int64(change(Storage(old))), nil })
+ return err
+}
+
+// Get bmp is a continuous 1's sequence like 0x00ff00
+func (s Storage) Get(bmp int64) int64 {
+ sft := bits.TrailingZeros64(uint64(bmp))
+ x := (int64(s) >> int64(sft)) & (bmp >> int64(sft))
+ logrus.Debugln("[ctxext] Storage", fmt.Sprintf("%016x", int64(s)), "get bmp", fmt.Sprintf("%016x", bmp), "sft:", sft, "val:", fmt.Sprintf("%016x", x))
+ return x
+}
+
+// Set bmp is a continuous 1's sequence like 0x00ff00
+func (s Storage) Set(x int64, bmp int64) Storage {
+ if bmp == 0 {
+ panic("cannot use bmp == 0")
+ }
+ sft := bits.TrailingZeros64(uint64(bmp))
+ news := Storage((int64(s) & (^bmp)) | ((x << int64(sft)) & bmp))
+ logrus.Debugln("[ctxext] Storage", fmt.Sprintf("%016x", int64(s)), "set bmp", fmt.Sprintf("%016x", bmp), "sft:", sft, "val:", fmt.Sprintf("%016x", x), "after set:", fmt.Sprintf("%016x", int64(news)))
+ return news
+}
+
+// GetBool bmp must be 1-bit-long
+func (s Storage) GetBool(bmp int64) bool {
+ x := s.Get(bmp) != 0
+ logrus.Debugln("[ctxext] Storage", fmt.Sprintf("%016x", int64(s)), "get bmp", fmt.Sprintf("%016x", bmp), "val:", x)
+ return x
+}
+
+// NewStorageSaveBitmapHandler is a easy handler that cooperate with OnPrefix
+// and extract int64 from args, save it to storage.
+func NewStorageSaveBitmapHandler(bmp int64, minv, maxv int64) func(ctx *zero.Ctx) {
+ return func(ctx *zero.Ctx) {
+ args := strings.TrimSpace(ctx.State["args"].(string))
+ if args == "" {
+ ctx.SendChain(message.Text("ERROR: empty args"))
+ return
+ }
+ r, err := strconv.ParseInt(args, 10, 64)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: parse int64 err: ", err))
+ return
+ }
+ if r > maxv {
+ r = maxv
+ } else if r < minv {
+ r = minv
+ }
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ stor, err := NewStorage(ctx, gid)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ _ = stor
+ err = UpdateStorage(ctx, gid, func(old Storage) Storage { return old.Set(r, bmp) })
+ if err != nil {
+ ctx.SendChain(message.Text("配置保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("成功"))
+ }
+}
+
+// NewStorageSaveBoolHandler is a easy handler that cooperate with OnRegex
+// and extract bool from args, save it to storage.
+//
+// Note:
+// - The first submatch must be (不)?
+// - If 不 is matched, true (1) will be set.
+// - Otherwise, false (0) will be set.
+func NewStorageSaveBoolHandler(bmp int64) func(ctx *zero.Ctx) {
+ if bits.OnesCount64(uint64(bmp)) != 1 {
+ panic("bool bmp must be 1-bit-long")
+ }
+ return func(ctx *zero.Ctx) {
+ args := ctx.State["regex_matched"].([]string)
+ isone := args[1] == "不"
+ gid := ctx.Event.GroupID
+ if gid == 0 {
+ gid = -ctx.Event.UserID
+ }
+ stor, err := NewStorage(ctx, gid)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ v := 0
+ if isone {
+ v = 1
+ }
+ _ = stor
+ err = UpdateStorage(ctx, gid, func(old Storage) Storage { return old.Set(int64(v), bmp) })
+ if err != nil {
+ ctx.SendChain(message.Text("配置保存失败,原设置保留。"))
+ return
+ }
+ ctx.SendChain(message.Text("成功设置为", isone))
+ }
+}
diff --git a/third_party/zbputils/driver/funcall.go b/third_party/zbputils/driver/funcall.go
new file mode 100644
index 0000000000..73ccae6a00
--- /dev/null
+++ b/third_party/zbputils/driver/funcall.go
@@ -0,0 +1,184 @@
+// Package driver 函数调用式驱动, 用于 gocqzbp
+package driver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "runtime/debug"
+ "strings"
+ "sync/atomic"
+
+ log "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/utils/helper"
+)
+
+var (
+ nullResponse = zero.APIResponse{}
+)
+
+// MSG 消息Map
+type MSG = map[string]interface{}
+
+// Caller ...
+type Caller interface {
+ // Call specific API
+ Call(action string, para string) MSG
+}
+
+// Event ...
+type Event interface {
+ // JSONBytes return bytes of json by lazy marshalling.
+ JSONBytes() []byte
+ // JSONBytes return raw event msg.
+ RawMSG() MSG
+}
+
+// CQBot ...
+type CQBot interface {
+ // OnEventPush 注册事件上报函数
+ OnEventPush(func(e Event))
+}
+
+// FCClient Funcall Client
+type FCClient struct {
+ seq uint64
+ newcaller func(CQBot) Caller
+ caller Caller
+ selfID int64
+ handler func([]byte, zero.APICaller)
+ init func(*FCClient)
+ name string
+}
+
+var fccs = make(map[string]*FCClient)
+
+// NewFuncallClient ...
+func NewFuncallClient(name string, newcaller func(CQBot) Caller, init func(*FCClient)) *FCClient {
+ fcc, ok := fccs[name]
+ if ok {
+ return fcc
+ }
+ fcc = new(FCClient)
+ fcc.name = name
+ fcc.newcaller = newcaller
+ fcc.init = init
+ fccs[name] = fcc
+ return fcc
+}
+
+// RegisterServer 传入注册 CQBot 函数
+// 如 go-cq 的 servers.RegisterCustom(name string, proc func(*coolq.CQBot))
+func RegisterServer(r func(string, func(CQBot))) {
+ r("funcall", runFuncall)
+}
+
+// Connect 连接服务端
+func (f *FCClient) Connect() {
+ rsp, err := f.CallAPI(context.TODO(), zero.APIRequest{
+ Action: "get_login_info",
+ Params: nil,
+ })
+ if err == nil {
+ f.selfID = rsp.Data.Get("user_id").Int()
+ zero.APICallers.Store(f.selfID, f) // 添加Caller到 APICaller list...
+ log.Infoln("连接funcall对端成功")
+ } else {
+ log.Warnln("连接funcall对端失败: ", err)
+ }
+}
+
+// Listen 开始监听事件
+func (f *FCClient) Listen(handler func([]byte, zero.APICaller)) {
+ f.handler = handler
+}
+
+// CallAPI 发送请求
+func (f *FCClient) CallAPI(_ context.Context, req zero.APIRequest) (zero.APIResponse, error) {
+ req.Echo = f.nextSeq()
+ rsp, err := f.handleRequest(&req)
+ log.Debug("向服务器发送请求: ", req)
+ return *rsp, err
+}
+
+// SelfID 获得 bot qq 号
+func (f *FCClient) SelfID() int64 {
+ return f.selfID
+}
+
+func (f *FCClient) nextSeq() uint64 {
+ return atomic.AddUint64(&f.seq, 1)
+}
+
+// runFuncall 运行经由函数调用的事件通信接口
+func runFuncall(b CQBot) {
+ for n, s := range fccs {
+ s.caller = s.newcaller(b)
+ b.OnEventPush(s.onBotPushEvent)
+ s.init(s)
+ log.Infoln("CQ funcall 服务器", n, "已启动")
+ }
+}
+
+func (f *FCClient) handleRequest(req *zero.APIRequest) (r *zero.APIResponse, err error) {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Errorf("处置funcall插件%s的命令时发生无法恢复的异常: %v\n%s", f.name, err, debug.Stack())
+ }
+ }()
+ t := strings.TrimSuffix(req.Action, "_async")
+ var p []byte
+ p, err = json.Marshal(req.Params)
+ if err != nil {
+ log.Errorf("funcall插件%s序列化参数失败: %v\n", f.name, err)
+ return nil, err
+ }
+ log.Debugf("funcall插件%s接收到API调用: %v 参数: %v", f.name, t, helper.BytesToString(p))
+ ret := f.caller.Call(t, helper.BytesToString(p))
+ if req.Echo > 0 { // 存在echo字段,是api调用的返回
+ buffer := new(bytes.Buffer)
+ err = json.NewEncoder(buffer).Encode(MSG{"data": ret["data"]})
+ if err == nil {
+ data := gjson.Parse(helper.BytesToString(buffer.Bytes()))
+ var s, m, w string
+ var c int64
+ if ret["status"] != nil {
+ s = ret["status"].(string)
+ }
+ if ret["msg"] != nil {
+ m = ret["msg"].(string)
+ }
+ if ret["wording"] != nil {
+ w = ret["wording"].(string)
+ }
+ if ret["retcode"] != nil {
+ c = int64(ret["retcode"].(int))
+ }
+ r = &zero.APIResponse{ // 发送api调用响应
+ Status: s,
+ Data: data.Get("data"),
+ Message: m,
+ Wording: w,
+ RetCode: c,
+ Echo: req.Echo,
+ }
+ log.Debug("接收到API调用返回: ", r)
+ }
+ return
+ }
+ return &nullResponse, errors.New("null echo response")
+}
+
+func (f *FCClient) onBotPushEvent(e Event) {
+ log.Debugf("向funcall插件%s推送Event: %s", f.name, e.JSONBytes())
+ rsp := e.RawMSG()
+ if m, ok := rsp["meta_event_type"]; ok && m != nil && m.(string) != "heartbeat" || !ok { // 忽略心跳事件
+ payload := e.JSONBytes()
+ log.Debug("接收到事件: ", helper.BytesToString(payload))
+ go f.handler(payload, f)
+ }
+}
diff --git a/third_party/zbputils/go.mod b/third_party/zbputils/go.mod
new file mode 100644
index 0000000000..dbcb04fbe7
--- /dev/null
+++ b/third_party/zbputils/go.mod
@@ -0,0 +1,116 @@
+module github.com/FloatTech/zbputils
+
+go 1.26.1
+
+require (
+ github.com/FloatTech/ZeroBot-Plugin-Webui v1.0.1-0.20230412164529-cfe878675931
+ github.com/FloatTech/floatbox v0.0.0-20251002074805-f95cbc7edb31
+ github.com/FloatTech/gg v1.1.4-0.20260404155637-671db2feeebb
+ github.com/FloatTech/rendercard v0.2.4-0.20260404155919-1fede0bcd22b
+ github.com/FloatTech/sqlite v1.7.2
+ github.com/FloatTech/zbpctrl v1.7.1
+ github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7
+ github.com/RomiChan/websocket v1.4.3-0.20251002072000-d3eb41798438
+ github.com/fumiama/cron v1.3.0
+ github.com/fumiama/deepinfra v0.0.0-20260425085418-d2615e46ea66
+ github.com/fumiama/go-base16384 v1.7.1
+ github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3
+ github.com/gin-gonic/gin v1.11.0
+ github.com/go-playground/assert/v2 v2.2.0
+ github.com/go-playground/validator/v10 v10.29.0
+ github.com/google/uuid v1.6.0
+ github.com/patrickmn/go-cache v2.1.0+incompatible
+ github.com/sirupsen/logrus v1.9.4
+ github.com/swaggo/files v1.0.1
+ github.com/swaggo/gin-swagger v1.6.1
+ github.com/swaggo/swag v1.16.6
+ github.com/tidwall/gjson v1.18.0
+ github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260117102541-393033a35adb
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/FloatTech/ttl v0.0.0-20250224045156-012b1463287d // indirect
+ github.com/KyleBanks/depth v1.2.1 // indirect
+ github.com/bytedance/gopkg v0.1.3 // indirect
+ github.com/bytedance/sonic v1.14.2 // indirect
+ github.com/bytedance/sonic/loader v0.4.0 // indirect
+ github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360 // indirect
+ github.com/disintegration/imaging v1.6.2 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 // indirect
+ github.com/fumiama/go-registry v0.2.7 // indirect
+ github.com/fumiama/go-simple-protobuf v0.2.0 // indirect
+ github.com/fumiama/gofastTEA v0.1.3 // indirect
+ github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a // indirect
+ github.com/fumiama/imgsz v0.0.4 // indirect
+ github.com/fumiama/orbyte v0.0.0-20251002065953-3bb358367eb5 // indirect
+ github.com/fumiama/terasu v1.0.2 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+ github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/go-openapi/jsonpointer v0.22.4 // indirect
+ github.com/go-openapi/jsonreference v0.21.4 // indirect
+ github.com/go-openapi/spec v0.22.2 // indirect
+ github.com/go-openapi/swag/conv v0.25.4 // indirect
+ github.com/go-openapi/swag/jsonname v0.25.4 // indirect
+ github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
+ github.com/go-openapi/swag/loading v0.25.4 // indirect
+ github.com/go-openapi/swag/stringutils v0.25.4 // indirect
+ github.com/go-openapi/swag/typeutils v0.25.4 // indirect
+ github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/goccy/go-yaml v1.19.1 // indirect
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
+ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.58.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/tidwall/match v1.2.0 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.3.1 // indirect
+ go.uber.org/mock v0.6.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/arch v0.23.0 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/image v0.38.0 // indirect
+ golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/net v0.50.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ modernc.org/libc v1.67.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.41.0 // indirect
+)
+
+replace modernc.org/sqlite => github.com/fumiama/sqlite3 v1.29.10-simp
+
+replace modernc.org/libc => github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5
+
+// Keep standalone development aligned with the application patch set.
+replace github.com/FloatTech/sqlite => ../sqlite
+
+replace github.com/FloatTech/zbpctrl => ../zbpctrl
+
+replace github.com/fumiama/go-onebot-agent => ../go-onebot-agent
+
+replace github.com/fumiama/go-registry => ../go-registry
diff --git a/third_party/zbputils/go.sum b/third_party/zbputils/go.sum
new file mode 100644
index 0000000000..a8adb4d5fc
--- /dev/null
+++ b/third_party/zbputils/go.sum
@@ -0,0 +1,273 @@
+github.com/FloatTech/ZeroBot-Plugin-Webui v1.0.1-0.20230412164529-cfe878675931 h1:jTc1mb2sG1krnxpgD75SMojeOMIl6bGX5t3YGIRKkHI=
+github.com/FloatTech/ZeroBot-Plugin-Webui v1.0.1-0.20230412164529-cfe878675931/go.mod h1:Se2A9vnC4eSYF7vR2LNSayV7rvbFcpgVs1ZN4sEYMyg=
+github.com/FloatTech/floatbox v0.0.0-20251002074805-f95cbc7edb31 h1:2K+/M64ixD1Pg5hr00Nbxr7GoWQOgahvpmp1pAMnrYc=
+github.com/FloatTech/floatbox v0.0.0-20251002074805-f95cbc7edb31/go.mod h1:kf+Ywc2lk8PLdg3RX0vrUmFLPO6k+23MFmt4GviV8C0=
+github.com/FloatTech/gg v1.1.4-0.20260404155637-671db2feeebb h1:9CxMctre5XIsFHNQTzZvdg5kLub5QZEsrV6E5rSKKfs=
+github.com/FloatTech/gg v1.1.4-0.20260404155637-671db2feeebb/go.mod h1:nweVlCnDkEXFVRq6t8gkzoUgD5uVI/5yAjBFfQDdE3Y=
+github.com/FloatTech/rendercard v0.2.4-0.20260404155919-1fede0bcd22b h1:xo68n707/B8tdsqQz3FB5YaBXd/X5FUeQqUgXKdtOPU=
+github.com/FloatTech/rendercard v0.2.4-0.20260404155919-1fede0bcd22b/go.mod h1:uiJSiGWuED77DtPypOr3ewNfqZbwC2A+ZnlAPqvxK/E=
+github.com/FloatTech/sqlite v1.7.2 h1:b8COegNLSzofzOyARsVwSbz9OOzWEa8IElsTlx1TBLw=
+github.com/FloatTech/sqlite v1.7.2/go.mod h1:/4tzfCGhrZnnjC1U8vcfwGQeF6eR649fhOsS3+Le0+s=
+github.com/FloatTech/ttl v0.0.0-20250224045156-012b1463287d h1:mUQ/c3wXKsUGa4Sg9DBy01APXKB68PmobhxOyaJI7lY=
+github.com/FloatTech/ttl v0.0.0-20250224045156-012b1463287d/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs=
+github.com/FloatTech/zbpctrl v1.7.1 h1:0yPEmCForhyMbnhTckmjDUFFDZgQp1RjO2bVF4ZVqOs=
+github.com/FloatTech/zbpctrl v1.7.1/go.mod h1:xmM4dSwHA02Gei3ogCRiG+RTrw/7Z69PfrN5NYf8BPE=
+github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
+github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
+github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7 h1:S/ferNiehVjNaBMNNBxUjLtVmP/YWD6Yh79RfPv4ehU=
+github.com/RomiChan/syncx v0.0.0-20240418144900-b7402ffdebc7/go.mod h1:vD7Ra3Q9onRtojoY5sMCLQ7JBgjUsrXDnDKyFxqpf9w=
+github.com/RomiChan/websocket v1.4.3-0.20251002072000-d3eb41798438 h1:I0bdwHZ+2DY45b39xPoTD2u+Z8zhvBuu9aZfjMZeiZM=
+github.com/RomiChan/websocket v1.4.3-0.20251002072000-d3eb41798438/go.mod h1:GO+9i5UYB4BuZEel6BfGx7O1u3ggwgZWUnGxPATUoTE=
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
+github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
+github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
+github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360 h1:SvD9vQN+3r0wskoSrQ7IOyDmOtRIXhT3rlnf819r/bY=
+github.com/corona10/goimagehash v1.1.1-0.20240121134706-d8115886f360/go.mod h1:PFDP0Q0oYvEuipJHMSbeK8tjbuRzkQqjPDYm3n71ITA=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4 h1:BBade+JlV/f7JstZ4pitd4tHhpN+w+6I+LyOS7B4fyU=
+github.com/ericpauley/go-quantize v0.0.0-20200331213906-ae555eb2afa4/go.mod h1:H7chHJglrhPPzetLdzBleF8d22WYOv7UM/lEKYiwlKM=
+github.com/fumiama/cron v1.3.0 h1:ZWlwuexF+HQHl3cYytEE5HNwD99q+3vNZF1GrEiXCFo=
+github.com/fumiama/cron v1.3.0/go.mod h1:bz5Izvgi/xEUI8tlBN8BI2jr9Moo8N4or0KV8xXuPDY=
+github.com/fumiama/deepinfra v0.0.0-20260425085418-d2615e46ea66 h1:tHvzebYMYwPHeIYS483WDe3tNPpQCan47XbuPtC8kjM=
+github.com/fumiama/deepinfra v0.0.0-20260425085418-d2615e46ea66/go.mod h1:uqsWK/GM9OvKV0pXZOQB63rWugBbiXInY8E1JoRKhkg=
+github.com/fumiama/go-base16384 v1.7.1 h1:1P1x6FWRvd7PtbH4idDAGWAjKKcVxggxlROYKRXbw58=
+github.com/fumiama/go-base16384 v1.7.1/go.mod h1:OEn+947GV5gsbTAnyuUW/SrfxJYUdYupSIQXOuGOcXM=
+github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3 h1:zM9kIr3e+J8Am8YxSgdmqzJ7ND7VitifNr3EEYOA0kY=
+github.com/fumiama/go-onebot-agent v0.0.0-20260425093914-9c92a03776e3/go.mod h1:3+CA6jImwBTvjKpJKYHshfdaM3uHdWLdi7/kBCONWKk=
+github.com/fumiama/go-registry v0.2.7 h1:tLEqgEpsiybQMqBv0dLHm5leia/z1DhajMupwnOHeNs=
+github.com/fumiama/go-registry v0.2.7/go.mod h1:m+wp5fF8dYgVoFkBPZl+vlK90loymaJE0JCtocVQLEs=
+github.com/fumiama/go-simple-protobuf v0.2.0 h1:ACyN1MAlu7pDR3EszWgzUeNP+IRsSHwH6V9JCJA5R5o=
+github.com/fumiama/go-simple-protobuf v0.2.0/go.mod h1:5yYNapXq1tQMOZg9bOIVhQlZk9pQqpuFIO4DZLbsdy4=
+github.com/fumiama/gofastTEA v0.1.3 h1:fxOi2D66knV6QN170hb59YiqxPhjlgizvBw+o0OjxUA=
+github.com/fumiama/gofastTEA v0.1.3/go.mod h1:RIdbYZyB4MbH6ZBlPymRaXn3cD6SedlCu5W/HHfMPBk=
+github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a h1:UXuWHMKoZqeN4pjsGcKG7WQ0lmHI4LxQyYce5Qw/lP4=
+github.com/fumiama/gozel v0.0.0-20260329105205-a95fde52433a/go.mod h1:tdrkF0OinEHQaDX0bFu8+MLKdTfrShyvwjqDZVzvQqY=
+github.com/fumiama/imgsz v0.0.4 h1:Lsasu2hdSSFS+vnD+nvR1UkiRMK7hcpyYCC0FzgSMFI=
+github.com/fumiama/imgsz v0.0.4/go.mod h1:bISOQVTlw9sRytPwe8ir7tAaEmyz9hSNj9n8mXMBG0E=
+github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5 h1:jDxsIupsT84A6WHcs6kWbst+KqrRQ8/o0VyoFMnbBOA=
+github.com/fumiama/libc v0.0.0-20240530081950-6f6d8586b5c5/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE=
+github.com/fumiama/orbyte v0.0.0-20251002065953-3bb358367eb5 h1:j9o0XVvdAeLwrBYMnh0SerrMc9CgNU6AGszbsvFzoc0=
+github.com/fumiama/orbyte v0.0.0-20251002065953-3bb358367eb5/go.mod h1:FOjdw7KdCbK2eH3gRPhwFNCoXKpu9sN5vPH4El/8e0c=
+github.com/fumiama/sqlite3 v1.29.10-simp h1:c5y3uKyU0q9t0/SyfynzYyuslQ5zP+5CD8e0yYY554A=
+github.com/fumiama/sqlite3 v1.29.10-simp/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
+github.com/fumiama/terasu v1.0.2 h1:Dxj2vPIgLHyeMlBd61xYHx5Jg8v7k24yg7taNLlK8/8=
+github.com/fumiama/terasu v1.0.2/go.mod h1:1eHzpL/GJVcOnqEkh1vUbKu2zo6gojSuueUNJ9yHJE0=
+github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
+github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
+github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
+github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
+github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
+github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
+github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
+github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
+github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
+github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
+github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
+github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
+github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
+github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
+github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
+github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
+github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
+github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
+github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
+github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
+github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
+github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
+github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
+github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
+github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
+github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
+github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
+github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
+github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
+github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
+github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
+github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
+github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
+github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
+github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
+github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260117102541-393033a35adb h1:pwmyrnnMzb0WygAwUgXoZckzJkusK4WSaXxXqsIJYmU=
+github.com/wdvxdr1123/ZeroBot v1.8.3-0.20260117102541-393033a35adb/go.mod h1:kCLja2sXXgbBTsEOyBNCuT4z9tI+URQ2y0q/GGXprzU=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
+golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
+golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk=
+modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs=
+modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/third_party/zbputils/img/pool/sender.go b/third_party/zbputils/img/pool/sender.go
new file mode 100644
index 0000000000..22cbd9bff4
--- /dev/null
+++ b/third_party/zbputils/img/pool/sender.go
@@ -0,0 +1,34 @@
+// Package pool only for backports
+package pool
+
+import (
+ "errors"
+
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/floatbox/file"
+
+ "github.com/FloatTech/zbputils/ctxext"
+)
+
+// SendImageFromPool ...
+func SendImageFromPool(
+ imgpath string, genimg func(string) error, send ctxext.NoCtxSendMsg,
+) error {
+ if file.IsNotExist(imgpath) {
+ err := genimg(imgpath)
+ if err != nil {
+ return err
+ }
+ }
+ // 发送图片
+ img := message.Image(file.BOTPATH + "/" + imgpath)
+ id := send(message.Message{img})
+ if id == 0 {
+ id = send(message.Message{img.Add("cache", "0")})
+ if id == 0 {
+ return errors.New("图片发送失败, 可能被风控了~")
+ }
+ }
+ return nil
+}
diff --git a/third_party/zbputils/img/text/font.go b/third_party/zbputils/img/text/font.go
new file mode 100644
index 0000000000..b896f299a3
--- /dev/null
+++ b/third_party/zbputils/img/text/font.go
@@ -0,0 +1,42 @@
+package text
+
+const (
+ // FontPath 通用字体路径
+ FontPath = "data/Font/"
+ // FontFile 苹方字体
+ FontFile = FontPath + "regular.ttf"
+ // BoldFontFile 粗体苹方字体
+ BoldFontFile = FontPath + "regular-bold.ttf"
+ // SakuraFontFile 樱酥体
+ SakuraFontFile = FontPath + "sakura.ttf"
+ // ConsolasFontFile ...
+ ConsolasFontFile = FontPath + "consolas.ttf"
+ // SyumatuFontFile 少女终末旅行字体
+ SyumatuFontFile = FontPath + "zhongmoti.ttf"
+ // NisiFontFile 少女终末旅行字体变体
+ NisiFontFile = FontPath + "Nisi-Regular.ttf"
+ // VioletEvergardenFontFile 紫罗兰永恒花园字体 https://github.com/kagurazakayashi/EvergardenConverter
+ VioletEvergardenFontFile = FontPath + "VioletEvergardenFont.ttf"
+ // GlowSansFontFile 未来荧黑 https://github.com/welai/glow-sans
+ GlowSansFontFile = FontPath + "GlowSansSC-Normal-ExtraBold.ttf"
+ // ImpactFontFile ...
+ ImpactFontFile = FontPath + "Impact.ttf"
+ // HachiMaruPopRegularFontFile ...
+ HachiMaruPopRegularFontFile = FontPath + "HachiMaruPop-Regular.ttf"
+ // GugiRegularFontFile ...
+ GugiRegularFontFile = FontPath + "Gugi-Regular.ttf"
+ // MaokenFontFile 猫啃珠圆体
+ MaokenFontFile = FontPath + "MaokenZhuyuanTi.ttf"
+ // BaotuXiaobaiFontFile 包图小白体
+ BaotuXiaobaiFontFile = FontPath + "BaotuXiaobaiTi.ttf"
+ // QiantuXiaotuFontFile 千图小兔体
+ QiantuXiaotuFontFile = FontPath + "QiantuXiaotuTi.ttf"
+ // GNUUnifontFontFile GNU Unifont https://unifoundry.com/unifont/index.html
+ GNUUnifontFontFile = FontPath + "unifont-15.0.06.ttf"
+ // ArialFontFile ...
+ ArialFontFile = FontPath + "arial.ttf"
+ // TorusFontFile ...
+ TorusFontFile = FontPath + "Torus-Regular.otf"
+ // YurukaFontFile ...
+ YurukaFontFile = FontPath + "FOT-Yuruka Std.ttf"
+)
diff --git a/third_party/zbputils/img/text/render.go b/third_party/zbputils/img/text/render.go
new file mode 100644
index 0000000000..e80eaca3ca
--- /dev/null
+++ b/third_party/zbputils/img/text/render.go
@@ -0,0 +1,42 @@
+// Package text 文字转图片
+package text
+
+import (
+ "image"
+ "os"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/FloatTech/floatbox/file"
+ "github.com/FloatTech/gg/factory"
+)
+
+// 加载数据库
+func init() {
+ _ = os.MkdirAll(FontPath, 0755)
+}
+
+// RenderToBase64 文字转base64
+func RenderToBase64(text, font string, width, fontSize int) (base64Bytes []byte, err error) {
+ im, err := Render(text, font, width, fontSize)
+ if err != nil {
+ log.Println("[txt2img]", err)
+ return nil, err
+ }
+ base64Bytes, err = factory.ToBase64(im)
+ if err != nil {
+ log.Println("[txt2img]", err)
+ return nil, err
+ }
+ return
+}
+
+// Render 文字转图片 width 是图片宽度
+func Render(text, font string, width, fontSize int) (txtPic image.Image, err error) {
+ data, err := file.GetLazyData(font, "data/control/stor.spb", true)
+ if err != nil {
+ return
+ }
+
+ return factory.RenderTextWith(text, data, width, fontSize)
+}
diff --git a/third_party/zbputils/job/main.go b/third_party/zbputils/job/main.go
new file mode 100644
index 0000000000..33ec6a7bfa
--- /dev/null
+++ b/third_party/zbputils/job/main.go
@@ -0,0 +1,627 @@
+// Package job 定时指令触发器
+package job
+
+import (
+ "encoding/json"
+ "errors"
+ "hash/crc64"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ sql "github.com/FloatTech/sqlite"
+ ctrl "github.com/FloatTech/zbpctrl"
+ "github.com/fumiama/cron"
+ "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/floatbox/binary"
+ "github.com/FloatTech/floatbox/process"
+ "github.com/FloatTech/floatbox/web"
+
+ "github.com/FloatTech/zbputils/control"
+ "github.com/FloatTech/zbputils/vevent"
+)
+
+var (
+ entries = map[int64]cron.EntryID{} // id entryid
+ matchers = map[int64]*zero.Matcher{}
+ mu sync.RWMutex
+ en = control.Register("job", &ctrl.Options[*zero.Ctx]{
+ DisableOnDefault: false,
+ Brief: "定时指令触发器",
+ Help: "- 记录以\"完全匹配关键词\"触发的指令\n- 取消以\"完全匹配关键词\"触发的指令\n- 记录在\"cron\"触发的(别名xxx的)指令\n- 取消在\"cron\"触发的指令\n- 查看所有触发指令\n- 查看在\"cron\"触发的指令\n- 查看以\"完全匹配关键词\"触发的指令\n- 注入指令结果:任意指令\n- 执行指令:任意指令\n- [我|大家|有人][说|问][正则表达式]你[答|说|做|执行][模版]\n- [查看|看看][我|大家|有人][说|问][正则表达式]\n- 删除[大家|有人|我][说|问|让你做|让你执行][正则表达式]",
+ PrivateDataFolder: "job",
+ })
+)
+
+func init() {
+ err := db.Open(time.Hour)
+ if err != nil {
+ panic(err)
+ }
+ go func() {
+ process.GlobalInitMutex.Lock()
+ process.SleepAbout1sTo2s()
+ zero.RangeBot(func(id int64, _ *zero.Ctx) bool {
+ ids := strconv.FormatInt(id, 36)
+ c := &cmd{}
+ err := db.Create(ids, c)
+ logrus.Debugln("[job]创建表", ids)
+ if err != nil {
+ panic(err)
+ }
+ err = db.FindFor(ids, c, "", func() error {
+ mu.Lock()
+ defer mu.Unlock()
+ if strings.HasPrefix(c.Cron, "fm:") {
+ m := en.OnFullMatch(c.Cron[3:] /* skip fm: */).SetBlock(true)
+ m.Handle(generalhandler(c.Cmd))
+ matchers[c.ID] = (*zero.Matcher)(m)
+ return nil
+ }
+ if strings.HasPrefix(c.Cron, "sm:") {
+ m := en.OnFullMatch(c.Cron[3:] /* skip sm: */).SetBlock(true)
+ h, err := superuserhandler(binary.StringToBytes(c.Cmd))
+ if err != nil {
+ return nil
+ }
+ m.Handle(h)
+ matchers[c.ID] = (*zero.Matcher)(m)
+ return nil
+ }
+ if strings.HasPrefix(c.Cron, "rm:") || strings.HasPrefix(c.Cron, "im:") {
+ patttens := strings.SplitN(c.Cron, ":", 3)
+ if len(patttens) != 3 {
+ return errors.New("error regex match global pattern")
+ }
+ grp, err := strconv.ParseInt(patttens[1], 36, 64)
+ if err != nil {
+ return err
+ }
+ if global.group[grp] == nil {
+ global.group[grp] = new(regexGroup)
+ }
+ tmpl := make([]byte, len(c.Cmd))
+ copy(tmpl, c.Cmd)
+ global.group[grp].All = append(global.group[grp].All, inst{
+ regex: regexp.MustCompile(transformPattern(patttens[2])),
+ Pattern: patttens[2],
+ Template: binary.BytesToString(tmpl),
+ IsInject: patttens[0][0] == 'i',
+ })
+ return nil
+ }
+ if strings.HasPrefix(c.Cron, "rp:") || strings.HasPrefix(c.Cron, "ip:") {
+ patttens := strings.SplitN(c.Cron, ":", 4)
+ if len(patttens) != 4 {
+ return errors.New("error regex match private pattern")
+ }
+ uid, err := strconv.ParseInt(patttens[1], 36, 64)
+ if err != nil {
+ return err
+ }
+ gid, err := strconv.ParseInt(patttens[2], 36, 64)
+ if err != nil {
+ return err
+ }
+ if global.group[gid] == nil {
+ global.group[gid] = new(regexGroup)
+ }
+ tmpl := make([]byte, len(c.Cmd))
+ copy(tmpl, c.Cmd)
+ if global.group[gid].Private == nil {
+ global.group[gid].Private = make(map[int64][]inst)
+ }
+ global.group[gid].Private[uid] = append(global.group[gid].Private[uid], inst{
+ regex: regexp.MustCompile(transformPattern(patttens[3])),
+ Pattern: patttens[3],
+ Template: binary.BytesToString(tmpl),
+ IsInject: patttens[0][0] == 'i',
+ })
+ return nil
+ }
+ cr, _, _ := strings.Cut(c.Cron, ":->")
+ eid, err := process.CronTab.AddFunc(cr, inject(zero.GetBot(id), []byte(c.Cmd)))
+ if err != nil {
+ return err
+ }
+ entries[c.ID] = eid
+ return nil
+ })
+ if err != nil && err != sql.ErrNullResult {
+ panic(err)
+ }
+ return true
+ })
+ logrus.Infoln("[job]本地环回初始化完成")
+ process.GlobalInitMutex.Unlock()
+ }()
+ en.OnRegex(`^记录在"(.*)"触发的(别名.*的)?指令$`, zero.UserOrGrpAdmin, isfirstregmatchnotnil, logevent).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ cron := ctx.State["regex_matched"].([]string)[1]
+ alias := ctx.State["regex_matched"].([]string)[2]
+ command := ctx.State["job_raw_event"].(string)
+ if alias != "" {
+ cron += ":->" + alias[len("别名"):len(alias)-len("的")]
+ }
+ c := &cmd{
+ ID: idof(cron, command),
+ Cron: cron,
+ Cmd: command,
+ }
+ err := addcmd(ctx, c)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("成功!"))
+ })
+ en.OnRegex(`^记录以"(.*)"触发的指令$`, zero.SuperUserPermission, isfirstregmatchnotnil, logevent).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ cron := "fm:" + ctx.State["regex_matched"].([]string)[1]
+ command := ctx.State["job_new_event"].(gjson.Result).Get("message").Raw
+ logrus.Debugln("[job] get cmd:", command)
+ c := &cmd{
+ ID: idof(cron, command),
+ Cron: cron,
+ Cmd: command,
+ }
+ err := registercmd(ctx.Event.SelfID, c)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("成功!"))
+ })
+ en.OnRegex(`^记录以"(.*)"触发的代表我执行的指令$`, zero.SuperUserPermission, isfirstregmatchnotnil, logevent).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ cron := "sm:" + ctx.State["regex_matched"].([]string)[1]
+ command := ctx.State["job_raw_event"].(string)
+ logrus.Debugln("[job] get cmd:", command)
+ c := &cmd{
+ ID: idof(cron, command),
+ Cron: cron,
+ Cmd: command,
+ }
+ err := registercmd(ctx.Event.SelfID, c)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("成功!"))
+ })
+ en.OnRegex(`^取消在"(.*)"触发的指令$`, zero.UserOrGrpAdmin, isfirstregmatchnotnil).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ cron := ctx.State["regex_matched"].([]string)[1]
+ err := rmcmd(ctx.Event.SelfID, ctx.Event.UserID, cron, zero.AdminPermission(ctx))
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("成功!"))
+ })
+ en.OnRegex(`^取消以"(.*)"触发的(代表我执行的)?指令$`, zero.SuperUserPermission, isfirstregmatchnotnil).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ issu := ctx.State["regex_matched"].([]string)[2] != ""
+ cron := ""
+ if issu {
+ cron = "sm:"
+ } else {
+ cron = "fm:"
+ }
+ cron += ctx.State["regex_matched"].([]string)[1]
+ err := delcmd(ctx.Event.SelfID, cron)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("成功!"))
+ })
+ en.OnFullMatch("查看所有触发指令", zero.SuperUserPermission).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ c := &cmd{}
+ ids := strconv.FormatInt(ctx.Event.SelfID, 36)
+ mu.Lock()
+ defer mu.Unlock()
+ n, err := db.Count(ids)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ lst := make([]string, 0, n+2)
+ q := ""
+ args := []any{}
+ if ctx.Event.GroupID != 0 {
+ grp := strconv.FormatInt(ctx.Event.GroupID, 36)
+ q = "WHERE cron LIKE 'fm:%' OR cron LIKE 'sm:%' OR cron LIKE ? OR cron LIKE ? "
+ args = []any{"_m:" + grp + ":%", "_p:" + grp + ":?:%"}
+ lst = append(lst, "在本群的触发指令]\n")
+ } else {
+ lst = append(lst, "全部触发指令]\n")
+ }
+ q += "GROUP BY cron"
+ err = db.FindFor(ids, c, q, func() error {
+ lst = append(lst, c.Cron+"\n")
+ return nil
+ }, args...)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ lst = append(lst, "[END")
+ ctx.SendChain(message.Text(lst))
+ })
+ en.OnRegex(`^查看在"(.*)"触发的指令$`, zero.SuperUserPermission, isfirstregmatchnotnil).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ c := &cmd{}
+ ids := strconv.FormatInt(ctx.Event.SelfID, 36)
+ cron := ctx.State["regex_matched"].([]string)[1]
+ mu.Lock()
+ defer mu.Unlock()
+ n, err := db.Count(ids)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ lst := make([]string, 0, n)
+ err = db.FindFor(ids, c, "WHERE cron = ?", func() error {
+ lst = append(lst, c.Cmd+"\n")
+ return nil
+ }, cron)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text(lst))
+ })
+ en.OnRegex(`^查看以"(.*)"触发的(代表我执行的)?指令$`, zero.SuperUserPermission, isfirstregmatchnotnil).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ c := &cmd{}
+ ids := strconv.FormatInt(ctx.Event.SelfID, 36)
+ issu := ctx.State["regex_matched"].([]string)[2] != ""
+ cron := ""
+ if issu {
+ cron = "sm:"
+ } else {
+ cron = "fm:"
+ }
+ cron += ctx.State["regex_matched"].([]string)[1]
+ mu.Lock()
+ defer mu.Unlock()
+ n, err := db.Count(ids)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ lst := make([]string, 0, n)
+ err = db.FindFor(ids, c, "WHERE cron = ?", func() error {
+ lst = append(lst, c.Cmd+"\n")
+ return nil
+ }, cron)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text(lst))
+ })
+ en.OnPrefix("执行指令:", zero.UserOrGrpAdmin, func(ctx *zero.Ctx) bool {
+ return ctx.State["args"].(string) != ""
+ }, parseArgs).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ ev := strings.ReplaceAll(ctx.Event.RawEvent.Raw, "执行指令:", "")
+ logrus.Debugln("[job] inject:", ev)
+ inject(ctx, binary.StringToBytes(ev))()
+ })
+ en.OnPrefix("注入指令结果:", zero.UserOrGrpAdmin, func(ctx *zero.Ctx) bool {
+ return ctx.State["args"].(string) != ""
+ }, parseArgs).SetBlock(true).Handle(func(ctx *zero.Ctx) {
+ hook := vevent.NewAPICallerReturnHook(ctx, func(_ zero.APIRequest, rsp zero.APIResponse, err error) {
+ if err == nil {
+ logrus.Debugln("[job] CallerHook returned")
+ id := message.NewMessageIDFromInteger(rsp.Data.Get("message_id").Int())
+ if id.ID() == 0 {
+ ctx.SendChain(message.Text("ERROR:未获取到返回结果"))
+ return
+ }
+ msg := ctx.GetMessage(id)
+ ctx.Event.NativeMessage = json.RawMessage("\"" + msg.Elements.String() + "\"")
+ ctx.Event.RawMessageID = json.RawMessage(msg.MessageID.String())
+ ctx.Event.RawMessage = msg.Elements.String()
+ process.SleepAbout1sTo2s() // 防止风控
+ ctx.Event.Time = time.Now().Unix()
+ ctx.DeleteMessage(id)
+ vev, cl := binary.OpenWriterF(func(w *binary.Writer) {
+ err = json.NewEncoder(w).Encode(ctx.Event)
+ })
+ if err != nil {
+ cl()
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ logrus.Debugln("[job] inject:", binary.BytesToString(vev))
+ defer func() {
+ _ = recover()
+ cl()
+ }()
+ ctx.Echo(vev)
+ }
+ })
+ hookedctx := *ctx //nolint: govet
+ vevent.HookCtxCaller(&hookedctx, hook)
+ hookedctx.Echo(binary.StringToBytes(strings.ReplaceAll(ctx.Event.RawEvent.Raw, "注入指令结果:", "")))
+ })
+}
+
+func isfirstregmatchnotnil(ctx *zero.Ctx) bool {
+ return ctx.State["regex_matched"].([]string)[1] != ""
+}
+
+func inject(ctx *zero.Ctx, response []byte) func() {
+ return func() { ctx.Echo(response) }
+}
+
+func idof(cron, cmd string) int64 {
+ return int64(crc64.Checksum(binary.StringToBytes(cron+cmd), crc64.MakeTable(crc64.ISO)))
+}
+
+func addcmd(ctx *zero.Ctx, c *cmd) error {
+ mu.Lock()
+ defer mu.Unlock()
+ cr, _, _ := strings.Cut(c.Cron, ":->")
+ eid, err := process.CronTab.AddFunc(cr, inject(ctx, []byte(c.Cmd)))
+ if err != nil {
+ return err
+ }
+ entries[c.ID] = eid
+ return db.Insert(strconv.FormatInt(ctx.Event.SelfID, 36), c)
+}
+
+func registercmd(bot int64, c *cmd) error {
+ mu.Lock()
+ defer mu.Unlock()
+ m := en.OnFullMatch(c.Cron[3:] /* skip fm: or sm: */).SetBlock(true)
+ if strings.HasPrefix(c.Cron, "sm:") {
+ h, err := superuserhandler(binary.StringToBytes(c.Cmd))
+ if err != nil {
+ return err
+ }
+ m.Handle(h)
+ } else {
+ m.Handle(generalhandler(c.Cmd))
+ }
+ matchers[c.ID] = (*zero.Matcher)(m)
+ return db.Insert(strconv.FormatInt(bot, 36), c)
+}
+
+func generalhandler(command string) zero.Handler {
+ cmdraw := make(json.RawMessage, len(command))
+ copy(cmdraw, command)
+ return func(ctx *zero.Ctx) {
+ ctx.Event.NativeMessage = cmdraw
+ ctx.Event.Time = time.Now().Unix()
+ var err error
+ vev, cl := binary.OpenWriterF(func(w *binary.Writer) {
+ err = json.NewEncoder(w).Encode(ctx.Event)
+ })
+ if err != nil {
+ cl()
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ logrus.Debugln("[job] inject:", binary.BytesToString(vev))
+ defer func() {
+ _ = recover()
+ cl()
+ }()
+ ctx.Echo(vev)
+ }
+}
+
+func superuserhandler(rsp []byte) (zero.Handler, error) {
+ e := &zero.Event{Sender: new(zero.User)}
+ err := json.Unmarshal(rsp, e)
+ if err != nil {
+ return nil, err
+ }
+ return func(ctx *zero.Ctx) {
+ ctx.Event.UserID = e.UserID
+ ctx.Event.RawMessage = e.RawMessage
+ ctx.Event.Sender = e.Sender
+ ctx.Event.NativeMessage = e.NativeMessage
+ vev, cl := binary.OpenWriterF(func(w *binary.Writer) {
+ err = json.NewEncoder(w).Encode(ctx.Event)
+ })
+ if err != nil {
+ cl()
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ logrus.Debugln("[job] inject:", binary.BytesToString(vev))
+ defer func() {
+ _ = recover()
+ cl()
+ }()
+ ctx.Echo(vev)
+ }, nil
+}
+
+func rmcmd(bot, caller int64, cron string, force bool) error {
+ c := &cmd{}
+ mu.Lock()
+ defer mu.Unlock()
+ bots := strconv.FormatInt(bot, 36)
+ e := new(zero.Event)
+ var delids []int64
+ err := db.FindFor(bots, c, "WHERE cron = ? OR cron LIKE ?", func() error {
+ err := json.Unmarshal(binary.StringToBytes(c.Cmd), e)
+ if err != nil {
+ return err
+ }
+ if !force && e.UserID != caller {
+ return nil
+ }
+ eid, ok := entries[c.ID]
+ if ok {
+ process.CronTab.Remove(eid)
+ delete(entries, c.ID)
+ delids = append(delids, c.ID)
+ }
+ return nil
+ }, cron, cron+":->%")
+ if err != nil {
+ return err
+ }
+ if len(delids) > 0 {
+ q, s := sql.QuerySet("WHERE id", "IN", delids)
+ return db.Del(bots, q, s...)
+ }
+ return nil
+}
+
+func delcmd(bot int64, cron string) error {
+ c := &cmd{}
+ mu.Lock()
+ defer mu.Unlock()
+ bots := strconv.FormatInt(bot, 36)
+ var delids []int64
+ err := db.FindFor(bots, c, "WHERE cron = ?", func() error {
+ m, ok := matchers[c.ID]
+ if ok {
+ m.Delete()
+ delete(matchers, c.ID)
+ delids = append(delids, c.ID)
+ }
+ return nil
+ }, cron)
+ if err != nil {
+ return err
+ }
+ if len(delids) > 0 {
+ q, s := sql.QuerySet("WHERE id", "IN", delids)
+ return db.Del(bots, q, s...)
+ }
+ return nil
+}
+
+func parseArgs(ctx *zero.Ctx) bool {
+ cmds := ctx.State["args"].(string)
+ if !strings.Contains(cmds, "?::") && !strings.Contains(cmds, "!::") {
+ return true
+ }
+ args := make(map[int]string)
+ for strings.Contains(ctx.Event.RawEvent.Raw, "?::") {
+ start := strings.Index(ctx.Event.RawEvent.Raw, "?::")
+ msgend := strings.Index(ctx.Event.RawEvent.Raw[start+3:], "::")
+ if msgend < 0 {
+ ctx.SendChain(message.Text("ERROR:找不到结束的::"))
+ return false
+ }
+ msgend += start + 3
+ numend := strings.Index(ctx.Event.RawEvent.Raw[msgend+2:], "!")
+ if numend <= 0 {
+ ctx.SendChain(message.Text("ERROR:找不到结束的!"))
+ return false
+ }
+ numend += msgend + 2
+ logrus.Debugln("[job]", start, msgend, numend)
+ msg := ctx.Event.RawEvent.Raw[start+3 : msgend]
+ arg, err := strconv.Atoi(ctx.Event.RawEvent.Raw[msgend+2 : numend])
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return false
+ }
+ arr, ok := args[arg]
+ if !ok {
+ var id message.ID
+ if msg == "" {
+ id = ctx.SendChain(message.At(ctx.Event.UserID), message.Text("请输入参数", arg))
+ } else {
+ id = ctx.SendChain(message.At(ctx.Event.UserID), message.Text("[", arg, "] ", msg))
+ }
+ select {
+ case <-time.After(time.Second * 120):
+ ctx.Send(message.ReplyWithMessage(id, message.Text("参数读取超时")))
+ if msg[0] != '?' {
+ return false
+ }
+ case c := <-zero.NewFutureEvent("message", 0, true, zero.CheckUser(ctx.Event.UserID)).Next():
+ args[arg] = c.Event.Message.String()
+ arr = args[arg]
+ process.SleepAbout1sTo2s()
+ ctx.SendChain(message.Reply(c.Event.MessageID), message.Text("已记录"))
+ process.SleepAbout1sTo2s()
+ }
+ }
+ ctx.Event.RawEvent.Raw = ctx.Event.RawEvent.Raw[:start] + arr + ctx.Event.RawEvent.Raw[numend+1:]
+ }
+ args = make(map[int]string)
+ for strings.Contains(ctx.Event.RawEvent.Raw, "!::") {
+ start := strings.Index(ctx.Event.RawEvent.Raw, "!::")
+ msgend := strings.Index(ctx.Event.RawEvent.Raw[start+3:], "::")
+ if msgend < 0 {
+ ctx.SendChain(message.Text("ERROR:找不到结束的::"))
+ return false
+ }
+ msgend += start + 3
+ numend := strings.Index(ctx.Event.RawEvent.Raw[msgend+2:], "!")
+ if numend <= 0 {
+ ctx.SendChain(message.Text("ERROR:找不到结束的!"))
+ return false
+ }
+ numend += msgend + 2
+ logrus.Debugln("[job]", start, msgend, numend)
+ u := ctx.Event.RawEvent.Raw[start+3 : msgend]
+ if u == "" {
+ return false
+ }
+ arg, err := strconv.Atoi(ctx.Event.RawEvent.Raw[msgend+2 : numend])
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return false
+ }
+ arr, ok := args[arg]
+ if !ok {
+ isnilable := u[0] == '?'
+ if isnilable {
+ u = u[1:]
+ if u == "" {
+ return false
+ }
+ }
+ b, err := web.GetData(u)
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ if !isnilable {
+ return false
+ }
+ }
+ if len(b) > 0 {
+ type fakejson struct {
+ Arg string `json:"arg"`
+ }
+ f := fakejson{Arg: binary.BytesToString(b)}
+ w := binary.SelectWriter()
+ defer binary.PutWriter(w)
+ _ = json.NewEncoder(w).Encode(&f)
+ arr = w.String()[8 : w.Len()-3]
+ args[arg] = arr
+ }
+ }
+ w := binary.SelectWriter()
+ w.WriteString(ctx.Event.RawEvent.Raw[:start])
+ w.WriteString(arr)
+ w.WriteString(ctx.Event.RawEvent.Raw[numend+1:])
+ ctx.Event.RawEvent.Raw = string(w.Bytes())
+ binary.PutWriter(w)
+ }
+ return true
+}
+
+func logevent(ctx *zero.Ctx) bool {
+ ctx.SendChain(message.Text("您的下一条指令将被记录, 在", ctx.State["regex_matched"].([]string)[1], "时触发"))
+ select {
+ case <-time.After(time.Second * 120):
+ ctx.SendChain(message.Text("指令记录超时"))
+ return false
+ case c := <-zero.NewFutureEvent("message", 0, true, zero.CheckUser(ctx.Event.UserID)).Next():
+ ctx.State["job_raw_event"] = c.Event.RawEvent.Raw
+ ctx.State["job_new_event"] = c.Event.RawEvent
+ return true
+ }
+}
diff --git a/third_party/zbputils/job/model.go b/third_party/zbputils/job/model.go
new file mode 100644
index 0000000000..693451ac0c
--- /dev/null
+++ b/third_party/zbputils/job/model.go
@@ -0,0 +1,11 @@
+package job
+
+import sql "github.com/FloatTech/sqlite"
+
+type cmd struct {
+ ID int64 `db:"id"`
+ Cron string `db:"cron"`
+ Cmd string `db:"cmd"`
+}
+
+var db = sql.New(en.DataFolder() + "job.db")
diff --git a/third_party/zbputils/job/regexqa.go b/third_party/zbputils/job/regexqa.go
new file mode 100644
index 0000000000..6e3741a465
--- /dev/null
+++ b/third_party/zbputils/job/regexqa.go
@@ -0,0 +1,388 @@
+package job
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+
+ "github.com/FloatTech/floatbox/binary"
+ "github.com/FloatTech/floatbox/process"
+ sql "github.com/FloatTech/sqlite"
+
+ "github.com/FloatTech/zbputils/ctxext"
+)
+
+var global = context{
+ group: make(map[int64]*regexGroup),
+}
+
+type context struct {
+ group map[int64]*regexGroup
+}
+
+type regexGroup struct {
+ All []inst
+ Private map[int64][]inst
+}
+
+type inst struct {
+ regex *regexp.Regexp
+ Pattern string
+ Template string
+ IsInject bool
+}
+
+var transformRegex = regexp.MustCompile(`<<.+?>>`)
+
+func transformPattern(pattern string) string {
+ pattern = transformRegex.ReplaceAllStringFunc(pattern, func(s string) string {
+ s = strings.Trim(s, "<>")
+ return `(?P<` + s + `>.+?)`
+ })
+ return "^" + pattern + "$"
+}
+
+// isPrivate:false & id:0 is global
+func saveRegex(gid, uid int64, bots, pattern, template string) error {
+ cr := "rm:"
+ if uid > 0 {
+ cr = "rp:" + strconv.FormatInt(uid, 36) + ":"
+ }
+ cr += strconv.FormatInt(gid, 36) + ":" + pattern
+ return db.Insert(bots, &cmd{
+ ID: idof(cr, template),
+ Cron: cr,
+ Cmd: template,
+ })
+}
+
+// isPrivate:false & id:0 is global
+func saveInjectRegex(gid, uid int64, bots, pattern, template string) error {
+ cr := "im:"
+ if uid > 0 {
+ cr = "ip:" + strconv.FormatInt(uid, 36) + ":"
+ }
+ cr += strconv.FormatInt(gid, 36) + ":" + pattern
+ return db.Insert(bots, &cmd{
+ ID: idof(cr, template),
+ Cron: cr,
+ Cmd: template,
+ })
+}
+
+// isPrivate:false & id:0 is global
+func removeRegex(gid, uid int64, bots, pattern string) error {
+ cr := "rm:"
+ if uid > 0 {
+ cr = "rp:" + strconv.FormatInt(uid, 36) + ":"
+ }
+ cr += strconv.FormatInt(gid, 36) + ":" + pattern
+ c := &cmd{}
+ var delids []int64
+ _ = db.FindFor(bots, c, "WHERE cron = ?", func() error {
+ delids = append(delids, c.ID)
+ return nil
+ }, cr)
+ if len(delids) > 0 {
+ q, s := sql.QuerySet("WHERE id", "IN", delids)
+ return db.Del(bots, q, s...)
+ }
+ return nil
+}
+
+// isPrivate:false & id:0 is global
+func removeInjectRegex(gid, uid int64, bots, pattern string) error {
+ cr := "im:"
+ if uid > 0 {
+ cr = "ip:" + strconv.FormatInt(uid, 36) + ":"
+ }
+ cr += strconv.FormatInt(gid, 36) + ":" + pattern
+ c := &cmd{}
+ var delids []int64
+ _ = db.FindFor(bots, c, "WHERE cron = ?", func() error {
+ delids = append(delids, c.ID)
+ return nil
+ }, cr)
+ if len(delids) > 0 {
+ q, s := sql.QuerySet("WHERE id", "IN", delids)
+ return db.Del(bots, q, s...)
+ }
+ return nil
+}
+
+func init() {
+ en.OnRegex(`^(我|大家|有人)(说|问)(.*)你(答|说|做|执行)`, zero.OnlyGroup, zero.OnlyToMe).Limit(ctxext.LimitByGroup).Handle(func(ctx *zero.Ctx) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ matched := ctx.State["regex_matched"].([]string)
+ all := true
+ if matched[1] == "我" {
+ all = false
+ }
+ if all && !zero.AdminPermission(ctx) {
+ ctx.SendChain(message.Text("非管理员/主人无法设置全局问答"))
+ return
+ }
+ isInject := false
+ if matched[4] == "做" || matched[4] == "执行" {
+ if !zero.AdminPermission(ctx) {
+ ctx.SendChain(message.Text("非管理员/主人无法设置注入"))
+ return
+ }
+ isInject = true
+ }
+ gid := ctx.Event.GroupID
+ uid := ctx.Event.UserID
+ pattern := message.UnescapeCQCodeText(matched[3])
+ template := strings.TrimPrefix(ctx.MessageString(), matched[0])
+ if global.group[gid] == nil {
+ global.group[gid] = ®exGroup{
+ Private: make(map[int64][]inst),
+ }
+ }
+ if global.group[gid].Private == nil {
+ global.group[gid].Private = make(map[int64][]inst)
+ }
+ compiled, err := regexp.Compile(transformPattern(pattern))
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR:无法编译正则表达式:", err))
+ return
+ }
+ regexInst := inst{
+ regex: compiled,
+ Pattern: pattern,
+ Template: template,
+ IsInject: isInject,
+ }
+ rg := global.group[gid]
+ if all {
+ if isInject {
+ err = saveInjectRegex(gid, 0, strconv.FormatInt(ctx.Event.SelfID, 36), pattern, template)
+ } else {
+ err = saveRegex(gid, 0, strconv.FormatInt(ctx.Event.SelfID, 36), pattern, template)
+ }
+ if err == nil {
+ rg.All = append(rg.All, regexInst)
+ }
+ } else {
+ if isInject {
+ err = saveInjectRegex(gid, uid, strconv.FormatInt(ctx.Event.SelfID, 36), pattern, template)
+ } else {
+ err = saveRegex(gid, uid, strconv.FormatInt(ctx.Event.SelfID, 36), pattern, template)
+ }
+ if err == nil {
+ rg.Private[uid] = append(rg.Private[uid], regexInst)
+ }
+ }
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR:无法保存正则表达式:", err))
+ return
+ }
+ ctx.SendChain(message.Text("成功"))
+ })
+
+ en.OnRegex(`^(查看|看看)(我|大家|有人)(说|问)`, zero.OnlyGroup, zero.OnlyToMe).Limit(ctxext.LimitByGroup).Handle(func(ctx *zero.Ctx) {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ gid := ctx.Event.GroupID
+ uid := ctx.Event.UserID
+ matched := ctx.State["regex_matched"].([]string)
+ all := true
+ if matched[2] == "我" {
+ all = false
+ }
+ arg := strings.TrimPrefix(ctx.MessageString(), matched[0])
+ rg := global.group[gid]
+ if rg == nil {
+ return
+ }
+
+ w := binary.SelectWriter()
+ defer binary.PutWriter(w)
+ if all {
+ w.WriteString("该群设置的“有人问”有:\n")
+ } else {
+ _, _ = fmt.Fprintf(w, "你在该群设置的含有 %s 的问题有:\n", arg)
+ }
+ show := func(insts []inst) {
+ for i := range insts {
+ if strings.Contains(insts[i].Pattern, arg) {
+ w.WriteString(strings.Trim(insts[i].Pattern, "^$"))
+ if insts[i].IsInject {
+ w.WriteString("(做)")
+ }
+ _ = w.WriteByte('\n')
+ }
+ }
+ }
+
+ if all {
+ show(rg.All)
+ } else {
+ show(rg.Private[uid])
+ }
+ ctx.SendChain(message.Text(w.String()))
+ })
+
+ en.OnRegex(`^删除(大家|有人|我)(说|问|让你做|让你执行)`, zero.OnlyGroup, zero.OnlyToMe).Limit(ctxext.LimitByGroup).Handle(func(ctx *zero.Ctx) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ gid := ctx.Event.GroupID
+ uid := ctx.Event.UserID
+ matched := ctx.State["regex_matched"].([]string)
+ pattern := strings.TrimPrefix(ctx.MessageString(), matched[0])
+ escapedpattern := message.UnescapeCQCodeText(pattern)
+ if pattern == escapedpattern {
+ escapedpattern = ""
+ }
+ rg := global.group[gid]
+ if rg == nil {
+ return
+ }
+ all := true
+ if matched[1] == "我" {
+ all = false
+ }
+ if all && !zero.AdminPermission(ctx) {
+ ctx.SendChain(message.Text("非管理员/主人无法删除全局问答"))
+ return
+ }
+ isInject := false
+ if matched[2] == "让你做" || matched[2] == "让你执行" {
+ if !zero.AdminPermission(ctx) {
+ ctx.SendChain(message.Text("非管理员/主人无法删除注入"))
+ return
+ }
+ isInject = true
+ }
+ var deleteInst func(insts []inst) ([]inst, error)
+ if escapedpattern == "" {
+ deleteInst = func(insts []inst) ([]inst, error) {
+ for i := range insts {
+ if insts[i].Pattern == pattern {
+ insts[i] = insts[len(insts)-1]
+ insts = insts[:len(insts)-1]
+ return insts, nil
+ }
+ }
+ return insts, errors.New("没有找到对应的问答词条")
+ }
+ } else {
+ deleteInst = func(insts []inst) ([]inst, error) {
+ for i := range insts {
+ if insts[i].Pattern == pattern || insts[i].Pattern == escapedpattern {
+ insts[i] = insts[len(insts)-1]
+ insts = insts[:len(insts)-1]
+ return insts, nil
+ }
+ }
+ return insts, errors.New("没有找到对应的问答词条")
+ }
+ }
+ removeInDB := func(f func(int64, int64, string, string) error, gid, uid int64, bots, pattern string) (err error) {
+ err = f(gid, uid, bots, pattern)
+ if err != nil && escapedpattern == "" {
+ return
+ }
+ if escapedpattern != "" {
+ err = f(gid, uid, bots, escapedpattern)
+ }
+ return
+ }
+ var err error
+ var f func(int64, int64, string, string) error
+ if isInject {
+ f = removeInjectRegex
+ } else {
+ f = removeRegex
+ }
+ if matched[1] == "我" {
+ err = removeInDB(f, gid, uid, strconv.FormatInt(ctx.Event.SelfID, 36), pattern)
+ if err == nil {
+ rg.Private[uid], err = deleteInst(rg.Private[uid])
+ }
+ } else {
+ err = removeInDB(f, gid, 0, strconv.FormatInt(ctx.Event.SelfID, 36), pattern)
+ if err == nil {
+ rg.All, err = deleteInst(rg.All)
+ }
+ }
+ if err != nil {
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ ctx.SendChain(message.Text("删除成功"))
+ })
+
+ en.On(`message/group`, func(ctx *zero.Ctx) bool {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ gid := ctx.Event.GroupID
+ uid := ctx.Event.UserID
+ rg := global.group[gid]
+ if rg == nil {
+ return false
+ }
+ if runInsts(ctx, rg.All) {
+ return true
+ }
+ return runInsts(ctx, rg.Private[uid])
+ }).Limit(ctxext.LimitByGroup).Handle(func(ctx *zero.Ctx) {
+ template := ctx.State["regqa_template"].(string)
+ if ctx.State["regqa_isinject"].(bool) {
+ ctx.Event.NativeMessage = json.RawMessage("\"" + template + "\"")
+ ctx.Event.RawMessage = template
+ process.SleepAbout1sTo2s() // 防止风控
+ ctx.Event.Time = time.Now().Unix()
+ var err error
+ vev, cl := binary.OpenWriterF(func(w *binary.Writer) {
+ err = json.NewEncoder(w).Encode(ctx.Event)
+ })
+ if err != nil {
+ cl()
+ ctx.SendChain(message.Text("ERROR: ", err))
+ return
+ }
+ logrus.Debugln("[job] inject:", binary.BytesToString(vev))
+ defer func() {
+ _ = recover()
+ cl()
+ }()
+ ctx.Echo(vev)
+ } else {
+ ctx.Send(template)
+ }
+ })
+}
+
+func runInsts(ctx *zero.Ctx, insts []inst) bool {
+ msg := ctx.MessageString()
+ for _, inst := range insts {
+ if matched := inst.regex.FindStringSubmatch(msg); matched != nil {
+ template := inst.Template
+ sub := inst.regex.SubexpNames()
+ for i := 1; i < len(matched); i++ {
+ if sub[i] != "" {
+ template = strings.ReplaceAll(template, "<<"+sub[i]+">>", matched[i])
+ }
+ template = strings.ReplaceAll(template, "$"+strconv.Itoa(i), matched[i])
+ }
+ ctx.State["regqa_template"] = template
+ ctx.State["regqa_isinject"] = inst.IsInject
+ return true
+ }
+ }
+ return false
+}
diff --git a/third_party/zbputils/job/web.go b/third_party/zbputils/job/web.go
new file mode 100644
index 0000000000..ea304125c4
--- /dev/null
+++ b/third_party/zbputils/job/web.go
@@ -0,0 +1,459 @@
+package job
+
+import (
+ "encoding/json"
+ "errors"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/FloatTech/floatbox/binary"
+ "github.com/FloatTech/floatbox/process"
+ sql "github.com/FloatTech/sqlite"
+ "github.com/sirupsen/logrus"
+ zero "github.com/wdvxdr1123/ZeroBot"
+ "github.com/wdvxdr1123/ZeroBot/message"
+)
+
+type (
+ // Type 任务类型,1=指令别名,2=定时任务,3=你问我答
+ Type uint8
+ // FullMatchType 指令别名类型, jobType=1使用的参数, 1=无状态消息, 2=主人消息
+ FullMatchType uint8
+ // QuestionType 问题类型, jobType=3使用的参数, 1=单独问, 2=所有人问
+ QuestionType uint8
+ // AnswerType 回答类型, jobType=3使用的参数, 1=文本消息, 2=注入消息
+ AnswerType uint8
+)
+
+const (
+ // FullMatchJob 指令别名
+ FullMatchJob Type = iota + 1
+ // CronJob 定时任务
+ CronJob
+ // RegexpJob 你问我答
+ RegexpJob
+)
+
+const (
+ // NoStateMsg 无状态消息
+ NoStateMsg FullMatchType = iota + 1
+ // SuperMsg 主人消息
+ SuperMsg
+)
+
+const (
+ // OneQuestion 单独问
+ OneQuestion QuestionType = iota + 1
+ // AllQuestion 所有人问
+ AllQuestion
+)
+
+const (
+ // TextMsg 文本消息
+ TextMsg AnswerType = iota + 1
+ // InjectMsg 注入消息
+ InjectMsg
+)
+
+// Job 添加任务的入参
+//
+// @Description 添加任务的入参
+type Job struct {
+ ID string `json:"id"` // 任务id
+ SelfID int64 `json:"selfId"` // 机器人id
+ JobType Type `json:"jobType"` // 任务类型,1-指令别名,2-定时任务,3-你问我答
+ Matcher string `json:"matcher"` // 当jobType=1时 为指令别名,当jobType=2时 为cron表达式,当jobType=3时 为正则表达式
+ Handler string `json:"handler"` // 执行内容
+ FullMatchType FullMatchType `json:"fullMatchType"` // 指令别名类型, jobType=1使用的参数, 1=无状态消息, 2=主人消息
+ QuestionType QuestionType `json:"questionType"` // 问题类型, jobType=3使用的参数, 1=单独问, 2=所有人问
+ AnswerType AnswerType `json:"answerType"` // 回答类型, jobType=3使用的参数, 1=文本消息, 2=注入消息
+ GroupID int64 `json:"groupId"` // 群聊id, jobType=2,3使用的参数, jobType=2且私聊, group_id=0
+ UserID int64 `json:"userId"` // 用户id, jobType=2,3使用的参数, 当jobType=3, QuestionType=2,userId=0
+}
+
+// List 任务列表
+func List() (jobList []Job, err error) {
+ jobList = make([]Job, 0, 16)
+ zero.RangeBot(func(id int64, _ *zero.Ctx) bool {
+ c := &cmd{}
+ ids := strconv.FormatInt(id, 36)
+ _ = db.FindFor(ids, c, "", func() error {
+ var j Job
+ var e zero.Event
+ j.SelfID = id
+ j.ID = strconv.FormatInt(c.ID, 10)
+ if len(c.Cron) >= 3 {
+ switch c.Cron[:3] {
+ case "sm:":
+ j.JobType = FullMatchJob
+ j.FullMatchType = SuperMsg
+ j.Matcher = c.Cron[3:]
+ err = json.Unmarshal(binary.StringToBytes(c.Cmd), &e)
+ if err != nil {
+ return err
+ }
+ j.Handler = e.RawMessage
+ case "fm:":
+ j.JobType = FullMatchJob
+ j.FullMatchType = NoStateMsg
+ j.Matcher = c.Cron[3:]
+ j.Handler = c.Cmd
+ case "rm:":
+ j.JobType = RegexpJob
+ j.QuestionType = AllQuestion
+ j.AnswerType = TextMsg
+ j.Handler = message.UnescapeCQCodeText(c.Cmd)
+ cutList := strings.SplitN(c.Cron, ":", 3)
+ if len(cutList) == 3 {
+ j.GroupID, err = strconv.ParseInt(cutList[len(cutList)-2], 36, 64)
+ if err != nil {
+ return err
+ }
+ j.Matcher = cutList[len(cutList)-1]
+ }
+ case "rp:":
+ j.JobType = RegexpJob
+ j.QuestionType = OneQuestion
+ j.AnswerType = TextMsg
+ j.Handler = message.UnescapeCQCodeText(c.Cmd)
+ cutList := strings.SplitN(c.Cron, ":", 4)
+ if len(cutList) == 4 {
+ j.UserID, err = strconv.ParseInt(cutList[len(cutList)-3], 36, 64)
+ if err != nil {
+ return err
+ }
+ j.GroupID, err = strconv.ParseInt(cutList[len(cutList)-2], 36, 64)
+ if err != nil {
+ return err
+ }
+ j.Matcher = cutList[len(cutList)-1]
+ }
+ case "im:":
+ j.JobType = RegexpJob
+ j.QuestionType = AllQuestion
+ j.AnswerType = InjectMsg
+ j.Handler = message.UnescapeCQCodeText(c.Cmd)
+ cutList := strings.SplitN(c.Cron, ":", 3)
+ if len(cutList) == 3 {
+ j.GroupID, err = strconv.ParseInt(cutList[len(cutList)-2], 36, 64)
+ if err != nil {
+ return err
+ }
+ j.Matcher = cutList[len(cutList)-1]
+ }
+ case "ip:":
+ j.JobType = RegexpJob
+ j.QuestionType = OneQuestion
+ j.AnswerType = InjectMsg
+ j.Handler = message.UnescapeCQCodeText(c.Cmd)
+ cutList := strings.SplitN(c.Cron, ":", 4)
+ if len(cutList) == 4 {
+ j.UserID, err = strconv.ParseInt(cutList[len(cutList)-3], 36, 64)
+ if err != nil {
+ return err
+ }
+ j.GroupID, err = strconv.ParseInt(cutList[len(cutList)-2], 36, 64)
+ if err != nil {
+ return err
+ }
+ j.Matcher = cutList[len(cutList)-1]
+ }
+ default:
+ j.JobType = CronJob
+ j.Matcher = c.Cron
+ err = json.Unmarshal(binary.StringToBytes(c.Cmd), &e)
+ if err != nil {
+ return err
+ }
+ j.Handler = e.RawMessage
+ j.GroupID = e.GroupID
+ j.UserID = e.UserID
+ }
+ }
+ jobList = append(jobList, j)
+ return nil
+ })
+ // 不能打断循环
+ if err != nil {
+ logrus.Errorln("jobList: ", err)
+ }
+ return true
+ })
+ return
+}
+
+// Add 添加任务
+func Add(j *Job) (err error) {
+ var (
+ c cmd
+ bot *zero.Ctx
+ b []byte
+ compiled *regexp.Regexp
+ )
+ bot = zero.GetBot(j.SelfID)
+ bot.Event = &zero.Event{
+ SelfID: j.SelfID,
+ }
+ switch j.JobType {
+ case FullMatchJob:
+ if j.FullMatchType == 1 {
+ c.Cron = "fm:" + j.Matcher
+ c.Cmd = binary.BytesToString(json.RawMessage("\"" + j.Handler + "\""))
+ } else {
+ c.Cron = "sm:" + j.Matcher
+ var e zero.Event
+ if len(zero.BotConfig.SuperUsers) > 0 {
+ e.UserID = zero.BotConfig.SuperUsers[0]
+ e.Sender = &zero.User{
+ ID: zero.BotConfig.SuperUsers[0],
+ }
+ e.RawMessage = j.Handler
+ e.NativeMessage = json.RawMessage("\"" + j.Handler + "\"")
+ }
+ b, err = json.Marshal(&e)
+ if err != nil {
+ return
+ }
+ c.Cmd = binary.BytesToString(b)
+ }
+ c.ID = idof(c.Cron, c.Cmd)
+ err = registercmd(j.SelfID, &c)
+ if err != nil {
+ return
+ }
+ case CronJob:
+ var e zero.Event
+ e.UserID = j.UserID
+ e.Sender = &zero.User{
+ ID: j.UserID,
+ }
+ e.SelfID = j.SelfID
+ e.RawMessage = j.Handler
+ e.NativeMessage = json.RawMessage("\"" + j.Handler + "\"")
+ e.GroupID = j.GroupID
+ e.PostType = "message"
+ if e.GroupID > 0 {
+ e.MessageType = "group"
+ } else {
+ e.MessageType = "private"
+ e.TargetID = j.SelfID
+ }
+ b, err = json.Marshal(&e)
+ if err != nil {
+ return
+ }
+ c.Cmd = binary.BytesToString(b)
+ c.Cron = j.Matcher
+ c.ID = idof(c.Cron, c.Cmd)
+ err = addcmd(bot, &c)
+ if err != nil {
+ return
+ }
+ case RegexpJob:
+ all := false
+ isInject := false
+ gid := j.GroupID
+ uid := j.UserID
+ switch {
+ case j.QuestionType == AllQuestion && j.AnswerType == InjectMsg:
+ all = true
+ isInject = true
+ c.Cron = "im:" + strconv.FormatInt(gid, 36) + ":" + j.Matcher
+ case j.QuestionType == AllQuestion && j.AnswerType == TextMsg:
+ all = true
+ isInject = false
+ c.Cron = "rm:" + strconv.FormatInt(gid, 36) + ":" + j.Matcher
+ case j.QuestionType == OneQuestion && j.AnswerType == InjectMsg:
+ all = false
+ isInject = true
+ c.Cron = "ip:" + strconv.FormatInt(uid, 36) + ":" + strconv.FormatInt(gid, 36) + ":" + j.Matcher
+ case j.QuestionType == OneQuestion && j.AnswerType == TextMsg:
+ all = false
+ isInject = false
+ c.Cron = "rp:" + strconv.FormatInt(uid, 36) + ":" + strconv.FormatInt(gid, 36) + ":" + j.Matcher
+ default:
+ }
+ c.Cmd = message.EscapeCQCodeText(j.Handler)
+ c.ID = idof(c.Cron, c.Cmd)
+ pattern := j.Matcher
+ template := message.EscapeCQCodeText(j.Handler)
+ if global.group[gid] == nil {
+ global.group[gid] = ®exGroup{
+ Private: make(map[int64][]inst),
+ }
+ }
+ if global.group[gid].Private == nil {
+ global.group[gid].Private = make(map[int64][]inst)
+ }
+ compiled, err = regexp.Compile(transformPattern(pattern))
+ if err != nil {
+ return
+ }
+ regexInst := inst{
+ regex: compiled,
+ Pattern: pattern,
+ Template: template,
+ IsInject: isInject,
+ }
+ rg := global.group[gid]
+ if all {
+ if isInject {
+ err = saveInjectRegex(gid, 0, strconv.FormatInt(j.SelfID, 36), pattern, template)
+ } else {
+ err = saveRegex(gid, 0, strconv.FormatInt(j.SelfID, 36), pattern, template)
+ }
+ if err == nil {
+ rg.All = append(rg.All, regexInst)
+ }
+ } else {
+ if isInject {
+ err = saveInjectRegex(gid, uid, strconv.FormatInt(j.SelfID, 36), pattern, template)
+ } else {
+ err = saveRegex(gid, uid, strconv.FormatInt(j.SelfID, 36), pattern, template)
+ }
+ if err == nil {
+ rg.Private[uid] = append(rg.Private[uid], regexInst)
+ }
+ }
+ if err != nil {
+ return
+ }
+ default:
+ err = errors.New("不存在的任务类型")
+ return
+ }
+ return
+}
+
+// DeleteReq 删除任务的入参
+//
+// @Description 删除任务的入参
+type DeleteReq struct {
+ IDList []string `json:"idList" form:"idList"` // 任务id
+ SelfID int64 `json:"selfId" form:"selfId"` // 机器人qq
+}
+
+// Delete 删除任务
+func Delete(req *DeleteReq) (err error) {
+ var (
+ c cmd
+ )
+ if len(req.IDList) == 0 {
+ return
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ bots := strconv.FormatInt(req.SelfID, 36)
+ var (
+ delids []int64
+ reqids = make([]int64, len(req.IDList))
+ )
+ for i, idstr := range req.IDList {
+ reqids[i], err = strconv.ParseInt(idstr, 10, 64)
+ if err != nil {
+ return
+ }
+ }
+ err = db.FindFor(bots, &c, "WHERE id in ?", func() error {
+ switch {
+ case len(c.Cron) >= 3 && (c.Cron[:3] == "fm:" || c.Cron[:3] == "sm:"):
+ m, ok := matchers[c.ID]
+ if ok {
+ m.Delete()
+ delete(matchers, c.ID)
+ }
+ case len(c.Cron) >= 3 && (c.Cron[:3] == "ip:" || c.Cron[:3] == "rp:" || c.Cron[:3] == "rm:" || c.Cron[:3] == "im:"):
+ var (
+ all bool
+ gid int64
+ uid int64
+ pattern string
+ )
+ if len(c.Cron) >= 3 && (c.Cron[:3] == "ip:" || c.Cron[:3] == "rp:") {
+ cutList := strings.SplitN(c.Cron, ":", 4)
+ if len(cutList) == 4 {
+ uid, err = strconv.ParseInt(cutList[len(cutList)-3], 36, 64)
+ if err != nil {
+ return err
+ }
+ gid, err = strconv.ParseInt(cutList[len(cutList)-2], 36, 64)
+ if err != nil {
+ return err
+ }
+ pattern = cutList[len(cutList)-1]
+ }
+ all = false
+ } else {
+ cutList := strings.SplitN(c.Cron, ":", 3)
+ if len(cutList) == 3 {
+ gid, err = strconv.ParseInt(cutList[len(cutList)-2], 36, 64)
+ if err != nil {
+ return err
+ }
+ }
+ all = true
+ pattern = cutList[len(cutList)-1]
+ }
+ escapedpattern := message.UnescapeCQCodeText(pattern)
+ if pattern == escapedpattern {
+ escapedpattern = ""
+ }
+ rg := global.group[gid]
+ if rg == nil {
+ return nil
+ }
+ var deleteInst func(insts []inst) ([]inst, error)
+ if escapedpattern == "" {
+ deleteInst = func(insts []inst) ([]inst, error) {
+ for i := range insts {
+ if insts[i].Pattern == pattern {
+ insts[i] = insts[len(insts)-1]
+ insts = insts[:len(insts)-1]
+ return insts, nil
+ }
+ }
+ return insts, errors.New("没有找到对应的问答词条")
+ }
+ } else {
+ deleteInst = func(insts []inst) ([]inst, error) {
+ for i := range insts {
+ if insts[i].Pattern == pattern || insts[i].Pattern == escapedpattern {
+ insts[i] = insts[len(insts)-1]
+ insts = insts[:len(insts)-1]
+ return insts, nil
+ }
+ }
+ return insts, errors.New("没有找到对应的问答词条")
+ }
+ }
+ if all {
+ rg.All, err = deleteInst(rg.All)
+ } else {
+ rg.Private[uid], err = deleteInst(rg.Private[uid])
+ }
+ if err != nil {
+ return err
+ }
+ default:
+ eid, ok := entries[c.ID]
+ if ok {
+ process.CronTab.Remove(eid)
+ delete(entries, c.ID)
+ }
+ }
+ delids = append(delids, c.ID)
+ return nil
+ }, reqids)
+ if err != nil {
+ return
+ }
+ if len(delids) > 0 {
+ q, s := sql.QuerySet("WHERE id", "IN", delids)
+ err = db.Del(bots, q, s...)
+ if err != nil {
+ return
+ }
+ }
+ return
+}
diff --git a/third_party/zbputils/vevent/ctx.go b/third_party/zbputils/vevent/ctx.go
new file mode 100644
index 0000000000..9d27fc7f81
--- /dev/null
+++ b/third_party/zbputils/vevent/ctx.go
@@ -0,0 +1,28 @@
+package vevent
+
+import (
+ "sync"
+ "unsafe"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// Ctx represents the Context which hold the event.
+// 代表上下文
+//
+//nolint:unused
+type Ctx struct {
+ ma *zero.Matcher
+ Event *zero.Event
+ State zero.State
+ caller zero.APICaller
+
+ // lazy message
+ once sync.Once
+ message string
+}
+
+// HookCtxCaller change ctx's caller to hook
+func HookCtxCaller(ctx *zero.Ctx, hook zero.APICaller) {
+ (*(**Ctx)(unsafe.Pointer(&ctx))).caller = hook
+}
diff --git a/third_party/zbputils/vevent/vcall.go b/third_party/zbputils/vevent/vcall.go
new file mode 100644
index 0000000000..291bea5a9d
--- /dev/null
+++ b/third_party/zbputils/vevent/vcall.go
@@ -0,0 +1,30 @@
+// Package vevent 虚拟事件
+package vevent
+
+import (
+ "context"
+ "unsafe"
+
+ zero "github.com/wdvxdr1123/ZeroBot"
+)
+
+// APICallerReturnHook is a caller middleware
+type APICallerReturnHook struct {
+ caller zero.APICaller
+ callback func(req zero.APIRequest, rsp zero.APIResponse, err error)
+}
+
+// NewAPICallerReturnHook hook ctx's caller
+func NewAPICallerReturnHook(ctx *zero.Ctx, callback func(req zero.APIRequest, rsp zero.APIResponse, err error)) (v *APICallerReturnHook) {
+ return &APICallerReturnHook{
+ caller: (*(**Ctx)(unsafe.Pointer(&ctx))).caller,
+ callback: callback,
+ }
+}
+
+// CallAPI call original caller and pass rsp to callback
+func (v *APICallerReturnHook) CallAPI(c context.Context, req zero.APIRequest) (rsp zero.APIResponse, err error) {
+ rsp, err = v.caller.CallAPI(c, req)
+ v.callback(req, rsp, err)
+ return
+}
From d15cb62b6204093b002d2779a4ce2028184ec869 Mon Sep 17 00:00:00 2001
From: qiye-yuri <200499966@qq.com~>
Date: Tue, 13 Oct 2026 21:33:00 +0800
Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=AC=AC?=
=?UTF-8?q?=E4=B8=80=E7=89=88GitHub=E4=BA=A4=E4=BB=98=E7=BB=93=E6=9E=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/local-admin/progress.md | 2 +-
docs/local-admin/release-v0.1.md | 2 ++
docs/local-admin/todo.md | 4 ++--
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/docs/local-admin/progress.md b/docs/local-admin/progress.md
index 47e15a0ba3..9f6e290190 100644
--- a/docs/local-admin/progress.md
+++ b/docs/local-admin/progress.md
@@ -12,7 +12,7 @@
| 日志与外观 | 已完成 | 脱敏、级别/关键词/时间筛选、暂停/清空/导出、深浅主题与小屏 |
| 配置备份与恢复 | 已完成 | 写前备份、三类预览/取消/确认/恢复、恢复后重启与凭据保持 |
| 维护与开发工具 | 已完成 | 模型/回复快照、资源观察、离线联合核对、独立Windows回归与源码构建 |
-| 文档与GitHub交付 | 交付中 | 公开使用/开发文档与中文PR模板,显式文件清单,私有中间记录整体忽略 |
+| 文档与GitHub交付 | 已完成 | 公开使用/开发文档与中文PR模板,407个公开文件核对并推送,私有中间记录整体忽略;[PR #1](https://github.com/qiye-yuri/ZeroBot-Plugin/pull/1)已创建,未自动合并 |
剩余非主人Agent真人测试、客户端体验和真实持续负载验收按使用者决定取消,不作为本轮交付条件,也不记为实测通过。已有隔离与真实通过范围保留,后续出现问题再安排优化。
diff --git a/docs/local-admin/release-v0.1.md b/docs/local-admin/release-v0.1.md
index b94cdc37bb..14e47c6d53 100644
--- a/docs/local-admin/release-v0.1.md
+++ b/docs/local-admin/release-v0.1.md
@@ -33,3 +33,5 @@ QQ内置AI语音当前走群接口,私聊不得向其他群生成语音;Agen
先备份本机配置、后台目录及需要保留的聊天档案,再从源码构建。仅更换ZeroBot时停止该服务并重启,保留NapCat登录;重新登录后台并核对连接、账号与已保存设置。程序更新不自动更改现有模型、角色或密钥。
GitHub交付使用中文提交与PR说明,目标为使用者自己的仓库。中间交流与本机调查统一保存在docs/local-notes并整体忽略;源码只包含实现与使用/开发资料,运行数据库、配置、日志、截图和原始聊天内容不上传。规则见[GitHub交付](github-delivery.md)。
+
+已更新用户仓库分支codex/local-admin,源码提交aa0aaf55;[中文PR #1](https://github.com/qiye-yuri/ZeroBot-Plugin/pull/1)已创建,尚未自动合并或覆盖项目版本标签。
diff --git a/docs/local-admin/todo.md b/docs/local-admin/todo.md
index e664587bdf..aaf61113e9 100644
--- a/docs/local-admin/todo.md
+++ b/docs/local-admin/todo.md
@@ -1,10 +1,10 @@
# 第一版待办与后续范围
-本地管理后台v0.1的功能实现与本轮约定验收已经完成。第一版交付正在按[GitHub规则](github-delivery.md)整理并更新用户仓库。
+本地管理后台v0.1的功能实现与本轮约定验收已经完成。第一版交付已按[GitHub规则](github-delivery.md)更新到用户仓库分支codex/local-admin,并创建[中文PR #1](https://github.com/qiye-yuri/ZeroBot-Plugin/pull/1)。PR未自动合并。
| 事项 | 当前状态 | 范围 |
| --- | --- | --- |
-| GitHub第一版交付 | 交付中 | 完成独立源码构建及最终核对后,提交并更新用户仓库,使用中文说明 |
+| GitHub第一版交付 | 已完成(分支推送及中文PR) | 独立源码构建、最终核对及提交/推送完成,PR未自动合并;源码提交aa0aaf55 |
| 非主人Agent真人动作测试 | 已关闭:取消本轮要求 | 不计为实测通过,不自动重开;保留隔离角色边界结果 |
| QQ客户端体验验收 | 已关闭:取消本轮要求 | 后续有需求再提出;不宣称客户端收到/已读或端到端耗时已测 |
| 真实持续业务负载验收 | 已关闭:暂时取消 | 模拟负载及20分钟资源观察保留原范围,不替代真实负载;后续出现问题再优化 |