diff --git a/.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml b/.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml index b5cbfd6db2a7..65a547e93524 100644 --- a/.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml +++ b/.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml @@ -26,7 +26,6 @@ paths: - extensions/nextcloud-talk/src - extensions/nostr/src - extensions/qa-channel/src - - extensions/qqbot/src - extensions/signal/src - extensions/slack/src - extensions/synology-chat/src diff --git a/.github/labeler.yml b/.github/labeler.yml index 3672f2538534..ad75ed371b4d 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -148,7 +148,6 @@ "channel: qqbot": - changed-files: - any-glob-to-any-file: - - "extensions/qqbot/**" - "docs/channels/qqbot.md" "channel: raft": - changed-files: diff --git a/.github/workflows/codeql-critical-quality.yml b/.github/workflows/codeql-critical-quality.yml index e18d434c8f08..580c1a5e8998 100644 --- a/.github/workflows/codeql-critical-quality.yml +++ b/.github/workflows/codeql-critical-quality.yml @@ -49,7 +49,6 @@ on: - "extensions/nextcloud-talk/src/**" - "extensions/nostr/src/**" - "extensions/qa-channel/src/**" - - "extensions/qqbot/src/**" - "extensions/signal/src/**" - "extensions/slack/src/**" - "extensions/synology-chat/src/**" @@ -240,7 +239,7 @@ jobs: src/auto-reply/reply/post-compaction-context.ts|src/auto-reply/reply/queue/*|src/auto-reply/reply/startup-context.ts|src/commands/doctor-session-*.ts|src/commands/session-store-targets.ts|src/commands/sessions*.ts|src/infra/diagnostic-*.ts|src/infra/diagnostics-timeline.ts|src/infra/session-delivery-queue*.ts|src/logging/diagnostic*.ts) session_diagnostics=true ;; - extensions/discord/src/*|extensions/feishu/src/*|extensions/googlechat/src/*|extensions/imessage/src/*|extensions/irc/src/*|extensions/line/src/*|extensions/matrix/src/*|extensions/mattermost/src/*|extensions/msteams/src/*|extensions/nextcloud-talk/src/*|extensions/nostr/src/*|extensions/qa-channel/src/*|extensions/qqbot/src/*|extensions/signal/src/*|extensions/slack/src/*|extensions/synology-chat/src/*|extensions/telegram/src/*|extensions/tlon/src/*|extensions/twitch/src/*|extensions/whatsapp/src/*|extensions/zalo/src/*|extensions/zalouser/src/*|src/channels/*) + extensions/discord/src/*|extensions/feishu/src/*|extensions/googlechat/src/*|extensions/imessage/src/*|extensions/irc/src/*|extensions/line/src/*|extensions/matrix/src/*|extensions/mattermost/src/*|extensions/msteams/src/*|extensions/nextcloud-talk/src/*|extensions/nostr/src/*|extensions/qa-channel/src/*|extensions/signal/src/*|extensions/slack/src/*|extensions/synology-chat/src/*|extensions/telegram/src/*|extensions/tlon/src/*|extensions/twitch/src/*|extensions/whatsapp/src/*|extensions/zalo/src/*|extensions/zalouser/src/*|src/channels/*) channel=true ;; src/config/*) diff --git a/.github/workflows/plugin-clawhub-release.yml b/.github/workflows/plugin-clawhub-release.yml index c5c452e35ab5..142c82687f77 100644 --- a/.github/workflows/plugin-clawhub-release.yml +++ b/.github/workflows/plugin-clawhub-release.yml @@ -403,7 +403,7 @@ jobs: dry_run: ${{ inputs.dry_run }} registry: https://clawhub.ai site: https://clawhub.ai - family: ${{ contains(fromJson('["@openclaw/acpx","@openclaw/diffs","@openclaw/feishu","@openclaw/qqbot"]'), matrix.plugin.packageName) && 'bundle-plugin' || '' }} + family: ${{ contains(fromJson('["@openclaw/acpx","@openclaw/diffs","@openclaw/feishu"]'), matrix.plugin.packageName) && 'bundle-plugin' || '' }} tags: ${{ matrix.plugin.publishTag }} source_repo: ${{ github.repository }} source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }} diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index d37e494f170a..62a743e63e9f 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -220,11 +220,6 @@ extensions/qa-lab/src/suite-launch.runtime.test.ts extensions/qa-lab/src/suite-launch.runtime.ts extensions/qa-lab/src/test-file-scenario-runner.test.ts extensions/qa-lab/web/src/app.ts -extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts -extensions/qqbot/src/engine/gateway/outbound-dispatch.ts -extensions/qqbot/src/engine/messaging/outbound-deliver.ts -extensions/qqbot/src/engine/messaging/outbound-media-send.ts -extensions/qqbot/src/engine/messaging/streaming-c2c.ts extensions/signal/src/approval-reactions.ts extensions/signal/src/client-container.test.ts extensions/signal/src/client-container.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 9f8856ff20e7..a70c68b5e9a2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"51617df7eeada155ae66bf04a3a30b8622708f48e6b0274ec2b831ff583f8e58","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"f17fda486bebaafacc259619d94b63f6b19420ef47a9740c7b35b954c9ee018e","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index b4b368b4b584..e247070936bd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"0cff92037a9d807ad0301e14dd319645f9e27336e2d07aa4613bce129f86a827","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"c017cdb95283e766f3455b2e5a1c828a7227db87ec82e5f346d94682292cbbf7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 5571874cd59e..7c9d05a53834 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"02efab07a69f7899421c36ab7cee6dd2bd84ce2d689d10549129dc4dc45a1f15","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"5da887b8d2f6cb04bb968b1d78ebc6c18f3d9a6ccfd17cf7dd3f6ea6f9cfe3ca","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 6f8031ecc445..a01fa28319fb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"2534a0f3e5bc7c6f7dd08cdc732f705660dbd5a71d992b81406ec0926054ed56","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"f04ae840aa03ecffd6a5c617a206aeb468ab7e2663f146dee15e82a859ab94ea","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 8fd6bbdd5462..e355823cae32 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"6df8cf7345a116398af3b08f0715e5cfc5a0386b3a0c357ba5140eff626f0191","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"da5462c33495c854121e954fe007c8bca17e3fd625f8fed130954705c62e1818","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 15d5b67384f0..5b726f8b5457 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"475a9e86021703425ca3a33e602011d8e26ab7525f082b4daa35bf9ef40effb1","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"20a70ea7ba78d5c618d82338e5901b73389313867458f67dbd95b3f6f91f4cbc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 72b1b6ef2354..861dd18cf12a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"16f5001d40fb5196f43834fff175a4e20cd7fe16c3020828dea46bf3fe7a9312","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"736070b02d2196ce75af349e9e71edcf464c8d43a01bdfd70c1b61abfac3e85f","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 9a4904fb9a02..ec923a568c3d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"c1a6a7c97f0a333a6b617eeb22017fb33bf2d57e4d884fce73c7849999b57f72","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"1770f99bd46d2ce7525fcd10cf143163efd1a01ef8a0a5cf22b097200477c762","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json index a55bb8be3e16..aa926456183c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-basic-runtime.json @@ -1 +1 @@ -{"contentHash":"e770363ffda93c00f3f1a12ad5b303e42f929b4f891d11e8457711a7fcb94a65","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} +{"contentHash":"bc8c23c5a5108c1781648509f7b4c6c07ad4e4a064f726d517ece59d8907f19c","entrypoint":"channel-secret-basic-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-basic-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json index b46159d2a188..6ab4c9893bdd 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-secret-runtime.json @@ -1 +1 @@ -{"contentHash":"b423164cfef742f2d9b7ba377cb7243407da6d32645e8eb85e1c2d6ef09c3714","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} +{"contentHash":"812007b404b41c995529acbc1fadc9fd5661a6a87676bb5dce2f30e52327becf","entrypoint":"channel-secret-runtime","importSpecifier":"openclaw/plugin-sdk/channel-secret-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json index c31f04d79e13..9106b41a163f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-mutation.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-mutation.json @@ -1 +1 @@ -{"contentHash":"2df969326d93c12f73523987617ebc453577a3610c374eb20529aa78b58759a3","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} +{"contentHash":"b70eb302db749674e237eb2d740dacca2c20f883cd7f0581299cf010d3a71863","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index cf8cbe7633bd..1ac84629e2e8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"96df262c825f0096a8050df72f6391bfa8c399555d148491dfbdbd6c402fd63b","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"84cac1377fdac6d6665de5b8264a95c17b79823063420b21eb31596c5af7611e","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index bc1018ea434a..8e1a7383539c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"e0c691c494cf0d17c251a21fecc4c856bce1c76ea647ed8ba62287d000145671","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"511e572af05c931e92680e1a154c5705a3ef54b822db8990605403d9d5697e3f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 78d6fac81856..ae368704b7a2 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"80d1dfc109596a642c6e54cc9a0755f6d9824067cfa069742ba7c94bb790b6b6","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"9ea9ae4e9fd1c35de0578f6c68722dd9c037430c8b5bb526d0f6266fdf7edb2c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index fa5bd67716b4..bac18fd06e94 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"e513691f5c73165100a0a9b820f2c5da0611dc8e901525542fd6539dd3d9c255","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"46ded5c5593969f06e7d8fef58d6a1c0d20f7deff515a386d3d03d7423d8f0c1","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 79de2feb2392..4c2820e013a4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"09f4cd3417e8a942d4636d4ac3297f54eaeeefc4a24b94dcfca9d23ae76cb463","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"bf1a5368e13eb0bdef48b175dfa26222272499e31e0bd13bc87dcef79708a869","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 3facc71815b1..1e6d5cd3b9d6 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"7f10ab95ec2d34e84789f8f298ddc1abbd1c5bb2fe804b84af469c7707ec8824","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"f62643baa25a9c3102e2173269ea301024a844e32dbfb7c39d122eca47a562dc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index ec382639cd65..01af5f2a755c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"6b7d65e8730f4c592c67c9aa2bf206b0c9b01d272650b5699aabff212c8aa68e","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"8c8acbb70d987d0470c65d2868d9a15f9677922e11ad97604ed0d7d3000a0132","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 85d389c3bb40..e08221ab744b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"77a500390fb4b5d465334b23fabb2f77c1ca9178a6e095a2340a4a950c7fece0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"ecf8ac0427d503d3c2f5c174cee58d8aa47192e66bdda123f34546d02e5146b2","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 0362e34c821d..e81db3bbe769 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"6697592146ce483645a562acf9a105b57541733ca631ccba638164256ac501a9","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"9ee8b93b462bd6869a15ad42fa97aab32bd0a5e012b61f2e75355b898f85dc5b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json index b0d86518d103..e0d55cfede16 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-auth.json @@ -1 +1 @@ -{"contentHash":"1b51ace8b1e469c12e17dfe749484bbfa624781917e346a431bfe76eb1c286c3","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} +{"contentHash":"7233a8bb04605b4022cb18569b93f062d4f7986ae5ab5dd09e6c655984b56542","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 679d4cf04c46..cab15ae2c440 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"12a68d5eb0af0b8026102f6faf695e5146e997bb3ce17882e576c8b63eb08c0c","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"9bcfbbe5a2ddf62272fd953b0687d4e6d34cb80aa5de23e485c468182b79d8e4","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json index 991ab282a877..0051ef28029a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-input-runtime.json @@ -1 +1 @@ -{"contentHash":"a202aafe8e27852bfe3aae8ccfcbe7837d3f7ccd97c9b23cf98814c3ac857325","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} +{"contentHash":"9b07f560a2a642bc9a9f5648d87219cacdddfe6313a472522b98fbc7a3ce482a","entrypoint":"secret-input-runtime","importSpecifier":"openclaw/plugin-sdk/secret-input-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json index 2ee0d696af26..517a38da9311 100644 --- a/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/secret-ref-runtime.json @@ -1 +1 @@ -{"contentHash":"ad0cf0d38a735859320870835e6b53a7a6f6c47208bbfbbc2bdc48f01c032373","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} +{"contentHash":"cf89c569d04b860dfc9e363c1b12c15072779459aa90e33f11f0d8305ab1336d","entrypoint":"secret-ref-runtime","importSpecifier":"openclaw/plugin-sdk/secret-ref-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index ae8829a6ed31..36ec32188fb4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"519c2334419a6eb156e96ae99fca2a77821f60be3d8714a17062493591df50e4","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"5bb57a7c8f5f158c973540af418c5ea7df019622ad890e9f8741737e550c4109","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index d7bd97998ad6..9b3cdb5b309e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"1ada23abeeeec02cbd00d3274fb591b85269bd3098d911a390b363b1ba5eca5f","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"8f23b9a7a62f9587e1a7da7adfcc9c2b17f59c3f86e8c8da342797172d16f504","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index 5e07934fa4fc..6e2bc76a212d 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -93,28 +93,15 @@ File-backed AppSecret: } ``` -Env SecretRef AppSecret: - -```json5 -{ - channels: { - qqbot: { - enabled: true, - appId: "YOUR_APP_ID", - clientSecret: { source: "env", provider: "default", id: "QQBOT_CLIENT_SECRET" }, - }, - }, -} -``` - Notes: - `openclaw channels add --channel qqbot --token-file ...` sets the AppSecret only; `appId` must already be set in config or `QQBOT_APP_ID`. -- `clientSecret` accepts a plaintext string, a file path (`clientSecretFile`), - or a structured SecretRef object. -- Legacy `secretref:...` / `secretref-env:...` marker strings are rejected for - `clientSecret`; use a structured SecretRef object instead. +- `clientSecret` accepts a plaintext string or a file path (`clientSecretFile`). +- Known limitation: the external `@tencent-connect/openclaw-qqbot` package does + not support structured SecretRef objects for `clientSecret`. If your config + uses one, move the secret to the `QQBOT_CLIENT_SECRET` environment variable + (or `clientSecretFile`) before upgrading. ### Streaming diff --git a/extensions/qqbot/README.md b/extensions/qqbot/README.md deleted file mode 100644 index 55ab200529db..000000000000 --- a/extensions/qqbot/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# OpenClaw QQ Bot - -Official OpenClaw channel plugin for QQ Bot group and direct-message workflows. - -Install from OpenClaw: - -```bash -openclaw plugins install @openclaw/qqbot -``` - -Configure QQ Bot credentials in OpenClaw, then connect the bot to the groups or direct-message contexts where agents should operate. diff --git a/extensions/qqbot/api.ts b/extensions/qqbot/api.ts deleted file mode 100644 index 554c82748e4c..000000000000 --- a/extensions/qqbot/api.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Qqbot API module exposes the plugin public contract. -export { qqbotPlugin } from "./src/channel.js"; -export { qqbotSetupPlugin } from "./src/channel.setup.js"; -export { getFrameworkCommands } from "./src/engine/commands/slash-commands-impl.js"; -export { registerChannelTool } from "./src/bridge/tools/channel.js"; -export { registerRemindTool } from "./src/bridge/tools/remind.js"; -export { registerQQBotTools } from "./src/bridge/tools/index.js"; -export { registerQQBotFull } from "./src/bridge/channel-entry.js"; -export { - type AudioFormatPolicy, - type C2CMessageEvent, - type GroupMessageEvent, - type GuildMessageEvent, - type MessageAttachment, - type QQBotAccountConfig, - type QQBotConfig, - type QQBotDmPolicy, - type QQBotExecApprovalConfig, - type QQBotGroupPolicy, - type ResolvedQQBotAccount, - type WSPayload, -} from "./src/types.js"; -export { - applyQQBotAccountConfig, - DEFAULT_ACCOUNT_ID, - listQQBotAccountIds, - resolveDefaultQQBotAccountId, - resolveQQBotAccount, -} from "./src/bridge/config.js"; -export { - buildMediaTarget, - checkMessageReplyLimit, - DEFAULT_MEDIA_SEND_ERROR, - getMessageReplyConfig, - getMessageReplyStats, - type MediaOutboundContext, - type MediaTargetContext, - MESSAGE_REPLY_LIMIT, - OUTBOUND_ERROR_CODES, - type OutboundContext, - type OutboundErrorCode, - type OutboundResult, - parseTarget, - recordMessageReply, - type ReplyLimitResult, - resolveOutboundMediaPath, - resolveUserFacingMediaError, - sendCronMessage, - sendDocument, - sendMedia, - sendPhoto, - sendProactiveMessage, - sendText, - sendVideoMsg, - sendVoice, - setOutboundAudioPort, -} from "./src/engine/messaging/outbound.js"; diff --git a/extensions/qqbot/channel-entry-api.ts b/extensions/qqbot/channel-entry-api.ts deleted file mode 100644 index 6eb00d00579e..000000000000 --- a/extensions/qqbot/channel-entry-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Narrow bridge entrypoint for qqbot registerFull composition. -export { registerQQBotFull } from "./src/bridge/channel-entry.js"; diff --git a/extensions/qqbot/channel-plugin-api.ts b/extensions/qqbot/channel-plugin-api.ts deleted file mode 100644 index 547071257dc9..000000000000 --- a/extensions/qqbot/channel-plugin-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Qqbot API module exposes the plugin public contract. -export { qqbotPlugin } from "./src/channel.js"; diff --git a/extensions/qqbot/doctor-contract-api.ts b/extensions/qqbot/doctor-contract-api.ts deleted file mode 100644 index 594c4c8e5aa8..000000000000 --- a/extensions/qqbot/doctor-contract-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js"; -export { stateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/qqbot/index.ts b/extensions/qqbot/index.ts deleted file mode 100644 index d59a7c14a1c9..000000000000 --- a/extensions/qqbot/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Qqbot plugin entrypoint registers its OpenClaw integration. -import { - defineBundledChannelEntry, - loadBundledEntryExportSync, - type OpenClawPluginApi, -} from "openclaw/plugin-sdk/channel-entry-contract"; - -function registerQQBotFull(api: OpenClawPluginApi): void { - if (api.registrationMode === "tool-discovery") { - const registerTools = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>( - import.meta.url, - { - specifier: "./tools-api.js", - exportName: "registerQQBotTools", - }, - ); - registerTools(api); - return; - } - const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, { - specifier: "./channel-entry-api.js", - exportName: "registerQQBotFull", - }); - register(api); -} - -export default defineBundledChannelEntry({ - id: "qqbot", - name: "QQ Bot", - description: "QQ Bot channel plugin", - importMetaUrl: import.meta.url, - plugin: { - specifier: "./channel-plugin-api.js", - exportName: "qqbotPlugin", - }, - secrets: { - specifier: "./secret-contract-api.js", - exportName: "channelSecrets", - }, - runtime: { - specifier: "./runtime-api.js", - exportName: "setQQBotRuntime", - }, - registerFull: registerQQBotFull, -}); diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json deleted file mode 100644 index adbb7a121825..000000000000 --- a/extensions/qqbot/openclaw.plugin.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "id": "qqbot", - "doctorContract": { - "configRepair": true, - "stateMigrations": true - }, - "name": "QQ Bot", - "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", - "icon": "https://cdn.simpleicons.org/qq", - "activation": { - "onStartup": false - }, - "channels": ["qqbot"], - "contracts": { - "tools": ["qqbot_channel_api", "qqbot_remind"] - }, - "enabledByDefault": true, - "skills": ["./skills"], - "configSchema": { - "type": "object", - "additionalProperties": true, - "$defs": { - "audioFormatPolicy": { - "type": "object", - "additionalProperties": false, - "properties": { - "sttDirectFormats": { - "type": "array", - "items": { "type": "string" } - }, - "uploadDirectFormats": { - "type": "array", - "items": { "type": "string" } - }, - "transcodeEnabled": { "type": "boolean" } - } - }, - "stt": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { "type": "boolean" }, - "provider": { "type": "string" }, - "baseUrl": { "type": "string" }, - "apiKey": { "type": "string" }, - "model": { "type": "string" } - } - }, - "secretRef": { - "type": "object", - "additionalProperties": false, - "properties": { - "source": { - "type": "string", - "enum": ["env", "file", "exec", "store"] - }, - "provider": { "type": "string" }, - "id": { "type": "string" } - }, - "required": ["source", "provider", "id"] - }, - "secretInput": { - "anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }] - }, - "contextVisibility": { - "type": "string", - "enum": ["all", "allowlist", "allowlist_quote"] - }, - "group": { - "type": "object", - "additionalProperties": true, - "properties": { - "requireMention": { "type": "boolean" }, - "commandLevel": { - "type": "string", - "enum": ["all", "safety", "strict"] - }, - "ignoreOtherMentions": { "type": "boolean" }, - "historyLimit": { "type": "number" }, - "name": { "type": "string" }, - "prompt": { "type": "string" } - } - }, - "groups": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/group" - } - }, - "account": { - "type": "object", - "additionalProperties": true, - "properties": { - "enabled": { "type": "boolean" }, - "name": { "type": "string" }, - "appId": { "type": "string" }, - "clientSecret": { "$ref": "#/$defs/secretInput" }, - "clientSecretFile": { "type": "string" }, - "allowFrom": { - "type": "array", - "items": { "type": "string" } - }, - "contextVisibility": { "$ref": "#/$defs/contextVisibility" }, - "systemPrompt": { "type": "string" }, - "markdownSupport": { "type": "boolean" }, - "audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" }, - "urlDirectUpload": { "type": "boolean" }, - "upgradeUrl": { "type": "string" }, - "upgradeMode": { - "type": "string", - "enum": ["doc", "hot-reload"] - }, - "streaming": { - "type": "object", - "additionalProperties": false, - "properties": { - "mode": { - "type": "string", - "enum": ["off", "partial"], - "default": "partial" - }, - "nativeTransport": { - "type": "boolean", - "description": "Use QQ's official C2C stream_messages API for DM replies (single-message typing-style updates)." - } - } - }, - "groups": { "$ref": "#/$defs/groups" } - } - } - }, - "properties": { - "enabled": { "type": "boolean" }, - "name": { "type": "string" }, - "appId": { "type": "string" }, - "clientSecret": { "$ref": "#/$defs/secretInput" }, - "clientSecretFile": { "type": "string" }, - "allowFrom": { - "type": "array", - "items": { "type": "string" } - }, - "contextVisibility": { "$ref": "#/$defs/contextVisibility" }, - "systemPrompt": { "type": "string" }, - "markdownSupport": { "type": "boolean" }, - "audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" }, - "stt": { "$ref": "#/$defs/stt" }, - "urlDirectUpload": { "type": "boolean" }, - "upgradeUrl": { "type": "string" }, - "upgradeMode": { - "type": "string", - "enum": ["doc", "hot-reload"] - }, - "streaming": { - "type": "object", - "additionalProperties": false, - "properties": { - "mode": { - "type": "string", - "enum": ["off", "partial"], - "default": "partial" - }, - "nativeTransport": { - "type": "boolean", - "description": "Use QQ's official C2C stream_messages API for DM replies (single-message typing-style updates)." - } - } - }, - "accounts": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/account" - } - }, - "defaultAccount": { "type": "string" }, - "groups": { "$ref": "#/$defs/groups" } - } - } -} diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json deleted file mode 100644 index ef2dcbbdbfcc..000000000000 --- a/extensions/qqbot/package.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "name": "@openclaw/qqbot", - "version": "2026.8.1", - "private": false, - "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", - "repository": { - "type": "git", - "url": "https://github.com/openclaw/openclaw" - }, - "type": "module", - "dependencies": { - "@tencent-connect/qqbot-connector": "1.2.0", - "mpg123-decoder": "1.0.3", - "p-map": "7.0.6", - "pretty-ms": "9.3.0", - "silk-wasm": "3.7.1", - "ws": "8.21.1", - "zod": "4.4.3" - }, - "devDependencies": { - "@openclaw/plugin-sdk": "workspace:*", - "@types/ws": "8.18.1", - "openclaw": "workspace:*" - }, - "peerDependencies": { - "openclaw": ">=2026.8.1" - }, - "peerDependenciesMeta": { - "openclaw": { - "optional": true - } - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "setupEntry": "./setup-entry.ts", - "channel": { - "id": "qqbot", - "configuredState": { - "env": { - "anyOf": [ - "QQBOT_APP_ID", - "QQBOT_CLIENT_SECRET" - ] - } - }, - "approvalFlags": [ - "native" - ], - "label": "QQ Bot", - "selectionLabel": "QQ Bot (Official API)", - "detailLabel": "QQ Bot", - "docsPath": "/channels/qqbot", - "docsLabel": "qqbot", - "blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.", - "systemImage": "bubble.left.and.bubble.right", - "setup": { - "fields": [ - { - "key": "token", - "kind": "string", - "sensitive": true, - "cli": { - "flags": "--token ", - "description": "QQBot app id and client secret" - } - }, - { - "key": "tokenFile", - "kind": "string", - "sensitive": true, - "cli": { - "flags": "--token-file ", - "description": "QQBot client secret file" - } - }, - { - "key": "useEnv", - "kind": "boolean", - "cli": { - "flags": "--use-env", - "description": "Use QQBOT environment credentials" - } - } - ] - } - }, - "install": { - "npmSpec": "@openclaw/qqbot", - "localPath": "extensions/qqbot", - "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" - }, - "compat": { - "pluginApi": ">=2026.8.1" - }, - "build": { - "openclawVersion": "2026.8.1" - }, - "release": { - "publishToClawHub": true, - "publishToNpm": true - } - } -} diff --git a/extensions/qqbot/runtime-api.ts b/extensions/qqbot/runtime-api.ts deleted file mode 100644 index d5bc07091b6d..000000000000 --- a/extensions/qqbot/runtime-api.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Qqbot API module exposes the plugin public contract. -export type { ChannelPlugin, OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core"; -export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -export type { - OpenClawPluginService, - OpenClawPluginServiceContext, - PluginLogger, -} from "openclaw/plugin-sdk/core"; -export type { ResolvedQQBotAccount, QQBotAccountConfig } from "./src/types.js"; -export { getQQBotRuntime, setQQBotRuntime } from "./src/bridge/runtime.js"; diff --git a/extensions/qqbot/secret-contract-api.ts b/extensions/qqbot/secret-contract-api.ts deleted file mode 100644 index 0151079c1154..000000000000 --- a/extensions/qqbot/secret-contract-api.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Qqbot API module exposes the plugin public contract. -export { - channelSecrets, - collectRuntimeConfigAssignments, - secretTargetRegistryEntries, -} from "./src/secret-contract.js"; diff --git a/extensions/qqbot/setup-entry.ts b/extensions/qqbot/setup-entry.ts deleted file mode 100644 index 158c398737c6..000000000000 --- a/extensions/qqbot/setup-entry.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Qqbot plugin module implements setup entry behavior. -import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; - -export default defineBundledChannelSetupEntry({ - importMetaUrl: import.meta.url, - plugin: { - specifier: "./setup-plugin-api.js", - exportName: "qqbotSetupPlugin", - }, - secrets: { - specifier: "./secret-contract-api.js", - exportName: "channelSecrets", - }, -}); diff --git a/extensions/qqbot/setup-plugin-api.ts b/extensions/qqbot/setup-plugin-api.ts deleted file mode 100644 index 665fb24f8e7a..000000000000 --- a/extensions/qqbot/setup-plugin-api.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Keep bundled setup entry imports narrow so setup loads do not pull the -// broader QQ Bot runtime plugin surface. -export { qqbotSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/qqbot/skills/qqbot-channel/SKILL.md b/extensions/qqbot/skills/qqbot-channel/SKILL.md deleted file mode 100644 index af6976a3848a..000000000000 --- a/extensions/qqbot/skills/qqbot-channel/SKILL.md +++ /dev/null @@ -1,275 +0,0 @@ ---- -name: qqbot-channel -description: QQ channel management skill. Use qqbot_channel_api for explicit QQ channel-management requests; confirm write, delete, and bulk actions before calling authenticated QQ Open Platform endpoints. -metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qqbot"] } } } ---- - -# QQ 频道 API 请求指导 - -`qqbot_channel_api` 是一个 QQ 开放平台 HTTP 代理工具,**自动填充鉴权 Token**。你只需要指定 HTTP 方法、API 路径、请求体和查询参数。 - -## 📚 详细参考文档 - -每个接口的完整参数说明、返回值结构和枚举值定义: - -- `references/api_references.md` - ---- - -## 🔧 工具参数 - -| 参数 | 类型 | 必填 | 说明 | -| --------------- | ------- | ---- | ---------------------------------------------------------------------------- | -| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` | -| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 | -| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) | -| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 | -| `confirmed` | boolean | 否 | `DELETE` 必须传 `true`,表示用户已确认精确删除目标 | -| `bulkConfirmed` | boolean | 否 | 批量 `DELETE`(如删除全部公告)必须额外传 `true` | - -> 基础 URL:`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。 - -## 🛡️ 安全边界 - -- 只在用户明确要求管理 QQ 频道、子频道、公告、论坛帖子或日程时调用写入接口。 -- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前先复述目标频道/子频道/帖子/日程和预期改动;删除、批量删除、公告覆盖等不可逆或大范围操作必须等用户确认后再执行。 -- 删除前优先用 `GET`/列表接口查出候选项,让用户选择具体 ID;不要根据模糊名称猜测删除目标。 -- `DELETE` 请求必须传 `confirmed: true`,否则工具会拒绝执行。`announces/all` 这样的批量操作还必须传 `bulkConfirmed: true`,只有在用户明确说要删除全部公告并再次确认后才可使用。 -- 成员资料、头像 URL、频道图标等属于用户/群组资料。默认只总结必要字段;只有用户要求查看头像/图标或视觉比对时才内联展示图片,不要无关转发头像 URL。 - ---- - -## ⭐ 接口速查 - -### 频道(Guild) - -| 操作 | 方法 | 路径 | 参数说明 | -| ----------------- | ----- | ----------------------------------- | ------------------------------------------ | -| 获取频道列表 | `GET` | `/users/@me/guilds` | query: `before`, `after`, `limit`(最大100) | -| 获取频道 API 权限 | `GET` | `/guilds/{guild_id}/api_permission` | — | - -### 子频道(Channel) - -| 操作 | 方法 | 路径 | 参数说明 | -| -------------- | ------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — | -| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — | -| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` | -| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) | -| 删除子频道 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 | - -**子频道类型(type)**:`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛 - -### 成员(Member) - -| 操作 | 方法 | 路径 | 参数说明 | -| ------------------ | ----- | -------------------------------------------- | --------------------------------------------- | -| 获取成员列表 | `GET` | `/guilds/{guild_id}/members` | query: `after`(首次填0), `limit`(1-400) | -| 获取成员详情 | `GET` | `/guilds/{guild_id}/members/{user_id}` | — | -| 获取身份组成员列表 | `GET` | `/guilds/{guild_id}/roles/{role_id}/members` | query: `start_index`(首次填0), `limit`(1-400) | -| 获取在线成员数 | `GET` | `/channels/{channel_id}/online_nums` | — | - -### 公告(Announces) - -| 操作 | 方法 | 路径 | 参数说明 | -| -------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------ | -| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) | -| 删除公告 | — | 见受确认保护的删除流程 | 破坏性操作;批量删除需二次确认 | - -### 论坛(Forum)— 仅私域机器人 - -| 操作 | 方法 | 路径 | 参数说明 | -| ------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ | -| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — | -| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — | -| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) | -| 删除帖子 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 | -| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` | - -### 日程(Schedule) - -| 操作 | 方法 | 路径 | 参数说明 | -| -------- | ------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- | -| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | -| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` | -| 删除日程 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 | - -**提醒类型(remind_type)**:`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前 - -> `*` 表示必填参数 - ---- - -## 💡 调用示例 - -### 获取频道列表 - -```json -{ - "method": "GET", - "path": "/users/@me/guilds", - "query": { "limit": "100" } -} -``` - -### 获取子频道列表 - -```json -{ - "method": "GET", - "path": "/guilds/123456/channels" -} -``` - -### 创建子频道 - -```json -{ - "method": "POST", - "path": "/guilds/123456/channels", - "body": { - "name": "新频道", - "type": 0, - "position": 1, - "sub_type": 0 - } -} -``` - -### 获取成员列表(分页) - -```json -{ - "method": "GET", - "path": "/guilds/123456/members", - "query": { "after": "0", "limit": "100" } -} -``` - -### 发表论坛帖子 - -```json -{ - "method": "PUT", - "path": "/channels/789012/threads", - "body": { - "title": "公告标题", - "content": "# 标题\n\n公告内容", - "format": 3 - } -} -``` - -### 创建日程 - -```json -{ - "method": "POST", - "path": "/channels/456789/schedules", - "body": { - "schedule": { - "name": "周会", - "start_timestamp": "1770733800000", - "end_timestamp": "1770737400000", - "remind_type": "2" - } - } -} -``` - -### 创建推荐子频道公告 - -```json -{ - "method": "POST", - "path": "/guilds/123456/announces", - "body": { - "announces_type": 0, - "recommend_channels": [{ "channel_id": "789012", "introduce": "欢迎来到攻略频道" }] - } -} -``` - -### 受确认保护的删除流程 - -删除类 QQ API 不作为普通速查示例暴露。若用户明确要求删除资源,先读取并复述目标对象,确认后再调用 `qqbot_channel_api`:`method` 设为 `"DELETE"`,`confirmed` 设为 `true`,`path` 使用已确认对象对应的资源路径。 - -| 删除对象 | 已确认后使用的 `path` | 额外要求 | -| -------- | ------------------------------------------------ | ---------------------------------------- | -| 子频道 | `/channels/{channel_id}` | 确认子频道 ID 和名称 | -| 单条公告 | `/guilds/{guild_id}/announces/{message_id}` | 确认公告 ID | -| 全部公告 | `/guilds/{guild_id}/announces/all` | 用户再次确认后再传 `bulkConfirmed: true` | -| 帖子 | `/channels/{channel_id}/threads/{thread_id}` | 确认帖子 ID、标题/作者 | -| 日程 | `/channels/{channel_id}/schedules/{schedule_id}` | 确认日程 ID、名称/时间 | - ---- - -## 🔄 常用操作流程 - -### 获取频道和子频道信息 - -``` -1. GET /users/@me/guilds → 获取频道列表,拿到 guild_id -2. GET /guilds/{guild_id}/channels → 获取子频道列表,拿到 channel_id -3. GET /channels/{channel_id} → 获取子频道详情 -``` - -### 论坛发帖 + 评论 - -``` -1. GET /guilds/{guild_id}/channels → 找到论坛子频道(type=10007) -2. PUT /channels/{channel_id}/threads → 发表帖子 -3. GET /channels/{channel_id}/threads → 获取帖子列表 -4. GET /channels/{channel_id}/threads/{thread_id} → 获取帖子详情(含 author_id) -5. POST /channels/{channel_id}/threads/{thread_id}/comment → 发表评论 -``` - -### 成员管理 - -``` -1. GET /users/@me/guilds → 获取 guild_id -2. GET /guilds/{guild_id}/members?after=0&limit=100 → 获取成员列表 - 翻页:用上次最后一个 user.id 作为 after,直到返回空数组 -3. GET /guilds/{guild_id}/members/{user_id} → 获取指定成员详情 -``` - -### 展示成员头像 - -成员详情返回的 `user.avatar` 是头像 URL。默认只展示昵称、ID、加入时间等必要字段;当用户明确要求查看头像/图标或头像是当前任务的必要依据时,再用 Markdown 图片语法内联展示: - -``` -成员信息: -· 昵称:{nick} -· 头像: -![头像]({user.avatar}) -``` - -不要无关输出原始头像 URL 或把头像作为普通链接转发。频道的 `icon` 字段同理:仅在用户明确需要查看时展示。 - ---- - -## 🚨 错误码处理 - -| 错误码 | 说明 | 解决方案 | -| ---------- | ---------------- | ------------------------------------------------------------------------------------- | -| **401** | Token 鉴权失败 | 检查 AppID 和 ClientSecret 配置 | -| **11241** | 频道 API 无权限 | 前往 QQ 开放平台申请权限,或调用 `GET /guilds/{guild_id}/api_permission` 查看可用权限 | -| **11242** | 仅私域机器人可用 | 需在 QQ 开放平台将机器人切换为私域模式 | -| **11243** | 需要管理频道权限 | 确保机器人拥有管理权限 | -| **11281** | 日程频率限制 | 单管理员/天限 10 次,单频道/天限 100 次 | -| **304023** | 推荐子频道超限 | 推荐子频道最多 3 条 | - ---- - -## ⚠️ 注意事项 - -1. **路径中的占位符**(如 `{guild_id}`、`{channel_id}`)必须替换为实际值 -2. **query 参数的值必须为字符串类型**,如 `{ "limit": "100" }` 而非 `{ "limit": 100 }` -3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重 -4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替 -5. **日程**的时间戳为毫秒级字符串 -6. **删除操作不可逆**,必须先确认精确目标并传 `confirmed: true`;批量删除需二次确认并传 `bulkConfirmed: true` -7. **论坛操作**仅私域机器人可用 -8. **子频道分组**(type=4)的 `position` 必须 >= 2 -9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次 -10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `![描述](URL)` 展示 diff --git a/extensions/qqbot/skills/qqbot-channel/references/api_references.md b/extensions/qqbot/skills/qqbot-channel/references/api_references.md deleted file mode 100644 index fb60f84154e1..000000000000 --- a/extensions/qqbot/skills/qqbot-channel/references/api_references.md +++ /dev/null @@ -1,529 +0,0 @@ -# QQ 频道 API 完整参考 - -本文档包含 QQ 开放平台频道相关所有接口的详细参数说明、返回值结构和枚举值定义。 - -通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。 - -## 调用安全规则 - -- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前确认用户明确授权了该操作。 -- 删除接口不可逆。删除前先用读取接口确认目标 ID、名称和范围,并把将要删除的对象复述给用户;`qqbot_channel_api` 要求 `confirmed: true` 才会执行 `DELETE`。 -- 批量删除 sentinel 必须二次确认并额外传 `bulkConfirmed: true`;不要把模糊表达自动扩展成“删除全部”。 -- 删除端点不作为普通 agent 速查路径列出。需要删除时,先用读取接口确认对象,再通过受确认保护的删除流程执行。 -- 成员资料和头像 URL 只用于当前请求;除非用户明确要求查看头像/图标,不要内联展示或转发这些图片 URL。 - ---- - -## 📌 通用说明 - -### 基础 URL - -`https://api.sgroup.qq.com` - -### 鉴权(自动处理) - -工具自动填充以下请求头,无需手动设置: - -``` -Authorization: QQBot {access_token} -Content-Type: application/json -``` - -### 错误返回格式 - -```json -{ - "message": "错误描述", - "code": 错误码 -} -``` - ---- - -## 📦 返回值类型定义 - -### Guild(频道) - -```typescript -interface Guild { - id: string; // 频道 ID - name: string; // 频道名称 - icon: string; // 频道头像 URL - owner_id: string; // 频道拥有者 ID - owner: boolean; // 机器人是否为频道拥有者 - joined_at: string; // 机器人加入时间(ISO 8601) - member_count: number; // 频道成员数 - max_members: number; // 频道最大成员数 - description: string; // 频道描述 -} -``` - -### Channel(子频道) - -```typescript -interface Channel { - id: string; // 子频道 ID - guild_id: string; // 所属频道 ID - name: string; // 子频道名称 - type: number; // 子频道类型(见枚举) - position: number; // 排序位置 - parent_id: string; // 所属分组 ID - owner_id: string; // 创建者 ID - sub_type: number; // 子类型(见枚举) - private_type?: number; // 私密类型(见枚举) - speak_permission?: number; // 发言权限(见枚举) - application_id?: string; // 应用子频道 AppID -} -``` - -### User(用户) - -```typescript -interface User { - id: string; // 用户 ID - username: string; // 用户名 - avatar: string; // 头像 URL - bot: boolean; // 是否为机器人 - union_openid?: string; // 特殊关联应用的 openid - union_user_account?: string; // 特殊关联应用的用户信息 -} -``` - -### Member(成员) - -```typescript -interface Member { - user: User; // 用户基本信息 - nick: string; // 在频道中的昵称 - roles: string[]; // 身份组 ID 列表 - joined_at: string; // 加入频道时间(ISO 8601) - deaf?: boolean; // 是否被禁言 - mute?: boolean; // 是否被闭麦 - pending?: boolean; // 是否待审核 -} -``` - -### APIPermission(API 权限) - -```typescript -interface APIPermission { - path: string; // 接口路径 - method: string; // 请求方法 - desc: string; // 接口描述 - auth_status: number; // 授权状态:0=未授权, 1=已授权 -} -``` - -### AnnouncesResult(公告结果) - -```typescript -interface AnnouncesResult { - guild_id: string; - channel_id: string; - message_id: string; - announces_type: number; - recommend_channels: RecommendChannel[]; -} - -interface RecommendChannel { - channel_id: string; // 推荐的子频道 ID - introduce: string; // 推荐语 -} -``` - -### ThreadDetail(帖子详情) - -```typescript -interface ThreadDetail { - thread: { - guild_id: string; - channel_id: string; - author_id: string; - thread_info: { - thread_id: string; - title: string; - content: string; - date_time: string; - }; - }; -} -``` - -### ThreadListResult(帖子列表) - -```typescript -interface ThreadListResult { - threads: Array<{ - guild_id: string; - channel_id: string; - author_id: string; - thread_info: { - thread_id: string; - title: string; - content: string; - date_time: string; - }; - }>; - is_finish: number; // 1=已到底, 0=还有更多 -} -``` - -### Schedule(日程) - -```typescript -interface Schedule { - id?: string; - name: string; - start_timestamp: string; // 毫秒级时间戳 - end_timestamp: string; - jump_channel_id?: string; - remind_type?: string; - creator?: { - user: { id: string; username: string; bot: boolean }; - nick: string; - joined_at: string; - }; -} -``` - ---- - -## 📋 枚举值定义 - -### 子频道类型(Channel type) - -| 值 | 名称 | 说明 | -| ------- | ---------- | -------------------------------- | -| `0` | 文字子频道 | 普通文字聊天 | -| `2` | 语音子频道 | 语音聊天 | -| `4` | 子频道分组 | 组织子频道的分组(position ≥ 2) | -| `10005` | 直播子频道 | 直播功能 | -| `10006` | 应用子频道 | 需 application_id | -| `10007` | 论坛子频道 | 论坛功能 | - -### 子频道子类型(Channel sub_type) - -| 值 | 名称 | -| --- | ---- | -| `0` | 闲聊 | -| `1` | 公告 | -| `2` | 攻略 | -| `3` | 开黑 | - -### 子频道私密类型(Channel private_type) - -| 值 | 说明 | -| --- | -------------------- | -| `0` | 公开子频道 | -| `1` | 管理员和指定成员可见 | -| `2` | 仅管理员可见 | - -### 子频道发言权限(Channel speak_permission) - -| 值 | 说明 | -| --- | ------------------------------------------ | -| `0` | 无效(仅创建公告子频道时有效,此时为只读) | -| `1` | 所有人可发言 | -| `2` | 仅管理员和指定成员可发言 | - -### 公告类型(announces_type) - -| 值 | 说明 | -| --- | -------- | -| `0` | 成员公告 | -| `1` | 欢迎公告 | - -### 帖子格式(format) - -| 值 | 格式 | -| --- | -------------------- | -| `1` | 纯文本 | -| `2` | HTML | -| `3` | Markdown(**默认**) | -| `4` | JSON(RichText) | - -### 日程提醒类型(remind_type) - -| 值 | 说明 | -| ----- | -------------- | -| `"0"` | 不提醒 | -| `"1"` | 开始时提醒 | -| `"2"` | 开始前 5 分钟 | -| `"3"` | 开始前 15 分钟 | -| `"4"` | 开始前 30 分钟 | -| `"5"` | 开始前 60 分钟 | - -### API 权限授权状态(auth_status) - -| 值 | 说明 | -| --- | ------ | -| `0` | 未授权 | -| `1` | 已授权 | - ---- - -## 📖 各接口详细说明 - -### GET /users/@me/guilds — 获取频道列表 - -**查询参数**: - -| 参数 | 类型 | 必填 | 说明 | -| -------- | ------ | ---- | ---------------------------------------------------- | -| `before` | string | 否 | 读此 guild id 之前的数据 | -| `after` | string | 否 | 读此 guild id 之后的数据(与 before 同时设置时无效) | -| `limit` | string | 否 | 每次拉取条数,默认 100,最大 100 | - -**返回**: `Guild[]` - -**调用示例**: - -```json -{ "method": "GET", "path": "/users/@me/guilds", "query": { "limit": "100" } } -``` - ---- - -### GET /guilds/{guild_id}/api_permission — 获取频道 API 权限 - -**返回**: `{ apis: APIPermission[] }` - -**调用示例**: - -```json -{ "method": "GET", "path": "/guilds/123456/api_permission" } -``` - ---- - -### GET /guilds/{guild_id}/channels — 获取子频道列表 - -**返回**: `Channel[]` - -**调用示例**: - -```json -{ "method": "GET", "path": "/guilds/123456/channels" } -``` - ---- - -### GET /channels/{channel_id} — 获取子频道详情 - -**返回**: `Channel` - ---- - -### POST /guilds/{guild_id}/channels — 创建子频道 - -> ⚠️ 仅私域机器人可用,需管理频道权限 - -**请求体**: - -| 参数 | 类型 | 必填 | 说明 | -| ------------------ | -------- | ---- | ------------------------------------- | -| `name` | string | 是 | 子频道名称 | -| `type` | number | 是 | 子频道类型 | -| `position` | number | 是 | 排序位置(type=4 时 ≥ 2) | -| `sub_type` | number | 否 | 子类型 | -| `parent_id` | string | 否 | 所属分组 ID | -| `private_type` | number | 否 | 私密类型 | -| `private_user_ids` | string[] | 否 | 私密成员列表(private_type=1 时有效) | -| `speak_permission` | number | 否 | 发言权限 | -| `application_id` | string | 否 | 应用 AppID(type=10006 时需要) | - -**返回**: `Channel` - ---- - -### PATCH /channels/{channel_id} — 修改子频道 - -> ⚠️ 仅私域机器人可用 - -**请求体**(至少一个): - -| 参数 | 类型 | 说明 | -| ------------------ | ------ | -------- | -| `name` | string | 名称 | -| `position` | number | 排序位置 | -| `parent_id` | string | 分组 ID | -| `private_type` | number | 私密类型 | -| `speak_permission` | number | 发言权限 | - -**返回**: `Channel` - ---- - -### 删除子频道(破坏性操作) - -> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体子频道 ID、子频道名称和用户删除意图,并传 `confirmed: true`;不要按模糊名称猜测删除目标。确认后使用子频道资源路径 `/channels/{channel_id}`。 - ---- - -### GET /guilds/{guild_id}/members — 获取成员列表 - -> 仅私域机器人可用 - -**查询参数**: - -| 参数 | 类型 | 说明 | -| ------- | ------ | ---------------------------------- | -| `after` | string | 上次最后一个 user.id,首次填 `"0"` | -| `limit` | string | 分页大小 1-400,默认 1 | - -**返回**: `Member[]` - -> 翻页:用最后一个 `user.id` 作为 `after`,直到返回空数组。可能返回重复成员,需按 `user.id` 去重。 - ---- - -### GET /guilds/{guild_id}/members/{user_id} — 获取成员详情 - -**返回**: `Member` - ---- - -### GET /guilds/{guild_id}/roles/{role_id}/members — 获取身份组成员列表 - -> 仅私域机器人可用 - -**查询参数**: - -| 参数 | 类型 | 说明 | -| ------------- | ------ | ---------------------- | -| `start_index` | string | 分页标识,首次填 `"0"` | -| `limit` | string | 分页大小 1-400,默认 1 | - -**返回**: `{ data: Member[], next: string }` - -> 翻页:用 `next` 作为 `start_index`,直到 `data` 为空。 - ---- - -### GET /channels/{channel_id}/online_nums — 获取在线成员数 - -**返回**: `{ online_nums: number }` - ---- - -### POST /guilds/{guild_id}/announces — 创建频道公告 - -**请求体**: - -| 参数 | 类型 | 必填 | 说明 | -| -------------------- | ------ | ---- | --------------------------------------------------- | -| `message_id` | string | 否 | 消息 ID(有值时创建消息公告,此时 channel_id 必填) | -| `channel_id` | string | 否 | 子频道 ID | -| `announces_type` | number | 否 | 0=成员公告,1=欢迎公告 | -| `recommend_channels` | array | 否 | 推荐子频道列表(最多 3 条,message_id 为空时生效) | - -> 两种公告类型会互相顶替 - -**返回**: `AnnouncesResult` - ---- - -### 删除公告(破坏性操作) - -> 调用前必须确认具体公告 ID 并传 `confirmed: true`,确认后使用公告资源路径 `/guilds/{guild_id}/announces/{message_id}`。批量删除全部公告只能在用户明确要求并再次确认后使用 `/guilds/{guild_id}/announces/all`,并且必须额外传 `bulkConfirmed: true`。 - ---- - -### GET /channels/{channel_id}/threads — 获取帖子列表 - -> 仅私域机器人可用,channel_id 须为论坛子频道(type=10007) - -**返回**: `ThreadListResult` - ---- - -### GET /channels/{channel_id}/threads/{thread_id} — 获取帖子详情 - -> 仅私域机器人可用 - -**返回**: `ThreadDetail` - ---- - -### PUT /channels/{channel_id}/threads — 发表帖子 - -> 仅私域机器人可用 - -**请求体**: - -| 参数 | 类型 | 必填 | 说明 | -| --------- | ------ | ---- | ------------------------------------------ | -| `title` | string | 是 | 帖子标题 | -| `content` | string | 是 | 帖子内容 | -| `format` | number | 否 | 1=文本, 2=HTML, 3=Markdown(默认), 4=JSON | - -**返回**: `{ task_id: string, create_time: string }` - ---- - -### 删除帖子(破坏性操作) - -> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体帖子 ID、帖子标题/作者和用户删除意图,并传 `confirmed: true`。确认后使用帖子资源路径 `/channels/{channel_id}/threads/{thread_id}`。 - ---- - -### POST /channels/{channel_id}/threads/{thread_id}/comment — 发表评论 - -> 仅私域机器人可用 - -**请求体**: - -| 参数 | 类型 | 必填 | 说明 | -| -------------------- | ------ | ---- | ------------ | -| `thread_author` | string | 是 | 帖子作者 ID | -| `content` | string | 是 | 评论内容 | -| `thread_create_time` | string | 否 | 帖子创建时间 | -| `image` | string | 否 | 图片链接 | - -**返回**: `{ task_id: string, create_time: number }` - ---- - -### POST /channels/{channel_id}/schedules — 创建日程 - -> 需要管理频道权限。单管理员/天限 10 次,单频道/天限 100 次。 - -**请求体**: - -```json -{ - "schedule": { - "name": "日程名称", - "start_timestamp": "毫秒时间戳", - "end_timestamp": "毫秒时间戳", - "jump_channel_id": "0", - "remind_type": "0" - } -} -``` - -| 参数 | 类型 | 必填 | 说明 | -| -------------------------- | ------ | ---- | ------------------------- | -| `schedule.name` | string | 是 | 日程名称 | -| `schedule.start_timestamp` | string | 是 | 开始时间(毫秒) | -| `schedule.end_timestamp` | string | 是 | 结束时间(毫秒) | -| `schedule.jump_channel_id` | string | 否 | 跳转子频道 ID,默认 `"0"` | -| `schedule.remind_type` | string | 否 | 提醒类型,默认 `"0"` | - -**返回**: `Schedule` - ---- - -### PATCH /channels/{channel_id}/schedules/{schedule_id} — 修改日程 - -> 需要管理频道权限 - -**请求体**:同创建日程 - -**返回**: `Schedule` - ---- - -### 删除日程(破坏性操作) - -> ⚠️ 不可逆!需要管理频道权限。调用前必须确认具体日程 ID、日程名称/时间和用户删除意图,并传 `confirmed: true`。确认后使用日程资源路径 `/channels/{channel_id}/schedules/{schedule_id}`。 diff --git a/extensions/qqbot/skills/qqbot-media/SKILL.md b/extensions/qqbot/skills/qqbot-media/SKILL.md deleted file mode 100644 index 8d3d88e991b3..000000000000 --- a/extensions/qqbot/skills/qqbot-media/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -name: qqbot-media -description: QQBot rich media send and receive support. Use tags only for explicit media send/view requests, treating inbound attachment paths as private current-conversation context. -metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qqbot"] } } } ---- - -# QQBot 富媒体收发 - -## 用法 - -``` -{实际路径或URL} -``` - -系统根据文件扩展名自动识别类型并路由: - -- `.jpg/.png/.gif/.webp/.bmp` → 图片 -- `.silk/.wav/.mp3/.ogg/.aac/.flac` 等 → 语音 -- `.mp4/.mov/.avi/.mkv/.webm` 等 → 视频 -- 其他扩展名 → 文件 -- 无扩展名的当前会话本地/host-read 媒体 → 按加载出的实际媒体类型路由 -- 无扩展名的远程 URL → 可能按文件发送;如需图片/语音/视频,请提供能识别类型的 URL/路径或使用明确媒体标签 - -## 接收媒体 - -- 用户发来的**图片**会由 QQBot 运行时下载到 OpenClaw 管理的 QQBot media 目录,路径只作为当前会话的附件上下文使用。 -- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写。 -- 附件路径和远程 URL 可能包含用户私有内容。不要无关输出本地绝对路径,不要把附件转发到其他会话;只有用户明确要求回发、分析或转存该媒体时才使用。 -- 不承诺长期保留附件。若用户需要长期保存,说明应由用户自行保存或重新发送。 - -## 规则 - -1. **标签必须用开闭标签包裹实际路径或 URL**:`{实际路径或URL}` -2. **使用你实际看到的文件路径**:刚创建文件时,用创建结果显示的路径;只有当沙箱 workspace-write 创建结果实际显示 `/workspace/...` 时,才按原样使用该路径,例如 `/workspace/report.pdf`。 -3. **附件路径直接使用上下文给出的路径**:如果路径来自会话【附件】上下文,不要改写成 `/workspace/...`。 -4. **URL 可以直接发送**:例如 `https://example.com/image.png`。 -5. **本地路径仍受安全根限制**:只能发送当前会话授权的 agent workspace、scoped media roots、OpenClaw 媒体目录或 QQBot 媒体目录内的文件;不要使用 `..` 逃出工作区。 -6. **不要扫描或主动发送上下文之外的本地文件**:只使用用户提供、工具刚生成,或当前会话上下文明确给出的路径。 -7. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB -8. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"** -9. 发送语音时不要重复语音中已朗读的文字 -10. 多个媒体用多个标签 -11. 以会话上下文中的能力说明为准(如未启用语音则不要发语音) diff --git a/extensions/qqbot/skills/qqbot-remind/SKILL.md b/extensions/qqbot/skills/qqbot-remind/SKILL.md deleted file mode 100644 index f738140a5813..000000000000 --- a/extensions/qqbot/skills/qqbot-remind/SKILL.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: qqbot-remind -description: QQBot scheduled reminders. Use only for explicit user requests to create, list, or cancel one-time or recurring QQ reminders; ask for missing time, content, or timezone before scheduling. -metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqbot"] } } } ---- - -# QQ Bot 定时提醒 - -## ⚠️ 意图规则 - -只有当用户明确要求创建、查询或取消提醒/闹钟/定时任务时,才调用工具。闲聊、假设、解释提醒功能、讨论将来计划但未要求创建提醒时,不要调用工具。 - -如果用户确实要求提醒,你没有内存或后台线程,口头承诺"到时候提醒"是无效的——必须调用工具才能真正注册定时任务。时间、提醒内容、目标会话或时区不清楚时先追问;不要替用户猜测。 - ---- - -## 推荐流程(使用 `qqbot_remind` 工具) - -**第一步**:调用 `qqbot_remind` 工具,传入简单参数: - -| 参数 | 说明 | 示例 | -| ---------- | -------------------------------------------- | ------------------------------------------- | -| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` | -| `content` | 提醒内容 | `"喝水"` | -| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — | -| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` | -| `timezone` | IANA 时区(周期提醒建议明确传入) | `"Asia/Shanghai"` / `"America/Los_Angeles"` | -| `jobId` | 任务 ID(仅 remove) | `"xxx"` | - -**第二步**:根据 `qqbot_remind` 的返回结果,回复用户。`qqbot_remind` 会直接创建、查询或取消 Gateway cron 任务;成功后不要再调用 `cron` 工具。 - -### 示例 - -用户说:"5分钟后提醒我喝水" - -1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "time": "5m" }` -2. 工具返回成功后,回复用户:`⏰ 好的,5分钟后提醒你喝水~` - -`qqbot_remind` 不可用时,不要绕过它直接创建 Gateway 任务。说明当前无法安全注册 QQ 提醒,并建议用户检查 QQBot 工具配置。 - ---- - -## cron 表达式速查 - -| 场景 | expr | -| -------------- | ---------------- | -| 每天早上8点 | `"0 8 * * *"` | -| 每天晚上10点 | `"0 22 * * *"` | -| 工作日早上9点 | `"0 9 * * 1-5"` | -| 每周一早上9点 | `"0 9 * * 1"` | -| 每周末上午10点 | `"0 10 * * 0,6"` | -| 每小时整点 | `"0 * * * *"` | - -> 周期提醒应使用用户明确提供、用户资料/会话中可信可得,或用户确认过的 IANA 时区。无法判断时先追问;不要把所有用户都假定在同一时区。 - ---- - -## AI 决策指南 - -| 用户说法 | action | time 格式 | -| ------------------- | ---------------- | --------------- | -| "5分钟后提醒我喝水" | `add` | `"5m"` | -| "1小时后提醒开会" | `add` | `"1h"` | -| "每天8点提醒我打卡" | `add` | `"0 8 * * *"` | -| "工作日早上9点提醒" | `add` | `"0 9 * * 1-5"` | -| "我有哪些提醒" | `list` | — | -| "取消喝水提醒" | `remove` | — | -| "修改提醒时间" | `remove` → `add` | — | -| "提醒我"(无时间) | **需追问** | — | - -纯相对时间("5分钟后"、"1小时后")可直接计算,无需确认。时间、日期、周期、内容或时区模糊/缺失时需追问。周期提醒在回复中说明解释后的本地时间和时区。 - ---- - -## 回复模板 - -- 一次性:`⏰ 好的,{时间}后提醒你{内容}~` -- 周期:`⏰ 收到,{周期}提醒你{内容}~` -- 查询无结果:`📋 目前没有提醒哦~ 说"5分钟后提醒我xxx"试试?` -- 删除成功:`✅ 已取消"{名称}"` diff --git a/extensions/qqbot/src/__traces__/budget-exhaustion.trace.jsonl b/extensions/qqbot/src/__traces__/budget-exhaustion.trace.jsonl deleted file mode 100644 index 1c8506211b05..000000000000 --- a/extensions/qqbot/src/__traces__/budget-exhaustion.trace.jsonl +++ /dev/null @@ -1,18 +0,0 @@ -{"seq":1,"at":0,"dir":"in","kind":"reply-start"} -{"seq":2,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-budget-exhaustion","msg_seq":1,"msg_type":6},"result":{"id":"wire-msg-1","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":3,"at":5000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-budget-exhaustion","msg_seq":2,"msg_type":6},"result":{"id":"wire-msg-2","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":4,"at":10000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-budget-exhaustion","msg_seq":3,"msg_type":6},"result":{"id":"wire-msg-3","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":5,"at":15000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-budget-exhaustion","msg_seq":4,"msg_type":6},"result":{"id":"wire-msg-4","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":6,"at":20000,"dir":"in","kind":"tool-progress","data":{"name":"message","phase":"result"}} -{"seq":7,"at":20000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"Reply 1 via message tool","msg_id":"qq-msg-budget-exhaustion","msg_seq":5,"msg_type":0},"result":{"id":"wire-msg-5","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":8,"at":20000,"dir":"in","kind":"tool-progress","data":{"name":"message","phase":"result"}} -{"seq":9,"at":20000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"Reply 2 via message tool","msg_type":0},"result":{"id":"wire-msg-6","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":10,"at":20000,"dir":"in","kind":"tool-progress","data":{"name":"message","phase":"result"}} -{"seq":11,"at":20000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"Reply 3 via message tool","msg_type":0},"result":{"id":"wire-msg-7","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":12,"at":20000,"dir":"in","kind":"tool-progress","data":{"name":"message","phase":"result"}} -{"seq":13,"at":20000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"Reply 4 via message tool","msg_type":0},"result":{"id":"wire-msg-8","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":14,"at":20000,"dir":"in","kind":"tool-progress","data":{"name":"message","phase":"result"}} -{"seq":15,"at":20000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"Reply 5 via message tool","msg_type":0},"result":{"id":"wire-msg-9","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":16,"at":20000,"dir":"in","kind":"final","data":{"text":"Budget check complete."}} -{"seq":17,"at":20000,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"Budget check complete.","msg_type":0},"result":{"id":"wire-msg-10","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":18,"at":20000,"dir":"in","kind":"idle"} diff --git a/extensions/qqbot/src/__traces__/cancel-mid-stream.trace.jsonl b/extensions/qqbot/src/__traces__/cancel-mid-stream.trace.jsonl deleted file mode 100644 index e1349989d0cd..000000000000 --- a/extensions/qqbot/src/__traces__/cancel-mid-stream.trace.jsonl +++ /dev/null @@ -1,9 +0,0 @@ -{"seq":1,"at":0,"dir":"in","kind":"reply-start"} -{"seq":2,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-cancel-mid-stream","msg_seq":1,"msg_type":6},"result":{"id":"wire-msg-1","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":3,"at":0,"dir":"in","kind":"partial","data":{"text":"Working on the fix"}} -{"seq":4,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Working on the fix","content_type":"markdown","event_id":"qq-msg-cancel-mid-stream","index":0,"input_mode":"replace","input_state":1,"msg_id":"qq-msg-cancel-mid-stream","msg_seq":2},"result":{"id":"stream-msg-1"}}} -{"seq":5,"at":300,"dir":"in","kind":"partial","data":{"text":"Working on the fix: patching now."}} -{"seq":6,"at":500,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Working on the fix: patching now.","content_type":"markdown","event_id":"qq-msg-cancel-mid-stream","index":1,"input_mode":"replace","input_state":1,"msg_id":"qq-msg-cancel-mid-stream","msg_seq":2,"stream_msg_id":"stream-msg-1"},"result":{"id":"stream-msg-1"}}} -{"seq":7,"at":600,"dir":"in","kind":"cancel"} -{"seq":8,"at":600,"dir":"in","kind":"idle"} -{"seq":9,"at":600,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Working on the fix: patching now.","content_type":"markdown","event_id":"qq-msg-cancel-mid-stream","index":2,"input_mode":"replace","input_state":10,"msg_id":"qq-msg-cancel-mid-stream","msg_seq":2,"stream_msg_id":"stream-msg-1"},"result":{"id":"stream-msg-1"}}} diff --git a/extensions/qqbot/src/__traces__/final-only.trace.jsonl b/extensions/qqbot/src/__traces__/final-only.trace.jsonl deleted file mode 100644 index 5d6cf216d0ee..000000000000 --- a/extensions/qqbot/src/__traces__/final-only.trace.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"seq":1,"at":0,"dir":"in","kind":"reply-start"} -{"seq":2,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-final-only","msg_seq":1,"msg_type":6},"result":{"id":"wire-msg-1","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":3,"at":0,"dir":"in","kind":"final","data":{"text":"All checks passed."}} -{"seq":4,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"content":"All checks passed.","msg_id":"qq-msg-final-only","msg_seq":2,"msg_type":0},"result":{"id":"wire-msg-2","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":5,"at":0,"dir":"in","kind":"idle"} diff --git a/extensions/qqbot/src/__traces__/media-interrupt.trace.jsonl b/extensions/qqbot/src/__traces__/media-interrupt.trace.jsonl deleted file mode 100644 index edbea44ca4c8..000000000000 --- a/extensions/qqbot/src/__traces__/media-interrupt.trace.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"seq":1,"at":0,"dir":"in","kind":"reply-start"} -{"seq":2,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-media-interrupt","msg_seq":1,"msg_type":6},"result":{"id":"wire-msg-1","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":3,"at":0,"dir":"in","kind":"partial","data":{"text":"Here is the chart:"}} -{"seq":4,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Here is the chart:","content_type":"markdown","event_id":"qq-msg-media-interrupt","index":0,"input_mode":"replace","input_state":1,"msg_id":"qq-msg-media-interrupt","msg_seq":2},"result":{"id":"stream-msg-1"}}} -{"seq":5,"at":300,"dir":"in","kind":"partial","data":{"text":"Here is the chart:\nhttps://example.com/chart.png\nKey takeaways: ship it."}} -{"seq":6,"at":300,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Here is the chart:\n","content_type":"markdown","event_id":"qq-msg-media-interrupt","index":1,"input_mode":"replace","input_state":10,"msg_id":"qq-msg-media-interrupt","msg_seq":2,"stream_msg_id":"stream-msg-1"},"result":{"id":"stream-msg-1"}}} -{"seq":7,"at":300,"dir":"out","kind":"POST /v2/users/user-openid-trace/files","data":{"payload":{"file_data":"cXFib3QtdHJhY2UtaW1hZ2UtYnl0ZXM=","file_type":1,"srv_send_msg":false},"result":{"file_info":"file-info-1","file_uuid":"file-uuid-1","ttl":600}}} -{"seq":8,"at":300,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"media":{"file_info":"file-info-1"},"msg_id":"qq-msg-media-interrupt","msg_seq":3,"msg_type":7},"result":{"id":"wire-msg-2","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":9,"at":300,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"\nKey takeaways: ship it.","content_type":"markdown","event_id":"qq-msg-media-interrupt","index":0,"input_mode":"replace","input_state":1,"msg_id":"qq-msg-media-interrupt","msg_seq":4},"result":{"id":"stream-msg-2"}}} -{"seq":10,"at":600,"dir":"in","kind":"final","data":{"text":"Here is the chart:\nhttps://example.com/chart.png\nKey takeaways: ship it."}} -{"seq":11,"at":600,"dir":"in","kind":"idle"} -{"seq":12,"at":600,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"\nKey takeaways: ship it.","content_type":"markdown","event_id":"qq-msg-media-interrupt","index":1,"input_mode":"replace","input_state":10,"msg_id":"qq-msg-media-interrupt","msg_seq":4,"stream_msg_id":"stream-msg-2"},"result":{"id":"stream-msg-2"}}} diff --git a/extensions/qqbot/src/__traces__/streaming-happy-c2c.trace.jsonl b/extensions/qqbot/src/__traces__/streaming-happy-c2c.trace.jsonl deleted file mode 100644 index 8e2ea5686445..000000000000 --- a/extensions/qqbot/src/__traces__/streaming-happy-c2c.trace.jsonl +++ /dev/null @@ -1,11 +0,0 @@ -{"seq":1,"at":0,"dir":"in","kind":"reply-start"} -{"seq":2,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/messages","data":{"payload":{"input_notify":{"input_second":10,"input_type":1},"msg_id":"qq-msg-streaming-happy-c2c","msg_seq":1,"msg_type":6},"result":{"id":"wire-msg-1","timestamp":"2026-01-01T00:00:00.000Z"}}} -{"seq":3,"at":0,"dir":"in","kind":"partial","data":{"text":"Deploy status:"}} -{"seq":4,"at":0,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Deploy status:","content_type":"markdown","event_id":"qq-msg-streaming-happy-c2c","index":0,"input_mode":"replace","input_state":1,"msg_id":"qq-msg-streaming-happy-c2c","msg_seq":2},"result":{"id":"stream-msg-1"}}} -{"seq":5,"at":300,"dir":"in","kind":"partial","data":{"text":"Deploy status: build is green."}} -{"seq":6,"at":500,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Deploy status: build is green.","content_type":"markdown","event_id":"qq-msg-streaming-happy-c2c","index":1,"input_mode":"replace","input_state":1,"msg_id":"qq-msg-streaming-happy-c2c","msg_seq":2,"stream_msg_id":"stream-msg-1"},"result":{"id":"stream-msg-1"}}} -{"seq":7,"at":600,"dir":"in","kind":"block-final","data":{"text":"Deploy status: build is green."}} -{"seq":8,"at":600,"dir":"in","kind":"partial","data":{"text":"Rolling out to production now."}} -{"seq":9,"at":900,"dir":"in","kind":"final","data":{"text":"Deploy status: build is green.\n\nRolling out to production now."}} -{"seq":10,"at":900,"dir":"in","kind":"idle"} -{"seq":11,"at":900,"dir":"out","kind":"POST /v2/users/user-openid-trace/stream_messages","data":{"payload":{"content_raw":"Deploy status: build is green.\n\nRolling out to production now.","content_type":"markdown","event_id":"qq-msg-streaming-happy-c2c","index":2,"input_mode":"replace","input_state":10,"msg_id":"qq-msg-streaming-happy-c2c","msg_seq":2,"stream_msg_id":"stream-msg-1"},"result":{"id":"stream-msg-1"}}} diff --git a/extensions/qqbot/src/bridge/approval/capability.ts b/extensions/qqbot/src/bridge/approval/capability.ts deleted file mode 100644 index 0dbc47a1b5f7..000000000000 --- a/extensions/qqbot/src/bridge/approval/capability.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * QQ Bot Approval Capability — entry point. - * - * QQBot uses a simpler approval model than Telegram/Slack: when no - * approver list is configured, the bot sends the approval message to the - * originating conversation and any participant can approve from there. - * - * When `execApprovals` IS configured, it gates which requests are - * handled natively and who is authorized. When it is NOT configured, - * QQBot falls back to "always handle, anyone can approve". - */ - -import { createChannelApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime"; -import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime"; -import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; -import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime"; -import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveApprovalTarget } from "../../engine/approval/index.js"; -import { - isQQBotExecApprovalClientEnabled, - matchesQQBotApprovalAccount, - shouldHandleQQBotExecApprovalRequest, - resolveQQBotExecApprovalConfig, - authorizeQQBotApprovalAction, -} from "../../exec-approvals.js"; -import { ensurePlatformAdapter } from "../bootstrap.js"; -import { resolveQQBotAccount } from "../config.js"; -import { getBridgeLogger } from "../logger.js"; - -/** - * When `execApprovals` is configured, delegate to the profile-based - * check. Otherwise fall back to target-resolvability plus the shared - * per-account ownership rule in `matchesQQBotApprovalAccount` so that - * each QQBot account handler only delivers approvals that originated - * from its own account (openids are account-scoped — cross-account - * delivery fails with 500 on the QQ Bot API). - */ -function shouldHandleRequest(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: { - request: { - sessionKey?: string | null; - turnSourceTo?: string | null; - turnSourceChannel?: string | null; - turnSourceAccountId?: string | null; - }; - }; -}): boolean { - if (hasExecApprovalConfig(params)) { - return shouldHandleQQBotExecApprovalRequest(params as never); - } - if (!canResolveTarget(params.request)) { - return false; - } - return matchesQQBotApprovalAccount({ - cfg: params.cfg, - accountId: params.accountId, - request: params.request as never, - }); -} - -function hasExecApprovalConfig(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): boolean { - return resolveQQBotExecApprovalConfig(params) !== undefined; -} - -function isNativeDeliveryEnabled(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): boolean { - if (hasExecApprovalConfig(params)) { - return isQQBotExecApprovalClientEnabled(params); - } - const account = resolveQQBotAccount(params.cfg, params.accountId); - return account.enabled && account.secretSource !== "none"; -} - -function canResolveTarget(request: { - request: { sessionKey?: string | null; turnSourceTo?: string | null }; -}): boolean { - const sessionKey = request.request.sessionKey ?? null; - const turnSourceTo = request.request.turnSourceTo ?? null; - - const target = resolveApprovalTarget(sessionKey, turnSourceTo); - if (target) { - return true; - } - - const sessionConversation = resolveApprovalRequestSessionConversation({ - request: request as never, - channel: "qqbot", - bundledFallback: true, - }); - return sessionConversation?.id != null; -} - -function resolveNativeDeliveryState(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): { kind: "enabled" } | { kind: "disabled" } { - const enabled = isNativeDeliveryEnabled(params); - return enabled ? { kind: "enabled" } : { kind: "disabled" }; -} - -function createQQBotApprovalCapability(): ChannelApprovalCapability { - return createChannelApprovalCapability({ - authorizeActorAction: ({ cfg, accountId, senderId, approvalKind }) => - authorizeQQBotApprovalAction({ cfg, accountId, senderId, approvalKind }), - - getActionAvailabilityState: resolveNativeDeliveryState, - - getExecInitiatingSurfaceState: resolveNativeDeliveryState, - - describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => { - const prefix = - accountId && accountId !== "default" - ? `channels.qqbot.accounts.${accountId}` - : "channels.qqbot"; - return `QQBot native exec approvals are enabled by default. To restrict who can approve, configure \`${prefix}.execApprovals.approvers\` with QQ user OpenIDs.`; - }, - - delivery: { - hasConfiguredDmRoute: () => true, - shouldSuppressForwardingFallback: (input) => { - const channel = normalizeOptionalString(input.target?.channel); - if (channel !== "qqbot") { - return false; - } - const accountId = - normalizeOptionalString(input.target?.accountId) ?? - normalizeOptionalString(input.request?.request?.turnSourceAccountId); - const result = isNativeDeliveryEnabled({ cfg: input.cfg, accountId }); - getBridgeLogger().debug?.( - `[qqbot:approval] shouldSuppressForwardingFallback channel=${channel} accountId=${accountId} → ${result}`, - ); - return result; - }, - }, - - native: { - describeDeliveryCapabilities: ({ cfg, accountId }) => ({ - enabled: isNativeDeliveryEnabled({ cfg, accountId }), - preferredSurface: "origin" as const, - supportsOriginSurface: true, - supportsApproverDmSurface: false, - notifyOriginWhenDmOnly: false, - }), - resolveOriginTarget: ({ request }) => { - const sessionKey = request.request.sessionKey ?? null; - const turnSourceTo = request.request.turnSourceTo ?? null; - const target = resolveApprovalTarget(sessionKey, turnSourceTo); - if (target) { - return { to: `${target.type}:${target.id}` }; - } - const sessionConversation = resolveApprovalRequestSessionConversation({ - request: request as never, - channel: "qqbot", - bundledFallback: true, - }); - if (sessionConversation?.id) { - const kind = sessionConversation.kind === "group" ? "group" : "c2c"; - return { to: `${kind}:${sessionConversation.id}` }; - } - return null; - }, - }, - - nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({ - eventKinds: ["exec", "plugin"], - isConfigured: ({ cfg, accountId }) => { - const result = isNativeDeliveryEnabled({ cfg, accountId }); - getBridgeLogger().debug?.( - `[qqbot:approval] nativeRuntime.isConfigured accountId=${accountId} → ${result}`, - ); - return result; - }, - shouldHandle: ({ cfg, accountId, request }) => { - const result = shouldHandleRequest({ - cfg, - accountId, - request: request as never, - }); - getBridgeLogger().debug?.( - `[qqbot:approval] nativeRuntime.shouldHandle accountId=${accountId} → ${result}`, - ); - return result; - }, - load: async () => { - // Ensure PlatformAdapter is registered before handler-runtime uses - // getPlatformAdapter(). When the framework spawns the approval handler - // outside the qqbot gateway startAccount context, channel.ts's - // side-effect `import "./bridge/bootstrap.js"` may not have run yet. - ensurePlatformAdapter(); - return (await import("./handler-runtime.js")) - .qqbotApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter; - }, - }), - }); -} - -const qqbotApprovalCapability = createQQBotApprovalCapability(); - -let cachedCapability: ChannelApprovalCapability | undefined; - -export function getQQBotApprovalCapability(): ChannelApprovalCapability { - cachedCapability ??= qqbotApprovalCapability; - return cachedCapability; -} diff --git a/extensions/qqbot/src/bridge/approval/handler-runtime.test.ts b/extensions/qqbot/src/bridge/approval/handler-runtime.test.ts deleted file mode 100644 index e29e8973dbe2..000000000000 --- a/extensions/qqbot/src/bridge/approval/handler-runtime.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Qqbot tests cover native approval presentation behavior. -import type { - ExecApprovalPendingView, - PluginApprovalPendingView, -} from "openclaw/plugin-sdk/approval-handler-runtime"; -import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/approval-runtime"; -import { describe, expect, it } from "vitest"; -import type { InlineKeyboard } from "../../engine/types.js"; -import { qqbotApprovalNativeRuntime } from "./handler-runtime.js"; - -type QQBotPendingPayload = { - text: string; - keyboard: InlineKeyboard; -}; - -function createExecView(commandText: string): ExecApprovalPendingView { - return { - approvalId: "approval-1", - approvalKind: "exec", - phase: "pending", - title: "Exec Approval Required", - metadata: [], - commandText, - commandPreview: "short preview", - actions: [ - { - decision: "allow-once", - label: "Allow Once", - command: "/approve approval-1 allow-once", - style: "success", - }, - { - decision: "deny", - label: "Deny", - command: "/approve approval-1 deny", - style: "danger", - }, - ], - expiresAtMs: Date.now() + 60_000, - }; -} - -function createPluginView(expiresAtMs: number): PluginApprovalPendingView { - return { - approvalId: "plugin:approval-1", - approvalKind: "plugin", - phase: "pending", - title: "Install plugin", - description: "Approve the requested plugin", - metadata: [], - pluginId: "example-plugin", - toolName: "plugin.install", - agentId: "main", - severity: "critical", - actions: [ - { - decision: "allow-once", - label: "Allow Once", - command: "/approve plugin:approval-1 allow-once", - style: "success", - }, - { - decision: "deny", - label: "Deny", - command: "/approve plugin:approval-1 deny", - style: "danger", - }, - ], - expiresAtMs, - }; -} - -describe("qqbotApprovalNativeRuntime", () => { - it("renders the sanitized primary command with callback buttons", async () => { - const secret = `ghp_${"a".repeat(36)}`; - const rawCommand = `printf '${secret}\u200b'\n你好😀`; - const commandText = resolveExecApprovalCommandDisplay({ command: rawCommand }).commandText; - const view = createExecView(commandText); - view.cwd = "/tmp\n![fake](u)"; - view.agentId = "agent```fake"; - const payload = (await qqbotApprovalNativeRuntime.presentation.buildPendingPayload({ - cfg: {} as never, - accountId: "default", - context: {}, - request: { - id: "approval-1", - request: { command: rawCommand, commandPreview: "short preview" }, - createdAtMs: Date.now(), - expiresAtMs: view.expiresAtMs, - }, - approvalKind: "exec", - nowMs: Date.now(), - view, - })) as QQBotPendingPayload; - - expect(commandText).not.toContain(secret); - expect(commandText).toContain("\\u{200B}"); - expect(commandText).toContain("\\u{A}"); - expect(commandText).toContain("你好😀"); - expect(payload.text.replace(/[↩\n]/g, "")).toContain(commandText); - expect(payload.text).not.toContain(secret); - expect(payload.text).not.toContain("short preview"); - expect(payload.text).not.toContain("/tmp\n![fake]"); - expect(payload.text).toContain("📁 目录:\n```\n/tmp\\u{A}![fake](u)\n```"); - expect(payload.text).toContain("🤖 Agent:\n````\nagent```fake\n````"); - expect(payload.keyboard.content.rows[0]?.buttons.map((button) => button.action.data)).toEqual([ - "approve:v2:exec:approval-1:allow-once", - "approve:v2:exec:approval-1:deny", - ]); - }); - - it("renders a plugin approval's actual remaining lifetime", async () => { - const nowMs = 1_000_000; - const view = createPluginView(nowMs + 600_000); - const payload = (await qqbotApprovalNativeRuntime.presentation.buildPendingPayload({ - cfg: {} as never, - accountId: "default", - context: {}, - request: { - id: view.approvalId, - request: { - title: "stale raw title", - description: "stale raw description", - severity: "info", - }, - createdAtMs: nowMs, - expiresAtMs: view.expiresAtMs, - }, - approvalKind: "plugin", - nowMs, - view, - })) as QQBotPendingPayload; - - expect(payload.text).toContain("🔴 审批请求"); - expect(payload.text).toContain("📋 Install plugin"); - expect(payload.text).toContain("📝 Approve the requested plugin"); - expect(payload.text).not.toContain("stale raw"); - expect(payload.text).toContain("⏱️ 超时: 600 秒"); - expect(payload.keyboard.content.rows[0]?.buttons.map((button) => button.action.data)).toEqual([ - "approve:v2:plugin:plugin%3Aapproval-1:allow-once", - "approve:v2:plugin:plugin%3Aapproval-1:deny", - ]); - }); -}); diff --git a/extensions/qqbot/src/bridge/approval/handler-runtime.ts b/extensions/qqbot/src/bridge/approval/handler-runtime.ts deleted file mode 100644 index 236be872e694..000000000000 --- a/extensions/qqbot/src/bridge/approval/handler-runtime.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * QQ Bot Native Approval Runtime Adapter. - * - * Implements the framework's ChannelApprovalNativeRuntimeSpec to deliver - * approval requests as QQ messages with inline keyboard buttons and handle - * resolved/expired lifecycle events. - * - * This file is lazily imported by capability.ts to avoid loading - * heavy dependencies on the critical startup path. - */ - -import type { ChannelApprovalNativeRuntimeSpec } from "openclaw/plugin-sdk/approval-handler-runtime"; -import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; -import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; -import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime"; -import { - buildExecApprovalText, - buildPluginApprovalText, - buildApprovalKeyboard, - resolveApprovalTarget, - type ExecApprovalRequest, - type PluginApprovalRequest, -} from "../../engine/approval/index.js"; -import { getMessageApi, accountToCreds } from "../../engine/messaging/sender.js"; -import type { ChatScope, InlineKeyboard, MessageResponse } from "../../engine/types.js"; -import { - matchesQQBotApprovalAccount, - resolveQQBotExecApprovalConfig, - isQQBotExecApprovalClientEnabled, - shouldHandleQQBotExecApprovalRequest, -} from "../../exec-approvals.js"; -import { ensurePlatformAdapter } from "../bootstrap.js"; -import { resolveQQBotAccount } from "../config.js"; -import { getBridgeLogger } from "../logger.js"; - -type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; - -type QQBotPendingEntry = { - messageId?: string; - targetType: ChatScope; - targetId: string; -}; - -type QQBotPendingPayload = { - text: string; - keyboard: InlineKeyboard; -}; - -function resolveQQTarget(request: ApprovalRequest): { type: ChatScope; id: string } | null { - const sessionConversation = resolveApprovalRequestSessionConversation({ - request: request as never, - channel: "qqbot", - bundledFallback: true, - }); - - const sessionKey = request.request.sessionKey ?? null; - const turnSourceTo = request.request.turnSourceTo ?? null; - - const target = resolveApprovalTarget(sessionKey, turnSourceTo); - if (target) { - return target; - } - - if (sessionConversation?.id) { - const kind = sessionConversation.kind; - const chatScope: ChatScope = kind === "group" ? "group" : "c2c"; - return { type: chatScope, id: sessionConversation.id }; - } - - return null; -} - -type QQBotPreparedTarget = { type: ChatScope; id: string }; - -const qqbotApprovalRuntimeSpec: ChannelApprovalNativeRuntimeSpec< - QQBotPendingPayload, - QQBotPreparedTarget, - QQBotPendingEntry -> = { - eventKinds: ["exec", "plugin"], - - availability: { - isConfigured: ({ cfg, accountId }) => { - if (resolveQQBotExecApprovalConfig({ cfg, accountId }) !== undefined) { - const result = isQQBotExecApprovalClientEnabled({ cfg, accountId }); - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] isConfigured(profile) accountId=${accountId} → ${result}`, - ); - return result; - } - const account = resolveQQBotAccount(cfg, accountId ?? undefined); - const result = account.enabled && account.secretSource !== "none"; - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] isConfigured(fallback) accountId=${accountId} enabled=${account.enabled} secretSource=${account.secretSource} → ${result}`, - ); - return result; - }, - shouldHandle: ({ cfg, accountId, request }) => { - if (resolveQQBotExecApprovalConfig({ cfg, accountId }) !== undefined) { - const result = shouldHandleQQBotExecApprovalRequest({ cfg, accountId, request }); - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] shouldHandle(profile) accountId=${accountId} → ${result}`, - ); - return result; - } - const target = resolveQQTarget(request as ApprovalRequest); - if (target === null) { - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] shouldHandle(fallback) accountId=${accountId} target=null → false`, - ); - return false; - } - const accountMatches = matchesQQBotApprovalAccount({ - cfg, - accountId, - request: request as ApprovalRequest, - }); - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] shouldHandle(fallback) accountId=${accountId} target=${JSON.stringify( - target, - )} accountMatches=${accountMatches} → ${accountMatches}`, - ); - return accountMatches; - }, - }, - - presentation: { - buildPendingPayload: ({ view, nowMs }) => { - const text = - view.approvalKind === "exec" - ? buildExecApprovalText(view, nowMs) - : buildPluginApprovalText(view, nowMs); - const keyboard = buildApprovalKeyboard( - view.approvalId, - view.approvalKind, - view.actions.map((action) => action.decision), - ); - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] buildPendingPayload requestId=${view.approvalId} kind=${view.approvalKind}`, - ); - return { text, keyboard }; - }, - buildResolvedResult: () => ({ kind: "leave" }), - buildExpiredResult: () => ({ kind: "leave" }), - }, - - transport: { - prepareTarget: ({ request }) => { - const target = resolveQQTarget(request as ApprovalRequest); - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] prepareTarget requestId=${request.id} target=${JSON.stringify(target)}`, - ); - if (!target) { - return null; - } - return { target, dedupeKey: `${target.type}:${target.id}` }; - }, - - deliverPending: async ({ cfg, accountId, preparedTarget, pendingPayload }) => { - // Ensure the PlatformAdapter is registered — resolveQQBotAccount below - // calls getPlatformAdapter() to resolve secret inputs. - ensurePlatformAdapter(); - const account = resolveQQBotAccount(cfg, accountId ?? undefined); - const creds = accountToCreds(account); - const messageApi = getMessageApi(account.appId); - - let result: MessageResponse; - try { - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] deliverPending accountId=${accountId} target=${preparedTarget.type}:${preparedTarget.id}`, - ); - result = await messageApi.sendMessage( - preparedTarget.type, - preparedTarget.id, - pendingPayload.text, - creds, - { inlineKeyboard: pendingPayload.keyboard }, - ); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to send approval message to ${preparedTarget.type}:${preparedTarget.id}: ${msg}`, - { cause: err }, - ); - } - - getBridgeLogger().debug?.( - `[qqbot:approval-runtime] deliverPending success accountId=${accountId} messageId=${result.id ?? ""}`, - ); - return { - messageId: result.id, - targetType: preparedTarget.type, - targetId: preparedTarget.id, - }; - }, - }, -}; - -export const qqbotApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter( - qqbotApprovalRuntimeSpec, -) as unknown as ChannelApprovalNativeRuntimeAdapter; diff --git a/extensions/qqbot/src/bridge/bootstrap.test.ts b/extensions/qqbot/src/bridge/bootstrap.test.ts deleted file mode 100644 index e21d66efb045..000000000000 --- a/extensions/qqbot/src/bridge/bootstrap.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Qqbot tests cover the built-in platform adapter boundary. -import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getPlatformAdapter } from "../engine/adapter/index.js"; -import { ensurePlatformAdapter } from "./bootstrap.js"; - -const mocks = vi.hoisted(() => ({ - getRuntimeConfig: vi.fn(), - readRemoteMediaBuffer: vi.fn(), - resolveApprovalOverGateway: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ - readRemoteMediaBuffer: (...args: unknown[]) => mocks.readRemoteMediaBuffer(...args), -})); - -vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", () => ({ - getRuntimeConfig: mocks.getRuntimeConfig, -})); - -vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({ - resolveApprovalOverGateway: mocks.resolveApprovalOverGateway, -})); - -const canonicalLoserResult = { - applied: false, - approval: { - id: "exec:looks-like-exec/1", - urlPath: "/approve/exec%3Alooks-like-exec%2F1", - createdAtMs: 1, - expiresAtMs: 10_000, - presentation: { - kind: "plugin", - title: "Plugin approval", - description: "Approve a plugin operation", - severity: "warning", - allowedDecisions: ["allow-once", "deny"], - }, - status: "denied", - decision: "deny", - resolvedAtMs: 2, - reason: "user", - }, -} satisfies ApprovalResolveResult; - -describe("QQBot built-in platform adapter", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getRuntimeConfig.mockReturnValue({ channels: { qqbot: {} } }); - mocks.resolveApprovalOverGateway.mockResolvedValue(canonicalLoserResult); - ensurePlatformAdapter(); - }); - - it("forwards response header deadlines to the media runtime", async () => { - mocks.readRemoteMediaBuffer.mockResolvedValueOnce({ - buffer: Buffer.from("image"), - fileName: "remote.png", - }); - - const result = await getPlatformAdapter().fetchMedia({ - url: "https://media.qq.com/assets/photo.png", - filePathHint: "photo.png", - maxBytes: 1024, - maxRedirects: 2, - timeoutMs: 5_000, - responseHeaderTimeoutMs: 120_000, - ssrfPolicy: { hostnameAllowlist: ["*.qq.com"] }, - requestInit: { headers: { accept: "image/png" } }, - }); - - expect(result).toEqual({ buffer: Buffer.from("image"), fileName: "remote.png" }); - expect(mocks.readRemoteMediaBuffer).toHaveBeenCalledWith({ - url: "https://media.qq.com/assets/photo.png", - filePathHint: "photo.png", - maxBytes: 1024, - maxRedirects: 2, - timeoutMs: 5_000, - responseHeaderTimeoutMs: 120_000, - ssrfPolicy: { hostnameAllowlist: ["*.qq.com"] }, - requestInit: { headers: { accept: "image/png" } }, - }); - }); - - it("preserves plugin ownership and the canonical first-answer result", async () => { - const adapter = getPlatformAdapter(); - - const result = await adapter.resolveApproval?.({ - approvalId: "exec:looks-like-exec/1", - approvalKind: "plugin", - decision: "allow-once", - accountId: "default", - senderId: "owner", - }); - - expect(mocks.resolveApprovalOverGateway).toHaveBeenCalledWith({ - cfg: { channels: { qqbot: {} } }, - approvalId: "exec:looks-like-exec/1", - approvalKind: "plugin", - decision: "allow-once", - channel: "qqbot", - accountId: "default", - senderId: "owner", - clientDisplayName: "QQBot Approval Handler", - }); - expect(result).toBe(canonicalLoserResult); - }); -}); diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts deleted file mode 100644 index bbf2f231250c..000000000000 --- a/extensions/qqbot/src/bridge/bootstrap.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { - registerPlatformAdapter, - registerPlatformAdapterFactory, - hasPlatformAdapter, - type PlatformAdapter, -} from "../engine/adapter/index.js"; -import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js"; -/** - * Bootstrap the PlatformAdapter for the built-in version. - * - * ## Design - * - * The adapter is registered via two complementary mechanisms: - * - * 1. **Factory registration** (`registerPlatformAdapterFactory`) — a lightweight - * callback stored in `adapter/index.ts` that is invoked lazily by - * `getPlatformAdapter()` on first access. This guarantees the adapter is - * available regardless of module evaluation order or bundler chunk splitting. - * - * 2. **Eager side-effect** (`ensurePlatformAdapter()`) — called at module - * evaluation time when `channel.ts` imports this file. Provides the adapter - * immediately for code that runs synchronously during startup. - * - * Heavy async-only dependencies (`media-runtime`, `config-runtime`, - * `approval-gateway-runtime`) are lazy-imported inside each async method body - * so that this module evaluates with minimal overhead. - * - * Synchronous dependencies (`secret-input`, `temp-path`) are imported - * statically at the top level so they work reliably in both production and - * vitest (which resolves bare specifiers via `resolve.alias`, not Node CJS). - */ -import { getBridgeLogger } from "./logger.js"; - -const loadReadRemoteMediaBuffer = createLazyRuntimeNamedExport( - () => import("openclaw/plugin-sdk/media-runtime"), - "readRemoteMediaBuffer", -); - -function createBuiltinAdapter(): PlatformAdapter { - return { - async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise { - // Built-in version delegates SSRF validation to readRemoteMediaBuffer's ssrfPolicy. - }, - - async resolveSecret(value): Promise { - if (typeof value === "string") { - return value || undefined; - } - return undefined; - }, - - async downloadFile(url: string, destDir: string, filename?: string): Promise { - const readRemoteMediaBuffer = await loadReadRemoteMediaBuffer(); - const result = await readRemoteMediaBuffer({ url, filePathHint: filename }); - const fs = await import("node:fs"); - const path = await import("node:path"); - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - const destPath = path.join(destDir, filename ?? "download"); - fs.writeFileSync(destPath, result.buffer); - return destPath; - }, - - async fetchMedia(options: FetchMediaOptions): Promise { - const readRemoteMediaBuffer = await loadReadRemoteMediaBuffer(); - const result = await readRemoteMediaBuffer({ - url: options.url, - filePathHint: options.filePathHint, - maxBytes: options.maxBytes, - maxRedirects: options.maxRedirects, - timeoutMs: options.timeoutMs, - responseHeaderTimeoutMs: options.responseHeaderTimeoutMs, - ssrfPolicy: options.ssrfPolicy, - requestInit: options.requestInit, - }); - return { buffer: result.buffer, fileName: result.fileName }; - }, - - getTempDir(): string { - return resolvePreferredOpenClawTmpDir(); - }, - - hasConfiguredSecret(value: unknown): boolean { - return hasConfiguredSecretInput(value); - }, - - normalizeSecretInputString(value: unknown): string | undefined { - return normalizeSecretInputString(value) ?? undefined; - }, - - resolveSecretInputString(params: { value: unknown; path: string }): string | undefined { - return normalizeResolvedSecretInputString(params) ?? undefined; - }, - - async resolveApproval(params): Promise { - try { - const { getRuntimeConfig } = await import("openclaw/plugin-sdk/runtime-config-snapshot"); - const { resolveApprovalOverGateway } = - await import("openclaw/plugin-sdk/approval-gateway-runtime"); - const cfg = getRuntimeConfig(); - return await resolveApprovalOverGateway({ - cfg, - approvalId: params.approvalId, - approvalKind: params.approvalKind, - decision: params.decision, - channel: "qqbot", - accountId: params.accountId, - senderId: params.senderId, - clientDisplayName: "QQBot Approval Handler", - }); - } catch (err) { - getBridgeLogger().error(`[qqbot] resolveApproval failed: ${String(err)}`); - throw err; - } - }, - }; -} - -/** - * Ensure the built-in PlatformAdapter is registered. - * - * Safe to call multiple times — only registers on the first invocation. - * Exported for backward compatibility with code that calls it explicitly. - */ -export function ensurePlatformAdapter(): void { - if (!hasPlatformAdapter()) { - registerPlatformAdapter(createBuiltinAdapter()); - } -} - -// Register the adapter factory so getPlatformAdapter() can lazy-init even when -// this module's side-effect import hasn't executed yet (bundler reordering, -// framework-spawned approval handlers, etc.). -registerPlatformAdapterFactory(createBuiltinAdapter); - -// Also eagerly register for the normal startup path (imported by channel.ts). -ensurePlatformAdapter(); diff --git a/extensions/qqbot/src/bridge/channel-entry.ts b/extensions/qqbot/src/bridge/channel-entry.ts deleted file mode 100644 index dd1df5f81475..000000000000 --- a/extensions/qqbot/src/bridge/channel-entry.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Orchestrator for the QQBot `registerFull` hook. - * - * Keeping this function in `src/bridge/` (rather than inline in the - * `extensions/qqbot/index.ts` channel-entry contract) lets the composition - * be unit-tested and aligns with the layering described in the double-repo - * migration spec, where bridge-layer composition code is expected to live - * under `src/bridge/` (or `src/bootstrap/` in the standalone variant). - */ - -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import { registerQQBotFrameworkCommands } from "./commands/framework-registration.js"; -import { registerQQBotTools } from "./tools/index.js"; - -export function registerQQBotFull(api: OpenClawPluginApi): void { - registerQQBotTools(api); - registerQQBotFrameworkCommands(api); -} diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts deleted file mode 100644 index fc0a5e99a12e..000000000000 --- a/extensions/qqbot/src/bridge/commands/framework-context-adapter.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Qqbot tests cover framework context adapter plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -import { describe, expect, it } from "vitest"; -import { buildFrameworkSlashContext } from "./framework-context-adapter.js"; - -function createCommandContext(isAuthorizedSender: boolean): PluginCommandContext { - return { - senderId: "SENDER_OPENID", - channel: "qqbot", - isAuthorizedSender, - args: "on", - commandBody: "/bot-streaming on", - config: {} as OpenClawConfig, - from: "qqbot:c2c:SENDER_OPENID", - requestConversationBinding: async () => undefined, - detachConversationBinding: async () => ({ removed: false }), - getCurrentConversationBinding: async () => null, - } as unknown as PluginCommandContext; -} - -describe("buildFrameworkSlashContext", () => { - it("preserves the framework authorization decision in the slash context", () => { - const authorized = buildFrameworkSlashContext({ - ctx: createCommandContext(true), - account: { - accountId: "default", - enabled: true, - appId: "app", - clientSecret: "secret", - secretSource: "config", - markdownSupport: true, - config: {}, - }, - from: { msgType: "c2c", targetType: "c2c", targetId: "SENDER_OPENID" }, - commandName: "bot-streaming", - }); - const unauthorized = buildFrameworkSlashContext({ - ctx: createCommandContext(false), - account: { - accountId: "default", - enabled: true, - appId: "app", - clientSecret: "secret", - secretSource: "config", - markdownSupport: true, - config: {}, - }, - from: { msgType: "c2c", targetType: "c2c", targetId: "SENDER_OPENID" }, - commandName: "bot-streaming", - }); - - expect(authorized.commandAuthorized).toBe(true); - expect(unauthorized.commandAuthorized).toBe(false); - }); -}); diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts deleted file mode 100644 index a80410a59129..000000000000 --- a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Adapter that builds a `SlashCommandContext` from a framework - * `PluginCommandContext`. - * - * Framework-registered commands enter the plugin through - * `api.registerCommand`, which surfaces a `PluginCommandContext` shape. Our - * engine-side command registry, however, is driven by `SlashCommandContext`. - * This adapter bridges the two so handlers authored against the engine - * registry can be reused unchanged on the framework command surface. - */ - -import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -import type { SlashCommandContext } from "../../engine/commands/slash-commands.js"; -import type { QQBotGroupCommandLevel } from "../../engine/config/group.js"; -import type { ResolvedQQBotAccount } from "../../types.js"; -import type { QQBotFromParseResult } from "./from-parser.js"; - -/** - * Default queue snapshot used for framework-registered commands. - * - * Framework-side command dispatch runs outside the per-sender queue, so - * handlers observe an empty snapshot by design. - */ -const DEFAULT_QUEUE_SNAPSHOT = { - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 10, - senderPending: 0, -} as const; - -interface BuildFrameworkSlashContextInput { - ctx: PluginCommandContext; - account: ResolvedQQBotAccount; - from: QQBotFromParseResult; - commandName: string; - groupCommandLevel?: QQBotGroupCommandLevel; -} - -export function buildFrameworkSlashContext({ - ctx, - account, - from, - commandName, - groupCommandLevel, -}: BuildFrameworkSlashContextInput): SlashCommandContext { - const args = ctx.args ?? ""; - const rawContent = args ? `/${commandName} ${args}` : `/${commandName}`; - - return { - type: from.msgType, - senderId: ctx.senderId ?? "", - messageId: "", - eventTimestamp: new Date().toISOString(), - receivedAt: Date.now(), - rawContent, - args, - accountId: account.accountId, - appId: account.appId, - accountConfig: account.config as unknown as Record, - commandAuthorized: ctx.isAuthorizedSender, - groupCommandLevel, - queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT }, - }; -} diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.test.ts b/extensions/qqbot/src/bridge/commands/framework-registration.test.ts deleted file mode 100644 index 243f2bddb405..000000000000 --- a/extensions/qqbot/src/bridge/commands/framework-registration.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Qqbot tests cover framework registration plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { - OpenClawPluginApi, - OpenClawPluginCommandDefinition, - PluginCommandContext, -} from "openclaw/plugin-sdk/plugin-entry"; -import { describe, expect, it } from "vitest"; -import { - getWrittenQQBotConfig, - installCommandRuntime, -} from "../../engine/commands/slash-command-test-support.js"; -import { ensurePlatformAdapter } from "../bootstrap.js"; -import { registerQQBotFrameworkCommands } from "./framework-registration.js"; - -function createConfig(): OpenClawConfig { - return { - channels: { - qqbot: { - appId: "app", - allowFrom: ["TRUSTED_OPENID"], - streaming: { mode: "off" }, - accounts: { - default: { - allowFrom: ["TRUSTED_OPENID"], - streaming: { mode: "off" }, - }, - }, - }, - }, - }; -} - -function registerCommands(): OpenClawPluginCommandDefinition[] { - ensurePlatformAdapter(); - const commands: OpenClawPluginCommandDefinition[] = []; - const api = { - logger: {}, - registerCommand: (command: OpenClawPluginCommandDefinition) => { - commands.push(command); - }, - } as unknown as OpenClawPluginApi; - - registerQQBotFrameworkCommands(api); - return commands; -} - -function findCommand( - commands: OpenClawPluginCommandDefinition[], - name: string, -): OpenClawPluginCommandDefinition { - const command = commands.find((entry) => entry.name === name); - if (!command) { - throw new Error(`expected QQBot command ${name}`); - } - return command; -} - -function createCommandContext( - config: OpenClawConfig, - from: string | undefined, -): PluginCommandContext { - return { - senderId: "TRUSTED_OPENID", - channel: "qqbot", - isAuthorizedSender: true, - args: "on", - commandBody: "/bot-streaming on", - config, - from, - requestConversationBinding: async () => undefined, - detachConversationBinding: async () => ({ removed: false }), - getCurrentConversationBinding: async () => null, - } as unknown as PluginCommandContext; -} - -describe("registerQQBotFrameworkCommands", () => { - it("registers bot-streaming as an auth-gated framework command", () => { - const command = findCommand(registerCommands(), "bot-streaming"); - - expect(command.requireAuth).toBe(true); - expect(command.channels).toEqual(["qqbot"]); - }); - - it("preserves the private-chat guard for bot-streaming on generic framework calls", async () => { - const config = createConfig(); - const writes: OpenClawConfig[] = []; - installCommandRuntime(config, writes); - const command = findCommand(registerCommands(), "bot-streaming"); - - const missingFromResult = await command.handler(createCommandContext(config, undefined)); - const nonQQBotResult = await command.handler(createCommandContext(config, "generic:dm:user")); - const groupResult = await command.handler( - createCommandContext(config, "qqbot:group:GROUP_OPENID"), - ); - - expect(missingFromResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" }); - expect(nonQQBotResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" }); - expect(groupResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" }); - expect(writes).toHaveLength(0); - }); - - it("keeps private-only framework commands private when command level is all", async () => { - const config = createConfig(); - const qqbot = config.channels?.qqbot as Record; - qqbot.groups = { - GROUP_OPENID: { commandLevel: "all" }, - }; - const writes: OpenClawConfig[] = []; - installCommandRuntime(config, writes); - const command = findCommand(registerCommands(), "bot-streaming"); - - const result = await command.handler(createCommandContext(config, "qqbot:group:GROUP_OPENID")); - - expect(result).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" }); - expect(writes).toHaveLength(0); - }); - - it("allows bot-streaming on explicit QQBot private-chat framework calls", async () => { - const config = createConfig(); - const writes: OpenClawConfig[] = []; - installCommandRuntime(config, writes); - const command = findCommand(registerCommands(), "bot-streaming"); - - const result = await command.handler(createCommandContext(config, "qqbot:c2c:TRUSTED_OPENID")); - - const qqbot = getWrittenQQBotConfig(writes[0]); - expect(result).toEqual({ - text: "✅ 流式消息已开启\n\nAI 的回复将以流式形式逐步显示(仅私聊生效)。", - }); - expect(writes).toHaveLength(1); - expect(qqbot?.streaming).toEqual({ mode: "partial", nativeTransport: true }); - expect(qqbot?.accounts?.default?.streaming).toEqual({ mode: "partial", nativeTransport: true }); - }); -}); diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.ts b/extensions/qqbot/src/bridge/commands/framework-registration.ts deleted file mode 100644 index cb741e12cd70..000000000000 --- a/extensions/qqbot/src/bridge/commands/framework-registration.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Register slash commands that are allowed on the framework surface via - * `api.registerCommand`. - * - * Routing through the framework lets `resolveCommandAuthorization()` apply - * `commands.allowFrom.qqbot` precedence and the `qqbot:` prefix normalization - * before any QQBot command handler runs. - * - * This module is intentionally thin: it wires the engine-side command registry - * (`getFrameworkCommands`) to the framework registration surface via the three - * single-responsibility helpers in this directory. - */ - -import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -import { PRIVATE_CHAT_ONLY_TEXT } from "../../engine/commands/command-visibility.js"; -import { getFrameworkCommands } from "../../engine/commands/slash-commands-impl.js"; -import { resolveGroupCommandLevelFromAccountConfig } from "../../engine/config/group.js"; -import { resolveQQBotAccount } from "../config.js"; -import { buildFrameworkSlashContext } from "./framework-context-adapter.js"; -import { parseQQBotFrom } from "./from-parser.js"; -import { dispatchFrameworkSlashResult } from "./result-dispatcher.js"; - -function isExplicitQQBotC2cFrom(from: string | undefined | null): boolean { - const raw = (from ?? "").trim(); - const stripped = raw.replace(/^qqbot:/iu, ""); - const colonIdx = stripped.indexOf(":"); - if (colonIdx === -1) { - return false; - } - const kind = stripped.slice(0, colonIdx).toLowerCase(); - const targetId = stripped.slice(colonIdx + 1).trim(); - return /^qqbot:/iu.test(raw) && kind === "c2c" && targetId.length > 0; -} - -export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void { - for (const cmd of getFrameworkCommands()) { - api.registerCommand({ - name: cmd.name, - description: cmd.description, - channels: ["qqbot"], - requireAuth: true, - acceptsArgs: true, - handler: async (ctx: PluginCommandContext) => { - const from = parseQQBotFrom(ctx.from); - const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined); - const groupCommandLevel = - from.msgType === "group" || from.msgType === "guild" - ? resolveGroupCommandLevelFromAccountConfig( - account.config as unknown as Record, - from.targetId, - ) - : undefined; - if (cmd.c2cOnly && !isExplicitQQBotC2cFrom(ctx.from)) { - return { text: PRIVATE_CHAT_ONLY_TEXT }; - } - - const slashCtx = buildFrameworkSlashContext({ - ctx, - account, - from, - commandName: cmd.name, - groupCommandLevel, - }); - const result = await cmd.handler(slashCtx); - return await dispatchFrameworkSlashResult({ - result, - account, - from, - logger: api.logger, - }); - }, - }); - } -} diff --git a/extensions/qqbot/src/bridge/commands/from-parser.test.ts b/extensions/qqbot/src/bridge/commands/from-parser.test.ts deleted file mode 100644 index 65cb4d1b0f53..000000000000 --- a/extensions/qqbot/src/bridge/commands/from-parser.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Qqbot tests cover from parser plugin behavior. -import { describe, expect, it } from "vitest"; -import { parseQQBotFrom } from "./from-parser.js"; - -describe("parseQQBotFrom", () => { - it("parses a group from string", () => { - expect(parseQQBotFrom("qqbot:group:ABCDEF")).toEqual({ - msgType: "group", - targetType: "group", - targetId: "ABCDEF", - }); - }); - - it("parses a channel prefix into the guild msgType", () => { - expect(parseQQBotFrom("qqbot:channel:123")).toEqual({ - msgType: "guild", - targetType: "channel", - targetId: "123", - }); - }); - - it("parses a dm prefix", () => { - expect(parseQQBotFrom("qqbot:dm:456")).toEqual({ - msgType: "dm", - targetType: "dm", - targetId: "456", - }); - }); - - it("parses a c2c prefix", () => { - expect(parseQQBotFrom("qqbot:c2c:user-1")).toEqual({ - msgType: "c2c", - targetType: "c2c", - targetId: "user-1", - }); - }); - - it("is case-insensitive on the qqbot: prefix", () => { - expect(parseQQBotFrom("QQBOT:group:gid")).toEqual({ - msgType: "group", - targetType: "group", - targetId: "gid", - }); - }); - - it("handles target ids that contain a colon", () => { - expect(parseQQBotFrom("qqbot:group:GROUP:ID")).toEqual({ - msgType: "group", - targetType: "group", - targetId: "GROUP:ID", - }); - }); - - it("falls back to c2c for unknown prefixes", () => { - expect(parseQQBotFrom("qqbot:unknown:abc")).toEqual({ - msgType: "c2c", - targetType: "c2c", - targetId: "abc", - }); - }); - - it("falls back to c2c for missing from", () => { - expect(parseQQBotFrom(undefined)).toEqual({ - msgType: "c2c", - targetType: "c2c", - targetId: "", - }); - expect(parseQQBotFrom(null)).toEqual({ - msgType: "c2c", - targetType: "c2c", - targetId: "", - }); - expect(parseQQBotFrom("")).toEqual({ - msgType: "c2c", - targetType: "c2c", - targetId: "", - }); - }); - - it("treats a bare prefix (no colon) as c2c with that id", () => { - expect(parseQQBotFrom("qqbot:c2c")).toEqual({ - msgType: "c2c", - targetType: "c2c", - targetId: "c2c", - }); - }); -}); diff --git a/extensions/qqbot/src/bridge/commands/from-parser.ts b/extensions/qqbot/src/bridge/commands/from-parser.ts deleted file mode 100644 index d07651833420..000000000000 --- a/extensions/qqbot/src/bridge/commands/from-parser.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Parse the framework `PluginCommandContext.from` string into the QQBot - * message type and send target. - * - * The framework passes `from` in the form `qqbot::` (case-insensitive - * prefix). We split that string once and map `` into the engine-side - * `SlashCommandContext.type` enum and the outbound `MediaTargetContext.targetType` - * enum. Both enums diverge only for guild/channel, so we keep two lookup - * tables to avoid the nested ternary chain the previous implementation used. - */ - -export interface QQBotFromParseResult { - /** Message type consumed by SlashCommandContext.type. */ - msgType: "c2c" | "guild" | "dm" | "group"; - /** Target type consumed by MediaTargetContext.targetType. */ - targetType: "c2c" | "group" | "channel" | "dm"; - /** Raw target id (everything after the first `:`). */ - targetId: string; -} - -type FromKind = "c2c" | "group" | "channel" | "dm"; - -const MSG_TYPE_MAP: Record = { - c2c: "c2c", - dm: "dm", - group: "group", - channel: "guild", -}; - -const TARGET_TYPE_MAP: Record = { - c2c: "c2c", - dm: "dm", - group: "group", - channel: "channel", -}; - -function isFromKind(value: string): value is FromKind { - return value === "c2c" || value === "dm" || value === "group" || value === "channel"; -} - -/** - * Parse `ctx.from` into the structured fields the QQBot bridge expects. - * - * Unknown or missing prefixes fall back to c2c. The remainder after the first - * `:` is returned verbatim as the target id, matching what the previous inline - * implementation did. - */ -export function parseQQBotFrom(from: string | undefined | null): QQBotFromParseResult { - const stripped = (from ?? "").replace(/^qqbot:/iu, ""); - const colonIdx = stripped.indexOf(":"); - const rawPrefix = colonIdx === -1 ? stripped : stripped.slice(0, colonIdx); - const targetId = colonIdx === -1 ? stripped : stripped.slice(colonIdx + 1); - const kind: FromKind = isFromKind(rawPrefix) ? rawPrefix : "c2c"; - - return { - msgType: MSG_TYPE_MAP[kind], - targetType: TARGET_TYPE_MAP[kind], - targetId, - }; -} diff --git a/extensions/qqbot/src/bridge/commands/result-dispatcher.ts b/extensions/qqbot/src/bridge/commands/result-dispatcher.ts deleted file mode 100644 index 495d7f2eb162..000000000000 --- a/extensions/qqbot/src/bridge/commands/result-dispatcher.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Dispatch a slash command result produced on the framework command surface. - * - * Slash command handlers return one of: - * 1. a plain string (text reply), - * 2. a `SlashCommandFileResult` (text plus a local file to upload), or - * 3. null / unexpected value (we surface a generic warning). - * - * This module isolates the text/file branching so the framework registration - * layer stays declarative and so the file-send side effect has a single - * location where logging and error handling live. - */ - -import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; -import type { SlashCommandResult } from "../../engine/commands/slash-commands.js"; -import { sendDocument, type MediaTargetContext } from "../../engine/messaging/outbound.js"; -import type { ResolvedQQBotAccount } from "../../types.js"; -import type { QQBotFromParseResult } from "./from-parser.js"; - -const UNEXPECTED_RESULT_TEXT = "⚠️ 命令返回了意外结果。"; - -interface FrameworkSlashReply { - text: string; -} - -interface DispatchFrameworkSlashResultInput { - result: SlashCommandResult; - account: ResolvedQQBotAccount; - from: QQBotFromParseResult; - logger?: PluginLogger; -} - -function hasFilePath(value: unknown): value is { text: string; filePath: string } { - return ( - typeof value === "object" && - value !== null && - "filePath" in value && - typeof (value as { filePath: unknown }).filePath === "string" - ); -} - -function buildMediaTarget( - account: ResolvedQQBotAccount, - from: QQBotFromParseResult, -): MediaTargetContext { - return { - targetType: from.targetType, - targetId: from.targetId, - account: account as unknown as MediaTargetContext["account"], - }; -} - -export async function dispatchFrameworkSlashResult({ - result, - account, - from, - logger, -}: DispatchFrameworkSlashResultInput): Promise { - if (typeof result === "string") { - return { text: result }; - } - - if (hasFilePath(result)) { - const mediaCtx = buildMediaTarget(account, from); - try { - await sendDocument(mediaCtx, result.filePath, { - allowQQBotDataDownloads: true, - }); - } catch (err) { - logger?.warn(`framework slash file send failed: ${String(err)}`); - } - return { text: result.text }; - } - - return { text: UNEXPECTED_RESULT_TEXT }; -} diff --git a/extensions/qqbot/src/bridge/config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts deleted file mode 100644 index a5fd7e0d1810..000000000000 --- a/extensions/qqbot/src/bridge/config-shared.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; -// Qqbot helper module supports config shared behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/core"; -import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - describeAccount as engineDescribeAccount, - formatAllowFrom as engineFormatAllowFrom, - isAccountConfigured as engineIsAccountConfigured, -} from "../engine/config/resolve.js"; -import { - applySetupAccountConfig as engineApplySetupAccountConfig, - validateSetupInput as engineValidateSetupInput, -} from "../engine/config/setup-logic.js"; -import type { ResolvedQQBotAccount } from "../types.js"; -import { - listQQBotAccountIds, - resolveDefaultQQBotAccountId, - resolveQQBotAccount, -} from "./config.js"; - -export const qqbotMeta = { - id: "qqbot", - label: "QQ Bot", - selectionLabel: "QQ Bot (Bot API)", - docsPath: "/channels/qqbot", - blurb: "Connect to QQ via official QQ Bot API", - order: 50, - preferSessionLookupForAnnounceTarget: true, -} as const; - -function validateQQBotSetupInput(params: { - accountId: string; - input: ChannelSetupInput; -}): string | null { - return engineValidateSetupInput(params.accountId, params.input); -} - -function applyQQBotSetupAccountConfig(params: { - cfg: OpenClawConfig; - accountId: string; - input: ChannelSetupInput; -}): OpenClawConfig { - return engineApplySetupAccountConfig( - params.cfg as unknown as Record, - params.accountId, - params.input, - ) as OpenClawConfig; -} - -function isQQBotConfigured(account: ResolvedQQBotAccount | undefined): boolean { - return engineIsAccountConfigured(account as never); -} - -function describeQQBotAccount(account: ResolvedQQBotAccount | undefined) { - return engineDescribeAccount(account as never); -} - -export const qqbotConfigAdapter = { - ...createScopedChannelConfigAdapter({ - sectionKey: "qqbot", - listAccountIds: listQQBotAccountIds, - resolveAccount: (cfg, accountId) => - resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }), - defaultAccountId: resolveDefaultQQBotAccountId, - clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"], - resolveAllowFrom: (account) => account.config.allowFrom, - formatAllowFrom: engineFormatAllowFrom, - }), - isConfigured: isQQBotConfigured, - describeAccount: describeQQBotAccount, -}; - -const qqbotSetupAdapterShared = { - resolveAccountId: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => - normalizeLowercaseStringOrEmpty(accountId) || resolveDefaultQQBotAccountId(cfg), - applyAccountName: ({ - cfg, - accountId, - name, - }: { - cfg: OpenClawConfig; - accountId: string; - name?: string; - }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "qqbot", - accountId, - name, - }), - validateInput: ({ accountId, input }: { accountId: string; input: ChannelSetupInput }) => - validateQQBotSetupInput({ accountId, input }), - applyAccountConfig: ({ - cfg, - accountId, - input, - }: { - cfg: OpenClawConfig; - accountId: string; - input: ChannelSetupInput; - }) => applyQQBotSetupAccountConfig({ cfg, accountId, input }), -}; - -export const qqbotSetupContract = defineChannelSetupContract({ - fields: { - token: { - kind: "string", - sensitive: true, - cli: { flags: "--token ", description: "QQBot app id and client secret" }, - }, - tokenFile: { - kind: "string", - sensitive: true, - cli: { flags: "--token-file ", description: "QQBot client secret file" }, - }, - useEnv: { - kind: "boolean", - cli: { flags: "--use-env", description: "Use QQBOT environment credentials" }, - }, - }, - legacyAdapter: qqbotSetupAdapterShared, -}); diff --git a/extensions/qqbot/src/bridge/config.ts b/extensions/qqbot/src/bridge/config.ts deleted file mode 100644 index f2bb97f3c2c5..000000000000 --- a/extensions/qqbot/src/bridge/config.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Qqbot helper module supports config behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; -import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; -import { coerceSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { getPlatformAdapter } from "../engine/adapter/index.js"; -import { - DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID, - applyAccountConfig, - listAccountIds, - resolveAccountBase, - resolveDefaultAccountId, -} from "../engine/config/resolve.js"; -import type { ResolvedQQBotAccount, QQBotAccountConfig } from "../types.js"; - -export const DEFAULT_ACCOUNT_ID = ENGINE_DEFAULT_ACCOUNT_ID; - -function assertNotLegacySecretRefMarker(value: unknown, path: string): void { - const normalized = normalizeSecretInputString(value); - if (!normalized || !/^secretref(?:-env)?:/i.test(normalized)) { - return; - } - throw new Error( - `${path}: legacy SecretRef marker strings are not valid QQ Bot clientSecret values; use a structured SecretRef object instead.`, - ); -} - -function resolveEnvSecretRefValue(params: { - cfg: OpenClawConfig; - value: unknown; - env?: NodeJS.ProcessEnv; -}): string | undefined { - const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults); - if (!ref || ref.source !== "env") { - return undefined; - } - - const providerConfig = params.cfg.secrets?.providers?.[ref.provider]; - if (providerConfig) { - if (providerConfig.source !== "env") { - throw new Error( - `Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "env".`, - ); - } - if (providerConfig.allowlist && !providerConfig.allowlist.includes(ref.id)) { - throw new Error( - `Environment variable "${ref.id}" is not allowlisted in secrets.providers.${ref.provider}.allowlist.`, - ); - } - } else if (ref.provider !== resolveDefaultSecretProviderAlias(params.cfg, "env")) { - throw new Error( - `Secret provider "${ref.provider}" is not configured (ref: env:${ref.provider}:${ref.id}).`, - ); - } - - return normalizeSecretInputString((params.env ?? process.env)[ref.id]); -} - -function resolveQQBotClientSecretInput(params: { - cfg: OpenClawConfig; - value: unknown; - path: string; -}): string | undefined { - assertNotLegacySecretRefMarker(params.value, params.path); - - const envSecret = resolveEnvSecretRefValue({ - cfg: params.cfg, - value: params.value, - }); - if (envSecret) { - return envSecret; - } - - return getPlatformAdapter().resolveSecretInputString({ - value: params.value, - path: params.path, - }); -} - -/** List all configured QQBot account IDs. */ -export function listQQBotAccountIds(cfg: OpenClawConfig): string[] { - return listAccountIds(cfg as unknown as Record); -} - -/** Resolve the default QQBot account ID. */ -export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string { - return resolveDefaultAccountId(cfg as unknown as Record); -} - -/** Resolve QQBot account config for runtime or setup flows. */ -export function resolveQQBotAccount( - cfg: OpenClawConfig, - accountId?: string | null, - opts?: { allowUnresolvedSecretRef?: boolean }, -): ResolvedQQBotAccount { - const raw = cfg as unknown as Record; - const base = resolveAccountBase(raw, accountId); - // Identity, secret, and authorization fields must use the same own-container - // and own-entry projection as account discovery and default selection. - const accountConfig = base.config as QQBotAccountConfig; - - let clientSecret = ""; - let secretSource: "config" | "file" | "env" | "none" = "none"; - - const clientSecretPath = - base.accountId === DEFAULT_ACCOUNT_ID - ? "channels.qqbot.clientSecret" - : `channels.qqbot.accounts.${base.accountId}.clientSecret`; - - const adapter = getPlatformAdapter(); - if (adapter.hasConfiguredSecret(accountConfig.clientSecret)) { - clientSecret = opts?.allowUnresolvedSecretRef - ? (adapter.normalizeSecretInputString(accountConfig.clientSecret) ?? "") - : (resolveQQBotClientSecretInput({ - cfg, - value: accountConfig.clientSecret, - path: clientSecretPath, - }) ?? ""); - secretSource = "config"; - } else if (accountConfig.clientSecretFile) { - try { - const fileSecret = tryReadSecretFileSync( - accountConfig.clientSecretFile, - "QQ Bot client secret", - // Existing clientSecretFile paths may be symlinks or hardlinks. Keep - // that contract while gaining the shared credential size limit. - { rejectHardlinks: false }, - ); - if (fileSecret) { - clientSecret = fileSecret; - secretSource = "file"; - } - } catch { - secretSource = "none"; - } - } else { - const envClientSecret = normalizeOptionalString(process.env.QQBOT_CLIENT_SECRET); - if (envClientSecret && base.accountId === DEFAULT_ACCOUNT_ID) { - clientSecret = envClientSecret; - secretSource = "env"; - } - } - - return { - accountId: base.accountId, - name: accountConfig.name, - enabled: base.enabled, - appId: base.appId, - clientSecret, - secretSource, - systemPrompt: base.systemPrompt, - markdownSupport: base.markdownSupport, - config: accountConfig, - }; -} - -/** Apply account config updates back into the OpenClaw config object. */ -export function applyQQBotAccountConfig( - cfg: OpenClawConfig, - accountId: string, - input: { - appId?: string; - clientSecret?: string; - clientSecretFile?: string; - name?: string; - }, -): OpenClawConfig { - return applyAccountConfig( - cfg as unknown as Record, - accountId, - input, - ) as OpenClawConfig; -} diff --git a/extensions/qqbot/src/bridge/gateway.test.ts b/extensions/qqbot/src/bridge/gateway.test.ts deleted file mode 100644 index 75b2945e8dcd..000000000000 --- a/extensions/qqbot/src/bridge/gateway.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { CoreGatewayContext } from "../engine/gateway/gateway.js"; -import type { ResolvedQQBotAccount } from "../types.js"; -import { startGateway } from "./gateway.js"; - -const mocks = vi.hoisted(() => ({ - coreStartGateway: vi.fn(), - currentConfig: vi.fn(), - ensurePlatformAdapter: vi.fn(), - getRuntime: vi.fn(), - initSender: vi.fn(), - registerAccount: vi.fn(), - setBridgeLogger: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/cli-runtime", () => ({ - resolveRuntimeServiceVersion: () => "test-version", -})); - -vi.mock("../engine/gateway/gateway.js", () => ({ - startGateway: mocks.coreStartGateway, -})); - -vi.mock("../engine/messaging/sender.js", () => ({ - initSender: mocks.initSender, - registerAccount: mocks.registerAccount, -})); - -vi.mock("../engine/utils/audio.js", () => ({ - audioFileToSilkBase64: vi.fn(), - isAudioFile: vi.fn(), - isVoiceAttachment: vi.fn(), - shouldTranscodeVoice: vi.fn(), - waitForFile: vi.fn(), - convertSilkToWav: vi.fn(), -})); - -vi.mock("../engine/utils/format.js", () => ({ formatDuration: vi.fn() })); -vi.mock("../engine/utils/log.js", () => ({ debugLog: vi.fn(), debugError: vi.fn() })); -vi.mock("./bootstrap.js", () => ({ ensurePlatformAdapter: mocks.ensurePlatformAdapter })); -vi.mock("./logger.js", () => ({ setBridgeLogger: mocks.setBridgeLogger })); -vi.mock("./narrowing.js", () => ({ toGatewayAccount: (account: unknown) => account })); -vi.mock("./plugin-version.js", () => ({ resolveQQBotPluginVersion: () => "test-plugin" })); -vi.mock("./runtime.js", () => ({ - getQQBotRuntime: mocks.getRuntime, -})); -vi.mock("./sdk-adapter.js", () => ({ - createSdkAccessAdapter: vi.fn(() => ({})), - createSdkHistoryAdapter: vi.fn(() => ({})), - createSdkMentionGateAdapter: vi.fn(() => ({})), -})); - -function makeAccount(): ResolvedQQBotAccount { - return { - accountId: "test-account", - appId: "test-app", - clientSecret: "test-secret", - enabled: true, - markdownSupport: false, - config: {}, - secretSource: "config", - } as unknown as ResolvedQQBotAccount; -} - -function makeContext(cfg: OpenClawConfig) { - return { - account: makeAccount(), - abortSignal: new AbortController().signal, - cfg, - }; -} - -describe("QQBot gateway config lifecycle", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.getRuntime.mockReturnValue({ - config: { current: mocks.currentConfig }, - }); - mocks.coreStartGateway.mockResolvedValue(undefined); - }); - - it("injects the live runtime config accessor without caching its value", async () => { - const startup = { bindings: [] } as OpenClawConfig; - const first = { bindings: [{ agentId: "first" }] } as OpenClawConfig; - const second = { bindings: [{ agentId: "second" }] } as OpenClawConfig; - mocks.currentConfig.mockReturnValueOnce(first).mockReturnValueOnce(second); - - await startGateway(makeContext(startup)); - - const coreContext = mocks.coreStartGateway.mock.calls[0]?.[0] as CoreGatewayContext; - expect(coreContext.cfg).toBe(startup); - expect(coreContext.getCurrentConfig()).toBe(first); - expect(coreContext.getCurrentConfig()).toBe(second); - expect(mocks.currentConfig).toHaveBeenCalledTimes(2); - }); - - it("fails startup when the runtime config lifecycle is unavailable", async () => { - mocks.getRuntime.mockImplementation(() => { - throw new Error("QQBot runtime not initialized"); - }); - - await expect(startGateway(makeContext({}))).rejects.toThrow("QQBot runtime not initialized"); - expect(mocks.coreStartGateway).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/bridge/gateway.ts b/extensions/qqbot/src/bridge/gateway.ts deleted file mode 100644 index d80b20e96f5c..000000000000 --- a/extensions/qqbot/src/bridge/gateway.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Gateway entry point — thin bridge shell that constructs - * {@link EngineAdapters} and passes them to the engine's - * `startGateway`. - * - * All adapter dependencies are assembled here in one place. - */ - -import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { EngineAdapters } from "../engine/adapter/index.js"; -import { - startGateway as coreStartGateway, - type CoreGatewayContext, -} from "../engine/gateway/gateway.js"; -import { initSender, registerAccount } from "../engine/messaging/sender.js"; -import type { EngineLogger } from "../engine/types.js"; -import * as audioModule from "../engine/utils/audio.js"; -import { formatDuration } from "../engine/utils/format.js"; -import { debugLog, debugError } from "../engine/utils/log.js"; -import type { ResolvedQQBotAccount } from "../types.js"; -import { ensurePlatformAdapter } from "./bootstrap.js"; -import { setBridgeLogger } from "./logger.js"; -import { toGatewayAccount } from "./narrowing.js"; -import { resolveQQBotPluginVersion } from "./plugin-version.js"; -import { getQQBotRuntime } from "./runtime.js"; -import { - createSdkAccessAdapter, - createSdkHistoryAdapter, - createSdkMentionGateAdapter, -} from "./sdk-adapter.js"; - -// ---- One-time startup initialization (module-level) ---- - -const pluginVersion = resolveQQBotPluginVersion(import.meta.url); -initSender({ - pluginVersion, - openclawVersion: resolveRuntimeServiceVersion(), -}); - -// ============ Public types ============ - -export interface GatewayContext { - account: ResolvedQQBotAccount; - abortSignal: AbortSignal; - cfg: OpenClawConfig; - onReady?: (data: unknown) => void; - onResumed?: (data: unknown) => void; - onError?: (error: Error) => void; - onDisconnected?: (info: { reason?: string; fatal?: boolean }) => void; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; - channelRuntime?: { - runtimeContexts: { - register: (params: { - channelId: string; - accountId: string; - capability: string; - context: unknown; - abortSignal?: AbortSignal; - }) => { dispose: () => void }; - }; - }; -} - -// ============ Adapter factory ============ - -/** - * Create the full set of engine adapters from the bridge layer. - * - * This is the **single assembly point** — all SDK → engine binding - * happens here. The engine receives a fully-populated - * {@link EngineAdapters} object with zero global singletons. - */ -function createEngineAdapters(): EngineAdapters { - return { - history: createSdkHistoryAdapter(), - mentionGate: createSdkMentionGateAdapter(), - access: createSdkAccessAdapter(), - audioConvert: { - convertSilkToWav: audioModule.convertSilkToWav, - isVoiceAttachment: audioModule.isVoiceAttachment, - formatDuration, - }, - outboundAudio: { - audioFileToSilkBase64: async (p: string, f?: string[]) => - (await audioModule.audioFileToSilkBase64(p, f)) ?? undefined, - isAudioFile: (p: string, m?: string) => audioModule.isAudioFile(p, m), - shouldTranscodeVoice: (p: string) => audioModule.shouldTranscodeVoice(p), - waitForFile: (p: string, ms?: number) => audioModule.waitForFile(p, ms), - }, - commands: { - resolveVersion: resolveRuntimeServiceVersion, - pluginVersion, - approveRuntimeGetter: () => { - const rt = getQQBotRuntime(); - return { config: rt.config }; - }, - }, - }; -} - -// ============ startGateway ============ - -/** - * Start the Gateway WebSocket connection. - * - * Assembles all adapters and passes them to the engine's core gateway. - */ -export async function startGateway(ctx: GatewayContext): Promise { - ensurePlatformAdapter(); - - const pluginRuntime = getQQBotRuntime(); - const runtime = pluginRuntime as unknown as CoreGatewayContext["runtime"]; - const getCurrentConfig = () => pluginRuntime.config.current() as OpenClawConfig; - const accountLogger = createAccountLogger(ctx.log, ctx.account.accountId); - - // Per-account registration (still global — sender is a leaf utility). - registerAccount(ctx.account.appId, { - logger: accountLogger, - markdownSupport: ctx.account.markdownSupport, - }); - setBridgeLogger(accountLogger); - - if (ctx.channelRuntime) { - accountLogger.info("Registering approval.native runtime context"); - const lease = ctx.channelRuntime.runtimeContexts.register({ - channelId: "qqbot", - accountId: ctx.account.accountId, - capability: "approval.native", - context: { account: ctx.account }, - abortSignal: ctx.abortSignal, - }); - accountLogger.info(`approval.native context registered (lease=${Boolean(lease)})`); - } else { - accountLogger.info("No channelRuntime — skipping approval.native registration"); - } - - const coreCtx: CoreGatewayContext = { - account: toGatewayAccount(ctx.account), - abortSignal: ctx.abortSignal, - cfg: ctx.cfg, - getCurrentConfig, - onReady: ctx.onReady, - onResumed: ctx.onResumed, - onError: ctx.onError, - onDisconnected: ctx.onDisconnected, - log: accountLogger, - runtime, - adapters: createEngineAdapters(), - }; - - return coreStartGateway(coreCtx); -} - -// ============ Per-account logger factory ============ - -function createAccountLogger( - raw: GatewayContext["log"] | undefined, - accountId: string, -): EngineLogger { - const prefix = `[${accountId}]`; - const withMeta = (msg: string, meta?: Record) => - meta && Object.keys(meta).length > 0 ? `${msg} ${JSON.stringify(meta)}` : msg; - - if (!raw) { - return { - info: (msg, meta) => debugLog(`${prefix} ${withMeta(msg, meta)}`), - error: (msg, meta) => debugError(`${prefix} ${withMeta(msg, meta)}`), - warn: (msg, meta) => debugError(`${prefix} ${withMeta(msg, meta)}`), - debug: (msg, meta) => debugLog(`${prefix} ${withMeta(msg, meta)}`), - }; - } - return { - info: (msg, meta) => raw.info(`${prefix} ${withMeta(msg, meta)}`), - error: (msg, meta) => raw.error(`${prefix} ${withMeta(msg, meta)}`), - warn: (msg, meta) => raw.error(`${prefix} ${withMeta(msg, meta)}`), - debug: (msg, meta) => raw.debug?.(`${prefix} ${withMeta(msg, meta)}`), - }; -} diff --git a/extensions/qqbot/src/bridge/logger.ts b/extensions/qqbot/src/bridge/logger.ts deleted file mode 100644 index 0938bbb8d8d5..000000000000 --- a/extensions/qqbot/src/bridge/logger.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Bridge-layer logger — holds the framework logger injected at gateway startup. - * - * Bridge modules (approval, tools, etc.) use this instead of `console.log` or - * engine's `debugLog` so that all logs flow through the OpenClaw log system. - */ - -interface BridgeLogger { - info: (msg: string) => void; - error: (msg: string) => void; - warn?: (msg: string) => void; - debug?: (msg: string) => void; -} - -let loggerInstance: BridgeLogger | null = null; - -/** Register the framework logger. Called once in startGateway(). */ -export function setBridgeLogger(logger: BridgeLogger): void { - loggerInstance = logger; -} - -/** Get the bridge logger. Falls back to console if not yet registered. */ -export function getBridgeLogger(): BridgeLogger { - return ( - loggerInstance ?? { - info: (msg) => console.log(msg), - error: (msg) => console.error(msg), - debug: (msg) => console.log(msg), - } - ); -} diff --git a/extensions/qqbot/src/bridge/narrowing.ts b/extensions/qqbot/src/bridge/narrowing.ts deleted file mode 100644 index e9eadf4adcf4..000000000000 --- a/extensions/qqbot/src/bridge/narrowing.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Qqbot plugin module implements narrowing behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import type { GatewayAccount } from "../engine/types.js"; -import type { ResolvedQQBotAccount } from "../types.js"; - -/** - * Map resolved plugin account to the engine gateway account shape (single assertion on nested config). - */ -export function toGatewayAccount(account: ResolvedQQBotAccount): GatewayAccount { - return { - accountId: account.accountId, - appId: account.appId, - clientSecret: account.clientSecret, - markdownSupport: account.markdownSupport, - systemPrompt: account.systemPrompt, - config: account.config as GatewayAccount["config"], - }; -} - -/** - * Persist OpenClaw config through the injected plugin runtime (typed entry point). - */ -export async function writeOpenClawConfigThroughRuntime( - runtime: PluginRuntime, - cfg: OpenClawConfig, -): Promise { - await runtime.config.replaceConfigFile({ - nextConfig: cfg, - afterWrite: { mode: "auto" }, - }); -} diff --git a/extensions/qqbot/src/bridge/plugin-version.test.ts b/extensions/qqbot/src/bridge/plugin-version.test.ts deleted file mode 100644 index 3d8cfea62a95..000000000000 --- a/extensions/qqbot/src/bridge/plugin-version.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Tests for `resolveQQBotPluginVersion`. - * - * These exercise the directory-walk lookup against controlled fixture - * trees rather than the repo's real `package.json`, so the behaviour - * is deterministic regardless of where the test runs. - */ - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveQQBotPluginVersion } from "./plugin-version.js"; - -/** Create a temp directory tree for an individual test and return its root. */ -function createTempTree(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-pkg-version-")); -} - -function writeJson(file: string, data: unknown): void { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, JSON.stringify(data), "utf8"); -} - -function fakeEntryFileUrl(dir: string): string { - const entryPath = path.join(dir, "gateway.ts"); - // File need not exist for `fileURLToPath` to work; the resolver - // only uses its *parent directory* as the walk start point. - return pathToFileURL(entryPath).href; -} - -describe("resolveQQBotPluginVersion", () => { - let tempRoots: string[] = []; - - beforeEach(() => { - tempRoots = []; - }); - - afterEach(() => { - for (const root of tempRoots) { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - function newTree(): string { - const root = createTempTree(); - tempRoots.push(root); - return root; - } - - it("returns the version from the nearest matching package.json", () => { - const root = newTree(); - const pluginDir = path.join(root, "extensions", "qqbot"); - const bridgeDir = path.join(pluginDir, "src", "bridge"); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/qqbot", - version: "2026.4.16", - }); - fs.mkdirSync(bridgeDir, { recursive: true }); - - const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); - - expect(version).toBe("2026.4.16"); - }); - - it("skips package.json files whose name field does not match", () => { - const root = newTree(); - // Parent package.json belongs to the framework, not the plugin. - writeJson(path.join(root, "package.json"), { - name: "openclaw", - version: "9.9.9", - }); - const pluginDir = path.join(root, "extensions", "qqbot"); - const bridgeDir = path.join(pluginDir, "src", "bridge"); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/qqbot", - version: "2026.4.16", - }); - fs.mkdirSync(bridgeDir, { recursive: true }); - - const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); - - // Must stop at the plugin manifest, never bubble up to the framework one. - expect(version).toBe("2026.4.16"); - }); - - it("ignores manifests with unrelated name and returns unknown when no match is found", () => { - const root = newTree(); - // Only an unrelated manifest exists up the tree. - writeJson(path.join(root, "package.json"), { - name: "some-other-package", - version: "1.0.0", - }); - const startDir = path.join(root, "extensions", "qqbot", "src", "bridge"); - fs.mkdirSync(startDir, { recursive: true }); - - const version = resolveQQBotPluginVersion(fakeEntryFileUrl(startDir)); - - expect(version).toBe("unknown"); - }); - - it("returns unknown when no package.json exists above the start directory", () => { - const root = newTree(); - const startDir = path.join(root, "extensions", "qqbot", "src", "bridge"); - fs.mkdirSync(startDir, { recursive: true }); - - const version = resolveQQBotPluginVersion(fakeEntryFileUrl(startDir)); - - expect(version).toBe("unknown"); - }); - - it("returns unknown when the matching manifest lacks a version field", () => { - const root = newTree(); - const pluginDir = path.join(root, "extensions", "qqbot"); - const bridgeDir = path.join(pluginDir, "src", "bridge"); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/qqbot", - // version intentionally missing - }); - fs.mkdirSync(bridgeDir, { recursive: true }); - - const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); - - expect(version).toBe("unknown"); - }); - - it("tolerates a malformed package.json and keeps walking", () => { - const root = newTree(); - const pluginDir = path.join(root, "extensions", "qqbot"); - const bridgeDir = path.join(pluginDir, "src", "bridge"); - // Broken manifest at the expected plugin location. - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "package.json"), "{ not valid json", "utf8"); - // Valid matching manifest higher up (unusual layout but still resolvable). - writeJson(path.join(root, "package.json"), { - name: "@openclaw/qqbot", - version: "2026.9.9", - }); - fs.mkdirSync(bridgeDir, { recursive: true }); - - const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); - - expect(version).toBe("2026.9.9"); - }); -}); diff --git a/extensions/qqbot/src/bridge/plugin-version.ts b/extensions/qqbot/src/bridge/plugin-version.ts deleted file mode 100644 index 70c62e7756df..000000000000 --- a/extensions/qqbot/src/bridge/plugin-version.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * QQBot plugin version resolver. - * - * Reads the version field from this plugin's own `package.json` by - * walking up the directory tree starting from `import.meta.url` of the - * caller until a `package.json` whose `name` field matches the plugin - * package id is located. - * - * Why not a hardcoded relative path? - * - The source file can live at different depths depending on whether - * we run from raw sources (`src/bridge/gateway.ts`) or a future - * compiled output. Hardcoding `"../../package.json"` breaks as soon - * as the source layout changes, which is what caused the previous - * `vunknown` regression. - * - A `name` guard prevents accidentally reading the parent - * `openclaw/package.json` (the framework root) when the plugin - * lives inside the monorepo. - * - * The lookup is performed only once per process at startup, so the - * synchronous file I/O is negligible. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** `name` field in this plugin's `package.json`. */ -const QQBOT_PLUGIN_PKG_NAME = "@openclaw/qqbot"; - -/** Sentinel used when the version cannot be resolved. */ -const QQBOT_PLUGIN_VERSION_UNKNOWN = "unknown"; - -/** - * Resolve the QQBot plugin version from `package.json`. - * - * @param startUrl — pass `import.meta.url` from the call site so the - * lookup begins at the caller's file regardless of where this helper - * itself lives. Falls back to this module's own location when omitted. - */ -export function resolveQQBotPluginVersion(startUrl?: string): string { - const entryUrl = startUrl ?? import.meta.url; - let dir: string; - try { - dir = path.dirname(fileURLToPath(entryUrl)); - } catch { - return QQBOT_PLUGIN_VERSION_UNKNOWN; - } - - const root = path.parse(dir).root; - while (dir && dir !== root) { - const candidate = path.join(dir, "package.json"); - if (fs.existsSync(candidate)) { - const version = readQQBotVersionFromManifest(candidate); - if (version) { - return version; - } - } - const parent = path.dirname(dir); - if (parent === dir) { - break; - } - dir = parent; - } - - return QQBOT_PLUGIN_VERSION_UNKNOWN; -} - -/** - * Read the `version` field from a `package.json` file and return it - * only when the manifest describes the QQBot plugin itself. - * - * Returning `null` for mismatched or malformed manifests lets the - * caller keep walking up the directory tree until the correct package - * boundary is located. - */ -function readQQBotVersionFromManifest(manifestPath: string): string | null { - let raw: string; - try { - raw = fs.readFileSync(manifestPath, "utf8"); - } catch { - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!parsed || typeof parsed !== "object") { - return null; - } - const manifest = parsed as { name?: unknown; version?: unknown }; - if (manifest.name !== QQBOT_PLUGIN_PKG_NAME) { - return null; - } - if (typeof manifest.version !== "string" || manifest.version.length === 0) { - return null; - } - return manifest.version; -} diff --git a/extensions/qqbot/src/bridge/runtime.ts b/extensions/qqbot/src/bridge/runtime.ts deleted file mode 100644 index b3668951084a..000000000000 --- a/extensions/qqbot/src/bridge/runtime.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Qqbot plugin module implements runtime behavior. -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import { setOpenClawVersion } from "../engine/messaging/sender.js"; - -// Single plugin runtime per process — concurrent multi-tenant qqbot runtimes are not supported. -const { setRuntime: _setRuntime, getRuntime: getQQBotRuntime } = - createPluginRuntimeStore({ - pluginId: "qqbot", - errorMessage: "QQBot runtime not initialized", - }); - -/** Set the QQBot runtime and inject the framework version into the User-Agent. */ -function setQQBotRuntime(runtime: PluginRuntime): void { - _setRuntime(runtime); - // Inject the framework version into the User-Agent string (same as standalone). - setOpenClawVersion(runtime.version); -} - -export { getQQBotRuntime, setQQBotRuntime }; diff --git a/extensions/qqbot/src/bridge/sdk-adapter.ts b/extensions/qqbot/src/bridge/sdk-adapter.ts deleted file mode 100644 index 2e3582767b0e..000000000000 --- a/extensions/qqbot/src/bridge/sdk-adapter.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Qqbot plugin module implements sdk adapter behavior. -import { parseAccessGroupAllowFromEntry } from "openclaw/plugin-sdk/access-groups"; -import { - createChannelIngressResolver, - defineStableChannelIngressIdentity, -} from "openclaw/plugin-sdk/channel-ingress-runtime"; -import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-mention-gating"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - createChannelHistoryWindow, - type HistoryEntry as SdkHistoryEntry, -} from "openclaw/plugin-sdk/reply-history"; -import { resolveQQBotEffectivePolicies } from "../engine/access/resolve-policy.js"; -import { normalizeQQBotAllowFrom, normalizeQQBotSenderId } from "../engine/access/sender-match.js"; -import type { HistoryPort, HistoryEntryLike } from "../engine/adapter/history.port.js"; -import type { AccessPort } from "../engine/adapter/index.js"; -import type { MentionGatePort } from "../engine/adapter/mention-gate.port.js"; - -const qqbotIngressIdentity = defineStableChannelIngressIdentity({ - key: "sender-id", - normalize: normalizeQQBotSenderId, - isWildcardEntry: (entry) => normalizeQQBotSenderId(entry) === "*", -}); - -function asSdkMap(map: Map): Map { - return map as unknown as Map; -} - -export function createSdkHistoryAdapter(): HistoryPort { - return { - recordPendingHistoryEntry(params: { - historyMap: Map; - historyKey: string; - entry?: T | null; - limit: number; - }) { - return createChannelHistoryWindow({ historyMap: asSdkMap(params.historyMap) }).record({ - historyKey: params.historyKey, - entry: params.entry as SdkHistoryEntry | undefined, - limit: params.limit, - }) as T[]; - }, - - buildPendingHistoryContext(params) { - return createChannelHistoryWindow({ - historyMap: asSdkMap(params.historyMap), - }).buildPendingContext({ - historyKey: params.historyKey, - limit: params.limit, - currentMessage: params.currentMessage, - formatEntry: params.formatEntry as (entry: SdkHistoryEntry) => string, - lineBreak: params.lineBreak, - }); - }, - - clearPendingHistory(params) { - createChannelHistoryWindow({ historyMap: asSdkMap(params.historyMap) }).clear({ - historyKey: params.historyKey, - limit: params.limit, - }); - }, - }; -} - -export function createSdkMentionGateAdapter(): MentionGatePort { - return { - resolveInboundMentionDecision(params) { - return resolveInboundMentionDecision(params); - }, - }; -} - -export function createSdkAccessAdapter(): AccessPort { - return { - async resolveInboundAccess(input) { - const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input); - const rawGroupAllowFrom = - input.groupAllowFrom && input.groupAllowFrom.length > 0 - ? input.groupAllowFrom - : (input.allowFrom ?? []); - const normalizedAllowFrom = normalizeQQBotAllowFrom(input.allowFrom); - const dmAllowFromForIngress = - dmPolicy === "open" && normalizedAllowFrom.length === 0 ? ["*"] : (input.allowFrom ?? []); - - const commandOwnerAllowFrom = input.isGroup - ? [] - : input.allowFrom && input.allowFrom.length > 0 - ? input.allowFrom - : ["*"]; - const resolved = await createChannelIngressResolver({ - channelId: "qqbot", - accountId: input.accountId, - identity: qqbotIngressIdentity, - cfg: input.cfg as OpenClawConfig, - }).message({ - subject: { stableId: input.senderId }, - conversation: { - kind: input.isGroup ? "group" : "direct", - id: input.conversationId, - }, - event: { - mayPair: false, - }, - dmPolicy, - groupPolicy, - policy: { - groupAllowFromFallbackToAllowFrom: false, - }, - allowFrom: dmAllowFromForIngress, - groupAllowFrom: rawGroupAllowFrom, - command: { - commandOwnerAllowFrom, - }, - }); - return resolved; - }, - async resolveSlashCommandAuthorization(input) { - return await resolveQQBotSlashCommandAuthorized(input); - }, - }; -} - -async function resolveQQBotSlashCommandAuthorized(params: { - cfg: unknown; - accountId: string; - isGroup: boolean; - senderId: string; - conversationId: string; - allowFrom?: Array | null; - groupAllowFrom?: Array | null; - commandsAllowFrom?: Array | null; -}): Promise { - const rawAllowFrom = - params.commandsAllowFrom ?? - (params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0 - ? params.groupAllowFrom - : params.allowFrom); - const explicitAllowFrom = normalizeQQBotCommandAllowFrom(rawAllowFrom); - if (explicitAllowFrom.length === 0) { - return false; - } - const resolved = await createChannelIngressResolver({ - channelId: "qqbot", - accountId: params.accountId, - identity: qqbotIngressIdentity, - cfg: params.cfg as OpenClawConfig, - }).message({ - subject: { stableId: params.senderId }, - conversation: { - kind: params.isGroup ? "group" : "direct", - id: params.conversationId, - }, - event: { - kind: "slash-command", - authMode: "none", - mayPair: false, - }, - dmPolicy: "allowlist", - groupPolicy: "open", - allowFrom: explicitAllowFrom, - command: { - modeWhenAccessGroupsOff: "configured", - }, - }); - return resolved.commandAccess.authorized; -} - -function normalizeQQBotCommandAllowFrom( - rawAllowFrom: Array | null | undefined, -): string[] { - const entries: string[] = []; - for (const rawEntry of rawAllowFrom ?? []) { - const entry = String(rawEntry).trim(); - if (!entry) { - continue; - } - if (parseAccessGroupAllowFromEntry(entry)) { - entries.push(entry); - continue; - } - const normalized = normalizeQQBotSenderId(entry); - if (normalized && normalized !== "*") { - entries.push(normalized); - } - } - return entries; -} diff --git a/extensions/qqbot/src/bridge/setup/finalize.test.ts b/extensions/qqbot/src/bridge/setup/finalize.test.ts deleted file mode 100644 index 023d9c668caa..000000000000 --- a/extensions/qqbot/src/bridge/setup/finalize.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - createNonExitingRuntimeEnv, - createQueuedWizardPrompter, -} from "openclaw/plugin-sdk/plugin-test-runtime"; -import { describe, expect, it, vi } from "vitest"; - -const qrConnect = vi.hoisted(() => vi.fn()); -const connectorModuleState = vi.hoisted(() => ({ loaded: false })); - -vi.mock("@tencent-connect/qqbot-connector", () => { - connectorModuleState.loaded = true; - return { qrConnect }; -}); - -import { registerPlatformAdapter } from "../../engine/adapter/index.js"; -import { finalizeQQBotSetup } from "./finalize.js"; - -registerPlatformAdapter({ - hasConfiguredSecret: () => false, -} as never); - -type FinalizeParams = Parameters[0]; - -function createParams(beforePersistentEffect: () => Promise): FinalizeParams { - const { prompter } = createQueuedWizardPrompter({ selectValues: ["qr"] }); - return { - cfg: {}, - accountId: "default", - forceAllowFrom: false, - prompter, - runtime: createNonExitingRuntimeEnv(), - options: { beforePersistentEffect }, - }; -} - -describe("QQ Bot setup persistent effects", () => { - it("revalidates immediately before starting QR binding", async () => { - qrConnect.mockReset(); - qrConnect.mockResolvedValue([{ appId: "qq-app", appSecret: "qq-secret" }]); - expect(connectorModuleState.loaded).toBe(false); - const beforePersistentEffect = vi.fn(async () => { - expect(connectorModuleState.loaded).toBe(true); - }); - - const result = await finalizeQQBotSetup(createParams(beforePersistentEffect)); - - expect(beforePersistentEffect).toHaveBeenCalledTimes(1); - expect(qrConnect).toHaveBeenCalledWith({ source: "openclaw" }); - expect(beforePersistentEffect.mock.invocationCallOrder[0]).toBeLessThan( - qrConnect.mock.invocationCallOrder[0]!, - ); - expect(result.cfg.channels?.qqbot?.appId).toBe("qq-app"); - }); - - it("propagates a stale inference guard outside the QR binding catch", async () => { - qrConnect.mockReset(); - const guardError = new Error("verified inference changed"); - const beforePersistentEffect = vi.fn(async () => { - throw guardError; - }); - const params = createParams(beforePersistentEffect); - - await expect(finalizeQQBotSetup(params)).rejects.toBe(guardError); - - expect(qrConnect).not.toHaveBeenCalled(); - expect(params.runtime.error).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/bridge/setup/finalize.ts b/extensions/qqbot/src/bridge/setup/finalize.ts deleted file mode 100644 index 96a19b620465..000000000000 --- a/extensions/qqbot/src/bridge/setup/finalize.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -// Qqbot plugin module implements finalize behavior. -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; -import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { applyQQBotAccountConfig, resolveQQBotAccount } from "../config.js"; - -type SetupPrompter = Parameters>[0]["prompter"]; -type SetupRuntime = Parameters>[0]["runtime"]; -type SetupOptions = Parameters>[0]["options"]; - -function isQQBotAccountConfigured(cfg: OpenClawConfig, accountId: string): boolean { - const account = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); - return Boolean(account.appId && account.clientSecret); -} - -async function reportQQBotLinkFailure( - params: { prompter: SetupPrompter; runtime: SetupRuntime }, - error: unknown, -): Promise { - params.runtime.error(`QQ Bot 绑定失败: ${String(error)}`); - await params.prompter.note( - ["绑定失败,您可以稍后手动配置。", `文档: ${formatDocsLink("/channels/qqbot", "qqbot")}`].join( - "\n", - ), - "QQ Bot", - ); -} - -async function linkViaQrCode(params: { - cfg: OpenClawConfig; - accountId: string; - prompter: SetupPrompter; - runtime: SetupRuntime; - beforePersistentEffect?: () => Promise; -}): Promise { - let connector: typeof import("@tencent-connect/qqbot-connector"); - try { - connector = await import("@tencent-connect/qqbot-connector"); - } catch (error) { - await reportQQBotLinkFailure(params, error); - return params.cfg; - } - - await params.beforePersistentEffect?.(); - try { - const accounts: { appId: string; appSecret: string }[] = await connector.qrConnect({ - source: "openclaw", - }); - - if (accounts.length === 0) { - await params.prompter.note("未获取到任何 QQ Bot 账号信息。", "QQ Bot"); - return params.cfg; - } - - let next = params.cfg; - - for (const [i, { appId, appSecret }] of accounts.entries()) { - // use current account id for first account, and use app id for subsequent accounts - const targetAccountId = i === 0 ? params.accountId : appId; - - next = applyQQBotAccountConfig(next, targetAccountId, { - appId, - clientSecret: appSecret, - }); - } - - if (accounts.length === 1) { - const account = expectDefined(accounts.at(0), "single linked QQ Bot account"); - params.runtime.log(`✔ QQ Bot 绑定成功!(AppID: ${account.appId})`); - } else { - const idList = accounts.map((a) => a.appId).join(", "); - params.runtime.log(`✔ ${accounts.length} 个 QQ Bot 绑定成功!(AppID: ${idList})`); - } - - return next; - } catch (error) { - await reportQQBotLinkFailure(params, error); - return params.cfg; - } -} - -async function linkViaManualInput(params: { - cfg: OpenClawConfig; - accountId: string; - prompter: SetupPrompter; -}): Promise { - const appId = await params.prompter.text({ - message: "请输入 QQ Bot AppID", - validate: (value: string) => (value.trim() ? undefined : "AppID 不能为空"), - }); - - const appSecret = await params.prompter.text({ - message: "请输入 QQ Bot AppSecret", - validate: (value: string) => (value.trim() ? undefined : "AppSecret 不能为空"), - }); - - const next = applyQQBotAccountConfig(params.cfg, params.accountId, { - appId: appId.trim(), - clientSecret: appSecret.trim(), - }); - - await params.prompter.note("✔ QQ Bot 配置完成!", "QQ Bot"); - return next; -} - -export async function finalizeQQBotSetup(params: { - cfg: OpenClawConfig; - accountId: string; - forceAllowFrom: boolean; - prompter: SetupPrompter; - runtime: SetupRuntime; - options?: SetupOptions; -}): Promise<{ cfg: OpenClawConfig }> { - const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID; - let next = params.cfg; - - const configured = isQQBotAccountConfigured(next, accountId); - - const mode = await params.prompter.select({ - message: configured ? "QQ 已绑定,选择操作" : "选择 QQ 绑定方式", - options: [ - { - value: "qr", - label: "扫码绑定(推荐)", - hint: "使用 QQ 扫描二维码自动完成绑定", - }, - { - value: "manual", - label: "手动输入 QQ Bot AppID 和 AppSecret", - hint: "需到 QQ 开放平台 q.qq.com 查看", - }, - { - value: "skip", - label: configured ? "保持当前配置" : "稍后配置", - }, - ], - }); - - if (mode === "qr") { - next = await linkViaQrCode({ - cfg: next, - accountId, - prompter: params.prompter, - runtime: params.runtime, - beforePersistentEffect: params.options?.beforePersistentEffect, - }); - } else if (mode === "manual") { - next = await linkViaManualInput({ - cfg: next, - accountId, - prompter: params.prompter, - }); - } else if (!configured) { - await params.prompter.note( - ["您可以稍后运行以下命令重新选择 QQ Bot 进行配置:", " openclaw channels add"].join("\n"), - "QQ Bot", - ); - } - - return { cfg: next }; -} diff --git a/extensions/qqbot/src/bridge/setup/surface.ts b/extensions/qqbot/src/bridge/setup/surface.ts deleted file mode 100644 index 31c7ddf6add0..000000000000 --- a/extensions/qqbot/src/bridge/setup/surface.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Qqbot plugin module implements surface behavior. -import { - createStandardChannelSetupStatus, - setSetupChannelEnabled, -} from "openclaw/plugin-sdk/setup"; -import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { isAccountConfigured } from "../../engine/config/resolve.js"; -import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; -import { finalizeQQBotSetup } from "./finalize.js"; - -const channel = "qqbot" as const; - -export const qqbotSetupWizard: ChannelSetupWizard = { - channel, - status: createStandardChannelSetupStatus({ - channelLabel: "QQ Bot", - configuredLabel: "configured", - unconfiguredLabel: "needs AppID + AppSercet", - configuredHint: "configured", - unconfiguredHint: "needs AppID + AppSercet", - configuredScore: 1, - unconfiguredScore: 6, - resolveConfigured: ({ cfg, accountId }) => - (accountId ? [accountId] : listQQBotAccountIds(cfg)).some((resolvedAccountId) => { - const account = resolveQQBotAccount(cfg, resolvedAccountId, { - allowUnresolvedSecretRef: true, - }); - return isAccountConfigured(account as never); - }), - }), - credentials: [], - finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime, options }) => - await finalizeQQBotSetup({ cfg, accountId, forceAllowFrom, prompter, runtime, options }), - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; diff --git a/extensions/qqbot/src/bridge/tools/channel.test.ts b/extensions/qqbot/src/bridge/tools/channel.test.ts deleted file mode 100644 index 225d867f0d95..000000000000 --- a/extensions/qqbot/src/bridge/tools/channel.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginToolContext, -} from "openclaw/plugin-sdk/core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { fetchWithSsrFGuardMock, getAccessTokenMock } = vi.hoisted(() => ({ - fetchWithSsrFGuardMock: vi.fn(), - getAccessTokenMock: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, fetchWithSsrFGuard: fetchWithSsrFGuardMock }; -}); - -vi.mock("../../engine/messaging/sender.js", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, getAccessToken: getAccessTokenMock }; -}); - -import { ensurePlatformAdapter } from "../bootstrap.js"; -import { registerChannelTool } from "./channel.js"; - -const cfg = { - channels: { - qqbot: { - appId: "app-a", - clientSecret: "secret-a", - accounts: { - bot2: { - appId: "app-b", - clientSecret: "secret-b", - }, - }, - }, - }, -} as OpenClawConfig; - -function registerToolFactory( - config: OpenClawConfig = cfg, -): (context: OpenClawPluginToolContext) => AnyAgentTool | null { - let factory: ((context: OpenClawPluginToolContext) => AnyAgentTool | null) | undefined; - const api = { - config, - registerTool( - tool: AnyAgentTool | ((context: OpenClawPluginToolContext) => AnyAgentTool | null), - ) { - if (typeof tool === "function") { - factory = tool; - } - }, - } as unknown as OpenClawPluginApi; - registerChannelTool(api); - if (!factory) { - throw new Error("Expected QQBot channel API tool factory"); - } - return factory; -} - -describe("bridge/tools/channel", () => { - beforeEach(() => { - ensurePlatformAdapter(); - getAccessTokenMock.mockImplementation( - async (appId: string, secret: string) => `token-for-${appId}-${secret}`, - ); - fetchWithSsrFGuardMock.mockResolvedValue({ - response: new Response(JSON.stringify([{ id: "guild-1" }]), { status: 200 }), - release: vi.fn(async () => {}), - }); - }); - - afterEach(() => { - getAccessTokenMock.mockReset(); - fetchWithSsrFGuardMock.mockReset(); - }); - - it("uses the active QQBot account for token acquisition and API authorization", async () => { - const tool = registerToolFactory()({ messageChannel: "qqbot", agentAccountId: "bot2" }); - expect(tool).not.toBeNull(); - - await tool?.execute("call-b", { method: "GET", path: "/users/@me/guilds" }); - - expect(getAccessTokenMock).toHaveBeenCalledWith("app-b", "secret-b"); - expect(getAccessTokenMock).not.toHaveBeenCalledWith("app-a", "secret-a"); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - init: expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "QQBot token-for-app-b-secret-b", - }), - }), - }), - ); - }); - - it("uses the configured default account without an active account", async () => { - const configuredDefault = { - ...cfg, - channels: { - qqbot: { - ...cfg.channels?.qqbot, - defaultAccount: "bot2", - }, - }, - } as OpenClawConfig; - const tool = registerToolFactory(configuredDefault)({}); - expect(tool).not.toBeNull(); - - await tool?.execute("call-default", { method: "GET", path: "/users/@me/guilds" }); - - expect(getAccessTokenMock).toHaveBeenCalledWith("app-b", "secret-b"); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - init: expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "QQBot token-for-app-b-secret-b", - }), - }), - }), - ); - }); - - it("does not expose the tool when the active account has no credentials", () => { - expect( - registerToolFactory()({ messageChannel: "qqbot", agentAccountId: "missing" }), - ).toBeNull(); - expect(getAccessTokenMock).not.toHaveBeenCalled(); - }); - - it("does not expose the tool when the active account is disabled", () => { - const disabledAccount = { - ...cfg, - channels: { - qqbot: { - ...cfg.channels?.qqbot, - accounts: { - bot2: { - ...cfg.channels?.qqbot?.accounts?.bot2, - enabled: false, - }, - }, - }, - }, - } as OpenClawConfig; - - expect( - registerToolFactory(disabledAccount)({ - messageChannel: "qqbot", - agentAccountId: "bot2", - }), - ).toBeNull(); - expect(getAccessTokenMock).not.toHaveBeenCalled(); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("does not treat another channel's account ID as a QQBot account", async () => { - const tool = registerToolFactory()({ messageChannel: "discord", agentAccountId: "bot2" }); - expect(tool).not.toBeNull(); - - await tool?.execute("call-discord", { method: "GET", path: "/users/@me/guilds" }); - - expect(getAccessTokenMock).toHaveBeenCalledWith("app-a", "secret-a"); - expect(getAccessTokenMock).not.toHaveBeenCalledWith("app-b", "secret-b"); - }); -}); diff --git a/extensions/qqbot/src/bridge/tools/channel.ts b/extensions/qqbot/src/bridge/tools/channel.ts deleted file mode 100644 index 21ff8811df92..000000000000 --- a/extensions/qqbot/src/bridge/tools/channel.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Qqbot plugin module implements channel behavior. -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginToolContext, -} from "openclaw/plugin-sdk/core"; -import { ChannelApiSchema, executeChannelApi } from "../../engine/tools/channel-api.js"; -import type { ChannelApiParams } from "../../engine/tools/channel-api.js"; -import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; - -/** - * Register the QQ channel API proxy tool. - * - * The tool acts as an authenticated HTTP proxy for the QQ Open Platform - * channel APIs. Agents learn endpoint details from the skill docs and - * send requests through this proxy. - */ -function createChannelTool( - cfg: NonNullable, - context: OpenClawPluginToolContext, -): AnyAgentTool | null { - // Bind credentials when the per-run tool is built; a process-wide account - // selection would let one QQBot ingress account exercise another account's API authority. - const activeConfig = context.runtimeConfig ?? cfg; - const activeChannel = context.messageChannel ?? context.deliveryContext?.channel; - const account = resolveQQBotAccount( - activeConfig, - activeChannel === "qqbot" - ? (context.agentAccountId ?? context.deliveryContext?.accountId) - : undefined, - ); - if (!account.enabled || !account.appId || !account.clientSecret) { - return null; - } - - return { - name: "qqbot_channel_api", - label: "QQBot Channel API", - description: - "Authenticated HTTP proxy for QQ Open Platform channel APIs. " + - "Use write and delete endpoints only after explicit user intent; DELETE requires confirmed=true, and bulk deletes require bulkConfirmed=true after confirming the exact target. " + - "Common endpoints: " + - "list guilds GET /users/@me/guilds | " + - "list channels GET /guilds/{guild_id}/channels | " + - "get channel GET /channels/{channel_id} | " + - "create channel POST /guilds/{guild_id}/channels | " + - "list members GET /guilds/{guild_id}/members?after=0&limit=100 | " + - "get member GET /guilds/{guild_id}/members/{user_id} | " + - "list threads GET /channels/{channel_id}/threads | " + - "create thread PUT /channels/{channel_id}/threads | " + - "create announce POST /guilds/{guild_id}/announces | " + - "create schedule POST /channels/{channel_id}/schedules. " + - "See the qqbot-channel skill for full endpoint details.", - parameters: ChannelApiSchema, - async execute(_toolCallId, params) { - const { getAccessToken } = await import("../../engine/messaging/sender.js"); - const accessToken = await getAccessToken(account.appId, account.clientSecret); - return executeChannelApi(params as ChannelApiParams, { - accessToken, - cfg: activeConfig, - accountId: account.accountId, - }); - }, - }; -} - -export function registerChannelTool(api: OpenClawPluginApi): void { - const cfg = api.config; - if (!cfg || listQQBotAccountIds(cfg).length === 0) { - return; - } - - api.registerTool((context) => createChannelTool(cfg, context), { - name: "qqbot_channel_api", - }); -} diff --git a/extensions/qqbot/src/bridge/tools/index.ts b/extensions/qqbot/src/bridge/tools/index.ts deleted file mode 100644 index 18844fdcf198..000000000000 --- a/extensions/qqbot/src/bridge/tools/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Aggregate QQBot plugin tool registrations. - * - * New tools should be added here rather than in the channel-entry contract - * file so that the plugin-level `index.ts` stays a pure declaration. - */ - -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { registerChannelTool } from "./channel.js"; -import { registerRemindTool } from "./remind.js"; - -export function registerQQBotTools(api: OpenClawPluginApi): void { - registerChannelTool(api); - registerRemindTool(api); -} diff --git a/extensions/qqbot/src/bridge/tools/remind.test.ts b/extensions/qqbot/src/bridge/tools/remind.test.ts deleted file mode 100644 index 28beff1d57f8..000000000000 --- a/extensions/qqbot/src/bridge/tools/remind.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginToolContext, -} from "openclaw/plugin-sdk/core"; -// Qqbot tests cover remind plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { callGatewayToolMock } = vi.hoisted(() => ({ - callGatewayToolMock: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({ - callGatewayTool: callGatewayToolMock, -})); - -import { registerRemindTool } from "./remind.js"; - -function createRegisteredRemindTool(context: OpenClawPluginToolContext = {}): AnyAgentTool { - let factory: ((ctx: OpenClawPluginToolContext) => AnyAgentTool) | undefined; - const api = { - registerTool(tool: AnyAgentTool | ((ctx: OpenClawPluginToolContext) => AnyAgentTool)) { - if (typeof tool === "function") { - factory = tool; - } - }, - } as unknown as OpenClawPluginApi; - registerRemindTool(api); - if (!factory) { - throw new Error("Expected QQBot reminder tool factory"); - } - return factory(context); -} - -type CronAddToolPayload = { - name?: string; - schedule?: { - kind?: string; - at?: string; - atMs?: number; - }; - sessionTarget?: string; - payload?: { - kind?: string; - message?: string; - toolsAllow?: string[]; - }; - delivery?: { - mode?: string; - channel?: string; - to?: string; - accountId?: string; - }; -}; - -describe("bridge/tools/remind", () => { - beforeEach(() => { - callGatewayToolMock.mockReset(); - callGatewayToolMock.mockResolvedValue({ ok: true }); - }); - - it("schedules reminders directly through Gateway cron with ambient QQ delivery context", async () => { - callGatewayToolMock.mockResolvedValue({ id: "job-1" }); - const tool = createRegisteredRemindTool({ - deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" }, - }); - - const result = await tool.execute("tool-call-1", { - action: "add", - content: "drink water", - time: "5m", - }); - - const addCall = callGatewayToolMock.mock.calls.at(0); - const addPayload = addCall?.[2] as CronAddToolPayload | undefined; - expect(addCall?.[0]).toBe("cron.add"); - expect(addCall?.[1]).toEqual({ timeoutMs: 60_000 }); - expect(addPayload).not.toHaveProperty("job"); - expect(addPayload?.name).toBe("Reminder: drink water"); - expect(addPayload?.schedule?.kind).toBe("at"); - expect(addPayload?.schedule?.at).toEqual(expect.any(String)); - expect(addPayload?.schedule).not.toHaveProperty("atMs"); - expect(addPayload?.sessionTarget).toBe("isolated"); - expect(addPayload?.payload?.kind).toBe("agentTurn"); - expect(addPayload?.payload?.message).toContain("drink water"); - expect(addPayload?.payload?.toolsAllow).toEqual([]); - expect(addPayload?.delivery).toEqual({ - mode: "announce", - channel: "qqbot", - to: "qqbot:c2c:user-openid", - accountId: "bot2", - }); - expect(result.details).toEqual({ - ok: true, - action: "add", - summary: '⏰ Reminder in 5m: "drink water"', - cronResult: { id: "job-1" }, - }); - }); - - it("routes list and remove through Gateway cron without exposing generic cron to the model", async () => { - const tool = createRegisteredRemindTool(); - - await tool.execute("tool-call-1", { action: "list" }); - await tool.execute("tool-call-2", { action: "remove", jobId: "job-1" }); - - expect(callGatewayToolMock).toHaveBeenNthCalledWith(1, "cron.list", { timeoutMs: 60_000 }, {}); - expect(callGatewayToolMock).toHaveBeenNthCalledWith( - 2, - "cron.remove", - { timeoutMs: 60_000 }, - { jobId: "job-1" }, - ); - }); -}); diff --git a/extensions/qqbot/src/bridge/tools/remind.ts b/extensions/qqbot/src/bridge/tools/remind.ts deleted file mode 100644 index 708a8c8ce65a..000000000000 --- a/extensions/qqbot/src/bridge/tools/remind.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Qqbot plugin module implements remind behavior. -import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime"; -import type { - AnyAgentTool, - OpenClawPluginApi, - OpenClawPluginToolContext, -} from "openclaw/plugin-sdk/core"; -import { RemindSchema, executeScheduledRemind } from "../../engine/tools/remind-logic.js"; -import type { RemindCronAction, RemindParams } from "../../engine/tools/remind-logic.js"; -import { getRequestContext } from "../../engine/utils/request-context.js"; - -type CronGatewayCaller = (params: RemindCronAction) => Promise; - -type RemindToolDeps = { - callCron: CronGatewayCaller; -}; - -const DEFAULT_GATEWAY_TIMEOUT_MS = 60_000; - -function unexpectedCronParams(params: never): never { - throw new Error(`Unsupported reminder cron action: ${JSON.stringify(params)}`); -} - -const defaultDeps: RemindToolDeps = { - callCron: async (params) => { - switch (params.action) { - case "list": - return await callGatewayTool("cron.list", { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, {}); - case "remove": - return await callGatewayTool( - "cron.remove", - { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, - { jobId: params.jobId }, - ); - case "add": - return await callGatewayTool( - "cron.add", - { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, - params.job, - ); - } - return unexpectedCronParams(params); - }, -}; - -function createRemindTool( - toolContext: OpenClawPluginToolContext = {}, - deps: RemindToolDeps = defaultDeps, -): AnyAgentTool { - return { - name: "qqbot_remind", - label: "QQBot Reminder", - description: - "Create, list, and remove QQ reminders. " + - "Use only for explicit user requests, and ask when reminder content, schedule, or timezone is ambiguous. " + - "This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" + - "Create: action=add, content=message, time=schedule (to is optional, " + - "resolved automatically from the current conversation)\n" + - "List: action=list\n" + - "Remove: action=remove, jobId=job id from list\n" + - 'Time examples: "5m", "1h", "0 8 * * *"; include timezone for recurring cron reminders when known.', - parameters: RemindSchema, - async execute(_toolCallId, params) { - const ctx = getRequestContext(); - return await executeScheduledRemind( - params as RemindParams, - { - fallbackTo: ctx?.target ?? toolContext.deliveryContext?.to, - fallbackAccountId: ctx?.accountId ?? toolContext.deliveryContext?.accountId, - }, - deps.callCron, - ); - }, - }; -} - -export function registerRemindTool(api: OpenClawPluginApi): void { - api.registerTool((ctx) => createRemindTool(ctx), { name: "qqbot_remind" }); -} diff --git a/extensions/qqbot/src/channel.credential-recovery.test.ts b/extensions/qqbot/src/channel.credential-recovery.test.ts deleted file mode 100644 index 71275e059b80..000000000000 --- a/extensions/qqbot/src/channel.credential-recovery.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -// QQBot tests cover backup recovery eligibility at the channel lifecycle boundary. -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ResolvedQQBotAccount } from "./types.js"; - -const { loadCredentialBackupMock, startGatewayMock, writeConfigMock } = vi.hoisted(() => ({ - loadCredentialBackupMock: vi.fn<(accountId?: string) => unknown>(), - startGatewayMock: vi.fn<(options: unknown) => Promise>(() => new Promise(() => {})), - writeConfigMock: vi.fn<(runtime: unknown, cfg: unknown) => Promise>(async () => {}), -})); - -vi.mock("./engine/config/credential-backup.js", () => ({ - loadCredentialBackup: (accountId?: string) => loadCredentialBackupMock(accountId), - saveCredentialBackup: vi.fn(), -})); - -vi.mock("./bridge/gateway.js", () => ({ - startGateway: (options: unknown) => startGatewayMock(options), -})); - -vi.mock("./bridge/runtime.js", () => ({ - getQQBotRuntime: () => ({}), -})); - -vi.mock("./bridge/narrowing.js", async (importOriginal) => ({ - ...(await importOriginal()), - writeOpenClawConfigThroughRuntime: (runtime: unknown, cfg: unknown) => - writeConfigMock(runtime, cfg), -})); - -import { qqbotPlugin } from "./channel.js"; - -function makeAccount(overrides: Partial): ResolvedQQBotAccount { - return { - accountId: "default", - appId: "", - clientSecret: "", - enabled: true, - markdownSupport: true, - secretSource: "none", - config: {}, - ...overrides, - }; -} - -function startAccount(account: ResolvedQQBotAccount, cfg: Record) { - const start = qqbotPlugin.gateway?.startAccount; - if (!start) { - throw new Error("expected QQBot gateway startAccount"); - } - void start({ - account, - accountId: account.accountId, - cfg, - runtime: {}, - abortSignal: new AbortController().signal, - getStatus: () => ({ - accountId: account.accountId, - running: true, - connected: false, - lastConnectedAt: null, - lastError: null, - }), - setStatus: vi.fn(), - } as never); -} - -describe("QQBot credential backup recovery", () => { - afterEach(() => { - vi.clearAllMocks(); - startGatewayMock.mockImplementation(() => new Promise(() => {})); - }); - - it("keeps partial live credentials authoritative over a stale backup", async () => { - loadCredentialBackupMock.mockReturnValue({ - accountId: "default", - appId: "old-app", - clientSecret: "old-secret", - }); - const cfg = { channels: { qqbot: { appId: "new-app" } } }; - const account = makeAccount({ appId: "new-app" }); - - expect(qqbotPlugin.config.isConfigured?.(account, cfg as never)).toBe(false); - expect(qqbotPlugin.config.describeAccount?.(account, cfg as never)?.configured).toBe(false); - - startAccount(account, cfg); - await vi.waitFor(() => expect(startGatewayMock).toHaveBeenCalledOnce()); - - expect(writeConfigMock).not.toHaveBeenCalled(); - expect(startGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - account: expect.objectContaining({ appId: "new-app", clientSecret: "" }), - }), - ); - }); - - it("restores a backup when all live credential inputs are absent", async () => { - loadCredentialBackupMock.mockReturnValue({ - accountId: "default", - appId: "backup-app", - clientSecret: "backup-secret", - }); - const cfg = { channels: { qqbot: {} } }; - const account = makeAccount({}); - - expect(qqbotPlugin.config.isConfigured?.(account, cfg as never)).toBe(true); - expect(qqbotPlugin.config.describeAccount?.(account, cfg as never)?.configured).toBe(true); - - startAccount(account, cfg); - await vi.waitFor(() => expect(startGatewayMock).toHaveBeenCalledOnce()); - - expect(writeConfigMock).toHaveBeenCalledOnce(); - expect(startGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - account: expect.objectContaining({ - appId: "backup-app", - clientSecret: "backup-secret", - }), - }), - ); - }); -}); diff --git a/extensions/qqbot/src/channel.gateway-status.test.ts b/extensions/qqbot/src/channel.gateway-status.test.ts deleted file mode 100644 index 07dcbd3e95fd..000000000000 --- a/extensions/qqbot/src/channel.gateway-status.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Qqbot tests cover channel gateway status truth on disconnect. -import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { qqbotPlugin } from "./channel.js"; -import type { ResolvedQQBotAccount } from "./types.js"; - -const startGatewayMock = vi.hoisted(() => vi.fn()); - -vi.mock("./bridge/gateway.js", () => ({ - startGateway: startGatewayMock, -})); - -type StartGatewayOptions = { - onReady?: (data: unknown) => void; - onResumed?: (data: unknown) => void; - onError?: (error: Error) => void; - onDisconnected?: (info: { reason?: string; fatal?: boolean }) => void; -}; - -async function startAccountAndCaptureGatewayOptions() { - startGatewayMock.mockImplementation(() => new Promise(() => {})); - const statusWrites: ChannelAccountSnapshot[] = []; - let status: ChannelAccountSnapshot = { - accountId: "test-account", - running: true, - connected: false, - lastConnectedAt: null, - lastError: null, - }; - const account = { - accountId: "test-account", - appId: "test-app", - clientSecret: "test-secret", - enabled: true, - markdownSupport: false, - config: {}, - secretSource: "config", - } as unknown as ResolvedQQBotAccount; - const ctx = { - cfg: {}, - accountId: "test-account", - account, - runtime: {}, - abortSignal: new AbortController().signal, - getStatus: () => status, - setStatus: (next: ChannelAccountSnapshot) => { - status = next; - statusWrites.push(next); - }, - }; - const startAccount = qqbotPlugin.gateway?.startAccount; - expect(startAccount).toBeDefined(); - void startAccount?.(ctx as Parameters>[0]); - await vi.waitFor(() => { - expect(startGatewayMock).toHaveBeenCalled(); - }); - const options = startGatewayMock.mock.calls[0]?.[0] as StartGatewayOptions; - return { account, options, statusWrites, getStatus: () => status }; -} - -describe("qqbot channel gateway status", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("marks the account disconnected when the gateway reports a disconnect", async () => { - const { options, getStatus } = await startAccountAndCaptureGatewayOptions(); - - options.onReady?.({}); - expect(getStatus().connected).toBe(true); - expect(getStatus().lifecycle).toBe("ready"); - - expect(options.onDisconnected).toBeDefined(); - options.onDisconnected?.({ reason: "close code 1006", fatal: false }); - expect(getStatus().connected).toBe(false); - expect(getStatus().running).toBe(true); - expect(getStatus().lifecycle).toBe("recovering"); - }); - - it("marks fatal disconnects unhealthy and records the close reason", async () => { - const { account, options, getStatus } = await startAccountAndCaptureGatewayOptions(); - - options.onReady?.({}); - options.onDisconnected?.({ reason: "banned", fatal: true }); - - expect(getStatus().connected).toBe(false); - // `running` is owned by the gateway lifecycle store: the account task - // stays held until an explicit stop/abort, so the plugin must not - // flip it here (a Start action would no-op against a held task). - expect(getStatus().running).toBe(true); - expect(getStatus().lastError).toBe("banned"); - expect(getStatus().lifecycle).toBe("blocked"); - - const publicStatus = await qqbotPlugin.status?.buildAccountSnapshot?.({ - account, - cfg: {}, - runtime: getStatus(), - }); - expect(publicStatus?.connected).toBe(false); - expect(publicStatus?.lastError).toBe("banned"); - expect(publicStatus?.lifecycle).toBe("blocked"); - - const publicSummary = await qqbotPlugin.status?.buildChannelSummary?.({ - account, - cfg: {}, - defaultAccountId: "test-account", - snapshot: getStatus(), - }); - expect(publicSummary?.lifecycle).toBe("blocked"); - }); - - it("clears fatal errors when the gateway becomes ready or resumes", async () => { - const { options, getStatus } = await startAccountAndCaptureGatewayOptions(); - - options.onReady?.({}); - options.onDisconnected?.({ reason: "banned", fatal: true }); - options.onResumed?.({}); - - expect(getStatus().connected).toBe(true); - expect(getStatus().lastError).toBeNull(); - expect(getStatus().lifecycle).toBe("ready"); - expect(getStatus().terminalDisconnect).toBeUndefined(); - - options.onDisconnected?.({ reason: "offline/sandbox-only", fatal: true }); - options.onReady?.({}); - - expect(getStatus().connected).toBe(true); - expect(getStatus().lastError).toBeNull(); - expect(getStatus().lifecycle).toBe("ready"); - expect(getStatus().terminalDisconnect).toBeUndefined(); - }); -}); diff --git a/extensions/qqbot/src/channel.logout.test.ts b/extensions/qqbot/src/channel.logout.test.ts deleted file mode 100644 index 2e35dc353165..000000000000 --- a/extensions/qqbot/src/channel.logout.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// QQBot logout tests cover gateway-level credential cleanup behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { setQQBotRuntime } from "./bridge/runtime.js"; -import { qqbotPlugin } from "./channel.js"; -import type { QQBotAccountConfig, ResolvedQQBotAccount } from "./types.js"; - -type QQBotRuntimeMocks = { - replaceConfigFile: ReturnType; -}; - -type QQBotLogoutAccount = NonNullable["logoutAccount"]>; - -function createRuntime(): { runtime: PluginRuntime; mocks: QQBotRuntimeMocks } { - const replaceConfigFile = vi.fn(async () => {}); - const runtime = { - version: "test", - config: { replaceConfigFile }, - } as unknown as PluginRuntime; - return { runtime, mocks: { replaceConfigFile } }; -} - -async function runLogoutScenario(params: { cfg: OpenClawConfig; accountId: string }): Promise<{ - result: Awaited>; - account: ResolvedQQBotAccount; - mocks: QQBotRuntimeMocks; -}> { - const { runtime, mocks } = createRuntime(); - setQQBotRuntime(runtime); - const logoutAccount = qqbotPlugin.gateway?.logoutAccount; - if (!logoutAccount) { - throw new Error("QQBot gateway logoutAccount missing"); - } - const account = qqbotPlugin.config.resolveAccount(params.cfg, params.accountId); - const result = await logoutAccount({ - cfg: params.cfg, - accountId: params.accountId, - account, - runtime: createRuntimeEnv(), - }); - return { result, account, mocks }; -} - -describe("qqbotPlugin gateway.logoutAccount", () => { - afterEach(() => { - setQQBotRuntime({ version: "test" } as PluginRuntime); - }); - - it("ignores inherited named accounts during logout cleanup", async () => { - const inheritedAccount = { - appId: "app-id", - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }; - const accounts = Object.create({ bot2: inheritedAccount }) as Record< - string, - Record - >; - const cfg = { - channels: { - qqbot: { - accounts, - }, - }, - } satisfies OpenClawConfig; - - const { result, account, mocks } = await runLogoutScenario({ cfg, accountId: "bot2" }); - - expect(account.secretSource).toBe("none"); - expect(result).toStrictEqual({ - ok: true, - cleared: false, - envToken: false, - loggedOut: true, - }); - expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); - expect(Object.hasOwn(accounts, "bot2")).toBe(false); - expect(inheritedAccount).toEqual({ - appId: "app-id", - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }); - }); - - it("ignores an inherited accounts container during logout", async () => { - const inheritedAccounts = { - bot2: { - appId: "app-id", - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }, - }; - const qqbot = Object.create({ accounts: inheritedAccounts }) as Record; - const cfg = { channels: { qqbot } } as unknown as OpenClawConfig; - - const { result, account, mocks } = await runLogoutScenario({ cfg, accountId: "bot2" }); - - expect(account.appId).toBe(""); - expect(account.secretSource).toBe("none"); - expect(result).toStrictEqual({ - ok: true, - cleared: false, - envToken: false, - loggedOut: true, - }); - expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); - expect(Object.hasOwn(qqbot, "accounts")).toBe(false); - expect(inheritedAccounts.bot2).toEqual({ - appId: "app-id", - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }); - }); - - it("ignores inherited credentials on an own named account during logout", async () => { - const ownAccount = Object.assign( - Object.create({ - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }) as QQBotAccountConfig, - { appId: "app-id" }, - ); - const cfg = { - channels: { - qqbot: { - accounts: { bot2: ownAccount }, - }, - }, - } satisfies OpenClawConfig; - - const { result, account, mocks } = await runLogoutScenario({ cfg, accountId: "bot2" }); - - expect(account.secretSource).toBe("none"); - expect(result).toStrictEqual({ - ok: true, - cleared: false, - envToken: false, - loggedOut: true, - }); - expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); - expect(Object.hasOwn(ownAccount, "clientSecret")).toBe(false); - expect(Object.hasOwn(ownAccount, "clientSecretFile")).toBe(false); - }); - - it("clears own named account credentials through the gateway logout entry point", async () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { - appId: "app-id", - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }, - }, - }, - }, - } satisfies OpenClawConfig; - - const { result, account, mocks } = await runLogoutScenario({ cfg, accountId: "bot2" }); - - expect(account.secretSource).toBe("config"); - expect(result).toStrictEqual({ - ok: true, - cleared: true, - envToken: false, - loggedOut: true, - }); - expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); - expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ - nextConfig: { - channels: { - qqbot: { - accounts: { - bot2: { - appId: "app-id", - }, - }, - }, - }, - }, - afterWrite: { mode: "auto" }, - }); - }); -}); diff --git a/extensions/qqbot/src/channel.message-adapter.test.ts b/extensions/qqbot/src/channel.message-adapter.test.ts deleted file mode 100644 index 9e338d9c569a..000000000000 --- a/extensions/qqbot/src/channel.message-adapter.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -// Qqbot tests cover channel.message adapter plugin behavior. -import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it, vi } from "vitest"; -import { qqbotPlugin } from "./channel.js"; - -describe("qqbotPlugin metadata", () => { - it("distinguishes c2c targets from shared targets", () => { - const infer = qqbotPlugin.messaging?.inferTargetChatType; - expect(infer?.({ to: "qqbot:c2c:owner" })).toBe("direct"); - expect(infer?.({ to: "qqbot:group:operators" })).toBe("group"); - expect(infer?.({ to: "qqbot:channel:alerts" })).toBe("group"); - }); - - it("opts announce delivery into persisted session lookup", () => { - expect(qqbotPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true); - }); -}); - -describe("qqbot outbound sanitizeText", () => { - it("strips reasoning/thinking tags before delivery", () => { - const sanitize = qqbotPlugin.outbound?.sanitizeText; - expect(sanitize).toBeDefined(); - if (!sanitize) { - return; - } - - const input1 = "internal reasoningfinal answer"; - expect(sanitize({ text: input1, payload: { text: input1 } })).toBe("final answer"); - - const input2 = "step by stepresult"; - expect(sanitize({ text: input2, payload: { text: input2 } })).toBe("result"); - - const input3 = "plain text without tags"; - expect(sanitize({ text: input3, payload: { text: input3 } })).toBe("plain text without tags"); - }); -}); - -describe("qqbot outbound session routing", () => { - it.each([ - { - target: "qqbot:c2c:user-openid", - peerKind: "direct", - chatType: "direct", - }, - { - target: "qqbot:group:group-openid", - peerKind: "group", - chatType: "group", - }, - { - target: "qqbot:channel:channel-id", - peerKind: "group", - chatType: "group", - }, - ] as const)("routes $target as $chatType", async ({ target, peerKind, chatType }) => { - const route = await qqbotPlugin.messaging?.resolveOutboundSessionRoute?.({ - cfg: {}, - agentId: "main", - target, - }); - - expect(route).toMatchObject({ - peer: { kind: peerKind }, - chatType, - from: target, - to: target, - }); - }); -}); - -const sendTextMock = vi.hoisted(() => vi.fn()); -const sendMediaMock = vi.hoisted(() => vi.fn()); - -type SentTextParams = { - to?: string; - text?: string; - replyToId?: string | null; - mediaAccess?: { - localRoots?: readonly string[]; - workspaceDir?: string; - readFile?: (filePath: string) => Promise; - }; - mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; -}; - -type SentMediaParams = { - to?: string; - text?: string; - mediaUrl?: string; - mediaAccess?: { - localRoots?: readonly string[]; - workspaceDir?: string; - readFile?: (filePath: string) => Promise; - }; - mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; -}; - -function latestMockArg(mock: ReturnType, label: string): unknown { - const call = mock.mock.calls[mock.mock.calls.length - 1]; - if (!call) { - throw new Error(`expected ${label} call`); - } - return call[0]; -} - -vi.mock("./bridge/gateway.js", () => ({})); -vi.mock("./engine/messaging/outbound.js", () => ({ - sendText: sendTextMock, - sendMedia: sendMediaMock, -})); - -const cfg = { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - }, - }, -} as OpenClawConfig; - -describe("qqbot message adapter", () => { - it("declares durable text, media, and reply target capabilities with receipt proofs", async () => { - sendTextMock.mockResolvedValue({ messageId: "qq-text-1" }); - sendMediaMock.mockResolvedValue({ messageId: "qq-media-1" }); - - const proofResults = await verifyChannelMessageAdapterCapabilityProofs({ - adapterName: "qqbot", - adapter: qqbotPlugin.message!, - proofs: { - text: async () => { - const result = await qqbotPlugin.message?.send?.text?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "hello", - }); - const sent = latestMockArg(sendTextMock, "sendText") as SentTextParams; - expect(sent.to).toBe("qqbot:c2c:user-1"); - expect(sent.text).toBe("hello"); - expect(result?.receipt.platformMessageIds).toEqual(["qq-text-1"]); - }, - media: async () => { - const mediaAccess = { - localRoots: ["/tmp/openclaw-sandbox"], - workspaceDir: "/tmp/workspace", - }; - const result = await qqbotPlugin.message?.send?.media?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "image", - mediaUrl: "https://example.com/image.png", - mediaAccess, - mediaLocalRoots: ["/tmp/openclaw-sandbox"], - }); - const sent = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams; - expect(sent.to).toBe("qqbot:c2c:user-1"); - expect(sent.text).toBe("image"); - expect(sent.mediaUrl).toBe("https://example.com/image.png"); - expect(sent.mediaAccess).toBe(mediaAccess); - expect(sent.mediaLocalRoots).toEqual(["/tmp/openclaw-sandbox"]); - expect(result?.receipt.platformMessageIds).toEqual(["qq-media-1"]); - }, - replyTo: async () => { - const result = await qqbotPlugin.message?.send?.text?.({ - cfg, - to: "qqbot:group:group-1", - text: "reply", - replyToId: "msg-1", - }); - const sent = latestMockArg(sendTextMock, "sendText") as SentTextParams; - expect(sent.to).toBe("qqbot:group:group-1"); - expect(sent.text).toBe("reply"); - expect(sent.replyToId).toBe("msg-1"); - expect(result?.receipt.platformMessageIds).toEqual(["qq-text-1"]); - }, - }, - }); - - expect(proofResults.find((result) => result.capability === "text")?.status).toBe("verified"); - expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified"); - expect(proofResults.find((result) => result.capability === "replyTo")?.status).toBe("verified"); - }); - - it("rejects media sends when QQBot reports an outbound error", async () => { - sendMediaMock.mockResolvedValue({ error: "QQ API returned 400 Bad Request" }); - - await expect( - qqbotPlugin.message?.send?.media?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "image", - mediaUrl: "https://example.com/image.png", - }), - ).rejects.toThrow("QQ API returned 400 Bad Request"); - }); - - it("rejects text sends when QQBot reports an outbound error", async () => { - sendTextMock.mockResolvedValue({ error: "QQ API returned 400 Bad Request" }); - - await expect( - qqbotPlugin.message?.send?.text?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "hello", - }), - ).rejects.toThrow("QQ API returned 400 Bad Request"); - }); - - it("rejects media sends without a QQ platform message id", async () => { - sendMediaMock.mockResolvedValue({}); - - await expect( - qqbotPlugin.message?.send?.media?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "image", - mediaUrl: "https://example.com/image.png", - }), - ).rejects.toThrow("QQBot message adapter send did not return a platform message id"); - }); - - it("rejects text sends without a QQ platform message id", async () => { - sendTextMock.mockResolvedValue({}); - - await expect( - qqbotPlugin.message?.send?.text?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "hello", - }), - ).rejects.toThrow("QQBot message adapter send did not return a platform message id"); - }); - - it("forwards scoped media access through outbound text and media sends", async () => { - const mediaReadFile = vi.fn(async () => Buffer.from("report")); - const mediaAccess = { - localRoots: ["/tmp/openclaw-sandbox"], - workspaceDir: "/tmp/workspace", - readFile: mediaReadFile, - }; - const mediaLocalRoots = ["/tmp/openclaw-sandbox"]; - - sendTextMock.mockResolvedValueOnce({ messageId: "qq-text-media-1" }); - await qqbotPlugin.outbound?.sendText?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "/tmp/openclaw-sandbox/report.docx", - mediaAccess, - mediaLocalRoots, - mediaReadFile, - }); - const sentText = latestMockArg(sendTextMock, "sendText") as SentTextParams; - expect(sentText.mediaAccess).toBe(mediaAccess); - expect(sentText.mediaLocalRoots).toBe(mediaLocalRoots); - expect(sentText.mediaReadFile).toBe(mediaReadFile); - - sendMediaMock.mockResolvedValueOnce({ messageId: "qq-media-local-1" }); - await qqbotPlugin.outbound?.sendMedia?.({ - cfg, - to: "qqbot:c2c:user-1", - text: "report", - mediaUrl: "/tmp/openclaw-sandbox/report.docx", - mediaAccess, - mediaLocalRoots, - mediaReadFile, - }); - const sentMedia = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams; - expect(sentMedia.mediaUrl).toBe("/tmp/openclaw-sandbox/report.docx"); - expect(sentMedia.mediaAccess).toBe(mediaAccess); - expect(sentMedia.mediaLocalRoots).toBe(mediaLocalRoots); - expect(sentMedia.mediaReadFile).toBe(mediaReadFile); - }); -}); diff --git a/extensions/qqbot/src/channel.setup.ts b/extensions/qqbot/src/channel.setup.ts deleted file mode 100644 index 76fd5aaf98e6..000000000000 --- a/extensions/qqbot/src/channel.setup.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Qqbot plugin module implements channel.setup behavior. -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import "./bridge/bootstrap.js"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupContract } from "./bridge/config-shared.js"; -import { qqbotSetupWizard } from "./bridge/setup/surface.js"; -import { qqbotChannelConfigSchema } from "./config-schema.js"; -import type { ResolvedQQBotAccount } from "./types.js"; - -/** - * Setup-only QQBot plugin — lightweight subset used during `openclaw onboard` - * and `openclaw configure` without pulling the full runtime dependencies. - */ -export const qqbotSetupPlugin: ChannelPlugin = { - id: "qqbot", - setupWizard: qqbotSetupWizard, - meta: { - ...qqbotMeta, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: false, - threads: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.qqbot"] }, - configSchema: qqbotChannelConfigSchema, - config: { - ...qqbotConfigAdapter, - }, - setupContract: qqbotSetupContract, -}; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts deleted file mode 100644 index cf21ff558d4c..000000000000 --- a/extensions/qqbot/src/channel.ts +++ /dev/null @@ -1,505 +0,0 @@ -// Qqbot plugin module implements channel behavior. -import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime"; -import { buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/channel-core"; -import { - createMessageReceiptFromOutboundResults, - defineChannelMessageAdapter, - type ChannelMessageSendResult, - type MessageReceiptPartKind, -} from "openclaw/plugin-sdk/channel-outbound"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime"; -import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -// Register the PlatformAdapter before any core/ module is used. -import "./bridge/bootstrap.js"; -import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; -import { getQQBotApprovalCapability } from "./bridge/approval/capability.js"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupContract } from "./bridge/config-shared.js"; -import { - applyQQBotAccountConfig, - DEFAULT_ACCOUNT_ID, - resolveQQBotAccount, -} from "./bridge/config.js"; -import type { GatewayContext } from "./bridge/gateway.js"; -import { toGatewayAccount, writeOpenClawConfigThroughRuntime } from "./bridge/narrowing.js"; -import { getQQBotRuntime } from "./bridge/runtime.js"; -import { qqbotSetupWizard } from "./bridge/setup/surface.js"; -import { qqbotChannelConfigSchema } from "./config-schema.js"; -import { qqbotDoctor } from "./doctor.js"; -import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js"; -import { clearAccountCredentials } from "./engine/config/credentials.js"; -import { chunkQQBotMarkdownText } from "./engine/messaging/markdown-table-chunking.js"; -import type { OutboundMediaAccessContext } from "./engine/messaging/outbound-types.js"; -import { - normalizeTarget as coreNormalizeTarget, - looksLikeQQBotTarget, - parseTarget, -} from "./engine/messaging/target-parser.js"; -import { resolveQQBotGroupToolPolicy } from "./group-policy.js"; -import type { ResolvedQQBotAccount } from "./types.js"; - -const loadGatewayModule = createLazyRuntimeModule(() => import("./bridge/gateway.js")); -const loadOutboundMessagingModule = createLazyRuntimeModule( - () => import("./engine/messaging/outbound.js"), -); - -function createQQBotSendReceipt(params: { - messageId?: string; - target: string; - kind: MessageReceiptPartKind; -}) { - const messageId = params.messageId?.trim(); - return createMessageReceiptFromOutboundResults({ - results: messageId - ? [ - { - channel: "qqbot", - messageId, - conversationId: params.target, - }, - ] - : [], - threadId: params.target, - kind: params.kind, - }); -} - -function resolveQQBotOutboundSessionRoute(params: { - cfg: OpenClawConfig; - agentId: string; - accountId?: string | null; - target: string; -}) { - const target = parseTarget(params.target); - const chatType = target.type === "c2c" ? "direct" : "group"; - const qualifiedTarget = `qqbot:${target.type}:${target.id}`; - return buildChannelOutboundSessionRoute({ - cfg: params.cfg, - agentId: params.agentId, - channel: "qqbot", - accountId: params.accountId, - recipientSessionExact: true, - peer: { kind: chatType, id: target.id }, - chatType, - from: qualifiedTarget, - to: qualifiedTarget, - }); -} - -async function sendQQBotText( - params: { - cfg: OpenClawConfig; - to: string; - text: string; - accountId?: string | null; - replyToId?: string | null; - } & OutboundMediaAccessContext, -) { - // Ensure bridge/gateway.ts module-level registrations (audio adapter factory, - // platform adapter, etc.) have executed before engine code runs. - await loadGatewayModule(); - const account = resolveQQBotAccount(params.cfg, params.accountId); - const { sendText } = await loadOutboundMessagingModule(); - const result = await sendText({ - to: params.to, - text: params.text, - accountId: params.accountId, - replyToId: params.replyToId, - account: toGatewayAccount(account), - ...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}), - ...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}), - ...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}), - }); - return { - channel: "qqbot" as const, - messageId: result.messageId ?? "", - receipt: createQQBotSendReceipt({ - messageId: result.messageId, - target: params.to, - kind: "text", - }), - meta: result.error ? { error: result.error } : undefined, - }; -} - -async function sendQQBotMedia( - params: { - cfg: OpenClawConfig; - to: string; - text?: string | null; - mediaUrl?: string | null; - accountId?: string | null; - replyToId?: string | null; - } & OutboundMediaAccessContext, -) { - // Same guard as sendText — ensure adapters are registered. - await loadGatewayModule(); - const account = resolveQQBotAccount(params.cfg, params.accountId); - const { sendMedia } = await loadOutboundMessagingModule(); - const result = await sendMedia({ - to: params.to, - text: params.text ?? "", - mediaUrl: params.mediaUrl ?? "", - accountId: params.accountId, - replyToId: params.replyToId, - account: toGatewayAccount(account), - ...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}), - ...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}), - ...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}), - }); - return { - channel: "qqbot" as const, - messageId: result.messageId ?? "", - receipt: createQQBotSendReceipt({ - messageId: result.messageId, - target: params.to, - kind: "media", - }), - meta: result.error ? { error: result.error } : undefined, - }; -} - -function resolveQQBotOutboundMediaAccessContext(ctx: unknown): OutboundMediaAccessContext { - const record = ctx && typeof ctx === "object" ? (ctx as OutboundMediaAccessContext) : undefined; - return { - ...(record?.mediaAccess ? { mediaAccess: record.mediaAccess } : {}), - ...(record?.mediaLocalRoots ? { mediaLocalRoots: record.mediaLocalRoots } : {}), - ...(record?.mediaReadFile ? { mediaReadFile: record.mediaReadFile } : {}), - }; -} - -function toQQBotMessageSendResult(result: Awaited>) { - if (result.meta?.error) { - throw new Error(result.meta.error); - } - if (result.receipt.platformMessageIds.length === 0) { - throw new Error("QQBot message adapter send did not return a platform message id"); - } - return { - messageId: result.messageId || result.receipt.primaryPlatformMessageId, - receipt: result.receipt, - } satisfies ChannelMessageSendResult; -} - -const qqbotMessageAdapter = defineChannelMessageAdapter({ - id: "qqbot", - durableFinal: { - capabilities: { - text: true, - media: true, - replyTo: true, - }, - }, - send: { - text: async (ctx) => - toQQBotMessageSendResult( - await sendQQBotText({ - cfg: ctx.cfg, - to: ctx.to, - text: ctx.text, - accountId: ctx.accountId, - replyToId: ctx.replyToId, - ...resolveQQBotOutboundMediaAccessContext(ctx), - }), - ), - media: async (ctx) => - toQQBotMessageSendResult( - await sendQQBotMedia({ - cfg: ctx.cfg, - to: ctx.to, - text: ctx.text, - mediaUrl: ctx.mediaUrl, - accountId: ctx.accountId, - replyToId: ctx.replyToId, - ...resolveQQBotOutboundMediaAccessContext(ctx), - }), - ), - }, -}); - -const EXEC_APPROVAL_COMMAND_RE = - /\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i; - -function persistAccountCredentialSnapshot(account: ResolvedQQBotAccount): void { - if (account.appId && account.clientSecret) { - saveCredentialBackup(account.accountId, account.appId, account.clientSecret); - } -} - -type QQBotCredentialRecoveryState = - | { kind: "configured" } - | { kind: "recoverable"; appId: string; clientSecret: string } - | { kind: "partial" } - | { kind: "missing" }; - -function hasConfiguredQQBotSecretInput(account: ResolvedQQBotAccount): boolean { - const configuredSecret = account.config.clientSecret; - return ( - account.secretSource !== "none" || - Boolean(normalizeOptionalString(account.clientSecret)) || - (typeof configuredSecret === "string" - ? Boolean(normalizeOptionalString(configuredSecret)) - : configuredSecret !== undefined && configuredSecret !== null) || - Boolean(normalizeOptionalString(account.config.clientSecretFile)) - ); -} - -function resolveQQBotCredentialRecoveryState( - account: ResolvedQQBotAccount | undefined, -): QQBotCredentialRecoveryState { - if (!account) { - return { kind: "missing" }; - } - if (qqbotConfigAdapter.isConfigured(account)) { - return { kind: "configured" }; - } - if (normalizeOptionalString(account.appId) || hasConfiguredQQBotSecretInput(account)) { - return { kind: "partial" }; - } - const backup = loadCredentialBackup(account.accountId); - return backup?.appId && backup.clientSecret - ? { kind: "recoverable", appId: backup.appId, clientSecret: backup.clientSecret } - : { kind: "missing" }; -} - -function shouldSuppressLocalQQBotApprovalPrompt(params: { - cfg: OpenClawConfig; - accountId?: string | null; - payload: { text?: string; channelData?: unknown }; - hint?: { kind: "approval-pending" | "approval-resolved"; approvalKind: "exec" | "plugin" }; -}): boolean { - if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") { - return false; - } - const account = resolveQQBotAccount(params.cfg, params.accountId); - if (!account.enabled || account.secretSource === "none") { - return false; - } - if (getExecApprovalReplyMetadata(params.payload as never)) { - return true; - } - const text = typeof params.payload.text === "string" ? params.payload.text : ""; - return EXEC_APPROVAL_COMMAND_RE.test(text); -} - -export const qqbotPlugin: ChannelPlugin = { - id: "qqbot", - setupWizard: qqbotSetupWizard, - meta: { - ...qqbotMeta, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: false, - threads: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.qqbot"] }, - configSchema: qqbotChannelConfigSchema, - doctor: qqbotDoctor, - config: { - ...qqbotConfigAdapter, - /** A backup is eligible only after complete credential loss, never partial edits. */ - isConfigured: (account: ResolvedQQBotAccount | undefined) => { - const state = resolveQQBotCredentialRecoveryState(account); - return state.kind === "configured" || state.kind === "recoverable"; - }, - describeAccount: (account: ResolvedQQBotAccount | undefined) => { - const description = qqbotConfigAdapter.describeAccount(account); - const state = resolveQQBotCredentialRecoveryState(account); - return { - ...description, - configured: state.kind === "configured" || state.kind === "recoverable", - }; - }, - }, - setupContract: qqbotSetupContract, - approvalCapability: getQQBotApprovalCapability(), - groups: { - resolveToolPolicy: resolveQQBotGroupToolPolicy, - }, - message: qqbotMessageAdapter, - messaging: { - targetPrefixes: ["qqbot"], - /** Normalize common QQ Bot target formats into the canonical qqbot:... form. */ - normalizeTarget: coreNormalizeTarget, - inferTargetChatType: ({ to }) => { - try { - return parseTarget(to).type === "c2c" ? "direct" : "group"; - } catch { - return undefined; - } - }, - resolveOutboundSessionRoute: (params) => resolveQQBotOutboundSessionRoute(params), - targetResolver: { - /** Return true when the id looks like a QQ Bot target. */ - looksLikeId: looksLikeQQBotTarget, - hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)", - }, - }, - outbound: { - deliveryMode: "direct", - chunker: (text, limit) => - chunkQQBotMarkdownText(text, limit, getQQBotRuntime().channel.text.chunkMarkdownText), - chunkerMode: "markdown", - textChunkLimit: 5000, - sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text), - shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) => - shouldSuppressLocalQQBotApprovalPrompt({ - cfg, - accountId, - payload, - hint, - }), - sendText: async (ctx) => - await sendQQBotText({ - cfg: ctx.cfg, - to: ctx.to, - text: ctx.text, - accountId: ctx.accountId, - replyToId: ctx.replyToId, - ...resolveQQBotOutboundMediaAccessContext(ctx), - }), - sendMedia: async (ctx) => - await sendQQBotMedia({ - cfg: ctx.cfg, - to: ctx.to, - text: ctx.text, - mediaUrl: ctx.mediaUrl, - accountId: ctx.accountId, - replyToId: ctx.replyToId, - ...resolveQQBotOutboundMediaAccessContext(ctx), - }), - }, - gateway: { - startAccount: async (ctx) => { - let { account, cfg } = ctx; - const { abortSignal, log } = ctx; - - // Recover only after complete credential loss. A partially edited live - // identity is authoritative and must never be replaced by a stale backup. - const credentialState = resolveQQBotCredentialRecoveryState(account); - if (credentialState.kind === "recoverable") { - try { - const nextCfg = applyQQBotAccountConfig(cfg, account.accountId, { - appId: credentialState.appId, - clientSecret: credentialState.clientSecret, - }); - await writeOpenClawConfigThroughRuntime(getQQBotRuntime(), nextCfg); - cfg = nextCfg; - account = resolveQQBotAccount(nextCfg, account.accountId); - log?.info( - `[qqbot:${account.accountId}] Restored credentials from backup (appId=${account.appId})`, - ); - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Failed to restore credentials from backup: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - // Serialize the dynamic import so concurrent multi-account startups - // do not hit an ESM circular-dependency race where the gateway chunk's - // transitive imports have not finished evaluating yet. - const { startGateway } = await loadGatewayModule(); - - log?.info( - `[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`, - ); - - await startGateway({ - account, - abortSignal, - cfg, - log, - channelRuntime: ctx.channelRuntime as GatewayContext["channelRuntime"], - onReady: () => { - log?.info(`[qqbot:${account.accountId}] Gateway ready`); - ctx.setStatus(channelReadyPatch({ accountId: account.accountId })); - // Snapshot credentials so we can recover from the next hot - // upgrade that might wipe openclaw.json mid-flight. - persistAccountCredentialSnapshot(account); - }, - onResumed: () => { - log?.info(`[qqbot:${account.accountId}] Gateway resumed`); - ctx.setStatus(channelReadyPatch({ accountId: account.accountId })); - persistAccountCredentialSnapshot(account); - }, - onError: (error) => { - log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`); - ctx.setStatus({ - ...ctx.getStatus(), - lastError: error.message, - }); - }, - onDisconnected: ({ reason, fatal }) => { - log?.info( - `[qqbot:${account.accountId}] Gateway disconnected${reason ? `: ${reason}` : ""}`, - ); - // Keep the raw lifecycle snapshot truthful so readiness and the shared - // health monitor see the failed transport. QQBot's fatal flag only - // suppresses its immediate reconnect policy. - ctx.setStatus({ - ...ctx.getStatus(), - connected: false, - lifecycle: fatal ? "blocked" : "recovering", - ...(fatal && reason ? { lastError: reason } : {}), - }); - }, - }); - }, - logoutAccount: async ({ accountId, cfg }) => { - const { nextCfg, cleared, changed } = clearAccountCredentials( - cfg as unknown as Record, - accountId, - ); - - if (changed) { - await writeOpenClawConfigThroughRuntime(getQQBotRuntime(), nextCfg as OpenClawConfig); - } - - const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as OpenClawConfig, accountId); - const loggedOut = resolved.secretSource === "none"; - const envToken = Boolean(normalizeOptionalString(process.env.QQBOT_CLIENT_SECRET)); - - return { ok: true, cleared, envToken, loggedOut }; - }, - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - connected: false, - lastConnectedAt: null, - lastError: null, - lastInboundAt: null, - lastOutboundAt: null, - }, - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - tokenSource: snapshot.tokenSource ?? "none", - running: snapshot.running ?? false, - connected: snapshot.connected ?? false, - lifecycle: snapshot.lifecycle ?? undefined, - lastConnectedAt: snapshot.lastConnectedAt ?? null, - lastError: snapshot.lastError ?? null, - }), - buildAccountSnapshot: ({ account, runtime }) => ({ - accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID, - name: account?.name, - enabled: account?.enabled ?? false, - configured: Boolean(account?.appId && account?.clientSecret), - tokenSource: account?.secretSource, - running: runtime?.running ?? false, - connected: runtime?.connected ?? false, - lifecycle: runtime?.lifecycle, - lastConnectedAt: runtime?.lastConnectedAt ?? null, - lastError: runtime?.lastError ?? null, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }), - }, -}; diff --git a/extensions/qqbot/src/command-auth.test.ts b/extensions/qqbot/src/command-auth.test.ts deleted file mode 100644 index ee62bb138b92..000000000000 --- a/extensions/qqbot/src/command-auth.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Regression tests for QQBot command authorization alignment with the shared - * command-auth model. - * - * Covers the regression identified in the code review: - * - * allowFrom entries with the qqbot: prefix must normalize correctly so that - * "qqbot:" in channel.allowFrom matches the inbound event.senderId "". - * Verified against the normalization logic in the gateway.ts inbound path. - * - * Note: framework command authorization precedence is covered by the - * framework's own tests rather than duplicated here. - */ - -import { describe, expect, it } from "vitest"; -import { createSdkAccessAdapter } from "./bridge/sdk-adapter.js"; - -// --------------------------------------------------------------------------- -// qqbot: prefix normalization for inbound commandAuthorized -// -// Uses qqbotPlugin.config.formatAllowFrom directly — the same function the -// fixed gateway.ts inbound path calls — so the test stays in sync with the -// actual implementation without duplicating the logic. -// --------------------------------------------------------------------------- - -describe("qqbot: prefix normalization for inbound commandAuthorized", () => { - const access = createSdkAccessAdapter(); - - async function resolveInboundCommandAuthorized( - rawAllowFrom: string[], - senderId: string, - options: { - isGroup?: boolean; - groupAllowFrom?: string[]; - } = {}, - ): Promise { - const result = await access.resolveInboundAccess({ - cfg: {}, - accountId: "default", - conversationId: options.isGroup ? "group-openid" : senderId, - isGroup: options.isGroup ?? false, - senderId, - allowFrom: rawAllowFrom, - groupAllowFrom: options.groupAllowFrom, - }); - return result.commandAccess.authorized; - } - - async function resolveSlashCommandAuthorized( - rawAllowFrom: string[], - senderId: string, - cfg: Record = {}, - ): Promise { - return await access.resolveSlashCommandAuthorization({ - cfg, - accountId: "default", - conversationId: senderId, - isGroup: false, - senderId, - allowFrom: rawAllowFrom, - }); - } - - it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", async () => { - await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).resolves.toBe(true); - }); - - it("authorizes when qqbot: prefix is mixed case", async () => { - await expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).resolves.toBe(true); - }); - - it("denies a sender not in the qqbot:-prefixed allowFrom list", async () => { - await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).resolves.toBe(false); - }); - - it("authorizes any sender when allowFrom is empty (open)", async () => { - await expect(resolveInboundCommandAuthorized([], "ANYONE")).resolves.toBe(true); - }); - - it("authorizes any sender when allowFrom contains wildcard *", async () => { - await expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).resolves.toBe(true); - }); - - it("authorizes slash commands from access group allowFrom entries", async () => { - await expect( - resolveSlashCommandAuthorized(["accessGroup:operators"], "USER123", { - accessGroups: { - operators: { - type: "message.senders", - members: { - qqbot: ["USER123"], - }, - }, - }, - }), - ).resolves.toBe(true); - }); - - it("denies group command auth in an open group without explicit allowlists", async () => { - await expect(resolveInboundCommandAuthorized([], "ANYONE", { isGroup: true })).resolves.toBe( - false, - ); - }); - - it("authorizes group command auth for an explicit group allowlist sender", async () => { - await expect( - resolveInboundCommandAuthorized([], "GROUP_OWNER", { - isGroup: true, - groupAllowFrom: ["qqbot:GROUP_OWNER"], - }), - ).resolves.toBe(true); - }); -}); diff --git a/extensions/qqbot/src/config-schema.ts b/extensions/qqbot/src/config-schema.ts deleted file mode 100644 index f3c001c85ae2..000000000000 --- a/extensions/qqbot/src/config-schema.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Qqbot helper module supports config schema behavior. -import { - AllowFromListSchema, - ContextVisibilityModeSchema, - GroupPolicySchema, - buildChannelConfigSchema, - buildGroupEntrySchema, - buildMultiAccountChannelSchema, -} from "openclaw/plugin-sdk/channel-config-schema"; -import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input"; -import { z } from "zod"; - -const AudioFormatPolicySchema = z - .object({ - sttDirectFormats: z.array(z.string()).optional(), - uploadDirectFormats: z.array(z.string()).optional(), - transcodeEnabled: z.boolean().optional(), - }) - .optional(); - -const QQBotSttSchema = z - .object({ - enabled: z.boolean().optional(), - provider: z.string().optional(), - baseUrl: z.string().optional(), - apiKey: z.string().optional(), - model: z.string().optional(), - }) - .strict() - .optional(); - -// Nested streaming config. Legacy scalar booleans and the `c2cStreamApi` key -// migrate to this shape via `openclaw doctor --fix`. -const QQBotStreamingSchema = z - .object({ - /** "partial" (default) enables block streaming; "off" disables it. */ - mode: z.enum(["off", "partial"]).default("partial"), - /** Use QQ's official C2C `stream_messages` API for DM replies. */ - nativeTransport: z.boolean().optional(), - }) - .strict() - .optional(); - -const QQBotExecApprovalsSchema = z - .object({ - enabled: z.union([z.boolean(), z.literal("auto")]).optional(), - approvers: z.array(z.string()).optional(), - agentFilter: z.array(z.string()).optional(), - sessionFilter: z.array(z.string()).optional(), - target: z.enum(["dm", "channel", "both"]).optional(), - }) - .strict() - .optional(); - -const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional(); -const QQBotGroupPolicySchema = GroupPolicySchema.optional(); -const QQBotGroupCommandLevelSchema = z.enum(["all", "safety", "strict"]).optional(); - -const QQBotGroupSchema = buildGroupEntrySchema({ - commandLevel: QQBotGroupCommandLevelSchema, - ignoreOtherMentions: z.boolean().optional(), - historyLimit: z.number().optional(), - name: z.string().optional(), - prompt: z.string().optional(), -}).omit({ skills: true, enabled: true, allowFrom: true, systemPrompt: true }); - -const QQBotGroupsSchema = z.record(z.string(), QQBotGroupSchema).optional(); - -const QQBotAccountSchema = z - .object({ - enabled: z.boolean().optional(), - name: z.string().optional(), - appId: z.string().optional(), - clientSecret: buildSecretInputSchema().optional(), - clientSecretFile: z.string().optional(), - allowFrom: AllowFromListSchema, - groupAllowFrom: AllowFromListSchema, - dmPolicy: QQBotDmPolicySchema, - groupPolicy: QQBotGroupPolicySchema, - contextVisibility: ContextVisibilityModeSchema.optional(), - systemPrompt: z.string().optional(), - markdownSupport: z.boolean().optional(), - audioFormatPolicy: AudioFormatPolicySchema, - urlDirectUpload: z.boolean().optional(), - upgradeUrl: z.string().optional(), - upgradeMode: z.enum(["doc", "hot-reload"]).optional(), - streaming: QQBotStreamingSchema, - execApprovals: QQBotExecApprovalsSchema, - groups: QQBotGroupsSchema, - }) - .passthrough(); - -const QQBotConfigSchema = buildMultiAccountChannelSchema( - QQBotAccountSchema.extend({ - stt: QQBotSttSchema, - }).passthrough(), - { - accountSchema: QQBotAccountSchema, - accountsMode: "catchall", - }, -); -export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema); diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts deleted file mode 100644 index 188d8364f5e4..000000000000 --- a/extensions/qqbot/src/config.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -// Qqbot tests cover config plugin behavior. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - type JsonSchemaObject, - validateJsonSchemaValue, -} from "openclaw/plugin-sdk/json-schema-runtime"; -import { DEFAULT_SECRET_FILE_MAX_BYTES } from "openclaw/plugin-sdk/secret-file-runtime"; -import { describe, expect, it } from "vitest"; -import { qqbotConfigAdapter } from "./bridge/config-shared.js"; -import { - DEFAULT_ACCOUNT_ID, - resolveDefaultQQBotAccountId, - resolveQQBotAccount, -} from "./bridge/config.js"; -import { qqbotSetupPlugin } from "./channel.setup.js"; -import { qqbotChannelConfigSchema } from "./config-schema.js"; - -function requireRuntimeSchema() { - const runtimeSchema = qqbotChannelConfigSchema.runtime; - if (!runtimeSchema) { - throw new Error("expected QQBot runtime config schema"); - } - return runtimeSchema; -} -import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js"; - -function requireQQBotSetup() { - if (!qqbotSetupPlugin.setupContract) { - throw new Error("QQBot setup missing"); - } - return qqbotSetupPlugin.setupContract; -} - -describe("qqbot config", () => { - it("rejects pairing because QQBot has no pairing flow", () => { - expect(requireRuntimeSchema().safeParse({ dmPolicy: "pairing" })).toMatchObject({ - success: false, - }); - expect( - requireRuntimeSchema().safeParse({ accounts: { work: { dmPolicy: "pairing" } } }), - ).toMatchObject({ success: false }); - }); - - it("validates context visibility modes", () => { - expect( - requireRuntimeSchema().safeParse({ contextVisibility: "allowlist_quote" }), - ).toMatchObject({ success: true }); - expect( - requireRuntimeSchema().safeParse({ - accounts: { work: { contextVisibility: "allowlist" } }, - }), - ).toMatchObject({ success: true }); - expect(requireRuntimeSchema().safeParse({ contextVisibility: "allowlistt" })).toMatchObject({ - success: false, - }); - expect( - requireRuntimeSchema().safeParse({ - accounts: { work: { contextVisibility: "allowlistt" } }, - }), - ).toMatchObject({ success: false }); - }); - - it("accepts top-level speech overrides in the manifest schema", () => { - const manifest = JSON.parse( - fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), - ) as { configSchema: JsonSchemaObject }; - - const result = validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: "qqbot.manifest.speech-overrides", - value: { - stt: { - provider: "openai", - baseUrl: "https://example.com/v1", - apiKey: "stt-key", - model: "whisper-1", - }, - }, - }); - - expect(result.ok).toBe(true); - }); - - it("accepts defaultAccount in the manifest schema", () => { - const manifest = JSON.parse( - fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), - ) as { configSchema: JsonSchemaObject }; - - const result = validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: "qqbot.manifest.default-account", - value: { - defaultAccount: "bot2", - accounts: { - bot2: { - appId: "654321", - }, - }, - }, - }); - - expect(result.ok).toBe(true); - }); - - it("honors configured defaultAccount when resolving the default QQ Bot account id", () => { - const cfg = { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { - appId: "654321", - }, - }, - }, - }, - } as OpenClawConfig; - - expect(resolveDefaultQQBotAccountId(cfg)).toBe("bot2"); - }); - - it("keeps account mutations and allowlists scoped to the selected QQ Bot account", () => { - const cfg = { - channels: { - qqbot: { - appId: "default-app", - clientSecret: "default-secret", - accounts: { - work: { - appId: "work-app", - clientSecret: "work-secret", - allowFrom: ["qqbot:work-user"], - }, - }, - }, - }, - } as OpenClawConfig; - - expect(qqbotConfigAdapter.resolveAccount(cfg, "work")).toMatchObject({ - accountId: "work", - appId: "work-app", - clientSecret: "work-secret", - }); - expect(qqbotConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "work" })).toEqual([ - "qqbot:work-user", - ]); - expect( - qqbotConfigAdapter.formatAllowFrom?.({ - cfg, - accountId: "work", - allowFrom: ["qqbot:work-user", 42], - }), - ).toEqual(["WORK-USER", "42"]); - expect( - qqbotConfigAdapter.setAccountEnabled?.({ - cfg, - accountId: "work", - enabled: false, - }), - ).toMatchObject({ - channels: { - qqbot: { - appId: "default-app", - accounts: { work: { appId: "work-app", enabled: false } }, - }, - }, - }); - }); - - it("clears default QQ Bot credentials without deleting named accounts", () => { - const cfg = { - channels: { - qqbot: { - appId: "default-app", - clientSecret: "default-secret", - clientSecretFile: "/tmp/default-qq-secret", - name: "Default bot", - accounts: { - work: { - appId: "work-app", - clientSecret: "work-secret", - }, - }, - }, - }, - } as OpenClawConfig; - - expect(qqbotConfigAdapter.deleteAccount?.({ cfg, accountId: "default" })).toMatchObject({ - channels: { - qqbot: { - appId: undefined, - clientSecret: undefined, - clientSecretFile: undefined, - name: undefined, - accounts: { - work: { - appId: "work-app", - clientSecret: "work-secret", - }, - }, - }, - }, - }); - }); - - it("accepts SecretRef-backed credentials in the runtime schema", () => { - const parsed = requireRuntimeSchema().safeParse({ - defaultAccount: "bot2", - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - allowFrom: ["*"], - audioFormatPolicy: { - sttDirectFormats: [".wav"], - uploadDirectFormats: [".mp3"], - transcodeEnabled: false, - }, - urlDirectUpload: false, - upgradeUrl: "https://docs.openclaw.ai/channels/qqbot", - upgradeMode: "doc", - accounts: { - bot2: { - appId: "654321", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET_BOT2", - }, - allowFrom: ["user-1"], - }, - }, - }); - - expect(parsed.success).toBe(true); - }); - - it("accepts account-level speech overrides as forward-compatible config", () => { - const parsed = requireRuntimeSchema().safeParse({ - accounts: { - bot2: { - appId: "654321", - stt: { - provider: "openai", - }, - }, - }, - }); - - expect(parsed.success).toBe(true); - }); - - it("accepts canonical group tools config", () => { - const parsed = requireRuntimeSchema().safeParse({ - groups: { - G1: { - requireMention: true, - commandLevel: "safety", - tools: { deny: ["*"] }, - toolsBySender: { - "id:alice": { allow: ["read"] }, - }, - }, - }, - accounts: { - bot2: { - groups: { - G1: { commandLevel: "strict", tools: { allow: [] } }, - }, - }, - }, - }); - - expect(parsed.success).toBe(true); - }); - - it("rejects retired group toolPolicy config", () => { - const parsed = requireRuntimeSchema().safeParse({ - groups: { - G1: { - toolPolicy: "none", - }, - }, - }); - - expect(parsed.success).toBe(false); - }); - - it("preserves top-level media and upgrade config on the default account", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: "secret-value", - audioFormatPolicy: { - sttDirectFormats: [".wav"], - uploadDirectFormats: [".mp3"], - transcodeEnabled: false, - }, - urlDirectUpload: false, - upgradeUrl: "https://docs.openclaw.ai/channels/qqbot", - upgradeMode: "hot-reload", - }, - }, - } as OpenClawConfig; - - const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID); - - expect(resolved.clientSecret).toBe("secret-value"); - expect(resolved.config.audioFormatPolicy).toEqual({ - sttDirectFormats: [".wav"], - uploadDirectFormats: [".mp3"], - transcodeEnabled: false, - }); - expect(resolved.config.urlDirectUpload).toBe(false); - expect(resolved.config.upgradeUrl).toBe("https://docs.openclaw.ai/channels/qqbot"); - expect(resolved.config.upgradeMode).toBe("hot-reload"); - }); - - it("uses configured defaultAccount when accountId is omitted", () => { - const cfg = { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { - appId: "654321", - clientSecret: "secret-value", - name: "Bot Two", - }, - }, - }, - }, - } as OpenClawConfig; - - const resolved = resolveQQBotAccount(cfg); - - expect(resolved.accountId).toBe("bot2"); - expect(resolved.appId).toBe("654321"); - expect(resolved.clientSecret).toBe("secret-value"); - expect(resolved.name).toBe("Bot Two"); - }); - - it("rejects oversized client secret files", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-client-secret-")); - try { - const secretFile = path.join(tempDir, "secret"); - fs.writeFileSync(secretFile, "x".repeat(DEFAULT_SECRET_FILE_MAX_BYTES + 1)); - const resolved = resolveQQBotAccount({ - channels: { qqbot: { appId: "123456", clientSecretFile: secretFile } }, - } as OpenClawConfig); - - expect(resolved.clientSecret).toBe(""); - expect(resolved.secretSource).toBe("none"); - } finally { - fs.rmSync(tempDir, { force: true, recursive: true }); - } - }); - - it.runIf(process.platform !== "win32").each(["symlink", "hardlink"] as const)( - "continues to resolve client secret files through a %s", - (linkType) => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-client-secret-link-")); - try { - const targetFile = path.join(tempDir, "secret-target"); - const linkedFile = path.join(tempDir, "secret-link"); - fs.writeFileSync(targetFile, " fixture-secret\n"); - if (linkType === "symlink") { - fs.symlinkSync(targetFile, linkedFile); - } else { - fs.linkSync(targetFile, linkedFile); - } - const resolved = resolveQQBotAccount({ - channels: { qqbot: { appId: "123456", clientSecretFile: linkedFile } }, - } as OpenClawConfig); - - expect(resolved.clientSecret).toBe("fixture-secret"); - expect(resolved.secretSource).toBe("file"); - } finally { - fs.rmSync(tempDir, { force: true, recursive: true }); - } - }, - ); - - it.each([ - { value: " ", secret: "", source: "none", configured: false }, - { value: " fixture-secret ", secret: "fixture-secret", source: "env", configured: true }, - ])( - "normalizes QQBOT_CLIENT_SECRET environment fallback %#", - ({ value, secret, source, configured }) => { - const cfg = { channels: { qqbot: { appId: "123456" } } } as OpenClawConfig; - const previous = process.env.QQBOT_CLIENT_SECRET; - process.env.QQBOT_CLIENT_SECRET = value; - try { - const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID); - expect(resolved.clientSecret).toBe(secret); - expect(resolved.secretSource).toBe(source); - expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(configured); - } finally { - if (previous === undefined) { - delete process.env.QQBOT_CLIENT_SECRET; - } else { - process.env.QQBOT_CLIENT_SECRET = previous; - } - } - }, - ); - - it("resolves env SecretRefs on runtime resolution", () => { - const cfg = makeQqbotSecretRefConfig(); - const previous = process.env.QQBOT_CLIENT_SECRET; - - process.env.QQBOT_CLIENT_SECRET = "resolved-secret"; - try { - const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID); - - expect(resolved.clientSecret).toBe("resolved-secret"); - expect(resolved.secretSource).toBe("config"); - } finally { - if (previous === undefined) { - delete process.env.QQBOT_CLIENT_SECRET; - } else { - process.env.QQBOT_CLIENT_SECRET = previous; - } - } - }); - - it("rejects unresolved non-env SecretRefs on runtime resolution", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "file", - provider: "default", - id: "/qqbot/clientSecret", - }, - }, - }, - } as OpenClawConfig; - - expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow( - 'channels.qqbot.clientSecret: unresolved SecretRef "file:default:/qqbot/clientSecret"', - ); - }); - - it("rejects legacy SecretRef marker strings before QQ token exchange", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: "secretref:/QQBOT_CLIENT_SECRET", - }, - }, - } as OpenClawConfig; - - expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow( - "channels.qqbot.clientSecret: legacy SecretRef marker strings are not valid QQ Bot clientSecret values; use a structured SecretRef object instead.", - ); - }); - - it("allows unresolved SecretRefs for setup/status flows", () => { - const cfg = makeQqbotSecretRefConfig(); - - const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID, { - allowUnresolvedSecretRef: true, - }); - - expect(resolved.clientSecret).toBe(""); - expect(resolved.secretSource).toBe("config"); - expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true); - expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true); - }); - - it.each([ - { - accountId: DEFAULT_ACCOUNT_ID, - inputAccountId: DEFAULT_ACCOUNT_ID, - expectedPath: ["channels", "qqbot"], - }, - { - accountId: "bot2", - inputAccountId: "bot2", - expectedPath: ["channels", "qqbot", "accounts", "bot2"], - }, - ])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => { - const setup = requireQQBotSetup(); - - const next = setup.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: inputAccountId, - input: { - token: "102905186:Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W", - }, - }) as Record; - - const accountConfig = expectedPath.reduce((value, key) => { - if (!value || typeof value !== "object") { - return undefined; - } - return (value as Record)[key]; - }, next) as Record | undefined; - - expect(accountConfig).toStrictEqual({ - enabled: true, - allowFrom: ["*"], - appId: "102905186", - clientSecret: "Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W", - clientSecretFile: undefined, - }); - }); - - it("rejects malformed --token", () => { - const setup = requireQQBotSetup(); - const input = { token: "broken", name: "Bad" }; - - expect( - setup.validateInput?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - input, - } as never), - ).toBe("QQBot --token must be in appId:clientSecret format"); - expect( - setup.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - input, - } as never), - ).toStrictEqual({}); - }); - - it("preserves the --use-env add flow", () => { - const setup = requireQQBotSetup(); - const input = { useEnv: true, name: "Env Bot" }; - - expect( - setup.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - input, - } as never), - ).toStrictEqual({ - channels: { - qqbot: { - enabled: true, - allowFrom: ["*"], - name: "Env Bot", - }, - }, - }); - }); - - it("uses configured defaultAccount when setup accountId is omitted", () => { - expect( - requireQQBotSetup().resolveAccountId?.({ - cfg: makeQqbotDefaultAccountConfig(), - accountId: undefined, - } as never), - ).toBe("bot2"); - }); - - it("rejects --use-env for named accounts", () => { - const setup = requireQQBotSetup(); - const input = { useEnv: true, name: "Env Bot" }; - - expect( - setup.validateInput?.({ - cfg: {} as OpenClawConfig, - accountId: "bot2", - input, - } as never), - ).toBe("QQBot --use-env only supports the default account"); - expect( - setup.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: "bot2", - input, - } as never), - ).toStrictEqual({}); - }); -}); diff --git a/extensions/qqbot/src/delivery-trace.test.ts b/extensions/qqbot/src/delivery-trace.test.ts deleted file mode 100644 index 132178e796aa..000000000000 --- a/extensions/qqbot/src/delivery-trace.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -// QQBot delivery trace goldens: replayable wire-level lifecycle recordings for -// the budget-constrained REPLACE-mode streaming channel. -// -// Wires the real engine paths the gateway uses — StreamingController -// (engine/messaging/streaming-c2c.ts), the typing keepalive with QQ passive -// reply budget accounting (engine/gateway/typing-keepalive.ts + -// gateway.ts startTypingForEvent), the static block-deliver pipeline -// (engine/gateway/outbound-dispatch.ts deliver wiring → -// engine/messaging/outbound-deliver.ts), and the common budget-limited sender -// path (engine/messaging/sender.ts text/media sends with ReplyLimiter proactive -// fallback) — against a recording ApiClient mock, so -// OUT events are the raw QQ Open Platform HTTP calls. Every wire call that -// carries `msg_id` + `msg_seq` spends QQ's ≤5-per-msg_id passive reply -// budget; typing renewals count against it, which is the point of this -// channel's traces. -// -// The gateway's per-turn glue (markBlockResponse, static fallback ordering, -// the dispatch `finally` finalization) is replicated inline from -// outbound-dispatch.ts; the scripted steps stand in for the dispatcher -// callbacks. Deliberately AS-IS captured behavior (not blessed as ideal): -// - A cancelled run still finalizes the stream via onIdle with a DONE chunk -// that re-sends the accumulated partial text unchanged (abortStreaming only -// runs when finalization throws, and performFlush/finalize have no dirty -// check against the last sent chunk). -// Refresh goldens with OPENCLAW_TRACE_UPDATE=1 (see delivery-trace harness docs). -import { - deliveryTraceScenarios, - expectDeliveryTraceMatchesGolden, - runDeliveryTraceScenario, - type DeliveryTraceInStep, - type DeliveryTraceScenario, - type WireRecorder, -} from "openclaw/plugin-sdk/channel-contract-testing"; -import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime"; -import { describe, expect, it, vi } from "vitest"; -import { TYPING_INPUT_SECOND, TypingKeepAlive } from "./engine/gateway/typing-keepalive.js"; -import { createQQBotMarkdownChunker } from "./engine/messaging/markdown-table-chunking.js"; -import { - parseAndSendMediaTags, - sendPlainReply, - TEXT_CHUNK_LIMIT, - type DeliverDeps, -} from "./engine/messaging/outbound-deliver.js"; -import { checkMessageReplyLimit, claimMessageReply } from "./engine/messaging/outbound-reply.js"; -import { - sendDocument, - sendMedia as sendOutboundMedia, - sendPhoto, - sendText as sendChannelOutboundText, - sendVideoMsg, - sendVoice, -} from "./engine/messaging/outbound.js"; -import { - handleStructuredPayload, - sendWithTokenRetry, - type ReplyDispatcherDeps, -} from "./engine/messaging/reply-dispatcher.js"; -import { - accountToCreds, - clearTokenCache, - createRawInputNotifyFn, - getAccessToken, - sendInputNotify, -} from "./engine/messaging/sender.js"; -import { - StreamingController, - shouldUseOfficialC2cStream, -} from "./engine/messaging/streaming-c2c.js"; -import type { GatewayAccount } from "./engine/types.js"; - -// Mutable holder shared with the hoisted module mocks. The per-appId account -// registry in sender.ts caches ApiClient instances across scenarios, so the -// mock resolves the active recorder and counters at call time. -const wire = vi.hoisted(() => ({ - recorder: null as { - recordWireCall: (call: { - method: string; - target?: string; - payload?: unknown; - result?: unknown; - }) => void; - } | null, - messageCount: 0, - streamSessionCount: 0, - uploadCount: 0, - msgSeqCount: 0, -})); - -// Wire seam: every QQ Open Platform REST call funnels through -// ApiClient.request (engine/api/api-client.ts). Record the call in observed -// order and script deterministic results. -vi.mock("./engine/api/api-client.js", () => { - class RecordingApiClient { - async request( - _accessToken: string, - method: string, - path: string, - body?: unknown, - ): Promise { - const recorder = wire.recorder; - if (!recorder) { - throw new Error("qqbot trace: wire call outside an active scenario"); - } - let result: unknown; - if (path.endsWith("/stream_messages")) { - const request = body as { stream_msg_id?: string }; - if (!request.stream_msg_id) { - wire.streamSessionCount += 1; - } - result = { id: request.stream_msg_id ?? `stream-msg-${wire.streamSessionCount}` }; - } else if (path.endsWith("/files")) { - wire.uploadCount += 1; - result = { - file_uuid: `file-uuid-${wire.uploadCount}`, - file_info: `file-info-${wire.uploadCount}`, - ttl: 600, - }; - } else { - wire.messageCount += 1; - result = { id: `wire-msg-${wire.messageCount}`, timestamp: "2026-01-01T00:00:00.000Z" }; - } - recorder.recordWireCall({ method: `${method} ${path}`, payload: body, result }); - return result; - } - } - return { ApiClient: RecordingApiClient }; -}); - -// Auth seam: token acquisition is plumbing, not delivery lifecycle, so it is -// scripted and never recorded. -vi.mock("./engine/api/token.js", () => { - class StaticTokenManager { - async getAccessToken(): Promise { - return "trace-access-token"; - } - clearCache(): void {} - startBackgroundRefresh(): void {} - stopBackgroundRefresh(): void {} - } - return { TokenManager: StaticTokenManager }; -}); - -// Prod getNextMsgSeq is randomized (engine/api/routes.ts); script a counter so -// msg_seq stays deterministic while preserving the semantics the goldens pin: -// one stream session shares a single msg_seq, every other passive send draws a -// fresh one. -vi.mock("./engine/api/routes.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getNextMsgSeq: () => { - wire.msgSeqCount += 1; - return wire.msgSeqCount; - }, - }; -}); - -// Media URL ingestion does a real download in prod; script the bytes so the -// upload body (base64 file_data) is deterministic. MediaApi itself stays real. -vi.mock("./engine/api/media.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - downloadDirectUploadUrl: async () => Buffer.from("qqbot-trace-image-bytes"), - }; -}); - -const APP_ID = "trace-app-id"; -const OPENID = "user-openid-trace"; -const QUALIFIED_TARGET = `qqbot:c2c:${OPENID}`; - -// Streaming-enabled C2C account without markdown permission — the common -// plain-text account shape; quoting stays available and no image-size probes run. -const ACCOUNT: GatewayAccount = { - accountId: "main", - appId: APP_ID, - // Placeholder credential; token acquisition is mocked and the value never - // reaches a recorded wire payload. - clientSecret: "trace-cred", - markdownSupport: false, - config: { streaming: { mode: "partial", nativeTransport: true } }, -}; - -const silentLog = { - info: () => {}, - error: () => {}, - warn: () => {}, - debug: () => {}, -}; - -function setupQqbotTrace(recorder: WireRecorder, msgId: string) { - wire.recorder = recorder; - wire.messageCount = 0; - wire.streamSessionCount = 0; - wire.uploadCount = 0; - wire.msgSeqCount = 0; - - const account = ACCOUNT; - const event = { type: "c2c" as const, senderId: OPENID, messageId: msgId }; - let keepAlive: TypingKeepAlive | null = null; - - // outbound-dispatch.ts builds the controller only for official C2C stream - // accounts; derive it through the real predicate. - const streamingController = shouldUseOfficialC2cStream(account, "c2c") - ? new StreamingController({ - account, - userId: event.senderId, - replyToMsgId: event.messageId, - eventId: event.messageId, - logPrefix: `[qqbot:${account.accountId}:streaming]`, - log: silentLog, - // Scenario media rides an https URL, so no workspace media roots are wired. - mediaContext: { account, event, log: silentLog }, - }) - : null; - if (!streamingController) { - throw new Error("qqbot trace expects the official C2C stream account shape"); - } - - const markdownChunker = createQQBotMarkdownChunker((text, limit) => - chunkMarkdownText(text, limit), - ); - const deliverDeps: DeliverDeps = { - mediaSender: { - sendPhoto: (target, imageUrl) => sendPhoto(target, imageUrl), - sendVoice: (target, voicePath, uploadFormats, transcodeEnabled) => - sendVoice(target, voicePath, uploadFormats, transcodeEnabled), - sendVideoMsg: (target, videoPath) => sendVideoMsg(target, videoPath), - sendDocument: (target, filePath) => sendDocument(target, filePath), - sendMedia: (opts) => sendOutboundMedia(opts), - }, - chunkText: (text, limit) => markdownChunker.chunkText(text, limit), - }; - const replyDeps: ReplyDispatcherDeps = { - tts: { - textToSpeech: async () => ({ success: false }), - audioFileToSilkBase64: async () => undefined, - }, - }; - const sendWithRetry = (sendFn: (token: string) => Promise) => - sendWithTokenRetry(account.appId, account.clientSecret, sendFn, silentLog, account.accountId); - const deliverEvent = { type: event.type, senderId: event.senderId, messageId: event.messageId }; - const deliverActx = { account, qualifiedTarget: QUALIFIED_TARGET, log: silentLog }; - const replyCtx = { target: deliverEvent, account, cfg: {}, log: silentLog }; - - // Replica of outbound-dispatch.ts markBlockResponse: the first block deliver - // stops the typing keepalive so the reserved passive reply stays available. - const markBlockResponse = () => { - keepAlive?.stop(); - }; - - // Replica of the outbound-dispatch.ts block-deliver wiring for a visible - // final payload (silent/media-only gates and group-skip branches are not - // exercised by these scripts). Deliver-first finals lock the controller and - // fall back to the static sender path, which shares the same passive budget - // and proactive fallback as channel outbound sends. - const deliverFinal = async (payload: { - text?: string; - mediaUrls?: string[]; - isError?: boolean; - }) => { - markBlockResponse(); - if (!streamingController.isTerminalPhase) { - await streamingController.onDeliver(payload); - if (!streamingController.shouldFallbackToStatic) { - return; - } - } - // Static fallback pipeline: media tags → structured payload → plain reply. - const consumeQuoteRef = () => undefined; - let replyText = payload.text ?? ""; - const mediaResult = await parseAndSendMediaTags( - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - deliverDeps, - ); - if (mediaResult.handled) { - return; - } - replyText = mediaResult.normalizedText; - if (await handleStructuredPayload(replyCtx, replyText, () => {}, replyDeps)) { - return; - } - await sendPlainReply( - payload, - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - [], - deliverDeps, - ); - }; - - // tool-progress "result" steps stand in for message-tool sends replying to - // the same inbound message. Those ride the channel outbound seam - // (channel.ts sendText → outbound.ts sendText → sender.ts sendText), which - // shares one five-request budget with typing and static final delivery. - let toolSendCount = 0; - const sendViaChannelOutbound = async () => { - toolSendCount += 1; - await sendChannelOutboundText({ - to: QUALIFIED_TARGET, - text: `Reply ${toolSendCount} via message tool`, - replyToId: msgId, - account, - }); - }; - - return async (step: DeliveryTraceInStep) => { - switch (step.kind) { - case "reply-start": { - // Replica of gateway.ts startTypingForEvent: initial input_notify - // (first budget spend), then the keepalive loop with - // TYPING_RENEWAL_LIMIT renewals reserving one reply for the final. - const passive = claimMessageReply(msgId, 1); - if (!passive.allowed) { - break; - } - await sendInputNotify({ - openid: OPENID, - creds: accountToCreds(account), - msgId, - inputSecond: TYPING_INPUT_SECOND, - }); - keepAlive = new TypingKeepAlive( - () => getAccessToken(account.appId, account.clientSecret), - () => clearTokenCache(account.appId), - createRawInputNotifyFn(account.appId), - OPENID, - msgId, - silentLog, - ); - keepAlive.start(); - break; - } - case "partial": - // replyOptions.onPartialReply wiring (outbound-dispatch.ts). - await streamingController.onPartialReply({ text: step.text }); - break; - case "block-final": - // REPLACE-mode streaming has no per-block wire effect: the controller - // infers the boundary from the next partial's raw-prefix mismatch and - // joins with "\n\n" (streaming-c2c.ts boundary handling). - break; - case "tool-progress": - await sendViaChannelOutbound(); - break; - case "final": - await deliverFinal({ - ...(step.text !== undefined ? { text: step.text } : {}), - ...(step.mediaUrls ? { mediaUrls: step.mediaUrls } : {}), - ...(step.isError ? { isError: true } : {}), - }); - break; - case "cancel": - // An aborted run stops emitting payloads; closeout happens on idle, - // mirroring dispatchOutbound's finally. - break; - case "idle": { - // Replica of dispatchOutbound's finally, then handleMessage's finally - // (gateway.ts): finalize the stream, then stop typing. - const pendingMarkdown = markdownChunker.flushPendingText(TEXT_CHUNK_LIMIT); - if (pendingMarkdown.length > 0) { - // These scripts never split markdown tables; pending text here means - // the scenario drifted from the flushPendingMarkdownText assumption. - throw new Error("qqbot trace: unexpected pending markdown-table text"); - } - if (!streamingController.isTerminalPhase) { - streamingController.markFullyComplete(); - await streamingController.onIdle(); - } - keepAlive?.stop(); - break; - } - case "wire-fault": - throw new Error("qqbot trace scenarios do not script wire faults"); - } - }; -} - -const MEDIA_INTERRUPT_FULL_TEXT = - "Here is the chart:\nhttps://example.com/chart.png\nKey takeaways: ship it."; - -const QQBOT_TRACE_SCENARIOS: readonly DeliveryTraceScenario[] = [ - // Official REPLACE-mode stream lifecycle over the shared streaming-happy - // script: one stream session, cumulative GENERATING chunks, boundary joined - // with "\n\n", DONE chunk sealing the full text. - { name: "streaming-happy-c2c", steps: deliveryTraceScenarios["streaming-happy"].steps }, - // Budget lifecycle against one msg_id: initial input_notify plus exactly - // TYPING_RENEWAL_LIMIT (3) renewals, then a renewal-free tick proving the - // reserved final reply; five message-tool sends where only the first can - // claim the fifth passive slot; then a static final. Every later text send - // falls back to a proactive body without msg_id/msg_seq. - { - name: "budget-exhaustion", - steps: [ - { kind: "reply-start" }, - { kind: "advance", ms: 5000 }, - { kind: "advance", ms: 5000 }, - { kind: "advance", ms: 5000 }, - { kind: "advance", ms: 5000 }, - { kind: "tool-progress", name: "message", phase: "result" }, - { kind: "tool-progress", name: "message", phase: "result" }, - { kind: "tool-progress", name: "message", phase: "result" }, - { kind: "tool-progress", name: "message", phase: "result" }, - { kind: "tool-progress", name: "message", phase: "result" }, - { kind: "final", text: "Budget check complete." }, - { kind: "idle" }, - ], - }, - deliveryTraceScenarios["final-only"], - deliveryTraceScenarios["cancel-mid-stream"], - // Media arriving mid-stream interrupts the session: DONE chunk for the text - // before the tag, synchronous upload + media message (both budget spends), - // then a fresh stream session with a new stream_msg_id and msg_seq resumes - // the remaining text. - { - name: "media-interrupt", - steps: [ - { kind: "reply-start" }, - { kind: "partial", text: "Here is the chart:" }, - { kind: "advance", ms: 300 }, - { kind: "partial", text: MEDIA_INTERRUPT_FULL_TEXT }, - { kind: "advance", ms: 300 }, - { kind: "final", text: MEDIA_INTERRUPT_FULL_TEXT }, - { kind: "idle" }, - ], - }, -]; - -const EXPECTED_REPLY_BUDGET_REMAINING: Readonly> = { - "streaming-happy-c2c": 3, - "budget-exhaustion": 0, - "media-interrupt": 1, -}; - -describe("qqbot delivery trace goldens", () => { - for (const scenario of QQBOT_TRACE_SCENARIOS) { - const scenarioName = scenario.name; - it(`records ${scenarioName}`, async () => { - const msgId = `qq-msg-${scenarioName}`; - try { - const events = await runDeliveryTraceScenario({ - scenario, - // Distinct msg_id per scenario keeps the module-global ReplyLimiter - // and upload caches from leaking budget state across scenarios. - setup: (recorder) => setupQqbotTrace(recorder, msgId), - }); - expectDeliveryTraceMatchesGolden({ - goldenUrl: new URL(`./__traces__/${scenarioName}.trace.jsonl`, import.meta.url), - events, - }); - const expectedRemaining = EXPECTED_REPLY_BUDGET_REMAINING[scenarioName]; - if (expectedRemaining !== undefined) { - const finalOffsetMs = events.at(-1)?.at ?? 0; - vi.useFakeTimers({ now: Date.UTC(2026, 0, 1) + finalOffsetMs }); - try { - // Each stream session and media message consumes one shared slot; - // uploads and later chunks within a session do not. - expect(checkMessageReplyLimit(msgId).remaining).toBe(expectedRemaining); - } finally { - vi.useRealTimers(); - } - } - } finally { - wire.recorder = null; - } - }); - } -}); diff --git a/extensions/qqbot/src/doctor-contract.test.ts b/extensions/qqbot/src/doctor-contract.test.ts deleted file mode 100644 index a0e6943f4ffb..000000000000 --- a/extensions/qqbot/src/doctor-contract.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -// Qqbot tests cover doctor migration behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it } from "vitest"; -import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js"; - -function findRule(pathSuffix: string, messageFragment: string) { - const rule = legacyConfigRules.find( - (candidate) => - candidate.path.join(".").endsWith(pathSuffix) && candidate.message.includes(messageFragment), - ); - if (!rule) { - throw new Error(`missing rule for ${pathSuffix} (${messageFragment})`); - } - return rule; -} - -describe("qqbot doctor contract", () => { - it("detects legacy root and account group toolPolicy config", () => { - expect( - findRule("qqbot.groups", "toolPolicy").match?.( - { - G1: { toolPolicy: "none" }, - }, - {}, - ), - ).toBe(true); - expect( - findRule("qqbot.accounts", "toolPolicy").match?.( - { - bot2: { - groups: { - G1: { toolPolicy: "none" }, - }, - }, - }, - {}, - ), - ).toBe(true); - }); - - it("detects legacy scalar streaming and c2cStreamApi config", () => { - const rootRule = findRule("channels.qqbot", "nativeTransport"); - expect(rootRule.match?.({ streaming: true }, {})).toBe(true); - expect(rootRule.match?.({ streaming: false }, {})).toBe(true); - expect(rootRule.match?.({ streaming: { mode: "off", c2cStreamApi: true } }, {})).toBe(true); - expect(rootRule.match?.({ streaming: { mode: "partial", nativeTransport: true } }, {})).toBe( - false, - ); - const accountsRule = findRule("qqbot.accounts", "nativeTransport"); - expect(accountsRule.match?.({ bot2: { streaming: true } }, {})).toBe(true); - expect(accountsRule.match?.({ bot2: { streaming: { mode: "off" } } }, {})).toBe(false); - }); - - it("migrates streaming true to the full nested enable (mode + nativeTransport)", () => { - const cfg = { channels: { qqbot: { streaming: true } } } as OpenClawConfig; - const result = normalizeCompatibilityConfig({ cfg }); - expect(result.config.channels?.qqbot?.streaming).toStrictEqual({ - mode: "partial", - nativeTransport: true, - }); - expect(result.changes).toContain( - "Moved channels.qqbot.streaming (boolean) → channels.qqbot.streaming.nativeTransport.", - ); - }); - - it("migrates streaming false to mode off without nativeTransport", () => { - const cfg = { channels: { qqbot: { streaming: false } } } as OpenClawConfig; - const result = normalizeCompatibilityConfig({ cfg }); - expect(result.config.channels?.qqbot?.streaming).toStrictEqual({ mode: "off" }); - }); - - it("renames c2cStreamApi to nativeTransport preserving the rest of the object", () => { - const cfg = { - channels: { - qqbot: { - streaming: { mode: "off", c2cStreamApi: true }, - accounts: { - bot2: { streaming: { c2cStreamApi: false } }, - }, - }, - }, - } as never as OpenClawConfig; - const result = normalizeCompatibilityConfig({ cfg }); - expect(result.config.channels?.qqbot?.streaming).toStrictEqual({ - mode: "off", - nativeTransport: true, - }); - expect(result.config.channels?.qqbot?.accounts?.bot2?.streaming).toStrictEqual({ - nativeTransport: false, - }); - }); - - it("drops c2cStreamApi when nativeTransport is already set", () => { - const cfg = { - channels: { - qqbot: { streaming: { nativeTransport: false, c2cStreamApi: true } }, - }, - } as never as OpenClawConfig; - const result = normalizeCompatibilityConfig({ cfg }); - expect(result.config.channels?.qqbot?.streaming).toStrictEqual({ nativeTransport: false }); - expect(result.changes).toContain( - "Removed channels.qqbot.streaming.c2cStreamApi (channels.qqbot.streaming.nativeTransport already set).", - ); - }); - - it("migrates account-level scalar streaming without touching other accounts", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { streaming: true }, - bot3: { streaming: { mode: "partial" } }, - }, - }, - }, - } as never as OpenClawConfig; - const result = normalizeCompatibilityConfig({ cfg }); - expect(result.config.channels?.qqbot?.accounts?.bot2?.streaming).toStrictEqual({ - mode: "partial", - nativeTransport: true, - }); - expect(result.config.channels?.qqbot?.accounts?.bot3?.streaming).toStrictEqual({ - mode: "partial", - }); - }); - - it("moves legacy voice upload formats at root and account scope", () => { - const cfg = { - channels: { - qqbot: { - voiceDirectUploadFormats: [".mp3"], - audioFormatPolicy: { transcodeEnabled: false }, - accounts: { - bot2: { - voiceDirectUploadFormats: [".silk"], - }, - bot3: { - voiceDirectUploadFormats: [".silk"], - audioFormatPolicy: { uploadDirectFormats: [".wav"] }, - }, - }, - }, - }, - } as never as OpenClawConfig; - - const result = normalizeCompatibilityConfig({ cfg }); - const qqbot = result.config.channels?.qqbot as unknown as Record; - expect(qqbot.voiceDirectUploadFormats).toBeUndefined(); - expect(qqbot.audioFormatPolicy).toStrictEqual({ - transcodeEnabled: false, - uploadDirectFormats: [".mp3"], - }); - const accounts = qqbot.accounts as Record>; - expect(accounts.bot2?.voiceDirectUploadFormats).toBeUndefined(); - expect(accounts.bot2?.audioFormatPolicy).toStrictEqual({ uploadDirectFormats: [".silk"] }); - expect(accounts.bot3?.voiceDirectUploadFormats).toBeUndefined(); - expect(accounts.bot3?.audioFormatPolicy).toStrictEqual({ uploadDirectFormats: [".wav"] }); - }); - - it("is idempotent: a second run reports no changes", () => { - const cfg = { - channels: { - qqbot: { streaming: true, accounts: { bot2: { streaming: false } } }, - }, - } as never as OpenClawConfig; - const first = normalizeCompatibilityConfig({ cfg }); - expect(first.changes.length).toBeGreaterThan(0); - const second = normalizeCompatibilityConfig({ cfg: first.config }); - expect(second.changes).toEqual([]); - expect(second.config).toBe(first.config); - }); - - it("migrates root legacy toolPolicy values to canonical tools", () => { - const cfg = { - channels: { - qqbot: { - groups: { - G1: { toolPolicy: "none", requireMention: true }, - G2: { toolPolicy: "full" }, - G3: { toolPolicy: "restricted" }, - }, - }, - }, - } as OpenClawConfig; - - const result = normalizeCompatibilityConfig({ cfg }); - - expect(result.changes).toHaveLength(3); - expect(result.config.channels?.qqbot?.groups).toStrictEqual({ - G1: { requireMention: true, tools: { deny: ["*"] } }, - G2: { tools: { allow: [] } }, - G3: { tools: { deny: ["exec", "read", "write"] } }, - }); - }); - - it("migrates named-account group toolPolicy values", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { - groups: { - G1: { toolPolicy: "none" }, - }, - }, - }, - }, - }, - } as OpenClawConfig; - - const result = normalizeCompatibilityConfig({ cfg }); - - expect(result.changes).toContain( - "Moved channels.qqbot.accounts.bot2.groups.G1.toolPolicy=none to channels.qqbot.accounts.bot2.groups.G1.tools.", - ); - expect(result.config.channels?.qqbot?.accounts?.bot2?.groups).toStrictEqual({ - G1: { tools: { deny: ["*"] } }, - }); - }); - - it("preserves existing canonical tools while deleting legacy toolPolicy", () => { - const cfg = { - channels: { - qqbot: { - groups: { - G1: { toolPolicy: "none", tools: { allow: ["read"] } }, - }, - }, - }, - } as OpenClawConfig; - - const result = normalizeCompatibilityConfig({ cfg }); - - expect(result.changes).toContain( - "Removed channels.qqbot.groups.G1.toolPolicy (channels.qqbot.groups.G1.tools already exists).", - ); - expect(result.config.channels?.qqbot?.groups).toStrictEqual({ - G1: { tools: { allow: ["read"] } }, - }); - }); -}); diff --git a/extensions/qqbot/src/doctor-contract.ts b/extensions/qqbot/src/doctor-contract.ts deleted file mode 100644 index 23ca02db32e0..000000000000 --- a/extensions/qqbot/src/doctor-contract.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Qqbot plugin module implements doctor contract behavior. -import type { - ChannelDoctorConfigMutation, - ChannelDoctorLegacyConfigRule, -} from "openclaw/plugin-sdk/channel-contract"; -import type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - asObjectRecord, - defineKeyMoveMigration, - hasLegacyAccountStreamingAliases, - normalizeChannelConfigEntries, -} from "openclaw/plugin-sdk/runtime-doctor-migrations"; - -const RESTRICTED_GROUP_TOOLS: GroupToolPolicyConfig = { - deny: ["exec", "read", "write"], -}; - -const streamingTransportMigration = defineKeyMoveMigration({ - from: ["streaming", "c2cStreamApi"], - to: ["streaming", "nativeTransport"], - match: (value) => value !== undefined, - sourceOwn: false, -}); - -// QQBot's legacy scalar `streaming` is not a plain mode alias: `true` enabled -// block streaming AND the official C2C stream API (shouldUseOfficialC2cStream -// treated `true` like `c2cStreamApi: true`), while `false` only disabled block -// streaming. It migrates to the nested `{mode, nativeTransport}` shape here -// instead of the shared alias DSL because qqbot has no flat delivery aliases -// and its strict streaming schema rejects the DSL's chunkMode/block slots. -// No account seeding: named accounts never inherit root config (bridge/config -// resolves them standalone), and the boolean carries its full semantics. -function hasLegacyStreamingValue(value: unknown): boolean { - const entry = asObjectRecord(value); - if (!entry) { - return false; - } - return typeof entry.streaming === "boolean" || streamingTransportMigration.hasLegacy(entry); -} - -function migrateStreamingValue(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - const streaming = params.entry.streaming; - const path = `${params.pathPrefix}.streaming`; - if (typeof streaming === "boolean") { - const next: Record = streaming - ? { mode: "partial", nativeTransport: true } - : { mode: "off" }; - params.changes.push(`Moved ${path} (boolean) → ${path}.mode (${next.mode as string}).`); - if (streaming) { - // `streaming: true` also enabled the official C2C stream API. - params.changes.push(`Moved ${path} (boolean) → ${path}.nativeTransport.`); - } - return { entry: { ...params.entry, streaming: next }, changed: true }; - } - return streamingTransportMigration.normalize(params); -} - -function migrateToolPolicy(value: unknown): GroupToolPolicyConfig | undefined { - if (value === "none") { - return { deny: ["*"] }; - } - if (value === "full") { - return { allow: [] }; - } - if (value === "restricted") { - return { ...RESTRICTED_GROUP_TOOLS }; - } - return undefined; -} - -function describeToolPolicy(value: unknown): string { - return typeof value === "string" ? value : String(value); -} - -const groupToolPolicyMigration = defineKeyMoveMigration({ - scope: ["*"], - from: ["toolPolicy"], - to: ["tools"], - match: (value) => value !== undefined, - sourceOwn: false, - map: (value) => { - const policy = migrateToolPolicy(value); - return policy ? { value: policy } : null; - }, - movedMessage: ({ sourcePath, targetPath, sourceValue }) => - `Moved ${sourcePath}=${describeToolPolicy(sourceValue)} to ${targetPath}.`, - existingMessage: ({ sourcePath, targetPath }) => - `Removed ${sourcePath} (${targetPath} already exists).`, - invalidMessage: ({ sourcePath, sourceValue }) => - `Removed unsupported ${sourcePath}=${describeToolPolicy(sourceValue)}.`, -}); - -const voiceDirectUploadFormatsMigration = defineKeyMoveMigration({ - from: ["voiceDirectUploadFormats"], - to: ["audioFormatPolicy", "uploadDirectFormats"], - match: (value) => value !== undefined, - sourceOwn: false, -}); - -export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ - { - path: ["channels", "qqbot"], - message: - 'channels.qqbot streaming aliases and voiceDirectUploadFormats are legacy; use streaming.{mode,nativeTransport} and audioFormatPolicy.uploadDirectFormats. Run "openclaw doctor --fix".', - match: (value) => - hasLegacyStreamingValue(value) || voiceDirectUploadFormatsMigration.hasLegacy(value), - }, - { - path: ["channels", "qqbot", "accounts"], - message: - 'channels.qqbot account streaming aliases and voiceDirectUploadFormats are legacy; use streaming.{mode,nativeTransport} and audioFormatPolicy.uploadDirectFormats. Run "openclaw doctor --fix".', - match: (value) => - hasLegacyAccountStreamingAliases( - value, - (entry) => - hasLegacyStreamingValue(entry) || voiceDirectUploadFormatsMigration.hasLegacy(entry), - ), - }, - { - path: ["channels", "qqbot", "groups"], - message: - 'channels.qqbot.groups..toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.groups..tools instead. Run "openclaw doctor --fix".', - match: groupToolPolicyMigration.hasLegacy, - }, - { - path: ["channels", "qqbot", "accounts"], - message: - 'channels.qqbot.accounts..groups..toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.accounts..groups..tools instead. Run "openclaw doctor --fix".', - match: (value) => - hasLegacyAccountStreamingAliases(value, (account) => - groupToolPolicyMigration.hasLegacy(asObjectRecord(account)?.groups), - ), - }, -]; - -function normalizeQqbotEntry(params: { - entry: Record; - pathPrefix: string; - changes: string[]; -}): { entry: Record; changed: boolean } { - let { entry, changed } = migrateStreamingValue(params); - const audioFormats = voiceDirectUploadFormatsMigration.normalize({ - ...params, - entry, - }); - entry = audioFormats.entry; - changed ||= audioFormats.changed; - const groups = asObjectRecord(entry.groups); - if (!groups) { - return { entry, changed }; - } - const migrated = groupToolPolicyMigration.normalize({ - entry: groups, - pathPrefix: `${params.pathPrefix}.groups`, - changes: params.changes, - }); - return migrated.changed - ? { entry: { ...entry, groups: migrated.entry }, changed: true } - : { entry, changed }; -} - -export function normalizeCompatibilityConfig({ - cfg, -}: { - cfg: OpenClawConfig; -}): ChannelDoctorConfigMutation { - return normalizeChannelConfigEntries({ - cfg, - channelId: "qqbot", - normalizeEntry: normalizeQqbotEntry, - }); -} diff --git a/extensions/qqbot/src/doctor.ts b/extensions/qqbot/src/doctor.ts deleted file mode 100644 index b743be25ff74..000000000000 --- a/extensions/qqbot/src/doctor.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Qqbot plugin module implements doctor behavior. -import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; -import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js"; - -export const qqbotDoctor: ChannelDoctorAdapter = { - legacyConfigRules, - normalizeCompatibilityConfig, -}; diff --git a/extensions/qqbot/src/engine/access/index.ts b/extensions/qqbot/src/engine/access/index.ts deleted file mode 100644 index c391db87f327..000000000000 --- a/extensions/qqbot/src/engine/access/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Qqbot plugin entrypoint registers its OpenClaw integration. -export { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js"; -export { type QQBotDmPolicy, type QQBotGroupPolicy } from "./types.js"; diff --git a/extensions/qqbot/src/engine/access/resolve-policy.test.ts b/extensions/qqbot/src/engine/access/resolve-policy.test.ts deleted file mode 100644 index 4b313b835da4..000000000000 --- a/extensions/qqbot/src/engine/access/resolve-policy.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Qqbot tests cover resolve policy plugin behavior. -import { describe, expect, it } from "vitest"; -import { resolveQQBotEffectivePolicies } from "./resolve-policy.js"; - -describe("resolveQQBotEffectivePolicies", () => { - describe("backwards-compatible inference", () => { - it("defaults to open when no allowFrom is configured", () => { - expect(resolveQQBotEffectivePolicies({})).toEqual({ - dmPolicy: "open", - groupPolicy: "open", - }); - }); - - it("defaults to open when allowFrom only contains wildcard", () => { - expect(resolveQQBotEffectivePolicies({ allowFrom: ["*"] })).toEqual({ - dmPolicy: "open", - groupPolicy: "open", - }); - }); - - it("infers allowlist when allowFrom has a concrete entry", () => { - expect(resolveQQBotEffectivePolicies({ allowFrom: ["USER1"] })).toEqual({ - dmPolicy: "allowlist", - groupPolicy: "allowlist", - }); - }); - - it("infers group=allowlist when only groupAllowFrom is restricted", () => { - expect( - resolveQQBotEffectivePolicies({ allowFrom: ["*"], groupAllowFrom: ["USER1"] }), - ).toEqual({ - dmPolicy: "open", - groupPolicy: "allowlist", - }); - }); - }); - - describe("explicit policy precedence", () => { - it("honours explicit dmPolicy over inference", () => { - expect(resolveQQBotEffectivePolicies({ allowFrom: ["USER1"], dmPolicy: "open" })).toEqual({ - dmPolicy: "open", - groupPolicy: "allowlist", - }); - }); - - it("honours explicit groupPolicy over inference", () => { - expect( - resolveQQBotEffectivePolicies({ - allowFrom: ["USER1"], - groupPolicy: "disabled", - }), - ).toEqual({ dmPolicy: "allowlist", groupPolicy: "disabled" }); - }); - - it("allows dmPolicy=disabled to cut off DM entirely", () => { - expect(resolveQQBotEffectivePolicies({ dmPolicy: "disabled" })).toEqual({ - dmPolicy: "disabled", - groupPolicy: "open", - }); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/access/resolve-policy.ts b/extensions/qqbot/src/engine/access/resolve-policy.ts deleted file mode 100644 index bbdcefaa9049..000000000000 --- a/extensions/qqbot/src/engine/access/resolve-policy.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Qqbot plugin module implements resolve policy behavior. -import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js"; - -export interface EffectivePolicyInput { - allowFrom?: Array | null; - groupAllowFrom?: Array | null; - dmPolicy?: QQBotDmPolicy | null; - groupPolicy?: QQBotGroupPolicy | null; -} - -function hasRealRestriction(list: Array | null | undefined): boolean { - if (!list || list.length === 0) { - return false; - } - return !list.every((entry) => String(entry).trim() === "*"); -} - -export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): { - dmPolicy: QQBotDmPolicy; - groupPolicy: QQBotGroupPolicy; -} { - const allowFromRestricted = hasRealRestriction(input.allowFrom); - const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom); - - const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open"); - - const groupPolicy: QQBotGroupPolicy = - input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open"); - - return { dmPolicy, groupPolicy }; -} diff --git a/extensions/qqbot/src/engine/access/sender-match.test.ts b/extensions/qqbot/src/engine/access/sender-match.test.ts deleted file mode 100644 index b12943cc2689..000000000000 --- a/extensions/qqbot/src/engine/access/sender-match.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Qqbot tests cover sender match plugin behavior. -import { describe, expect, it } from "vitest"; -import { - createQQBotSenderMatcher, - normalizeQQBotAllowFrom, - normalizeQQBotSenderId, -} from "./sender-match.js"; - -describe("normalizeQQBotSenderId", () => { - it("uppercases and strips qqbot: prefix", () => { - expect(normalizeQQBotSenderId("qqbot:abc123")).toBe("ABC123"); - expect(normalizeQQBotSenderId("QQBot:abc123")).toBe("ABC123"); - }); - - it("trims whitespace", () => { - expect(normalizeQQBotSenderId(" USER1 ")).toBe("USER1"); - }); - - it("returns empty string for non-string input", () => { - expect(normalizeQQBotSenderId(undefined as unknown as string)).toBe(""); - expect(normalizeQQBotSenderId(null as unknown as string)).toBe(""); - expect(normalizeQQBotSenderId({} as unknown as string)).toBe(""); - }); - - it("accepts numeric input", () => { - expect(normalizeQQBotSenderId(42)).toBe("42"); - }); -}); - -describe("normalizeQQBotAllowFrom", () => { - it("normalizes all entries and drops empty ones", () => { - expect(normalizeQQBotAllowFrom(["qqbot:user1", "USER2", "", " "])).toEqual(["USER1", "USER2"]); - }); - - it("returns empty array for undefined/null", () => { - expect(normalizeQQBotAllowFrom(undefined)).toStrictEqual([]); - expect(normalizeQQBotAllowFrom(null)).toStrictEqual([]); - }); -}); - -describe("createQQBotSenderMatcher", () => { - it("matches wildcard regardless of sender", () => { - expect(createQQBotSenderMatcher("USER1")(["*"])).toBe(true); - expect(createQQBotSenderMatcher("")(["*"])).toBe(true); - }); - - it("matches case-insensitive with qqbot: prefix", () => { - const match = createQQBotSenderMatcher("qqbot:USER1"); - expect(match(["qqbot:user1"])).toBe(true); - expect(match(["USER1"])).toBe(true); - expect(match(["USER2"])).toBe(false); - }); - - it("returns false on empty allowlist", () => { - expect(createQQBotSenderMatcher("USER1")([])).toBe(false); - }); - - it("returns false for empty sender against non-wildcard list", () => { - expect(createQQBotSenderMatcher("")(["USER1"])).toBe(false); - }); -}); diff --git a/extensions/qqbot/src/engine/access/sender-match.ts b/extensions/qqbot/src/engine/access/sender-match.ts deleted file mode 100644 index 9b0b5092572b..000000000000 --- a/extensions/qqbot/src/engine/access/sender-match.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * QQBot sender normalization and allowlist matching. - * - * Keeps QQ-specific quirks (the `qqbot:` prefix, uppercase-insensitive - * comparison) localized to this module so the policy engine itself can - * stay channel-agnostic. - */ - -/** Normalize a single entry (openid): strip `qqbot:` prefix, uppercase, trim. */ -export function normalizeQQBotSenderId(raw: unknown): string { - if (typeof raw !== "string" && typeof raw !== "number") { - return ""; - } - return String(raw) - .trim() - .replace(/^qqbot:/i, "") - .toUpperCase(); -} - -/** Normalize an entire allowFrom list, dropping empty entries. */ -export function normalizeQQBotAllowFrom(list: Array | undefined | null): string[] { - if (!list || list.length === 0) { - return []; - } - const out: string[] = []; - for (const entry of list) { - const normalized = normalizeQQBotSenderId(entry); - if (normalized) { - out.push(normalized); - } - } - return out; -} - -/** - * Build a matcher closure suitable for passing to the policy engine's - * `isSenderAllowed` callback. The caller supplies the sender once, and - * the returned function can be invoked against different allowlists - * (DM allowlist vs group allowlist) without repeating normalization. - */ -export function createQQBotSenderMatcher(senderId: string): (allowFrom: string[]) => boolean { - const normalizedSender = normalizeQQBotSenderId(senderId); - return (allowFrom: string[]) => { - if (allowFrom.length === 0) { - return false; - } - if (allowFrom.includes("*")) { - return true; - } - if (!normalizedSender) { - return false; - } - return allowFrom.some((entry) => normalizeQQBotSenderId(entry) === normalizedSender); - }; -} diff --git a/extensions/qqbot/src/engine/access/types.ts b/extensions/qqbot/src/engine/access/types.ts deleted file mode 100644 index d2a61b0910a3..000000000000 --- a/extensions/qqbot/src/engine/access/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Qqbot type declarations define plugin contracts. -export type QQBotDmPolicy = "open" | "allowlist" | "disabled"; -export type QQBotGroupPolicy = "open" | "allowlist" | "disabled"; diff --git a/extensions/qqbot/src/engine/adapter/audio.port.ts b/extensions/qqbot/src/engine/adapter/audio.port.ts deleted file mode 100644 index cd8cc9b2644c..000000000000 --- a/extensions/qqbot/src/engine/adapter/audio.port.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Audio port — abstracts inbound + outbound audio conversion operations. - * - * The engine defines this interface; the bridge layer provides an - * implementation backed by `engine/utils/audio.js` functions. - */ - -/** Inbound audio conversion (SILK→WAV, voice detection, duration formatting). */ -export interface AudioConvertPort { - convertSilkToWav( - silkPath: string, - outputDir: string, - ): Promise<{ wavPath: string; duration: number } | null>; - isVoiceAttachment(att: { content_type: string; filename?: string }): boolean; - formatDuration(seconds: number): string; -} - -/** Outbound audio conversion (WAV→SILK, audio detection, transcoding). */ -export interface OutboundAudioPort { - audioFileToSilkBase64( - audioPath: string, - directUploadFormats?: string[], - ): Promise; - isAudioFile(pathOrUrl: string, mimeType?: string): boolean; - shouldTranscodeVoice(filePath: string): boolean; - waitForFile(filePath: string, maxWaitMs?: number): Promise; -} diff --git a/extensions/qqbot/src/engine/adapter/commands.port.ts b/extensions/qqbot/src/engine/adapter/commands.port.ts deleted file mode 100644 index db34078e5262..000000000000 --- a/extensions/qqbot/src/engine/adapter/commands.port.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Commands port — abstracts slash-command dependencies injected by the - * bridge layer (version resolvers, approve runtime getter). - * - * Eliminates global `register*` singletons in `slash-commands-impl.ts`. - */ - -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; - -/** Runtime getter shape for the `/bot-approve` command. */ -export type ApproveRuntimeGetter = () => { - config: Pick; -}; - -export interface CommandsPort { - /** Resolve the framework runtime version string. */ - resolveVersion: () => string; - /** Plugin version string (e.g. "1.2.3"). */ - pluginVersion: string; - /** Runtime getter for `/bot-approve` config management. */ - approveRuntimeGetter?: ApproveRuntimeGetter; -} diff --git a/extensions/qqbot/src/engine/adapter/history.port.ts b/extensions/qqbot/src/engine/adapter/history.port.ts deleted file mode 100644 index 29e1210e4d1c..000000000000 --- a/extensions/qqbot/src/engine/adapter/history.port.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * History port — abstracts the group history cache operations. - * - * The engine defines this interface; the bridge layer provides an - * implementation backed by SDK `reply-history` functions. The engine's - * built-in implementation in `group/history.ts` is used as the default - * when no adapter is injected (standalone build). - */ - -/** Minimal history entry shape expected by the port. */ -export interface HistoryEntryLike { - sender: string; - body: string; - timestamp?: number; - messageId?: string; -} - -export interface HistoryPort { - /** - * Record a non-@ message into the pending history buffer. - * No-op when `limit <= 0` or `entry` is missing. - */ - recordPendingHistoryEntry(params: { - historyMap: Map; - historyKey: string; - entry?: T | null; - limit: number; - }): T[]; - - /** - * Build the full user-message string prefixed with buffered history. - * Returns `currentMessage` unchanged when no history exists. - */ - buildPendingHistoryContext(params: { - historyMap: Map; - historyKey: string; - limit: number; - currentMessage: string; - formatEntry: (entry: HistoryEntryLike) => string; - lineBreak?: string; - }): string; - - /** - * Clear a group's pending history buffer. - * No-op when `limit <= 0`. - */ - clearPendingHistory(params: { - historyMap: Map; - historyKey: string; - limit: number; - }): void; -} diff --git a/extensions/qqbot/src/engine/adapter/index.ts b/extensions/qqbot/src/engine/adapter/index.ts deleted file mode 100644 index 25a44b3a219f..000000000000 --- a/extensions/qqbot/src/engine/adapter/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Qqbot plugin entrypoint registers its OpenClaw integration. -import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; -import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; -import type { EffectivePolicyInput } from "../access/resolve-policy.js"; -import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js"; - -export type QQBotInboundAccess = ResolvedChannelMessageIngress; - -export interface AccessPort { - resolveInboundAccess( - input: EffectivePolicyInput & { - cfg: unknown; - accountId: string; - isGroup: boolean; - senderId: string; - conversationId: string; - }, - ): QQBotInboundAccess | Promise; - - resolveSlashCommandAuthorization(input: { - cfg: unknown; - accountId: string; - isGroup: boolean; - senderId: string; - conversationId: string; - allowFrom?: Array; - groupAllowFrom?: Array; - commandsAllowFrom?: Array; - }): boolean | Promise; -} - -export interface EngineAdapters { - history: import("./history.port.js").HistoryPort; - mentionGate: import("./mention-gate.port.js").MentionGatePort; - access: AccessPort; - audioConvert: import("./audio.port.js").AudioConvertPort; - outboundAudio: import("./audio.port.js").OutboundAudioPort; - commands: import("./commands.port.js").CommandsPort; -} - -export interface PlatformAdapter { - validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise; - resolveSecret(value: string | SecretInputRef | undefined): Promise; - downloadFile(url: string, destDir: string, filename?: string): Promise; - fetchMedia(options: FetchMediaOptions): Promise; - getTempDir(): string; - hasConfiguredSecret(value: unknown): boolean; - normalizeSecretInputString(value: unknown): string | undefined; - resolveSecretInputString(params: { value: unknown; path: string }): string | undefined; - resolveApproval?(params: { - approvalId: string; - approvalKind: "exec" | "plugin"; - decision: "allow-once" | "allow-always" | "deny"; - accountId: string; - senderId: string; - }): Promise; -} - -let platformAdapter: PlatformAdapter | null = null; -let platformAdapterFactory: (() => PlatformAdapter) | null = null; - -export function registerPlatformAdapter(adapter: PlatformAdapter): void { - platformAdapter = adapter; -} - -export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void { - platformAdapterFactory = factory; -} - -export function getPlatformAdapter(): PlatformAdapter { - if (!platformAdapter && platformAdapterFactory) { - platformAdapter = platformAdapterFactory(); - } - if (!platformAdapter) { - throw new Error( - "PlatformAdapter not registered. Call registerPlatformAdapter() during bootstrap.", - ); - } - return platformAdapter; -} - -export function hasPlatformAdapter(): boolean { - return platformAdapter !== null || platformAdapterFactory !== null; -} diff --git a/extensions/qqbot/src/engine/adapter/mention-gate.port.ts b/extensions/qqbot/src/engine/adapter/mention-gate.port.ts deleted file mode 100644 index 7a03a9940208..000000000000 --- a/extensions/qqbot/src/engine/adapter/mention-gate.port.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Mention gate port — abstracts the SDK's `resolveInboundMentionDecision` - * + `resolveControlCommandGate` into a single interface. - * - * The engine's `resolveGroupMessageGate` (Layer 1: ignoreOtherMentions) - * is QQ-specific and stays in `group/message-gating.ts`. Layer 2+3 - * (command gating + mention gating + command bypass) delegate to this port. - */ - -/** Implicit mention kind aligned with SDK's `InboundImplicitMentionKind`. */ -type ImplicitMentionKind = "reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native"; - -/** Facts about the current message's mention state. */ -interface MentionFacts { - canDetectMention: boolean; - wasMentioned: boolean; - hasAnyMention?: boolean; - implicitMentionKinds?: readonly ImplicitMentionKind[]; -} - -/** Policy configuration for the mention gate. */ -interface MentionPolicy { - isGroup: boolean; - requireMention: boolean; - allowTextCommands: boolean; - hasControlCommand: boolean; - commandAuthorized: boolean; -} - -/** Result of the mention gate evaluation. */ -interface MentionGateDecision { - effectiveWasMentioned: boolean; - shouldSkip: boolean; - shouldBypassMention: boolean; - implicitMention: boolean; -} - -export interface MentionGatePort { - /** - * Evaluate whether the message should be skipped based on mention - * policy, command bypass, and implicit mention rules. - * - * Equivalent to SDK's `resolveInboundMentionDecision` with the - * command-bypass logic folded in. - */ - resolveInboundMentionDecision(params: { - facts: MentionFacts; - policy: MentionPolicy; - }): MentionGateDecision; -} diff --git a/extensions/qqbot/src/engine/adapter/types.ts b/extensions/qqbot/src/engine/adapter/types.ts deleted file mode 100644 index f945aebf61f2..000000000000 --- a/extensions/qqbot/src/engine/adapter/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Shared types used by the PlatformAdapter interface. - */ - -/** Reference to a secret stored in the platform's secret management system. */ -export interface SecretInputRef { - source: "env" | "file" | "config"; - id: string; -} - -/** Options for fetching remote media through the platform adapter. */ -export interface FetchMediaOptions { - url: string; - /** Hint for the local filename when saving. */ - filePathHint?: string; - /** Maximum bytes to download. */ - maxBytes?: number; - /** Maximum redirects to follow. */ - maxRedirects?: number; - /** Abort the complete remote media request after this many milliseconds. */ - timeoutMs?: number; - /** Abort if final response headers have not arrived after this many milliseconds. */ - responseHeaderTimeoutMs?: number; - /** SSRF policy configuration. */ - ssrfPolicy?: SsrfPolicyConfig; - /** Extra fetch() RequestInit options. */ - requestInit?: RequestInit; -} - -/** Result of a remote media fetch operation. */ -export interface FetchMediaResult { - buffer: Buffer; - fileName?: string; -} - -/** SSRF policy configuration — platform-agnostic subset. */ -export interface SsrfPolicyConfig { - /** Hostnames that are always allowed (supports `*.example.com` wildcards). */ - hostnameAllowlist?: string[]; - /** Whether to allow RFC 2544 benchmark ranges (198.18.0.0/15). */ - allowRfc2544BenchmarkRange?: boolean; -} diff --git a/extensions/qqbot/src/engine/api/api-client.test.ts b/extensions/qqbot/src/engine/api/api-client.test.ts deleted file mode 100644 index 03bb83d6ded3..000000000000 --- a/extensions/qqbot/src/engine/api/api-client.test.ts +++ /dev/null @@ -1,280 +0,0 @@ -import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime"; -// Qqbot tests cover api-client plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js"; - -const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); -const ssrfRuntimeActual = vi.hoisted(() => ({ - fetchWithSsrFGuard: undefined as - | typeof import("openclaw/plugin-sdk/ssrf-runtime").fetchWithSsrFGuard - | undefined, -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const actual = await importOriginal(); - ssrfRuntimeActual.fetchWithSsrFGuard = actual.fetchWithSsrFGuard; - return { - ...actual, - fetchWithSsrFGuard: fetchWithSsrFGuardMock, - }; -}); - -import { ApiError } from "../types.js"; -import { ApiClient } from "./api-client.js"; - -function cancelTrackedResponse( - text: string, - init: ResponseInit, -): { - response: Response; - wasCanceled: () => boolean; -} { - let canceled = false; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(text)); - }, - cancel() { - canceled = true; - }, - }); - return { - response: new Response(stream, init), - wasCanceled: () => canceled, - }; -} - -describe("ApiClient", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - fetchWithSsrFGuardMock.mockReset(); - }); - - it("bounds error bodies on a UTF-16 boundary without using response.text()", async () => { - const release = vi.fn(async () => {}); - const safePrefix = "x".repeat(199); - const tracked = cancelTrackedResponse(`${safePrefix}🎉${"tail".repeat(4096)}`, { - status: 503, - headers: { "content-type": "text/plain" }, - }); - const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: tracked.response, - release, - }); - - const client = new ApiClient({ baseUrl: "https://qqbot.test" }); - - let error: unknown; - try { - await client.request("token-1", "GET", "/v2/users/@me"); - } catch (caught) { - error = caught; - } - - expect(error).toBeInstanceOf(ApiError); - expect((error as Error).message).toBe(`API Error [/v2/users/@me] HTTP 503: ${safePrefix}`); - expect(tracked.wasCanceled()).toBe(true); - expect(textSpy).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ - url: "https://qqbot.test/v2/users/@me", - init: { - method: "GET", - headers: { - Authorization: "QQBot token-1", - "Content-Type": "application/json", - "User-Agent": "QQBotPlugin/unknown", - }, - }, - auditContext: "qqbot-api", - policy: { - hostnameAllowlist: ["qqbot.test"], - allowRfc2544BenchmarkRange: true, - }, - timeoutMs: 30_000, - }); - }); - - it("adds network and whitelist guidance to DNS failures without suggesting credentials", async () => { - fetchWithSsrFGuardMock.mockRejectedValueOnce( - new Error("getaddrinfo ENOTFOUND api.sgroup.qq.com"), - ); - - const client = new ApiClient({ baseUrl: "https://qqbot.test" }); - let error: unknown; - try { - await client.request("token-1", "GET", "/v2/users/@me"); - } catch (caught) { - error = caught; - } - - const message = error instanceof Error ? error.message : String(error); - expect(message).toContain("Network error [/v2/users/@me]"); - expect(message).toContain("network connectivity and DNS"); - expect(message).toContain("server IP whitelist"); - expect(message).not.toContain("appId"); - expect(message).not.toContain("clientSecret"); - }); - - it("adds credential guidance to structured HTTP 401 errors", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response('{"code":11241,"message":"invalid credentials"}', { - status: 401, - headers: { "content-type": "application/json" }, - }), - release, - }); - - const client = new ApiClient({ baseUrl: "https://qqbot.test" }); - let error: unknown; - try { - await client.request("token-1", "POST", "/v2/messages", { content: "hi" }); - } catch (caught) { - error = caught; - } - - const message = error instanceof Error ? error.message : String(error); - expect(message).toContain("API Error [/v2/messages]: invalid credentials"); - expect(message).toContain("QQBot account appId and clientSecret"); - expect(message).toContain("https://q.qq.com/"); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("adds credential guidance when QQ reports an expired token as HTTP 500", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response('{"code":11244,"message":"token not exist or expire"}', { - status: 500, - headers: { "content-type": "application/json" }, - }), - release, - }); - - const client = new ApiClient({ baseUrl: "https://qqbot.test" }); - let error: unknown; - try { - await client.request("token-1", "GET", "/gateway"); - } catch (caught) { - error = caught; - } - - expect(error).toBeInstanceOf(ApiError); - expect(error).toMatchObject({ httpStatus: 500, bizCode: 11244 }); - expect((error as Error).message).toContain("QQBot account appId and clientSecret"); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("keeps non-auth structured API guidance generic", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response('{"code":40034025,"message":"invalid event id"}', { - status: 400, - headers: { "content-type": "application/json" }, - }), - release, - }); - - const client = new ApiClient({ baseUrl: "https://qqbot.test" }); - let error: unknown; - try { - await client.request("token-1", "POST", "/v2/messages", { content: "hi" }); - } catch (caught) { - error = caught; - } - - const message = error instanceof Error ? error.message : String(error); - expect(message).toContain("API Error [/v2/messages]: invalid event id"); - expect(message).toContain("QQBot API troubleshooting"); - expect(message).not.toContain("appId"); - expect(message).not.toContain("clientSecret"); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("bounds successful response bodies without using response.text()", async () => { - const release = vi.fn(async () => {}); - const streamed = createStreamingResponse({ - chunkCount: 32, - chunkSize: 1024 * 1024, - text: "x", - headers: { "content-type": "application/json" }, - }); - const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded")); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: streamed.response, - release, - }); - - const client = new ApiClient({ baseUrl: "https://qqbot.test" }); - - let error: unknown; - try { - await client.request("token-1", "GET", "/v2/users/@me"); - } catch (caught) { - error = caught; - } - - expect(error).toBeInstanceOf(ApiError); - expect(String(error)).toContain("QQBot API response: text response exceeds 16777216 bytes"); - expect(streamed.getReadCount()).toBeLessThan(32); - expect(streamed.wasCanceled()).toBe(true); - expect(textSpy).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); - }); - - it.each([0, 25])( - "keeps the %dms request deadline active while reading a hanging response body", - async (timeoutMs) => { - vi.useFakeTimers(); - const actualGuard = ssrfRuntimeActual.fetchWithSsrFGuard; - if (!actualGuard) { - throw new Error("expected the real SSRF guard implementation"); - } - let requestSignal: AbortSignal | undefined; - const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - const signal = init?.signal; - if (!(signal instanceof AbortSignal)) { - throw new Error("expected the guarded fetch to pass its deadline signal"); - } - requestSignal = signal; - return new Response( - new ReadableStream({ - start(controller) { - signal.addEventListener("abort", () => controller.error(signal.reason), { - once: true, - }); - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ); - }); - const lookupFn = vi.fn(async () => [ - { address: "93.184.216.34", family: 4 }, - ]) as unknown as LookupFn; - fetchWithSsrFGuardMock.mockImplementationOnce( - async (request: Parameters[0]) => - await actualGuard({ ...request, fetchImpl, lookupFn }), - ); - - const client = new ApiClient({ - baseUrl: "https://qqbot.test", - defaultTimeoutMs: timeoutMs, - }); - - const rejection = expect(client.request("token-1", "GET", "/v2/users/@me")).rejects.toThrow( - `Request timeout [/v2/users/@me]: exceeded ${timeoutMs}ms`, - ); - const guardedTimeoutMs = Math.max(1, timeoutMs); - await vi.advanceTimersByTimeAsync(guardedTimeoutMs); - - await rejection; - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ timeoutMs: guardedTimeoutMs }), - ); - expect(requestSignal?.aborted).toBe(true); - expect(vi.getTimerCount()).toBe(0); - }, - ); -}); diff --git a/extensions/qqbot/src/engine/api/api-client.ts b/extensions/qqbot/src/engine/api/api-client.ts deleted file mode 100644 index aec72cf41c26..000000000000 --- a/extensions/qqbot/src/engine/api/api-client.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Core HTTP client for the QQ Open Platform REST API. - * - * Key improvements over the old `src/api.ts#apiRequest`: - * - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA) - * is injected via the constructor, eliminating module-level globals. - * - Throws structured `ApiError` with httpStatus, bizCode, and path fields. - * - Detects HTML error pages from CDN/gateway and returns user-friendly messages. - * - `redactBodyKeys` replaces the hardcoded `file_data` redaction. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - readProviderTextResponse, - readResponseTextLimited, -} from "openclaw/plugin-sdk/provider-http"; -import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { qqbotApiGuidance, qqbotNetworkGuidance } from "../config/setup-guidance.js"; -import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js"; - -const DEFAULT_BASE_URL = "https://api.sgroup.qq.com"; -const DEFAULT_TIMEOUT_MS = 30_000; -const FILE_UPLOAD_TIMEOUT_MS = 120_000; -const QQBOT_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024; - -function isTimeoutError(err: unknown): boolean { - return err instanceof Error && err.name === "TimeoutError"; -} - -function resolveQqbotApiSsrfPolicy(url: string): SsrFPolicy { - return { - hostnameAllowlist: [new URL(url).hostname], - allowRfc2544BenchmarkRange: true, - }; -} - -interface RequestOptions { - /** Request timeout override in milliseconds. */ - timeoutMs?: number; - /** Body keys to redact in debug logs (e.g. `['file_data']`). */ - redactBodyKeys?: string[]; - /** - * Mark the request as a file-upload call. - * - * Triggers the longer `fileUploadTimeoutMs` (default 120s) instead of the - * standard `defaultTimeoutMs` (default 30s). Prefer this flag over - * inspecting the request path; it keeps the timeout policy independent of - * route naming conventions. - */ - uploadRequest?: boolean; -} - -/** - * Stateful HTTP client for the QQ Open Platform. - * - * Usage: - * ```ts - * const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' }); - * const data = await client.request<{ url: string }>(token, 'GET', '/gateway'); - * ``` - */ -export class ApiClient { - private readonly baseUrl: string; - private readonly defaultTimeoutMs: number; - private readonly fileUploadTimeoutMs: number; - private readonly logger?: EngineLogger; - private readonly resolveUserAgent: () => string; - - constructor(config: ApiClientConfig = {}) { - this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; - this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; - this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS; - this.logger = config.logger; - const ua = config.userAgent ?? "QQBotPlugin/unknown"; - this.resolveUserAgent = typeof ua === "function" ? ua : () => ua; - } - - /** - * Send an authenticated JSON request to the QQ Open Platform. - * - * @param accessToken - Bearer token (`QQBot {token}`). - * @param method - HTTP method. - * @param path - API path (appended to baseUrl). - * @param body - Optional JSON body. - * @param options - Optional request overrides. - * @returns Parsed JSON response. - * @throws {ApiError} On HTTP or parse errors. - */ - async request( - accessToken: string, - method: string, - path: string, - body?: unknown, - options?: RequestOptions, - ): Promise { - const url = `${this.baseUrl}${path}`; - - const headers: Record = { - Authorization: `QQBot ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": this.resolveUserAgent(), - }; - - const isFileUpload = - options?.uploadRequest === true || - // Back-compat: legacy callers that predate the explicit `uploadRequest` - // flag still get the long timeout when hitting file endpoints. New - // code should always pass `uploadRequest: true` explicitly. - path.includes("/files") || - path.includes("/upload_prepare") || - path.includes("/upload_part_finish"); - const timeout = - options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs); - const guardedTimeoutMs = timeout > 0 ? timeout : 1; - - const fetchInit: RequestInit = { - method, - headers, - }; - - if (body) { - fetchInit.body = JSON.stringify(body); - } - - // Debug logging with optional body redaction. - this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`); - if (body && this.logger?.debug) { - const logBody = { ...(body as Record) }; - for (const key of options?.redactBodyKeys ?? ["file_data"]) { - if (typeof logBody[key] === "string") { - logBody[key] = ``; - } - } - this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`); - } - - let guarded: Awaited>; - try { - guarded = await fetchWithSsrFGuard({ - url, - init: fetchInit, - auditContext: "qqbot-api", - policy: resolveQqbotApiSsrfPolicy(url), - timeoutMs: guardedTimeoutMs, - }); - } catch (err) { - if (isTimeoutError(err)) { - this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`); - throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path); - } - this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`); - throw new ApiError( - `Network error [${path}]: ${formatErrorMessage(err)}. ${qqbotNetworkGuidance()}`, - 0, - path, - ); - } - - const res = guarded.response; - try { - // Log response status and trace ID. - const traceId = res.headers.get("x-tps-trace-id") ?? ""; - this.logger?.info?.( - `[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`, - ); - - const readBody = async (limitBytes?: number): Promise => { - try { - return limitBytes === undefined - ? await readProviderTextResponse(res, "QQBot API response") - : await readResponseTextLimited(res, limitBytes); - } catch (err) { - if (isTimeoutError(err)) { - this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`); - throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path); - } - throw new ApiError( - `Failed to read response [${path}]: ${formatErrorMessage(err)}`, - res.status, - path, - ); - } - }; - - const rawBody = res.ok ? await readBody() : await readBody(QQBOT_API_ERROR_BODY_LIMIT_BYTES); - this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`); - - // Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages). - const contentType = res.headers.get("content-type") ?? ""; - const isHtmlResponse = - contentType.includes("text/html") || rawBody.trimStart().startsWith("<"); - - if (!res.ok) { - if (isHtmlResponse) { - const statusHint = - res.status === 502 || res.status === 503 || res.status === 504 - ? "调用发生异常,请稍候重试" - : res.status === 429 - ? "请求过于频繁,已被限流" - : `开放平台返回 HTTP ${res.status}`; - throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path); - } - - // JSON error response. - try { - const error = JSON.parse(rawBody) as { - message?: string; - code?: number; - err_code?: number; - }; - const bizCode = error.code ?? error.err_code; - throw new ApiError( - `API Error [${path}]: ${error.message ?? rawBody}. ${qqbotApiGuidance(res.status, bizCode)}`, - res.status, - path, - bizCode, - error.message, - ); - } catch (parseErr) { - if (parseErr instanceof ApiError) { - throw parseErr; - } - throw new ApiError( - `API Error [${path}] HTTP ${res.status}: ${truncateUtf16Safe(rawBody, 200)}`, - res.status, - path, - ); - } - } - - // Successful response but not JSON (extreme edge case). - if (isHtmlResponse) { - throw new ApiError( - `QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`, - res.status, - path, - ); - } - - try { - return JSON.parse(rawBody) as T; - } catch { - throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path); - } - } finally { - await guarded.release(); - } - } -} diff --git a/extensions/qqbot/src/engine/api/auth-errors.ts b/extensions/qqbot/src/engine/api/auth-errors.ts deleted file mode 100644 index 79e3e192b198..000000000000 --- a/extensions/qqbot/src/engine/api/auth-errors.ts +++ /dev/null @@ -1,6 +0,0 @@ -const QQBOT_TOKEN_EXPIRED_OR_MISSING_CODE = 11244; - -/** Match QQ's HTTP and business-code signals for an invalid access token. */ -export function isQQBotTokenAuthenticationFailure(httpStatus: number, bizCode?: number): boolean { - return httpStatus === 401 || bizCode === QQBOT_TOKEN_EXPIRED_OR_MISSING_CODE; -} diff --git a/extensions/qqbot/src/engine/api/media-chunked.test.ts b/extensions/qqbot/src/engine/api/media-chunked.test.ts deleted file mode 100644 index 04cf9b475a44..000000000000 --- a/extensions/qqbot/src/engine/api/media-chunked.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -// Qqbot tests cover media chunked plugin behavior. -import * as crypto from "node:crypto"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { normalizeSource } from "../messaging/media-source.js"; -import { - ApiError, - MediaFileType, - type UploadMediaResponse, - type UploadPrepareResponse, -} from "../types.js"; -import type { ApiClient } from "./api-client.js"; -import { ChunkedMediaApi, UploadDailyLimitExceededError } from "./media-chunked.js"; -import type { UploadCacheAdapter } from "./media.js"; -import { UPLOAD_PREPARE_FALLBACK_CODE } from "./retry.js"; -import type { TokenManager } from "./token.js"; - -const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchWithSsrFGuardMock, -})); - -// ============ Test doubles ============ - -/** Build a minimal ApiClient stub whose `request` is fully mockable. */ -function mockApiClient(): ApiClient & { request: ReturnType> } { - return { - request: vi.fn(), - } as unknown as ApiClient & { request: ReturnType> }; -} - -/** Minimal TokenManager stub returning a static token. */ -function mockTokenManager(token = "test-token"): TokenManager { - return { - getAccessToken: vi.fn().mockResolvedValue(token), - } as unknown as TokenManager; -} - -/** In-memory upload-cache adapter. */ -function inMemoryCache(): UploadCacheAdapter & { - getSpy: ReturnType; - setSpy: ReturnType; -} { - const store = new Map(); - const getSpy = vi.fn( - (hash: string, scope: string, targetId: string, fileType: number) => - store.get(`${hash}:${scope}:${targetId}:${fileType}`) ?? null, - ); - const setSpy = vi.fn( - (hash: string, scope: string, targetId: string, fileType: number, fileInfo: string) => { - store.set(`${hash}:${scope}:${targetId}:${fileType}`, fileInfo); - }, - ); - return { - computeHash: (data: string | Uint8Array) => crypto.createHash("md5").update(data).digest("hex"), - get: getSpy, - set: setSpy, - getSpy, - setSpy, - }; -} - -/** Build a canned upload_prepare response with `parts` presigned URLs. */ -function makePrepareResponse(uploadId: string, parts: number): UploadPrepareResponse { - return { - upload_id: uploadId, - block_size: 8, - parts: Array.from({ length: parts }, (_, i) => ({ - index: i + 1, - presigned_url: `https://cos.example.com/part-${i + 1}`, - })), - concurrency: 2, - retry_timeout: 60, - }; -} - -/** Fixture: a 20-byte buffer that spans 3 parts at block_size=8. */ -const FIXTURE_BUFFER = Buffer.from("0123456789abcdefghij"); // 20 bytes - -// ============ fetch stub for COS PUT ============ - -let originalFetch: typeof globalThis.fetch; - -function stubFetchOk(): ReturnType { - fetchWithSsrFGuardMock.mockImplementation(async () => ({ - response: new Response("", { - status: 200, - headers: { - ETag: '"etag-value"', - "x-cos-request-id": "req-id", - }, - }), - release: vi.fn(), - })); - return fetchWithSsrFGuardMock; -} - -function cancelTrackedResponse( - text: string, - init: ResponseInit, -): { - response: Response; - wasCanceled: () => boolean; -} { - let canceled = false; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(text)); - }, - cancel() { - canceled = true; - }, - }); - return { - response: new Response(stream, init), - wasCanceled: () => canceled, - }; -} - -// ============ Tests ============ - -describe("media-chunked: UploadDailyLimitExceededError", () => { - it("captures filePath / fileSize / message", () => { - const err = new UploadDailyLimitExceededError("/tmp/x.mp4", 123, "quota exceeded"); - expect(err).toBeInstanceOf(Error); - expect(err.name).toBe("UploadDailyLimitExceededError"); - expect(err.filePath).toBe("/tmp/x.mp4"); - expect(err.fileSize).toBe(123); - expect(err.message).toBe("quota exceeded"); - }); -}); - -describe("media-chunked: ChunkedMediaApi.uploadChunked", () => { - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - vi.useRealTimers(); - globalThis.fetch = originalFetch; - fetchWithSsrFGuardMock.mockReset(); - vi.restoreAllMocks(); - }); - - it("rejects url / base64 sources up-front", async () => { - const client = mockApiClient(); - const tm = mockTokenManager(); - const api = new ChunkedMediaApi(client, tm); - - await expect( - api.uploadChunked({ - scope: "c2c", - targetId: "u1", - fileType: MediaFileType.IMAGE, - source: { kind: "url", url: "https://x" }, - creds: { appId: "a", clientSecret: "s" }, - }), - ).rejects.toThrow(/unsupported source kind 'url'/); - - await expect( - api.uploadChunked({ - scope: "c2c", - targetId: "u1", - fileType: MediaFileType.IMAGE, - source: { kind: "base64", data: "AA==" }, - creds: { appId: "a", clientSecret: "s" }, - }), - ).rejects.toThrow(/unsupported source kind 'base64'/); - - expect(client.request).not.toHaveBeenCalled(); - }); - - it("takes the cache fast path and skips upload_prepare on hit", async () => { - const client = mockApiClient(); - const tm = mockTokenManager(); - const cache = inMemoryCache(); - - // Seed cache with the md5 that uploadChunked will compute. - const md5 = crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex"); - cache.set(md5, "c2c", "u1", MediaFileType.IMAGE, "cached-file-info", "uuid", 999); - - const api = new ChunkedMediaApi(client, tm, { uploadCache: cache }); - - const result = await api.uploadChunked({ - scope: "c2c", - targetId: "u1", - fileType: MediaFileType.IMAGE, - source: { kind: "buffer", buffer: FIXTURE_BUFFER }, - creds: { appId: "a", clientSecret: "s" }, - }); - - expect(result.file_info).toBe("cached-file-info"); - expect(client.request).not.toHaveBeenCalled(); - expect(cache.getSpy).toHaveBeenCalledWith(md5, "c2c", "u1", MediaFileType.IMAGE); - }); - - it("runs prepare → COS PUT → part_finish → complete for a buffer source", async () => { - const client = mockApiClient(); - const tm = mockTokenManager(); - const cache = inMemoryCache(); - const fetchSpy = stubFetchOk(); - - const prepareResp = makePrepareResponse("uid-1", 3); - const completeResp: UploadMediaResponse = { - file_uuid: "uuid-final", - file_info: "final-file-info", - ttl: 3600, - }; - - // First request: upload_prepare; three follow-ups: upload_part_finish ×3 - // plus one complete. Because concurrency=2 the order of part_finish is - // not strictly deterministic, so match on path + payload key. - client.request.mockImplementation( - async (_token: string, _method: string, pathLocal: string, body: unknown) => { - const uploadBody = body as Record; - if (pathLocal.endsWith("/upload_prepare")) { - expect(uploadBody.file_type).toBe(MediaFileType.FILE); - expect(typeof uploadBody.md5).toBe("string"); - expect(typeof uploadBody.sha1).toBe("string"); - expect(typeof uploadBody.md5_10m).toBe("string"); - expect(uploadBody.file_size).toBe(FIXTURE_BUFFER.length); - return prepareResp; - } - if (pathLocal.endsWith("/upload_part_finish")) { - expect(uploadBody.upload_id).toBe("uid-1"); - expect(typeof uploadBody.part_index).toBe("number"); - return {}; - } - if (pathLocal.endsWith("/files")) { - expect(uploadBody.upload_id).toBe("uid-1"); - return completeResp; - } - throw new Error(`unexpected path ${pathLocal}`); - }, - ); - - const api = new ChunkedMediaApi(client, tm, { uploadCache: cache }); - const onProgress = vi.fn(); - - const result = await api.uploadChunked({ - scope: "group", - targetId: "g1", - fileType: MediaFileType.FILE, - source: { kind: "buffer", buffer: FIXTURE_BUFFER, fileName: "blob.bin" }, - creds: { appId: "a", clientSecret: "s" }, - onProgress, - }); - - expect(result).toEqual(completeResp); - - // One prepare + 3 part_finish + 1 complete = 5 client requests. - expect(client.request).toHaveBeenCalledTimes(5); - - // 3 COS PUTs, one per part, each to the presigned URL. - expect(fetchSpy).toHaveBeenCalledTimes(3); - const putUrls = fetchSpy.mock.calls.map((c) => (c[0] as { url: string }).url); - expect(new Set(putUrls)).toEqual( - new Set([ - "https://cos.example.com/part-1", - "https://cos.example.com/part-2", - "https://cos.example.com/part-3", - ]), - ); - - // FILE uploads carry filename metadata in upload_prepare, so the content-only - // cache is bypassed to avoid reusing file_info with a stale name. - expect(cache.getSpy).not.toHaveBeenCalled(); - expect(cache.setSpy).not.toHaveBeenCalled(); - - // Progress callback hit 3 times with monotonically-increasing counts. - expect(onProgress).toHaveBeenCalledTimes(3); - const last = onProgress.mock.calls.at(2)?.[0]; - expect(last.completedParts).toBe(3); - expect(last.totalParts).toBe(3); - expect(last.uploadedBytes).toBe(FIXTURE_BUFFER.length); - expect(last.totalBytes).toBe(FIXTURE_BUFFER.length); - }); - - it("bounds COS PUT error bodies on UTF-16 boundaries without using response.text()", async () => { - vi.useFakeTimers(); - const client = mockApiClient(); - const tm = mockTokenManager(); - const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn() }; - client.request.mockImplementation(async (_token, _method, pathLocal) => { - if (pathLocal.endsWith("/upload_prepare")) { - return makePrepareResponse("uid-bounded", 1); - } - throw new Error(`unexpected path ${pathLocal}`); - }); - - const releases = [vi.fn(async () => {}), vi.fn(async () => {}), vi.fn(async () => {})]; - const safeErrorPrefix = "x".repeat(119); - const safeLogPrefix = `${safeErrorPrefix}🎉${"y".repeat(38)}`; - const trackedResponses = releases.map((release) => { - const tracked = cancelTrackedResponse(`${safeLogPrefix}🎉${"tail".repeat(4096)}`, { - status: 503, - statusText: "Service Unavailable", - headers: { - "content-type": "text/plain", - "x-cos-request-id": "req-bounded", - }, - }); - const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); - return { - response: tracked.response, - wasCanceled: tracked.wasCanceled, - release, - textSpy, - }; - }); - const pendingResponses = [...trackedResponses]; - - fetchWithSsrFGuardMock.mockImplementation(async () => { - const next = pendingResponses.shift(); - if (!next) { - throw new Error("unexpected extra COS PUT attempt"); - } - return { - response: next.response, - release: next.release, - }; - }); - - const api = new ChunkedMediaApi(client, tm, { logger }); - const upload = api - .uploadChunked({ - scope: "group", - targetId: "g1", - fileType: MediaFileType.FILE, - source: { kind: "buffer", buffer: Buffer.from("01234567"), fileName: "blob.bin" }, - creds: { appId: "a", clientSecret: "s" }, - }) - .catch((error: unknown) => error); - await vi.runAllTimersAsync(); - const error = await upload; - - expect((error as Error).message).toBe( - `COS PUT failed: 503 Service Unavailable - ${safeErrorPrefix}`, - ); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(3); - for (const tracked of trackedResponses) { - expect(tracked.wasCanceled()).toBe(true); - expect(tracked.textSpy).not.toHaveBeenCalled(); - expect(tracked.release).toHaveBeenCalledTimes(1); - } - expect(String(logger.error.mock.calls[0]?.[0]).split("body=")[1]).toBe(safeLogPrefix); - expect(JSON.stringify(logger.error.mock.calls)).not.toContain("tail"); - }); - - it("maps UPLOAD_PREPARE_FALLBACK_CODE to UploadDailyLimitExceededError", async () => { - const client = mockApiClient(); - const tm = mockTokenManager(); - client.request.mockRejectedValueOnce( - new ApiError( - "daily limit exceeded", - 200, - "/v2/users/u1/upload_prepare", - UPLOAD_PREPARE_FALLBACK_CODE, - "quota", - ), - ); - - const api = new ChunkedMediaApi(client, tm); - await expect( - api.uploadChunked({ - scope: "c2c", - targetId: "u1", - fileType: MediaFileType.FILE, - source: { kind: "buffer", buffer: FIXTURE_BUFFER, fileName: "big.bin" }, - creds: { appId: "a", clientSecret: "s" }, - }), - ).rejects.toBeInstanceOf(UploadDailyLimitExceededError); - }); - - it("streams hashes from a localPath source", async () => { - const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chunked-")); - const filePath = path.join(tmp, "fixture.bin"); - await fs.promises.writeFile(filePath, FIXTURE_BUFFER); - try { - const client = mockApiClient(); - const tm = mockTokenManager(); - stubFetchOk(); - - client.request.mockImplementation(async (_t, _m, p) => { - if (p.endsWith("/upload_prepare")) { - return makePrepareResponse("uid-2", 3); - } - if (p.endsWith("/upload_part_finish")) { - return {}; - } - if (p.endsWith("/files")) { - return { file_uuid: "u", file_info: "fi", ttl: 10 } satisfies UploadMediaResponse; - } - throw new Error(`unexpected ${p}`); - }); - - const api = new ChunkedMediaApi(client, tm); - const result = await api.uploadChunked({ - scope: "c2c", - targetId: "u1", - fileType: MediaFileType.VIDEO, - source: { kind: "localPath", path: filePath, size: FIXTURE_BUFFER.length }, - creds: { appId: "a", clientSecret: "s" }, - }); - - expect(result.file_info).toBe("fi"); - - // Verify prepare received the md5 of the on-disk bytes. - const prepareCall = client.request.mock.calls.find((c) => c[2].endsWith("/upload_prepare"))!; - const prepareBody = prepareCall[3] as { md5: string; file_name: string }; - expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex")); - expect(prepareBody.file_name).toBe("fixture.bin"); - } finally { - await fs.promises.rm(tmp, { recursive: true, force: true }); - } - }); - - it("uses the verified localPath handle if the path is replaced before chunked upload", async () => { - const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chunked-verified-")); - const filePath = path.join(tmp, "fixture.bin"); - await fs.promises.writeFile(filePath, FIXTURE_BUFFER); - const source = await normalizeSource({ localPath: filePath }, { maxSize: 1_000_000 }); - await fs.promises.rm(filePath); - await fs.promises.writeFile(filePath, Buffer.from("replacement bytes")); - try { - const client = mockApiClient(); - const tm = mockTokenManager(); - stubFetchOk(); - - client.request.mockImplementation(async (_t, _m, p) => { - if (p.endsWith("/upload_prepare")) { - return makePrepareResponse("uid-verified", 3); - } - if (p.endsWith("/upload_part_finish")) { - return {}; - } - if (p.endsWith("/files")) { - return { file_uuid: "u", file_info: "fi", ttl: 10 } satisfies UploadMediaResponse; - } - throw new Error(`unexpected ${p}`); - }); - - const api = new ChunkedMediaApi(client, tm); - await api.uploadChunked({ - scope: "c2c", - targetId: "u1", - fileType: MediaFileType.VIDEO, - source, - creds: { appId: "a", clientSecret: "s" }, - }); - - const prepareCall = client.request.mock.calls.find((c) => c[2].endsWith("/upload_prepare"))!; - const prepareBody = prepareCall[3] as { md5: string }; - expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex")); - } finally { - if (source.kind === "localPath") { - await source.opened?.close().catch(() => undefined); - } - await fs.promises.rm(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/extensions/qqbot/src/engine/api/media-chunked.ts b/extensions/qqbot/src/engine/api/media-chunked.ts deleted file mode 100644 index 0794a89524a0..000000000000 --- a/extensions/qqbot/src/engine/api/media-chunked.ts +++ /dev/null @@ -1,616 +0,0 @@ -/** - * Chunked media upload for the QQ Open Platform. - * - * ## Flow (mirrors the upload sequence diagram) - * - * 1. `upload_prepare` — submit file metadata + (md5 / sha1 / md5_10m) hashes, - * receive `{ upload_id, block_size, parts[], concurrency?, retry_timeout? }`. - * 2. For every part (parallelized under a bounded concurrency): - * a. Read the part bytes (stream from disk or slice in-memory buffer). - * b. PUT the bytes to the pre-signed COS URL. - * c. POST `upload_part_finish { upload_id, part_index, block_size, md5 }`, - * retrying under {@link PART_FINISH_RETRY_POLICY} + the persistent - * retry loop for {@link PART_FINISH_RETRYABLE_CODES}. - * 3. POST `complete_upload { upload_id }` — returns `{ file_uuid, file_info, - * ttl }` identical to the one-shot path. - * 4. If `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE} - * (`40093002` — daily upload quota exceeded), throw - * {@link UploadDailyLimitExceededError} so the upper layer can surface a - * user-facing message. The dispatcher is responsible for the fallback - * (there is no server path that will accept the file at this point). - * - * ## Why a class - * - * Mirrors {@link MediaApi}: injects {@link ApiClient}, {@link TokenManager}, - * the upload cache adapter, an optional filename sanitizer, and a logger. - * Keeping the client singleton plumbing consistent means only one place - * manages UA / baseUrl / file-upload timeouts. - * - * ## Upload cache integration - * - * Chunked uploads participate in the same `file_info` cache as - * {@link MediaApi.uploadMedia}. The cache key is derived from the full-file - * md5 (already computed for `upload_prepare`) so repeat sends of the same - * large file hit the cache before we even talk to `upload_prepare`. - */ - -import * as crypto from "node:crypto"; -import type { FileHandle } from "node:fs/promises"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; -import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import pMap from "p-map"; -import type { MediaSource, OpenedLocalFile } from "../messaging/media-source.js"; -import { openLocalFile } from "../messaging/media-source.js"; -import { - ApiError, - MediaFileType, - type ChatScope, - type EngineLogger, - type UploadMediaResponse, - type UploadPart, - type UploadPrepareHashes, - type UploadPrepareResponse, -} from "../types.js"; -import { formatFileSize } from "../utils/file-utils.js"; -import type { ApiClient } from "./api-client.js"; -import type { SanitizeFileNameFn, UploadCacheAdapter } from "./media.js"; -import { - buildPartFinishPersistentPolicy, - COMPLETE_UPLOAD_RETRY_POLICY, - PART_FINISH_RETRY_POLICY, - UPLOAD_PREPARE_FALLBACK_CODE, - withRetry, -} from "./retry.js"; -import { uploadCompletePath, uploadPartFinishPath, uploadPreparePath } from "./routes.js"; -import type { TokenManager } from "./token.js"; - -// ============ Public types ============ - -/** - * Raised when `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE} - * (40093002). Carries enough context for the outbound layer to render a - * user-facing fallback message (file name, size, and the originating - * local path when available). - */ -export class UploadDailyLimitExceededError extends Error { - override readonly name = "UploadDailyLimitExceededError"; - - constructor( - /** Original local file path, or `""` when uploading an in-memory buffer. */ - public readonly filePath: string, - /** File size in bytes. */ - public readonly fileSize: number, - /** Original error message from the server. */ - originalMessage: string, - ) { - super(originalMessage); - } -} - -/** Chunked-upload progress callback payload. */ -interface ChunkedUploadProgress { - completedParts: number; - totalParts: number; - uploadedBytes: number; - totalBytes: number; -} - -/** Per-call options for {@link ChunkedMediaApi.uploadChunked}. */ -interface UploadChunkedOptions { - scope: ChatScope; - targetId: string; - fileType: MediaFileType; - source: MediaSource; - creds: { appId: string; clientSecret: string }; - /** - * Optional filename override. When omitted, derived from `source.path` - * (localPath) / `source.fileName` (buffer) / `"file"` (fallback). - */ - fileName?: string; - /** Progress callback invoked after every successful part. */ - onProgress?: (progress: ChunkedUploadProgress) => void; - /** Log prefix — defaults to `"[qqbot:chunked-upload]"`. */ - logPrefix?: string; -} - -/** Configuration for the {@link ChunkedMediaApi} constructor. */ -interface ChunkedMediaApiConfig { - logger?: EngineLogger; - /** Upload cache adapter (optional; omit to disable caching). */ - uploadCache?: UploadCacheAdapter; - /** File name sanitizer — defaults to identity. */ - sanitizeFileName?: SanitizeFileNameFn; -} - -// ============ Tuning constants ============ - -/** Default concurrency when the server does not specify one. */ -const DEFAULT_CONCURRENT_PARTS = 1; - -/** Hard cap on per-upload concurrency regardless of what the server returns. */ -const MAX_CONCURRENT_PARTS = 10; - -/** - * Upper bound on the persistent-retry window for `upload_part_finish`. - * - * The server may suggest `retry_timeout` via `upload_prepare` — we honor - * it but clamp to 10 minutes so a runaway server can't hold the caller - * hostage. - */ -const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000; - -/** Per-part PUT timeout (5 minutes). Matches the low-bandwidth tolerance. */ -const PART_UPLOAD_TIMEOUT_MS = 300_000; -const PART_UPLOAD_ERROR_BODY_LIMIT_BYTES = 8 * 1024; - -/** - * Boundary used by `md5_10m` — first 10,002,432 bytes. - * - * Files smaller than this return the whole-file md5 for `md5_10m` (per the - * server contract). - */ -const MD5_10M_SIZE = 10_002_432; - -// ============ Class ============ - -/** - * Chunked upload module. Stateless across calls — see - * {@link ChunkedMediaApi.uploadChunked} for the main entry. - */ -export class ChunkedMediaApi { - private readonly client: ApiClient; - private readonly tokenManager: TokenManager; - private readonly logger?: EngineLogger; - private readonly cache?: UploadCacheAdapter; - private readonly sanitize: SanitizeFileNameFn; - - constructor(client: ApiClient, tokenManager: TokenManager, config: ChunkedMediaApiConfig = {}) { - this.client = client; - this.tokenManager = tokenManager; - this.logger = config.logger; - this.cache = config.uploadCache; - this.sanitize = config.sanitizeFileName ?? ((n) => n); - } - - /** - * Upload a {@link MediaSource} via the chunked endpoint. Only `localPath` - * and `buffer` sources are accepted — `url` / `base64` must fall through - * to {@link MediaApi.uploadMedia}. - * - * @throws {UploadDailyLimitExceededError} when `upload_prepare` returns - * {@link UPLOAD_PREPARE_FALLBACK_CODE}. - */ - async uploadChunked(opts: UploadChunkedOptions): Promise { - const prefix = opts.logPrefix ?? "[qqbot:chunked-upload]"; - - // 1. Resolve input: size + verified local file descriptor (or buffer). - const input = await resolveSource(opts.source, opts.fileName); - - try { - const displayName = input.fileName; - const fileSize = input.size; - const pathLabel = input.kind === "localPath" ? input.path : ""; - - this.logger?.info?.( - `${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`, - ); - - // 2. Compute md5 / sha1 / md5_10m. Identical for buffer and localPath, - // but the localPath descriptor streams so it never has to materialize the - // whole file twice or reopen a path after validation. - const hashes = await computeHashes(input); - this.logger?.debug?.( - `${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`, - ); - - // 3. Upload-cache fast path: the md5 hash is already a strong content - // identifier, so we can short-circuit before even calling upload_prepare. - const canUseUploadCache = opts.fileType !== MediaFileType.FILE; - if (this.cache && canUseUploadCache) { - const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType); - if (cached) { - this.logger?.info?.( - `${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`, - ); - return { file_uuid: "", file_info: cached, ttl: 0 }; - } - } - - // 4. upload_prepare. - const fileNameForPrepare = - opts.fileType === MediaFileType.FILE ? this.sanitize(displayName) : displayName; - const prepareResp = await this.callUploadPrepare( - opts, - fileNameForPrepare, - fileSize, - hashes, - pathLabel, - ); - - const { upload_id, parts } = prepareResp; - const block_size = prepareResp.block_size; - const maxConcurrent = Math.min( - prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS, - MAX_CONCURRENT_PARTS, - ); - const retryTimeoutMs = prepareResp.retry_timeout - ? Math.min(prepareResp.retry_timeout * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS) - : undefined; - - this.logger?.info?.( - `${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`, - ); - - // 5. Upload every part. Concurrency is per-upload, not global. - let completedParts = 0; - let uploadedBytes = 0; - - const uploadPart = async (part: UploadPart): Promise => { - const partIndex = part.index; // 1-based. - const offset = (partIndex - 1) * block_size; - const length = Math.min(block_size, fileSize - offset); - - const partBuffer = await readPart(input, offset, length); - const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex"); - - this.logger?.debug?.( - `${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`, - ); - - // 5a. PUT to pre-signed COS URL. - await putToPresignedUrl( - part.presigned_url, - partBuffer, - partIndex, - parts.length, - this.logger, - prefix, - ); - - // 5b. upload_part_finish — fetch a fresh token each time to defend - // against long uploads exceeding the token TTL. - await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs); - - completedParts++; - uploadedBytes += length; - this.logger?.info?.( - `${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`, - ); - - opts.onProgress?.({ - completedParts, - totalParts: parts.length, - uploadedBytes, - totalBytes: fileSize, - }); - }; - - await pMap(parts, uploadPart, { - concurrency: maxConcurrent, - stopOnError: true, - }); - - this.logger?.info?.(`${prefix} all parts uploaded, completing...`); - - // 6. complete_upload. - const result = await this.callCompleteUpload(opts, upload_id); - this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`); - - // 7. Populate the shared upload cache so subsequent sends skip re-uploading. - if (this.cache && canUseUploadCache && result.file_info && result.ttl > 0) { - this.cache.set( - hashes.md5, - opts.scope, - opts.targetId, - opts.fileType, - result.file_info, - result.file_uuid, - result.ttl, - ); - } - - return result; - } finally { - if (input.kind === "localPath" && input.closeWhenDone) { - await input.opened.close().catch(() => undefined); - } - } - } - - // -------- Internal call wrappers -------- - - private async callUploadPrepare( - opts: UploadChunkedOptions, - fileName: string, - fileSize: number, - hashes: UploadPrepareHashes, - pathLabel: string, - ): Promise { - const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); - const path = uploadPreparePath(opts.scope, opts.targetId); - try { - return await this.client.request( - token, - "POST", - path, - { - file_type: opts.fileType, - file_name: fileName, - file_size: fileSize, - md5: hashes.md5, - sha1: hashes.sha1, - md5_10m: hashes.md5_10m, - }, - { uploadRequest: true }, - ); - } catch (err) { - if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) { - throw new UploadDailyLimitExceededError(pathLabel, fileSize, err.message); - } - throw err; - } - } - - private async callUploadPartFinish( - opts: UploadChunkedOptions, - uploadId: string, - partIndex: number, - blockSize: number, - md5: string, - retryTimeoutMs?: number, - ): Promise { - const persistentPolicy = buildPartFinishPersistentPolicy(retryTimeoutMs); - const path = uploadPartFinishPath(opts.scope, opts.targetId); - await withRetry( - async () => { - // Refresh the token on every attempt — the token may be expired by - // the time we reach the tail of a long upload. - const token = await this.tokenManager.getAccessToken( - opts.creds.appId, - opts.creds.clientSecret, - ); - return this.client.request( - token, - "POST", - path, - { - upload_id: uploadId, - part_index: partIndex, - block_size: blockSize, - md5, - }, - { uploadRequest: true }, - ); - }, - PART_FINISH_RETRY_POLICY, - persistentPolicy, - this.logger, - ); - } - - private async callCompleteUpload( - opts: UploadChunkedOptions, - uploadId: string, - ): Promise { - const path = uploadCompletePath(opts.scope, opts.targetId); - return withRetry( - async () => { - const token = await this.tokenManager.getAccessToken( - opts.creds.appId, - opts.creds.clientSecret, - ); - return this.client.request( - token, - "POST", - path, - { upload_id: uploadId }, - { uploadRequest: true }, - ); - }, - COMPLETE_UPLOAD_RETRY_POLICY, - undefined, - this.logger, - ); - } -} - -// ============ Source resolution ============ - -/** - * Normalized chunked-upload input: everything the uploader needs to read - * the bytes plus the metadata required by `upload_prepare`. - */ -type ChunkedInput = - | { - kind: "localPath"; - path: string; - size: number; - fileName: string; - opened: OpenedLocalFile; - closeWhenDone: boolean; - } - | { kind: "buffer"; buffer: Buffer; size: number; fileName: string }; - -async function resolveSource( - source: MediaSource, - fileNameOverride?: string, -): Promise { - if (source.kind === "localPath") { - const inferredName = source.path.split(/[/\\]/).pop() || "file"; - const opened = - source.opened ?? (await openLocalFile(source.path, { maxSize: Number.MAX_SAFE_INTEGER })); - return { - kind: "localPath", - path: source.path, - size: opened.size, - fileName: fileNameOverride ?? inferredName, - opened, - closeWhenDone: source.opened === undefined, - }; - } - if (source.kind === "buffer") { - return { - kind: "buffer", - buffer: source.buffer, - size: source.buffer.length, - fileName: fileNameOverride ?? source.fileName ?? "file", - }; - } - throw new Error( - `ChunkedMediaApi: unsupported source kind '${source.kind}'. ` + - "Chunked upload only supports 'localPath' and 'buffer'; route 'url'/'base64' through the one-shot uploader.", - ); -} - -async function readPart(input: ChunkedInput, offset: number, length: number): Promise { - if (input.kind === "buffer") { - return input.buffer.subarray(offset, offset + length); - } - const buf = Buffer.alloc(length); - const { bytesRead } = await input.opened.handle.read(buf, 0, length, offset); - return bytesRead < length ? buf.subarray(0, bytesRead) : buf; -} - -// ============ Hash computation ============ - -/** - * Stream the source once to compute md5 + sha1 + md5_10m. - * - * For buffer inputs the three hashes are computed in a single pass over - * the existing memory. For localPath inputs the verified descriptor drives - * the hashers so memory use stays constant. - */ -async function computeHashes(input: ChunkedInput): Promise { - if (input.kind === "buffer") { - const md5 = crypto.createHash("md5").update(input.buffer).digest("hex"); - const sha1 = crypto.createHash("sha1").update(input.buffer).digest("hex"); - const md5_10m = - input.size > MD5_10M_SIZE - ? crypto.createHash("md5").update(input.buffer.subarray(0, MD5_10M_SIZE)).digest("hex") - : md5; - return { md5, sha1, md5_10m }; - } - - return new Promise((resolve, reject) => { - const md5 = crypto.createHash("md5"); - const sha1 = crypto.createHash("sha1"); - const md5_10m = crypto.createHash("md5"); - let consumed = 0; - const needsMd5_10m = input.size > MD5_10M_SIZE; - - const stream = createReadStreamFromHandle(input.opened.handle); - stream.on("data", (chunk: Buffer | string) => { - const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - md5.update(buf); - sha1.update(buf); - if (needsMd5_10m) { - const remaining = MD5_10M_SIZE - consumed; - if (remaining > 0) { - md5_10m.update(remaining >= buf.length ? buf : buf.subarray(0, remaining)); - } - } - consumed += buf.length; - }); - stream.on("end", () => { - const md5Hex = md5.digest("hex"); - const sha1Hex = sha1.digest("hex"); - resolve({ - md5: md5Hex, - sha1: sha1Hex, - md5_10m: needsMd5_10m ? md5_10m.digest("hex") : md5Hex, - }); - }); - stream.on("error", reject); - }); -} - -function createReadStreamFromHandle(handle: FileHandle): NodeJS.ReadableStream { - return handle.createReadStream({ autoClose: false, start: 0 }); -} - -// ============ COS PUT ============ - -/** Per-part retry budget for the COS PUT call (exponential backoff). */ -const PART_UPLOAD_MAX_RETRIES = 2; - -async function putToPresignedUrl( - presignedUrl: string, - data: Buffer, - partIndex: number, - totalParts: number, - logger: EngineLogger | undefined, - prefix: string, -): Promise { - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT_MS); - - try { - // Convert to a standard ArrayBuffer before wrapping in Blob so type - // definitions (incl. bun-types) accept the argument. - const ab = data.buffer.slice( - data.byteOffset, - data.byteOffset + data.byteLength, - ) as ArrayBuffer; - - const startTime = Date.now(); - const { response, release } = await fetchWithSsrFGuard({ - url: presignedUrl, - auditContext: "qqbot-media-part-upload", - init: { - method: "PUT", - body: new Blob([ab]), - headers: { "Content-Length": String(data.length) }, - }, - signal: controller.signal, - }); - try { - const elapsed = Date.now() - startTime; - const requestId = response.headers.get("x-cos-request-id") ?? "-"; - const etag = response.headers.get("ETag") ?? "-"; - - if (!response.ok) { - const body = await readResponseTextLimited( - response, - PART_UPLOAD_ERROR_BODY_LIMIT_BYTES, - ).catch(() => ""); - logger?.error?.( - `${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${truncateUtf16Safe(body, 160)}`, - ); - throw new Error( - `COS PUT failed: ${response.status} ${response.statusText} - ${truncateUtf16Safe(body, 120)}`, - ); - } - - logger?.debug?.( - `${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`, - ); - return; - } finally { - await release(); - } - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - if (lastError.name === "AbortError") { - lastError = new Error( - `Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT_MS}ms`, - ); - } - if (attempt < PART_UPLOAD_MAX_RETRIES) { - const delay = 1000 * 2 ** attempt; - (logger?.warn ?? logger?.error)?.( - `${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${truncateUtf16Safe(lastError.message, 120)}), retrying in ${delay}ms`, - ); - await sleep(delay); - } - } finally { - clearTimeout(timeoutId); - } - } - - throw lastError ?? new Error(`Part ${partIndex}/${totalParts} upload failed`); -} diff --git a/extensions/qqbot/src/engine/api/media.test.ts b/extensions/qqbot/src/engine/api/media.test.ts deleted file mode 100644 index 0db5351e60a4..000000000000 --- a/extensions/qqbot/src/engine/api/media.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -// Qqbot tests cover media plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MediaFileType, type UploadMediaResponse } from "../types.js"; -import { MAX_UPLOAD_SIZE } from "../utils/file-utils.js"; -import { ApiClient } from "./api-client.js"; -import { MediaApi } from "./media.js"; -import { TokenManager } from "./token.js"; - -const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); -const readResponseWithLimitMock = vi.hoisted(() => vi.fn()); - -vi.mock("openclaw/plugin-sdk/response-limit-runtime", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - readResponseWithLimit: readResponseWithLimitMock, - }; -}); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchWithSsrFGuard: fetchWithSsrFGuardMock, - }; -}); - -const UPLOAD_RESPONSE: UploadMediaResponse = { - file_uuid: "uuid-1", - file_info: "file-info-1", - ttl: 3600, -}; - -const MEDIA_BYTES = Buffer.from("downloaded-media"); -const MEDIA_BASE64 = MEDIA_BYTES.toString("base64"); - -function mockGuardedResponse( - body: BodyInit = MEDIA_BYTES, - init?: ResponseInit, -): { - response: Response; - release: ReturnType; -} { - const release = vi.fn(async () => {}); - const response = new Response(body, init); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response, - release, - }); - return { response, release }; -} - -function mockApiClient(): ApiClient { - const client = new ApiClient(); - vi.spyOn(client, "request").mockResolvedValue(UPLOAD_RESPONSE); - return client; -} - -function mockTokenManager(): TokenManager { - const tokenManager = new TokenManager(); - vi.spyOn(tokenManager, "getAccessToken").mockResolvedValue("token-1"); - return tokenManager; -} - -function expectGuardedDownload(url: string): void { - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ - url, - maxRedirects: 0, - signal: expect.any(AbortSignal), - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalledWith( - expect.objectContaining({ timeoutMs: expect.any(Number) }), - ); - const signal = fetchWithSsrFGuardMock.mock.calls.at(-1)?.[0]?.signal; - expect(signal).toBeInstanceOf(AbortSignal); -} - -describe("MediaApi.uploadMedia direct URL uploads", () => { - beforeEach(() => { - fetchWithSsrFGuardMock.mockReset(); - readResponseWithLimitMock.mockReset(); - readResponseWithLimitMock.mockResolvedValue(MEDIA_BYTES); - mockGuardedResponse(); - }); - - it.each([ - { fileType: MediaFileType.IMAGE, url: "https://cdn.example.com/assets/photo.png" }, - { fileType: MediaFileType.VIDEO, url: "http://cdn.example.com/assets/video.mp4" }, - { fileType: MediaFileType.FILE, url: "http://cdn.example.com/assets/report.pdf" }, - ])( - "downloads public HTTP(S) $fileType URLs through the pinned SSRF guard", - async ({ fileType, url }) => { - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - const result = await api.uploadMedia( - "c2c", - "user-openid", - fileType, - { appId: "app-id", clientSecret: "client-secret" }, - { url }, - ); - - expect(result).toBe(UPLOAD_RESPONSE); - expectGuardedDownload(url); - expect(readResponseWithLimitMock).toHaveBeenCalledWith( - expect.any(Response), - MAX_UPLOAD_SIZE, - { chunkTimeoutMs: 10_000 }, - ); - expect(tokenManager["getAccessToken"]).toHaveBeenCalledWith("app-id", "client-secret"); - expect(client["request"]).toHaveBeenCalledWith( - "token-1", - "POST", - expect.any(String), - { - file_type: fileType, - srv_send_msg: false, - file_data: MEDIA_BASE64, - }, - { - redactBodyKeys: ["file_data"], - uploadRequest: true, - }, - ); - }, - ); - - it("releases the pinned SSRF dispatcher after downloading media", async () => { - fetchWithSsrFGuardMock.mockReset(); - const { release } = mockGuardedResponse(); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/assets/photo.png" }, - ); - - expect(release).toHaveBeenCalledTimes(1); - }); - - it("bounds stalled guarded fetch setup before reading URL bodies", async () => { - vi.useFakeTimers(); - try { - fetchWithSsrFGuardMock.mockReset(); - fetchWithSsrFGuardMock.mockImplementationOnce(() => new Promise(() => {})); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - const uploadPromise = api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://slow-dns.example.com/assets/photo.png" }, - ); - const rejection = expect(uploadPromise).rejects.toThrow( - "Direct-upload media URL fetch timed out", - ); - - await vi.advanceTimersByTimeAsync(30_000); - await rejection; - expect(readResponseWithLimitMock).not.toHaveBeenCalled(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it("rejects URL bodies that keep trickling under the idle timeout", async () => { - vi.useFakeTimers(); - try { - fetchWithSsrFGuardMock.mockReset(); - const { release } = mockGuardedResponse(); - readResponseWithLimitMock.mockReset(); - readResponseWithLimitMock.mockImplementationOnce(() => new Promise(() => {})); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - const uploadPromise = api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/assets/slow.bin" }, - ); - - for (let i = 0; i < 5 && readResponseWithLimitMock.mock.calls.length === 0; i += 1) { - await Promise.resolve(); - } - expect(readResponseWithLimitMock).toHaveBeenCalledOnce(); - - const rejection = expect(uploadPromise).rejects.toThrow( - "Direct-upload media URL body timed out", - ); - await vi.advanceTimersByTimeAsync(8 * 60_000); - await rejection; - expect(release).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it("dedupes downloaded URL media through the base64 upload cache", async () => { - const cache = { - computeHash: vi.fn(() => "hash-1"), - get: vi.fn(() => "cached-file-info"), - set: vi.fn(), - }; - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager, { uploadCache: cache }); - - const result = await api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/assets/photo.png" }, - ); - - expect(result).toEqual({ file_uuid: "", file_info: "cached-file-info", ttl: 0 }); - expect(cache.computeHash).toHaveBeenCalledWith(MEDIA_BASE64); - expect(cache.get).toHaveBeenCalledWith("hash-1", "c2c", "user-openid", MediaFileType.IMAGE); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }); - - it("does not reuse cached FILE uploads when the requested filename differs", async () => { - const cache = { - computeHash: vi.fn(() => "hash-1"), - get: vi.fn(() => "cached-file-info"), - set: vi.fn(), - }; - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager, { - uploadCache: cache, - sanitizeFileName: (name) => `safe-${name}`, - }); - - await api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.FILE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/report.pdf", fileName: "report.pdf" }, - ); - - expect(cache.computeHash).not.toHaveBeenCalled(); - expect(cache.get).not.toHaveBeenCalled(); - expect(cache.set).not.toHaveBeenCalled(); - expect(client["request"]).toHaveBeenCalledWith( - "token-1", - "POST", - expect.any(String), - expect.objectContaining({ - file_data: MEDIA_BASE64, - file_name: "safe-report.pdf", - }), - expect.any(Object), - ); - }); - - it("rejects invalid direct-upload URLs before downloading media or calling the QQ API", async () => { - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await expect( - api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "not a url" }, - ), - ).rejects.toThrow("Direct-upload media URL must be a valid URL"); - - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }); - - it("rejects non-HTTP direct-upload URLs before downloading media or calling the QQ API", async () => { - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await expect( - api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "ftp://media.qq.com/assets/photo.png" }, - ), - ).rejects.toThrow("Direct-upload media URL must use HTTP or HTTPS"); - - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }); - - it.each(["127.0.0.1", "169.254.169.254", "10.0.0.1", "192.168.1.1"])( - "does not upload direct URLs rejected by the SSRF guard: %s", - async (host) => { - fetchWithSsrFGuardMock.mockReset(); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await expect( - api.uploadMedia( - "group", - "group-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: `https://${host}/latest/meta-data/` }, - ), - ).rejects.toThrow("Blocked hostname"); - - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }, - ); - - it("does not forward URLs when the guarded download fails", async () => { - fetchWithSsrFGuardMock.mockReset(); - fetchWithSsrFGuardMock.mockRejectedValueOnce( - new Error("Blocked: resolves to private/internal/special-use IP address"), - ); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await expect( - api.uploadMedia( - "group", - "group-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://attacker.example/latest/meta-data/" }, - ), - ).rejects.toThrow("resolves to private"); - - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }); - - it("rejects literal RFC 2544 special-use URL hosts through the guarded download", async () => { - fetchWithSsrFGuardMock.mockReset(); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await expect( - api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://198.18.0.42/assets/photo.png" }, - ), - ).rejects.toThrow("Blocked hostname"); - - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }); - - it("keeps public literal IP URLs on the default SSRF policy", async () => { - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "http://93.184.216.34/assets/photo.png" }, - ); - - expectGuardedDownload("http://93.184.216.34/assets/photo.png"); - }); - - it("does not pass URL or fake-IP DNS policy to the QQ upload body", async () => { - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/assets/photo.png" }, - ); - - expectGuardedDownload("https://cdn.example.com/assets/photo.png"); - expect(client["request"]).toHaveBeenCalledWith( - "token-1", - "POST", - expect.any(String), - expect.objectContaining({ - file_data: MEDIA_BASE64, - }), - expect.any(Object), - ); - expect(client["request"]).not.toHaveBeenCalledWith( - expect.any(String), - expect.any(String), - expect.any(String), - expect.objectContaining({ url: expect.any(String) }), - expect.any(Object), - ); - }); - - it("rejects HTTP errors from guarded direct-upload downloads before calling the QQ API", async () => { - fetchWithSsrFGuardMock.mockReset(); - const { response, release } = mockGuardedResponse("not found", { status: 404 }); - const cancelSpy = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined); - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - - await expect( - api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/missing.png" }, - ), - ).rejects.toThrow("Direct-upload media URL returned HTTP 404"); - - expect(cancelSpy).toHaveBeenCalledOnce(); - expect(release).toHaveBeenCalledOnce(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - }); - - it("rejects promptly when a capture clone keeps body cancellation pending", async () => { - fetchWithSsrFGuardMock.mockReset(); - const response = new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode("server error")); - }, - }), - { status: 500 }, - ); - const captureClone = response.clone(); - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release }); - - const body = response.body!; - const originalCancel = body.cancel.bind(body); - let cancellation: Promise | undefined; - let cancellationSettled = false; - const cancellationStarted = new Promise((resolve) => { - vi.spyOn(body, "cancel").mockImplementation((reason) => { - cancellation = originalCancel(reason).finally(() => { - cancellationSettled = true; - }); - resolve(); - return cancellation; - }); - }); - - const client = mockApiClient(); - const tokenManager = mockTokenManager(); - const api = new MediaApi(client, tokenManager); - const upload = api.uploadMedia( - "c2c", - "user-openid", - MediaFileType.IMAGE, - { appId: "app-id", clientSecret: "client-secret" }, - { url: "https://cdn.example.com/server-error.png" }, - ); - const cancellationPending = Symbol("capture cancellation pending"); - - try { - await cancellationStarted; - expect(cancellationSettled).toBe(false); - - const result = await Promise.race([ - upload.then( - () => undefined, - (error: unknown) => error, - ), - new Promise((resolve) => { - setImmediate(() => resolve(cancellationPending)); - }), - ]); - - expect(result).not.toBe(cancellationPending); - expect(result).toMatchObject({ - message: "Direct-upload media URL returned HTTP 500", - }); - expect(release).toHaveBeenCalledOnce(); - expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled(); - expect(client["request"]).not.toHaveBeenCalled(); - } finally { - void captureClone.body?.cancel().catch(() => undefined); - await cancellation?.catch(() => undefined); - await upload.catch(() => undefined); - } - }); -}); diff --git a/extensions/qqbot/src/engine/api/media.ts b/extensions/qqbot/src/engine/api/media.ts deleted file mode 100644 index 0f02dabb762d..000000000000 --- a/extensions/qqbot/src/engine/api/media.ts +++ /dev/null @@ -1,346 +0,0 @@ -/** - * Media upload API for the QQ Open Platform (small-file direct upload). - * - * Key improvements: - * - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`. - * - Upload cache integration via composition (passed in constructor). - * - Uses `withRetry` from the shared retry engine. - * - * Chunked upload for files above `LARGE_FILE_THRESHOLD` is tracked by - * {@link ./media-chunked.ts}; this module currently handles only the - * one-shot path. - */ - -import * as fs from "node:fs"; -import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; -import { fetchWithSsrFGuard, isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - MediaFileType, - type ChatScope, - type UploadMediaResponse, - type MessageResponse, - type EngineLogger, -} from "../types.js"; -import { MAX_UPLOAD_SIZE } from "../utils/file-utils.js"; -import { ApiClient } from "./api-client.js"; -import { withRetry, UPLOAD_RETRY_POLICY } from "./retry.js"; -import { mediaUploadPath, messagePath, getNextMsgSeq } from "./routes.js"; -import { TokenManager } from "./token.js"; - -/** Upload cache interface — the caller provides the implementation. */ -export interface UploadCacheAdapter { - computeHash: (data: string) => string; - get: (hash: string, scope: string, targetId: string, fileType: number) => string | null; - set: ( - hash: string, - scope: string, - targetId: string, - fileType: number, - fileInfo: string, - fileUuid: string, - ttl: number, - ) => void; -} - -/** File name sanitizer — injected to avoid importing platform-specific utils. */ -export type SanitizeFileNameFn = (name: string) => string; - -interface MediaApiConfig { - logger?: EngineLogger; - /** Upload cache adapter (optional, omit to disable caching). */ - uploadCache?: UploadCacheAdapter; - /** File name sanitizer. */ - sanitizeFileName?: SanitizeFileNameFn; -} - -const DIRECT_UPLOAD_DOWNLOAD_TIMEOUT_MS = 30_000; -const DIRECT_UPLOAD_READ_IDLE_TIMEOUT_MS = 10_000; -const DIRECT_UPLOAD_BODY_GRACE_TIMEOUT_MS = 30_000; -const DIRECT_UPLOAD_MIN_DOWNLOAD_BYTES_PER_SECOND = 256 * 1024; -const DIRECT_UPLOAD_MAX_BODY_TIMEOUT_MS = 8 * 60_000; - -function assertDirectUploadDownloadHostAllowed(hostname: string): void { - if (isBlockedHostnameOrIp(hostname)) { - throw new Error("Blocked hostname or private/internal/special-use IP address"); - } -} - -async function fetchDirectUploadDownload(url: string) { - const controller = new AbortController(); - const timeoutError = new Error("Direct-upload media URL fetch timed out"); - let timedOut = false; - let timeout: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => { - timedOut = true; - controller.abort(timeoutError); - reject(timeoutError); - }, DIRECT_UPLOAD_DOWNLOAD_TIMEOUT_MS); - unrefTimer(timeout); - }); - const guardedFetch = fetchWithSsrFGuard({ - url, - maxRedirects: 0, - signal: controller.signal, - }); - void guardedFetch.then( - (result) => { - if (timedOut) { - void result.release().catch(() => undefined); - } - }, - () => undefined, - ); - try { - return await Promise.race([guardedFetch, timeoutPromise]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} - -function unrefTimer(timeout: ReturnType): void { - if (typeof timeout === "object" && "unref" in timeout) { - (timeout as { unref: () => void }).unref(); - } -} - -function resolveDirectUploadBodyTimeoutMs(maxBytes: number): number { - const transferTimeoutMs = Math.ceil( - (maxBytes / DIRECT_UPLOAD_MIN_DOWNLOAD_BYTES_PER_SECOND) * 1000, - ); - return Math.min( - DIRECT_UPLOAD_BODY_GRACE_TIMEOUT_MS + transferTimeoutMs, - DIRECT_UPLOAD_MAX_BODY_TIMEOUT_MS, - ); -} - -async function readDirectUploadResponse(response: Response, maxBytes: number): Promise { - const timeoutMs = resolveDirectUploadBodyTimeoutMs(maxBytes); - const timeoutError = new Error(`Direct-upload media URL body timed out after ${timeoutMs}ms`); - let timeout: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => { - void response.body?.cancel(timeoutError).catch(() => undefined); - reject(timeoutError); - }, timeoutMs); - unrefTimer(timeout); - }); - - try { - return await Promise.race([ - readResponseWithLimit(response, maxBytes, { - chunkTimeoutMs: DIRECT_UPLOAD_READ_IDLE_TIMEOUT_MS, - }), - timeoutPromise, - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} - -export async function downloadDirectUploadUrl( - url: string, - opts: { maxBytes?: number } = {}, -): Promise { - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new Error("Direct-upload media URL must be a valid URL"); - } - - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error("Direct-upload media URL must use HTTP or HTTPS"); - } - - assertDirectUploadDownloadHostAllowed(parsed.hostname); - const { response, release } = await fetchDirectUploadDownload(parsed.toString()); - try { - if (!response.ok) { - // A debug-capture clone can keep the tee open, so waiting for cancel would - // hang before the error is returned. Fire-and-forget matches the timeout - // path above and the pattern used across other plugins. - void response.body?.cancel().catch(() => undefined); - throw new Error(`Direct-upload media URL returned HTTP ${response.status}`); - } - return await readDirectUploadResponse(response, opts.maxBytes ?? MAX_UPLOAD_SIZE); - } finally { - await release?.(); - } -} - -/** - * Small-file media upload module. - * - * Handles base64 and URL-based uploads with optional caching and retry. - */ -export class MediaApi { - private readonly client: ApiClient; - private readonly tokenManager: TokenManager; - private readonly logger?: EngineLogger; - private readonly cache?: UploadCacheAdapter; - private readonly sanitize: SanitizeFileNameFn; - - constructor(client: ApiClient, tokenManager: TokenManager, config: MediaApiConfig = {}) { - this.client = client; - this.tokenManager = tokenManager; - this.logger = config.logger; - this.cache = config.uploadCache; - this.sanitize = config.sanitizeFileName ?? ((n) => n); - } - - /** - * Upload media via base64, URL, buffer, or local file path to a C2C or Group target. - * - * The `localPath` and `buffer` branches are equivalent to `fileData` for the - * current one-shot implementation — the file is read and base64-encoded - * synchronously. They exist as first-class inputs so that a future chunked - * upload implementation can consume them without interface churn. - * - * @param scope - `'c2c'` or `'group'`. - * @param targetId - User openid or group openid. - * @param fileType - Media file type code. - * @param creds - Authentication credentials. - * @param opts - Upload options. Exactly one of `url`/`fileData`/`buffer`/`localPath` - * must be supplied. - * @returns Upload result containing `file_info` for subsequent message sends. - */ - async uploadMedia( - scope: ChatScope, - targetId: string, - fileType: MediaFileType, - creds: { appId: string; clientSecret: string }, - opts: { - url?: string; - fileData?: string; - /** - * Raw bytes in memory. Currently re-encoded to base64 internally; - * reserved as a dedicated input for the future chunked uploader. - */ - buffer?: Buffer; - /** - * On-disk path. Currently read + base64-encoded internally; reserved - * for streaming ingestion by the future chunked uploader. - */ - localPath?: string; - srvSendMsg?: boolean; - fileName?: string; - }, - ): Promise { - const sources = [opts.url, opts.fileData, opts.buffer, opts.localPath].filter( - (v) => v !== undefined, - ); - if (sources.length === 0) { - throw new Error(`uploadMedia: one of url/fileData/buffer/localPath is required`); - } - if (sources.length > 1) { - throw new Error( - `uploadMedia: url/fileData/buffer/localPath are mutually exclusive (got ${sources.length})`, - ); - } - - // One-shot path: materialize buffer/localPath into fileData. - // Future chunked-upload work will branch here on size and route - // buffer/localPath through streaming ingestion instead of base64 encoding. - let fileData = opts.fileData; - if (opts.buffer) { - fileData = opts.buffer.toString("base64"); - } else if (opts.localPath) { - const buf = await fs.promises.readFile(opts.localPath); - fileData = buf.toString("base64"); - } else if (opts.url !== undefined) { - const buf = await downloadDirectUploadUrl(opts.url); - fileData = buf.toString("base64"); - } - - // Check cache for base64 uploads. - const uploadCache = - fileData !== undefined && !(fileType === MediaFileType.FILE && opts.fileName) - ? this.cache - : undefined; - if (fileData !== undefined && uploadCache) { - const hash = uploadCache.computeHash(fileData); - const cached = uploadCache.get(hash, scope, targetId, fileType); - if (cached) { - return { file_uuid: "", file_info: cached, ttl: 0 }; - } - } - - const body: Record = { - file_type: fileType, - srv_send_msg: opts.srvSendMsg ?? false, - }; - if (fileData !== undefined) { - body.file_data = fileData; - } - if (fileType === MediaFileType.FILE && opts.fileName) { - body.file_name = this.sanitize(opts.fileName); - } - - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - const path = mediaUploadPath(scope, targetId); - - const result = await withRetry( - () => - this.client.request(token, "POST", path, body, { - redactBodyKeys: ["file_data"], - uploadRequest: true, - }), - UPLOAD_RETRY_POLICY, - undefined, - this.logger, - ); - - // Cache the result for future dedup. - if (fileData !== undefined && uploadCache && result.file_info && result.ttl > 0) { - const hash = uploadCache.computeHash(fileData); - uploadCache.set( - hash, - scope, - targetId, - fileType, - result.file_info, - result.file_uuid, - result.ttl, - ); - } - - return result; - } - - /** - * Send a media message (upload result → message) to a C2C or Group target. - * - * @param scope - `'c2c'` or `'group'`. - * @param targetId - User openid or group openid. - * @param fileInfo - `file_info` from a prior upload. - * @param creds - Authentication credentials. - * @param opts - Message options. - */ - async sendMediaMessage( - scope: ChatScope, - targetId: string, - fileInfo: string, - creds: { appId: string; clientSecret: string }, - opts?: { - msgId?: string; - content?: string; - }, - ): Promise { - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1; - const path = messagePath(scope, targetId); - - return this.client.request(token, "POST", path, { - msg_type: 7, - media: { file_info: fileInfo }, - msg_seq: msgSeq, - ...(opts?.content ? { content: opts.content } : {}), - ...(opts?.msgId ? { msg_id: opts.msgId } : {}), - }); - } -} diff --git a/extensions/qqbot/src/engine/api/messages.ts b/extensions/qqbot/src/engine/api/messages.ts deleted file mode 100644 index d481b054b38d..000000000000 --- a/extensions/qqbot/src/engine/api/messages.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Message sending API for the QQ Open Platform. - * - * Key design improvements: - * - Unified `sendMessage(scope, ...)` replaces `sendC2CMessage` + `sendGroupMessage`. - * - `onMessageSent` hook is scoped to the instance, not a module-level global. - * - Markdown support flag is per-instance, not a global Map. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import type { - ChatScope, - MessageResponse, - OutboundMeta, - EngineLogger, - InlineKeyboard, - StreamMessageRequest, -} from "../types.js"; -import { ApiClient } from "./api-client.js"; -import { - messagePath, - channelMessagePath, - dmMessagePath, - gatewayPath, - interactionPath, - getNextMsgSeq, - streamMessagePath, -} from "./routes.js"; -import { TokenManager } from "./token.js"; - -interface MessageApiConfig { - /** Whether the QQ Bot has markdown permission. */ - markdownSupport: boolean; - /** Logger for diagnostics. */ - logger?: EngineLogger; -} - -type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void; - -/** - * Message sending module. - * - * Usage: - * ```ts - * const api = new MessageApi(client, tokenMgr, { markdownSupport: true }); - * await api.sendMessage('c2c', openid, 'Hello!', { appId, clientSecret, msgId }); - * ``` - */ -export class MessageApi { - private readonly client: ApiClient; - private readonly tokenManager: TokenManager; - private readonly markdownSupport: boolean; - private readonly logger?: EngineLogger; - private messageSentHook: OnMessageSentCallback | null = null; - - constructor(client: ApiClient, tokenManager: TokenManager, config: MessageApiConfig) { - this.client = client; - this.tokenManager = tokenManager; - this.markdownSupport = config.markdownSupport; - this.logger = config.logger; - } - - /** Register a callback invoked when a sent message returns a ref_idx. */ - onMessageSent(callback: OnMessageSentCallback): void { - this.messageSentHook = callback; - } - - /** - * Notify the registered hook about a sent message. - * Use this for media sends that bypass `sendAndNotify`. - */ - notifyMessageSent(refIdx: string, meta: OutboundMeta): void { - if (this.messageSentHook) { - try { - this.messageSentHook(refIdx, meta); - } catch (err) { - this.logger?.error?.( - `[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`, - ); - } - } - } - - // ---- Unified message sending ---- - - /** - * Send a text message to a C2C or Group target. - * - * Automatically constructs the correct path, body format (markdown vs plain), - * and message sequence number. - */ - async sendMessage( - scope: ChatScope, - targetId: string, - content: string, - creds: Credentials, - opts?: { - msgId?: string; - messageReference?: string; - inlineKeyboard?: InlineKeyboard; - forcePlainText?: boolean; - }, - ): Promise { - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1; - const body = this.buildMessageBody( - content, - opts?.msgId, - msgSeq, - opts?.messageReference, - opts?.inlineKeyboard, - opts?.forcePlainText, - ); - const path = messagePath(scope, targetId); - return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content }); - } - - /** Send a proactive (no msgId) message to a C2C or Group target. */ - async sendProactiveMessage( - scope: ChatScope, - targetId: string, - content: string, - creds: Credentials, - opts?: { forcePlainText?: boolean }, - ): Promise { - if (!content?.trim()) { - throw new Error("Proactive message content must not be empty"); - } - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - const body = this.buildProactiveBody(content, opts?.forcePlainText); - const path = messagePath(scope, targetId); - return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content }); - } - - // ---- Channel / DM ---- - - /** Send a channel message. */ - async sendChannelMessage(opts: { - channelId: string; - content: string; - creds: Credentials; - msgId?: string; - }): Promise { - const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); - return this.client.request(token, "POST", channelMessagePath(opts.channelId), { - content: opts.content, - ...(opts.msgId ? { msg_id: opts.msgId } : {}), - }); - } - - /** Send a DM (guild direct message). */ - async sendDmMessage(opts: { - guildId: string; - content: string; - creds: Credentials; - msgId?: string; - }): Promise { - const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); - return this.client.request(token, "POST", dmMessagePath(opts.guildId), { - content: opts.content, - ...(opts.msgId ? { msg_id: opts.msgId } : {}), - }); - } - - // ---- C2C Input Notify ---- - - /** Send a typing indicator to a C2C user. */ - async sendInputNotify(opts: { - openid: string; - creds: Credentials; - msgId?: string; - inputSecond?: number; - }): Promise<{ refIdx?: string }> { - const inputSecond = opts.inputSecond ?? 60; - const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); - const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1; - const response = await this.client.request<{ ext_info?: { ref_idx?: string } }>( - token, - "POST", - messagePath("c2c", opts.openid), - { - msg_type: 6, - input_notify: { input_type: 1, input_second: inputSecond }, - msg_seq: msgSeq, - ...(opts.msgId ? { msg_id: opts.msgId } : {}), - }, - ); - return { refIdx: response.ext_info?.ref_idx }; - } - - // ---- Interaction ---- - - /** Acknowledge an INTERACTION_CREATE event. */ - async acknowledgeInteraction( - interactionId: string, - creds: Credentials, - code: 0 | 1 | 2 | 3 | 4 | 5 = 0, - ): Promise { - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - await this.client.request(token, "PUT", interactionPath(interactionId), { code }); - } - - // ---- Gateway ---- - - /** Get the WebSocket gateway URL. */ - async getGatewayUrl(creds: Credentials): Promise { - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - const data = await this.client.request<{ url: string }>(token, "GET", gatewayPath()); - return data.url; - } - - /** - * Send a C2C stream message chunk (`/v2/users/{openid}/stream_messages`). - * Only supported for one-to-one chats. - */ - async sendC2CStreamMessage( - creds: Credentials, - openid: string, - req: StreamMessageRequest, - ): Promise { - const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); - const path = streamMessagePath(openid); - const body: Record = { - input_mode: req.input_mode, - input_state: req.input_state, - content_type: req.content_type, - content_raw: req.content_raw, - event_id: req.event_id, - msg_id: req.msg_id, - msg_seq: req.msg_seq, - index: req.index, - }; - if (req.stream_msg_id) { - body.stream_msg_id = req.stream_msg_id; - } - return this.client.request(token, "POST", path, body); - } - - // ---- Internal ---- - - private async sendAndNotify( - _appId: string, - accessToken: string, - method: string, - path: string, - body: unknown, - meta: OutboundMeta, - ): Promise { - const result = await this.client.request(accessToken, method, path, body); - if (result.ext_info?.ref_idx && this.messageSentHook) { - try { - this.messageSentHook(result.ext_info.ref_idx, meta); - } catch (err) { - this.logger?.error?.( - `[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`, - ); - } - } - return result; - } - - private buildMessageBody( - content: string, - msgId: string | undefined, - msgSeq: number, - messageReference?: string, - inlineKeyboard?: InlineKeyboard, - forcePlainText = false, - ): Record { - const useMarkdown = this.markdownSupport && !forcePlainText; - const body: Record = useMarkdown - ? { markdown: { content }, msg_type: 2, msg_seq: msgSeq } - : { content, msg_type: 0, msg_seq: msgSeq }; - - if (msgId) { - body.msg_id = msgId; - } - if (messageReference && !useMarkdown) { - body.message_reference = { message_id: messageReference }; - } - if (inlineKeyboard) { - body.keyboard = inlineKeyboard; - } - return body; - } - - private buildProactiveBody(content: string, forcePlainText = false): Record { - return this.markdownSupport && !forcePlainText - ? { markdown: { content }, msg_type: 2 } - : { content, msg_type: 0 }; - } -} - -// ---- Shared helpers ---- - -/** Credentials needed to authenticate API requests. */ -export interface Credentials { - appId: string; - clientSecret: string; -} diff --git a/extensions/qqbot/src/engine/api/retry.test.ts b/extensions/qqbot/src/engine/api/retry.test.ts deleted file mode 100644 index 5bc8dec676cf..000000000000 --- a/extensions/qqbot/src/engine/api/retry.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { EngineLogger } from "../types.js"; -import { withRetry } from "./retry.js"; - -const mocks = vi.hoisted(() => ({ - sleep: vi.fn(async () => {}), -})); - -vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ sleep: mocks.sleep })); - -function createLogger(): EngineLogger { - return { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }; -} - -beforeEach(() => { - mocks.sleep.mockClear(); -}); - -describe("withRetry", () => { - it("uses the shared runner without changing exponential schedules", async () => { - vi.useFakeTimers(); - const operation = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error("first")) - .mockRejectedValueOnce(new Error("second")) - .mockResolvedValueOnce("ok"); - const logger = createLogger(); - - try { - const promise = withRetry( - operation, - { maxRetries: 2, baseDelayMs: 100, backoff: "exponential" }, - undefined, - logger, - ); - await vi.advanceTimersByTimeAsync(99); - expect(operation).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(1); - expect(operation).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(199); - expect(operation).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(1); - await expect(promise).resolves.toBe("ok"); - expect(operation).toHaveBeenCalledTimes(3); - expect(logger.debug).toHaveBeenNthCalledWith( - 1, - "[qqbot:retry] Attempt 1 failed, retrying in 100ms: first", - ); - expect(logger.debug).toHaveBeenNthCalledWith( - 2, - "[qqbot:retry] Attempt 2 failed, retrying in 200ms: second", - ); - } finally { - vi.clearAllTimers(); - vi.useRealTimers(); - } - }); - - it("keeps fixed retry schedules flat", async () => { - vi.useFakeTimers(); - const operation = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error("first")) - .mockRejectedValueOnce(new Error("second")) - .mockResolvedValueOnce("ok"); - - try { - const promise = withRetry(operation, { - maxRetries: 2, - baseDelayMs: 75, - backoff: "fixed", - }); - await vi.advanceTimersByTimeAsync(75); - expect(operation).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(75); - await expect(promise).resolves.toBe("ok"); - expect(operation).toHaveBeenCalledTimes(3); - } finally { - vi.clearAllTimers(); - vi.useRealTimers(); - } - }); - - it("preserves the policy's zero-based attempt index", async () => { - const shouldRetry = vi.fn(() => false); - await expect( - withRetry( - async () => { - throw new Error("stop"); - }, - { - maxRetries: 2, - baseDelayMs: 100, - backoff: "fixed", - shouldRetry, - }, - ), - ).rejects.toThrow("stop"); - expect(shouldRetry).toHaveBeenCalledWith(expect.any(Error), 0); - expect(mocks.sleep).not.toHaveBeenCalled(); - }); - - it("does not restart a persistent loop after its terminal failure", async () => { - const persistentTrigger = Object.assign(new Error("processing"), { bizCode: 42 }); - const terminal = new Error("permission denied"); - const operation = vi - .fn<() => Promise>() - .mockRejectedValueOnce(persistentTrigger) - .mockRejectedValueOnce(terminal); - - await expect( - withRetry( - operation, - { maxRetries: 2, baseDelayMs: 100, backoff: "fixed" }, - { - timeoutMs: 1_000, - intervalMs: 10, - shouldPersistRetry: (error) => - "bizCode" in error && (error as { bizCode?: number }).bizCode === 42, - }, - ), - ).rejects.toBe(terminal); - expect(operation).toHaveBeenCalledTimes(2); - expect(mocks.sleep).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/engine/api/retry.ts b/extensions/qqbot/src/engine/api/retry.ts deleted file mode 100644 index 01d6f2feb7ba..000000000000 --- a/extensions/qqbot/src/engine/api/retry.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Generic retry engine for QQ Bot API requests. - * - * Replaces the three separate retry implementations in the old `api.ts`: - * - `apiRequestWithRetry` (upload retry with exponential backoff) - * - `partFinishWithRetry` (part-finish retry + persistent retry on specific biz codes) - * - `completeUploadWithRetry` (unconditional retry for complete-upload) - * - * All three patterns are expressed as a single `withRetry` function - * parameterized by `RetryPolicy` and optional `PersistentRetryPolicy`. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { createChannelApiRetryRunner, resolveRetryConfig } from "openclaw/plugin-sdk/retry-runtime"; -import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { EngineLogger } from "../types.js"; - -/** Standard retry policy with exponential or fixed backoff. */ -interface RetryPolicy { - /** Maximum retry attempts (excluding the initial attempt). */ - maxRetries: number; - /** Base delay in milliseconds. */ - baseDelayMs: number; - /** Backoff strategy. */ - backoff: "exponential" | "fixed"; - /** - * Predicate to decide whether an error is retryable. - * Return `false` to immediately rethrow. - * Defaults to always-retry when omitted. - */ - shouldRetry?: (error: Error, attempt: number) => boolean; -} - -/** - * Persistent retry policy for specific business error codes. - * - * When `shouldPersistRetry` returns true, the engine switches from - * the standard retry loop into a tight fixed-interval loop bounded - * only by the total timeout. - */ -interface PersistentRetryPolicy { - /** Total timeout in milliseconds for the persistent retry loop. */ - timeoutMs: number; - /** Fixed interval between retries in milliseconds. */ - intervalMs: number; - /** Predicate to decide whether an error triggers persistent retry. */ - shouldPersistRetry: (error: Error) => boolean; -} - -/** - * Execute an async operation with configurable retry semantics. - * - * @param fn - The async operation to retry. - * @param policy - Standard retry configuration. - * @param persistentPolicy - Optional persistent retry for specific error codes. - * @param logger - Optional logger for retry diagnostics. - * @returns The result of the first successful invocation. - */ -export async function withRetry( - fn: () => Promise, - policy: RetryPolicy, - persistentPolicy?: PersistentRetryPolicy, - logger?: EngineLogger, -): Promise { - // A persistent loop owns its terminal failure. Mark that Error so the outer - // bounded runner does not accidentally restart the completed deadline loop. - const persistentFailures = new WeakSet(); - const retryConfig = resolveRetryConfig(undefined, { - attempts: policy.maxRetries + 1, - minDelayMs: policy.baseDelayMs, - maxDelayMs: policy.backoff === "fixed" ? policy.baseDelayMs : 2_147_000_000, - jitter: 0, - }); - const runWithRetry = createChannelApiRetryRunner({ - retry: retryConfig, - strictShouldRetry: true, - retryAfterMs: () => undefined, - shouldRetry: (err, attempt) => { - const error = err instanceof Error ? err : new Error(formatErrorMessage(err)); - const shouldRetry = - !persistentFailures.has(error) && policy.shouldRetry?.(error, attempt - 1) !== false; - if (shouldRetry) { - const delayMs = - policy.backoff === "fixed" - ? retryConfig.minDelayMs - : Math.min(retryConfig.minDelayMs * 2 ** (attempt - 1), retryConfig.maxDelayMs); - logger?.debug?.( - `[qqbot:retry] Attempt ${attempt} failed, retrying in ${delayMs}ms: ${truncateUtf16Safe(error.message, 100)}`, - ); - } - return shouldRetry; - }, - }); - return await runWithRetry(async () => { - try { - return await fn(); - } catch (err) { - const error = err instanceof Error ? err : new Error(formatErrorMessage(err)); - if (!persistentPolicy?.shouldPersistRetry(error)) { - throw error; - } - (logger?.warn ?? logger?.error)?.( - `[qqbot:retry] Hit persistent-retry trigger, entering persistent loop (timeout=${persistentPolicy.timeoutMs / 1000}s)`, - ); - try { - return await persistentRetryLoop(fn, persistentPolicy, logger); - } catch (persistentError) { - const terminal = - persistentError instanceof Error - ? persistentError - : new Error(formatErrorMessage(persistentError)); - persistentFailures.add(terminal); - throw terminal; - } - } - }); -} - -/** - * Persistent retry loop: fixed-interval retries bounded by a total timeout. - * - * Used for `upload_part_finish` when the server returns specific business - * error codes indicating the backend is still processing. - */ -async function persistentRetryLoop( - fn: () => Promise, - policy: PersistentRetryPolicy, - logger?: EngineLogger, -): Promise { - const deadline = Date.now() + policy.timeoutMs; - let attempt = 0; - let lastError: Error | null = null; - - while (Date.now() < deadline) { - try { - const result = await fn(); - logger?.debug?.(`[qqbot:retry] Persistent retry succeeded after ${attempt} retries`); - return result; - } catch (err) { - lastError = err instanceof Error ? err : new Error(formatErrorMessage(err)); - - // If the error is no longer retryable, abort immediately. - if (!policy.shouldPersistRetry(lastError)) { - logger?.error?.(`[qqbot:retry] Persistent retry: error is no longer retryable, aborting`); - throw lastError; - } - - attempt++; - const remaining = deadline - Date.now(); - if (remaining <= 0) { - break; - } - - const actualDelay = Math.min(policy.intervalMs, remaining); - (logger?.warn ?? logger?.error)?.( - `[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`, - ); - await sleep(actualDelay); - } - } - - logger?.error?.( - `[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1000}s (${attempt} attempts)`, - ); - throw lastError ?? new Error(`Persistent retry timed out (${policy.timeoutMs / 1000}s)`); -} - -// ============ Pre-built Retry Policies ============ - -/** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */ -export const UPLOAD_RETRY_POLICY: RetryPolicy = { - maxRetries: 2, - baseDelayMs: 1000, - backoff: "exponential", - shouldRetry: (error) => { - const msg = error.message; - return !( - msg.includes("400") || - msg.includes("401") || - msg.includes("Invalid") || - msg.includes("timeout") || - msg.includes("Timeout") - ); - }, -}; - -/** Complete-upload retry: unconditional retry with exponential backoff. */ -export const COMPLETE_UPLOAD_RETRY_POLICY: RetryPolicy = { - maxRetries: 2, - baseDelayMs: 2000, - backoff: "exponential", - // Always retry — complete-upload failures are often transient server-side. -}; - -/** Part-finish standard retry policy. */ -export const PART_FINISH_RETRY_POLICY: RetryPolicy = { - maxRetries: 2, - baseDelayMs: 1000, - backoff: "exponential", -}; - -/** - * Build a persistent retry policy for part-finish with a specific timeout. - * - * @param retryTimeoutMs - Total timeout (defaults to 2 minutes). - * @param retryableCodes - Business error codes that trigger persistent retry. - */ -export function buildPartFinishPersistentPolicy( - retryTimeoutMs?: number, - retryableCodes: Set = PART_FINISH_RETRYABLE_CODES, -): PersistentRetryPolicy { - return { - timeoutMs: retryTimeoutMs ?? 2 * 60 * 1000, - intervalMs: 1000, - shouldPersistRetry: (error) => { - if (retryableCodes.size === 0) { - return false; - } - // Check for ApiError with matching bizCode. - if ("bizCode" in error && typeof (error as { bizCode?: number }).bizCode === "number") { - return retryableCodes.has((error as { bizCode: number }).bizCode); - } - return false; - }, - }; -} - -/** Business error codes that trigger persistent part-finish retry. */ -const PART_FINISH_RETRYABLE_CODES: Set = new Set([40093001]); - -/** upload_prepare error code indicating daily limit exceeded. */ -export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002; diff --git a/extensions/qqbot/src/engine/api/routes.ts b/extensions/qqbot/src/engine/api/routes.ts deleted file mode 100644 index 6eaac4931f35..000000000000 --- a/extensions/qqbot/src/engine/api/routes.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Centralized API route templates for the QQ Open Platform. - * - * Eliminates C2C/Group path duplication by parameterizing on `ChatScope`. - * Inspired by `bot-node-sdk/src/openapi/v1/resource.ts`. - */ - -import type { ChatScope } from "../types.js"; - -/** - * Build the message-send path for C2C or Group. - * - * - C2C: `/v2/users/{id}/messages` - * - Group: `/v2/groups/{id}/messages` - */ -export function messagePath(scope: ChatScope, targetId: string): string { - return scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`; -} - -/** Channel message path. */ -export function channelMessagePath(channelId: string): string { - return `/channels/${channelId}/messages`; -} - -/** DM (direct message inside a guild) path. */ -export function dmMessagePath(guildId: string): string { - return `/dms/${guildId}/messages`; -} - -/** - * Build the media upload (small-file) path for C2C or Group. - * - * - C2C: `/v2/users/{id}/files` - * - Group: `/v2/groups/{id}/files` - */ -export function mediaUploadPath(scope: ChatScope, targetId: string): string { - return scope === "c2c" ? `/v2/users/${targetId}/files` : `/v2/groups/${targetId}/files`; -} - -/** - * Build the upload_prepare path for C2C or Group. - * - * - C2C: `/v2/users/{id}/upload_prepare` - * - Group: `/v2/groups/{id}/upload_prepare` - */ -export function uploadPreparePath(scope: ChatScope, targetId: string): string { - return scope === "c2c" - ? `/v2/users/${targetId}/upload_prepare` - : `/v2/groups/${targetId}/upload_prepare`; -} - -/** - * Build the upload_part_finish path for C2C or Group. - */ -export function uploadPartFinishPath(scope: ChatScope, targetId: string): string { - return scope === "c2c" - ? `/v2/users/${targetId}/upload_part_finish` - : `/v2/groups/${targetId}/upload_part_finish`; -} - -/** - * Build the complete-upload (files) path for C2C or Group. - * (Same as mediaUploadPath — the complete endpoint reuses the files path.) - */ -export function uploadCompletePath(scope: ChatScope, targetId: string): string { - return mediaUploadPath(scope, targetId); -} - -/** Stream message path (C2C only). */ -export function streamMessagePath(openid: string): string { - return `/v2/users/${openid}/stream_messages`; -} - -/** Gateway URL path. */ -export function gatewayPath(): string { - return "/gateway"; -} - -/** Interaction acknowledgement path. */ -export function interactionPath(interactionId: string): string { - return `/interactions/${interactionId}`; -} - -// ============ Shared Helpers ============ - -/** - * Generate a message sequence number in the 0..65535 range. - * - * Used by both `messages.ts` and `media.ts` to avoid duplicate definitions. - */ -export function getNextMsgSeq(_msgId: string): number { - const timePart = Date.now() % 100_000_000; - const random = Math.floor(Math.random() * 65536); - return (timePart ^ random) % 65536; -} diff --git a/extensions/qqbot/src/engine/api/token.test.ts b/extensions/qqbot/src/engine/api/token.test.ts deleted file mode 100644 index b9bc093f324c..000000000000 --- a/extensions/qqbot/src/engine/api/token.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -// Qqbot tests cover token plugin behavior. -import { getEventListeners } from "node:events"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TokenManager } from "./token.js"; - -const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchWithSsrFGuard: fetchWithSsrFGuardMock, - }; -}); - -function mockGuardedTokenResponse(body: BodyInit, init?: ResponseInit): ReturnType { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(body, init), - release, - }); - return release; -} - -function cancelTrackedResponse( - text: string, - init: ResponseInit, -): { - release: ReturnType; - response: Response; - wasCanceled: () => boolean; -} { - let canceled = false; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(text)); - }, - cancel() { - canceled = true; - }, - }); - const release = vi.fn(async () => {}); - const response = new Response(stream, init); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release }); - return { - release, - response, - wasCanceled: () => canceled, - }; -} - -describe("QQBot token manager", () => { - beforeEach(() => { - fetchWithSsrFGuardMock.mockReset(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("wraps malformed access token JSON", async () => { - const release = mockGuardedTokenResponse("{not json", { - status: 200, - headers: { "content-type": "application/json" }, - }); - - await expect(new TokenManager().getAccessToken("app-id", "secret")).rejects.toThrow( - "QQBot access_token response was malformed JSON", - ); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ - url: "https://bots.qq.com/app/getAppAccessToken", - auditContext: "qqbot-token", - capture: false, - policy: { - hostnameAllowlist: ["bots.qq.com"], - allowRfc2544BenchmarkRange: true, - }, - timeoutMs: 30_000, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "QQBotPlugin/unknown", - }, - body: JSON.stringify({ appId: "app-id", clientSecret: "secret" }), - }, - }); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("adds account-neutral credential guidance when the token endpoint omits access_token", async () => { - const release = mockGuardedTokenResponse('{"code":4001,"message":"invalid app secret"}', { - status: 200, - headers: { "content-type": "application/json" }, - }); - - let error: unknown; - try { - await new TokenManager().getAccessToken("app-id", "secret"); - } catch (caught) { - error = caught; - } - - const message = error instanceof Error ? error.message : String(error); - expect(message).toContain("Failed to get QQBot access_token"); - expect(message).toContain("QQBot account appId and clientSecret"); - expect(message).toContain("https://q.qq.com/"); - expect(message).toContain('{"code":4001,"message":"invalid app secret"}'); - expect(message).not.toContain("QQBOT_APP_ID"); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("bounds access token responses without using response.text()", async () => { - const logger = { debug: vi.fn(), info: vi.fn(), error: vi.fn() }; - const tracked = cancelTrackedResponse(`${"qqbot token unavailable ".repeat(1024)}tail`, { - status: 503, - headers: { "content-type": "text/plain" }, - }); - const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); - - await expect(new TokenManager({ logger }).getAccessToken("app-id", "secret")).rejects.toThrow( - "QQBot access_token response was malformed JSON", - ); - - expect(tracked.wasCanceled()).toBe(true); - expect(textSpy).not.toHaveBeenCalled(); - expect(tracked.release).toHaveBeenCalledTimes(1); - expect(logger.debug.mock.calls.join("\n")).toContain("qqbot token unavailable"); - expect(logger.debug.mock.calls.join("\n")).not.toContain("tail"); - }); - - it("passes the RFC2544 SSRF allowance to the token fetch (regression for #88984)", async () => { - mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', { - status: 200, - headers: { "content-type": "application/json" }, - }); - - await expect(new TokenManager().getAccessToken("app-id", "secret")).resolves.toBe("token-1"); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://bots.qq.com/app/getAppAccessToken", - auditContext: "qqbot-token", - policy: { - hostnameAllowlist: ["bots.qq.com"], - allowRfc2544BenchmarkRange: true, - }, - }), - ); - }); - - it("does not cache access tokens forever when expires_in is unsafe", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); - mockGuardedTokenResponse('{"access_token":"token-1","expires_in":1e309}', { - status: 200, - headers: { "content-type": "application/json" }, - }); - - const manager = new TokenManager(); - await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1"); - - const status = manager.getStatus("app-id"); - expect(status.status).toBe("valid"); - expect(status.expiresAt).toBe(Date.now() + 7200 * 1000); - }); - - it("does not extend explicit non-positive token lifetimes", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); - mockGuardedTokenResponse('{"access_token":"token-1","expires_in":0}', { - status: 200, - headers: { "content-type": "application/json" }, - }); - - const manager = new TokenManager(); - await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1"); - - expect(manager.getStatus("app-id")).toEqual({ - status: "expired", - expiresAt: Date.now(), - }); - }); - - it("does not cache fetched tokens when the process clock is outside the Date range", async () => { - const logger = { debug: vi.fn(), info: vi.fn(), error: vi.fn() }; - const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001); - mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', { - status: 200, - headers: { "content-type": "application/json" }, - }); - - const manager = new TokenManager({ logger }); - try { - await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1"); - } finally { - dateNowSpy.mockRestore(); - } - - expect(manager.getStatus("app-id")).toEqual({ status: "none", expiresAt: null }); - expect(logger.debug).toHaveBeenCalledWith( - "[qqbot:token:app-id] Not cached: invalid process clock", - ); - }); - - it("times out one stalled token fetch for every singleflight waiter and allows retry", async () => { - vi.useFakeTimers(); - const { fetchWithSsrFGuard } = await vi.importActual< - typeof import("openclaw/plugin-sdk/ssrf-runtime") - >("openclaw/plugin-sdk/ssrf-runtime"); - fetchWithSsrFGuardMock.mockImplementation(fetchWithSsrFGuard); - - let fetchSignal: AbortSignal | undefined; - const stalledFetch = vi.fn( - (_input: RequestInfo | URL, init?: RequestInit) => - new Promise((_resolve, reject) => { - fetchSignal = init?.signal ?? undefined; - if (!fetchSignal) { - reject(new Error("missing guarded fetch signal")); - return; - } - fetchSignal.addEventListener( - "abort", - () => { - const reason = fetchSignal?.reason; - const error = - reason instanceof Error ? reason : new Error("request aborted", { cause: reason }); - reject(error); - }, - { once: true }, - ); - }), - ); - vi.stubGlobal("fetch", stalledFetch); - - const manager = new TokenManager(); - const first = manager.getAccessToken("app-id", "secret"); - const second = manager.getAccessToken(" app-id ", "secret"); - const outcomes = Promise.allSettled([first, second]); - - await vi.advanceTimersByTimeAsync(0); - expect(stalledFetch).toHaveBeenCalledTimes(1); - expect(manager.getStatus("app-id").status).toBe("refreshing"); - - await vi.advanceTimersByTimeAsync(30_000); - const [firstOutcome, secondOutcome] = await outcomes; - expect(fetchSignal?.aborted).toBe(true); - expect(firstOutcome.status).toBe("rejected"); - expect(secondOutcome.status).toBe("rejected"); - if (firstOutcome.status !== "rejected" || secondOutcome.status !== "rejected") { - throw new Error("expected every singleflight waiter to reject"); - } - const timeoutError = firstOutcome.reason as Error; - expect(timeoutError).toBe(secondOutcome.reason); - expect(timeoutError.message).toContain("Network error getting access_token: request timed out"); - expect(timeoutError.message).toContain("Check network connectivity and DNS"); - expect(timeoutError.message).toContain("server IP whitelist"); - expect(timeoutError.message).not.toContain("appId"); - expect(timeoutError.cause).toMatchObject({ - name: "TimeoutError", - message: "request timed out", - }); - expect(manager.getStatus("app-id")).toEqual({ status: "none", expiresAt: null }); - - stalledFetch.mockResolvedValueOnce( - new Response('{"access_token":"token-2","expires_in":7200}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-2"); - expect(stalledFetch).toHaveBeenCalledTimes(2); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); - }); - - it("yields and does not grow abort listeners across zero-delay refresh sleeps", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); - - const accessTokenField = ["access", "token"].join("_"); - for (let i = 1; i <= 4; i += 1) { - const body = JSON.stringify({ [accessTokenField]: `token-${i}`, expires_in: 0 }); - mockGuardedTokenResponse(body, { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - - const addListenerSpy = vi.spyOn(AbortSignal.prototype, "addEventListener"); - const activeAbortListenerCount = () => - [...new Set(addListenerSpy.mock.instances)] - .filter((signal): signal is AbortSignal => signal instanceof AbortSignal) - .reduce((count, signal) => count + getEventListeners(signal, "abort").length, 0); - - const manager = new TokenManager(); - try { - manager.startBackgroundRefresh("app-id", "secret", { - refreshAheadMs: 0, - randomOffsetMs: 0, - minRefreshIntervalMs: 0, - retryDelayMs: 0, - }); - - await vi.advanceTimersByTimeAsync(0); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); - expect(activeAbortListenerCount()).toBe(1); - - for (let cycle = 2; cycle <= 4; cycle += 1) { - await vi.advanceTimersByTimeAsync(1); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(cycle); - expect(activeAbortListenerCount()).toBe(1); - } - } finally { - manager.stopBackgroundRefresh("app-id"); - await vi.advanceTimersByTimeAsync(0); - try { - expect(activeAbortListenerCount()).toBe(0); - } finally { - addListenerSpy.mockRestore(); - } - } - }); -}); diff --git a/extensions/qqbot/src/engine/api/token.ts b/extensions/qqbot/src/engine/api/token.ts deleted file mode 100644 index bcc9ee6653b3..000000000000 --- a/extensions/qqbot/src/engine/api/token.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Token management for the QQ Open Platform. - * - * All state (cache, singleflight promises, background refresh controllers) - * is encapsulated in the `TokenManager` class instance — no module-level - * globals, fully supporting multi-account concurrent operation. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - asDateTimestampMs, - parseStrictPositiveInteger, - resolveExpiresAtMsFromDurationSeconds, - resolveTimestampMsToIsoString, -} from "openclaw/plugin-sdk/number-runtime"; -import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; -import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; -import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { qqbotNetworkGuidance, qqbotTokenFailureMessage } from "../config/setup-guidance.js"; -import type { EngineLogger } from "../types.js"; - -const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; -const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200; -const QQBOT_TOKEN_RESPONSE_LIMIT_BYTES = 8 * 1024; -const QQBOT_TOKEN_REQUEST_TIMEOUT_MS = 30_000; - -/** - * Host-scoped SSRF policy for the QQ Bot token endpoint. - * - * `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this - * relaxation only ever applies to that single host. Fake-IP proxy stacks - * (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into - * the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF - * guard blocks. We mirror the existing media-path pattern - * (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation - * stays narrowly host-scoped instead of weakening the global default. - * - * See https://github.com/openclaw/openclaw/issues/88984. - */ -const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = { - hostnameAllowlist: ["bots.qq.com"], - allowRfc2544BenchmarkRange: true, -}; - -interface CachedToken { - token: string; - expiresAt: number; - appId: string; -} - -interface BackgroundRefreshOptions { - refreshAheadMs?: number; - randomOffsetMs?: number; - minRefreshIntervalMs?: number; - retryDelayMs?: number; -} - -function resolveTokenExpiresInSeconds(value: unknown): number { - const parsed = parseStrictPositiveInteger(value); - if (parsed !== undefined) { - return parsed; - } - if (value == null || (typeof value === "number" && !Number.isFinite(value))) { - return DEFAULT_TOKEN_EXPIRES_IN_SECONDS; - } - return 0; -} - -/** - * Per-appId token manager with caching, singleflight, and background refresh. - * - * Usage: - * ```ts - * const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' }); - * const token = await tm.getAccessToken('appId', 'secret'); - * ``` - */ -export class TokenManager { - private readonly cache = new Map(); - private readonly fetchPromises = new Map>(); - private readonly refreshControllers = new Map(); - private readonly logger?: EngineLogger; - private readonly resolveUserAgent: () => string; - - constructor(config?: { logger?: EngineLogger; userAgent?: string | (() => string) }) { - this.logger = config?.logger; - const ua = config?.userAgent ?? "QQBotPlugin/unknown"; - this.resolveUserAgent = typeof ua === "function" ? ua : () => ua; - } - - /** - * Obtain an access token with caching and singleflight semantics. - * - * When multiple callers request a token for the same appId concurrently, - * only one actual HTTP request is made — the others await the same promise. - */ - async getAccessToken(appId: string, clientSecret: string): Promise { - const normalizedId = appId.trim(); - const cached = this.cache.get(normalizedId); - - // Refresh slightly before expiry without making short-lived tokens unusable. - const refreshAheadMs = cached - ? Math.min(5 * 60 * 1000, (cached.expiresAt - Date.now()) / 3) - : 0; - - if (cached && Date.now() < cached.expiresAt - refreshAheadMs) { - return cached.token; - } - - // Singleflight: reuse an in-progress fetch. - let pending = this.fetchPromises.get(normalizedId); - if (pending) { - this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`); - return pending; - } - - pending = (async () => { - try { - return await this.doFetchToken(normalizedId, clientSecret); - } finally { - this.fetchPromises.delete(normalizedId); - } - })(); - - this.fetchPromises.set(normalizedId, pending); - return pending; - } - - /** Clear the cached token for one appId, or all. */ - clearCache(appId?: string): void { - if (appId) { - this.cache.delete(appId.trim()); - this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`); - } else { - this.cache.clear(); - this.logger?.debug?.(`[token] All caches cleared`); - } - } - - /** Return token status for diagnostics. */ - getStatus(appId: string): { - status: "valid" | "expired" | "refreshing" | "none"; - expiresAt: number | null; - } { - if (this.fetchPromises.has(appId)) { - return { status: "refreshing", expiresAt: this.cache.get(appId)?.expiresAt ?? null }; - } - const cached = this.cache.get(appId); - if (!cached) { - return { status: "none", expiresAt: null }; - } - const remaining = cached.expiresAt - Date.now(); - const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3); - return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt }; - } - - /** Start a background token refresh loop for one appId. */ - startBackgroundRefresh( - appId: string, - clientSecret: string, - options?: BackgroundRefreshOptions, - ): void { - if (this.refreshControllers.has(appId)) { - this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`); - return; - } - - const { - refreshAheadMs = 5 * 60 * 1000, - randomOffsetMs = 30 * 1000, - minRefreshIntervalMs = 60 * 1000, - retryDelayMs = 5 * 1000, - } = options ?? {}; - - const controller = new AbortController(); - this.refreshControllers.set(appId, controller); - const { signal } = controller; - // Preserve the old timer's event-loop yield for zero/invalid overrides; - // the shared helper's no-op semantics would let this refresh loop spin. - const sleepAndYield = (ms: number) => - sleepWithAbort(Number.isFinite(ms) ? Math.max(ms, 1) : 1, signal); - - const loop = async () => { - this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`); - - while (!signal.aborted) { - try { - await this.getAccessToken(appId, clientSecret); - const cached = this.cache.get(appId); - - if (cached) { - const expiresIn = cached.expiresAt - Date.now(); - const randomOffset = Math.random() * randomOffsetMs; - const refreshIn = Math.max( - expiresIn - refreshAheadMs - randomOffset, - minRefreshIntervalMs, - ); - this.logger?.debug?.( - `[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`, - ); - await sleepAndYield(refreshIn); - } else { - await sleepAndYield(minRefreshIntervalMs); - } - } catch (err) { - if (signal.aborted) { - break; - } - this.logger?.error?.( - `[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`, - ); - await sleepAndYield(retryDelayMs); - } - } - - this.refreshControllers.delete(appId); - this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`); - }; - - loop().catch((err: unknown) => { - this.refreshControllers.delete(appId); - this.logger?.error?.( - `[qqbot:token:${appId}] Background refresh crashed: ${formatErrorMessage(err)}`, - ); - }); - } - - /** Stop background refresh for one appId, or all. */ - stopBackgroundRefresh(appId?: string): void { - if (appId) { - const ctrl = this.refreshControllers.get(appId); - if (ctrl) { - ctrl.abort(); - this.refreshControllers.delete(appId); - } - } else { - for (const ctrl of this.refreshControllers.values()) { - ctrl.abort(); - } - this.refreshControllers.clear(); - } - } - - // ---- Internal ---- - - private async doFetchToken(appId: string, clientSecret: string): Promise { - this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`); - - let response: Response; - let release: (() => Promise) | undefined; - try { - const guarded = await fetchWithSsrFGuard({ - url: TOKEN_URL, - auditContext: "qqbot-token", - capture: false, - policy: QQBOT_TOKEN_SSRF_POLICY, - timeoutMs: QQBOT_TOKEN_REQUEST_TIMEOUT_MS, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": this.resolveUserAgent(), - }, - body: JSON.stringify({ appId, clientSecret }), - }, - }); - response = guarded.response; - release = guarded.release; - } catch (err) { - this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`); - throw new Error( - `Network error getting access_token: ${formatErrorMessage(err)}. ${qqbotNetworkGuidance()}`, - { cause: err }, - ); - } - - try { - const traceId = response.headers.get("x-tps-trace-id") ?? ""; - this.logger?.debug?.( - `[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`, - ); - - let rawBody: string; - try { - rawBody = await readResponseTextLimited(response, QQBOT_TOKEN_RESPONSE_LIMIT_BYTES); - } catch (err) { - throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, { - cause: err, - }); - } - const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"'); - this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`); - - let data: { access_token?: string; expires_in?: unknown }; - try { - data = JSON.parse(rawBody); - } catch { - throw new Error("QQBot access_token response was malformed JSON"); - } - - if (!data.access_token) { - throw new Error(qqbotTokenFailureMessage(JSON.stringify(data))); - } - - const nowMs = asDateTimestampMs(Date.now()); - if (nowMs === undefined) { - this.logger?.debug?.(`[qqbot:token:${appId}] Not cached: invalid process clock`); - return data.access_token; - } - const expiresAt = - resolveExpiresAtMsFromDurationSeconds(resolveTokenExpiresInSeconds(data.expires_in), { - nowMs, - }) ?? nowMs; - this.cache.set(appId, { token: data.access_token, expiresAt, appId }); - this.logger?.debug?.( - `[qqbot:token:${appId}] Cached, expires at: ${resolveTimestampMsToIsoString(expiresAt)}`, - ); - - return data.access_token; - } finally { - await release?.(); - } - } -} diff --git a/extensions/qqbot/src/engine/approval/index.test.ts b/extensions/qqbot/src/engine/approval/index.test.ts deleted file mode 100644 index 8dca59b7c45d..000000000000 --- a/extensions/qqbot/src/engine/approval/index.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Qqbot tests cover index plugin behavior. -import type { ExecApprovalPendingView } from "openclaw/plugin-sdk/approval-handler-runtime"; -import { describe, expect, it } from "vitest"; -import { buildApprovalKeyboard, buildExecApprovalText, parseApprovalButtonData } from "./index.js"; - -function createExecView(commandText: string): ExecApprovalPendingView { - return { - approvalId: "approval-1", - approvalKind: "exec", - phase: "pending", - title: "Exec Approval Required", - metadata: [], - commandText, - actions: [], - expiresAtMs: Date.now() + 60_000, - }; -} - -function readCommandBlock(text: string): { body: string; fence: string } { - const match = text.match(/(?:^|\n)(`{3,})\n([\s\S]*?)\n\1(?:\n|$)/); - if (!match?.[1] || match[2] === undefined) { - throw new Error("Expected fenced command preview"); - } - return { fence: match[1], body: match[2] }; -} - -describe("buildApprovalKeyboard", () => { - it("omits allow-always when the decision is unavailable", () => { - const keyboard = buildApprovalKeyboard("approval-123", "exec", ["allow-once", "deny"]); - const buttons = keyboard.content.rows[0]?.buttons ?? []; - - expect(buttons.map((button) => button.id)).toEqual(["allow", "deny"]); - expect(buttons.map((button) => button.action.data)).toEqual([ - "approve:v2:exec:approval-123:allow-once", - "approve:v2:exec:approval-123:deny", - ]); - expect(buttons.map((button) => button.render_data.visited_label)).toEqual([ - "\u5df2\u5904\u7406", - "\u5df2\u5904\u7406", - ]); - }); - - it("keeps all buttons when all decisions are allowed", () => { - const keyboard = buildApprovalKeyboard("approval-123", "plugin", [ - "allow-once", - "allow-always", - "deny", - ]); - const buttons = keyboard.content.rows[0]?.buttons ?? []; - - expect(buttons.map((button) => button.id)).toEqual(["allow", "always", "deny"]); - expect(buttons.map((button) => button.render_data.visited_label)).toEqual([ - "\u5df2\u5904\u7406", - "\u5df2\u5904\u7406", - "\u5df2\u5904\u7406", - ]); - }); - - it("round-trips an opaque id with an explicit kind", () => { - const keyboard = buildApprovalKeyboard("exec:looks-like-exec/1", "plugin", ["deny"]); - const data = keyboard.content.rows[0]?.buttons[0]?.action.data ?? ""; - - expect(parseApprovalButtonData(data)).toEqual({ - approvalId: "exec:looks-like-exec/1", - approvalKind: "plugin", - decision: "deny", - }); - }); - - it("rejects legacy button data without an explicit kind", () => { - expect(parseApprovalButtonData("approve:plugin:abc:deny")).toBeNull(); - }); - - it.each([ - "Approve:v2:exec:approval-123:deny", - "approve:V2:exec:approval-123:deny", - "approve:v2:EXEC:approval-123:deny", - "approve:v2:exec:approval-123:DENY", - ])("rejects non-canonical uppercase envelope tokens: %s", (buttonData) => { - expect(parseApprovalButtonData(buttonData)).toBeNull(); - }); - - it("rejects data after an otherwise valid envelope", () => { - expect(parseApprovalButtonData("approve:v2:exec:approval-123:deny\n")).toBeNull(); - }); -}); - -describe("buildExecApprovalText", () => { - it("keeps a truncated command UTF-16 well formed", () => { - const safePrefix = "x".repeat(299); - const text = buildExecApprovalText(createExecView(`${safePrefix}🎉 trailing text`)); - const { body } = readCommandBlock(text); - - expect(body.replace(/[↩\n]/g, "")).toBe(`${safePrefix}…[truncated]`); - expect(body).not.toContain("🎉"); - }); - - it("wraps ASCII and double-width text after 24 graphemes", () => { - const ascii = readCommandBlock(buildExecApprovalText(createExecView("x".repeat(25)))); - const wide = readCommandBlock(buildExecApprovalText(createExecView(`${"表".repeat(24)}😀`))); - - expect(ascii.body).toBe(`${"x".repeat(24)}↩\nx`); - expect(wide.body).toBe(`${"表".repeat(24)}↩\n😀`); - }); - - it("keeps an extended emoji grapheme intact at the 300-unit cap", () => { - const family = "👨‍👩‍👧‍👦"; - const command = `${"x".repeat(289)}${family}`; - const { body } = readCommandBlock(buildExecApprovalText(createExecView(command))); - - expect(body.replace(/[↩\n]/g, "")).toBe(command); - }); - - it("shows a truncation marker when the first grapheme exceeds the cap", () => { - const oversizedGrapheme = `x${"\u0301".repeat(300)}`; - const { body } = readCommandBlock( - buildExecApprovalText(createExecView(`${oversizedGrapheme}; echo hidden`)), - ); - - expect(body.replace(/[↩\n]/g, "")).toBe(`${oversizedGrapheme.slice(0, 300)}…[truncated]`); - }); - - it("marks a display wrap before a shell comment boundary", () => { - const command = `${"x".repeat(22)} \\# harmless ; echo dangerous`; - const text = buildExecApprovalText(createExecView(command)); - const { body } = readCommandBlock(text); - - expect(text).toContain("↩ = display wrap only; not command text"); - expect(body).toContain(" \\↩\n# harmless ; echo danger↩\nous"); - expect(body.replace(/[↩\n]/g, "")).toBe(command); - }); - - it("uses a longer fence when the command contains triple backticks", () => { - const command = "echo ```danger```"; - const { body, fence } = readCommandBlock(buildExecApprovalText(createExecView(command))); - - expect(fence).toBe("````"); - expect(body).toBe(command); - }); - - it("reserves the display-wrap marker", () => { - const { body } = readCommandBlock(buildExecApprovalText(createExecView("echo ↩"))); - - expect(body).toBe("echo \\u{21A9}"); - }); -}); diff --git a/extensions/qqbot/src/engine/approval/index.ts b/extensions/qqbot/src/engine/approval/index.ts deleted file mode 100644 index b71c5e0d5b7d..000000000000 --- a/extensions/qqbot/src/engine/approval/index.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Approval helpers — pure functions, zero framework dependencies. - * - * - Build approval message text + inline keyboard - * - Resolve delivery target from session metadata - * - Parse INTERACTION_CREATE button data - */ - -import type { - ExecApprovalPendingView, - PluginApprovalPendingView, -} from "openclaw/plugin-sdk/approval-handler-runtime"; -import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/approval-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { ChatScope, InlineKeyboard, KeyboardButton } from "../types.js"; - -// ============ Types ============ - -export interface ExecApprovalRequest { - id: string; - expiresAtMs: number; - request: { - commandPreview?: string; - command?: string; - cwd?: string; - agentId?: string; - turnSourceAccountId?: string; - sessionKey?: string; - turnSourceTo?: string; - [key: string]: unknown; - }; -} - -export interface PluginApprovalRequest { - id: string; - request: { - severity?: string; - title: string; - description?: string; - toolName?: string; - pluginId?: string; - agentId?: string; - turnSourceAccountId?: string; - sessionKey?: string; - turnSourceTo?: string; - [key: string]: unknown; - }; -} - -type ApprovalDecision = "allow-once" | "allow-always" | "deny"; -type ApprovalKind = "exec" | "plugin"; - -interface ApprovalTarget { - type: ChatScope; - id: string; -} - -interface ParsedApprovalAction { - approvalId: string; - approvalKind: ApprovalKind; - decision: ApprovalDecision; -} - -// ============ Text Builders ============ - -const COMMAND_PREVIEW_MAX_LENGTH = 300; -const COMMAND_PREVIEW_GRAPHEMES_PER_LINE = 24; -const COMMAND_PREVIEW_WRAP_MARKER = "↩"; -const commandPreviewSegmenter = - typeof Intl !== "undefined" && "Segmenter" in Intl - ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) - : null; - -function splitCommandPreviewGraphemes(commandText: string): string[] { - return commandPreviewSegmenter - ? Array.from(commandPreviewSegmenter.segment(commandText), ({ segment }) => segment) - : Array.from(commandText); -} - -function formatCommandPreview(commandText: string): string { - // QQ Desktop does not wrap fenced blocks. The sanitized view has already escaped real command - // newlines, so these grapheme-safe line breaks are presentation-only and unambiguous. Limiting - // each line to 24 graphemes also bounds common double-width text to roughly 48 columns. - const lines = [""]; - const displayText = commandText.replaceAll(COMMAND_PREVIEW_WRAP_MARKER, "\\u{21A9}"); - let previewLength = 0; - let lineGraphemes = 0; - let truncated = false; - let wrapped = false; - for (const grapheme of splitCommandPreviewGraphemes(displayText)) { - if (previewLength + grapheme.length > COMMAND_PREVIEW_MAX_LENGTH) { - // A pathological first grapheme cannot fit intact; keep a visible UTF-16-safe prefix instead - // of presenting an empty command with active approval buttons. - if (previewLength === 0) { - lines[0] = truncateUtf16Safe(grapheme, COMMAND_PREVIEW_MAX_LENGTH); - } - truncated = true; - break; - } - previewLength += grapheme.length; - if (lineGraphemes === COMMAND_PREVIEW_GRAPHEMES_PER_LINE) { - lines[lines.length - 1] += COMMAND_PREVIEW_WRAP_MARKER; - lines.push(""); - lineGraphemes = 0; - wrapped = true; - } - lines[lines.length - 1] += grapheme; - lineGraphemes += 1; - } - const preview = `${lines.join("\n")}${truncated ? "\n…[truncated]" : ""}`; - const longestBacktickRun = Math.max(0, ...(preview.match(/`+/g)?.map((run) => run.length) ?? [])); - const fence = "`".repeat(Math.max(3, longestBacktickRun + 1)); - const block = `${fence}\n${preview}\n${fence}`; - return wrapped - ? `${COMMAND_PREVIEW_WRAP_MARKER} = display wrap only; not command text\n${block}` - : block; -} - -function formatApprovalMetadata(value: string): string { - const sanitized = resolveExecApprovalCommandDisplay({ command: value }).commandText; - return formatCommandPreview(sanitized); -} - -export function buildExecApprovalText(view: ExecApprovalPendingView, nowMs = Date.now()): string { - const expiresIn = Math.max(0, Math.round((view.expiresAtMs - nowMs) / 1000)); - const lines: string[] = ["\u{1f510} \u547d\u4ee4\u6267\u884c\u5ba1\u6279", ""]; - if (view.commandText) { - lines.push(formatCommandPreview(view.commandText)); - } - if (view.cwd) { - lines.push(`\u{1f4c1} \u76ee\u5f55:\n${formatApprovalMetadata(view.cwd)}`); - } - if (view.agentId) { - lines.push(`\u{1f916} Agent:\n${formatApprovalMetadata(view.agentId)}`); - } - lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${expiresIn} \u79d2`); - return lines.join("\n"); -} - -export function buildPluginApprovalText( - view: PluginApprovalPendingView, - nowMs = Date.now(), -): string { - const expiresIn = Math.max(0, Math.round((view.expiresAtMs - nowMs) / 1000)); - const severityIcon = - view.severity === "critical" - ? "\u{1f534}" - : view.severity === "info" - ? "\u{1f535}" - : "\u{1f7e1}"; - - const lines: string[] = [`${severityIcon} \u5ba1\u6279\u8bf7\u6c42`, ""]; - lines.push(`\u{1f4cb} ${view.title}`); - if (view.description) { - lines.push(`\u{1f4dd} ${view.description}`); - } - if (view.toolName) { - lines.push(`\u{1f527} \u5de5\u5177: ${view.toolName}`); - } - if (view.pluginId) { - lines.push(`\u{1f50c} \u63d2\u4ef6: ${view.pluginId}`); - } - if (view.agentId) { - lines.push(`\u{1f916} Agent: ${view.agentId}`); - } - lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${expiresIn} \u79d2`); - return lines.join("\n"); -} - -// ============ Keyboard Builder ============ - -/** - * Build the three-button inline keyboard for approval messages. - * - * type=1 (Callback): click triggers INTERACTION_CREATE, button_data = data field. - * group_id "approval": clicking one button grays out the others (mutual exclusion). - * click_limit=1: each user can only click once. - * permission.type=2: all users can interact. - */ -export function buildApprovalKeyboard( - approvalId: string, - approvalKind: ApprovalKind, - allowedDecisions: readonly ApprovalDecision[] = ["allow-once", "allow-always", "deny"], -): InlineKeyboard { - const actionPrefix = `approve:v2:${approvalKind}:${encodeURIComponent(approvalId)}`; - const makeBtn = ( - id: string, - label: string, - visitedLabel: string, - data: string, - style: 0 | 1, - ): KeyboardButton => ({ - id, - render_data: { label, visited_label: visitedLabel, style }, - action: { - type: 1, - data, - permission: { type: 2 }, - click_limit: 1, - }, - group_id: "approval", - }); - - const buttons: KeyboardButton[] = []; - if (allowedDecisions.includes("allow-once")) { - buttons.push( - makeBtn( - "allow", - "\u2705 \u5141\u8bb8\u4e00\u6b21", - "\u5df2\u5904\u7406", - `${actionPrefix}:allow-once`, - 1, - ), - ); - } - if (allowedDecisions.includes("allow-always")) { - buttons.push( - makeBtn( - "always", - "\u2b50 \u59cb\u7ec8\u5141\u8bb8", - "\u5df2\u5904\u7406", - `${actionPrefix}:allow-always`, - 1, - ), - ); - } - if (allowedDecisions.includes("deny")) { - buttons.push( - makeBtn("deny", "\u274c \u62d2\u7edd", "\u5df2\u5904\u7406", `${actionPrefix}:deny`, 0), - ); - } - - return { - content: { - rows: [ - { - buttons, - }, - ], - }, - }; -} - -// ============ Target Resolver ============ - -/** - * Extract the delivery target from a sessionKey or turnSourceTo string. - * - * Expected formats: - * agent:main:qqbot:direct:OPENID -> { type: "c2c", id: "OPENID" } - * agent:main:qqbot:c2c:OPENID -> { type: "c2c", id: "OPENID" } - * agent:main:qqbot:group:GROUPID -> { type: "group", id: "GROUPID" } - * - * Returns null if neither field matches the expected pattern. - */ -export function resolveApprovalTarget( - sessionKey: string | null | undefined, - turnSourceTo: string | null | undefined, -): ApprovalTarget | null { - const sk = sessionKey ?? turnSourceTo; - if (!sk) { - return null; - } - const m = sk.match(/qqbot:(c2c|direct|group):([A-F0-9]+)/i); - if (!m) { - return null; - } - const scope = m[1]; - const id = m[2]; - if (scope === undefined || id === undefined) { - return null; - } - const type: ChatScope = scope.toLowerCase() === "group" ? "group" : "c2c"; - return { type, id }; -} - -// ============ Interaction Parser ============ - -/** - * Parse the button_data string from an INTERACTION_CREATE event. - * - * Expected format: `approve:v2:::`. - * - * Returns null if the data does not match the approval button format. - */ -export function parseApprovalButtonData(buttonData: string): ParsedApprovalAction | null { - const m = buttonData.match(/^approve:v2:(exec|plugin):([^:]+):(allow-once|allow-always|deny)$/); - if (!m || m[0] !== buttonData) { - return null; - } - let approvalId: string; - const kind = m[1]; - const encodedId = m[2]; - const decision = m[3]; - if ( - (kind !== "exec" && kind !== "plugin") || - encodedId === undefined || - (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") - ) { - return null; - } - try { - approvalId = decodeURIComponent(encodedId); - } catch { - return null; - } - if (!approvalId) { - return null; - } - return { - approvalId, - approvalKind: kind, - decision, - }; -} diff --git a/extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts b/extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts deleted file mode 100644 index 5133065d0621..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Qqbot tests cover log helpers plugin behavior. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const platformMock = await vi.hoisted(async () => { - const fsLocal = await import("node:fs"); - const pathLocal = await import("node:path"); - return { - fs: fsLocal, - homeDir: "", - path: pathLocal, - }; -}); - -vi.mock("../../utils/platform.js", () => ({ - getHomeDir: () => platformMock.homeDir, - getQQBotDataDir: (...subPaths: string[]) => { - const dir = platformMock.path.join(platformMock.homeDir, ".openclaw", "qqbot", ...subPaths); - platformMock.fs.mkdirSync(dir, { recursive: true }); - return dir; - }, - isWindows: () => false, -})); - -import { buildBotLogsResult } from "./log-helpers.js"; - -describe("buildBotLogsResult", () => { - let tempHome: string; - - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-logs-")); - platformMock.homeDir = tempHome; - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); - fs.rmSync(tempHome, { recursive: true, force: true }); - }); - - it("suffixes same-second log exports instead of overwriting", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z")); - const logDir = path.join(tempHome, ".openclaw", "logs"); - fs.mkdirSync(logDir, { recursive: true }); - fs.writeFileSync(path.join(logDir, "gateway.log"), "line 1\nline 2\n", "utf8"); - - const first = buildBotLogsResult(); - const second = buildBotLogsResult(); - - expect(typeof first).toBe("object"); - expect(typeof second).toBe("object"); - if (!first || !second || typeof first === "string" || typeof second === "string") { - throw new Error("expected file upload results"); - } - expect(path.basename(first.filePath)).toBe("bot-logs-2026-05-05T10-11-12.txt"); - expect(path.basename(second.filePath)).toBe("bot-logs-2026-05-05T10-11-12-2.txt"); - expect(fs.readFileSync(first.filePath, "utf8")).toContain("line 1"); - expect(fs.readFileSync(second.filePath, "utf8")).toContain("line 2"); - }); - - it("completes short fs.readSync tail windows before selecting lines", () => { - const logDir = path.join(tempHome, ".openclaw", "logs"); - fs.mkdirSync(logDir, { recursive: true }); - const logFile = path.join(logDir, "gateway.log"); - const lines = Array.from( - { length: 40 }, - (_, index) => `line ${String(index + 1).padStart(2, "0")}`, - ); - const contents = `${lines.join("\n")}\n`; - fs.writeFileSync(logFile, contents, "utf8"); - - const realReadSync = fs.readSync.bind(fs) as typeof fs.readSync; - const readSpy = vi.spyOn(fs, "readSync").mockImplementation((( - fd: number, - buffer: NodeJS.ArrayBufferView, - offset: number, - length: number, - position: number | null, - ) => { - return realReadSync(fd, buffer, offset, Math.min(length, 7), position); - }) as typeof fs.readSync); - - const result = buildBotLogsResult(); - - expect(readSpy.mock.calls.length).toBeGreaterThan(1); - expect(typeof result).toBe("object"); - if (!result || typeof result === "string") { - throw new Error("expected file upload result"); - } - const exportedLogs = fs.readFileSync(result.filePath, "utf8"); - expect(exportedLogs).toContain("line 01"); - expect(exportedLogs).toContain("line 40"); - expect(exportedLogs).not.toContain("\0"); - }); -}); diff --git a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts deleted file mode 100644 index 17db71b1e698..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts +++ /dev/null @@ -1,357 +0,0 @@ -// Qqbot helper module supports log helpers behavior. -import fs from "node:fs"; -import path from "node:path"; -import { loadJsonFile } from "openclaw/plugin-sdk/json-store"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { getHomeDir, getQQBotDataDir, isWindows } from "../../utils/platform.js"; -import type { SlashCommandResult } from "../slash-commands.js"; - -/** Read user-configured log file paths from local config files. */ -function getConfiguredLogFiles(): string[] { - const homeDir = getHomeDir(); - const files: string[] = []; - for (const cli of ["openclaw", "clawdbot", "moltbot"]) { - try { - const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`); - const cfg = loadJsonFile<{ logging?: { file?: unknown } }>(cfgPath); - const logFile = cfg?.logging?.file; - if (logFile && typeof logFile === "string") { - files.push(path.resolve(logFile)); - } - break; - } catch { - // ignore - } - } - return files; -} - -/** Collect directories that may contain runtime logs across common install layouts. */ -function collectCandidateLogDirs(): string[] { - const homeDir = getHomeDir(); - const dirs = new Set(); - - const pushDir = (p?: string) => { - if (!p) { - return; - } - const normalized = path.resolve(p); - dirs.add(normalized); - }; - - const pushStateDir = (stateDir?: string) => { - if (!stateDir) { - return; - } - pushDir(stateDir); - pushDir(path.join(stateDir, "logs")); - }; - - for (const logFile of getConfiguredLogFiles()) { - pushDir(path.dirname(logFile)); - } - - for (const [key, value] of Object.entries(process.env)) { - if (!value) { - continue; - } - if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) { - pushStateDir(value); - } - } - - for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) { - pushDir(path.join(homeDir, name)); - pushDir(path.join(homeDir, name, "logs")); - } - - const searchRoots = new Set([homeDir, process.cwd(), path.dirname(process.cwd())]); - if (process.env.APPDATA) { - searchRoots.add(process.env.APPDATA); - } - if (process.env.LOCALAPPDATA) { - searchRoots.add(process.env.LOCALAPPDATA); - } - - for (const root of searchRoots) { - try { - const entries = fs.readdirSync(root, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) { - continue; - } - const base = path.join(root, entry.name); - pushDir(base); - pushDir(path.join(base, "logs")); - } - } catch { - // Ignore missing or inaccessible directories. - } - } - - if (!isWindows()) { - for (const name of ["openclaw", "clawdbot", "moltbot"]) { - pushDir(path.join("/var/log", name)); - } - } - - const tmpRoots = new Set(); - if (isWindows()) { - tmpRoots.add("C:\\tmp"); - if (process.env.TEMP) { - tmpRoots.add(process.env.TEMP); - } - if (process.env.TMP) { - tmpRoots.add(process.env.TMP); - } - if (process.env.LOCALAPPDATA) { - tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp")); - } - } else { - tmpRoots.add("/tmp"); - } - for (const tmpRoot of tmpRoots) { - for (const name of ["openclaw", "clawdbot", "moltbot"]) { - pushDir(path.join(tmpRoot, name)); - } - } - - return Array.from(dirs); -} - -type LogCandidate = { - filePath: string; - sourceDir: string; - mtimeMs: number; -}; - -function addCollisionSuffix(filePath: string, suffix: number): string { - const ext = path.extname(filePath); - const baseName = path.basename(filePath, ext); - return path.join(path.dirname(filePath), `${baseName}-${suffix}${ext}`); -} - -function writeNewTextFileSync(filePath: string, contents: string): string { - for (let suffix = 1; suffix <= 100; suffix++) { - const candidate = suffix === 1 ? filePath : addCollisionSuffix(filePath, suffix); - try { - fs.writeFileSync(candidate, contents, { encoding: "utf8", flag: "wx" }); - return candidate; - } catch (error) { - if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") { - continue; - } - throw error; - } - } - throw new Error(`Could not find an unused log export filename near ${filePath}`); -} - -function collectRecentLogFiles(logDirs: string[]): LogCandidate[] { - const candidates: LogCandidate[] = []; - const dedupe = new Set(); - - const pushFile = (filePath: string, sourceDir: string) => { - const normalized = path.resolve(filePath); - if (dedupe.has(normalized)) { - return; - } - try { - const stat = fs.statSync(normalized); - if (!stat.isFile()) { - return; - } - dedupe.add(normalized); - candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs }); - } catch { - // Ignore missing or inaccessible files. - } - }; - - for (const logFile of getConfiguredLogFiles()) { - pushFile(logFile, path.dirname(logFile)); - } - - for (const dir of logDirs) { - pushFile(path.join(dir, "gateway.log"), dir); - pushFile(path.join(dir, "gateway.err.log"), dir); - pushFile(path.join(dir, "openclaw.log"), dir); - pushFile(path.join(dir, "clawdbot.log"), dir); - pushFile(path.join(dir, "moltbot.log"), dir); - - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile()) { - continue; - } - if (!/\.(log|txt)$/i.test(entry.name)) { - continue; - } - if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name)) { - continue; - } - pushFile(path.join(dir, entry.name), dir); - } - } catch { - // Ignore missing or inaccessible directories. - } - } - - candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); - return candidates; -} - -/** - * Read the last N lines of a file without loading the entire file into memory. - */ -function tailFileLines( - filePath: string, - maxLines: number, -): { tail: string[]; totalFileLines: number } { - const fd = fs.openSync(filePath, "r"); - try { - const stat = fs.fstatSync(fd); - const fileSize = stat.size; - if (fileSize === 0) { - return { tail: [], totalFileLines: 0 }; - } - - const CHUNK_SIZE = 64 * 1024; - const chunks: Buffer[] = []; - let bytesRead = 0; - let position = fileSize; - let newlineCount = 0; - - while (position > 0 && newlineCount <= maxLines) { - const readSize = Math.min(CHUNK_SIZE, position); - position -= readSize; - const buf = Buffer.alloc(readSize); - let actualRead = 0; - while (actualRead < readSize) { - const justRead = fs.readSync( - fd, - buf, - actualRead, - readSize - actualRead, - position + actualRead, - ); - if (justRead === 0) { - throw new Error(`Could not complete log read for ${filePath}`); - } - actualRead += justRead; - } - - chunks.unshift(buf); - bytesRead += actualRead; - - for (let i = 0; i < actualRead; i++) { - if (buf[i] === 0x0a) { - newlineCount++; - } - } - } - - const tailContent = Buffer.concat(chunks).toString("utf8"); - const allLines = tailContent.split("\n"); - - const tail = allLines.slice(-maxLines); - - let totalFileLines: number; - if (bytesRead >= fileSize) { - totalFileLines = allLines.length; - } else { - const avgBytesPerLine = bytesRead / Math.max(allLines.length, 1); - totalFileLines = Math.round(fileSize / avgBytesPerLine); - } - - return { tail, totalFileLines }; - } finally { - fs.closeSync(fd); - } -} - -/** - * Build the /bot-logs result: collect recent log files, write them to a temp file. - */ -export function buildBotLogsResult(): SlashCommandResult { - const logDirs = collectCandidateLogDirs(); - const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4); - - if (recentFiles.length === 0) { - const existingDirs = logDirs.filter((d) => { - try { - return fs.existsSync(d); - } catch { - return false; - } - }); - const searched = - existingDirs.length > 0 - ? existingDirs.map((d) => ` • ${d}`).join("\n") - : logDirs - .slice(0, 6) - .map((d) => ` • ${d}`) - .join("\n") + (logDirs.length > 6 ? `\n …以及另外 ${logDirs.length - 6} 个路径` : ""); - return [ - `⚠️ 未找到日志文件`, - ``, - `已搜索以下${existingDirs.length > 0 ? "存在的" : ""}路径:`, - searched, - ``, - `💡 如果日志存放在自定义路径,请在配置中添加:`, - ` "logging": { "file": "/path/to/your/logfile.log" }`, - ].join("\n"); - } - - const lines: string[] = []; - let totalIncluded = 0; - let totalOriginal = 0; - let truncatedCount = 0; - const MAX_LINES_PER_FILE = 1000; - for (const logFile of recentFiles) { - try { - const { tail, totalFileLines } = tailFileLines(logFile.filePath, MAX_LINES_PER_FILE); - if (tail.length > 0) { - const fileName = path.basename(logFile.filePath); - lines.push( - `\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`, - ); - lines.push(`from: ${logFile.sourceDir}`); - lines.push(...tail); - totalIncluded += tail.length; - totalOriginal += totalFileLines; - if (totalFileLines > MAX_LINES_PER_FILE) { - truncatedCount++; - } - } - } catch { - lines.push(`[Failed to read ${path.basename(logFile.filePath)}]`); - } - } - - if (lines.length === 0) { - return `⚠️ 找到了日志文件,但无法读取。请检查文件权限。`; - } - - const tmpDir = getQQBotDataDir("downloads"); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const tmpFile = writeNewTextFileSync( - path.join(tmpDir, `bot-logs-${timestamp}.txt`), - lines.join("\n"), - ); - - const fileCount = recentFiles.length; - const topSources = uniqueStrings(recentFiles.map((item) => item.sourceDir)).slice(0, 3); - let summaryText = `共 ${fileCount} 个日志文件,包含 ${totalIncluded} 行内容`; - if (truncatedCount > 0) { - summaryText += `(其中 ${truncatedCount} 个文件已截断为最后 ${MAX_LINES_PER_FILE} 行,总计原始 ${totalOriginal} 行)`; - } - return { - text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`, - filePath: tmpFile, - }; -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-all.ts b/extensions/qqbot/src/engine/commands/builtin/register-all.ts deleted file mode 100644 index 2417fe433971..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-all.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Qqbot plugin module implements register all behavior. -import type { SlashCommandRegistry } from "../slash-commands.js"; -import { registerApproveCommands } from "./register-approve.js"; -import { registerBasicBotCommands } from "./register-basic.js"; -import { registerClearStorageCommands } from "./register-clear-storage.js"; -import { registerGroupAllwaysCommand } from "./register-group-allways.js"; -import { registerLogCommands } from "./register-logs.js"; -import { registerStreamingCommands } from "./register-streaming.js"; - -/** - * Register all built-in slash commands on the shared registry instance. - */ -export function registerBuiltinSlashCommands(registry: SlashCommandRegistry): void { - registerBasicBotCommands(registry); - registerLogCommands(registry); - registerClearStorageCommands(registry); - registerStreamingCommands(registry); - registerApproveCommands(registry); - registerGroupAllwaysCommand(registry); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-approve.ts b/extensions/qqbot/src/engine/commands/builtin/register-approve.ts deleted file mode 100644 index 911901d98cf6..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-approve.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Qqbot plugin module implements register approve behavior. -import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js"; -import type { SlashCommandRegistry } from "../slash-commands.js"; -import { getApproveRuntimeGetter } from "./state.js"; - -export function registerApproveCommands(registry: SlashCommandRegistry): void { - registry.register({ - name: "bot-approve", - description: "管理命令执行审批配置", - requireAuth: true, - c2cOnly: true, - usage: [ - `/bot-approve 查看操作指引`, - `/bot-approve on 开启审批(白名单模式,推荐)`, - `/bot-approve off 关闭审批,命令直接执行`, - `/bot-approve always 始终审批,每次执行都需审批`, - `/bot-approve reset 恢复框架默认值`, - `/bot-approve status 查看当前审批配置`, - ].join("\n"), - handler: async (ctx) => { - const arg = ctx.args.trim().toLowerCase(); - - let runtime: ReturnType>; - try { - const getter = getApproveRuntimeGetter(); - if (!getter) { - throw new Error("runtime not available"); - } - runtime = getter(); - } catch { - return [ - `🔐 命令执行审批配置`, - ``, - `❌ 当前环境不支持在线配置修改,请通过 CLI 手动配置:`, - ``, - `\`\`\`shell`, - `# 开启审批(白名单模式)`, - `openclaw config set tools.exec.security allowlist`, - `openclaw config set tools.exec.ask on-miss`, - ``, - `# 关闭审批`, - `openclaw config set tools.exec.security full`, - `openclaw config set tools.exec.ask off`, - `\`\`\``, - ].join("\n"); - } - - const configApi = runtime.config; - - const loadExecConfig = () => { - const cfg = configApi.current(); - const tools = ((cfg as Record).tools ?? {}) as Record; - const exec = (tools.exec ?? {}) as Record; - const security = typeof exec.security === "string" ? exec.security : "deny"; - const ask = typeof exec.ask === "string" ? exec.ask : "on-miss"; - return { security, ask }; - }; - - const writeExecConfig = async (security: string, ask: string) => { - const cfg = structuredClone(configApi.current() as Record); - const tools = (cfg.tools ?? {}) as Record; - const exec = (tools.exec ?? {}) as Record; - exec.security = security; - exec.ask = ask; - tools.exec = exec; - cfg.tools = tools; - await configApi.replaceConfigFile({ nextConfig: cfg, afterWrite: { mode: "auto" } }); - }; - - const formatStatus = (security: string, ask: string) => { - const secIcon = security === "full" ? "🟢" : security === "allowlist" ? "🟡" : "🔴"; - const askIcon = ask === "off" ? "🟢" : ask === "always" ? "🔴" : "🟡"; - return [ - `🔐 当前审批配置`, - ``, - `${secIcon} 安全模式 (security): **${security}**`, - `${askIcon} 审批模式 (ask): **${ask}**`, - ``, - security === "deny" - ? `⚠️ 当前为 deny 模式,所有命令执行被拒绝` - : security === "full" && ask === "off" - ? `✅ 所有命令无需审批直接执行` - : security === "allowlist" && ask === "on-miss" - ? `🛡️ 白名单命令直接执行,其余需审批` - : ask === "always" - ? `🔒 每次命令执行都需要人工审批` - : `ℹ️ security=${security}, ask=${ask}`, - ].join("\n"); - }; - - if (!arg) { - return [ - `🔐 命令执行审批配置`, - ``, - ` 开启审批(白名单模式)`, - ` 关闭审批`, - ` 严格模式`, - ` 恢复默认`, - ` 查看当前配置`, - ].join("\n"); - } - - if (arg === "status") { - const { security, ask } = loadExecConfig(); - return [ - formatStatus(security, ask), - ``, - ` 开启审批`, - ` 关闭审批`, - ` 严格模式`, - ` 恢复默认`, - ].join("\n"); - } - - if (arg === "on") { - try { - await writeExecConfig("allowlist", "on-miss"); - return [ - `✅ 审批已开启`, - ``, - `• security = allowlist(白名单模式)`, - `• ask = on-miss(未命中白名单时需审批)`, - ``, - `已批准的命令自动加入白名单,下次直接执行。`, - ].join("\n"); - } catch (err: unknown) { - return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; - } - } - - if (arg === "off") { - try { - await writeExecConfig("full", "off"); - return [ - `✅ 审批已关闭`, - ``, - `• security = full(允许所有命令)`, - `• ask = off(不需要审批)`, - ``, - `⚠️ 所有命令将直接执行,不会弹出审批确认。`, - ].join("\n"); - } catch (err: unknown) { - return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; - } - } - - if (arg === "always" || arg === "strict") { - try { - await writeExecConfig("allowlist", "always"); - return [ - `✅ 已切换为严格审批模式`, - ``, - `• security = allowlist`, - `• ask = always(每次执行都需审批)`, - ``, - `每个命令都会弹出审批按钮,需手动确认。`, - ].join("\n"); - } catch (err: unknown) { - return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; - } - } - - if (arg === "reset") { - try { - const cfg = structuredClone(configApi.current() as Record); - const tools = (cfg.tools ?? {}) as Record; - const exec = (tools.exec ?? {}) as Record; - delete exec.security; - delete exec.ask; - if (Object.keys(exec).length === 0) { - delete tools.exec; - } else { - tools.exec = exec; - } - if (Object.keys(tools).length === 0) { - delete cfg.tools; - } else { - cfg.tools = tools; - } - await configApi.replaceConfigFile({ nextConfig: cfg, afterWrite: { mode: "auto" } }); - return [ - `✅ 审批配置已重置`, - ``, - `已移除 tools.exec.security 和 tools.exec.ask`, - `框架将使用默认值(security=deny, ask=on-miss)`, - ``, - `如需开启命令执行,请使用 /bot-approve on`, - ].join("\n"); - } catch (err: unknown) { - return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; - } - } - - return [ - `❌ 未知参数: ${arg}`, - ``, - `可用选项: on | off | always | reset | status`, - `输入 /bot-approve ? 查看详细用法`, - ].join("\n"); - }, - }); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-basic.ts b/extensions/qqbot/src/engine/commands/builtin/register-basic.ts deleted file mode 100644 index 41342fe8e488..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-basic.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Qqbot plugin module implements register basic behavior. -import type { SlashCommandRegistry } from "../slash-commands.js"; -import { getPluginVersionString, resolveRuntimeServiceVersion } from "./state.js"; - -const QQBOT_PLUGIN_GITHUB_URL = "https://github.com/openclaw/openclaw/tree/main/extensions/qqbot"; -const QQBOT_UPGRADE_GUIDE_URL = "https://q.qq.com/qqbot/openclaw/upgrade.html"; - -export function registerBasicBotCommands(registry: SlashCommandRegistry): void { - registry.register({ - name: "bot-help", - description: "查看所有内置命令", - usage: [ - `/bot-help`, - ``, - `查看所有可用的 QQBot 内置命令及其简要说明。`, - `在命令后追加 ? 可查看详细用法。`, - ].join("\n"), - handler: (ctx) => { - const isGroup = ctx.type === "group"; - const lines = [`### QQBot 内置命令`, ``]; - for (const [name, cmd] of registry.getAllCommands()) { - if (isGroup && cmd.c2cOnly) { - continue; - } - lines.push(` ${cmd.description}`); - } - lines.push(``, `> 插件版本 v${getPluginVersionString()}`); - return lines.join("\n"); - }, - }); - - registry.register({ - name: "bot-me", - description: "查看当前发送者的账号ID", - c2cOnly: true, - usage: [`/bot-me`, ``, `显示当前发送者的账号ID`].join("\n"), - handler: (ctx) => { - return `你的账号ID:\`${ctx.senderId}\``; - }, - }); - - registry.register({ - name: "bot-ping", - description: "测试 OpenClaw 与 QQ 之间的网络延迟", - usage: [ - `/bot-ping`, - ``, - `测试当前 OpenClaw 宿主机与 QQ 服务器之间的网络延迟。`, - `返回网络传输耗时和插件处理耗时。`, - ].join("\n"), - handler: (ctx) => { - const now = Date.now(); - const eventTime = new Date(ctx.eventTimestamp).getTime(); - if (Number.isNaN(eventTime)) { - return `✅ pong!`; - } - const totalMs = now - eventTime; - const qqToPlugin = ctx.receivedAt - eventTime; - const pluginProcess = now - ctx.receivedAt; - const lines = [ - `✅ pong!`, - ``, - `⏱ 延迟:${totalMs}ms`, - ` ├ 网络传输:${qqToPlugin}ms`, - ` └ 插件处理:${pluginProcess}ms`, - ]; - return lines.join("\n"); - }, - }); - - registry.register({ - name: "bot-version", - description: "查看 QQBot 插件版本和 OpenClaw 框架版本", - c2cOnly: true, - usage: [`/bot-version`, ``, `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`].join("\n"), - handler: async () => { - const frameworkVersion = resolveRuntimeServiceVersion(); - const ver = getPluginVersionString(); - const lines = [ - `🦞 OpenClaw 框架版本:${frameworkVersion}`, - `🤖 QQBot 插件版本:v${ver}`, - `🌟 官方 GitHub 仓库:[点击前往](${QQBOT_PLUGIN_GITHUB_URL})`, - ]; - return lines.join("\n"); - }, - }); - - registry.register({ - name: "bot-upgrade", - description: "查看 QQBot 升级指引", - c2cOnly: true, - usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"), - handler: () => - [`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"), - }); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts b/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts deleted file mode 100644 index acfe9af3a164..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-clear-storage.ts +++ /dev/null @@ -1,194 +0,0 @@ -// Qqbot plugin module implements register clear storage behavior. -import fs from "node:fs"; -import path from "node:path"; -import { formatByteSize } from "openclaw/plugin-sdk/number-runtime"; -import { getQQBotMediaPath } from "../../utils/platform.js"; -import type { SlashCommandRegistry } from "../slash-commands.js"; - -function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] { - const files: { filePath: string; size: number }[] = []; - if (!fs.existsSync(dirPath)) { - return files; - } - const walk = (dir: string) => { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.isFile()) { - try { - const stat = fs.statSync(fullPath); - files.push({ filePath: fullPath, size: stat.size }); - } catch { - // Skip inaccessible files. - } - } - } - }; - walk(dirPath); - files.sort((a, b) => b.size - a.size); - return files; -} - -function formatBytes(bytes: number): string { - return formatByteSize(bytes, { - style: "legacy-binary", - maxUnit: "giga", - separator: " ", - fractionDigits: (_value, unit) => (unit === "byte" ? null : 1), - }); -} - -function removeEmptyDirs(dirPath: string): void { - if (!fs.existsSync(dirPath)) { - return; - } - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dirPath, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (entry.isDirectory()) { - removeEmptyDirs(path.join(dirPath, entry.name)); - } - } - try { - const remaining = fs.readdirSync(dirPath); - if (remaining.length === 0) { - fs.rmdirSync(dirPath); - } - } catch { - // Directory may be in use, skip. - } -} - -const CLEAR_STORAGE_MAX_DISPLAY = 10; - -/** - * Resolve the canonical QQBot downloads directory. - * - * All inbound attachments and outbound fallback downloads are stored directly - * under `~/.openclaw/media/qqbot/downloads/` without appId subdivision. - * The clear-storage command therefore cleans the entire downloads root. - */ -function resolveQqbotDownloadsDir(): string { - return getQQBotMediaPath("downloads"); -} - -function clearQqbotDownloads(targetDir: string): string { - const files = scanDirectoryFiles(targetDir); - - if (files.length === 0) { - return `✅ 目录已为空,无需清理`; - } - - let deletedCount = 0; - let deletedSize = 0; - let failedCount = 0; - - for (const f of files) { - try { - fs.unlinkSync(f.filePath); - deletedCount++; - deletedSize += f.size; - } catch { - failedCount++; - } - } - - try { - removeEmptyDirs(targetDir); - } catch { - // Non-critical, silently ignore. - } - - if (failedCount === 0) { - return [ - `✅ 清理成功`, - ``, - `已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`, - ].join("\n"); - } - - return [ - `⚠️ 部分清理完成`, - ``, - `已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}),${failedCount} 个文件删除失败。`, - ].join("\n"); -} - -export function registerClearStorageCommands(registry: SlashCommandRegistry): void { - registry.register({ - name: "bot-clear-storage", - description: "清理通过 QQBot 对话产生的下载文件,释放主机磁盘空间", - requireAuth: true, - c2cOnly: true, - usage: [ - `/bot-clear-storage`, - ``, - `扫描 QQBot 下载目录下的所有文件并列出明细。`, - `确认后执行删除,释放主机磁盘空间。`, - ``, - `/bot-clear-storage --force 确认执行清理`, - ``, - `⚠️ 仅在私聊中可用。`, - ].join("\n"), - handler: async (ctx) => { - const isForce = ctx.args.trim() === "--force"; - const targetDir = resolveQqbotDownloadsDir(); - const displayDir = `~/.openclaw/media/qqbot/downloads`; - - if (!isForce) { - const files = scanDirectoryFiles(targetDir); - - if (files.length === 0) { - return [`✅ 当前没有需要清理的文件`, ``, `目录 \`${displayDir}\` 为空或不存在。`].join( - "\n", - ); - } - - const totalSize = files.reduce((sum, f) => sum + f.size, 0); - const lines: string[] = [ - `即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}。`, - ``, - `目录文件概况:`, - ]; - - const displayFiles = files.slice(0, CLEAR_STORAGE_MAX_DISPLAY); - for (const f of displayFiles) { - const relativePath = path.relative(targetDir, f.filePath).replace(/\\/g, "/"); - lines.push(`${relativePath} (${formatBytes(f.size)})`, ``, ``); - } - if (files.length > CLEAR_STORAGE_MAX_DISPLAY) { - lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)})]`, ``); - } - - lines.push( - ``, - `---`, - ``, - `确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中 AI 无法再找回相关文件。`, - `‼️ 点击指令确认删除`, - ``, - ); - - return lines.join("\n"); - } - - const run = async () => clearQqbotDownloads(targetDir); - if (!ctx.runIngressEffectOnce) { - return await run(); - } - const outcome = await ctx.runIngressEffectOnce({ effect: "clear-storage", run }); - return outcome.kind === "executed" ? outcome.value : `✅ 此清理请求已经处理,无需重复清理`; - }, - }); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-group-allways.test.ts b/extensions/qqbot/src/engine/commands/builtin/register-group-allways.test.ts deleted file mode 100644 index a2ccd633154d..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-group-allways.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -// Qqbot tests cover group-allways command plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { QueuedMessage } from "../../gateway/message-queue.js"; -import type { GatewayAccount } from "../../gateway/types.js"; -import { sendText } from "../../messaging/sender.js"; -import { trySlashCommand } from "../slash-command-handler.js"; -import { installCommandRuntime } from "../slash-command-test-support.js"; - -vi.mock("../../messaging/outbound.js", () => ({ - sendDocument: vi.fn(async () => undefined), -})); - -vi.mock("../../messaging/sender.js", () => ({ - accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })), - buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })), - sendText: vi.fn(async () => undefined), -})); - -type WrittenQQBotConfigWithAllways = { - defaultRequireMention?: unknown; - accounts?: Record; -}; - -type RunCommandParams = { - account?: GatewayAccount; - arg?: string; - config?: OpenClawConfig; -}; - -const queueSnapshot = { - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, -}; - -function createGroupAllwaysMessage(arg = ""): QueuedMessage { - return { - type: "c2c", - senderId: "TRUSTED_OPENID", - content: `/bot-group-allways ${arg}`.trim(), - messageId: "msg-1", - timestamp: "2026-01-01T00:00:00.000Z", - }; -} - -function createAccount(accountId = "default", overrides?: Record): GatewayAccount { - return { - accountId, - appId: "app", - clientSecret: "", - markdownSupport: true, - config: { - allowFrom: ["*"], - ...(accountId === "default" ? { defaultRequireMention: true } : {}), - ...overrides, - }, - }; -} - -function createConfig(qqbot: NonNullable["qqbot"]): OpenClawConfig { - return { - commands: { - allowFrom: { qqbot: ["TRUSTED_OPENID"] }, - }, - channels: { qqbot }, - }; -} - -function getAllwaysConfig( - write: OpenClawConfig | undefined, -): WrittenQQBotConfigWithAllways | undefined { - return write?.channels?.qqbot as WrittenQQBotConfigWithAllways | undefined; -} - -async function runGroupAllwaysCommand({ - account = createAccount(), - arg = "", - config = createConfig({ allowFrom: ["*"], defaultRequireMention: true }), -}: RunCommandParams = {}) { - const writes: OpenClawConfig[] = []; - installCommandRuntime(config, writes); - - const result = await trySlashCommand(createGroupAllwaysMessage(arg), { - account, - cfg: config, - getMessagePeerId: () => "c2c:TRUSTED_OPENID", - getQueueSnapshot: () => queueSnapshot, - }); - - return { - result, - writes, - reply: vi.mocked(sendText).mock.calls.at(0)?.[1] ?? "", - }; -} - -describe("bot-group-allways command", () => { - beforeEach(() => { - vi.mocked(sendText).mockClear(); - }); - - it.each([ - { - defaultRequireMention: true, - expectedReply: "仅被 @ 时回复", - }, - { - defaultRequireMention: false, - expectedReply: "自主判断何时发言", - }, - ])("shows current status for defaultRequireMention=$defaultRequireMention", async (testCase) => { - const config = createConfig({ - allowFrom: ["*"], - defaultRequireMention: testCase.defaultRequireMention, - }); - - const { result, reply, writes } = await runGroupAllwaysCommand({ - account: createAccount("default", { - defaultRequireMention: testCase.defaultRequireMention, - }), - config, - }); - - expect(result).toBe("handled"); - expect(writes).toHaveLength(0); - expect(reply).toContain(testCase.expectedReply); - }); - - it.each([ - { - arg: "on", - currentDefaultRequireMention: true, - expectedDefaultRequireMention: false, - expectedReply: "**on**", - }, - { - arg: "off", - currentDefaultRequireMention: false, - expectedDefaultRequireMention: true, - expectedReply: "**off**", - }, - ])("writes defaultRequireMention for default account when toggled $arg", async (testCase) => { - const config = createConfig({ - allowFrom: ["*"], - defaultRequireMention: testCase.currentDefaultRequireMention, - }); - - const { result, reply, writes } = await runGroupAllwaysCommand({ - account: createAccount("default", { - defaultRequireMention: testCase.currentDefaultRequireMention, - }), - arg: testCase.arg, - config, - }); - - expect(result).toBe("handled"); - expect(writes).toHaveLength(1); - expect(getAllwaysConfig(writes[0])?.defaultRequireMention).toBe( - testCase.expectedDefaultRequireMention, - ); - expect(reply).toContain(testCase.expectedReply); - }); - - it("writes to accounts.{accountId}.defaultRequireMention for named accounts", async () => { - const { result, writes } = await runGroupAllwaysCommand({ - account: createAccount("bot-a"), - arg: "on", - config: createConfig({ - allowFrom: ["*"], - accounts: { - "bot-a": {}, - }, - }), - }); - - expect(result).toBe("handled"); - expect(writes).toHaveLength(1); - expect(getAllwaysConfig(writes[0])?.accounts?.["bot-a"]?.defaultRequireMention).toBe(false); - }); - - it("returns no-op when toggling to same state", async () => { - const { result, reply, writes } = await runGroupAllwaysCommand({ - arg: "off", - config: createConfig({ - allowFrom: ["*"], - defaultRequireMention: true, - }), - }); - - expect(result).toBe("handled"); - expect(writes).toHaveLength(0); - expect(reply).toContain("无需操作"); - }); - - it("returns error for invalid argument", async () => { - const { result, reply, writes } = await runGroupAllwaysCommand({ - arg: "invalid", - config: createConfig({ - allowFrom: ["*"], - }), - }); - - expect(result).toBe("handled"); - expect(writes).toHaveLength(0); - expect(reply).toContain("参数错误"); - }); -}); diff --git a/extensions/qqbot/src/engine/commands/builtin/register-group-allways.ts b/extensions/qqbot/src/engine/commands/builtin/register-group-allways.ts deleted file mode 100644 index af9f96e9d341..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-group-allways.ts +++ /dev/null @@ -1,131 +0,0 @@ -// 导入运行时配置缓存清除函数,确保配置更新后 getRuntimeConfig() 能读取到最新值 -import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot"; -// Qqbot plugin module implements register group allways behavior. -import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js"; -import type { SlashCommandRegistry } from "../slash-commands.js"; -import { - getApproveRuntimeGetter, - getPluginVersionString, - resolveRuntimeServiceVersion, -} from "./state.js"; - -export function registerGroupAllwaysCommand(registry: SlashCommandRegistry): void { - registry.register({ - name: "bot-group-allways", - description: "修改群消息默认响应模式", - requireAuth: true, - c2cOnly: true, - usage: [ - `/bot-group-allways on AI 自主判断何时发言(无需 @)`, - `/bot-group-allways off 仅在被 @ 时回复`, - `/bot-group-allways 查看当前设置`, - ``, - `设为 on 后,AI 会自主判断每条消息是否需要回复(无需 @)。`, - `仍可通过 groups.{groupId}.requireMention 对单个群覆盖。`, - ``, - `优先级:具体群配置 > 通配符 "*" > defaultRequireMention(本指令)> 默认 true`, - ].join("\n"), - handler: async (ctx) => { - const arg = ctx.args.trim().toLowerCase(); - - // 读取当前 defaultRequireMention 状态 - const currentVal = ctx.accountConfig?.defaultRequireMention; - const currentRequireMention = currentVal ?? true; // 未设置时硬编码默认为 true - - // 无参数:查看当前状态 - if (!arg) { - return [ - `🤖 群自主发言状态:${currentRequireMention ? "❌ 仅被 @ 时回复" : "✅ 自主判断何时发言"}`, - `使用 设为自主发言`, - `使用 设为仅被 @ 时回复`, - ].join("\n"); - } - - if (arg !== "on" && arg !== "off") { - return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-group-allways on`; - } - - const newRequireMention = arg === "off"; // on=自主发言(requireMention=false), off=仅被@时回复(requireMention=true) - - // 如果状态没变,直接返回 - if (newRequireMention === currentRequireMention) { - return `🤖 群自主发言已经是"${arg}"状态,无需操作`; - } - - // 获取运行时配置 API - let runtime: ReturnType>; - try { - const getter = getApproveRuntimeGetter(); - if (!getter) { - throw new Error("runtime not available"); - } - runtime = getter(); - } catch { - const fwVer = resolveRuntimeServiceVersion(); - const ver = getPluginVersionString(); - return [ - `❌ 当前版本不支持该指令`, - ``, - `🦞框架版本:${fwVer}`, - `🤖QQBot 插件版本:v${ver}`, - ``, - `可通过以下命令手动设置:`, - ``, - `\`\`\`shell`, - `# 设为 AI 自主判断何时发言(defaultRequireMention=false)`, - `openclaw config set channels.qqbot.defaultRequireMention false`, - `# 或设为仅被 @ 时回复(defaultRequireMention=true)`, - `openclaw config set channels.qqbot.defaultRequireMention true`, - `\`\`\``, - ].join("\n"); - } - - try { - const configApi = runtime.config; - const currentCfg = structuredClone(configApi.current() as Record); - const qqbot = ((currentCfg.channels ?? {}) as Record).qqbot as - | Record - | undefined; - - if (!qqbot) { - return `❌ 配置文件中未找到 qqbot 通道配置`; - } - - const accountId = ctx.accountId; - const isNamedAccount = - accountId !== "default" && - Boolean( - (qqbot.accounts as Record> | undefined)?.[accountId], - ); - - if (isNamedAccount) { - // 命名账户:更新 accounts.{accountId}.defaultRequireMention - const accounts = (qqbot.accounts as Record>) ?? {}; - const nextAccounts = { ...accounts }; - const acct = { ...nextAccounts[accountId] }; - acct.defaultRequireMention = newRequireMention; - nextAccounts[accountId] = acct; - qqbot.accounts = nextAccounts; - } else { - // 默认账户:更新 qqbot.defaultRequireMention - qqbot.defaultRequireMention = newRequireMention; - } - - await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } }); - - // 清除运行时配置缓存,确保 getRuntimeConfig() 下次调用时重新加载最新配置 - clearRuntimeConfigSnapshot(); - - return [ - `✅ 群自主发言已设置为 ${newRequireMention ? "**off**(仅被 @ 时回复)" : "**on**(AI 自主判断何时发言)"}`, - ``, - newRequireMention - ? `仅在被 @ 机器人才会回复。` - : `AI 将自主判断群消息是否需要回复,无需被 @ 即可发言。`, - ].join("\n"); - } catch (err: unknown) { - return `❌ 配置写入失败: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-logs.ts b/extensions/qqbot/src/engine/commands/builtin/register-logs.ts deleted file mode 100644 index fbca0cd84671..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-logs.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Qqbot plugin module implements register logs behavior. -import type { SlashCommandRegistry } from "../slash-commands.js"; -import { buildBotLogsResult } from "./log-helpers.js"; - -export function registerLogCommands(registry: SlashCommandRegistry): void { - registry.register({ - name: "bot-logs", - description: "导出本地日志文件", - requireAuth: true, - c2cOnly: true, - usage: [ - `/bot-logs`, - ``, - `导出最近的 OpenClaw 日志文件(最多 4 个文件)。`, - `每个文件只保留最后 1000 行,并作为附件返回。`, - ].join("\n"), - handler: () => { - return buildBotLogsResult(); - }, - }); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts b/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts deleted file mode 100644 index b85856f64a88..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/register-streaming.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Qqbot plugin module implements register streaming behavior. -import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js"; -import type { SlashCommandRegistry } from "../slash-commands.js"; -import { - getApproveRuntimeGetter, - getPluginVersionString, - resolveRuntimeServiceVersion, -} from "./state.js"; - -function isStreamingConfigEnabled(streaming: unknown): boolean { - if (!streaming || typeof streaming !== "object") { - return false; - } - const o = streaming as Record; - if (o.nativeTransport === true) { - return true; - } - return o.mode !== "off"; -} - -export function registerStreamingCommands(registry: SlashCommandRegistry): void { - registry.register({ - name: "bot-streaming", - description: "一键开关流式消息", - requireAuth: true, - c2cOnly: true, - usage: [ - `/bot-streaming on 开启流式消息`, - `/bot-streaming off 关闭流式消息`, - `/bot-streaming 查看当前流式消息状态`, - ``, - `开启后,AI 的回复会以流式形式逐步显示(打字机效果)。`, - `注意:仅 C2C(私聊)支持流式消息。`, - ].join("\n"), - handler: async (ctx) => { - const arg = ctx.args.trim().toLowerCase(); - const currentOn = isStreamingConfigEnabled(ctx.accountConfig?.streaming); - - if (!arg) { - return [ - `📡 流式消息状态:${currentOn ? "✅ 已开启" : "❌ 已关闭"}`, - ``, - `使用 开启`, - `使用 关闭`, - ].join("\n"); - } - - if (arg !== "on" && arg !== "off") { - return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-streaming on`; - } - - const wantOn = arg === "on"; - if (wantOn === currentOn) { - return `📡 流式消息已经是${wantOn ? "开启" : "关闭"}状态,无需操作`; - } - - let runtime: ReturnType>; - try { - const getter = getApproveRuntimeGetter(); - if (!getter) { - throw new Error("runtime not available"); - } - runtime = getter(); - } catch { - const fwVer = resolveRuntimeServiceVersion(); - const ver = getPluginVersionString(); - return [ - `❌ 当前版本不支持该指令`, - ``, - `🦞框架版本:${fwVer}`, - `🤖QQBot 插件版本:v${ver}`, - ``, - `可通过以下命令手动开启流式消息:`, - ``, - `\`\`\`shell`, - `# 1. 开启流式消息`, - `openclaw config set channels.qqbot.streaming.nativeTransport true`, - ``, - `# 2. 重启网关使配置生效`, - `openclaw gateway restart`, - `\`\`\``, - ].join("\n"); - } - - try { - const configApi = runtime.config; - const currentCfg = structuredClone(configApi.current() as Record); - const qqbot = ((currentCfg.channels ?? {}) as Record).qqbot as - | Record - | undefined; - - if (!qqbot) { - return `❌ 配置文件中未找到 qqbot 通道配置`; - } - - const accountId = ctx.accountId; - // Nested-only spelling: "on" is the retired `streaming: true` shape - // (block streaming + official C2C stream), "off" disables both. - const newVal: unknown = wantOn - ? { mode: "partial", nativeTransport: true } - : { mode: "off" }; - - if (accountId !== "default") { - const prevAccounts = - (qqbot.accounts as Record> | undefined) ?? {}; - const nextAccounts = { ...prevAccounts }; - const acct = { ...nextAccounts[accountId] }; - acct.streaming = newVal; - nextAccounts[accountId] = acct; - qqbot.accounts = nextAccounts; - } else { - qqbot.streaming = newVal; - const accs = qqbot.accounts as Record> | undefined; - if (accs?.default && typeof accs.default === "object") { - const nextAccs = { ...accs }; - const def = { ...accs.default, streaming: newVal }; - nextAccs.default = def; - qqbot.accounts = nextAccs; - } - } - - await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } }); - - return [ - `✅ 流式消息已${wantOn ? "开启" : "关闭"}`, - ``, - wantOn ? `AI 的回复将以流式形式逐步显示(仅私聊生效)。` : `AI 的回复将恢复为完整发送。`, - ].join("\n"); - } catch (err: unknown) { - return `❌ 配置写入失败: ${err instanceof Error ? err.message : String(err)}`; - } - }, - }); -} diff --git a/extensions/qqbot/src/engine/commands/builtin/state.ts b/extensions/qqbot/src/engine/commands/builtin/state.ts deleted file mode 100644 index 65c5d66cdbf6..000000000000 --- a/extensions/qqbot/src/engine/commands/builtin/state.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Qqbot plugin module implements state behavior. -import type { ApproveRuntimeGetter, CommandsPort } from "../../adapter/commands.port.js"; - -let resolveVersionGetter: () => string = () => "unknown"; -let approveRuntimeGetter: ApproveRuntimeGetter | null = null; -let PLUGIN_VERSION = "unknown"; - -/** - * Initialize command dependencies from the EngineAdapters.commands port. - * Called once by the bridge layer during startup. - */ -export function initSlashCommandDeps(port: CommandsPort): void { - resolveVersionGetter = port.resolveVersion; - PLUGIN_VERSION = port.pluginVersion; - approveRuntimeGetter = port.approveRuntimeGetter ?? null; -} - -export function resolveRuntimeServiceVersion(): string { - return resolveVersionGetter(); -} - -export function getPluginVersionString(): string { - return PLUGIN_VERSION; -} - -export function getFrameworkVersionString(): string { - return resolveVersionGetter(); -} - -export function getApproveRuntimeGetter(): ApproveRuntimeGetter | null { - return approveRuntimeGetter; -} diff --git a/extensions/qqbot/src/engine/commands/command-visibility.test.ts b/extensions/qqbot/src/engine/commands/command-visibility.test.ts deleted file mode 100644 index 3c2f62578430..000000000000 --- a/extensions/qqbot/src/engine/commands/command-visibility.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Qqbot tests cover group command visibility classification. -import { describe, expect, it } from "vitest"; -import { classifyCoreCommandForGroup } from "./command-visibility.js"; - -describe("QQBot command visibility", () => { - it("parses slash command names case-insensitively", () => { - expect(classifyCoreCommandForGroup(" /NEW now ").commandName).toBe("new"); - expect(classifyCoreCommandForGroup("/CONFIG: show").commandName).toBe("config"); - expect(classifyCoreCommandForGroup("/config:show").commandName).toBe("config"); - expect(classifyCoreCommandForGroup("/config@bot show").commandName).toBe("config"); - expect(classifyCoreCommandForGroup("hello").commandName).toBeUndefined(); - }); - - it("keeps safe collaboration commands visible in groups", () => { - for (const command of ["/help", "/btw side question", "/stop"]) { - expect(classifyCoreCommandForGroup(command).visibility).toBe("group"); - } - }); - - it("keeps group-session controls callable but hidden from group menus", () => { - for (const command of ["/new", "/reset", "/name", "/compact"]) { - expect(classifyCoreCommandForGroup(command).visibility).toBe("hidden"); - } - expect(classifyCoreCommandForGroup("/name", "safety").visibility).toBe("hidden"); - }); - - it("marks sensitive core commands as private-only in groups", () => { - for (const command of [ - "/config", - "/bash", - "/export-session", - "/diagnostics", - "/tts", - "/steer", - "/tell", - "/model", - "/models", - "/status", - "/verbose", - "/v", - "/config: show", - "/model@bot sonnet", - ]) { - expect(classifyCoreCommandForGroup(command, "safety").visibility).toBe("private"); - } - }); - - it("keeps omitted command level compatible with all mode", () => { - for (const command of ["/config", "/bash", "/new", "/status"]) { - expect(classifyCoreCommandForGroup(command).visibility).not.toBe("private"); - } - }); - - it("allows every recognized core command in all mode", () => { - for (const command of ["/config", "/bash", "/new", "/name", "/status"]) { - expect(classifyCoreCommandForGroup(command, "all").visibility).not.toBe("private"); - } - }); - - it("keeps urgent stop callable in strict mode", () => { - expect(classifyCoreCommandForGroup("/stop", "strict").visibility).toBe("group"); - }); - - it("limits other core commands in strict mode", () => { - expect(classifyCoreCommandForGroup("/new", "strict").visibility).toBe("hidden"); - expect(classifyCoreCommandForGroup("/reset", "strict").visibility).toBe("hidden"); - expect(classifyCoreCommandForGroup("/name", "strict").visibility).toBe("private"); - expect(classifyCoreCommandForGroup("/status", "strict").visibility).toBe("private"); - expect(classifyCoreCommandForGroup("/config", "strict").visibility).toBe("private"); - }); - - it("keeps strict mode fail-closed for unclassified slash commands", () => { - expect(classifyCoreCommandForGroup("/bot-dynamic", "strict").visibility).toBe("private"); - expect(classifyCoreCommandForGroup("/unknown", "strict").visibility).toBe("private"); - }); - - it("does not make plugin and unknown slash commands private in all mode", () => { - expect(classifyCoreCommandForGroup("/bot-help").visibility).not.toBe("private"); - expect(classifyCoreCommandForGroup("/unknown").visibility).not.toBe("private"); - }); - - it("leaves plugin and unknown slash commands to their existing dispatch path in safety mode", () => { - expect(classifyCoreCommandForGroup("/bot-help", "safety").visibility).toBe("unknown"); - expect(classifyCoreCommandForGroup("/unknown", "safety").visibility).toBe("unknown"); - }); -}); diff --git a/extensions/qqbot/src/engine/commands/command-visibility.ts b/extensions/qqbot/src/engine/commands/command-visibility.ts deleted file mode 100644 index 17b4f42c078d..000000000000 --- a/extensions/qqbot/src/engine/commands/command-visibility.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Qqbot plugin module classifies slash-command visibility for QQ group chats. -import type { QQBotGroupCommandLevel } from "../config/group.js"; - -type GroupCommandVisibility = "group" | "hidden" | "private" | "unknown"; - -export const PRIVATE_CHAT_ONLY_TEXT = "该命令仅限私聊使用,请在私聊中发送。"; - -const GROUP_VISIBLE_CORE_COMMANDS = new Set(["help", "btw", "side", "stop"]); - -const STRICT_CORE_COMMANDS = new Set(["new", "reset"]); - -const GROUP_HIDDEN_CORE_COMMANDS = new Set([ - "goal", - "usage", - "activation", - "send", - "reset", - "new", - "name", - "compact", - "think", - "thinking", - "t", - "fast", - "reasoning", - "reason", - "queue", -]); - -const PRIVATE_ONLY_CORE_COMMANDS = new Set([ - "commands", - "tools", - "skill", - "diagnostics", - "openclaw", - "tasks", - "allowlist", - "approve", - "context", - "export-session", - "export", - "export-trajectory", - "trajectory", - "tts", - "whoami", - "id", - "session", - "subagents", - "acp", - "focus", - "unfocus", - "agents", - "steer", - "tell", - "config", - "mcp", - "plugins", - "plugin", - "debug", - "status", - "restart", - "trace", - "verbose", - "v", - "elevated", - "elev", - "exec", - "model", - "models", - "bash", -]); - -function parseSlashCommandName(content: string | undefined | null): string | undefined { - const trimmed = (content ?? "").trim(); - if (!trimmed.startsWith("/")) { - return undefined; - } - const firstToken = trimmed.slice(1).split(/\s+/, 1)[0]?.trim().toLowerCase() ?? ""; - const commandName = firstToken.split(/[@::]/u, 1)[0] ?? ""; - return commandName || undefined; -} - -export function classifyCoreCommandForGroup( - content: string | undefined | null, - commandLevel: QQBotGroupCommandLevel = "all", -): { - commandName?: string; - visibility: GroupCommandVisibility; -} { - const commandName = parseSlashCommandName(content); - if (!commandName) { - return { visibility: "unknown" }; - } - if (commandLevel === "all") { - return { - commandName, - visibility: GROUP_VISIBLE_CORE_COMMANDS.has(commandName) ? "group" : "hidden", - }; - } - if (commandLevel === "strict") { - if (commandName === "stop") { - return { commandName, visibility: "group" }; - } - if (STRICT_CORE_COMMANDS.has(commandName)) { - return { commandName, visibility: "hidden" }; - } - return { commandName, visibility: "private" }; - } - if (GROUP_VISIBLE_CORE_COMMANDS.has(commandName)) { - return { commandName, visibility: "group" }; - } - if (GROUP_HIDDEN_CORE_COMMANDS.has(commandName)) { - return { commandName, visibility: "hidden" }; - } - if (PRIVATE_ONLY_CORE_COMMANDS.has(commandName)) { - return { commandName, visibility: "private" }; - } - return { commandName, visibility: "unknown" }; -} diff --git a/extensions/qqbot/src/engine/commands/slash-command-auth.ts b/extensions/qqbot/src/engine/commands/slash-command-auth.ts deleted file mode 100644 index 69dfbe63ba74..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-command-auth.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Pre-dispatch authorization for requireAuth slash commands. - * - * Unlike the inbound message ingress command projection (which permits - * open-policy chat senders), this function requires the sender to appear in an - * **explicit non-wildcard** allowFrom list. - * - * Rationale: sensitive operations (log export, file deletion, approval - * config changes) must be gated behind a deliberate operator decision. - * A wide-open DM policy means "anyone can chat", not "anyone can run - * admin commands". - */ - -import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../access/index.js"; - -type SlashCommandAuthEntry = string | number; - -function isSlashCommandAuthEntry(value: unknown): value is SlashCommandAuthEntry { - return typeof value === "string" || typeof value === "number"; -} - -function readSlashCommandAuthList(value: unknown): SlashCommandAuthEntry[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - return value.filter(isSlashCommandAuthEntry); -} - -/** - * Resolve the command-specific QQBot allowlist from the root OpenClaw config. - * - * `commands.allowFrom.qqbot` takes precedence over the global - * `commands.allowFrom["*"]`, matching the framework command authorization - * contract used by registered plugin commands. - */ -export function resolveQQBotCommandsAllowFrom(cfg: unknown): SlashCommandAuthEntry[] | undefined { - if (!cfg || typeof cfg !== "object") { - return undefined; - } - const commands = (cfg as { commands?: unknown }).commands; - if (!commands || typeof commands !== "object") { - return undefined; - } - const allowFrom = (commands as { allowFrom?: unknown }).allowFrom; - if (!allowFrom || typeof allowFrom !== "object" || Array.isArray(allowFrom)) { - return undefined; - } - const byProvider = allowFrom as Record; - return readSlashCommandAuthList(byProvider.qqbot) ?? readSlashCommandAuthList(byProvider["*"]); -} - -/** - * Determine whether `senderId` is authorized to execute `requireAuth` - * slash commands for the given account configuration. - * - * Authorization rules: - * - `commands.allowFrom.qqbot` / `commands.allowFrom["*"]` configured → - * use that command-specific list instead of channel allowFrom - * - `allowFrom` not configured / empty / only `["*"]` → **false** - * (wildcard means "open to everyone", not explicit authorization) - * - `allowFrom` contains at least one concrete entry AND sender - * matches a concrete entry → **true** - * - Group messages use `groupAllowFrom` when present, falling back - * to `allowFrom`. - */ -export function resolveSlashCommandAuth(params: { - senderId: string; - isGroup: boolean; - allowFrom?: Array; - groupAllowFrom?: Array; - commandsAllowFrom?: Array; -}): boolean { - const rawList = - params.commandsAllowFrom ?? - (params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0 - ? params.groupAllowFrom - : params.allowFrom); - - const normalized = normalizeQQBotAllowFrom(rawList); - - // Require and match only explicit (non-wildcard) entries. - const explicitEntries = normalized.filter((entry) => entry !== "*"); - if (explicitEntries.length === 0) { - return false; - } - - return createQQBotSenderMatcher(params.senderId)(explicitEntries); -} diff --git a/extensions/qqbot/src/engine/commands/slash-command-effect-once.test.ts b/extensions/qqbot/src/engine/commands/slash-command-effect-once.test.ts deleted file mode 100644 index c288daaf686e..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-command-effect-once.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createQQBotIngressEffectOnce } from "../gateway/ingress-effects.js"; -import type { QueuedMessage } from "../gateway/message-queue.js"; -import type { GatewayAccount } from "../gateway/types.js"; -import { sendText } from "../messaging/sender.js"; -import { trySlashCommand, type SlashCommandHandlerContext } from "./slash-command-handler.js"; - -vi.mock("../messaging/outbound.js", () => ({ - sendDocument: vi.fn(async () => undefined), -})); - -vi.mock("../messaging/sender.js", () => ({ - accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })), - buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })), - sendText: vi.fn(async () => undefined), -})); - -const queueSnapshot = { - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, -}; - -let testRoot = ""; - -function createAccount(): GatewayAccount { - return { - accountId: "default", - appId: "app", - clientSecret: "", - markdownSupport: true, - config: { allowFrom: ["TRUSTED_OPENID"] }, - }; -} - -function createClearMessage(): QueuedMessage { - return { - type: "c2c", - senderId: "TRUSTED_OPENID", - content: "/bot-clear-storage --force", - messageId: "clear-1", - timestamp: "2026-01-01T00:00:00.000Z", - }; -} - -function createHandlerContext(): SlashCommandHandlerContext { - return { - account: createAccount(), - cfg: {}, - getMessagePeerId: () => "c2c:TRUSTED_OPENID", - getQueueSnapshot: () => queueSnapshot, - }; -} - -function createDownload(name: string): string { - const downloads = path.join(testRoot, ".openclaw", "media", "qqbot", "downloads"); - fs.mkdirSync(downloads, { recursive: true }); - const filePath = path.join(downloads, name); - fs.writeFileSync(filePath, "payload", "utf8"); - return filePath; -} - -beforeEach(() => { - resetPluginStateStoreForTests(); - testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-effect-once-")); - const stateDir = path.join(testRoot, "state"); - fs.mkdirSync(stateDir, { recursive: true }); - vi.stubEnv("OPENCLAW_HOME", testRoot); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - vi.mocked(sendText).mockClear(); -}); - -afterEach(() => { - resetPluginStateStoreForTests(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - fs.rmSync(testRoot, { recursive: true, force: true }); - testRoot = ""; -}); - -describe("QQBot slash-command ingress effects", () => { - it("clears storage once and still replies when the ingress event replays", async () => { - const filePath = createDownload("first.txt"); - const unlink = vi.spyOn(fs, "unlinkSync"); - const effectOnce = createQQBotIngressEffectOnce({ accountId: "default" }); - const ingress = { eventId: "message:clear-1", effectOnce }; - - await expect( - trySlashCommand(createClearMessage(), createHandlerContext(), ingress), - ).resolves.toBe("handled"); - await expect( - trySlashCommand(createClearMessage(), createHandlerContext(), ingress), - ).resolves.toBe("handled"); - - expect(fs.existsSync(filePath)).toBe(false); - expect(unlink).toHaveBeenCalledOnce(); - expect(sendText).toHaveBeenCalledTimes(2); - expect(vi.mocked(sendText).mock.calls[0]?.[1]).toContain("清理成功"); - expect(vi.mocked(sendText).mock.calls[1]?.[1]).toContain("已经处理"); - }); - - it("releases a failed effect so the same ingress event executes on retry", async () => { - const filePath = createDownload("retry.txt"); - const targetDir = path.dirname(filePath); - const failure = new Error("scan failed"); - const realExistsSync = fs.existsSync.bind(fs); - let failScan = true; - vi.spyOn(fs, "existsSync").mockImplementation((candidate) => { - if (String(candidate) === targetDir && failScan) { - failScan = false; - throw failure; - } - return realExistsSync(candidate); - }); - const effectOnce = createQQBotIngressEffectOnce({ accountId: "default" }); - const ingress = { eventId: "message:clear-retry", effectOnce }; - - await expect( - trySlashCommand(createClearMessage(), createHandlerContext(), ingress), - ).rejects.toBe(failure); - expect(realExistsSync(filePath)).toBe(true); - - await expect( - trySlashCommand(createClearMessage(), createHandlerContext(), ingress), - ).resolves.toBe("handled"); - expect(realExistsSync(filePath)).toBe(false); - expect(sendText).toHaveBeenCalledOnce(); - expect(vi.mocked(sendText).mock.calls[0]?.[1]).toContain("清理成功"); - }); -}); diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.test.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.test.ts deleted file mode 100644 index e5ef7bfa5ae6..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-command-handler.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Qqbot tests cover slash command handler plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { QueuedMessage } from "../gateway/message-queue.js"; -import type { GatewayAccount } from "../gateway/types.js"; -import { sendText } from "../messaging/sender.js"; -import { trySlashCommand } from "./slash-command-handler.js"; -import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js"; - -vi.mock("../messaging/outbound.js", () => ({ - sendDocument: vi.fn(async () => undefined), -})); - -vi.mock("../messaging/sender.js", () => ({ - accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })), - buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })), - sendText: vi.fn(async () => undefined), -})); - -function createStreamingMessage(): QueuedMessage { - return { - type: "c2c", - senderId: "TRUSTED_OPENID", - content: "/bot-streaming on", - messageId: "msg-1", - timestamp: "2026-01-01T00:00:00.000Z", - }; -} - -function createGroupStopMessage(): QueuedMessage { - return { - type: "group", - senderId: "TRUSTED_OPENID", - content: "/stop", - messageId: "msg-stop", - timestamp: "2026-01-01T00:00:00.000Z", - groupOpenid: "GROUP_OPENID", - }; -} - -function createDmStopMessage(): QueuedMessage { - return { - type: "c2c", - senderId: "TRUSTED_OPENID", - content: "/stop", - messageId: "msg-stop-dm", - timestamp: "2026-01-01T00:00:00.000Z", - }; -} - -function createAccount(): GatewayAccount { - return { - accountId: "default", - appId: "app", - clientSecret: "", - markdownSupport: true, - config: { - allowFrom: ["*"], - streaming: { mode: "off" }, - }, - }; -} - -function authorizeGroupCommands(account: GatewayAccount): void { - account.config.groupAllowFrom = ["TRUSTED_OPENID"]; -} - -describe("trySlashCommand", () => { - beforeEach(() => { - vi.mocked(sendText).mockClear(); - }); - - it("honors commands.allowFrom for pre-dispatch bot-streaming in open DM configs", async () => { - const writes: OpenClawConfig[] = []; - const config: OpenClawConfig = { - commands: { - allowFrom: { - qqbot: ["TRUSTED_OPENID"], - }, - }, - channels: { - qqbot: { - allowFrom: ["*"], - streaming: { mode: "off" }, - }, - }, - }; - installCommandRuntime(config, writes); - - const result = await trySlashCommand(createStreamingMessage(), { - account: createAccount(), - cfg: config, - getMessagePeerId: () => "c2c:TRUSTED_OPENID", - getQueueSnapshot: () => ({ - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, - }), - }); - - const qqbot = getWrittenQQBotConfig(writes[0]); - expect(result).toBe("handled"); - expect(writes).toHaveLength(1); - expect(qqbot?.streaming).toEqual({ mode: "partial", nativeTransport: true }); - expect(vi.mocked(sendText).mock.calls.at(0)?.[1]).toContain("已开启"); - }); - - it("keeps group /stop urgent when command level is strict", async () => { - const account = createAccount(); - authorizeGroupCommands(account); - account.config.groups = { - GROUP_OPENID: { commandLevel: "strict" }, - }; - - const result = await trySlashCommand(createGroupStopMessage(), { - account, - cfg: {}, - getMessagePeerId: () => "group:GROUP_OPENID", - getQueueSnapshot: () => ({ - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, - }), - }); - - expect(result).toBe("urgent"); - }); - - it("keeps group /stop urgent outside strict command level", async () => { - const account = createAccount(); - authorizeGroupCommands(account); - - const result = await trySlashCommand(createGroupStopMessage(), { - account, - cfg: {}, - getMessagePeerId: () => "group:GROUP_OPENID", - getQueueSnapshot: () => ({ - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, - }), - }); - - expect(result).toBe("urgent"); - }); - - it("does not let unauthorized group /stop bypass the queue", async () => { - const result = await trySlashCommand(createGroupStopMessage(), { - account: createAccount(), - cfg: {}, - getMessagePeerId: () => "group:GROUP_OPENID", - getQueueSnapshot: () => ({ - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, - }), - }); - - expect(result).toBe("enqueue"); - }); - - it("keeps open DM /stop urgent", async () => { - const result = await trySlashCommand(createDmStopMessage(), { - account: createAccount(), - cfg: {}, - getMessagePeerId: () => "c2c:TRUSTED_OPENID", - getQueueSnapshot: () => ({ - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, - }), - }); - - expect(result).toBe("urgent"); - }); -}); diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.ts deleted file mode 100644 index fb7b69101b7b..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-command-handler.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Slash command handler — intercept slash commands before message queue. - * - * Extracted from gateway.ts to keep the gateway connection logic thin. - * Handles urgent commands, normal slash commands, and file delivery. - */ - -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js"; -import type { QQBotIngressEffectOnce } from "../gateway/ingress-effects.js"; -import type { QueuedMessage } from "../gateway/message-queue.js"; -import type { GatewayAccount, EngineLogger } from "../gateway/types.js"; -import { sendDocument } from "../messaging/outbound.js"; -import { - sendText as senderSendText, - buildDeliveryTarget, - accountToCreds, -} from "../messaging/sender.js"; -import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js"; -import { matchSlashCommand } from "./slash-commands-impl.js"; -import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js"; - -// ============ Types ============ - -export interface SlashCommandHandlerContext { - account: GatewayAccount; - cfg?: unknown; - log?: EngineLogger; - getMessagePeerId: (msg: QueuedMessage) => string; - getQueueSnapshot: (peerId: string) => QueueSnapshot; - resolveCommandAuthorized?: (params: { - isGroup: boolean; - senderId: string; - conversationId: string; - allowFrom?: Array; - groupAllowFrom?: Array; - commandsAllowFrom?: Array; - }) => boolean | Promise; -} - -// ============ Constants ============ - -const URGENT_COMMANDS = ["/stop"]; - -class SlashCommandIngressEffectError extends Error { - constructor(readonly effectCause: unknown) { - super("QQBot slash-command ingress effect failed"); - this.name = "SlashCommandIngressEffectError"; - } -} - -// ============ trySlashCommandOrEnqueue ============ - -/** - * Check if the message is a slash command and handle it. - * - * @returns `true` if handled (command executed or enqueued as urgent), - * `false` if the message should be queued for normal processing. - */ -export async function trySlashCommand( - msg: QueuedMessage, - ctx: SlashCommandHandlerContext, - ingress?: { eventId: string; effectOnce: QQBotIngressEffectOnce }, -): Promise<"handled" | "urgent" | "enqueue"> { - const { account, log } = ctx; - const content = (msg.content ?? "").trim(); - - if (!content.startsWith("/")) { - return "enqueue"; - } - - const isGroup = msg.type === "group" || msg.type === "guild"; - const groupCommandLevel = isGroup - ? resolveGroupCommandLevelFromAccountConfig( - account.config, - msg.groupOpenid ?? msg.channelId ?? null, - ) - : undefined; - const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg); - const commandAuthorized = ctx.resolveCommandAuthorized - ? await ctx.resolveCommandAuthorized({ - isGroup, - senderId: msg.senderId, - conversationId: msg.groupOpenid ?? msg.channelId ?? msg.senderId, - allowFrom: account.config?.allowFrom, - groupAllowFrom: account.config?.groupAllowFrom, - commandsAllowFrom, - }) - : resolveSlashCommandAuth({ - senderId: msg.senderId, - isGroup, - allowFrom: account.config?.allowFrom, - groupAllowFrom: account.config?.groupAllowFrom, - commandsAllowFrom, - }); - - // Urgent command detection — bypass queue and execute immediately. - const contentLower = content.toLowerCase(); - const isUrgentCommand = URGENT_COMMANDS.some( - (cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "), - ); - if (isUrgentCommand) { - if (isGroup && !commandAuthorized) { - return "enqueue"; - } - log?.info(`Urgent command detected: ${truncateUtf16Safe(content, 20)}`); - return "urgent"; - } - - // Normal slash command — try to match and execute. - const receivedAt = Date.now(); - const peerId = ctx.getMessagePeerId(msg); - const cmdCtx: SlashCommandContext = { - type: msg.type, - senderId: msg.senderId, - senderName: msg.senderName, - messageId: msg.messageId, - eventTimestamp: msg.timestamp, - receivedAt, - rawContent: content, - args: "", - channelId: msg.channelId, - groupOpenid: msg.groupOpenid, - accountId: account.accountId, - appId: account.appId, - accountConfig: account.config, - commandAuthorized, - groupCommandLevel, - queueSnapshot: ctx.getQueueSnapshot(peerId), - ...(ingress - ? { - runIngressEffectOnce: async (params: { effect: string; run: () => Promise }) => { - try { - return await ingress.effectOnce.runOnce({ eventId: ingress.eventId, ...params }); - } catch (error) { - throw new SlashCommandIngressEffectError(error); - } - }, - } - : {}), - }; - - try { - const reply = await matchSlashCommand(cmdCtx); - if (reply === null) { - return "enqueue"; - } - - log?.debug?.(`Slash command matched: ${content}`); - - const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply; - const replyText = isFileResult ? (reply as { text: string }).text : reply; - const replyFile = isFileResult ? (reply as { filePath: string }).filePath : null; - - // Send text reply. - if (msg.type === "c2c" || msg.type === "group" || msg.type === "dm" || msg.type === "guild") { - const slashTarget = buildDeliveryTarget(msg); - const slashCreds = accountToCreds(account); - await senderSendText(slashTarget, replyText, slashCreds, { msgId: msg.messageId }); - } - - // Send file attachment if present. - if (replyFile) { - try { - const targetType = - msg.type === "group" - ? "group" - : msg.type === "dm" - ? "dm" - : msg.type === "c2c" - ? "c2c" - : "channel"; - const targetId = - msg.type === "group" - ? msg.groupOpenid || msg.senderId - : msg.type === "dm" - ? msg.guildId || msg.senderId - : msg.type === "c2c" - ? msg.senderId - : msg.channelId || msg.senderId; - await sendDocument( - { - targetType, - targetId, - account, - replyToId: msg.messageId, - }, - replyFile, - { allowQQBotDataDownloads: true }, - ); - } catch (fileErr) { - log?.error(`Failed to send slash command file: ${String(fileErr)}`); - } - } - - return "handled"; - } catch (err) { - if (err instanceof SlashCommandIngressEffectError) { - throw err.effectCause; - } - log?.error(`Slash command error: ${String(err)}`); - return "enqueue"; - } -} diff --git a/extensions/qqbot/src/engine/commands/slash-command-test-support.ts b/extensions/qqbot/src/engine/commands/slash-command-test-support.ts deleted file mode 100644 index a1ded90794ec..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-command-test-support.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Qqbot plugin module implements slash command test support behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { CommandsPort } from "../adapter/commands.port.js"; -import { initCommands } from "./slash-commands-impl.js"; - -type RuntimeConfigApi = ReturnType>["config"]; -type ReplaceConfigFile = RuntimeConfigApi["replaceConfigFile"]; -type ReplaceConfigFileResult = Awaited>; - -type WrittenQQBotConfig = { - streaming?: unknown; - accounts?: { default?: { streaming?: unknown } }; -}; - -export function installCommandRuntime( - currentConfig: OpenClawConfig, - writes: OpenClawConfig[], -): void { - const replaceConfigFile: ReplaceConfigFile = async (params) => { - writes.push(params.nextConfig); - return undefined as unknown as ReplaceConfigFileResult; - }; - - initCommands({ - resolveVersion: () => "test", - pluginVersion: "0.0.0-test", - approveRuntimeGetter: () => ({ - config: { - current: () => currentConfig, - replaceConfigFile, - }, - }), - }); -} - -export function getWrittenQQBotConfig( - write: OpenClawConfig | undefined, -): WrittenQQBotConfig | undefined { - return write?.channels?.qqbot as WrittenQQBotConfig | undefined; -} diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts deleted file mode 100644 index a7b43ad8f3d4..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -// Qqbot tests cover slash commands impl plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it } from "vitest"; -import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js"; -import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js"; -import { getFrameworkCommands, matchSlashCommand } from "./slash-commands-impl.js"; -import { SlashCommandRegistry, type SlashCommandContext } from "./slash-commands.js"; - -function createStreamingContext(overrides: Partial = {}): SlashCommandContext { - return { - type: "c2c", - senderId: "UNTRUSTED_OPENID", - messageId: "msg-1", - eventTimestamp: "2026-01-01T00:00:00.000Z", - receivedAt: 1, - rawContent: "/bot-streaming on", - args: "", - accountId: "default", - appId: "app", - accountConfig: { allowFrom: ["*"], streaming: { mode: "off" } }, - commandAuthorized: false, - queueSnapshot: { - totalPending: 0, - activeUsers: 0, - maxConcurrentUsers: 1, - senderPending: 0, - }, - ...overrides, - }; -} - -describe("QQBot framework slash commands", () => { - it("exposes private-only admin commands with private-chat metadata", () => { - const commands = getFrameworkCommands(); - const names = commands.map((command) => command.name); - - expect(names).toContain("bot-approve"); - expect(names).toContain("bot-clear-storage"); - expect(names).toContain("bot-logs"); - expect(names).toContain("bot-streaming"); - for (const commandName of ["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]) { - const command = commands.find((entry) => entry.name === commandName); - expect(command?.c2cOnly).toBe(true); - } - }); - - it("preserves private-only auth metadata for framework registration", () => { - const registry = new SlashCommandRegistry(); - registry.register({ - name: "private-admin", - description: "private admin command", - requireAuth: true, - c2cOnly: true, - handler: () => "ok", - }); - registry.register({ - name: "shared-admin", - description: "shared admin command", - requireAuth: true, - handler: () => "ok", - }); - - const commands = registry.getFrameworkCommands(); - - expect(commands.map((command) => command.name)).toEqual(["private-admin", "shared-admin"]); - const privateAdmin = commands.find((command) => command.name === "private-admin"); - const sharedAdmin = commands.find((command) => command.name === "shared-admin"); - expect(privateAdmin?.c2cOnly).toBe(true); - expect(sharedAdmin?.c2cOnly).toBeUndefined(); - }); - - it("routes bot-streaming through the auth-gated framework registry", () => { - expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-streaming"); - }); - - it("rejects private-only plugin commands in groups with the shared private-chat message", async () => { - const result = await matchSlashCommand( - createStreamingContext({ - type: "group", - rawContent: "/bot-me", - groupOpenid: "group-1", - commandAuthorized: true, - }), - ); - - expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。"); - }); - - it("keeps private-only plugin commands private even when command level is all", async () => { - const result = await matchSlashCommand( - createStreamingContext({ - type: "group", - rawContent: "/bot-me", - groupOpenid: "group-1", - commandAuthorized: true, - groupCommandLevel: "all", - }), - ); - - expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。"); - }); - - it("rejects plugin commands in groups when command level is strict", async () => { - const result = await matchSlashCommand( - createStreamingContext({ - type: "group", - rawContent: "/bot-ping", - groupOpenid: "group-1", - commandAuthorized: true, - groupCommandLevel: "strict", - }), - ); - - expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。"); - }); - - it("keeps requireAuth commands gated in default all group mode", async () => { - const registry = new SlashCommandRegistry(); - registry.register({ - name: "shared-admin", - description: "shared admin command", - requireAuth: true, - handler: () => "ok", - }); - - const result = await registry.matchSlashCommand( - createStreamingContext({ - type: "group", - rawContent: "/shared-admin", - groupOpenid: "group-1", - commandAuthorized: false, - }), - ); - - expect(result).toContain("权限不足"); - }); - - it("does not write streaming config when the sender is not command-authorized", async () => { - const writes: OpenClawConfig[] = []; - installCommandRuntime( - { - channels: { - qqbot: { - allowFrom: ["*"], - streaming: { mode: "off" }, - }, - }, - }, - writes, - ); - - const result = await matchSlashCommand(createStreamingContext()); - - expect(result).toContain("权限不足"); - expect(writes).toHaveLength(0); - }); - - it("does not write streaming config when allowFrom mixes wildcard with another sender", async () => { - const writes: OpenClawConfig[] = []; - const allowFrom = ["*", "TRUSTED_OPENID"]; - installCommandRuntime( - { - channels: { - qqbot: { - allowFrom, - streaming: { mode: "off" }, - }, - }, - }, - writes, - ); - - const commandAuthorized = resolveSlashCommandAuth({ - senderId: "UNTRUSTED_OPENID", - isGroup: false, - allowFrom, - }); - const result = await matchSlashCommand( - createStreamingContext({ - accountConfig: { allowFrom, streaming: { mode: "off" } }, - commandAuthorized, - }), - ); - - expect(commandAuthorized).toBe(false); - expect(result).toContain("权限不足"); - expect(writes).toHaveLength(0); - }); - - it("writes streaming config when commands.allowFrom grants the sender in open DM configs", async () => { - const writes: OpenClawConfig[] = []; - installCommandRuntime( - { - commands: { - allowFrom: { - qqbot: ["TRUSTED_OPENID"], - }, - }, - channels: { - qqbot: { - allowFrom: ["*"], - streaming: { mode: "off" }, - }, - }, - }, - writes, - ); - - const commandAuthorized = resolveSlashCommandAuth({ - senderId: "TRUSTED_OPENID", - isGroup: false, - allowFrom: ["*"], - commandsAllowFrom: resolveQQBotCommandsAllowFrom({ - commands: { - allowFrom: { - qqbot: ["TRUSTED_OPENID"], - }, - }, - }), - }); - const result = await matchSlashCommand( - createStreamingContext({ - senderId: "TRUSTED_OPENID", - accountConfig: { allowFrom: ["*"], streaming: { mode: "off" } }, - commandAuthorized, - }), - ); - - const qqbot = getWrittenQQBotConfig(writes[0]); - expect(commandAuthorized).toBe(true); - expect(result).toContain("已开启"); - expect(writes).toHaveLength(1); - expect(qqbot?.streaming).toEqual({ mode: "partial", nativeTransport: true }); - }); - - it("writes streaming config when the sender is command-authorized", async () => { - const writes: OpenClawConfig[] = []; - const allowFrom = ["*", "TRUSTED_OPENID"]; - installCommandRuntime( - { - channels: { - qqbot: { - allowFrom, - streaming: { mode: "off" }, - accounts: { - default: { - allowFrom, - streaming: { mode: "off" }, - }, - }, - }, - }, - }, - writes, - ); - - const commandAuthorized = resolveSlashCommandAuth({ - senderId: "TRUSTED_OPENID", - isGroup: false, - allowFrom, - }); - const result = await matchSlashCommand( - createStreamingContext({ - senderId: "TRUSTED_OPENID", - accountConfig: { allowFrom, streaming: { mode: "off" } }, - commandAuthorized, - }), - ); - - const qqbot = getWrittenQQBotConfig(writes[0]); - expect(commandAuthorized).toBe(true); - expect(result).toContain("已开启"); - expect(writes).toHaveLength(1); - expect(qqbot?.streaming).toEqual({ mode: "partial", nativeTransport: true }); - expect(qqbot?.accounts?.default?.streaming).toEqual({ mode: "partial", nativeTransport: true }); - }); - - it("writes streaming mode off when toggled off", async () => { - const writes: OpenClawConfig[] = []; - const allowFrom = ["*", "TRUSTED_OPENID"]; - installCommandRuntime( - { - channels: { - qqbot: { - allowFrom, - streaming: { mode: "partial", nativeTransport: true }, - }, - }, - }, - writes, - ); - - const result = await matchSlashCommand( - createStreamingContext({ - senderId: "TRUSTED_OPENID", - rawContent: "/bot-streaming off", - accountConfig: { allowFrom, streaming: { mode: "partial", nativeTransport: true } }, - commandAuthorized: true, - }), - ); - - const qqbot = getWrittenQQBotConfig(writes[0]); - expect(result).toContain("已关闭"); - expect(writes).toHaveLength(1); - expect(qqbot?.streaming).toEqual({ mode: "off" }); - }); -}); diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts deleted file mode 100644 index 80b07e2c0874..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * QQBot plugin-level slash command handler. - * - * Type definitions and the command registry/dispatcher are in - * `./slash-commands.ts`. Built-in command bodies live under `./builtin/`. - */ - -import type { CommandsPort } from "../adapter/commands.port.js"; -import { debugLog } from "../utils/log.js"; -import { registerBuiltinSlashCommands } from "./builtin/register-all.js"; -import { - getFrameworkVersionString, - getPluginVersionString, - initSlashCommandDeps, -} from "./builtin/state.js"; -import { - SlashCommandRegistry, - type SlashCommandContext, - type SlashCommandResult, - type QQBotFrameworkCommand, -} from "./slash-commands.js"; - -const registry = new SlashCommandRegistry(); -registerBuiltinSlashCommands(registry); - -/** - * Initialize command dependencies from the EngineAdapters.commands port. - * Called once by the bridge layer during startup. - */ -export function initCommands(port: CommandsPort): void { - initSlashCommandDeps(port); -} - -/** - * Return commands that may be registered with the framework via - * api.registerCommand() in registerFull(). - */ -export function getFrameworkCommands(): QQBotFrameworkCommand[] { - return registry.getFrameworkCommands(); -} - -// Slash command entry point — delegates to core/ registry. - -/** - * Try to match and execute a plugin-level slash command. - * - * @returns A reply when matched, or null when the message should continue through normal routing. - */ -export async function matchSlashCommand(ctx: SlashCommandContext): Promise { - return registry.matchSlashCommand(ctx, { info: debugLog }); -} - -/** Return the plugin version for external callers. */ -export function getPluginVersion(): string { - return getPluginVersionString(); -} - -/** Return the framework version for external callers. */ -export function getFrameworkVersion(): string { - return getFrameworkVersionString(); -} diff --git a/extensions/qqbot/src/engine/commands/slash-commands.ts b/extensions/qqbot/src/engine/commands/slash-commands.ts deleted file mode 100644 index c40d3802c8fa..000000000000 --- a/extensions/qqbot/src/engine/commands/slash-commands.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Slash command registration and dispatch framework. - * - * This module provides the type definitions, command registry, and - * `matchSlashCommand` dispatcher that both plugin versions share. - * - * Concrete command implementations (e.g. `/bot-ping`, `/bot-logs`) are - * registered by the upper-layer bootstrap code, NOT defined here. - * - * Zero external dependencies. - */ - -import type { QQBotGroupCommandLevel } from "../config/group.js"; -import { PRIVATE_CHAT_ONLY_TEXT } from "./command-visibility.js"; - -// ============ Types ============ - -/** Slash command context (message metadata plus runtime state). */ -export interface SlashCommandContext { - /** Message type. */ - type: "c2c" | "guild" | "dm" | "group"; - /** Sender ID. */ - senderId: string; - /** Sender display name. */ - senderName?: string; - /** Message ID used for passive replies. */ - messageId: string; - /** Event timestamp from QQ as an ISO string. */ - eventTimestamp: string; - /** Local receipt timestamp in milliseconds. */ - receivedAt: number; - /** Raw message content. */ - rawContent: string; - /** Command arguments after stripping the command name. */ - args: string; - /** Channel ID for guild messages. */ - channelId?: string; - /** Group openid for group messages. */ - groupOpenid?: string; - /** Account ID. */ - accountId: string; - /** Bot App ID. */ - appId: string; - /** Account config available to the command handler. */ - accountConfig?: Record; - /** Whether the sender is authorized per the allowFrom config. */ - commandAuthorized: boolean; - /** Effective per-group command level for group invocations. */ - groupCommandLevel?: QQBotGroupCommandLevel; - /** Queue snapshot for the current sender. */ - queueSnapshot: QueueSnapshot; - /** Durable guard for non-idempotent effects during ingress drain dispatch. */ - runIngressEffectOnce?: SlashCommandIngressEffectRunner; -} - -/** Queue status snapshot. */ -export interface QueueSnapshot { - totalPending: number; - activeUsers: number; - maxConcurrentUsers: number; - senderPending: number; -} - -type SlashCommandIngressEffectRunner = (params: { - effect: string; - run: () => Promise; -}) => Promise<{ kind: "executed"; value: T } | { kind: "replayed" }>; - -/** Slash command result: text, a text+file result, or null to skip handling. */ -export type SlashCommandResult = string | SlashCommandFileResult | null; - -/** Slash command result that sends text first and then a local file. */ -interface SlashCommandFileResult { - text: string; - /** Local file path to send. */ - filePath: string; -} - -/** Slash command definition. */ -interface SlashCommand { - /** Command name without the leading slash. */ - name: string; - /** Short description. */ - description: string; - /** Detailed usage text shown by `/command ?`. */ - usage?: string; - /** When true, the command requires the sender to pass the allowFrom authorization check. */ - requireAuth?: boolean; - /** When true, the command is only available in c2c (private) chat. Group invocations are rejected automatically. */ - c2cOnly?: boolean; - /** Command handler. */ - handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; -} - -/** Framework command definition for commands that require authorization. */ -export interface QQBotFrameworkCommand { - name: string; - description: string; - usage?: string; - c2cOnly?: boolean; - handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; -} - -// ============ Command Registry ============ - -/** Lowercase and trim a string. */ -function lc(s: string): string { - return (s ?? "").toLowerCase().trim(); -} - -/** - * Slash command registry. - * - * Maintains two maps: - * - `commands` — QQBot message-flow commands - * - `frameworkCommands` — auth-gated commands that are safe on the framework surface - */ -export class SlashCommandRegistry { - private readonly commands = new Map(); - private readonly frameworkCommands = new Map(); - - /** Register one command. */ - register(cmd: SlashCommand): void { - const key = lc(cmd.name); - // Always register in the pre-dispatch map so QQ message-flow slash - // commands can match and execute directly (with requireAuth gating). - this.commands.set(key, cmd); - // Auth-gated commands are exposed to the framework command surface. - // Private-chat-only metadata is preserved so the bridge can enforce the - // same routing restriction before dispatching handlers. - if (cmd.requireAuth) { - this.frameworkCommands.set(key, cmd); - } - } - - /** Return all commands that may be registered on the framework surface. */ - getFrameworkCommands(): QQBotFrameworkCommand[] { - return Array.from(this.frameworkCommands.values()).map((cmd) => ({ - name: cmd.name, - description: cmd.description, - usage: cmd.usage, - c2cOnly: cmd.c2cOnly, - handler: cmd.handler, - })); - } - - /** Return all registered commands (both maps) for help listing. */ - getAllCommands(): Map { - const all = new Map(); - for (const [k, v] of this.commands) { - all.set(k, v); - } - for (const [k, v] of this.frameworkCommands) { - all.set(k, v); - } - return all; - } - - /** - * Try to match and execute a pre-dispatch slash command. - * - * @returns A reply when matched, or null when the message should continue - * through normal routing. - */ - async matchSlashCommand( - ctx: SlashCommandContext, - log?: { info?: (msg: string) => void }, - ): Promise { - const content = ctx.rawContent.trim(); - if (!content.startsWith("/")) { - return null; - } - - const spaceIdx = content.indexOf(" "); - const cmdName = lc(spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)); - const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim(); - - const cmd = this.commands.get(cmdName); - if (!cmd) { - return null; - } - - const isGroup = ctx.type === "group" || ctx.type === "guild"; - const groupCommandLevel = ctx.groupCommandLevel ?? "all"; - if (isGroup && groupCommandLevel === "strict") { - return PRIVATE_CHAT_ONLY_TEXT; - } - - // Reject c2cOnly commands when invoked outside private chat. - if (cmd.c2cOnly && ctx.type !== "c2c") { - return PRIVATE_CHAT_ONLY_TEXT; - } - - // Gate sensitive commands behind the allowFrom authorization check. - if (cmd.requireAuth && !ctx.commandAuthorized) { - log?.info?.( - `[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`, - ); - const configHint = isGroup ? "groupAllowFrom" : "allowFrom"; - return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}。`; - } - - // `/command ?` returns usage help. - if (args === "?") { - if (cmd.usage) { - return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`; - } - return `/${cmd.name} - ${cmd.description}`; - } - - ctx.args = args; - return await cmd.handler(ctx); - } -} diff --git a/extensions/qqbot/src/engine/config/credential-backup.test.ts b/extensions/qqbot/src/engine/config/credential-backup.test.ts deleted file mode 100644 index ac4754d92ae1..000000000000 --- a/extensions/qqbot/src/engine/config/credential-backup.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Qqbot tests cover credential backup plugin behavior. -import fs from "node:fs"; -import path from "node:path"; -import { - createPluginStateSyncKeyedStoreForTests, - resetPluginStateStoreForTests, -} from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import { - resolvePreferredOpenClawTmpDir, - tempWorkspaceSync, - type TempWorkspaceSync, -} from "openclaw/plugin-sdk/temp-path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - installQQBotRuntimeForStateTests, - resetQQBotStateTestRuntime, -} from "../../test-support/runtime.js"; - -type CredentialBackup = { - accountId: string; - appId: string; - clientSecret: string; - savedAt: string; -}; - -const tempWorkspaces: TempWorkspaceSync[] = []; - -async function useMockHome(homeDir: string): Promise { - vi.doMock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, homedir: () => homeDir }, - homedir: () => homeDir, - }; - }); -} - -function useStateDir(stateDir: string): void { - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - installQQBotRuntimeForStateTests(stateDir); -} - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -function legacyCredentialBackupFile(accountId: string): string { - return path.join( - process.env.OPENCLAW_STATE_DIR!, - "qqbot", - "data", - `credential-backup-${accountId}.json`, - ); -} - -function legacySingleCredentialBackupFile(): string { - return path.join(process.env.OPENCLAW_STATE_DIR!, "qqbot", "data", "credential-backup.json"); -} - -function readCredentialRows(stateDir: string): CredentialBackup[] { - const store = createPluginStateSyncKeyedStoreForTests("qqbot", { - namespace: "credential-backups", - maxEntries: 1000, - env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, - }); - return store.entries().map((entry) => entry.value); -} - -describe("engine/config/credential-backup", () => { - beforeEach(async () => { - vi.resetModules(); - const stateWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-state-", - }); - const homeWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-home-", - }); - tempWorkspaces.push(stateWorkspace, homeWorkspace); - const stateDir = stateWorkspace.dir; - const homeDir = homeWorkspace.dir; - vi.stubEnv("HOME", homeDir); - await useMockHome(homeDir); - useStateDir(stateDir); - }); - - afterEach(() => { - resetQQBotStateTestRuntime(); - resetPluginStateStoreForTests(); - vi.doUnmock("node:os"); - vi.resetModules(); - vi.unstubAllEnvs(); - for (const workspace of tempWorkspaces.splice(0)) { - workspace.cleanup(); - } - }); - - it("round-trips a credential snapshot through SQLite without writing JSON", async () => { - const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js"); - const stateDir = process.env.OPENCLAW_STATE_DIR!; - - saveCredentialBackup("default", "app-1", "secret-1"); - - const loaded = loadCredentialBackup("default"); - expect(loaded).toMatchObject({ - accountId: "default", - appId: "app-1", - clientSecret: "secret-1", - }); - expect(fs.existsSync(legacyCredentialBackupFile("default"))).toBe(false); - expect(readCredentialRows(stateDir)).toHaveLength(1); - }); - - it("keeps same account IDs isolated across state directories", async () => { - const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js"); - const stateDirA = process.env.OPENCLAW_STATE_DIR!; - saveCredentialBackup("default", "app-a", "secret-a"); - - const stateWorkspaceB = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-state-b-", - }); - tempWorkspaces.push(stateWorkspaceB); - const stateDirB = stateWorkspaceB.dir; - useStateDir(stateDirB); - expect(loadCredentialBackup("default")).toBeNull(); - saveCredentialBackup("default", "app-b", "secret-b"); - - useStateDir(stateDirA); - expect(loadCredentialBackup("default")?.appId).toBe("app-a"); - - useStateDir(stateDirB); - expect(loadCredentialBackup("default")?.appId).toBe("app-b"); - }); - - it("does not import state-dir legacy JSON backups during runtime reads", async () => { - const { loadCredentialBackup } = await import("./credential-backup.js"); - const legacyFile = legacyCredentialBackupFile("default"); - writeJson(legacyFile, { - accountId: "default", - appId: "app-old", - clientSecret: "secret-old", - savedAt: new Date().toISOString(), - }); - - expect(loadCredentialBackup("default")).toBeNull(); - expect(fs.existsSync(legacyFile)).toBe(true); - }); - - it("does not import legacy single-file backups during runtime reads", async () => { - const { loadCredentialBackup } = await import("./credential-backup.js"); - const legacyFile = legacySingleCredentialBackupFile(); - writeJson(legacyFile, { - accountId: "other-acct", - appId: "app-old", - clientSecret: "secret-old", - savedAt: new Date().toISOString(), - }); - - expect(loadCredentialBackup("default")).toBeNull(); - expect(fs.existsSync(legacyFile)).toBe(true); - }); - - it("ignores empty appId/clientSecret on save", async () => { - const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js"); - saveCredentialBackup("default", "", "secret"); - saveCredentialBackup("default", "app", ""); - - expect(loadCredentialBackup("default")).toBeNull(); - expect(readCredentialRows(process.env.OPENCLAW_STATE_DIR!)).toHaveLength(0); - }); -}); diff --git a/extensions/qqbot/src/engine/config/credential-backup.ts b/extensions/qqbot/src/engine/config/credential-backup.ts deleted file mode 100644 index e960759ba0dc..000000000000 --- a/extensions/qqbot/src/engine/config/credential-backup.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Credential backup & recovery. - * 凭证暂存与恢复。 - * - * Solves the "hot-upgrade interrupted, appId/secret vanished from - * openclaw.json" failure mode. - * - * Mechanics: - * - After each successful gateway start we snapshot the currently - * resolved `appId` / `clientSecret` to a per-account SQLite KV entry. - * - During plugin startup, if the live config has an empty appId or - * secret, the gateway consults the backup and restores the values - * via the config mutation API. - * - Legacy JSON backups are imported by `openclaw doctor --fix`, not by - * runtime startup. - * - * Safety notes: - * - Only restore when credentials are **actually empty** — never - * overwrite a user's intentional config change. - * - Per-account key only; not keyed by appId because recovery happens - * precisely when appId is unknown. - */ - -import { buildQQBotStateKey, openQQBotSyncKeyedStore } from "../utils/sqlite-state.js"; - -interface CredentialBackup { - accountId: string; - appId: string; - clientSecret: string; - savedAt: string; -} - -const CREDENTIAL_BACKUPS_NAMESPACE = "credential-backups"; -const MAX_CREDENTIAL_BACKUPS = 1000; - -function createCredentialBackupStore() { - return openQQBotSyncKeyedStore({ - namespace: CREDENTIAL_BACKUPS_NAMESPACE, - maxEntries: MAX_CREDENTIAL_BACKUPS, - }); -} - -function credentialBackupKey(accountId: string): string { - return buildQQBotStateKey("credential-backup", accountId); -} - -function isUsableBackup(data: CredentialBackup | null | undefined): data is CredentialBackup { - return Boolean(data?.accountId && data.appId && data.clientSecret); -} - -/** Persist a credential snapshot (called once gateway reaches READY). */ -export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void { - if (!appId || !clientSecret) { - return; - } - try { - const data: CredentialBackup = { - accountId, - appId, - clientSecret, - savedAt: new Date().toISOString(), - }; - createCredentialBackupStore().register(credentialBackupKey(accountId), data); - } catch { - /* best-effort — ignore */ - } -} - -/** - * Load a credential snapshot for `accountId`. - * - * Reads SQLite only. Legacy JSON backup import is owned by doctor/setup - * migration so runtime startup stays canonical-state-only. - */ -export function loadCredentialBackup(accountId?: string): CredentialBackup | null { - try { - if (accountId) { - const store = createCredentialBackupStore(); - const data = store.lookup(credentialBackupKey(accountId)); - if (isUsableBackup(data)) { - return data; - } - } - } catch { - /* corrupt file — ignore */ - } - return null; -} diff --git a/extensions/qqbot/src/engine/config/credentials.test.ts b/extensions/qqbot/src/engine/config/credentials.test.ts deleted file mode 100644 index 55abe74a7cc3..000000000000 --- a/extensions/qqbot/src/engine/config/credentials.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { clearAccountCredentials } from "./credentials.js"; -import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; - -describe("engine/config/credentials", () => { - it("ignores inherited account entries when clearing named-account credentials", () => { - const inheritedAccount = { clientSecret: "secret", clientSecretFile: "/tmp/secret" }; - const accounts = Object.create({ bot2: inheritedAccount }) as Record; - const cfg = { - channels: { - qqbot: { - accounts, - }, - }, - } satisfies Record; - - const result = clearAccountCredentials(cfg, "bot2"); - - expect(result.cleared).toBe(false); - expect(result.changed).toBe(false); - expect(Object.hasOwn(accounts, "bot2")).toBe(false); - expect(inheritedAccount).toEqual({ - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }); - }); - - it("ignores inherited credential properties on an own account entry", () => { - const account = Object.assign( - Object.create({ - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }), - { appId: "app-id" }, - ); - const cfg = { - channels: { - qqbot: { - accounts: { bot2: account }, - }, - }, - } satisfies Record; - - const result = clearAccountCredentials(cfg, "bot2"); - - expect(result.cleared).toBe(false); - expect(result.changed).toBe(false); - expect(account).toEqual({ appId: "app-id" }); - expect(account.clientSecret).toBe("secret"); - expect(account.clientSecretFile).toBe("/tmp/secret"); - }); - - it("clears own named-account credential properties and drops empty entries", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { - clientSecret: "secret", - clientSecretFile: "/tmp/secret", - }, - }, - }, - }, - } satisfies Record; - - const result = clearAccountCredentials(cfg, "bot2"); - const nextAccounts = ( - (result.nextCfg.channels as Record).qqbot as Record - ).accounts as Record; - - expect(result.cleared).toBe(true); - expect(result.changed).toBe(true); - expect(nextAccounts.bot2).toBeUndefined(); - }); - - it("clears own default-account credential properties", () => { - const cfg = { - channels: { - qqbot: { - appId: "app-id", - clientSecret: "", - clientSecretFile: "", - }, - }, - } satisfies Record; - - const result = clearAccountCredentials(cfg, DEFAULT_ACCOUNT_ID); - const nextQQBot = (result.nextCfg.channels as Record).qqbot as Record< - string, - unknown - >; - - expect(result.cleared).toBe(true); - expect(result.changed).toBe(true); - expect(Object.hasOwn(nextQQBot, "clientSecret")).toBe(false); - expect(Object.hasOwn(nextQQBot, "clientSecretFile")).toBe(false); - }); -}); diff --git a/extensions/qqbot/src/engine/config/credentials.ts b/extensions/qqbot/src/engine/config/credentials.ts deleted file mode 100644 index f7815065690b..000000000000 --- a/extensions/qqbot/src/engine/config/credentials.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * QQBot credential management (pure logic layer). - * QQBot 凭证管理(纯逻辑层)。 - * - * Credential clearing and field-level cleanup for logout and setup - * flows. All functions operate on plain objects (Record) - * and stay framework-agnostic. - */ - -import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; -import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; - -// ---- Logout: clear all credential fields for an account ---- - -interface ClearCredentialsResult { - nextCfg: Record; - cleared: boolean; - changed: boolean; -} - -/** - * Remove clientSecret / clientSecretFile from a QQBot account config. - * - * Returns a shallow-cloned config with credentials removed, plus flags - * indicating whether anything actually changed. - */ -export function clearAccountCredentials( - cfg: Record, - accountId: string, -): ClearCredentialsResult { - const nextCfg = { ...cfg }; - const channels = asOptionalObjectRecord(cfg.channels); - const nextQQBot = channels?.qqbot ? { ...asOptionalObjectRecord(channels.qqbot) } : undefined; - let cleared = false; - let changed = false; - - if (nextQQBot) { - const qqbot = nextQQBot as Record; - if (accountId === DEFAULT_ACCOUNT_ID) { - if (Object.hasOwn(qqbot, "clientSecret")) { - delete qqbot.clientSecret; - cleared = true; - changed = true; - } - if (Object.hasOwn(qqbot, "clientSecretFile")) { - delete qqbot.clientSecretFile; - cleared = true; - changed = true; - } - } - const accounts = qqbot.accounts as Record> | undefined; - if (accounts && Object.hasOwn(accounts, accountId)) { - const entry = accounts[accountId] as Record | undefined; - if (entry && Object.hasOwn(entry, "clientSecret")) { - delete entry.clientSecret; - cleared = true; - changed = true; - } - if (entry && Object.hasOwn(entry, "clientSecretFile")) { - delete entry.clientSecretFile; - cleared = true; - changed = true; - } - if (entry && Object.keys(entry).length === 0) { - delete accounts[accountId]; - changed = true; - } - } - } - - if (changed && nextQQBot) { - nextCfg.channels = { ...channels, qqbot: nextQQBot }; - } - - return { nextCfg, cleared, changed }; -} diff --git a/extensions/qqbot/src/engine/config/group.test.ts b/extensions/qqbot/src/engine/config/group.test.ts deleted file mode 100644 index a039b123d620..000000000000 --- a/extensions/qqbot/src/engine/config/group.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -// Qqbot tests cover group plugin behavior. -import { describe, expect, it } from "vitest"; -import { - DEFAULT_GROUP_PROMPT, - resolveGroupCommandLevelFromAccountConfig, - resolveGroupConfig, - resolveGroupSettings, - resolveMentionPatterns, -} from "./group.js"; - -describe("engine/config/group", () => { - describe("resolveGroupConfig precedence", () => { - it("returns defaults when no config exists", () => { - const cfg = resolveGroupConfig({}, "G1"); - expect(cfg).toStrictEqual({ - requireMention: true, - ignoreOtherMentions: false, - commandLevel: "all", - name: "", - prompt: undefined, - historyLimit: 50, - }); - }); - - it("falls back to wildcard when specific is missing", () => { - const cfg = { - channels: { - qqbot: { - appId: "1", - groups: { - "*": { - requireMention: false, - commandLevel: "strict", - historyLimit: 20, - name: "wild", - }, - }, - }, - }, - }; - const resolved = resolveGroupConfig(cfg, "G1"); - expect(resolved.requireMention).toBe(false); - expect(resolved.commandLevel).toBe("strict"); - expect(resolved.historyLimit).toBe(20); - expect(resolved.name).toBe("wild"); - }); - - it("specific overrides wildcard and defaults", () => { - const cfg = { - channels: { - qqbot: { - appId: "1", - groups: { - "*": { requireMention: true, commandLevel: "strict", historyLimit: 20 }, - GROUPA: { requireMention: false, commandLevel: "all", historyLimit: 5, name: "A" }, - }, - }, - }, - }; - const resolved = resolveGroupConfig(cfg, "GROUPA"); - expect(resolved.requireMention).toBe(false); - expect(resolved.commandLevel).toBe("all"); - expect(resolved.historyLimit).toBe(5); - expect(resolved.name).toBe("A"); - }); - - it("historyLimit is clamped to >= 0 and floored", () => { - const cfg = { - channels: { - qqbot: { appId: "1", groups: { "*": { historyLimit: -3.7 } } }, - }, - }; - expect(resolveGroupConfig(cfg, "G").historyLimit).toBe(0); - }); - - it("non-finite historyLimit falls back to default", () => { - const cfg = { - channels: { - qqbot: { appId: "1", groups: { "*": { historyLimit: "not a number" } } }, - }, - }; - expect(resolveGroupConfig(cfg, "G").historyLimit).toBe(50); - }); - - describe("account-level defaultRequireMention layer", () => { - it("uses hardcoded true when nothing configured (default account)", () => { - const cfg = { channels: { qqbot: { appId: "1" } } }; - expect(resolveGroupConfig(cfg, "G1").requireMention).toBe(true); - }); - - it("reads defaultRequireMention from top-level qqbot (default account)", () => { - const cfg = { - channels: { qqbot: { appId: "1", defaultRequireMention: false } }, - }; - expect(resolveGroupConfig(cfg, "G1").requireMention).toBe(false); - }); - - it("reads defaultRequireMention from named account config", () => { - const cfg = { - channels: { - qqbot: { - accounts: { bot2: { appId: "9", defaultRequireMention: false } }, - }, - }, - }; - expect(resolveGroupConfig(cfg, "G1", "bot2").requireMention).toBe(false); - }); - - it("wildcard overrides account-level defaultRequireMention", () => { - const cfg = { - channels: { - qqbot: { - appId: "1", - defaultRequireMention: false, - groups: { "*": { requireMention: true } }, - }, - }, - }; - // wildcard requireMention=true wins over account-level defaultRequireMention=false - expect(resolveGroupConfig(cfg, "G1").requireMention).toBe(true); - }); - - it("specific group config has highest priority", () => { - const cfg = { - channels: { - qqbot: { - appId: "1", - defaultRequireMention: false, - groups: { - "*": { requireMention: true }, - SPECIAL_GROUP: { requireMention: false }, - }, - }, - }, - }; - expect(resolveGroupConfig(cfg, "SPECIAL_GROUP").requireMention).toBe(false); - expect(resolveGroupConfig(cfg, "OTHER_GROUP").requireMention).toBe(true); // wildcard - }); - }); - }); - - describe("named accounts", () => { - it("reads groups from the named-account scope", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { - appId: "9", - groups: { "*": { requireMention: false, historyLimit: 7 } }, - }, - }, - }, - }, - }; - const resolved = resolveGroupConfig(cfg, "G", "bot2"); - expect(resolved.requireMention).toBe(false); - expect(resolved.historyLimit).toBe(7); - }); - }); - - describe("resolveGroupCommandLevelFromAccountConfig", () => { - it("defaults to all when unset", () => { - expect(resolveGroupCommandLevelFromAccountConfig({}, "G")).toBe("all"); - }); - - it("uses specific group before wildcard", () => { - expect( - resolveGroupCommandLevelFromAccountConfig( - { - groups: { - "*": { commandLevel: "strict" }, - G1: { commandLevel: "all" }, - }, - }, - "G1", - ), - ).toBe("all"); - }); - }); - - describe("group display name", () => { - it("uses the first 8 chars of openid when name is unset", () => { - expect(resolveGroupSettings({ cfg: {}, groupOpenid: "ABCDEFGH1234" }).name).toBe("ABCDEFGH"); - }); - - it("prefers the configured name", () => { - const cfg = { - channels: { qqbot: { appId: "1", groups: { ABCDEFGH1234: { name: "Foo" } } } }, - }; - expect(resolveGroupSettings({ cfg, groupOpenid: "ABCDEFGH1234" }).name).toBe("Foo"); - }); - }); - - describe("group prompt", () => { - it("returns the default prompt when nothing configured", () => { - expect(resolveGroupConfig({}, "G").prompt ?? DEFAULT_GROUP_PROMPT).toContain("bot"); - }); - - it("prefers specific over wildcard", () => { - const cfg = { - channels: { - qqbot: { - appId: "1", - groups: { "*": { prompt: "WILD" }, G1: { prompt: "SPEC" } }, - }, - }, - }; - expect(resolveGroupConfig(cfg, "G1").prompt).toBe("SPEC"); - expect(resolveGroupConfig(cfg, "G2").prompt).toBe("WILD"); - }); - }); - - describe("ignoreOtherMentions", () => { - it("defaults to false", () => { - expect(resolveGroupConfig({}, "G").ignoreOtherMentions).toBe(false); - }); - - it("honours wildcard override", () => { - const cfg = { - channels: { qqbot: { appId: "1", groups: { "*": { ignoreOtherMentions: true } } } }, - }; - expect(resolveGroupConfig(cfg, "G").ignoreOtherMentions).toBe(true); - }); - }); - - describe("resolveMentionPatterns", () => { - it("returns [] when nothing configured", () => { - expect(resolveMentionPatterns({})).toStrictEqual([]); - }); - - it("reads global patterns", () => { - const cfg = { messages: { groupChat: { mentionPatterns: ["/^hey/"] } } }; - expect(resolveMentionPatterns(cfg)).toEqual(["/^hey/"]); - }); - - it("agent-level overrides global", () => { - const cfg = { - messages: { groupChat: { mentionPatterns: ["g"] } }, - agents: { - list: [{ id: "main", groupChat: { mentionPatterns: ["a", "b"] } }], - }, - }; - expect(resolveMentionPatterns(cfg, "main")).toEqual(["a", "b"]); - expect(resolveMentionPatterns(cfg, "OTHER")).toEqual(["g"]); - }); - - it("filters non-string entries", () => { - const cfg = { messages: { groupChat: { mentionPatterns: ["ok", 42, null] } } }; - expect(resolveMentionPatterns(cfg)).toEqual(["ok"]); - }); - }); - - describe("resolveGroupSettings (aggregate)", () => { - it("returns merged config + name + mentionPatterns in one call", () => { - const cfg = { - channels: { - qqbot: { - appId: "1", - groups: { - G1: { requireMention: false, name: "Dev" }, - "*": { historyLimit: 10 }, - }, - }, - }, - messages: { groupChat: { mentionPatterns: ["@bot"] } }, - }; - const settings = resolveGroupSettings({ cfg, groupOpenid: "G1" }); - expect(settings.config.requireMention).toBe(false); - expect(settings.config.historyLimit).toBe(10); - expect(settings.name).toBe("Dev"); - expect(settings.mentionPatterns).toEqual(["@bot"]); - }); - - it("falls back to the first 8 chars of the openid for name", () => { - const settings = resolveGroupSettings({ - cfg: {}, - groupOpenid: "ABCDEFGHIJKLMNOP", - }); - expect(settings.name).toBe("ABCDEFGH"); - }); - - it("applies agent-level mentionPatterns over global", () => { - const cfg = { - agents: { - list: [{ id: "custom", groupChat: { mentionPatterns: ["@agent"] } }], - }, - messages: { groupChat: { mentionPatterns: ["@global"] } }, - }; - const settings = resolveGroupSettings({ - cfg, - groupOpenid: "G1", - agentId: "custom", - }); - expect(settings.mentionPatterns).toEqual(["@agent"]); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/config/group.ts b/extensions/qqbot/src/engine/config/group.ts deleted file mode 100644 index a9a394db6d87..000000000000 --- a/extensions/qqbot/src/engine/config/group.ts +++ /dev/null @@ -1,208 +0,0 @@ -// Qqbot plugin module implements group behavior. -import { resolveScopeRequireMention, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; -import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; -import { resolveAccountBase } from "./resolve.js"; - -interface GroupConfig { - requireMention: boolean; - ignoreOtherMentions: boolean; - commandLevel: QQBotGroupCommandLevel; - name: string; - prompt?: string; - historyLimit: number; -} - -export type QQBotGroupCommandLevel = "all" | "safety" | "strict"; - -const DEFAULT_GROUP_HISTORY_LIMIT = 50; -// Omitted commandLevel preserves shipped QQBot group behavior. Operators opt in to -// the fail-closed safety/strict modes per group or wildcard group config. -const DEFAULT_GROUP_COMMAND_LEVEL: QQBotGroupCommandLevel = "all"; - -export const DEFAULT_GROUP_PROMPT = - "If the sender is a bot, respond only when they explicitly @mention you to ask a question or request assistance with a specific task; keep your replies concise and clear, avoiding the urge to race other bots to answer or engage in lengthy, unproductive exchanges. In group chats, prioritize responding to messages from human users; bots should maintain a collaborative rather than competitive dynamic to ensure the conversation remains orderly and does not result in message flooding."; - -const DEFAULT_GROUP_CONFIG: Readonly> = { - requireMention: true, - ignoreOtherMentions: false, - commandLevel: DEFAULT_GROUP_COMMAND_LEVEL, - name: "", - historyLimit: DEFAULT_GROUP_HISTORY_LIMIT, -}; - -function readGroupsMap( - cfg: Record, - accountId?: string | null, -): Record> { - const account = resolveAccountBase(cfg, accountId); - const groups = asOptionalObjectRecord(account.config.groups); - if (!groups) { - return {}; - } - const normalized: Record> = {}; - for (const [key, value] of Object.entries(groups)) { - const sub = asOptionalObjectRecord(value); - if (sub) { - normalized[key] = sub; - } - } - return normalized; -} - -function readNonEmptyGroupString(obj: Record, key: string): string | undefined { - const v = obj[key]; - return typeof v === "string" && v.length > 0 ? v : undefined; -} - -function readCommandLevel( - obj: Record, - key: string, -): QQBotGroupCommandLevel | undefined { - const v = readNonEmptyGroupString(obj, key); - return v === "all" || v === "safety" || v === "strict" ? v : undefined; -} - -function readHistoryLimit(obj: Record, key: string): number | undefined { - const v = obj[key]; - if (typeof v !== "number" || !Number.isFinite(v)) { - return undefined; - } - return Math.max(0, Math.floor(v)); -} - -export function resolveGroupConfig( - cfg: Record, - groupOpenid?: string | null, - accountId?: string | null, -): GroupConfig { - const account = resolveAccountBase(cfg, accountId); - const groups = readGroupsMap(cfg, accountId); - const { "*": wildcard = {}, ...scopes } = groups; - const specific = groupOpenid ? (groups[groupOpenid] ?? {}) : {}; - - // 账户级默认值:defaultRequireMention 配置 > 默认 true - const accountDefaultRequireMention = asBoolean(account.config.defaultRequireMention); - const mentionTree: ScopeTree = { - defaults: { requireMention: asBoolean(wildcard.requireMention) }, - scopes: Object.fromEntries( - Object.entries(scopes).map(([key, entry]) => [ - key, - { requireMention: asBoolean(entry.requireMention) }, - ]), - ), - }; - // Engine mention matching stays exact and case-sensitive. QQBot's tool-policy - // adapter is intentionally case-insensitive, so these paths remain asymmetric. - const mentionPath = - groupOpenid && Object.hasOwn(mentionTree.scopes, groupOpenid) ? [groupOpenid] : []; - - return { - requireMention: resolveScopeRequireMention({ - tree: mentionTree, - path: mentionPath, - requireMentionOverride: accountDefaultRequireMention, - overrideOrder: "after-config", - }), - ignoreOtherMentions: - asBoolean(specific.ignoreOtherMentions) ?? - asBoolean(wildcard.ignoreOtherMentions) ?? - DEFAULT_GROUP_CONFIG.ignoreOtherMentions, - commandLevel: - readCommandLevel(specific, "commandLevel") ?? - readCommandLevel(wildcard, "commandLevel") ?? - DEFAULT_GROUP_CONFIG.commandLevel, - name: - readNonEmptyGroupString(specific, "name") ?? - readNonEmptyGroupString(wildcard, "name") ?? - DEFAULT_GROUP_CONFIG.name, - prompt: - readNonEmptyGroupString(specific, "prompt") ?? readNonEmptyGroupString(wildcard, "prompt"), - historyLimit: - readHistoryLimit(specific, "historyLimit") ?? - readHistoryLimit(wildcard, "historyLimit") ?? - DEFAULT_GROUP_CONFIG.historyLimit, - }; -} - -export function resolveGroupCommandLevelFromAccountConfig( - accountConfig: Record | undefined, - groupOpenid?: string | null, -): QQBotGroupCommandLevel { - const groups = asOptionalObjectRecord(accountConfig?.groups); - const wildcard = asOptionalObjectRecord(groups?.["*"]) ?? {}; - const specific = groupOpenid ? (asOptionalObjectRecord(groups?.[groupOpenid]) ?? {}) : {}; - return ( - readCommandLevel(specific, "commandLevel") ?? - readCommandLevel(wildcard, "commandLevel") ?? - DEFAULT_GROUP_CONFIG.commandLevel - ); -} - -// ============ GroupSettings (aggregate) ============ - -/** - * Per-inbound aggregate of everything the pipeline needs about a group. - * - * Built once at the top of the group-gate stage so downstream consumers - * don't repeatedly re-parse the same `cfg` tree. Superset of - * {@link GroupConfig}: also includes the effective `mentionPatterns` - * (which depend on `agentId`, not on the group itself) and a - * pre-computed display name for logging. - */ -interface GroupSettings { - /** Merged group config (specific > wildcard > defaults). */ - config: GroupConfig; - /** Display name — `config.name` or the first 8 chars of the openid. */ - name: string; - /** Raw mentionPatterns (agent > global > []). */ - mentionPatterns: string[]; -} - -export function resolveGroupSettings(params: { - cfg: Record; - groupOpenid: string; - accountId?: string | null; - agentId?: string | null; -}): GroupSettings { - const config = resolveGroupConfig(params.cfg, params.groupOpenid, params.accountId); - const name = config.name || params.groupOpenid.slice(0, 8); - const mentionPatterns = resolveMentionPatterns(params.cfg, params.agentId); - return { config, name, mentionPatterns }; -} - -interface AgentEntry { - id?: unknown; - groupChat?: { mentionPatterns?: unknown }; -} - -export function resolveMentionPatterns( - cfg: Record, - agentId?: string | null, -): string[] { - if (agentId) { - const agents = asOptionalObjectRecord(cfg.agents); - const list = Array.isArray(agents?.list) ? (agents?.list as AgentEntry[]) : []; - const entry = list.find( - (a) => typeof a.id === "string" && a.id.trim().toLowerCase() === agentId.trim().toLowerCase(), - ); - const agentGroupChat = entry?.groupChat; - if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) { - const patterns = agentGroupChat.mentionPatterns; - return Array.isArray(patterns) - ? patterns.filter((p): p is string => typeof p === "string") - : []; - } - } - - const messages = asOptionalObjectRecord(cfg.messages); - const globalGroupChat = asOptionalObjectRecord(messages?.groupChat); - if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) { - const patterns = globalGroupChat.mentionPatterns; - return Array.isArray(patterns) - ? patterns.filter((p): p is string => typeof p === "string") - : []; - } - - return []; -} diff --git a/extensions/qqbot/src/engine/config/resolve.test.ts b/extensions/qqbot/src/engine/config/resolve.test.ts deleted file mode 100644 index f89288014619..000000000000 --- a/extensions/qqbot/src/engine/config/resolve.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -// Qqbot tests cover resolve plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - applyAccountConfig, - DEFAULT_ACCOUNT_ID, - listAccountIds, - resolveDefaultAccountId, - resolveAccountBase, -} from "./resolve.js"; - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("engine/config/resolve", () => { - it("returns empty list when no accounts configured", () => { - expect(listAccountIds({})).toStrictEqual([]); - }); - - it("returns default when top-level appId is set", () => { - const cfg = { - channels: { - qqbot: { appId: "123456" }, - }, - }; - expect(listAccountIds(cfg)).toEqual([DEFAULT_ACCOUNT_ID]); - }); - - it("ignores blank app IDs when discovering configured accounts", () => { - vi.stubEnv("QQBOT_APP_ID", " "); - const cfg = { - channels: { - qqbot: { - appId: " ", - accounts: { - bot2: { appId: " " }, - }, - }, - }, - }; - - expect(listAccountIds(cfg)).toEqual([]); - expect(resolveDefaultAccountId(cfg)).toBe(DEFAULT_ACCOUNT_ID); - expect(resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID).appId).toBe(""); - }); - - it("uses non-blank QQBOT_APP_ID as an implicit default account", () => { - vi.stubEnv("QQBOT_APP_ID", " 123456 "); - - expect(listAccountIds({})).toEqual([DEFAULT_ACCOUNT_ID]); - expect(resolveDefaultAccountId({})).toBe(DEFAULT_ACCOUNT_ID); - expect(resolveAccountBase({}, DEFAULT_ACCOUNT_ID).appId).toBe("123456"); - }); - - it("lists named accounts", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { appId: "654321" }, - bot3: { appId: "111222" }, - }, - }, - }, - }; - const ids = listAccountIds(cfg); - expect(ids).toContain("bot2"); - expect(ids).toContain("bot3"); - }); - - it("ignores inherited appId on a named account when listing IDs", () => { - const account = Object.assign( - Object.create({ appId: "inherited-app-id" }) as Record, - { name: "Owned Bot" }, - ); - const cfg = { - channels: { - qqbot: { - accounts: { bot2: account }, - }, - }, - }; - - expect(listAccountIds(cfg)).toStrictEqual([]); - expect(resolveDefaultAccountId(cfg)).toBe(DEFAULT_ACCOUNT_ID); - }); - - it("ignores an inherited accounts container", () => { - const inheritedAccounts = { - bot2: { appId: "inherited-app-id", name: "Inherited Bot" }, - }; - const qqbot = Object.create({ accounts: inheritedAccounts }) as Record; - const cfg = { channels: { qqbot } }; - const base = resolveAccountBase(cfg, "bot2"); - - expect(listAccountIds(cfg)).toStrictEqual([]); - expect(resolveDefaultAccountId(cfg)).toBe(DEFAULT_ACCOUNT_ID); - expect(base.appId).toBe(""); - expect(base.config).toEqual({}); - expect(Object.hasOwn(qqbot, "accounts")).toBe(false); - }); - - it("resolves default account id to 'default' when top-level appId exists", () => { - const cfg = { - channels: { - qqbot: { appId: "123456" }, - }, - }; - expect(resolveDefaultAccountId(cfg)).toBe(DEFAULT_ACCOUNT_ID); - }); - - it("honors configured defaultAccount", () => { - const cfg = { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { appId: "654321" }, - }, - }, - }, - }; - expect(resolveDefaultAccountId(cfg)).toBe("bot2"); - }); - - it("falls back to first named account when no default configured", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - mybot: { appId: "999999" }, - }, - }, - }, - }; - expect(resolveDefaultAccountId(cfg)).toBe("mybot"); - }); - - it("resolves base account info for default account", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - name: "Test Bot", - systemPrompt: "You are helpful.", - markdownSupport: true, - }, - }, - }; - const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID); - expect(base.accountId).toBe(DEFAULT_ACCOUNT_ID); - expect(base.appId).toBe("123456"); - expect(base.name).toBe("Test Bot"); - expect(base.systemPrompt).toBe("You are helpful."); - expect(base.markdownSupport).toBe(true); - expect(base.enabled).toBe(true); - }); - - it("merges accounts.default into the default account config", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - name: "Top Bot", - groups: { G1: { commandLevel: "all" } }, - accounts: { - default: { - appId: "654321", - name: "Default Bot", - groups: { G1: { commandLevel: "safety" } }, - }, - }, - }, - }, - }; - - const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID); - - expect(base.name).toBe("Default Bot"); - expect(base.appId).toBe("654321"); - expect(base.config.groups).toEqual({ G1: { commandLevel: "safety" } }); - }); - - it("resolves base account info for named account", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - bot2: { - appId: "654321", - name: "Bot Two", - enabled: false, - }, - }, - }, - }, - }; - const base = resolveAccountBase(cfg, "bot2"); - expect(base.accountId).toBe("bot2"); - expect(base.appId).toBe("654321"); - expect(base.name).toBe("Bot Two"); - expect(base.enabled).toBe(false); - }); - - it("ignores inherited fields on an own named account entry", () => { - const account = Object.assign( - Object.create({ - appId: "inherited-app-id", - clientSecret: "placeholder", - clientSecretFile: "/tmp/placeholder", - }) as Record, - { name: "Owned Bot", enabled: false }, - ); - const cfg = { - channels: { - qqbot: { - accounts: { bot2: account }, - }, - }, - }; - - const base = resolveAccountBase(cfg, "bot2"); - - expect(account.appId).toBe("inherited-app-id"); - expect(base.appId).toBe(""); - expect(base.name).toBe("Owned Bot"); - expect(base.enabled).toBe(false); - expect(base.config).toEqual({ name: "Owned Bot", enabled: false }); - expect(base.config.clientSecret).toBeUndefined(); - expect(base.config.clientSecretFile).toBeUndefined(); - }); - - it("does not copy an inherited accounts container during named-account setup", () => { - const qqbot = Object.create({ - accounts: { - inherited: { appId: "inherited-app-id" }, - }, - }) as Record; - - const next = applyAccountConfig({ channels: { qqbot } }, "bot2", { - appId: "owned-app-id", - }); - const nextAccounts = ( - (next.channels as Record).qqbot as Record - ).accounts as Record; - - expect(Object.hasOwn(nextAccounts, "inherited")).toBe(false); - expect(nextAccounts).toEqual({ - bot2: { - enabled: true, - allowFrom: ["*"], - appId: "owned-app-id", - }, - }); - }); - - it("uses configured defaultAccount when accountId is omitted", () => { - const cfg = { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { appId: "654321" }, - }, - }, - }, - }; - const base = resolveAccountBase(cfg); - expect(base.accountId).toBe("bot2"); - expect(base.appId).toBe("654321"); - }); - - it("preserves audioFormatPolicy on the config object", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - audioFormatPolicy: { - sttDirectFormats: [".wav"], - uploadDirectFormats: [".mp3"], - transcodeEnabled: false, - }, - }, - }, - }; - const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID); - expect(base.config.audioFormatPolicy).toEqual({ - sttDirectFormats: [".wav"], - uploadDirectFormats: [".mp3"], - transcodeEnabled: false, - }); - }); -}); diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts deleted file mode 100644 index 4f4762e549f4..000000000000 --- a/extensions/qqbot/src/engine/config/resolve.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * QQBot config resolution (pure logic layer). - * QQBot 配置解析(纯逻辑层)。 - * - * Resolves account IDs, default account selection, and base account - * info from raw config objects. Secret/credential resolution is - * intentionally left to the outer layer (src/bridge/config.ts) so that - * this module stays framework-agnostic and self-contained. - */ - -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, - normalizeStringifiedEntries, - readStringField, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { getPlatformAdapter } from "../adapter/index.js"; -import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; - -/** - * Default account ID, used for the unnamed top-level account. - * 默认账号 ID,用于顶层配置中未命名的账号。 - */ -export const DEFAULT_ACCOUNT_ID = "default"; - -/** - * Internal shape of the channels.qqbot config section. - * channels.qqbot 配置节的内部结构。 - */ -interface QQBotChannelConfig { - appId?: unknown; - clientSecret?: unknown; - clientSecretFile?: string; - accounts?: Record>; - defaultAccount?: unknown; - [key: string]: unknown; -} - -/** - * Base account resolution result (without credentials). - * 账号基础解析结果(不含凭证信息)。 - * - * The outer config.ts layer extends this with clientSecret / secretSource. - */ -interface ResolvedAccountBase { - accountId: string; - name?: string; - enabled: boolean; - appId: string; - systemPrompt?: string; - markdownSupport: boolean; - config: Record; -} - -function normalizeOptionalAppId(raw: unknown): string | undefined { - if (typeof raw === "number") { - return String(raw); - } - return normalizeOptionalString(raw); -} - -function normalizeAppId(raw: unknown): string { - return normalizeOptionalAppId(raw) ?? ""; -} - -function hasAppId(raw: unknown): boolean { - return normalizeOptionalAppId(raw) !== undefined; -} - -function normalizeAccountConfig( - account: Record | undefined, -): Record { - if (!account) { - return {}; - } - const audioPolicy = asOptionalObjectRecord(account.audioFormatPolicy); - return { - ...account, - ...(audioPolicy ? { audioFormatPolicy: { ...audioPolicy } } : {}), - }; -} - -function readQQBotSection(cfg: Record): QQBotChannelConfig | undefined { - const channels = asOptionalObjectRecord(cfg.channels); - return asOptionalObjectRecord(channels?.qqbot) as QQBotChannelConfig | undefined; -} - -function readOwnAccounts( - qqbot: Record | undefined, -): QQBotChannelConfig["accounts"] | undefined { - if (!qqbot || !Object.hasOwn(qqbot, "accounts")) { - return undefined; - } - return asOptionalObjectRecord(qqbot.accounts) as QQBotChannelConfig["accounts"] | undefined; -} - -function readOwnAccountConfig( - qqbot: Record | undefined, - accountId: string, -): Record | undefined { - const accounts = readOwnAccounts(qqbot); - if (!accounts || !Object.hasOwn(accounts, accountId)) { - return undefined; - } - const account = asOptionalObjectRecord(accounts[accountId]); - return account ? { ...account } : undefined; -} - -/** - * List all configured QQBot account IDs. - * 列出所有已配置的 QQBot 账号 ID。 - */ -export function listAccountIds(cfg: Record): string[] { - const ids = new Set(); - const qqbot = readQQBotSection(cfg); - - if (hasAppId(qqbot?.appId) || hasAppId(process.env.QQBOT_APP_ID)) { - ids.add(DEFAULT_ACCOUNT_ID); - } - - const accounts = readOwnAccounts(qqbot); - if (accounts) { - for (const accountId of Object.keys(accounts)) { - if (hasAppId(readOwnAccountConfig(qqbot, accountId)?.appId)) { - ids.add(accountId); - } - } - } - - return Array.from(ids); -} - -/** - * Resolve the default QQBot account ID. - * 解析默认 QQBot 账号 ID(优先级:defaultAccount > 顶层 appId > 第一个命名账号)。 - */ -export function resolveDefaultAccountId(cfg: Record): string { - const qqbot = readQQBotSection(cfg); - const accounts = readOwnAccounts(qqbot); - const configuredDefaultAccountId = normalizeOptionalLowercaseString(qqbot?.defaultAccount); - if ( - configuredDefaultAccountId && - (configuredDefaultAccountId === DEFAULT_ACCOUNT_ID || - hasAppId(readOwnAccountConfig(qqbot, configuredDefaultAccountId)?.appId)) - ) { - return configuredDefaultAccountId; - } - if (hasAppId(qqbot?.appId) || hasAppId(process.env.QQBOT_APP_ID)) { - return DEFAULT_ACCOUNT_ID; - } - if (accounts) { - const ids = Object.keys(accounts); - const firstId = ids.find((id) => hasAppId(readOwnAccountConfig(qqbot, id)?.appId)); - if (firstId !== undefined) { - return firstId; - } - } - return DEFAULT_ACCOUNT_ID; -} - -/** - * Resolve base account info (without credentials). - * 解析账号基础信息(不含凭证)。 - * - * Resolves everything except Secret/credential fields. The outer - * config.ts layer calls this and adds Secret handling on top. - */ -export function resolveAccountBase( - cfg: Record, - accountId?: string | null, -): ResolvedAccountBase { - const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg); - const qqbot = readQQBotSection(cfg); - - let accountConfig: Record; - let appId; - - if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { - accountConfig = normalizeAccountConfig({ - ...asOptionalObjectRecord(qqbot), - ...readOwnAccountConfig(qqbot, DEFAULT_ACCOUNT_ID), - }); - appId = normalizeAppId(accountConfig.appId); - } else { - const account = readOwnAccountConfig(qqbot, resolvedAccountId); - accountConfig = normalizeAccountConfig(account); - appId = normalizeAppId(account?.appId); - } - - if (!appId && hasAppId(process.env.QQBOT_APP_ID) && resolvedAccountId === DEFAULT_ACCOUNT_ID) { - appId = normalizeAppId(process.env.QQBOT_APP_ID); - } - - return { - accountId: resolvedAccountId, - name: readStringField(accountConfig, "name"), - enabled: accountConfig.enabled !== false, - appId, - systemPrompt: readStringField(accountConfig, "systemPrompt"), - markdownSupport: accountConfig.markdownSupport !== false, - config: accountConfig, - }; -} - -// ---- Account config apply ---- - -interface ApplyAccountInput { - appId?: string; - clientSecret?: string; - clientSecretFile?: string; - name?: string; -} - -/** Apply account config updates into a raw config object. */ -export function applyAccountConfig( - cfg: Record, - accountId: string, - input: ApplyAccountInput, -): Record { - const next = { ...cfg }; - const channels = asOptionalObjectRecord(cfg.channels) ?? {}; - const existingQQBot = asOptionalObjectRecord(channels.qqbot) ?? {}; - - if (accountId === DEFAULT_ACCOUNT_ID) { - const allowFrom = (existingQQBot.allowFrom as unknown[]) ?? ["*"]; - next.channels = { - ...channels, - qqbot: { - ...existingQQBot, - enabled: true, - allowFrom, - ...(input.appId ? { appId: input.appId } : {}), - ...(input.clientSecret - ? { clientSecret: input.clientSecret, clientSecretFile: undefined } - : input.clientSecretFile - ? { clientSecretFile: input.clientSecretFile, clientSecret: undefined } - : {}), - ...(input.name ? { name: input.name } : {}), - }, - }; - } else { - const accounts = readOwnAccounts(existingQQBot) ?? {}; - const existingAccount = readOwnAccountConfig(existingQQBot, accountId) ?? {}; - const allowFrom = (existingAccount.allowFrom as unknown[]) ?? ["*"]; - next.channels = { - ...channels, - qqbot: { - ...existingQQBot, - enabled: true, - accounts: { - ...accounts, - [accountId]: { - ...existingAccount, - enabled: true, - allowFrom, - ...(input.appId ? { appId: input.appId } : {}), - ...(input.clientSecret - ? { clientSecret: input.clientSecret, clientSecretFile: undefined } - : input.clientSecretFile - ? { clientSecretFile: input.clientSecretFile, clientSecret: undefined } - : {}), - ...(input.name ? { name: input.name } : {}), - }, - }, - }, - }; - } - - return next; -} - -// ---- Account status helpers ---- - -/** Resolved account shape expected by isAccountConfigured / describeAccount. */ -interface AccountSnapshot { - accountId: string; - name?: string; - enabled: boolean; - appId: string; - clientSecret?: string; - secretSource?: string; - config: Record & { - clientSecret?: unknown; - clientSecretFile?: string; - }; -} - -/** Check whether a QQBot account has been fully configured. */ -export function isAccountConfigured(account: AccountSnapshot | undefined): boolean { - return Boolean( - account?.appId && - (Boolean(account?.clientSecret) || - getPlatformAdapter().hasConfiguredSecret(account?.config?.clientSecret) || - Boolean(account?.config?.clientSecretFile?.trim())), - ); -} - -/** Build a summary description of an account. */ -export function describeAccount(account: AccountSnapshot | undefined) { - return { - accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID, - name: account?.name, - enabled: account?.enabled ?? false, - configured: isAccountConfigured(account), - tokenSource: account?.secretSource, - }; -} - -/** Normalize allowFrom entries into uppercase strings without the qqbot: prefix. */ -export function formatAllowFrom(allowFrom: Array | undefined | null): string[] { - return normalizeStringifiedEntries(allowFrom ?? []) - .map((entry) => entry.replace(/^qqbot:/i, "")) - .map((entry) => entry.toUpperCase()); -} diff --git a/extensions/qqbot/src/engine/config/setup-guidance.test.ts b/extensions/qqbot/src/engine/config/setup-guidance.test.ts deleted file mode 100644 index 36ff0d58e139..000000000000 --- a/extensions/qqbot/src/engine/config/setup-guidance.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - qqbotApiGuidance, - qqbotNetworkGuidance, - qqbotNotConfiguredMessage, -} from "./setup-guidance.js"; - -describe("QQBot setup guidance", () => { - it("offers default-account config and environment variables", () => { - const message = qqbotNotConfiguredMessage("default"); - - expect(message).toContain("channels.qqbot.appId"); - expect(message).toContain("QQBOT_APP_ID and QQBOT_CLIENT_SECRET"); - expect(message).toContain("https://docs.openclaw.ai/channels/qqbot"); - }); - - it("directs named accounts to account-scoped config without default-only environment variables", () => { - const message = qqbotNotConfiguredMessage("operations"); - - expect(message).toContain("channels.qqbot.accounts.operations.appId"); - expect(message).toContain("clientSecret (or clientSecretFile)"); - expect(message).not.toContain("QQBOT_APP_ID"); - expect(message).not.toContain("QQBOT_CLIENT_SECRET"); - }); - - it("keeps authentication guidance account-neutral", () => { - const message = qqbotApiGuidance(401); - - expect(message).toContain("QQBot account appId"); - expect(message).toContain("https://q.qq.com/"); - expect(message).not.toContain("QQBOT_APP_ID"); - expect(message).not.toContain("QQBOT_CLIENT_SECRET"); - }); - - it("keeps network guidance cause-specific", () => { - const message = qqbotNetworkGuidance(); - - expect(message).toContain("network connectivity and DNS"); - expect(message).toContain("server IP whitelist"); - expect(message).not.toContain("appId"); - expect(message).not.toContain("clientSecret"); - }); - - it("uses credential guidance for HTTP and QQ business-code auth failures", () => { - expect(qqbotApiGuidance(401)).toContain("appId and clientSecret"); - expect(qqbotApiGuidance(500, 11244)).toContain("appId and clientSecret"); - expect(qqbotApiGuidance(403)).not.toContain("appId"); - expect(qqbotApiGuidance(500, 40034025)).not.toContain("appId"); - expect(qqbotApiGuidance(429)).not.toContain("appId"); - }); -}); diff --git a/extensions/qqbot/src/engine/config/setup-guidance.ts b/extensions/qqbot/src/engine/config/setup-guidance.ts deleted file mode 100644 index 8288e2e84eb5..000000000000 --- a/extensions/qqbot/src/engine/config/setup-guidance.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { isQQBotTokenAuthenticationFailure } from "../api/auth-errors.js"; -import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; - -const QQBOT_DOCS_URL = "https://docs.openclaw.ai/channels/qqbot"; -const QQBOT_OPEN_PLATFORM_URL = "https://q.qq.com/"; - -function qqbotAuthGuidance(): string { - return `Check the QQBot account appId and clientSecret (or clientSecretFile) in OpenClaw and verify the credentials in QQ Open Platform at ${QQBOT_OPEN_PLATFORM_URL}. See ${QQBOT_DOCS_URL}`; -} - -export function qqbotNetworkGuidance(): string { - return `Check network connectivity and DNS, and verify the server IP whitelist in QQ Open Platform at ${QQBOT_OPEN_PLATFORM_URL}. See ${QQBOT_DOCS_URL}`; -} - -export function qqbotApiGuidance(httpStatus: number, bizCode?: number): string { - return isQQBotTokenAuthenticationFailure(httpStatus, bizCode) - ? qqbotAuthGuidance() - : `See ${QQBOT_DOCS_URL} for QQBot API troubleshooting`; -} - -export function qqbotNotConfiguredMessage(accountId: string): string { - const guidance = - accountId === DEFAULT_ACCOUNT_ID - ? `Set channels.qqbot.appId and clientSecret (or clientSecretFile), or set QQBOT_APP_ID and QQBOT_CLIENT_SECRET. See ${QQBOT_DOCS_URL}` - : `Set channels.qqbot.accounts.${accountId}.appId and clientSecret (or clientSecretFile). See ${QQBOT_DOCS_URL}`; - return `QQBot not configured (missing appId or clientSecret). ${guidance}`; -} - -export function qqbotTokenFailureMessage(detail: string): string { - return `Failed to get QQBot access_token. ${qqbotAuthGuidance()}. Open platform response: ${detail}`; -} diff --git a/extensions/qqbot/src/engine/config/setup-logic.ts b/extensions/qqbot/src/engine/config/setup-logic.ts deleted file mode 100644 index 7206c4cb0f14..000000000000 --- a/extensions/qqbot/src/engine/config/setup-logic.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * QQBot setup business logic (pure layer). - * QQBot setup 相关纯业务逻辑。 - * - * Token parsing, input validation, and setup config application. - * All functions are framework-agnostic and operate on plain objects. - */ - -import { applyAccountConfig } from "./resolve.js"; -import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; - -/** Parse an inline "appId:clientSecret" token string. */ -function parseInlineToken(token: string): { appId: string; clientSecret: string } | null { - const colonIdx = token.indexOf(":"); - if (colonIdx <= 0 || colonIdx === token.length - 1) { - return null; - } - - const appId = token.slice(0, colonIdx).trim(); - const clientSecret = token.slice(colonIdx + 1).trim(); - if (!appId || !clientSecret) { - return null; - } - - return { appId, clientSecret }; -} - -interface SetupInput { - token?: string; - tokenFile?: string; - useEnv?: boolean; - name?: string; -} - -/** Validate setup input for a QQBot account. Returns an error string or null. */ -export function validateSetupInput(accountId: string, input: SetupInput): string | null { - if (!input.token && !input.tokenFile && !input.useEnv) { - return "QQBot requires --token (format: appId:clientSecret) or --use-env"; - } - - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "QQBot --use-env only supports the default account"; - } - - if (input.token && !parseInlineToken(input.token)) { - return "QQBot --token must be in appId:clientSecret format"; - } - - return null; -} - -/** Apply setup input to account config. Returns updated config. */ -export function applySetupAccountConfig( - cfg: Record, - accountId: string, - input: SetupInput, -): Record { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return cfg; - } - - let appId = ""; - let clientSecret = ""; - - if (input.token) { - const parsed = parseInlineToken(input.token); - if (!parsed) { - return cfg; - } - appId = parsed.appId; - clientSecret = parsed.clientSecret; - } - - if (!appId && !input.tokenFile && !input.useEnv) { - return cfg; - } - - return applyAccountConfig(cfg, accountId, { - appId, - clientSecret, - clientSecretFile: input.tokenFile, - name: input.name, - }); -} diff --git a/extensions/qqbot/src/engine/engine-import-boundary.test.ts b/extensions/qqbot/src/engine/engine-import-boundary.test.ts deleted file mode 100644 index 7bcb98e7c5e2..000000000000 --- a/extensions/qqbot/src/engine/engine-import-boundary.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Engine import boundary test. - * - * Ensures that engine/ sources only import from `openclaw/plugin-sdk/*` - * and never reach into other openclaw internals directly. - */ - -import fs from "node:fs"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it } from "vitest"; - -const ENGINE_DIR = path.resolve(import.meta.dirname); - -/** Recursively collect all non-test .ts files under a directory. */ -function walkSourceFiles(dir: string, files: string[] = []): string[] { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules" || entry.name === "dist") { - continue; - } - walkSourceFiles(fullPath, files); - continue; - } - if ( - entry.name.endsWith(".ts") && - !entry.name.endsWith(".test.ts") && - !entry.name.endsWith(".spec.ts") - ) { - files.push(fullPath); - } - } - return files; -} - -/** - * Extract all `openclaw/...` import specifiers from source text. - * Matches: import ... from "openclaw/...", import("openclaw/...") - */ -function findOpenclawImports(source: string): string[] { - return [ - ...source.matchAll(/from\s+["'](openclaw\/[^"']+)["']/g), - ...source.matchAll(/import\(\s*["'](openclaw\/[^"']+)["']\s*\)/g), - ].map((match) => expectDefined(match[1], "OpenClaw import specifier")); -} - -/** Check if an import specifier is an allowed openclaw/plugin-sdk subpath. */ -const ALLOWED_PREFIX = ["openclaw", "plugin-sdk"].join("/"); -function isAllowedImport(specifier: string): boolean { - return specifier.startsWith(ALLOWED_PREFIX); -} - -describe("engine import boundary", () => { - it("only imports from openclaw/plugin-sdk, never from other openclaw internals", () => { - const sourceFiles = walkSourceFiles(ENGINE_DIR); - const offenders: Array<{ file: string; imports: string[] }> = []; - - for (const file of sourceFiles) { - const source = fs.readFileSync(file, "utf8"); - const openclawImports = findOpenclawImports(source); - const forbidden = openclawImports.filter((specifier) => !isAllowedImport(specifier)); - - if (forbidden.length > 0) { - offenders.push({ - file: path.relative(ENGINE_DIR, file), - imports: forbidden, - }); - } - } - - expect(offenders).toStrictEqual([]); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/codec.ts b/extensions/qqbot/src/engine/gateway/codec.ts deleted file mode 100644 index d2a2c492d2b9..000000000000 --- a/extensions/qqbot/src/engine/gateway/codec.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Gateway message decoding utilities. - * - * Extracted from `gateway.ts` — handles the various data formats that - * the QQ Bot WebSocket can deliver (string, Buffer, Buffer[], ArrayBuffer). - * - * Zero external dependencies beyond Node.js built-ins. - */ - -/** - * Decode raw WebSocket `data` into a UTF-8 string. - * - * The QQ Bot gateway can send data as a plain string, a single Buffer, - * an array of Buffer chunks, an ArrayBuffer, or a typed array view. - */ -export function decodeGatewayMessageData(data: unknown): string { - if (typeof data === "string") { - return data; - } - if (Buffer.isBuffer(data)) { - return data.toString("utf8"); - } - if (Array.isArray(data) && data.every((chunk) => Buffer.isBuffer(chunk))) { - return Buffer.concat(data).toString("utf8"); - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf8"); - } - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); - } - return ""; -} - -/** - * Read the optional `message_scene.ext` array from an event payload. - * - * Guild, C2C, and Group events may carry a `message_scene` object - * with an `ext` string array used for ref-index parsing. - */ -export function readOptionalMessageSceneExt(event: Record): string[] | undefined { - if (!("message_scene" in event)) { - return undefined; - } - const scene = event.message_scene as { ext?: string[] } | undefined; - return scene?.ext; -} diff --git a/extensions/qqbot/src/engine/gateway/constants.ts b/extensions/qqbot/src/engine/gateway/constants.ts deleted file mode 100644 index a21ff6d473e5..000000000000 --- a/extensions/qqbot/src/engine/gateway/constants.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * QQ Bot WebSocket Gateway protocol constants. - * - * Extracted from `gateway.ts` to share between both plugin versions. - * Zero external dependencies. - */ - -/** QQ Bot WebSocket intents grouped by permission level. */ -const INTENTS = { - GUILDS: 1 << 0, - GUILD_MEMBERS: 1 << 1, - PUBLIC_GUILD_MESSAGES: 1 << 30, - DIRECT_MESSAGE: 1 << 12, - GROUP_AND_C2C: 1 << 25, - /** Button interaction callbacks (INTERACTION_CREATE). */ - INTERACTION: 1 << 26, -} as const; - -/** Full intent mask: groups + DMs + channels + interaction. */ -export const FULL_INTENTS = - INTENTS.PUBLIC_GUILD_MESSAGES | - INTENTS.DIRECT_MESSAGE | - INTENTS.GROUP_AND_C2C | - INTENTS.INTERACTION; - -/** Exponential backoff delays for reconnection attempts (ms). */ -export const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000] as const; - -/** Delay after receiving a rate-limit close code (ms). */ -export const RATE_LIMIT_DELAY = 60000; - -/** Maximum reconnection attempts before giving up. */ -export const MAX_RECONNECT_ATTEMPTS = 100; - -/** How many quick disconnects before warning about permissions. */ -export const MAX_QUICK_DISCONNECT_COUNT = 3; - -/** A disconnect within this window (ms) counts as "quick". */ -export const QUICK_DISCONNECT_THRESHOLD = 5000; - -// ============ Opcode Constants ============ - -/** Gateway opcodes used by the QQ Bot WebSocket protocol. */ -export const GatewayOp = { - /** Server → Client: Dispatch event (type + data). */ - DISPATCH: 0, - /** Client → Server: Heartbeat. */ - HEARTBEAT: 1, - /** Client → Server: Identify (initial auth). */ - IDENTIFY: 2, - /** Client → Server: Resume a dropped session. */ - RESUME: 6, - /** Server → Client: Request client to reconnect. */ - RECONNECT: 7, - /** Server → Client: Invalid session. */ - INVALID_SESSION: 9, - /** Server → Client: Hello (heartbeat interval). */ - HELLO: 10, - /** Server → Client: Heartbeat ACK. */ - HEARTBEAT_ACK: 11, -} as const; - -// ============ Close Codes ============ - -/** WebSocket close codes used by the QQ Gateway. */ -export const GatewayCloseCode = { - /** Normal closure — do not reconnect. */ - NORMAL: 1000, - /** Authentication failed — refresh token then reconnect. */ - AUTH_FAILED: 4004, - /** Session invalid — clear session, refresh token, reconnect. */ - INVALID_SESSION: 4006, - /** Sequence number out of range — clear session, refresh token, reconnect. */ - SEQ_OUT_OF_RANGE: 4007, - /** Rate limited — wait before reconnecting. */ - RATE_LIMITED: 4008, - /** Session timed out — clear session, refresh token, reconnect. */ - SESSION_TIMEOUT: 4009, - /** Server internal error (range start) — clear session, refresh token, reconnect. */ - SERVER_ERROR_START: 4900, - /** Server internal error (range end). */ - SERVER_ERROR_END: 4913, - /** Insufficient intents — fatal, do not reconnect. */ - INSUFFICIENT_INTENTS: 4914, - /** Disallowed intents — fatal, do not reconnect. */ - DISALLOWED_INTENTS: 4915, -} as const; - -// ============ Dispatch Event Types ============ - -/** Event type strings dispatched under opcode 0 (DISPATCH). */ -export const GatewayEvent = { - READY: "READY", - RESUMED: "RESUMED", - C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE", - AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE", - DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE", - /** Group message that explicitly @-mentions the bot. */ - GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE", - /** - * Group message that does NOT mention the bot. Still dispatched to the - * pipeline so the group history buffer and the `requireMention=false` - * path can observe it. - */ - GROUP_MESSAGE_CREATE: "GROUP_MESSAGE_CREATE", - INTERACTION_CREATE: "INTERACTION_CREATE", -} as const; - -// ============ Interaction Type Constants ============ - -/** Interaction sub-types carried in `InteractionEvent.data.type`. */ -export const InteractionType = { - /** Remote config query — bot reports its current claw_cfg snapshot. */ - CONFIG_QUERY: 2001, - /** Remote config update — caller pushes new settings. */ - CONFIG_UPDATE: 2002, -} as const; diff --git a/extensions/qqbot/src/engine/gateway/event-dispatcher.ts b/extensions/qqbot/src/engine/gateway/event-dispatcher.ts deleted file mode 100644 index d704184ce6f2..000000000000 --- a/extensions/qqbot/src/engine/gateway/event-dispatcher.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Event dispatcher — convert raw WebSocket op=0 events into QueuedMessage objects. - * - * Pure mapping logic with zero side effects (except known-user recording). - * Independently testable. - */ - -import { recordKnownUser } from "../session/known-users.js"; -import type { InteractionEvent } from "../types.js"; -import { parseRefIndices } from "../utils/text-parsing.js"; -import { readOptionalMessageSceneExt } from "./codec.js"; -import { GatewayEvent } from "./constants.js"; -import type { QueuedMessage } from "./message-queue.js"; -import type { - C2CMessageEvent, - GuildMessageEvent, - GroupMessageEvent, - EngineLogger, -} from "./types.js"; - -// ============ Dispatch result ============ - -type DispatchResult = - | { action: "ready"; data: unknown; sessionId: string } - | { action: "resumed"; data: unknown } - | { action: "message"; msg: QueuedMessage } - | { action: "interaction"; event: InteractionEvent } - | { action: "ignore" }; - -// ============ dispatchEvent ============ - -/** - * Map a raw op=0 event into a structured dispatch result. - * - * Returns "message" for events that should be queued for processing, - * "ready"/"resumed" for session lifecycle events, and "ignore" otherwise. - */ -export function dispatchEvent( - eventType: string, - data: unknown, - accountId: string, - _log?: EngineLogger, -): DispatchResult { - if (eventType === GatewayEvent.READY) { - const d = data as { session_id: string }; - return { action: "ready", data, sessionId: d.session_id }; - } - - if (eventType === GatewayEvent.RESUMED) { - return { action: "resumed", data }; - } - - if (eventType === GatewayEvent.C2C_MESSAGE_CREATE) { - const ev = data as C2CMessageEvent; - recordKnownUser({ - openid: ev.author.user_openid, - type: "c2c", - accountId, - }); - const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements); - return { - action: "message", - msg: { - type: "c2c", - senderId: ev.author.user_openid, - content: ev.content, - messageId: ev.id, - timestamp: ev.timestamp, - attachments: ev.attachments, - refMsgIdx: refs.refMsgIdx, - msgIdx: refs.msgIdx, - msgType: ev.message_type, - msgElements: ev.msg_elements, - }, - }; - } - - if (eventType === GatewayEvent.AT_MESSAGE_CREATE) { - const ev = data as GuildMessageEvent; - const refs = parseRefIndices( - readOptionalMessageSceneExt(ev as unknown as Record), - ); - return { - action: "message", - msg: { - type: "guild", - senderId: ev.author.id, - senderName: ev.author.username, - content: ev.content, - messageId: ev.id, - timestamp: ev.timestamp, - channelId: ev.channel_id, - guildId: ev.guild_id, - attachments: ev.attachments, - refMsgIdx: refs.refMsgIdx, - msgIdx: refs.msgIdx, - }, - }; - } - - if (eventType === GatewayEvent.DIRECT_MESSAGE_CREATE) { - const ev = data as GuildMessageEvent; - const refs = parseRefIndices( - readOptionalMessageSceneExt(ev as unknown as Record), - ); - return { - action: "message", - msg: { - type: "dm", - senderId: ev.author.id, - senderName: ev.author.username, - content: ev.content, - messageId: ev.id, - timestamp: ev.timestamp, - guildId: ev.guild_id, - attachments: ev.attachments, - refMsgIdx: refs.refMsgIdx, - msgIdx: refs.msgIdx, - }, - }; - } - - if (eventType === GatewayEvent.GROUP_AT_MESSAGE_CREATE) { - return { action: "message", msg: buildGroupQueuedMessage(data, accountId, eventType) }; - } - - if (eventType === GatewayEvent.GROUP_MESSAGE_CREATE) { - return { action: "message", msg: buildGroupQueuedMessage(data, accountId, eventType) }; - } - - if (eventType === GatewayEvent.INTERACTION_CREATE) { - return { action: "interaction", event: data as InteractionEvent }; - } - - return { action: "ignore" }; -} - -/** - * Build a {@link QueuedMessage} from a raw QQ group event payload. - * - * Used for both `GROUP_AT_MESSAGE_CREATE` (bot was @-ed) and - * `GROUP_MESSAGE_CREATE` (non-@ background chatter). The only difference - * between the two is the carried `eventType` — downstream gating uses - * that to decide whether to treat the message as a bot-directed turn. - */ -function buildGroupQueuedMessage( - data: unknown, - accountId: string, - eventType: string, -): QueuedMessage { - const ev = data as GroupMessageEvent; - recordKnownUser({ - openid: ev.author.member_openid, - type: "group", - groupOpenid: ev.group_openid, - accountId, - }); - const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements); - return { - type: "group", - senderId: ev.author.member_openid, - senderName: ev.author.username, - senderIsBot: ev.author.bot, - content: ev.content, - messageId: ev.id, - timestamp: ev.timestamp, - groupOpenid: ev.group_openid, - attachments: ev.attachments, - refMsgIdx: refs.refMsgIdx, - msgIdx: refs.msgIdx, - msgType: ev.message_type, - msgElements: ev.msg_elements, - eventType, - mentions: ev.mentions, - messageScene: ev.message_scene, - }; -} diff --git a/extensions/qqbot/src/engine/gateway/gateway-connection.test.ts b/extensions/qqbot/src/engine/gateway/gateway-connection.test.ts deleted file mode 100644 index c2b7e424312e..000000000000 --- a/extensions/qqbot/src/engine/gateway/gateway-connection.test.ts +++ /dev/null @@ -1,569 +0,0 @@ -// Qqbot tests cover gateway connection close/disconnect status behavior. -import { EventEmitter } from "node:events"; -import { expectDefined } from "@openclaw/normalization-core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EngineAdapters } from "../adapter/index.js"; -import { stopBackgroundTokenRefresh } from "../messaging/sender.js"; -import { flushRefIndex } from "../ref/store.js"; -import { flushKnownUsers } from "../session/known-users.js"; -import { GatewayEvent, GatewayOp, MAX_RECONNECT_ATTEMPTS } from "./constants.js"; -import { GatewayConnection } from "./gateway-connection.js"; -import { QQBotIngressAdmissionError, type QQBotIngressMonitor } from "./ingress.js"; -import type { EngineLogger, GatewayAccount, GatewayPluginRuntime } from "./types.js"; - -const createQQWSClientMock = vi.hoisted(() => vi.fn()); - -vi.mock("./ws-client.js", () => ({ - createQQWSClient: createQQWSClientMock, -})); - -vi.mock("../messaging/sender.js", () => ({ - getAccessToken: vi.fn(async () => "test-token"), - getGatewayUrl: vi.fn(async () => "wss://mock-gateway"), - getPluginUserAgent: vi.fn(() => "test-agent"), - startBackgroundTokenRefresh: vi.fn(), - stopBackgroundTokenRefresh: vi.fn(), - clearTokenCache: vi.fn(), -})); - -vi.mock("../session/session-store.js", () => ({ - loadSession: vi.fn(() => undefined), - saveSession: vi.fn(), - clearSession: vi.fn(), -})); - -vi.mock("../session/known-users.js", () => ({ - recordKnownUser: vi.fn(), - flushKnownUsers: vi.fn(), -})); - -vi.mock("../ref/store.js", () => ({ - flushRefIndex: vi.fn(), -})); - -vi.mock("../commands/slash-command-handler.js", () => ({ - trySlashCommand: vi.fn(async () => "enqueue"), -})); - -class FakeWebSocket extends EventEmitter { - readyState = 3; // CLOSED — keeps cleanup() from re-entering close() - close = vi.fn(); - terminate = vi.fn(); - send = vi.fn(); -} - -function createNoopIngressMonitor() { - return { - receive: vi.fn(async () => {}), - stop: vi.fn(async () => {}), - waitForIdle: vi.fn(async () => {}), - }; -} - -function makeAccount(): GatewayAccount { - return { - accountId: "test-account", - appId: "test-app", - clientSecret: "test-secret", - markdownSupport: false, - config: {}, - }; -} - -async function startConnection(params: { - log?: EngineLogger; - onDisconnected?: (info: unknown) => void; - onError?: (error: Error) => void; - createIngressMonitor?: () => QQBotIngressMonitor; -}) { - const ws = new FakeWebSocket(); - createQQWSClientMock.mockResolvedValue(ws); - const controller = new AbortController(); - const connection = new GatewayConnection({ - account: makeAccount(), - abortSignal: controller.signal, - cfg: {}, - runtime: {} as GatewayPluginRuntime, - adapters: {} as EngineAdapters, - log: params.log, - handleMessage: async () => {}, - createIngressMonitor: createNoopIngressMonitor, - ...(params.createIngressMonitor ? { createIngressMonitor: params.createIngressMonitor } : {}), - onDisconnected: params.onDisconnected, - onError: params.onError, - }); - const started = connection.start(); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalled(); - }); - return { ws, controller, started }; -} - -describe("GatewayConnection disconnect status", () => { - beforeEach(() => { - vi.useFakeTimers(); - createQQWSClientMock.mockReset(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it("reports a fatal disconnect when the close code says the bot is banned", async () => { - const onDisconnected = vi.fn(); - const { ws, controller, started } = await startConnection({ onDisconnected }); - - ws.emit("close", 4915, Buffer.from("")); - - expect(onDisconnected).toHaveBeenCalledWith({ reason: "banned", fatal: true }); - controller.abort(); - await started; - }); - - it("reports a non-fatal disconnect on a transient close before reconnecting", async () => { - const onDisconnected = vi.fn(); - const { ws, controller, started } = await startConnection({ onDisconnected }); - - ws.emit("close", 1006, Buffer.from("")); - - expect(onDisconnected).toHaveBeenCalledWith({ reason: "close code 1006", fatal: false }); - controller.abort(); - await started; - }); - - it("reports a fatal disconnect when reconnect attempts are exhausted", async () => { - const onDisconnected = vi.fn(); - const sockets = Array.from({ length: MAX_RECONNECT_ATTEMPTS + 1 }, () => new FakeWebSocket()); - let socketIndex = 0; - createQQWSClientMock.mockImplementation(async () => sockets[socketIndex++]); - const controller = new AbortController(); - const connection = new GatewayConnection({ - account: makeAccount(), - abortSignal: controller.signal, - cfg: {}, - runtime: {} as GatewayPluginRuntime, - adapters: {} as EngineAdapters, - handleMessage: async () => {}, - createIngressMonitor: createNoopIngressMonitor, - onDisconnected, - }); - const started = connection.start(); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalledTimes(1); - }); - - for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) { - expectDefined(sockets[attempt], `QQBot socket ${attempt}`).emit( - "close", - 1006, - Buffer.from(""), - ); - await vi.runOnlyPendingTimersAsync(); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalledTimes(attempt + 2); - }); - } - expectDefined(sockets[MAX_RECONNECT_ATTEMPTS], "final QQBot socket").emit( - "close", - 1006, - Buffer.from(""), - ); - - expect(onDisconnected).toHaveBeenCalledWith({ - reason: "reconnect attempts exhausted", - fatal: true, - }); - controller.abort(); - await started; - }); - - it("ignores a stale close from a superseded socket after a server-driven reconnect", async () => { - const onDisconnected = vi.fn(); - const staleWs = new FakeWebSocket(); - const replacementWs = new FakeWebSocket(); - createQQWSClientMock.mockResolvedValueOnce(staleWs).mockResolvedValueOnce(replacementWs); - const controller = new AbortController(); - const connection = new GatewayConnection({ - account: makeAccount(), - abortSignal: controller.signal, - cfg: {}, - runtime: {} as GatewayPluginRuntime, - adapters: {} as EngineAdapters, - handleMessage: async () => {}, - createIngressMonitor: createNoopIngressMonitor, - onDisconnected, - }); - const started = connection.start(); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalledTimes(1); - }); - - // Server asks for a reconnect: the old socket is torn down and a - // replacement is scheduled, then becomes live. - staleWs.emit("open"); - staleWs.emit("message", JSON.stringify({ op: 7 })); - await vi.waitFor(() => { - expect(onDisconnected).toHaveBeenCalledWith({ - reason: "server requested reconnect", - fatal: false, - }); - }); - await vi.advanceTimersByTimeAsync(1_100); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalledTimes(2); - }); - replacementWs.emit("open"); - - // The superseded socket's close arrives late; it must not regress - // the live replacement's status. - staleWs.emit("close", 1000, Buffer.from("")); - - expect(onDisconnected).toHaveBeenCalledTimes(1); - controller.abort(); - await started; - }); - - it("ignores a stale close while a server-driven reconnect is pending", async () => { - const onDisconnected = vi.fn(); - const staleWs = new FakeWebSocket(); - const replacementWs = new FakeWebSocket(); - createQQWSClientMock.mockResolvedValueOnce(staleWs).mockResolvedValueOnce(replacementWs); - const controller = new AbortController(); - const connection = new GatewayConnection({ - account: makeAccount(), - abortSignal: controller.signal, - cfg: {}, - runtime: {} as GatewayPluginRuntime, - adapters: {} as EngineAdapters, - handleMessage: async () => {}, - createIngressMonitor: createNoopIngressMonitor, - onDisconnected, - }); - const started = connection.start(); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalledTimes(1); - }); - - staleWs.emit("open"); - staleWs.emit("message", JSON.stringify({ op: 7 })); - await vi.waitFor(() => { - expect(onDisconnected).toHaveBeenCalledWith({ - reason: "server requested reconnect", - fatal: false, - }); - }); - staleWs.emit("close", 1006, Buffer.from("")); - - expect(onDisconnected).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1_100); - await vi.waitFor(() => { - expect(createQQWSClientMock).toHaveBeenCalledTimes(2); - }); - - controller.abort(); - await started; - }); - - it("reports a disconnect when the server invalidates the session", async () => { - const onDisconnected = vi.fn(); - const { ws, controller, started } = await startConnection({ onDisconnected }); - - ws.emit("open"); - ws.emit("message", JSON.stringify({ op: 9, d: false })); - - await vi.waitFor(() => { - expect(onDisconnected).toHaveBeenCalledWith({ - reason: "session invalidated", - fatal: false, - }); - }); - - controller.abort(); - await started; - }); - - it("does not report a disconnect for the close caused by an intentional abort", async () => { - const onDisconnected = vi.fn(); - const { ws, controller, started } = await startConnection({ onDisconnected }); - - controller.abort(); - ws.emit("close", 1000, Buffer.from("")); - - expect(onDisconnected).not.toHaveBeenCalled(); - await started; - }); - - it("continues shutdown cleanup and rejects when one cleanup step fails", async () => { - const cleanupError = new Error("ingress stop failed"); - const stop = vi.fn(async () => { - throw cleanupError; - }); - const { controller, started } = await startConnection({ - createIngressMonitor: () => ({ - receive: vi.fn(async () => {}), - stop, - waitForIdle: vi.fn(async () => {}), - }), - }); - - controller.abort(); - - await expect(started).rejects.toBe(cleanupError); - expect(stop).toHaveBeenCalledTimes(1); - expect(stopBackgroundTokenRefresh).toHaveBeenCalledWith("test-app"); - expect(flushKnownUsers).toHaveBeenCalledTimes(1); - expect(flushRefIndex).toHaveBeenCalledTimes(1); - }); - - it("observes shutdown rejection while the initial connection is pending", async () => { - vi.useRealTimers(); - const cleanupError = new Error("ingress stop failed"); - const ws = new FakeWebSocket(); - let resolveSocket!: (socket: FakeWebSocket) => void; - createQQWSClientMock.mockReturnValue( - new Promise((resolve) => { - resolveSocket = resolve; - }), - ); - const controller = new AbortController(); - const connection = new GatewayConnection({ - account: makeAccount(), - abortSignal: controller.signal, - cfg: {}, - runtime: {} as GatewayPluginRuntime, - adapters: {} as EngineAdapters, - handleMessage: async () => {}, - createIngressMonitor: () => ({ - receive: vi.fn(async () => {}), - stop: vi.fn(async () => { - throw cleanupError; - }), - waitForIdle: vi.fn(async () => {}), - }), - }); - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - unhandledRejections.push(reason); - }; - process.on("unhandledRejection", onUnhandledRejection); - - try { - const started = connection.start(); - await vi.waitFor(() => expect(createQQWSClientMock).toHaveBeenCalledTimes(1)); - - controller.abort(); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(unhandledRejections).toStrictEqual([]); - resolveSocket(ws); - await expect(started).rejects.toBe(cleanupError); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } - }); - - it("terminates the socket when durable admission fails closed", async () => { - const admissionError = new QQBotIngressAdmissionError("sqlite unavailable"); - const receive = vi.fn(async () => { - throw admissionError; - }); - const onError = vi.fn(); - const { ws, controller, started } = await startConnection({ - onError, - createIngressMonitor: () => ({ - receive, - stop: vi.fn(async () => {}), - waitForIdle: vi.fn(async () => {}), - }), - }); - - const event = JSON.stringify({ - op: GatewayOp.DISPATCH, - t: GatewayEvent.C2C_MESSAGE_CREATE, - d: { - id: "message-1", - content: "hello", - timestamp: "2026-07-18T12:00:00Z", - author: { user_openid: "user-1" }, - }, - }); - ws.emit("message", event); - ws.emit("message", event); - - await vi.waitFor(() => expect(ws.terminate).toHaveBeenCalledTimes(1)); - await Promise.resolve(); - await Promise.resolve(); - expect(receive).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledWith(admissionError); - controller.abort(); - await started; - }); -}); - -describe("GatewayConnection heartbeat liveness", () => { - beforeEach(() => { - vi.useFakeTimers(); - createQQWSClientMock.mockReset(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it("does not terminate when heartbeats are ACKed within the interval", async () => { - const { ws, controller, started } = await startConnection({}); - ws.readyState = 1; // OPEN so the heartbeat sender fires - ws.emit("open"); - ws.emit("message", JSON.stringify({ op: GatewayOp.HELLO, d: { heartbeat_interval: 10_000 } })); - - // Advance one interval, then deliver the ACK for that heartbeat. - await vi.advanceTimersByTimeAsync(10_000); - expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('"op":1')); - ws.emit("message", JSON.stringify({ op: GatewayOp.HEARTBEAT_ACK })); - await vi.advanceTimersByTimeAsync(10_000); - ws.emit("message", JSON.stringify({ op: GatewayOp.HEARTBEAT_ACK })); - - expect(ws.terminate).not.toHaveBeenCalled(); - controller.abort(); - await started; - }); - - it("terminates a half-open connection whose heartbeats receive no ACK", async () => { - // A connection that sends heartbeats but never receives Op 11 must be torn - // down so handleClose → scheduleReconnect re-establishes delivery. - const { ws, controller, started } = await startConnection({}); - ws.readyState = 1; // OPEN so the heartbeat sender fires - ws.emit("open"); - ws.emit("message", JSON.stringify({ op: GatewayOp.HELLO, d: { heartbeat_interval: 10_000 } })); - - // tick 1: sends heartbeat 1 (outstanding=1), no ACK. - await vi.advanceTimersByTimeAsync(10_000); - expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('"op":1')); - // tick 2: sends heartbeat 2 (outstanding=2), no ACK. - await vi.advanceTimersByTimeAsync(10_000); - // tick 3: two unanswered sends → terminate. - await vi.advanceTimersByTimeAsync(10_000); - - expect(ws.terminate).toHaveBeenCalledTimes(1); - controller.abort(); - await started; - }); - - it("clears an ACK that arrives while socketMessageTail is blocked by ingress", async () => { - // Guards the pre-tail ACK path: even with a DISPATCH holding the serialized - // queue open inside ingress.receive(), an Op 11 must still reset the counter. - let releaseIngress!: () => void; - const receive = vi.fn( - () => - new Promise((resolve) => { - releaseIngress = resolve; - }), - ); - const { ws, controller, started } = await startConnection({ - createIngressMonitor: () => ({ - receive, - stop: vi.fn(async () => {}), - waitForIdle: vi.fn(async () => {}), - }), - }); - ws.readyState = 1; // OPEN - ws.emit("open"); - ws.emit("message", JSON.stringify({ op: GatewayOp.HELLO, d: { heartbeat_interval: 10_000 } })); - - // tick 1: send heartbeat 1. - await vi.advanceTimersByTimeAsync(10_000); - // A DISPATCH enters ingress.receive() and blocks the serialized queue. - ws.emit( - "message", - JSON.stringify({ - op: GatewayOp.DISPATCH, - t: GatewayEvent.C2C_MESSAGE_CREATE, - d: { - id: "m1", - content: "hi", - timestamp: "2026-07-18T12:00:00Z", - author: { user_openid: "u1" }, - }, - }), - ); - await vi.waitFor(() => expect(receive).toHaveBeenCalled()); - // tick 2: send heartbeat 2 while the queue is still blocked. - await vi.advanceTimersByTimeAsync(10_000); - // The ACK for heartbeat 1 arrives now — it must reset the counter despite the - // blocked queue, or the next tick would falsely terminate a live socket. - ws.emit("message", JSON.stringify({ op: GatewayOp.HEARTBEAT_ACK })); - // tick 3: counter was reset, so this sends heartbeat 3 instead of terminating. - await vi.advanceTimersByTimeAsync(10_000); - - expect(ws.terminate).not.toHaveBeenCalled(); - releaseIngress(); - controller.abort(); - await started; - }); - - it("does not throw on a malformed null frame and keeps handling later frames", async () => { - // A syntactically valid but non-object frame (JSON null) must not escape the - // synchronous message listener as an uncaught exception. - const { ws, controller, started } = await startConnection({}); - ws.readyState = 1; // OPEN - ws.emit("open"); - ws.emit("message", JSON.stringify({ op: GatewayOp.HELLO, d: { heartbeat_interval: 10_000 } })); - // tick 1: send heartbeat 1 (outstanding=1). - await vi.advanceTimersByTimeAsync(10_000); - expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('"op":1')); - - // A syntactically valid but non-object frame (JSON null) must not escape the - // synchronous message listener as an uncaught exception or terminate the socket. - ws.emit("message", "null"); - await Promise.resolve(); - expect(ws.terminate).not.toHaveBeenCalled(); - - // The ACK for heartbeat 1 still resets the counter after the malformed frame. - ws.emit("message", JSON.stringify({ op: GatewayOp.HEARTBEAT_ACK })); - await vi.advanceTimersByTimeAsync(10_000); // tick 2: pre-send 0 < 2 → send (outstanding=1) - await vi.advanceTimersByTimeAsync(10_000); // tick 3: pre-send 1 < 2 → send (outstanding=2) - expect(ws.terminate).not.toHaveBeenCalled(); - controller.abort(); - await started; - }); - - it.each([ - ["missing", { op: GatewayOp.HELLO }], - ["null", { op: GatewayOp.HELLO, d: null }], - ["array", { op: GatewayOp.HELLO, d: [] }], - ["object", { op: GatewayOp.HELLO, d: {} }], - [ - "non-stringifiable interval", - { - op: GatewayOp.HELLO, - d: { heartbeat_interval: { toString: null, valueOf: null } }, - }, - ], - ])("uses the fallback heartbeat for a %s HELLO body", async (_case, payload) => { - const error = vi.fn(); - const { ws, controller, started } = await startConnection({ - log: { info: vi.fn(), error }, - }); - ws.readyState = 1; // OPEN - ws.emit("open"); - - ws.emit("message", JSON.stringify(payload)); - await vi.advanceTimersByTimeAsync(44_999); - - expect(ws.send).not.toHaveBeenCalledWith(expect.stringContaining('"op":1')); - expect(ws.terminate).not.toHaveBeenCalled(); - expect(error).toHaveBeenCalledExactlyOnceWith( - "Invalid QQ gateway HELLO heartbeat interval; using default 45000ms", - ); - - await vi.advanceTimersByTimeAsync(1); - expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('"op":1')); - expect(ws.terminate).not.toHaveBeenCalled(); - controller.abort(); - await started; - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/gateway-connection.ts b/extensions/qqbot/src/engine/gateway/gateway-connection.ts deleted file mode 100644 index 6db553c11646..000000000000 --- a/extensions/qqbot/src/engine/gateway/gateway-connection.ts +++ /dev/null @@ -1,568 +0,0 @@ -// Qqbot plugin module implements gateway connection behavior. -import { asSafeIntegerInRange, MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import WebSocket from "ws"; -import type { EngineAdapters } from "../adapter/index.js"; -import { - trySlashCommand, - type SlashCommandHandlerContext, -} from "../commands/slash-command-handler.js"; -import { - clearTokenCache, - getAccessToken, - getGatewayUrl, - getPluginUserAgent, - startBackgroundTokenRefresh, - stopBackgroundTokenRefresh, -} from "../messaging/sender.js"; -import { flushRefIndex } from "../ref/store.js"; -import { flushKnownUsers } from "../session/known-users.js"; -import { clearSession, loadSession, saveSession } from "../session/session-store.js"; -import type { InteractionEvent } from "../types.js"; -import { decodeGatewayMessageData } from "./codec.js"; -import { FULL_INTENTS, RATE_LIMIT_DELAY, GatewayOp } from "./constants.js"; -import { dispatchEvent } from "./event-dispatcher.js"; -import { createQQBotIngressEffectOnce } from "./ingress-effects.js"; -import { isQQBotTurnEventType } from "./ingress-envelope.js"; -import { - createQQBotIngressMonitor, - QQBotIngressAdmissionError, - type QQBotIngressDispatchResult, - type QQBotIngressMonitor, -} from "./ingress.js"; -import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; -import { ReconnectState } from "./reconnect.js"; -import type { - GatewayAccount, - EngineLogger, - GatewayPluginRuntime, - QQBotIngressLifecycle, - WSPayload, -} from "./types.js"; -import { createQQWSClient } from "./ws-client.js"; - -const DEFAULT_HEARTBEAT_INTERVAL_MS = 45_000; -const MIN_HEARTBEAT_INTERVAL_MS = 1_000; - -interface GatewayConnectionContext { - account: GatewayAccount; - abortSignal: AbortSignal; - cfg: unknown; - log?: EngineLogger; - runtime: GatewayPluginRuntime; - adapters: EngineAdapters; - onReady?: (data: unknown) => void; - onResumed?: (data: unknown) => void; - onError?: (error: Error) => void; - onDisconnected?: (info: { reason?: string; fatal?: boolean }) => void; - handleMessage: (event: QueuedMessage) => Promise; - onInteraction?: (event: InteractionEvent) => void; - createIngressMonitor?: typeof createQQBotIngressMonitor; -} - -export class GatewayConnection { - private isAborted = false; - private currentWs: WebSocket | null = null; - private heartbeatInterval: ReturnType | null = null; - private sessionId: string | null = null; - private lastSeq: number | null = null; - // Sent heartbeats not yet cleared by an op:11 ACK. Counter-based (not wall-clock) - // so an event-loop stall cannot trip a false termination on a live socket. - private outstandingHeartbeats = 0; - private isConnecting = false; - private reconnectTimer: ReturnType | null = null; - private shouldRefreshToken = false; - private ingress: QQBotIngressMonitor | undefined; - private socketMessageTail: Promise = Promise.resolve(); - private shutdownTask: Promise | undefined; - private readonly failedIngressSockets = new WeakSet(); - - private readonly reconnect: ReconnectState; - private readonly msgQueue; - private readonly ingressEffectOnce; - private readonly ctx: GatewayConnectionContext; - - constructor(ctx: GatewayConnectionContext) { - this.ctx = ctx; - this.reconnect = new ReconnectState(ctx.account.accountId, ctx.log); - this.msgQueue = createMessageQueue({ - accountId: ctx.account.accountId, - log: ctx.log, - isAborted: () => this.isAborted, - }); - this.ingressEffectOnce = createQQBotIngressEffectOnce({ - accountId: ctx.account.accountId, - log: ctx.log, - }); - } - - async start(): Promise { - this.restoreSession(); - this.msgQueue.startProcessor(this.ctx.handleMessage); - const slashCtx = this.createSlashCommandContext(); - const createIngressMonitor = this.ctx.createIngressMonitor ?? createQQBotIngressMonitor; - this.ingress = createIngressMonitor({ - accountId: this.ctx.account.accountId, - runtime: this.ctx.runtime, - log: this.ctx.log, - dispatch: (message, lifecycle, eventId) => - this.dispatchIngressMessage(message, lifecycle, eventId, slashCtx), - }); - const stopped = new Promise((resolve, reject) => { - const stop = () => void this.shutdown().then(resolve, reject); - if (this.ctx.abortSignal.aborted) { - stop(); - return; - } - this.ctx.abortSignal.addEventListener("abort", stop, { once: true }); - }); - // Observe shutdown immediately: abort can reject while the initial connection is still pending. - const stoppedResult = stopped.then( - () => ({ ok: true as const }), - (error: unknown) => ({ ok: false as const, error }), - ); - if (!this.isAborted) { - await this.connect(); - } - const result = await stoppedResult; - if (!result.ok) { - throw result.error; - } - } - - private restoreSession(): void { - const { account, log } = this.ctx; - const saved = loadSession(account.accountId, account.appId); - if (saved) { - this.sessionId = saved.sessionId; - this.lastSeq = saved.lastSeq; - log?.info(`Restored session: sessionId=${this.sessionId}, lastSeq=${this.lastSeq}`); - } - } - - private saveCurrentSession(): void { - const { account } = this.ctx; - if (!this.sessionId) { - return; - } - saveSession({ - sessionId: this.sessionId, - lastSeq: this.lastSeq, - lastConnectedAt: Date.now(), - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - } - - private shutdown(): Promise { - this.shutdownTask ??= (async () => { - const { account } = this.ctx; - const errors: unknown[] = []; - const runCleanup = async ( - label: string, - cleanup: () => void | Promise, - ): Promise => { - try { - await cleanup(); - } catch (error) { - errors.push(error); - this.ctx.log?.error(`QQBot gateway shutdown ${label} failed: ${String(error)}`); - } - }; - this.isAborted = true; - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - await runCleanup("socket cleanup", () => this.cleanup()); - await runCleanup("ingress stop", () => this.ingress?.stop()); - await runCleanup("socket drain", () => this.socketMessageTail); - await runCleanup("message queue stop", () => this.msgQueue.stop()); - await runCleanup("token refresh stop", () => stopBackgroundTokenRefresh(account.appId)); - await runCleanup("known-user flush", () => flushKnownUsers()); - await runCleanup("reference-index flush", () => flushRefIndex()); - if (errors.length === 1) { - throw errors[0]; - } - if (errors.length > 1) { - throw new AggregateError(errors, "QQBot gateway shutdown failed."); - } - })(); - return this.shutdownTask; - } - - private createSlashCommandContext(): SlashCommandHandlerContext { - const { account, cfg, log, adapters } = this.ctx; - return { - account, - cfg, - log, - getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg), - getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId), - resolveCommandAuthorized: (params) => - adapters.access.resolveSlashCommandAuthorization({ - cfg, - accountId: account.accountId, - ...params, - }), - }; - } - - private async dispatchIngressMessage( - msg: QueuedMessage, - lifecycle: QQBotIngressLifecycle, - eventId: string, - slashCtx: SlashCommandHandlerContext, - ): Promise { - if (this.isAborted || lifecycle.abortSignal.aborted) { - return { - kind: "failed-retryable", - error: - lifecycle.abortSignal.reason ?? this.ctx.abortSignal.reason ?? new Error("QQBot stopped"), - }; - } - msg.turnAdoptionLifecycle = lifecycle; - // Fleet at-least-once contract: a pre-tombstone crash can replay slash commands. - // Non-idempotent handlers opt into createIngressEffectOnce through this dispatch context. - const result = await trySlashCommand(msg, slashCtx, { - eventId, - effectOnce: this.ingressEffectOnce, - }); - if (result === "handled") { - return { kind: "completed" }; - } - if (this.isAborted || lifecycle.abortSignal.aborted) { - return { - kind: "failed-retryable", - error: - lifecycle.abortSignal.reason ?? this.ctx.abortSignal.reason ?? new Error("QQBot stopped"), - }; - } - if (result === "urgent") { - const peerId = this.msgQueue.getMessagePeerId(msg); - this.msgQueue.clearUserQueue(peerId); - this.msgQueue.executeImmediate(msg); - } else { - this.msgQueue.enqueue(msg); - } - return { kind: "deferred" }; - } - - private cleanup(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } - if ( - this.currentWs && - (this.currentWs.readyState === WebSocket.OPEN || - this.currentWs.readyState === WebSocket.CONNECTING) - ) { - this.currentWs.close(); - } - this.currentWs = null; - } - - private scheduleReconnect(customDelay?: number): void { - const { account: _account, log } = this.ctx; - if (this.isAborted || this.reconnect.isExhausted()) { - log?.error(`Max reconnect attempts reached or aborted`); - // Exhaustion is a permanent give-up: report it as fatal so the - // channel status does not keep claiming a live connection. - if (!this.isAborted) { - this.ctx.onDisconnected?.({ reason: "reconnect attempts exhausted", fatal: true }); - } - return; - } - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - const delay = this.reconnect.getNextDelay(customDelay); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - if (!this.isAborted) { - void this.connect(); - } - }, delay); - } - - private async connect(): Promise { - const { account, log } = this.ctx; - - if (this.isConnecting) { - log?.debug?.(`Already connecting, skip`); - return; - } - this.isConnecting = true; - - try { - this.cleanup(); - if (this.shouldRefreshToken) { - log?.debug?.(`Refreshing token...`); - clearTokenCache(account.appId); - this.shouldRefreshToken = false; - } - - const accessToken = await getAccessToken(account.appId, account.clientSecret); - log?.info(`✅ Access token obtained successfully`); - const gatewayUrl = await getGatewayUrl(accessToken, account.appId); - log?.info(`Connecting to ${gatewayUrl}`); - const ws = await createQQWSClient({ - gatewayUrl, - userAgent: getPluginUserAgent(), - }); - this.currentWs = ws; - - // ---- WebSocket: open ---- - ws.on("open", () => { - log?.info(`WebSocket connected`); - this.isConnecting = false; - this.reconnect.onConnected(); - startBackgroundTokenRefresh(account.appId, account.clientSecret, { log }); - }); - - // ---- WebSocket: message ---- - // Decode/parse once and carry the prepared frame into the serialized handler. - // Op 11 Heartbeat ACK resets the liveness counter here and returns, never enqueued - // behind socketMessageTail, so a slow ingress.receive() cannot mask an arrived ACK. - ws.on("message", (data) => { - if (this.isAborted || this.currentWs !== ws || this.failedIngressSockets.has(ws)) { - return; - } - let payload: WSPayload; - let rawData: string; - try { - rawData = decodeGatewayMessageData(data); - payload = JSON.parse(rawData) as WSPayload; - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - log?.error(`Message parse error: ${message}`); - return; - } - if (payload === null || typeof payload !== "object") { - log?.error(`Message parse error: unexpected payload shape`); - return; - } - if (payload.op === GatewayOp.HEARTBEAT_ACK) { - this.outstandingHeartbeats = 0; - return; - } - this.socketMessageTail = this.socketMessageTail - .then(() => this.handleSocketMessage(ws, { rawData, payload }, accessToken)) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - if (error instanceof QQBotIngressAdmissionError) { - log?.error(`Durable ingress failed; terminating gateway socket: ${message}`); - this.ctx.onError?.(error); - if (this.currentWs === ws) { - // Fence callbacks already queued behind the failed append before - // terminate emits close and starts the reconnect path. - this.failedIngressSockets.add(ws); - ws.terminate(); - } - return; - } - log?.error(`Message parse error: ${message}`); - }); - }); - - // ---- WebSocket: close ---- - ws.on("close", (code, reason) => { - log?.info(`WebSocket closed: ${code} ${reason.toString()}`); - // cleanup() clears currentWs before a server-driven reconnect. Ignore - // the old socket's delayed close both during that gap and after the - // replacement is live, or it can reschedule reconnect handling. - if (this.currentWs !== ws) { - return; - } - this.isConnecting = false; - this.handleClose(code); - }); - - // ---- WebSocket: error ---- - ws.on("error", (err) => { - log?.error(`WebSocket error: ${err.message}`); - this.ctx.onError?.(err); - }); - } catch (err) { - this.isConnecting = false; - const errMsg = err instanceof Error ? err.message : String(err); - log?.error(`Connection failed: ${errMsg}`); - if (errMsg.includes("Too many requests") || errMsg.includes("100001")) { - this.scheduleReconnect(RATE_LIMIT_DELAY); - } else { - this.scheduleReconnect(); - } - } - } - - private async handleSocketMessage( - ws: WebSocket, - frame: { rawData: string; payload: WSPayload }, - accessToken: string, - ): Promise { - if (this.isAborted || this.currentWs !== ws || this.failedIngressSockets.has(ws)) { - return; - } - const { rawData, payload } = frame; - const { op, d, s, t } = payload; - let saveAfterDispatch = false; - - switch (op) { - case GatewayOp.HELLO: - this.handleHello(ws, d, accessToken); - break; - - case GatewayOp.DISPATCH: { - this.ctx.log?.debug?.(`Dispatch event: t=${t}, d=${JSON.stringify(d)}`); - if (isQQBotTurnEventType(t)) { - if (!this.ingress) { - throw new Error("QQBot ingress monitor is unavailable."); - } - // Resume sequence advances only after the raw turn is durable. - await this.ingress.receive(rawData); - } else { - const result = dispatchEvent(t ?? "", d, this.ctx.account.accountId, this.ctx.log); - if (result.action === "ready") { - this.sessionId = result.sessionId; - saveAfterDispatch = true; - this.ctx.onReady?.(result.data); - } else if (result.action === "resumed") { - (this.ctx.onResumed ?? this.ctx.onReady)?.(result.data); - saveAfterDispatch = true; - } else if (result.action === "interaction") { - this.ctx.onInteraction?.(result.event); - } - } - break; - } - case GatewayOp.RECONNECT: - this.ctx.onDisconnected?.({ reason: "server requested reconnect", fatal: false }); - this.cleanup(); - this.scheduleReconnect(); - break; - - case GatewayOp.INVALID_SESSION: { - const canResume = d as boolean; - this.ctx.onDisconnected?.({ - reason: canResume ? "session resume rejected" : "session invalidated", - fatal: false, - }); - if (!canResume) { - this.sessionId = null; - this.lastSeq = null; - clearSession(this.ctx.account.accountId); - this.shouldRefreshToken = true; - } - this.cleanup(); - this.scheduleReconnect(3000); - break; - } - } - - if (typeof s === "number") { - this.lastSeq = s; - saveAfterDispatch = true; - } - if (saveAfterDispatch) { - this.saveCurrentSession(); - } - } - - // ============ Protocol handlers ============ - - private handleHello(ws: WebSocket, d: unknown, accessToken: string): void { - const hello = asOptionalRecord(d) ?? {}; - const receivedInterval = asSafeIntegerInRange(hello.heartbeat_interval, { - min: MIN_HEARTBEAT_INTERVAL_MS, - max: MAX_TIMER_TIMEOUT_MS, - }); - if (receivedInterval === undefined) { - // Do not interpolate hostile input here: diagnostics must not throw while - // recovering the heartbeat schedule from a malformed gateway frame. - this.ctx.log?.error( - `Invalid QQ gateway HELLO heartbeat interval; using default ${DEFAULT_HEARTBEAT_INTERVAL_MS}ms`, - ); - } - const interval = receivedInterval ?? DEFAULT_HEARTBEAT_INTERVAL_MS; - - if (this.sessionId && this.lastSeq !== null) { - ws.send( - JSON.stringify({ - op: GatewayOp.RESUME, - d: { - token: `QQBot ${accessToken}`, - session_id: this.sessionId, - seq: this.lastSeq, - }, - }), - ); - } else { - ws.send( - JSON.stringify({ - op: GatewayOp.IDENTIFY, - d: { - token: `QQBot ${accessToken}`, - intents: FULL_INTENTS, - shard: [0, 1], - }, - }), - ); - } - - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - } - this.outstandingHeartbeats = 0; - // Terminate after this many heartbeats go unanswered. Check before sending so the - // threshold counts unanswered sends: tick 1 sends (1), tick 2 sends (2), tick 3 trips. - const missedAckThreshold = 2; - this.heartbeatInterval = setInterval(() => { - if (ws.readyState !== WebSocket.OPEN) { - return; - } - if (this.outstandingHeartbeats >= missedAckThreshold) { - this.ctx.log?.error( - `Heartbeat ACK overdue (${this.outstandingHeartbeats} unanswered); terminating gateway socket`, - ); - ws.terminate(); - return; - } - ws.send(JSON.stringify({ op: GatewayOp.HEARTBEAT, d: this.lastSeq })); - this.outstandingHeartbeats += 1; - }, interval); - } - - private handleClose(code: number): void { - const { account } = this.ctx; - const action = this.reconnect.handleClose(code, this.isAborted); - - if (action.clearSession) { - this.sessionId = null; - this.lastSeq = null; - clearSession(account.accountId); - } - if (action.refreshToken) { - this.shouldRefreshToken = true; - } - - this.cleanup(); - - // Publish the disconnect so channel status stops claiming a live - // connection; a fatal close (bot banned / offline) never reconnects. - // Abort-driven closes are an intentional stop, not a status change. - if (!this.isAborted) { - this.ctx.onDisconnected?.({ reason: action.reason, fatal: action.fatal }); - } - - if (action.fatal) { - return; - } - if (action.shouldReconnect) { - this.scheduleReconnect(action.reconnectDelay); - } - } -} diff --git a/extensions/qqbot/src/engine/gateway/gateway.config.test.ts b/extensions/qqbot/src/engine/gateway/gateway.config.test.ts deleted file mode 100644 index 6178910e1614..000000000000 --- a/extensions/qqbot/src/engine/gateway/gateway.config.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ApiError } from "../types.js"; -import { startGateway, type CoreGatewayContext } from "./gateway.js"; -import type { InboundPipelineDeps } from "./inbound-context.js"; -import type { QueuedMessage } from "./message-queue.js"; -import type { GatewayAccount } from "./types.js"; - -const mocks = vi.hoisted(() => ({ - clearTokenCache: vi.fn(), - handleMessage: undefined as ((event: QueuedMessage) => Promise) | undefined, - sendInputNotify: vi.fn(), -})); - -vi.mock("../commands/slash-commands-impl.js", () => ({ - initCommands: vi.fn(), -})); - -vi.mock("../messaging/outbound-reply.js", () => ({ - claimMessageReply: vi.fn(() => ({ allowed: true })), -})); - -vi.mock("../messaging/outbound.js", () => ({ - setOutboundAudioPort: vi.fn(), -})); - -vi.mock("../messaging/sender.js", () => ({ - accountToCreds: vi.fn((account: GatewayAccount) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - })), - buildDeliveryTarget: vi.fn(), - clearTokenCache: mocks.clearTokenCache, - createRawInputNotifyFn: vi.fn(() => vi.fn()), - getAccessToken: vi.fn(async () => "token"), - initApiConfig: vi.fn(), - onMessageSent: vi.fn(), - sendInputNotify: mocks.sendInputNotify, - sendText: vi.fn(), -})); - -vi.mock("../utils/diagnostics.js", () => ({ - runDiagnostics: vi.fn(async () => ({ warnings: [] })), -})); - -vi.mock("./gateway-connection.js", () => ({ - GatewayConnection: class { - constructor(options: { handleMessage: (event: QueuedMessage) => Promise }) { - mocks.handleMessage = options.handleMessage; - } - - async start() {} - }, -})); - -vi.mock("./inbound-pipeline.js", () => ({ - buildInboundContext: vi.fn( - async (event: QueuedMessage, deps: Pick) => ({ - blocked: true, - blockReason: "test", - typing: await deps.startTyping(event), - }), - ), - clearGroupPendingHistory: vi.fn(), -})); - -vi.mock("./interaction-handler.js", () => ({ - createInteractionHandler: vi.fn(() => vi.fn()), -})); - -vi.mock("./outbound-dispatch.js", () => ({ - dispatchOutbound: vi.fn(), -})); - -function makeContext(accountId = "default", withCredentials = true): CoreGatewayContext { - return { - account: { - accountId, - appId: withCredentials ? "app-id" : "", - clientSecret: withCredentials ? "secret" : "", - markdownSupport: false, - config: {}, - }, - cfg: {}, - getCurrentConfig: () => ({}), - log: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, - runtime: { - channel: { - activity: { record: vi.fn() }, - }, - }, - adapters: { - commands: {}, - outboundAudio: {}, - }, - } as unknown as CoreGatewayContext; -} - -describe("QQBot gateway configuration guidance", () => { - it("shows default-account recovery paths from the real gateway entry point", async () => { - await expect(startGateway(makeContext("default", false))).rejects.toThrow( - /channels\.qqbot\.appId.*QQBOT_APP_ID and QQBOT_CLIENT_SECRET/, - ); - }); - - it("shows account-scoped recovery without default-only env vars", async () => { - let error: unknown; - try { - await startGateway(makeContext("operations", false)); - } catch (caught) { - error = caught; - } - - const message = error instanceof Error ? error.message : String(error); - expect(message).toContain("channels.qqbot.accounts.operations.appId"); - expect(message).not.toContain("QQBOT_APP_ID"); - expect(message).not.toContain("QQBOT_CLIENT_SECRET"); - }); -}); - -async function sendC2CTyping(): Promise { - await startGateway(makeContext()); - const handleMessage = mocks.handleMessage; - if (!handleMessage) { - throw new Error("Gateway did not register a message handler"); - } - await handleMessage({ - type: "c2c", - senderId: "openid-1", - content: "hello", - messageId: "msg-1", - timestamp: "2026-08-07T00:00:00Z", - }); -} - -describe("QQBot gateway typing token retry", () => { - beforeEach(() => { - mocks.clearTokenCache.mockReset(); - mocks.handleMessage = undefined; - mocks.sendInputNotify.mockReset(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("refreshes a keyword-free HTTP 500/business-code 11244 failure", async () => { - mocks.sendInputNotify - .mockRejectedValueOnce(new ApiError("credential rejected", 500, "/typing", 11244)) - .mockResolvedValueOnce({ refIdx: "ref-1" }); - - await sendC2CTyping(); - - expect(mocks.clearTokenCache).toHaveBeenCalledOnce(); - expect(mocks.clearTokenCache).toHaveBeenCalledWith("app-id"); - expect(mocks.sendInputNotify).toHaveBeenCalledTimes(2); - }); - - it("preserves the string fallback for non-ApiError failures", async () => { - mocks.sendInputNotify - .mockRejectedValueOnce(new Error("401 token rejected")) - .mockResolvedValueOnce({ refIdx: "ref-1" }); - - await sendC2CTyping(); - - expect(mocks.clearTokenCache).toHaveBeenCalledOnce(); - expect(mocks.sendInputNotify).toHaveBeenCalledTimes(2); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/gateway.ts b/extensions/qqbot/src/engine/gateway/gateway.ts deleted file mode 100644 index 9f22f356a876..000000000000 --- a/extensions/qqbot/src/engine/gateway/gateway.ts +++ /dev/null @@ -1,338 +0,0 @@ -// Qqbot plugin module implements gateway behavior. -import path from "node:path"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { isQQBotTokenAuthenticationFailure } from "../api/auth-errors.js"; -import { - classifyCoreCommandForGroup, - PRIVATE_CHAT_ONLY_TEXT, -} from "../commands/command-visibility.js"; -import { initCommands } from "../commands/slash-commands-impl.js"; -import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js"; -import { qqbotNotConfiguredMessage } from "../config/setup-guidance.js"; -import type { HistoryEntry } from "../group/history.js"; -import { claimMessageReply } from "../messaging/outbound-reply.js"; -import { setOutboundAudioPort } from "../messaging/outbound.js"; -import { - clearTokenCache, - getAccessToken, - initApiConfig, - onMessageSent, - sendInputNotify as senderSendInputNotify, - createRawInputNotifyFn, - accountToCreds, - buildDeliveryTarget, - sendText as senderSendText, -} from "../messaging/sender.js"; -import { setRefIndex } from "../ref/store.js"; -import { ApiError } from "../types.js"; -import { runDiagnostics } from "../utils/diagnostics.js"; -import { runWithRequestContext } from "../utils/request-context.js"; -import { GatewayConnection } from "./gateway-connection.js"; -import { buildInboundContext, clearGroupPendingHistory } from "./inbound-pipeline.js"; -import { createInteractionHandler } from "./interaction-handler.js"; -import type { QueuedMessage } from "./message-queue.js"; -import { dispatchOutbound } from "./outbound-dispatch.js"; -import type { - CoreGatewayContext, - GatewayAccount, - EngineLogger, - RefAttachmentSummary, -} from "./types.js"; -import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js"; - -export type { CoreGatewayContext } from "./types.js"; - -export async function startGateway(ctx: CoreGatewayContext): Promise { - const { account, log, runtime, adapters } = ctx; - - setOutboundAudioPort(adapters.outboundAudio); - initCommands(adapters.commands); - - if (!account.appId || !account.clientSecret) { - throw new Error(qqbotNotConfiguredMessage(account.accountId)); - } - - const diag = await runDiagnostics(); - if (diag.warnings.length > 0) { - for (const w of diag.warnings) { - log?.info(w); - } - } - - initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); - log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`); - - onMessageSent(account.appId, (refIdx, meta) => { - log?.info( - `onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText === undefined ? undefined : truncateUtf16Safe(meta.ttsText, 30)}`, - ); - const attachments: RefAttachmentSummary[] = []; - if (meta.mediaType) { - const localPath = meta.mediaLocalPath; - const filename = localPath ? path.basename(localPath) : undefined; - const attachment: RefAttachmentSummary = { - type: meta.mediaType, - ...(localPath ? { localPath } : {}), - ...(filename ? { filename } : {}), - ...(meta.mediaUrl ? { url: meta.mediaUrl } : {}), - }; - if (meta.mediaType === "voice" && meta.ttsText) { - attachment.transcript = meta.ttsText; - attachment.transcriptSource = "tts"; - } - attachments.push(attachment); - } - setRefIndex(refIdx, { - content: meta.text ?? "", - senderId: account.accountId, - senderName: account.accountId, - timestamp: Date.now(), - isBot: true, - ...(attachments.length > 0 ? { attachments } : {}), - }); - }); - - const groupOpts = { - enabled: ctx.group?.enabled ?? true, - allowTextCommands: ctx.group?.allowTextCommands, - isControlCommand: ctx.group?.isControlCommand, - resolveIntroHint: ctx.group?.resolveIntroHint, - }; - const groupChatEnabled = groupOpts.enabled; - const groupHistories: Map | undefined = groupChatEnabled - ? new Map() - : undefined; - // ---- 7. Message handler ---- - const handleMessage = async (event: QueuedMessage): Promise => { - if (event.turnAdoptionLifecycle?.abortSignal.aborted) { - await event.turnAdoptionLifecycle.onAbandoned(); - return; - } - log?.info(`Processing message from ${event.senderId}: ${event.content}`, { - accountId: account.accountId, - messageId: event.messageId, - senderId: event.senderId, - type: event.type, - groupOpenid: event.groupOpenid, - }); - - runtime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "inbound", - }); - - const activeCfg = ctx.getCurrentConfig(); - - const inbound = await buildInboundContext(event, { - account, - cfg: activeCfg, - log, - runtime, - startTyping: (ev) => startTypingForEvent(ev, account, log), - groupHistories, - allowTextCommands: groupOpts.allowTextCommands, - isControlCommand: groupOpts.isControlCommand, - resolveGroupIntroHint: groupOpts.resolveIntroHint, - adapters, - }); - - if (inbound.blocked) { - log?.info(`Dropped inbound qqbot message: ${inbound.blockReason ?? "blocked by allowFrom"}`, { - accountId: account.accountId, - messageId: event.messageId, - blockReason: inbound.blockReason, - }); - inbound.typing.keepAlive?.stop(); - await event.turnAdoptionLifecycle?.onAdopted(); - return; - } - - if (inbound.skipped) { - if (inbound.skipReason === "private_command_only") { - log?.info("Rejected private-only command in qqbot group before mention gate", { - accountId: account.accountId, - messageId: event.messageId, - senderId: event.senderId, - type: event.type, - groupOpenid: event.groupOpenid, - }); - await senderSendText( - buildDeliveryTarget(event), - PRIVATE_CHAT_ONLY_TEXT, - accountToCreds(account), - { - msgId: event.messageId, - }, - ); - inbound.typing.keepAlive?.stop(); - await event.turnAdoptionLifecycle?.onAdopted(); - return; - } - log?.info( - `Skipped group inbound: reason=${inbound.skipReason ?? "unknown"} group=${event.groupOpenid ?? ""}`, - { - accountId: account.accountId, - messageId: event.messageId, - skipReason: inbound.skipReason, - groupOpenid: event.groupOpenid, - }, - ); - inbound.typing.keepAlive?.stop(); - await event.turnAdoptionLifecycle?.onAdopted(); - return; - } - - // Keep this after buildInboundContext() so ingress access policy can silently drop - // unauthorized group senders before we emit any command-specific reply. - const groupCommandLevel = - event.type === "group" || event.type === "guild" - ? (inbound.group?.commandLevel ?? - resolveGroupCommandLevelFromAccountConfig( - account.config, - event.groupOpenid ?? event.channelId ?? null, - )) - : undefined; - const groupCommandVisibility = - event.type === "group" || event.type === "guild" - ? classifyCoreCommandForGroup(inbound.agentBody, groupCommandLevel) - : { visibility: "unknown" as const }; - if (groupCommandVisibility.visibility === "private") { - log?.info( - `Rejected private-only command in qqbot group: /${groupCommandVisibility.commandName}`, - { - accountId: account.accountId, - messageId: event.messageId, - senderId: event.senderId, - type: event.type, - groupOpenid: event.groupOpenid, - }, - ); - await senderSendText( - buildDeliveryTarget(event), - PRIVATE_CHAT_ONLY_TEXT, - accountToCreds(account), - { - msgId: event.messageId, - }, - ); - inbound.typing.keepAlive?.stop(); - await event.turnAdoptionLifecycle?.onAdopted(); - return; - } - - try { - await runWithRequestContext( - { - accountId: account.accountId, - target: inbound.qualifiedTarget, - targetId: inbound.peerId, - chatType: event.type, - }, - () => dispatchOutbound(inbound, { runtime, cfg: activeCfg, account, log }), - ); - } catch (err) { - log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`); - if (event.turnAdoptionLifecycle) { - throw err; - } - } finally { - inbound.typing.keepAlive?.stop(); - if (event.type === "group" && event.groupOpenid && inbound.group) { - clearGroupPendingHistory({ - historyMap: groupHistories, - groupOpenid: event.groupOpenid, - historyLimit: inbound.group.historyLimit, - historyPort: adapters.history, - }); - } - } - }; - - const handleInteraction = createInteractionHandler(account, ctx.runtime, log, { - getActiveCfg: ctx.getCurrentConfig, - resolveCommandAuthorized: (params) => adapters.access.resolveSlashCommandAuthorization(params), - }); - - const connection = new GatewayConnection({ - account, - abortSignal: ctx.abortSignal, - cfg: ctx.cfg, - log, - runtime, - adapters, - onReady: ctx.onReady, - onResumed: ctx.onResumed, - onError: ctx.onError, - onDisconnected: ctx.onDisconnected, - onInteraction: handleInteraction, - handleMessage, - }); - - await connection.start(); -} - -// ============ Typing helper ============ - -/** - * Start typing indicator for a C2C event. - * Returns the refIdx from InputNotify and a TypingKeepAlive handle. - */ -async function startTypingForEvent( - event: QueuedMessage, - account: GatewayAccount, - log?: EngineLogger, -): Promise<{ refIdx?: string; keepAlive: TypingKeepAlive | null }> { - const isC2C = event.type === "c2c" || event.type === "dm"; - if (!isC2C) { - return { keepAlive: null }; - } - try { - const creds = accountToCreds(account); - const rawNotifyFn = createRawInputNotifyFn(account.appId); - const sendNotifyAndStartKeepAlive = async () => { - // Typing and text share QQ's five passive calls. Keep one slot for the - // final reply. The claim stays inside this retried closure so each wire - // attempt consumes its own slot. - const passive = claimMessageReply(event.messageId, 1); - if (!passive.allowed) { - return { keepAlive: null }; - } - const resp = await senderSendInputNotify({ - openid: event.senderId, - creds, - msgId: event.messageId, - inputSecond: TYPING_INPUT_SECOND, - }); - const keepAlive = new TypingKeepAlive( - () => getAccessToken(account.appId, account.clientSecret), - () => clearTokenCache(account.appId), - rawNotifyFn, - event.senderId, - event.messageId, - log, - ); - keepAlive.start(); - return { refIdx: resp.refIdx, keepAlive }; - }; - try { - return await sendNotifyAndStartKeepAlive(); - } catch (notifyErr) { - const isStructuredAuthFailure = - notifyErr instanceof ApiError && - isQQBotTokenAuthenticationFailure(notifyErr.httpStatus, notifyErr.bizCode); - const errMsg = String(notifyErr); - const isSyntheticAuthFailure = - !(notifyErr instanceof ApiError) && - (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")); - if (isStructuredAuthFailure || isSyntheticAuthFailure) { - clearTokenCache(account.appId); - return await sendNotifyAndStartKeepAlive(); - } - throw notifyErr; - } - } catch (err) { - log?.error(`sendInputNotify error: ${err instanceof Error ? err.message : String(err)}`); - return { keepAlive: null }; - } -} diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts deleted file mode 100644 index fa5e8beaadb9..000000000000 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -// Qqbot tests cover inbound attachments plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { AudioConvertPort } from "../adapter/audio.port.js"; -import { processAttachments } from "./inbound-attachments.js"; - -const downloadFileMock = vi.hoisted(() => vi.fn()); -const resolveSTTConfigMock = vi.hoisted(() => vi.fn()); -const transcribeAudioMock = vi.hoisted(() => vi.fn()); - -vi.mock("../utils/file-utils.js", () => ({ - downloadFile: downloadFileMock, -})); - -vi.mock("../utils/platform.js", () => ({ - getQQBotMediaDir: () => "/tmp/openclaw-qqbot-downloads", -})); - -vi.mock("../utils/stt.js", () => ({ - resolveSTTConfig: resolveSTTConfigMock, - transcribeAudio: transcribeAudioMock, -})); - -function createAudioConvert(overrides: Partial = {}): AudioConvertPort { - return { - convertSilkToWav: vi.fn(async () => null), - formatDuration: (seconds: number) => `${seconds}s`, - isVoiceAttachment: (att: { content_type: string; filename?: string }) => - att.content_type === "voice" || att.content_type.startsWith("audio/"), - ...overrides, - }; -} - -describe("engine/gateway/inbound-attachments", () => { - let audioConvert: AudioConvertPort; - - beforeEach(() => { - vi.clearAllMocks(); - resolveSTTConfigMock.mockReturnValue(null); - transcribeAudioMock.mockResolvedValue(null); - audioConvert = createAudioConvert(); - }); - - it("returns an empty result when no attachments are present", async () => { - await expect( - processAttachments(undefined, { accountId: "qq", cfg: {}, audioConvert }), - ).resolves.toStrictEqual({ - attachmentInfo: "", - imageUrls: [], - imageMediaTypes: [], - voiceAttachmentPaths: [], - voiceAttachmentUrls: [], - voiceAsrReferTexts: [], - voiceTranscripts: [], - voiceTranscriptSources: [], - attachmentLocalPaths: [], - }); - }); - - it("uses remote image URL when image download fails", async () => { - downloadFileMock.mockResolvedValue(null); - - const result = await processAttachments( - [{ content_type: "image/png", url: "//cdn.example.test/a.png", filename: "a.png" }], - { accountId: "qq", cfg: {}, audioConvert }, - ); - - expect(downloadFileMock).toHaveBeenCalledWith( - "https://cdn.example.test/a.png", - "/tmp/openclaw-qqbot-downloads", - "a.png", - ); - expect(result.imageUrls).toEqual(["https://cdn.example.test/a.png"]); - expect(result.imageMediaTypes).toEqual(["image/png"]); - expect(result.attachmentLocalPaths).toEqual([null]); - }); - - it("classifies image content types case-insensitively when download succeeds", async () => { - downloadFileMock.mockResolvedValueOnce("/tmp/openclaw-qqbot-downloads/a.png"); - downloadFileMock.mockResolvedValueOnce("/tmp/openclaw-qqbot-downloads/b.png"); - - const result = await processAttachments( - [ - { content_type: "image/png", url: "https://cdn.example.test/a.png", filename: "a.png" }, - { content_type: "Image/PNG", url: "https://cdn.example.test/b.png", filename: "b.png" }, - ], - { accountId: "qq", cfg: {}, audioConvert }, - ); - - expect(result.imageUrls).toEqual([ - "/tmp/openclaw-qqbot-downloads/a.png", - "/tmp/openclaw-qqbot-downloads/b.png", - ]); - expect(result.imageMediaTypes).toEqual(["image/png", "image/png"]); - expect(result.attachmentInfo).toBe(""); - }); - - it("uses the remote image URL for a mixed-case image content type when download fails", async () => { - downloadFileMock.mockResolvedValue(null); - - const result = await processAttachments( - [{ content_type: "Image/PNG", url: "//cdn.example.test/a.png", filename: "a.png" }], - { accountId: "qq", cfg: {}, audioConvert }, - ); - - expect(result.imageUrls).toEqual(["https://cdn.example.test/a.png"]); - expect(result.imageMediaTypes).toEqual(["image/png"]); - expect(result.attachmentLocalPaths).toEqual([null]); - }); - - it("does not classify a mixed-case non-image content type as an image", async () => { - downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/doc.pdf"); - - const result = await processAttachments( - [ - { - content_type: "Application/PDF", - url: "https://cdn.example.test/doc.pdf", - filename: "doc.pdf", - }, - ], - { accountId: "qq", cfg: {}, audioConvert }, - ); - - expect(result.imageUrls).toEqual([]); - expect(result.attachmentInfo).toBe("\n[Attachment: /tmp/openclaw-qqbot-downloads/doc.pdf]"); - }); - - it("prefers voice_wav_url for voice downloads and transcribes with configured STT", async () => { - downloadFileMock.mockResolvedValue("/tmp/openclaw-qqbot-downloads/voice.wav"); - resolveSTTConfigMock.mockReturnValue({ - baseUrl: "https://stt.example.test", - apiKey: "key", - model: "whisper-1", - }); - transcribeAudioMock.mockResolvedValue("transcribed voice"); - - const result = await processAttachments( - [ - { - content_type: "voice", - url: "https://cdn.example.test/voice.silk", - filename: "voice.silk", - voice_wav_url: "//cdn.example.test/voice.wav", - asr_refer_text: "platform text", - }, - ], - { accountId: "qq", cfg: { channels: { qqbot: { stt: {} } } }, audioConvert }, - ); - - expect(downloadFileMock).toHaveBeenCalledWith( - "https://cdn.example.test/voice.wav", - "/tmp/openclaw-qqbot-downloads", - ); - expect(transcribeAudioMock).toHaveBeenCalledWith("/tmp/openclaw-qqbot-downloads/voice.wav", { - channels: { qqbot: { stt: {} } }, - }); - expect(result.voiceAttachmentPaths).toEqual(["/tmp/openclaw-qqbot-downloads/voice.wav"]); - expect(result.voiceAttachmentUrls).toEqual(["https://cdn.example.test/voice.wav"]); - expect(result.voiceAsrReferTexts).toEqual(["platform text"]); - expect(result.voiceTranscripts).toEqual(["transcribed voice"]); - expect(result.voiceTranscriptSources).toEqual(["stt"]); - }); - - it("falls back to platform ASR text when voice download fails", async () => { - downloadFileMock.mockResolvedValue(null); - - const result = await processAttachments( - [ - { - content_type: "voice", - url: "https://cdn.example.test/voice.silk", - filename: "voice.silk", - asr_refer_text: "platform text", - }, - ], - { accountId: "qq", cfg: {}, audioConvert }, - ); - - expect(result.voiceAttachmentUrls).toEqual(["https://cdn.example.test/voice.silk"]); - expect(result.voiceTranscripts).toEqual(["platform text"]); - expect(result.voiceTranscriptSources).toEqual(["asr"]); - expect(result.attachmentLocalPaths).toEqual([null]); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts deleted file mode 100644 index 1fb589e7a290..000000000000 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts +++ /dev/null @@ -1,365 +0,0 @@ -// Qqbot plugin module implements inbound attachments behavior. - -import { normalizeMimeType } from "openclaw/plugin-sdk/media-mime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { AudioConvertPort } from "../adapter/audio.port.js"; -import { downloadFile } from "../utils/file-utils.js"; -import { getQQBotMediaDir } from "../utils/platform.js"; -import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js"; - -interface RawAttachment { - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - asr_refer_text?: string; -} - -type TranscriptSource = "stt" | "asr" | "fallback"; - -/** Normalized attachment output consumed by the gateway. */ -export interface ProcessedAttachments { - attachmentInfo: string; - imageUrls: string[]; - imageMediaTypes: string[]; - voiceAttachmentPaths: string[]; - voiceAttachmentUrls: string[]; - voiceAsrReferTexts: string[]; - voiceTranscripts: string[]; - voiceTranscriptSources: TranscriptSource[]; - attachmentLocalPaths: Array; -} - -interface ProcessContext { - accountId: string; - cfg: unknown; - audioConvert: AudioConvertPort; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -const EMPTY_RESULT: ProcessedAttachments = { - attachmentInfo: "", - imageUrls: [], - imageMediaTypes: [], - voiceAttachmentPaths: [], - voiceAttachmentUrls: [], - voiceAsrReferTexts: [], - voiceTranscripts: [], - voiceTranscriptSources: [], - attachmentLocalPaths: [], -}; - -/** Download, convert, transcribe, and classify inbound attachments. */ -export async function processAttachments( - attachments: RawAttachment[] | undefined, - ctx: ProcessContext, -): Promise { - if (!attachments?.length) { - return EMPTY_RESULT; - } - - const { accountId: _accountId, cfg, log, audioConvert } = ctx; - const downloadDir = getQQBotMediaDir("downloads"); - - const imageUrls: string[] = []; - const imageMediaTypes: string[] = []; - const voiceAttachmentPaths: string[] = []; - const voiceAttachmentUrls: string[] = []; - const voiceAsrReferTexts: string[] = []; - const voiceTranscripts: string[] = []; - const voiceTranscriptSources: TranscriptSource[] = []; - const attachmentLocalPaths: Array = []; - const otherAttachments: string[] = []; - - // Phase 1: download all attachments in parallel. - const downloadTasks = attachments.map(async (att) => { - const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url; - const isVoice = audioConvert.isVoiceAttachment(att); - const wavUrl = - isVoice && att.voice_wav_url - ? att.voice_wav_url.startsWith("//") - ? `https:${att.voice_wav_url}` - : att.voice_wav_url - : ""; - - let localPath: string | null = null; - let audioPath: string | null = null; - - if (isVoice && wavUrl) { - const wavLocalPath = await downloadFile(wavUrl, downloadDir); - if (wavLocalPath) { - localPath = wavLocalPath; - audioPath = wavLocalPath; - log?.debug?.(`Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`); - } else { - log?.error(`Failed to download voice_wav_url, falling back to original URL`); - } - } - - if (!localPath) { - localPath = await downloadFile(attUrl, downloadDir, att.filename); - } - - return { att, attUrl, isVoice, localPath, audioPath }; - }); - - const downloadResults = await Promise.all(downloadTasks); - - // Phase 2: convert/transcribe voice attachments and classify everything else. - const processTasks = downloadResults.map( - async ({ att, attUrl, isVoice, localPath, audioPath }) => { - const asrReferText = normalizeOptionalString(att.asr_refer_text) ?? ""; - // Canonicalize the type/subtype before both classification and propagation. - // Downstream image resolvers intentionally consume canonical MIME values. - const normalizedContentType = normalizeMimeType(att.content_type) ?? ""; - const wavUrl = - isVoice && att.voice_wav_url - ? att.voice_wav_url.startsWith("//") - ? `https:${att.voice_wav_url}` - : att.voice_wav_url - : ""; - const voiceSourceUrl = wavUrl || attUrl; - - const meta = { - voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined, - asrReferText: isVoice && asrReferText ? asrReferText : undefined, - }; - - if (localPath) { - if (normalizedContentType.startsWith("image/")) { - log?.debug?.(`Downloaded attachment to: ${localPath}`); - return { localPath, type: "image" as const, contentType: normalizedContentType, meta }; - } - if (isVoice) { - log?.debug?.(`Downloaded attachment to: ${localPath}`); - return processVoiceAttachment( - localPath, - audioPath, - att, - asrReferText, - cfg, - downloadDir, - audioConvert, - log, - ); - } - log?.debug?.(`Downloaded attachment to: ${localPath}`); - return { localPath, type: "other" as const, filename: att.filename, meta }; - } - log?.error(`Failed to download: ${attUrl}`); - if (normalizedContentType.startsWith("image/")) { - return { - localPath: null, - type: "image-fallback" as const, - attUrl, - contentType: normalizedContentType, - meta, - }; - } - if (isVoice && asrReferText) { - log?.info(`Voice attachment download failed, using asr_refer_text fallback`); - return { - localPath: null, - type: "voice-fallback" as const, - transcript: asrReferText, - meta, - }; - } - return { - localPath: null, - type: "other-fallback" as const, - filename: att.filename ?? att.content_type, - meta, - }; - }, - ); - - const processResults = await Promise.all(processTasks); - - // Phase 3: collect results in the original attachment order. - for (const result of processResults) { - if (result.meta.voiceUrl) { - voiceAttachmentUrls.push(result.meta.voiceUrl); - } - if (result.meta.asrReferText) { - voiceAsrReferTexts.push(result.meta.asrReferText); - } - - if (result.type === "image" && result.localPath) { - imageUrls.push(result.localPath); - imageMediaTypes.push(result.contentType); - attachmentLocalPaths.push(result.localPath); - } else if (result.type === "voice" && result.localPath) { - voiceAttachmentPaths.push(result.localPath); - voiceTranscripts.push(result.transcript); - voiceTranscriptSources.push(result.transcriptSource); - attachmentLocalPaths.push(result.localPath); - } else if (result.type === "other" && result.localPath) { - otherAttachments.push(`[Attachment: ${result.localPath}]`); - attachmentLocalPaths.push(result.localPath); - } else if (result.type === "image-fallback") { - imageUrls.push(result.attUrl); - imageMediaTypes.push(result.contentType); - attachmentLocalPaths.push(null); - } else if (result.type === "voice-fallback") { - voiceTranscripts.push(result.transcript); - voiceTranscriptSources.push("asr"); - attachmentLocalPaths.push(null); - } else if (result.type === "other-fallback") { - otherAttachments.push(`[Attachment: ${result.filename}] (download failed)`); - attachmentLocalPaths.push(null); - } - } - - const attachmentInfo = otherAttachments.length > 0 ? "\n" + otherAttachments.join("\n") : ""; - - return { - attachmentInfo, - imageUrls, - imageMediaTypes, - voiceAttachmentPaths, - voiceAttachmentUrls, - voiceAsrReferTexts, - voiceTranscripts, - voiceTranscriptSources, - attachmentLocalPaths, - }; -} - -// formatVoiceText is now in core/utils/voice-text.ts (re-exported above). - -// Internal helpers. - -type VoiceResult = - | { - localPath: string; - type: "voice"; - transcript: string; - transcriptSource: TranscriptSource; - meta: { voiceUrl?: string; asrReferText?: string }; - } - | { - localPath: string; - type: "voice"; - transcript: string; - transcriptSource: TranscriptSource; - meta: { voiceUrl?: string; asrReferText?: string }; - }; - -async function processVoiceAttachment( - localPath: string, - audioPathInput: string | null, - att: RawAttachment, - asrReferText: string, - cfg: unknown, - downloadDir: string, - audioConvert: AudioConvertPort, - log: ProcessContext["log"], -): Promise { - let audioPath = audioPathInput; - const wavUrl = att.voice_wav_url - ? att.voice_wav_url.startsWith("//") - ? `https:${att.voice_wav_url}` - : att.voice_wav_url - : ""; - const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url; - const voiceSourceUrl = wavUrl || attUrl; - const meta = { - voiceUrl: voiceSourceUrl || undefined, - asrReferText: asrReferText || undefined, - }; - - const sttCfg = resolveSTTConfig(cfg as Record); - if (!sttCfg) { - if (asrReferText) { - log?.debug?.( - `Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`, - ); - return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; - } - log?.debug?.(`Voice attachment: ${att.filename} (STT not configured, skipping transcription)`); - return { - localPath, - type: "voice", - transcript: "[Voice message - transcription unavailable because STT is not configured]", - transcriptSource: "fallback", - meta, - }; - } - - // Convert SILK input to WAV before STT when necessary. - if (!audioPath) { - log?.debug?.(`Voice attachment: ${att.filename}, converting SILK→WAV...`); - try { - const wavResult = await audioConvert.convertSilkToWav(localPath, downloadDir); - if (wavResult) { - audioPath = wavResult.wavPath; - log?.debug?.( - `Voice converted: ${wavResult.wavPath} (${audioConvert.formatDuration(wavResult.duration)})`, - ); - } else { - audioPath = localPath; - } - } catch (convertErr) { - log?.error( - `Voice conversion failed: ${ - convertErr instanceof Error ? convertErr.message : JSON.stringify(convertErr) - }`, - ); - if (asrReferText) { - return { - localPath, - type: "voice", - transcript: asrReferText, - transcriptSource: "asr", - meta, - }; - } - return { - localPath, - type: "voice", - transcript: "[Voice message - format conversion failed]", - transcriptSource: "fallback", - meta, - }; - } - } - - // Run speech-to-text on the prepared audio file. - try { - const transcript = await transcribeAudio(audioPath, cfg as Record); - if (transcript) { - log?.debug?.(`STT transcript: ${truncateUtf16Safe(transcript, 100)}...`); - return { localPath, type: "voice", transcript, transcriptSource: "stt", meta }; - } - if (asrReferText) { - log?.debug?.(`STT returned empty result, using asr_refer_text fallback`); - return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; - } - log?.debug?.(`STT returned empty result`); - return { - localPath, - type: "voice", - transcript: "[Voice message - transcription returned an empty result]", - transcriptSource: "fallback", - meta, - }; - } catch (sttErr) { - log?.error(`STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`); - if (asrReferText) { - return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; - } - return { - localPath, - type: "voice", - transcript: "[Voice message - transcription failed]", - transcriptSource: "fallback", - meta, - }; - } -} diff --git a/extensions/qqbot/src/engine/gateway/inbound-context.ts b/extensions/qqbot/src/engine/gateway/inbound-context.ts deleted file mode 100644 index 2d6c2fbc8bec..000000000000 --- a/extensions/qqbot/src/engine/gateway/inbound-context.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Qqbot plugin module implements inbound context behavior. -import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { EngineAdapters } from "../adapter/index.js"; -import type { QQBotGroupCommandLevel } from "../config/group.js"; -import type { GroupActivationMode } from "../group/activation.js"; -import type { HistoryEntry } from "../group/history.js"; -import type { GroupMessageGateResult } from "../group/message-gating.js"; -import type { QueuedMessage } from "./message-queue.js"; -import type { GatewayAccount, EngineLogger, GatewayPluginRuntime } from "./types.js"; -import type { TypingKeepAlive } from "./typing-keepalive.js"; - -export interface ReplyToInfo { - id: string; - body?: string; - sender?: string; - isQuote: boolean; -} - -export interface InboundGroupInfo { - gate: GroupMessageGateResult; - activation: GroupActivationMode; - commandLevel: QQBotGroupCommandLevel; - historyLimit: number; - isMerged: boolean; - mergedMessages?: readonly QueuedMessage[]; - display: { - groupName: string; - senderLabel: string; - introHint?: string; - behaviorPrompt?: string; - }; -} - -export interface InboundContext { - event: QueuedMessage; - route: { - sessionKey: string; - accountId: string; - agentId?: string; - dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; - }; - isGroupChat: boolean; - peerId: string; - qualifiedTarget: string; - fromAddress: string; - agentBody: string; - body: string; - groupSystemPrompt?: string; - localMediaPaths: string[]; - localMediaTypes: string[]; - remoteMediaUrls: string[]; - uniqueVoicePaths: string[]; - uniqueVoiceUrls: string[]; - uniqueVoiceAsrReferTexts: string[]; - voiceMediaTypes: string[]; - hasAsrReferFallback: boolean; - voiceTranscriptSources: string[]; - replyTo?: ReplyToInfo; - commandAuthorized: boolean; - group?: InboundGroupInfo; - blocked: boolean; - blockReason?: string; - blockReasonCode?: string; - accessDecision?: ChannelIngressDecision["decision"]; - skipped: boolean; - skipReason?: - | "drop_other_mention" - | "block_unauthorized_command" - | "skip_no_mention" - | "private_command_only"; - typing: { keepAlive: TypingKeepAlive | null }; - inputNotifyRefIdx?: string; -} - -export interface InboundPipelineDeps { - account: GatewayAccount; - cfg: OpenClawConfig; - log?: EngineLogger; - runtime: GatewayPluginRuntime; - startTyping: (event: QueuedMessage) => Promise<{ - refIdx?: string; - keepAlive: TypingKeepAlive | null; - }>; - groupHistories?: Map; - allowTextCommands?: boolean; - isControlCommand?: (content: string) => boolean; - resolveGroupIntroHint?: (params: { - cfg: unknown; - accountId: string; - groupId: string; - }) => string | undefined; - adapters: EngineAdapters; -} diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts deleted file mode 100644 index 1e9f035e3c1e..000000000000 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.self-echo.test.ts +++ /dev/null @@ -1,581 +0,0 @@ -// Qqbot tests cover inbound pipeline.self echo plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { QQBotInboundAccess } from "../adapter/index.js"; -import type { RefIndexEntry } from "../ref/types.js"; -import { MSG_TYPE_QUOTE } from "../utils/text-parsing.js"; -import type { ProcessedAttachments } from "./inbound-attachments.js"; -import type { InboundPipelineDeps } from "./inbound-context.js"; -import { buildInboundContext } from "./inbound-pipeline.js"; -import type { QueuedMessage } from "./message-queue.js"; -import type { GatewayAccount, GatewayPluginRuntime } from "./types.js"; - -const getRefIndexMock = vi.hoisted(() => vi.fn<(refIdx: string) => RefIndexEntry | null>()); -const setRefIndexMock = vi.hoisted(() => vi.fn<(refIdx: string, entry: RefIndexEntry) => void>()); -const formatRefEntryForAgentMock = vi.hoisted(() => vi.fn<(entry: RefIndexEntry) => string>()); -const processAttachmentsMock = vi.hoisted(() => - vi.fn< - ( - attachments: QueuedMessage["attachments"], - ctx: { accountId: string; cfg: unknown; log?: unknown }, - ) => Promise - >(), -); - -vi.mock("../ref/store.js", () => ({ - getRefIndex: getRefIndexMock, - setRefIndex: setRefIndexMock, - formatRefEntryForAgent: formatRefEntryForAgentMock, -})); - -vi.mock("./inbound-attachments.js", () => ({ - processAttachments: processAttachmentsMock, -})); - -const emptyProcessedAttachments: ProcessedAttachments = { - attachmentInfo: "", - imageUrls: [], - imageMediaTypes: [], - voiceAttachmentPaths: [], - voiceAttachmentUrls: [], - voiceAsrReferTexts: [], - voiceTranscripts: [], - voiceTranscriptSources: [], - attachmentLocalPaths: [], -}; - -const account: GatewayAccount = { - accountId: "qq-main", - appId: "app", - clientSecret: "secret", - markdownSupport: false, - config: {}, -}; - -const allowlistQuoteVisibilityCfg = { - channels: { qqbot: { contextVisibility: "allowlist" as const } }, -}; - -const emptyAllowlist: QQBotInboundAccess["state"]["allowlists"]["dm"] = { - rawEntryCount: 0, - normalizedEntries: [], - invalidEntries: [], - disabledEntries: [], - matchedEntryIds: [], - hasConfiguredEntries: false, - hasMatchableEntries: false, - hasWildcard: false, - accessGroups: { - referenced: [], - matched: [], - missing: [], - unsupported: [], - failed: [], - }, - match: { - matched: false, - matchedEntryIds: [], - }, -}; - -function makeAccessResult( - input: { isGroup?: boolean; allowed?: boolean } = {}, -): QQBotInboundAccess { - const allowed = input.allowed ?? true; - const isGroup = input.isGroup ?? false; - return { - state: { - channelId: "qqbot", - accountId: "qq-main", - conversationKind: isGroup ? "group" : "direct", - event: { - kind: "message", - authMode: "inbound", - mayPair: true, - hasOriginSubject: false, - originSubjectMatched: false, - }, - routeFacts: [], - allowlists: { - dm: emptyAllowlist, - pairingStore: emptyAllowlist, - group: emptyAllowlist, - commandOwner: emptyAllowlist, - commandGroup: emptyAllowlist, - }, - }, - ingress: { - admission: allowed ? "dispatch" : "drop", - decision: allowed ? "allow" : "block", - decisiveGateId: allowed ? "activation" : "sender", - reasonCode: allowed - ? "activation_allowed" - : isGroup - ? "group_policy_not_allowlisted" - : "dm_policy_not_allowlisted", - graph: { gates: [] }, - }, - senderAccess: { - allowed, - decision: allowed ? "allow" : "block", - reasonCode: allowed - ? isGroup - ? "group_policy_allowed" - : "dm_policy_open" - : isGroup - ? "group_policy_not_allowlisted" - : "dm_policy_not_allowlisted", - effectiveAllowFrom: [], - effectiveGroupAllowFrom: [], - providerMissingFallbackApplied: false, - }, - commandAccess: { - requested: true, - authorized: allowed, - shouldBlockControlCommand: false, - reasonCode: allowed ? "command_authorized" : "control_command_unauthorized", - }, - routeAccess: { - allowed, - }, - activationAccess: { - ran: false, - allowed, - shouldSkip: false, - reasonCode: allowed ? "activation_allowed" : "activation_skipped", - }, - }; -} - -function makeRuntime(): GatewayPluginRuntime { - return { - state: { - openChannelIngressQueue: () => { - throw new Error("unexpected durable ingress access"); - }, - }, - channel: { - activity: { record: vi.fn() }, - routing: { - resolveAgentRoute: vi.fn(() => ({ - sessionKey: "qqbot:c2c:user-openid", - accountId: "qq-main", - })), - }, - reply: { - dispatchReplyWithBufferedBlockDispatcher: vi.fn(), - finalizeInboundContext: vi.fn((fields: Record) => fields), - formatInboundEnvelope: vi.fn(() => "formatted inbound"), - resolveEffectiveMessagesConfig: vi.fn(() => ({})), - resolveEnvelopeFormatOptions: vi.fn(() => ({})), - }, - session: { - resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"), - recordInboundSession: vi.fn(async () => undefined), - }, - inbound: { - run: vi.fn(async (rawParams: unknown) => { - const params = rawParams as { - raw: unknown; - adapter: { - ingest: (raw: unknown) => unknown; - resolveTurn: (...args: unknown[]) => unknown; - }; - }; - const input = await params.adapter.ingest(params.raw); - await params.adapter.resolveTurn( - input, - { - kind: "message", - canStartAgentTurn: true, - }, - {}, - ); - return { - dispatched: true, - dispatchResult: { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }, - }; - }), - }, - text: { - chunkMarkdownText: (text: string) => [text], - }, - }, - tts: { - textToSpeech: vi.fn(), - }, - }; -} - -function makeEvent(overrides: Partial = {}): QueuedMessage { - return { - type: "c2c", - senderId: "user-openid", - messageId: "msg-1", - content: "hello", - timestamp: "2026-04-25T00:00:00.000Z", - ...overrides, - }; -} - -function makeDeps(overrides: Partial = {}): InboundPipelineDeps { - return { - account, - cfg: {}, - log: { info: vi.fn(), error: vi.fn(), debug: vi.fn() }, - runtime: makeRuntime(), - startTyping: vi.fn(async () => ({ keepAlive: null })), - adapters: { - history: { - recordPendingHistoryEntry: vi.fn(() => []), - buildPendingHistoryContext: vi.fn(() => ""), - clearPendingHistory: vi.fn(), - }, - mentionGate: { - resolveInboundMentionDecision: vi.fn(() => ({ - effectiveWasMentioned: false, - shouldSkip: false, - shouldBypassMention: false, - implicitMention: false, - })), - }, - access: { - resolveInboundAccess: vi.fn((input): QQBotInboundAccess => makeAccessResult(input)), - resolveSlashCommandAuthorization: vi.fn(() => true), - }, - audioConvert: { - convertSilkToWav: vi.fn(async () => null), - isVoiceAttachment: vi.fn(() => false), - formatDuration: vi.fn(() => "0s"), - }, - outboundAudio: { - audioFileToSilkBase64: vi.fn(async () => undefined), - isAudioFile: vi.fn(() => false), - shouldTranscodeVoice: vi.fn(() => false), - waitForFile: vi.fn(async () => 0), - }, - commands: { - pluginVersion: "0.0.0-test", - resolveVersion: vi.fn(() => "0.0.0"), - }, - }, - ...overrides, - }; -} - -describe("buildInboundContext bot self-echo suppression", () => { - beforeEach(() => { - vi.clearAllMocks(); - getRefIndexMock.mockReturnValue(null); - formatRefEntryForAgentMock.mockReturnValue("bot reply"); - processAttachmentsMock.mockResolvedValue(emptyProcessedAttachments); - }); - - it("does not block inbound events whose current msgIdx matches this bot's outbound ref (self-echo handled upstream)", async () => { - getRefIndexMock.mockReturnValue({ - content: "mirrored reply", - senderId: "qq-main", - timestamp: 1, - isBot: true, - }); - const deps = makeDeps(); - - const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps); - - // Self-echo suppression is handled by the gateway layer upstream; - // buildInboundContext no longer short-circuits on msgIdx match. - expect(inbound.blocked).toBe(false); - expect(deps.startTyping).toHaveBeenCalledTimes(1); - expect(processAttachmentsMock).toHaveBeenCalledTimes(1); - }); - - it("does not block a restricted user message that quotes this account's bot-authored ref", async () => { - getRefIndexMock.mockReturnValue({ - content: "previous bot reply", - senderId: "qq-main", - timestamp: 1, - isBot: true, - }); - const deps = makeDeps({ - cfg: allowlistQuoteVisibilityCfg, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "allowlist" }, - }, - }); - - const inbound = await buildInboundContext(makeEvent({ refMsgIdx: "REF_BOT" }), deps); - - expect(getRefIndexMock).toHaveBeenCalledWith("REF_BOT"); - expect(formatRefEntryForAgentMock).toHaveBeenCalled(); - expect(inbound.blocked).toBe(false); - expect(inbound.replyTo).toStrictEqual({ - id: "REF_BOT", - body: "bot reply", - sender: "qq-main", - isQuote: true, - }); - expect(deps.startTyping).toHaveBeenCalledTimes(1); - expect(processAttachmentsMock).toHaveBeenCalledTimes(1); - }); - - it("omits cached quoted content from another bot account under restricted policy", async () => { - getRefIndexMock.mockReturnValue({ - content: "other bot reply", - senderId: "qq-other", - timestamp: 1, - isBot: true, - }); - const deps = makeDeps({ - cfg: allowlistQuoteVisibilityCfg, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "allowlist" }, - }, - }); - deps.adapters.access.resolveInboundAccess = vi.fn( - (input): QQBotInboundAccess => - makeAccessResult({ - isGroup: input.isGroup, - allowed: input.senderId === "user-openid", - }), - ); - - const inbound = await buildInboundContext(makeEvent({ refMsgIdx: "REF_OTHER_BOT" }), deps); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_OTHER_BOT", - isQuote: true, - }); - expect(inbound.agentBody).toContain("Original content unavailable"); - expect(inbound.agentBody).not.toContain("other bot reply"); - }); - - it("omits cache-miss quoted content when restricted policy cannot verify the quoted sender", async () => { - const deps = makeDeps({ - cfg: allowlistQuoteVisibilityCfg, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "allowlist" }, - }, - }); - - const inbound = await buildInboundContext( - makeEvent({ - refMsgIdx: "REF_UNKNOWN", - msgType: MSG_TYPE_QUOTE, - msgElements: [{ msg_idx: "REF_UNKNOWN", content: "quoted outsider content" }], - }), - deps, - ); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_UNKNOWN", - isQuote: true, - }); - expect(inbound.agentBody).toContain("Original content unavailable"); - expect(inbound.agentBody).not.toContain("quoted outsider content"); - }); - - it("keeps cache-miss quoted content for open conversations", async () => { - const deps = makeDeps({ - account: { - ...account, - config: { dmPolicy: "open" }, - }, - }); - - const inbound = await buildInboundContext( - makeEvent({ - refMsgIdx: "REF_UNKNOWN", - msgType: MSG_TYPE_QUOTE, - msgElements: [{ msg_idx: "REF_UNKNOWN", content: "quoted open content" }], - }), - deps, - ); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_UNKNOWN", - body: "quoted open content", - isQuote: true, - }); - expect(inbound.agentBody).toContain("quoted open content"); - }); - - it("keeps cache-miss quoted content for all visibility under restricted sender policy", async () => { - const deps = makeDeps({ - cfg: { channels: { qqbot: { contextVisibility: "all" } } }, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "allowlist" }, - }, - }); - - const inbound = await buildInboundContext( - makeEvent({ - refMsgIdx: "REF_ALL", - msgType: MSG_TYPE_QUOTE, - msgElements: [{ msg_idx: "REF_ALL", content: "quoted all-mode content" }], - }), - deps, - ); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_ALL", - body: "quoted all-mode content", - isQuote: true, - }); - expect(inbound.agentBody).toContain("quoted all-mode content"); - }); - - it("keeps cache-miss quoted content for quote visibility under restricted sender policy", async () => { - const deps = makeDeps({ - cfg: { channels: { qqbot: { contextVisibility: "allowlist_quote" } } }, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "allowlist" }, - }, - }); - - const inbound = await buildInboundContext( - makeEvent({ - refMsgIdx: "REF_QUOTE", - msgType: MSG_TYPE_QUOTE, - msgElements: [{ msg_idx: "REF_QUOTE", content: "quoted quote-mode content" }], - }), - deps, - ); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_QUOTE", - body: "quoted quote-mode content", - isQuote: true, - }); - expect(inbound.agentBody).toContain("quoted quote-mode content"); - }); - - it("omits cached quoted content when open DM policy is narrowed by allowFrom", async () => { - getRefIndexMock.mockReturnValue({ - content: "quoted narrowed outsider", - senderId: "outsider-openid", - timestamp: 1, - }); - const deps = makeDeps({ - cfg: allowlistQuoteVisibilityCfg, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "open" }, - }, - }); - deps.adapters.access.resolveInboundAccess = vi.fn( - (input): QQBotInboundAccess => - makeAccessResult({ - isGroup: input.isGroup, - allowed: input.senderId === "user-openid", - }), - ); - - const inbound = await buildInboundContext(makeEvent({ refMsgIdx: "REF_NARROWED" }), deps); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_NARROWED", - isQuote: true, - }); - expect(inbound.agentBody).toContain("Original content unavailable"); - expect(inbound.agentBody).not.toContain("quoted narrowed outsider"); - }); - - it("omits cached quoted content when open group policy is narrowed by groupAllowFrom", async () => { - getRefIndexMock.mockReturnValue({ - content: "quoted narrowed group outsider", - senderId: "outsider-openid", - timestamp: 1, - }); - const deps = makeDeps({ - cfg: allowlistQuoteVisibilityCfg, - account: { - ...account, - config: { groupAllowFrom: ["user-openid"], groupPolicy: "open" }, - }, - }); - const resolveInboundAccessMock = vi.fn( - (input): QQBotInboundAccess => - makeAccessResult({ - isGroup: input.isGroup, - allowed: input.senderId === "user-openid", - }), - ); - deps.adapters.access.resolveInboundAccess = resolveInboundAccessMock; - - const inbound = await buildInboundContext( - makeEvent({ - type: "group", - groupOpenid: "group-openid", - refMsgIdx: "REF_GROUP_NARROWED", - }), - deps, - ); - - expect(resolveInboundAccessMock).toHaveBeenCalledWith( - expect.objectContaining({ - conversationId: "group-openid", - groupPolicy: "allowlist", - isGroup: true, - senderId: "outsider-openid", - }), - ); - expect(inbound.replyTo).toStrictEqual({ - id: "REF_GROUP_NARROWED", - isQuote: true, - }); - expect(inbound.agentBody).toContain("Original content unavailable"); - expect(inbound.agentBody).not.toContain("quoted narrowed group outsider"); - }); - - it("omits cached quoted content when the quoted sender no longer passes restricted policy", async () => { - getRefIndexMock.mockReturnValue({ - content: "quoted outsider cache", - senderId: "outsider-openid", - timestamp: 1, - }); - const deps = makeDeps({ - cfg: allowlistQuoteVisibilityCfg, - account: { - ...account, - config: { allowFrom: ["user-openid"], dmPolicy: "allowlist" }, - }, - }); - deps.adapters.access.resolveInboundAccess = vi.fn( - (input): QQBotInboundAccess => - makeAccessResult({ - isGroup: input.isGroup, - allowed: input.senderId === "user-openid", - }), - ); - - const inbound = await buildInboundContext(makeEvent({ refMsgIdx: "REF_OTHER" }), deps); - - expect(inbound.replyTo).toStrictEqual({ - id: "REF_OTHER", - isQuote: true, - }); - expect(inbound.agentBody).toContain("Original content unavailable"); - expect(inbound.agentBody).not.toContain("quoted outsider cache"); - expect(formatRefEntryForAgentMock).not.toHaveBeenCalled(); - }); - - it("does not block matching refs from another QQ Bot account", async () => { - getRefIndexMock.mockReturnValue({ - content: "other bot reply", - senderId: "qq-other", - timestamp: 1, - isBot: true, - }); - const deps = makeDeps(); - - const inbound = await buildInboundContext(makeEvent({ msgIdx: "REF_BOT" }), deps); - - expect(inbound.blocked).toBe(false); - expect(deps.startTyping).toHaveBeenCalledTimes(1); - expect(processAttachmentsMock).toHaveBeenCalledTimes(1); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts deleted file mode 100644 index 9965a990e950..000000000000 --- a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Qqbot plugin module implements inbound pipeline behavior. -import type { HistoryPort } from "../adapter/history.port.js"; -import type { HistoryEntry } from "../group/history.js"; -import { processAttachments } from "./inbound-attachments.js"; -import type { InboundContext, InboundPipelineDeps } from "./inbound-context.js"; -import type { QueuedMessage } from "./message-queue.js"; -import { - buildAgentBody, - buildBody, - buildDynamicCtx, - buildGroupSystemPrompt, - buildQuotePart, - buildSkippedInboundContext, - buildUserContent, - buildUserMessage, - classifyMedia, - resolveQuote, - runAccessStage, - runGroupGateStage, - writeRefIndex, -} from "./stages/index.js"; - -export async function buildInboundContext( - event: QueuedMessage, - deps: InboundPipelineDeps, -): Promise { - const { account, log } = deps; - - const accessResult = await runAccessStage(event, deps); - if (accessResult.kind === "block") { - return accessResult.context; - } - const { isGroupChat, peerId, qualifiedTarget, fromAddress, route, access } = accessResult; - - const typingPromise = deps.startTyping(event); - - const processed = await processAttachments(event.attachments, { - accountId: account.accountId, - cfg: deps.cfg, - audioConvert: deps.adapters.audioConvert, - log, - }); - - const { parsedContent, userContent } = buildUserContent({ - event, - attachmentInfo: processed.attachmentInfo, - voiceTranscripts: processed.voiceTranscripts, - }); - - const replyTo = await resolveQuote(event, deps); - - const typingResult = await typingPromise; - writeRefIndex({ - event, - parsedContent, - processed, - inputNotifyRefIdx: typingResult.refIdx, - }); - - let groupInfo: InboundContext["group"]; - if (event.type === "group" && event.groupOpenid) { - const gateOutcome = runGroupGateStage({ - event, - deps, - accountId: account.accountId, - agentId: route.agentId, - sessionKey: route.sessionKey, - userContent, - processedAttachments: processed, - access, - }); - - if (gateOutcome.kind === "skip") { - typingResult.keepAlive?.stop(); - return buildSkippedInboundContext({ - event, - route, - isGroupChat: true, - peerId, - qualifiedTarget, - fromAddress, - group: gateOutcome.groupInfo, - skipReason: gateOutcome.skipReason, - access, - typing: { keepAlive: typingResult.keepAlive }, - inputNotifyRefIdx: typingResult.refIdx, - }); - } - groupInfo = gateOutcome.groupInfo; - } - - const body = buildBody({ - event, - deps, - userContent, - isGroupChat, - imageUrls: processed.imageUrls, - }); - const quotePart = buildQuotePart(replyTo); - const media = classifyMedia(processed); - const dynamicCtx = buildDynamicCtx({ - imageUrls: processed.imageUrls, - uniqueVoicePaths: media.uniqueVoicePaths, - uniqueVoiceUrls: media.uniqueVoiceUrls, - uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts, - }); - - const userMessage = buildUserMessage({ - event, - userContent, - quotePart, - isGroupChat, - groupInfo, - }); - const agentBody = buildAgentBody({ - event, - userContent, - userMessage, - dynamicCtx, - isGroupChat, - groupInfo, - deps, - }); - - const accountSystemInstruction = account.systemPrompt ?? ""; - const groupSystemPrompt = buildGroupSystemPrompt(accountSystemInstruction, groupInfo); - - return { - event, - route, - isGroupChat, - peerId, - qualifiedTarget, - fromAddress, - agentBody, - body, - groupSystemPrompt, - localMediaPaths: media.localMediaPaths, - localMediaTypes: media.localMediaTypes, - remoteMediaUrls: media.remoteMediaUrls, - uniqueVoicePaths: media.uniqueVoicePaths, - uniqueVoiceUrls: media.uniqueVoiceUrls, - uniqueVoiceAsrReferTexts: media.uniqueVoiceAsrReferTexts, - voiceMediaTypes: media.voiceMediaTypes, - hasAsrReferFallback: media.hasAsrReferFallback, - voiceTranscriptSources: media.voiceTranscriptSources, - replyTo, - commandAuthorized: access.commandAccess.authorized, - group: groupInfo, - blocked: false, - skipped: false, - accessDecision: access.senderAccess.decision, - typing: { keepAlive: typingResult.keepAlive }, - inputNotifyRefIdx: typingResult.refIdx, - }; -} - -export function clearGroupPendingHistory(params: { - historyMap: Map | undefined; - groupOpenid: string | undefined; - historyLimit: number; - historyPort: HistoryPort; -}): void { - if (!params.historyMap || !params.groupOpenid) { - return; - } - params.historyPort.clearPendingHistory({ - historyMap: params.historyMap, - historyKey: params.groupOpenid, - limit: params.historyLimit, - }); -} diff --git a/extensions/qqbot/src/engine/gateway/ingress-effects.ts b/extensions/qqbot/src/engine/gateway/ingress-effects.ts deleted file mode 100644 index edb30cd0cbae..000000000000 --- a/extensions/qqbot/src/engine/gateway/ingress-effects.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { createIngressEffectOnce } from "openclaw/plugin-sdk/ingress-effect-once"; -import { QQBOT_INGRESS_COMPLETED_MAX_ENTRIES, QQBOT_INGRESS_COMPLETED_TTL_MS } from "./ingress.js"; -import type { EngineLogger } from "./types.js"; - -export type QQBotIngressEffectOnce = ReturnType; - -export function createQQBotIngressEffectOnce(params: { - accountId: string; - log?: EngineLogger; -}): QQBotIngressEffectOnce { - return createIngressEffectOnce({ - pluginId: "qqbot", - namespacePrefix: `qqbot.gateway.${params.accountId}`, - ttlMs: QQBOT_INGRESS_COMPLETED_TTL_MS, - stateMaxEntries: QQBOT_INGRESS_COMPLETED_MAX_ENTRIES, - onDiskError: (error) => { - params.log?.error(`QQBot ingress effect state failed: ${formatErrorMessage(error)}`); - }, - }); -} diff --git a/extensions/qqbot/src/engine/gateway/ingress-envelope.ts b/extensions/qqbot/src/engine/gateway/ingress-envelope.ts deleted file mode 100644 index 2e179e00448f..000000000000 --- a/extensions/qqbot/src/engine/gateway/ingress-envelope.ts +++ /dev/null @@ -1,108 +0,0 @@ -// QQBot plugin module validates raw gateway envelopes for durable ingress. -import { normalizeNullableString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { GatewayEvent, GatewayOp } from "./constants.js"; -import type { WSPayload } from "./types.js"; - -const QQBOT_TURN_EVENT_TYPES = new Set([ - GatewayEvent.C2C_MESSAGE_CREATE, - GatewayEvent.AT_MESSAGE_CREATE, - GatewayEvent.DIRECT_MESSAGE_CREATE, - GatewayEvent.GROUP_AT_MESSAGE_CREATE, - GatewayEvent.GROUP_MESSAGE_CREATE, -]); - -export class QQBotIngressPayloadError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "QQBotIngressPayloadError"; - } -} - -type QQBotIngressEnvelopeFacts = { - eventId: string; - eventType: string; - laneKey: string; - payload: WSPayload; -}; - -function record(value: unknown, field: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new QQBotIngressPayloadError(`QQBot gateway event is missing ${field}.`); - } - return value as Record; -} - -function requiredString(value: unknown, field: string): string { - const normalized = nonEmptyString(value); - if (!normalized) { - throw new QQBotIngressPayloadError(`QQBot gateway event is missing ${field}.`); - } - return normalized; -} - -function parseRawEnvelope(rawEnvelope: string): WSPayload { - let parsed: unknown; - try { - parsed = JSON.parse(rawEnvelope); - } catch (error) { - throw new QQBotIngressPayloadError("QQBot gateway envelope contains invalid JSON.", { - cause: error, - }); - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new QQBotIngressPayloadError("QQBot gateway envelope must be an object."); - } - return parsed as WSPayload; -} - -export function isQQBotTurnEventType(eventType: string | undefined): boolean { - return eventType !== undefined && QQBOT_TURN_EVENT_TYPES.has(eventType); -} - -export function inspectQQBotIngressEnvelope(rawEnvelope: string): QQBotIngressEnvelopeFacts | null { - const payload = parseRawEnvelope(rawEnvelope); - if (payload.op !== GatewayOp.DISPATCH || !isQQBotTurnEventType(payload.t)) { - return null; - } - const eventType = requiredString(payload.t, "t"); - const data = record(payload.d, "d"); - // Message id, not the outer delivery id: QQ can expose one logical group - // post through the @ and full-message create variants with distinct envelope - // ids. Both variants carry the same stable data.id. - const eventId = `message:${requiredString(data.id, "d.id")}`; - - if (eventType === GatewayEvent.C2C_MESSAGE_CREATE) { - const author = record(data.author, "d.author"); - return { - eventId, - eventType, - laneKey: `user:${requiredString(author.user_openid, "d.author.user_openid")}`, - payload, - }; - } - if (eventType === GatewayEvent.AT_MESSAGE_CREATE) { - return { - eventId, - eventType, - laneKey: `channel:${requiredString(data.channel_id, "d.channel_id")}`, - payload, - }; - } - if (eventType === GatewayEvent.DIRECT_MESSAGE_CREATE) { - const author = record(data.author, "d.author"); - return { - eventId, - eventType, - laneKey: `user:${requiredString(author.id, "d.author.id")}`, - payload, - }; - } - const author = record(data.author, "d.author"); - requiredString(author.member_openid, "d.author.member_openid"); - return { - eventId, - eventType, - laneKey: `group:${requiredString(data.group_openid, "d.group_openid")}`, - payload, - }; -} diff --git a/extensions/qqbot/src/engine/gateway/ingress-errors.ts b/extensions/qqbot/src/engine/gateway/ingress-errors.ts deleted file mode 100644 index 22bf588f0f1d..000000000000 --- a/extensions/qqbot/src/engine/gateway/ingress-errors.ts +++ /dev/null @@ -1,26 +0,0 @@ -// QQBot plugin module classifies durable ingress failures. -export function isQQBotAuthenticationFailure(error: unknown): boolean { - let current: unknown = error; - const seen = new Set(); - while (current && typeof current === "object" && !seen.has(current)) { - seen.add(current); - const candidate = current as { - httpStatus?: unknown; - status?: unknown; - statusCode?: unknown; - cause?: unknown; - }; - if ( - candidate.httpStatus === 401 || - candidate.httpStatus === 403 || - candidate.status === 401 || - candidate.status === 403 || - candidate.statusCode === 401 || - candidate.statusCode === 403 - ) { - return true; - } - current = candidate.cause; - } - return false; -} diff --git a/extensions/qqbot/src/engine/gateway/ingress.test-support.ts b/extensions/qqbot/src/engine/gateway/ingress.test-support.ts deleted file mode 100644 index ee139ef00239..000000000000 --- a/extensions/qqbot/src/engine/gateway/ingress.test-support.ts +++ /dev/null @@ -1,77 +0,0 @@ -// QQBot durable ingress test helpers own isolated persistent queue state. -import fs from "node:fs/promises"; -import path from "node:path"; -import { - closeOpenClawStateDatabaseForTest, - createChannelIngressQueueForTests, -} from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; -import { GatewayEvent, GatewayOp } from "./constants.js"; - -export type QQBotTestIngressPayload = { - version: 1; - receivedAt: number; - rawEnvelope: string; -}; - -export function qqC2CEnvelope(params: { - messageId: string; - deliveryId?: string; - userId?: string; - sequence?: number; -}): string { - return JSON.stringify({ - op: GatewayOp.DISPATCH, - id: params.deliveryId ?? `delivery-${params.messageId}`, - s: params.sequence ?? 1, - t: GatewayEvent.C2C_MESSAGE_CREATE, - d: { - id: params.messageId, - content: "hello", - timestamp: "2026-07-18T12:00:00Z", - author: { user_openid: params.userId ?? "user-1" }, - }, - }); -} - -export function qqGroupEnvelope(params: { - messageId: string; - deliveryId: string; - eventType: typeof GatewayEvent.GROUP_AT_MESSAGE_CREATE | typeof GatewayEvent.GROUP_MESSAGE_CREATE; -}): string { - return JSON.stringify({ - op: GatewayOp.DISPATCH, - id: params.deliveryId, - s: 1, - t: params.eventType, - d: { - id: params.messageId, - content: "hello group", - timestamp: "2026-07-18T12:00:00Z", - author: { member_openid: "member-1" }, - group_openid: "group-1", - }, - }); -} - -export async function withQQBotIngressQueue( - run: ( - queue: ReturnType>, - ) => Promise, -): Promise { - const created = await fs.mkdtemp( - path.join(resolvePreferredOpenClawTmpDir(), "openclaw-qqbot-ingress-"), - ); - const stateDir = await fs.realpath(created); - const queue = createChannelIngressQueueForTests({ - channelId: "qqbot", - accountId: "default", - stateDir, - }); - try { - return await run(queue); - } finally { - closeOpenClawStateDatabaseForTest(); - await fs.rm(stateDir, { recursive: true, force: true }); - } -} diff --git a/extensions/qqbot/src/engine/gateway/ingress.test.ts b/extensions/qqbot/src/engine/gateway/ingress.test.ts deleted file mode 100644 index 5ab2168a478d..000000000000 --- a/extensions/qqbot/src/engine/gateway/ingress.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -// QQBot durable ingress tests cover raw admission, recovery, and twin parity. -import type { ChannelIngressQueue } from "openclaw/plugin-sdk/channel-outbound"; -import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; -import { closeOpenClawStateDatabaseForTest } from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { GatewayEvent } from "./constants.js"; -import { createQQBotIngressMonitor } from "./ingress.js"; -import { - qqC2CEnvelope, - qqGroupEnvelope, - type QQBotTestIngressPayload, - withQQBotIngressQueue, -} from "./ingress.test-support.js"; - -type QQBotIngressDispatch = Parameters[0]["dispatch"]; - -function startMonitor( - queue: ChannelIngressQueue, - dispatch: QQBotIngressDispatch, -) { - return createQQBotIngressMonitor({ - accountId: "default", - queue, - dispatch, - pollIntervalMs: 10, - adoptionStallTimeoutMs: 5_000, - }); -} - -afterEach(() => { - closeOpenClawStateDatabaseForTest(); - vi.restoreAllMocks(); -}); - -describe("QQBot durable ingress", () => { - it("does not stage or dispatch before the raw envelope is durable", async () => { - await withQQBotIngressQueue(async (queue) => { - const appendGate = createDeferred(); - const enqueue = vi.fn(async (...args: Parameters) => { - await appendGate.promise; - return await queue.enqueue(...args); - }); - const gatedQueue = { ...queue, enqueue }; - const dispatch = vi.fn(async (_message, lifecycle) => { - await lifecycle.onAdopted(); - }); - const monitor = startMonitor(gatedQueue, dispatch); - try { - const admitted = monitor.receive(qqC2CEnvelope({ messageId: "durable-first" })); - await vi.waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1)); - expect(dispatch).not.toHaveBeenCalled(); - - appendGate.resolve(); - await admitted; - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); - } finally { - await monitor.stop(); - } - }); - }); - - it("recovers an uncompleted row with a fresh drain and dispatches exactly once", async () => { - await withQQBotIngressQueue(async (queue) => { - const firstDispatch = vi.fn(async () => ({ kind: "deferred" as const })); - const first = startMonitor(queue, firstDispatch); - await first.receive(qqC2CEnvelope({ messageId: "restart" })); - await vi.waitFor(() => expect(firstDispatch).toHaveBeenCalledTimes(1)); - await first.stop(); - - const recoveredDispatch = vi.fn(async (_message, lifecycle) => { - await lifecycle.onAdopted(); - }); - const recovered = startMonitor(queue, recoveredDispatch); - try { - await vi.waitFor(() => expect(recoveredDispatch).toHaveBeenCalledTimes(1)); - await recovered.waitForIdle(); - expect(recoveredDispatch).toHaveBeenCalledTimes(1); - } finally { - await recovered.stop(); - } - }); - }); - - it("keeps a completion tombstone so a duplicate cannot dispatch twice", async () => { - await withQQBotIngressQueue(async (queue) => { - const dispatch = vi.fn(async (_message, lifecycle) => { - await lifecycle.onAdopted(); - }); - const monitor = startMonitor(queue, dispatch); - try { - const envelope = qqC2CEnvelope({ messageId: "completed" }); - await monitor.receive(envelope); - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); - await monitor.receive(envelope); - await monitor.waitForIdle(); - expect(dispatch).toHaveBeenCalledTimes(1); - } finally { - await monitor.stop(); - } - }); - }); - - it("deduplicates group @ and full-message twins by stable message id", async () => { - await withQQBotIngressQueue(async (queue) => { - const dispatch = vi.fn(async (_message, lifecycle) => { - await lifecycle.onAdopted(); - }); - const monitor = startMonitor(queue, dispatch); - try { - await Promise.all([ - monitor.receive( - qqGroupEnvelope({ - messageId: "logical-twin", - deliveryId: "delivery-at", - eventType: GatewayEvent.GROUP_AT_MESSAGE_CREATE, - }), - ), - monitor.receive( - qqGroupEnvelope({ - messageId: "logical-twin", - deliveryId: "delivery-full", - eventType: GatewayEvent.GROUP_MESSAGE_CREATE, - }), - ), - ]); - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); - } finally { - await monitor.stop(); - } - }); - }); - - it("preserves arrival order across an append retry backoff", async () => { - await withQQBotIngressQueue(async (queue) => { - let failFirst = true; - const enqueue = queue.enqueue.bind(queue); - queue.enqueue = async (...args) => { - if (failFirst) { - failFirst = false; - throw new Error("sqlite busy"); - } - return await enqueue(...args); - }; - const dispatched: string[] = []; - const monitor = startMonitor(queue, async (message, lifecycle) => { - dispatched.push(message.messageId); - await lifecycle.onAdopted(); - }); - try { - await Promise.all([ - monitor.receive(qqC2CEnvelope({ messageId: "first", sequence: 1 })), - monitor.receive(qqC2CEnvelope({ messageId: "second", sequence: 2 })), - ]); - await vi.waitFor(() => expect(dispatched).toEqual(["first", "second"])); - } finally { - await monitor.stop(); - } - }); - }); - - it("does not dispatch from a pump racing stop through async prune", async () => { - await withQQBotIngressQueue(async (queue) => { - const pruneGate = createDeferred(); - const pruneStarted = createDeferred(); - const prune = queue.prune.bind(queue); - queue.prune = async (...args) => { - pruneStarted.resolve(); - await pruneGate.promise; - return await prune(...args); - }; - const dispatch = vi.fn(); - const monitor = startMonitor(queue, dispatch); - await pruneStarted.promise; - await monitor.receive(qqC2CEnvelope({ messageId: "stop-race" })); - - const stopping = monitor.stop(); - pruneGate.resolve(); - await stopping; - expect(dispatch).not.toHaveBeenCalled(); - }); - }); - - it("stores the exact raw envelope in the user conversation lane", async () => { - await withQQBotIngressQueue(async (queue) => { - const dispatch = vi.fn(async () => ({ kind: "deferred" as const })); - const monitor = startMonitor(queue, dispatch); - const rawEnvelope = qqC2CEnvelope({ messageId: "raw", userId: "user-raw" }); - try { - await monitor.receive(rawEnvelope); - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)); - expect(dispatch.mock.calls[0]?.[2]).toBe("message:raw"); - expect(await queue.listClaims()).toEqual([ - expect.objectContaining({ - id: "message:raw", - laneKey: "user:user-raw", - payload: expect.objectContaining({ rawEnvelope }), - }), - ]); - } finally { - await monitor.stop(); - } - }); - }); - - it("dead-letters malformed persisted envelopes without retry", async () => { - await withQQBotIngressQueue(async (queue) => { - await queue.enqueue( - "message:malformed", - { version: 1, receivedAt: 1, rawEnvelope: "{" }, - { receivedAt: 1, laneKey: "user:user-1" }, - ); - const dispatch = vi.fn(); - const monitor = startMonitor(queue, dispatch); - try { - await vi.waitFor(async () => { - const verdict = await queue.enqueue("message:malformed", { - version: 1, - receivedAt: 1, - rawEnvelope: "{}", - }); - expect(verdict.kind).toBe("failed"); - }); - expect(dispatch).not.toHaveBeenCalled(); - } finally { - await monitor.stop(); - } - }); - }); - - it("dead-letters permanent QQ authentication failures", async () => { - await withQQBotIngressQueue(async (queue) => { - const dispatch = vi.fn(async () => { - throw Object.assign(new Error("QQ API unauthorized"), { httpStatus: 401 }); - }); - const monitor = startMonitor(queue, dispatch); - try { - await monitor.receive(qqC2CEnvelope({ messageId: "auth" })); - await vi.waitFor(async () => { - const verdict = await queue.enqueue("message:auth", { - version: 1, - receivedAt: 1, - rawEnvelope: "{}", - }); - expect(verdict.kind).toBe("failed"); - }); - expect(dispatch).toHaveBeenCalledTimes(1); - } finally { - await monitor.stop(); - } - }); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/ingress.ts b/extensions/qqbot/src/engine/gateway/ingress.ts deleted file mode 100644 index 0bf931390728..000000000000 --- a/extensions/qqbot/src/engine/gateway/ingress.ts +++ /dev/null @@ -1,157 +0,0 @@ -// QQBot plugin module owns raw gateway-envelope durable ingress and replay. -import { - CHANNEL_INGRESS_RETENTION_DEFAULTS, - createChannelIngressError, - createChannelIngressMonitor, - DEFAULT_INGRESS_ADOPTION_STALL_MS, - type ChannelIngressQueue, -} from "openclaw/plugin-sdk/channel-outbound"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { dispatchEvent } from "./event-dispatcher.js"; -import { inspectQQBotIngressEnvelope, QQBotIngressPayloadError } from "./ingress-envelope.js"; -import { isQQBotAuthenticationFailure } from "./ingress-errors.js"; -import type { QueuedMessage } from "./message-queue.js"; -import type { EngineLogger, GatewayPluginRuntime, QQBotIngressLifecycle } from "./types.js"; - -const QQBOT_INGRESS_PAYLOAD_VERSION = 1; -const QQBOT_INGRESS_POLL_INTERVAL_MS = 1_000; -export const QQBOT_INGRESS_COMPLETED_TTL_MS = CHANNEL_INGRESS_RETENTION_DEFAULTS.completedTtlMs; -export const QQBOT_INGRESS_COMPLETED_MAX_ENTRIES = - CHANNEL_INGRESS_RETENTION_DEFAULTS.completedMaxEntries; - -type QQBotIngressPayload = { - version: 1; - receivedAt: number; - rawEnvelope: string; -}; - -export type QQBotIngressDispatchResult = - | { kind: "completed" } - | { kind: "deferred" } - | { kind: "failed-retryable"; error: unknown }; - -type QQBotIngressDispatch = ( - message: QueuedMessage, - lifecycle: QQBotIngressLifecycle, - eventId: string, -) => Promise | QQBotIngressDispatchResult | void; - -export const QQBotIngressAdmissionError = createChannelIngressError("QQBotIngressAdmissionError"); -export type QQBotIngressAdmissionError = InstanceType; - -export type QQBotIngressMonitor = { - receive: (rawEnvelope: string) => Promise; - stop: () => Promise; - waitForIdle: () => Promise; -}; - -export function createQQBotIngressMonitor(options: { - accountId: string; - runtime?: Pick; - queue?: ChannelIngressQueue; - dispatch: QQBotIngressDispatch; - log?: EngineLogger; - pollIntervalMs?: number; - adoptionStallTimeoutMs?: number; -}): QQBotIngressMonitor { - const monitor = createChannelIngressMonitor< - string, - { receivedAt: number; rawEnvelope: string }, - QQBotIngressPayload - >({ - queue: - options.queue ?? - (() => { - if (!options.runtime) { - throw new Error("QQBot ingress runtime is unavailable."); - } - return options.runtime.state.openChannelIngressQueue({ - accountId: options.accountId, - }); - }), - inspect: (rawEnvelope) => { - const facts = inspectQQBotIngressEnvelope(rawEnvelope); - return facts ? { eventId: facts.eventId, laneKey: facts.laneKey } : null; - }, - payload: { - version: QQBOT_INGRESS_PAYLOAD_VERSION, - serialize: (rawEnvelope, { receivedAt }) => ({ receivedAt, rawEnvelope }), - deserialize: (body) => body.rawEnvelope, - encode: ({ body }) => ({ version: QQBOT_INGRESS_PAYLOAD_VERSION, ...body }), - decode: (payload) => ({ - version: payload.version, - body: { receivedAt: payload.receivedAt, rawEnvelope: payload.rawEnvelope }, - }), - createClaimError: (kind, claim) => - new QQBotIngressPayloadError( - kind === "invalid-version" - ? "QQBot ingress payload version is unsupported." - : `QQBot ingress row ${claim.id} changed identity after durable admission.`, - ), - }, - deliver: async (rawEnvelope, lifecycle, claim) => { - const facts = inspectQQBotIngressEnvelope(rawEnvelope); - if (!facts) { - throw new QQBotIngressPayloadError( - `QQBot ingress row ${claim.id} no longer maps to a message turn.`, - ); - } - // Stage mapping stays claim-side. Receive stores the exact transport envelope. - const mapped = dispatchEvent( - facts.eventType, - facts.payload.d, - options.accountId, - options.log, - ); - if (mapped.action !== "message") { - throw new QQBotIngressPayloadError( - `QQBot ingress row ${claim.id} no longer maps to a message turn.`, - ); - } - return await options.dispatch(mapped.msg, lifecycle, claim.id); - }, - pollIntervalMs: options.pollIntervalMs ?? QQBOT_INGRESS_POLL_INTERVAL_MS, - retention: "standard", - drain: { - orderBy: "received", - adoptionStallTimeoutMs: options.adoptionStallTimeoutMs ?? DEFAULT_INGRESS_ADOPTION_STALL_MS, - resolveNonRetryableFailure: (error) => { - if (error instanceof QQBotIngressPayloadError) { - return { reason: "invalid-event", message: error.message }; - } - if (isQQBotAuthenticationFailure(error)) { - return { reason: "authentication-failed", message: formatErrorMessage(error) }; - } - return null; - }, - onLog: (message) => options.log?.error(`QQBot ingress: ${message}`), - }, - createStoppedError: () => new Error("QQBot ingress monitor is stopped."), - onError: (error) => - options.log?.error(`QQBot ingress drain failed: ${formatErrorMessage(error)}`), - }); - monitor.start(); - - return { - receive: async (rawEnvelope) => { - if (monitor.isStopped()) { - throw new Error("QQBot ingress monitor is stopped."); - } - const facts = inspectQQBotIngressEnvelope(rawEnvelope); - if (!facts) { - return; - } - try { - await monitor.admit(rawEnvelope, { - facts: { eventId: facts.eventId, laneKey: facts.laneKey }, - }); - } catch (error) { - throw new QQBotIngressAdmissionError("QQBot durable ingress append failed.", { - cause: error, - }); - } - }, - stop: monitor.stop, - waitForIdle: monitor.waitForIdle, - }; -} diff --git a/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts b/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts deleted file mode 100644 index a2e32168e0d5..000000000000 --- a/extensions/qqbot/src/engine/gateway/interaction-handler.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -// Qqbot tests cover interaction handler plugin behavior. -import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createSdkAccessAdapter } from "../../bridge/sdk-adapter.js"; -import { registerPlatformAdapter, type PlatformAdapter } from "../adapter/index.js"; -import type { InteractionEvent } from "../types.js"; -import { createInteractionHandler } from "./interaction-handler.js"; -import type { GatewayAccount, GatewayPluginRuntime } from "./types.js"; - -const acknowledgeInteractionMock = vi.hoisted(() => vi.fn(async () => undefined)); -const sendTextMock = vi.hoisted(() => vi.fn(async () => ({ id: "message-1", timestamp: 1 }))); - -function waitForQqInteraction(assertion: () => void) { - return vi.waitFor(assertion, { interval: 1 }); -} - -vi.mock("../messaging/sender.js", () => ({ - accountToCreds: (account: GatewayAccount) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - }), - acknowledgeInteraction: acknowledgeInteractionMock, - sendText: sendTextMock, -})); - -const appliedApprovalResult = { - applied: true, - approval: { - id: "exec:abc12345", - urlPath: "/approve/exec%3Aabc12345", - createdAtMs: 1, - expiresAtMs: 10_000, - presentation: { - kind: "exec", - commandText: "echo approved", - allowedDecisions: ["allow-once", "deny"], - }, - status: "allowed", - decision: "allow-once", - resolvedAtMs: 2, - reason: "user", - }, -} satisfies ApprovalResolveResult; - -const resolveApprovalMock = vi.fn( - async (): Promise => appliedApprovalResult, -); -const expectedApprovalResolve = (senderId = "ATTACKER_OPENID") => - ({ - approvalId: "exec:abc12345", - approvalKind: "exec", - decision: "allow-once", - accountId: "default", - senderId, - }) as const; - -function makeAccount(config: GatewayAccount["config"] = {}): GatewayAccount { - return { - accountId: "default", - appId: "app", - clientSecret: "secret", - markdownSupport: false, - config, - }; -} - -const account = makeAccount(); - -const runtime = {} as GatewayPluginRuntime; - -function makeRestrictedCfg(approvers: string[]): OpenClawConfig { - return { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - execApprovals: { - enabled: true, - approvers, - }, - }, - }, - } as OpenClawConfig; -} - -function makeCommandAuthorizedFallbackCfg(): OpenClawConfig { - return { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - allowFrom: ["ATTACKER_OPENID"], - }, - }, - } as OpenClawConfig; -} - -function makeApprovalEvent(overrides: Partial = {}): InteractionEvent { - return { - id: "interaction-1", - type: 11, - chat_type: 1, - group_openid: "group-1", - group_member_openid: "ATTACKER_OPENID", - version: 1, - data: { - type: 11, - resolved: { - button_data: "approve:v2:exec:exec%3Aabc12345:allow-once", - user_id: "ATTACKER_USER_ID", - }, - }, - ...overrides, - }; -} - -function installPlatformAdapter(): void { - registerPlatformAdapter({ - validateRemoteUrl: vi.fn(async () => undefined), - resolveSecret: vi.fn(async (value: unknown) => (typeof value === "string" ? value : undefined)), - downloadFile: vi.fn(async () => "/tmp/file"), - fetchMedia: vi.fn(async () => { - throw new Error("unused"); - }), - getTempDir: () => "/tmp", - hasConfiguredSecret: (value: unknown) => typeof value === "string" && value.length > 0, - normalizeSecretInputString: (value: unknown) => (typeof value === "string" ? value : undefined), - resolveSecretInputString: ({ value }: { value: unknown }) => - typeof value === "string" ? value : undefined, - resolveApproval: resolveApprovalMock, - } as PlatformAdapter); -} - -describe("createInteractionHandler approval buttons", () => { - beforeEach(() => { - vi.clearAllMocks(); - resolveApprovalMock.mockResolvedValue(appliedApprovalResult); - installPlatformAdapter(); - }); - - it("rejects approval button clicks from users outside the configured approvers", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }); - - it("does not authorize from resolved user id when the actor openid is not approved", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler( - makeApprovalEvent({ - data: { - type: 11, - resolved: { - button_data: "approve:v2:exec:exec%3Aabc12345:allow-once", - user_id: "OWNER_OPENID", - }, - }, - }), - ); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }); - - it("resolves approval button clicks from configured approvers", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve("OWNER_OPENID")), - ); - }); - - it("preserves plugin ownership through configured-approver authorization", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler( - makeApprovalEvent({ - group_member_openid: "OWNER_OPENID", - data: { - type: 11, - resolved: { - button_data: "approve:v2:plugin:exec%3Alooks-like-exec%2F1:deny", - user_id: "ATTACKER_USER_ID", - }, - }, - }), - ); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith({ - approvalId: "exec:looks-like-exec/1", - approvalKind: "plugin", - decision: "deny", - accountId: "default", - senderId: "OWNER_OPENID", - }), - ); - }); - - it("rejects plugin approval buttons from users outside the configured approvers", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler( - makeApprovalEvent({ - data: { - type: 11, - resolved: { - button_data: "approve:v2:plugin:exec%3Alooks-like-exec%2F1:deny", - user_id: "ATTACKER_USER_ID", - }, - }, - }), - ); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }); - - it("logs the canonical winner when another surface already resolved", async () => { - resolveApprovalMock.mockResolvedValueOnce({ - applied: false, - approval: { - id: "exec:abc12345", - urlPath: "/approve/exec%3Aabc12345", - createdAtMs: 1, - expiresAtMs: 10_000, - presentation: { - kind: "exec", - commandText: "echo approved", - allowedDecisions: ["allow-once", "deny"], - }, - status: "denied", - decision: "deny", - resolvedAtMs: 2, - reason: "user", - }, - }); - const log = { info: vi.fn(), error: vi.fn() }; - const handler = createInteractionHandler(account, runtime, log, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); - - await waitForQqInteraction(() => - expect(log.info).toHaveBeenCalledWith( - "Approval already resolved: id=exec:abc12345, status=denied, decision=deny", - ), - ); - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "Approval response received." }, - ); - expect(sendTextMock).toHaveBeenCalledWith( - { type: "group", id: "group-1" }, - "This approval was already resolved: Denied.", - { appId: "app", clientSecret: "secret" }, - { msgId: undefined }, - ); - expect(log.info).not.toHaveBeenCalledWith(expect.stringContaining("decision=allow-once")); - expect(log.error).not.toHaveBeenCalled(); - }); - - it("acknowledges before a slow canonical resolution completes", async () => { - let releaseResolution!: (result: ApprovalResolveResult) => void; - resolveApprovalMock.mockImplementationOnce( - async () => - await new Promise((resolve) => { - releaseResolution = resolve; - }), - ); - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" })); - - await waitForQqInteraction(() => - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "Approval response received." }, - ), - ); - await waitForQqInteraction(() => expect(resolveApprovalMock).toHaveBeenCalled()); - expect(sendTextMock).not.toHaveBeenCalled(); - - releaseResolution(appliedApprovalResult); - await waitForQqInteraction(() => expect(sendTextMock).toHaveBeenCalled()); - }); - - it("uses the direct user openid when a group member openid is unavailable", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeRestrictedCfg(["OWNER_OPENID"]), - }); - - handler( - makeApprovalEvent({ - chat_type: 2, - group_openid: undefined, - group_member_openid: undefined, - user_openid: "OWNER_OPENID", - }), - ); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve("OWNER_OPENID")), - ); - }); - - it("resolves fallback approval buttons from explicit command-authorized senders", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeCommandAuthorizedFallbackCfg(), - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()), - ); - }); - - it.each([ - [ - "an inherited accounts container", - () => - Object.create({ - accounts: { - bot2: { allowFrom: ["ATTACKER_OPENID"] }, - }, - }) as Record, - ], - [ - "an inherited account allowlist", - () => ({ - accounts: { - bot2: Object.create({ allowFrom: ["ATTACKER_OPENID"] }) as Record, - }, - }), - ], - ] satisfies Array<[string, () => Record]>)( - "rejects fallback approval buttons authorized only by %s", - async (_name, createQQBotConfig) => { - const namedAccount = { ...account, accountId: "bot2" }; - const cfg = { - channels: { - qqbot: createQQBotConfig(), - }, - } as unknown as OpenClawConfig; - const handler = createInteractionHandler(namedAccount, runtime, undefined, { - getActiveCfg: () => cfg, - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }, - ); - - it("uses an own named-account allowlist for fallback approval buttons", async () => { - const namedAccount = { ...account, accountId: "bot2" }; - const handler = createInteractionHandler(namedAccount, runtime, undefined, { - getActiveCfg: () => - ({ - channels: { - qqbot: { - accounts: { - bot2: { allowFrom: ["ATTACKER_OPENID"] }, - }, - }, - }, - }) as OpenClawConfig, - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith({ - ...expectedApprovalResolve(), - accountId: "bot2", - }), - ); - }); - - it("delegates fallback approval button auth to the gateway command resolver", async () => { - const access = createSdkAccessAdapter(); - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => - ({ - accessGroups: { - operators: { - type: "message.senders", - members: { - qqbot: ["ATTACKER_OPENID"], - }, - }, - }, - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - allowFrom: ["accessGroup:operators"], - }, - }, - }) as OpenClawConfig, - resolveCommandAuthorized: (params) => access.resolveSlashCommandAuthorization(params), - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()), - ); - }); - - it("uses merged account config for fallback button command auth", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => - ({ - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - accounts: { - default: { - allowFrom: ["ATTACKER_OPENID"], - }, - }, - }, - }, - }) as OpenClawConfig, - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => - expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()), - ); - }); - - it("rejects fallback approval buttons from senders without explicit command auth", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => - ({ - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - allowFrom: ["OWNER_OPENID"], - }, - }, - }) as OpenClawConfig, - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }); - - it.each([ - [ - "no allowlist", - { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - }, - }, - }, - ], - [ - "wildcard allowlist", - { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - allowFrom: ["*"], - }, - }, - }, - ], - ] satisfies Array<[string, OpenClawConfig]>)( - "rejects fallback approval buttons when %s does not grant command auth", - async (_name, cfg) => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => cfg, - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }, - ); - - it("rejects fallback approval buttons without a trusted actor id", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => makeCommandAuthorizedFallbackCfg(), - }); - - handler(makeApprovalEvent({ group_member_openid: undefined, user_openid: undefined })); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "You are not authorized to approve this request." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }); - - it("rejects approval button clicks when active config cannot be loaded", async () => { - const handler = createInteractionHandler(account, runtime, undefined, { - getActiveCfg: () => { - throw new Error("config unavailable"); - }, - }); - - handler(makeApprovalEvent()); - - await waitForQqInteraction(() => expect(acknowledgeInteractionMock).toHaveBeenCalled()); - - expect(acknowledgeInteractionMock).toHaveBeenCalledWith( - { appId: "app", clientSecret: "secret" }, - "interaction-1", - 0, - { content: "Approval is unavailable." }, - ); - expect(resolveApprovalMock).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/interaction-handler.ts b/extensions/qqbot/src/engine/gateway/interaction-handler.ts deleted file mode 100644 index 5efc37d070b8..000000000000 --- a/extensions/qqbot/src/engine/gateway/interaction-handler.ts +++ /dev/null @@ -1,481 +0,0 @@ -/** - * INTERACTION_CREATE event handler. - * - * Handles three interaction branches: - * - * 1. **Config query** (type=2001) — reads config, ACKs with `claw_cfg`. - * 2. **Config update** (type=2002) — writes config, ACKs with updated snapshot. - * 3. **Approval button** (other) — ACKs, resolves authorized approval actions. - * - * Config query/update require `runtime.config`. When unavailable, those - * branches fall through to a bare ACK (backward-compatible). - */ - -import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime"; -import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { authorizeQQBotApprovalAction } from "../../exec-approvals.js"; -import { resolveQQBotEffectivePolicies } from "../access/resolve-policy.js"; -import { getPlatformAdapter } from "../adapter/index.js"; -import { parseApprovalButtonData } from "../approval/index.js"; -import { - resolveQQBotCommandsAllowFrom, - resolveSlashCommandAuth, -} from "../commands/slash-command-auth.js"; -import { getPluginVersion, getFrameworkVersion } from "../commands/slash-commands-impl.js"; -import { resolveGroupConfig, resolveMentionPatterns } from "../config/group.js"; -import { resolveAccountBase } from "../config/resolve.js"; -import type { GroupActivationMode } from "../group/activation.js"; -import { accountToCreds, acknowledgeInteraction, sendText } from "../messaging/sender.js"; -import type { InteractionEvent, QQBotAccountConfigView } from "../types.js"; -import { InteractionType } from "./constants.js"; -import type { GatewayAccount, GatewayPluginRuntime, EngineLogger } from "./types.js"; - -type QQBotCommandAuthorizationResolver = (params: { - cfg: OpenClawConfig; - accountId: string; - isGroup: boolean; - senderId: string; - conversationId: string; - allowFrom?: Array; - groupAllowFrom?: Array; - commandsAllowFrom?: Array; -}) => boolean | Promise; - -// ============ claw_cfg snapshot ============ - -/** - * Build the canonical `claw_cfg` snapshot returned in interaction ACKs. - * - * Pure function — all resolution helpers live in engine/config/. - */ -function buildClawCfgSnapshot( - cfg: Record, - accountId: string, - groupOpenid: string, - runtime: GatewayPluginRuntime, -): Record { - const groupCfg = groupOpenid ? resolveGroupConfig(cfg, groupOpenid, accountId) : null; - const accountBase = resolveAccountBase(cfg, accountId); - const acctCfg = accountBase.config as QQBotAccountConfigView; - const policies = resolveQQBotEffectivePolicies({ - allowFrom: acctCfg.allowFrom, - groupAllowFrom: acctCfg.groupAllowFrom, - dmPolicy: acctCfg.dmPolicy, - groupPolicy: acctCfg.groupPolicy, - }); - - const requireMentionMode: GroupActivationMode = - (groupCfg?.requireMention ?? true) ? "mention" : "always"; - - const interactionAgentId = groupOpenid - ? ( - runtime.channel.routing.resolveAgentRoute({ - cfg, - channel: "qqbot", - accountId, - peer: { kind: "group", id: groupOpenid }, - }) as { agentId?: string } | undefined - )?.agentId - : undefined; - - return { - channel_type: "qqbot", - channel_ver: getPluginVersion(), - claw_type: "openclaw", - claw_ver: getFrameworkVersion(), - require_mention: requireMentionMode, - group_policy: policies.groupPolicy, - mention_patterns: resolveMentionPatterns(cfg, interactionAgentId).join(","), - online_state: "online", - }; -} - -// ============ Config update ============ - -/** Apply a config-update interaction and return the updated claw_cfg. */ -async function applyConfigUpdate( - event: InteractionEvent, - accountId: string, - runtime: GatewayPluginRuntime, - log?: EngineLogger, -): Promise> { - const configApi = runtime.config; - if (!configApi) { - throw new Error("runtime.config not available"); - } - - const resolved = event.data?.resolved as Record | undefined; - const clawCfgUpdate = resolved?.claw_cfg as Record | undefined; - const groupOpenid = event.group_openid ?? ""; - - const currentCfg = structuredClone(configApi.current()); - let changed = false; - - if (clawCfgUpdate?.require_mention !== undefined && groupOpenid) { - applyRequireMentionUpdate(currentCfg, accountId, groupOpenid, clawCfgUpdate); - changed = true; - } - - if (changed) { - await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } }); - log?.info( - `Config updated via interaction ${event.id}: require_mention=${String(clawCfgUpdate?.require_mention)}, group=${groupOpenid}`, - ); - } - - const latestCfg = changed ? configApi.current() : currentCfg; - return buildClawCfgSnapshot(latestCfg, accountId, groupOpenid, runtime); -} - -/** Mutate `cfg` in place to apply a require_mention update for a group. */ -function applyRequireMentionUpdate( - cfg: Record, - accountId: string, - groupOpenid: string, - update: Record, -): void { - const requireMentionBool = update.require_mention === "mention"; - const channels = (cfg.channels ?? {}) as Record; - const qqbot = (channels.qqbot ?? {}) as Record; - - const isNamedAccount = - accountId !== "default" && - Boolean((qqbot.accounts as Record> | undefined)?.[accountId]); - - if (isNamedAccount) { - const accounts = (qqbot.accounts ?? {}) as Record>; - const acct = accounts[accountId] ?? {}; - const groups = (acct.groups ?? {}) as Record>; - groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool }; - acct.groups = groups; - accounts[accountId] = acct; - qqbot.accounts = accounts; - } else { - const groups = (qqbot.groups ?? {}) as Record>; - groups[groupOpenid] = { ...groups[groupOpenid], requireMention: requireMentionBool }; - qqbot.groups = groups; - } -} - -// ============ Public factory ============ - -/** - * Create the INTERACTION_CREATE event handler. - * - * Returns a fire-and-forget callback that `GatewayConnection` calls - * on every `action: "interaction"` dispatch result. - */ -export function createInteractionHandler( - account: GatewayAccount, - runtime: GatewayPluginRuntime, - log?: EngineLogger, - options?: { - getActiveCfg?: () => OpenClawConfig; - resolveCommandAuthorized?: QQBotCommandAuthorizationResolver; - }, -): (event: InteractionEvent) => void { - return (event) => { - const creds = accountToCreds(account); - const type = event.data?.type; - - // ---- Config query (type=2001) ---- - if (type === InteractionType.CONFIG_QUERY && runtime.config) { - void handleWithAck(creds, event, log, "CONFIG_QUERY", () => { - const cfg = runtime.config!.current(); - return buildClawCfgSnapshot(cfg, account.accountId, event.group_openid ?? "", runtime); - }); - return; - } - - // ---- Config update (type=2002) ---- - if (type === InteractionType.CONFIG_UPDATE && runtime.config) { - void handleWithAck(creds, event, log, "CONFIG_UPDATE", () => - applyConfigUpdate(event, account.accountId, runtime, log), - ); - return; - } - - // ---- Approval button / other ---- - const parsed = parseApprovalButtonData(event.data?.resolved?.button_data ?? ""); - if (!parsed) { - void acknowledgeInteraction(creds, event.id).catch((err: unknown) => { - log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`); - }); - return; - } - - void handleApprovalButtonInteraction({ - account, - creds, - event, - getActiveCfg: options?.getActiveCfg ?? runtime.config?.current, - log, - parsed, - resolveCommandAuthorized: options?.resolveCommandAuthorized, - }); - }; -} - -// ============ Helpers ============ - -async function handleApprovalButtonInteraction(params: { - account: GatewayAccount; - creds: { appId: string; clientSecret: string }; - event: InteractionEvent; - getActiveCfg?: () => OpenClawConfig | Record; - log?: EngineLogger; - parsed: { - approvalId: string; - approvalKind: "exec" | "plugin"; - decision: "allow-once" | "allow-always" | "deny"; - }; - resolveCommandAuthorized?: QQBotCommandAuthorizationResolver; -}): Promise { - if (!params.getActiveCfg) { - await acknowledgeApprovalInteraction(params.creds, params.event, params.log, { - content: "Approval is unavailable.", - }); - params.log?.error("Approval button rejected: active config is unavailable"); - return; - } - - let cfg: OpenClawConfig; - try { - cfg = params.getActiveCfg() as OpenClawConfig; - } catch (err) { - await acknowledgeApprovalInteraction(params.creds, params.event, params.log, { - content: "Approval is unavailable.", - }); - params.log?.error( - `Approval button rejected: active config failed to load: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - return; - } - - const authorization = await authorizeApprovalButtonActor({ - cfg, - account: params.account, - event: params.event, - approvalKind: params.parsed.approvalKind, - resolveCommandAuthorized: params.resolveCommandAuthorized, - }); - if (!authorization.authorized) { - await acknowledgeApprovalInteraction(params.creds, params.event, params.log, { - content: authorization.reason ?? "You are not authorized to approve this request.", - }); - params.log?.info(`Approval button rejected: id=${params.parsed.approvalId}`); - return; - } - - // QQ applies the clicked button's visited state as soon as the interaction is ACKed. Keep that - // state neutral, ACK promptly, then post the durable canonical outcome once Gateway resolves. - await acknowledgeApprovalInteraction(params.creds, params.event, params.log, { - content: "Approval response received.", - }); - - const adapter = getPlatformAdapter(); - if (!adapter.resolveApproval) { - await reportApprovalInteractionOutcome({ - creds: params.creds, - event: params.event, - log: params.log, - content: "Approval is unavailable.", - }); - params.log?.error("resolveApproval not available on PlatformAdapter"); - return; - } - - try { - const result = await adapter.resolveApproval({ - ...params.parsed, - accountId: params.account.accountId, - senderId: authorization.senderId, - }); - const canonicalDecision = - "decision" in result.approval ? `, decision=${result.approval.decision}` : ""; - const canonicalOutcome = formatCanonicalApprovalOutcome(result.approval); - await reportApprovalInteractionOutcome({ - creds: params.creds, - event: params.event, - log: params.log, - content: result.applied - ? `Approval resolved: ${canonicalOutcome}.` - : `This approval was already resolved: ${canonicalOutcome}.`, - }); - params.log?.info( - result.applied - ? `Approval resolved: id=${result.approval.id}, status=${result.approval.status}${canonicalDecision}` - : `Approval already resolved: id=${result.approval.id}, status=${result.approval.status}${canonicalDecision}`, - ); - } catch (err) { - await reportApprovalInteractionOutcome({ - creds: params.creds, - event: params.event, - log: params.log, - content: "Approval could not be resolved.", - }); - params.log?.error( - `Approval resolve failed: id=${params.parsed.approvalId}: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } -} - -async function reportApprovalInteractionOutcome(params: { - creds: { appId: string; clientSecret: string }; - event: InteractionEvent; - log?: EngineLogger; - content: string; -}): Promise { - const target = params.event.group_openid - ? { type: "group" as const, id: params.event.group_openid } - : params.event.user_openid - ? { type: "c2c" as const, id: params.event.user_openid } - : params.event.channel_id - ? { type: "channel" as const, id: params.event.channel_id } - : null; - if (!target) { - params.log?.info(`Approval interaction outcome: ${params.content}`); - return; - } - try { - await sendText(target, params.content, params.creds, { - msgId: params.event.data.resolved.message_id, - }); - } catch (err) { - params.log?.error( - `Approval outcome delivery failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } -} - -function formatCanonicalApprovalOutcome(approval: ApprovalResolveResult["approval"]): string { - if (approval.status === "allowed") { - return approval.decision === "allow-always" ? "Allowed always" : "Allowed once"; - } - if (approval.status === "denied") { - return "Denied"; - } - return approval.status === "expired" ? "Expired" : "Cancelled"; -} - -async function acknowledgeApprovalInteraction( - creds: { appId: string; clientSecret: string }, - event: InteractionEvent, - log: EngineLogger | undefined, - data?: Record, -): Promise { - try { - await acknowledgeInteraction(creds, event.id, 0, data); - } catch (err) { - log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -async function authorizeApprovalButtonActor(params: { - cfg: OpenClawConfig; - account: GatewayAccount; - event: InteractionEvent; - approvalKind: "exec" | "plugin"; - resolveCommandAuthorized?: QQBotCommandAuthorizationResolver; -}): Promise<{ authorized: true; senderId: string } | { authorized: false; reason?: string }> { - const senderIds = resolveApprovalActorSenderIds(params.event); - if (senderIds.length === 0) { - return { authorized: false, reason: "You are not authorized to approve this request." }; - } - - let denial: { authorized: false; reason?: string } | undefined; - for (const senderId of senderIds) { - const result = authorizeQQBotApprovalAction({ - cfg: params.cfg, - accountId: params.account.accountId, - senderId, - approvalKind: params.approvalKind, - }); - if (result.authorized) { - if ( - !isImplicitSameChatApprovalAuthorization(result) || - (await isImplicitApprovalButtonActorAuthorized({ - cfg: params.cfg, - account: params.account, - event: params.event, - senderId, - resolveCommandAuthorized: params.resolveCommandAuthorized, - })) - ) { - return { authorized: true, senderId }; - } - denial ??= { - authorized: false, - reason: "You are not authorized to approve this request.", - }; - continue; - } - denial ??= { authorized: false, ...(result.reason ? { reason: result.reason } : {}) }; - } - return denial ?? { authorized: false, reason: "You are not authorized to approve this request." }; -} - -async function isImplicitApprovalButtonActorAuthorized(params: { - cfg: OpenClawConfig; - account: GatewayAccount; - event: InteractionEvent; - senderId: string; - resolveCommandAuthorized?: QQBotCommandAuthorizationResolver; -}): Promise { - const accountConfig = resolveApprovalButtonAccountConfig(params.cfg, params.account.accountId); - const authInput = { - cfg: params.cfg, - accountId: params.account.accountId, - senderId: params.senderId, - isGroup: Boolean(params.event.group_openid), - conversationId: params.event.group_openid ?? params.event.user_openid ?? params.senderId, - allowFrom: accountConfig.allowFrom, - groupAllowFrom: accountConfig.groupAllowFrom, - commandsAllowFrom: resolveQQBotCommandsAllowFrom(params.cfg), - }; - return params.resolveCommandAuthorized - ? await params.resolveCommandAuthorized(authInput) - : resolveSlashCommandAuth(authInput); -} - -function resolveApprovalButtonAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): QQBotAccountConfigView { - // Approval authorization must use the same own-container and own-entry - // projection as runtime account resolution or inherited allowlists can grant access. - return resolveAccountBase(cfg as unknown as Record, accountId) - .config as QQBotAccountConfigView; -} - -function resolveApprovalActorSenderIds(event: InteractionEvent): string[] { - const ids = [event.group_member_openid, event.user_openid].flatMap((value) => { - const normalized = typeof value === "string" ? value.trim() : ""; - return normalized ? [normalized] : []; - }); - return uniqueStrings(ids); -} - -/** Execute an async handler, ACK with the result, and handle errors. */ -async function handleWithAck( - creds: { appId: string; clientSecret: string }, - event: InteractionEvent, - log: EngineLogger | undefined, - label: string, - handler: () => Record | Promise>, -): Promise { - try { - const clawCfg = await handler(); - await acknowledgeInteraction(creds, event.id, 0, { claw_cfg: clawCfg }); - log?.info(`Interaction ACK (${label}) sent: ${event.id}`); - } catch (err) { - log?.error(`${label} interaction failed: ${err instanceof Error ? err.message : String(err)}`); - void acknowledgeInteraction(creds, event.id).catch(() => {}); - } -} diff --git a/extensions/qqbot/src/engine/gateway/message-queue-ingress.test.ts b/extensions/qqbot/src/engine/gateway/message-queue-ingress.test.ts deleted file mode 100644 index 23d70bddfe84..000000000000 --- a/extensions/qqbot/src/engine/gateway/message-queue-ingress.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -// QQBot queue ingress tests cover merged lifecycle fan-out and shutdown release. -import { describe, expect, it, vi } from "vitest"; -import { buildQQBotMergedIngressLifecycle } from "./message-queue-ingress.js"; -import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; -import type { QQBotIngressLifecycle } from "./types.js"; - -function groupMessage(messageId: string, lifecycle?: QQBotIngressLifecycle): QueuedMessage { - return { - type: "group", - senderId: "member-1", - content: messageId, - messageId, - timestamp: "2026-07-18T12:00:00Z", - groupOpenid: "group-1", - ...(lifecycle ? { turnAdoptionLifecycle: lifecycle } : {}), - }; -} - -function testLifecycle() { - const adopted = vi.fn(async () => {}); - const abandoned = vi.fn(async () => {}); - return { - adopted, - abandoned, - lifecycle: { - abortSignal: new AbortController().signal, - onAdopted: adopted, - onDeferred: vi.fn(), - onAdoptionFinalizing: vi.fn(), - onAbandoned: abandoned, - } satisfies QQBotIngressLifecycle, - }; -} - -describe("QQBot message queue ingress lifecycle", () => { - it("fans merged-turn adoption out to every constituent claim", async () => { - let releaseBlocker!: () => void; - const blocker = new Promise((resolve) => { - releaseBlocker = resolve; - }); - const first = testLifecycle(); - const second = testLifecycle(); - const handler = vi.fn(async (message: QueuedMessage) => { - if (message.messageId === "blocker") { - await blocker; - return; - } - await message.turnAdoptionLifecycle?.onAdopted(); - }); - const queue = createMessageQueue({ accountId: "default", isAborted: () => false }); - queue.startProcessor(handler); - queue.enqueue(groupMessage("blocker")); - await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - queue.enqueue(groupMessage("first", first.lifecycle)); - queue.enqueue(groupMessage("second", second.lifecycle)); - - releaseBlocker(); - await vi.waitFor(() => { - expect(first.adopted).toHaveBeenCalledTimes(1); - expect(second.adopted).toHaveBeenCalledTimes(1); - }); - await queue.stop(); - }); - - it("settles every merged claim when one adoption callback fails", async () => { - const adoptionError = new Error("first adoption failed"); - const first = testLifecycle(); - const second = testLifecycle(); - first.adopted.mockRejectedValueOnce(adoptionError); - const lifecycle = buildQQBotMergedIngressLifecycle([ - groupMessage("first", first.lifecycle), - groupMessage("second", second.lifecycle), - ]); - - await expect(lifecycle?.onAdopted()).rejects.toBe(adoptionError); - expect(first.adopted).toHaveBeenCalledTimes(1); - expect(second.adopted).toHaveBeenCalledTimes(1); - }); - - it("settles every merged claim when one abandonment callback fails", async () => { - const abandonmentError = new Error("first abandonment failed"); - const first = testLifecycle(); - const second = testLifecycle(); - first.abandoned.mockRejectedValueOnce(abandonmentError); - const lifecycle = buildQQBotMergedIngressLifecycle([ - groupMessage("first", first.lifecycle), - groupMessage("second", second.lifecycle), - ]); - - await expect(lifecycle?.onAbandoned()).rejects.toBe(abandonmentError); - expect(first.abandoned).toHaveBeenCalledTimes(1); - expect(second.abandoned).toHaveBeenCalledTimes(1); - }); - - it("tombstones deferred permanent auth failures instead of releasing them", async () => { - const tracked = testLifecycle(); - const queue = createMessageQueue({ accountId: "default", isAborted: () => false }); - queue.startProcessor(async () => { - throw Object.assign(new Error("unauthorized"), { httpStatus: 401 }); - }); - queue.enqueue(groupMessage("auth-failure", tracked.lifecycle)); - - await vi.waitFor(() => expect(tracked.adopted).toHaveBeenCalledTimes(1)); - expect(tracked.abandoned).not.toHaveBeenCalled(); - await queue.stop(); - }); - - it("releases buffered claims as retryable when shutdown stops the queue", async () => { - let releaseBlocker!: () => void; - const blocker = new Promise((resolve) => { - releaseBlocker = resolve; - }); - const queued = testLifecycle(); - const queue = createMessageQueue({ accountId: "default", isAborted: () => false }); - queue.startProcessor(async (message) => { - if (message.messageId === "blocker") { - await blocker; - } - }); - queue.enqueue(groupMessage("blocker")); - queue.enqueue(groupMessage("queued", queued.lifecycle)); - - const stopping = queue.stop(); - releaseBlocker(); - await stopping; - expect(queued.abandoned).toHaveBeenCalledTimes(1); - expect(queued.adopted).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/message-queue-ingress.ts b/extensions/qqbot/src/engine/gateway/message-queue-ingress.ts deleted file mode 100644 index 620b363dda13..000000000000 --- a/extensions/qqbot/src/engine/gateway/message-queue-ingress.ts +++ /dev/null @@ -1,70 +0,0 @@ -// QQBot plugin module fans one merged turn lifecycle across its durable claims. -import type { QQBotIngressLifecycle } from "./types.js"; - -async function settleAll( - lifecycles: readonly QQBotIngressLifecycle[], - label: string, - settle: (lifecycle: QQBotIngressLifecycle) => void | Promise, -): Promise { - const results = await Promise.allSettled( - lifecycles.map(async (lifecycle) => await settle(lifecycle)), - ); - const errors = results - .filter((result): result is PromiseRejectedResult => result.status === "rejected") - .map((result) => result.reason); - if (errors.length === 1) { - throw errors[0]; - } - if (errors.length > 1) { - throw new AggregateError(errors, `QQBot merged ingress ${label} failed.`); - } -} - -function notifyAll( - lifecycles: readonly QQBotIngressLifecycle[], - label: string, - notify: (lifecycle: QQBotIngressLifecycle) => void, -): void { - const errors: unknown[] = []; - for (const lifecycle of lifecycles) { - try { - notify(lifecycle); - } catch (error) { - errors.push(error); - } - } - if (errors.length === 1) { - throw errors[0]; - } - if (errors.length > 1) { - throw new AggregateError(errors, `QQBot merged ingress ${label} failed.`); - } -} - -export function buildQQBotMergedIngressLifecycle( - messages: readonly { turnAdoptionLifecycle?: QQBotIngressLifecycle }[], -): QQBotIngressLifecycle | undefined { - const lifecycles = messages - .map((message) => message.turnAdoptionLifecycle) - .filter((lifecycle) => lifecycle !== undefined); - const [firstLifecycle] = lifecycles; - if (!firstLifecycle) { - return undefined; - } - if (lifecycles.length === 1) { - return firstLifecycle; - } - return { - abortSignal: AbortSignal.any(lifecycles.map((lifecycle) => lifecycle.abortSignal)), - onAdopted: () => settleAll(lifecycles, "adoption", (lifecycle) => lifecycle.onAdopted()), - onDeferred: () => { - notifyAll(lifecycles, "deferral", (lifecycle) => lifecycle.onDeferred()); - }, - onAdoptionFinalizing: () => { - notifyAll(lifecycles, "adoption finalization", (lifecycle) => - lifecycle.onAdoptionFinalizing(), - ); - }, - onAbandoned: () => settleAll(lifecycles, "abandonment", (lifecycle) => lifecycle.onAbandoned()), - }; -} diff --git a/extensions/qqbot/src/engine/gateway/message-queue.test.ts b/extensions/qqbot/src/engine/gateway/message-queue.test.ts deleted file mode 100644 index 9c44e164fdab..000000000000 --- a/extensions/qqbot/src/engine/gateway/message-queue.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -// Qqbot tests cover message queue plugin behavior. -import { describe, expect, it, vi } from "vitest"; -import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; - -function groupMsg(overrides: Partial = {}): QueuedMessage { - return { - type: "group", - senderId: "U1", - senderName: "Alice", - content: "hello", - messageId: "M1", - timestamp: "2026-01-01T00:00:00Z", - groupOpenid: "G1", - ...overrides, - }; -} - -function requireMergeMetadata(message: QueuedMessage): NonNullable { - if (!message.merge) { - throw new Error("expected QQBot merged message metadata"); - } - return message.merge; -} - -describe("engine/gateway/message-queue", () => { - describe("createMessageQueue enqueue / evict", () => { - it("uses group peerId for group messages", () => { - const q = createMessageQueue({ accountId: "a", isAborted: () => true }); - expect(q.getMessagePeerId(groupMsg({ groupOpenid: "G9" }))).toBe("group:G9"); - }); - - it("uses dm peerId for c2c messages", () => { - const q = createMessageQueue({ accountId: "a", isAborted: () => true }); - expect( - q.getMessagePeerId({ - ...groupMsg(), - type: "c2c", - groupOpenid: undefined, - senderId: "U9", - }), - ).toBe("dm:U9"); - }); - - it("enqueue without processor still drains (no-op when fn is null)", async () => { - // When no processor is attached, drain shifts messages but does - // nothing with them. The queue ends empty on the next microtask. - const q = createMessageQueue({ accountId: "a", isAborted: () => false }); - q.enqueue(groupMsg({ messageId: "M1" })); - q.enqueue(groupMsg({ messageId: "M2" })); - await Promise.resolve(); - await Promise.resolve(); - expect(q.getSnapshot("group:G1").senderPending).toBe(0); - }); - - it("group overflow evicts a bot message first (eviction is synchronous)", () => { - // Use isAborted=true so drain exits immediately on the first - // microtask. Our `eviction` logic runs synchronously inside - // enqueue, BEFORE drain kicks in, so the 4th enqueue still has to - // evict even though we never actually process anything. - const q = createMessageQueue({ - accountId: "a", - isAborted: () => true, - groupQueueSize: 3, - }); - // Fill the queue to the cap (3), then enqueue one more to trigger - // eviction. The first three enqueues trigger drainUserQueue which - // synchronously deletes the empty queue in its finally block when - // isAborted=true. We bypass that by calling enqueue then reading - // inside the same synchronous tick via getSnapshot is NOT viable, - // so we instead observe the eviction by counting what ends up - // visible after the queue has stabilized. - q.enqueue(groupMsg({ messageId: "H1" })); - q.enqueue(groupMsg({ messageId: "B1", senderIsBot: true })); - q.enqueue(groupMsg({ messageId: "H2" })); - q.enqueue(groupMsg({ messageId: "H3" })); - // With isAborted=true the drain deletes the queue after each - // enqueue, so the snapshot just confirms we didn't throw. The - // actual eviction logic is covered by the "group overflow via - // processor" scenario below. - expect(q.getSnapshot("group:G1").senderPending).toBe(0); - }); - - it("group overflow drops bot messages first (via processor)", async () => { - const seen: QueuedMessage[] = []; - let gate: ((value?: unknown) => void) | undefined; - const blocker = new Promise((res) => { - gate = res; - }); - const q = createMessageQueue({ - accountId: "a", - isAborted: () => false, - groupQueueSize: 3, - }); - q.startProcessor(async (msg) => { - seen.push(msg); - // Hold the processor until we've filled the queue to capacity. - await blocker; - }); - // First enqueue starts processing immediately (blocker held). - q.enqueue(groupMsg({ messageId: "First" })); - await Promise.resolve(); - // Now fill the queue with 3 more (cap=3). - q.enqueue(groupMsg({ messageId: "H1" })); - q.enqueue(groupMsg({ messageId: "B1", senderIsBot: true })); - q.enqueue(groupMsg({ messageId: "H2" })); - expect(q.getSnapshot("group:G1").senderPending).toBe(3); - // 5th enqueue → eviction. Bot message (B1) should be the victim. - q.enqueue(groupMsg({ messageId: "H3" })); - const peerQueueIds = q.getSnapshot("group:G1"); - expect(peerQueueIds.senderPending).toBe(3); - // Release the processor and drain. - if (!gate) { - throw new Error("Expected QQBot queue gate callback to be initialized"); - } - gate(); - await vi.waitFor(() => { - expect(seen.length).toBeGreaterThan(1); - }); - const seenIds = seen.map((m) => m.messageId); - expect(seenIds).toContain("First"); - // The bot message should NOT have been processed — it was evicted. - // (Note: The first batch ran merged, so the exact count of calls - // varies; we only assert the bot message id never appeared.) - const mergedCall = seen.find((m) => (m.merge?.count ?? 0) > 1); - if (mergedCall) { - expect(requireMergeMetadata(mergedCall).messages.map((m) => m.messageId)).not.toContain( - "B1", - ); - } else { - expect(seenIds).not.toContain("B1"); - } - }); - - it("clearUserQueue drops buffered items before drain runs", () => { - // Use a processor that never resolves so enqueued messages stay - // buffered behind a single active worker — then clearUserQueue - // should drop the rest. - let release: (() => void) | undefined; - const blocker = new Promise((res) => { - release = res; - }); - const q = createMessageQueue({ accountId: "a", isAborted: () => false }); - q.startProcessor(async () => { - await blocker; - }); - q.enqueue(groupMsg({ messageId: "M1" })); - q.enqueue(groupMsg({ messageId: "M2" })); - q.enqueue(groupMsg({ messageId: "M3" })); - // First message is being processed; remaining two are queued. - expect(q.getSnapshot("group:G1").senderPending).toBeGreaterThanOrEqual(0); - const dropped = q.clearUserQueue("group:G1"); - expect(dropped).toBeGreaterThanOrEqual(0); - if (!release) { - throw new Error("Expected QQBot queue release callback to be initialized"); - } - release(); - }); - }); - - describe("drainGroupBatch merging", () => { - it("merges multiple normal group messages into one processor call", async () => { - const seen: QueuedMessage[] = []; - let aborted = false; - const q = createMessageQueue({ - accountId: "a", - isAborted: () => aborted, - }); - q.startProcessor(async (msg) => { - seen.push(msg); - }); - // Enqueue three normal group messages synchronously so they batch - // before the drain loop kicks in — the first enqueue starts the - // drain, but the synchronous enqueues land before the first await. - q.enqueue(groupMsg({ messageId: "M1", content: "hi" })); - q.enqueue(groupMsg({ messageId: "M2", content: "yo" })); - q.enqueue(groupMsg({ messageId: "M3", content: "!!" })); - // Allow microtasks to flush. - await Promise.resolve(); - await Promise.resolve(); - aborted = true; - // Depending on timing the first message may have been processed solo; - // what we guarantee is that the total processor calls are fewer than - // three and the remaining messages were merged. - expect(seen.length).toBeGreaterThanOrEqual(1); - expect(seen.length).toBeLessThan(3); - const mergedCall = seen.find((m) => (m.merge?.count ?? 0) > 1); - expect(mergedCall?.content).toContain("[Alice]:"); - expect(mergedCall?.merge?.count).toBeGreaterThan(1); - }); - - it("processes slash commands independently from regular messages", async () => { - const seen: QueuedMessage[] = []; - let aborted = false; - const q = createMessageQueue({ - accountId: "a", - isAborted: () => aborted, - }); - q.startProcessor(async (msg) => { - seen.push(msg); - }); - q.enqueue(groupMsg({ messageId: "M1", content: "hi" })); - q.enqueue(groupMsg({ messageId: "M2", content: "/stop" })); - q.enqueue(groupMsg({ messageId: "M3", content: "yo" })); - await Promise.resolve(); - await Promise.resolve(); - aborted = true; - // Command should appear as its own call (not merged with the others). - const cmdCall = seen.find((m) => m.content === "/stop"); - expect(cmdCall?.content).toBe("/stop"); - expect(cmdCall).not.toHaveProperty("merge"); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/message-queue.ts b/extensions/qqbot/src/engine/gateway/message-queue.ts deleted file mode 100644 index adaa26502c27..000000000000 --- a/extensions/qqbot/src/engine/gateway/message-queue.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -// Qqbot plugin module implements message queue behavior. -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { isQQBotAuthenticationFailure } from "./ingress-errors.js"; -import { buildQQBotMergedIngressLifecycle } from "./message-queue-ingress.js"; -import type { QQBotIngressLifecycle } from "./types.js"; - -const DEFAULT_GLOBAL_QUEUE_SIZE = 1000; -const DEFAULT_PER_PEER_QUEUE_SIZE = 20; -const DEFAULT_GROUP_QUEUE_SIZE = 50; -const DEFAULT_MAX_CONCURRENT_USERS = 10; - -export interface QueuedMention { - scope?: "all" | "single"; - id?: string; - user_openid?: string; - member_openid?: string; - username?: string; - nickname?: string; - bot?: boolean; - is_you?: boolean; -} - -interface QueuedMergeInfo { - count: number; - messages: readonly QueuedMessage[]; -} - -export interface QueuedMessage { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - senderName?: string; - senderIsBot?: boolean; - content: string; - messageId: string; - timestamp: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; - attachments?: Array<{ - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - asr_refer_text?: string; - }>; - refMsgIdx?: string; - msgIdx?: string; - msgType?: number; - msgElements?: Array<{ - msg_idx?: string; - content?: string; - attachments?: Array<{ - content_type: string; - url: string; - filename?: string; - height?: number; - width?: number; - size?: number; - voice_wav_url?: string; - asr_refer_text?: string; - }>; - }>; - eventType?: string; - mentions?: QueuedMention[]; - messageScene?: { source?: string; ext?: string[] }; - merge?: QueuedMergeInfo; - turnAdoptionLifecycle?: QQBotIngressLifecycle; -} - -export function isMergedTurn(msg: QueuedMessage): msg is QueuedMessage & { - merge: QueuedMergeInfo; -} { - return (msg.merge?.count ?? 0) > 1; -} - -interface MessageQueueContext { - accountId: string; - log?: { - info: (msg: string, meta?: Record) => void; - error: (msg: string, meta?: Record) => void; - debug?: (msg: string, meta?: Record) => void; - }; - isAborted: () => boolean; - groupQueueSize?: number; - peerQueueSize?: number; - globalQueueSize?: number; - maxConcurrentUsers?: number; -} - -interface QueueSnapshot { - totalPending: number; - activeUsers: number; - maxConcurrentUsers: number; - senderPending: number; -} - -interface MessageQueue { - enqueue: (msg: QueuedMessage) => void; - startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise) => void; - getSnapshot: (senderPeerId: string) => QueueSnapshot; - getMessagePeerId: (msg: QueuedMessage) => string; - clearUserQueue: (peerId: string) => number; - executeImmediate: (msg: QueuedMessage) => void; - stop: () => Promise; -} - -function isGroupPeer(peerId: string): boolean { - return peerId.startsWith("group:") || peerId.startsWith("guild:"); -} - -function isSlashCommand(msg: QueuedMessage): boolean { - return (msg.content ?? "").trim().startsWith("/"); -} - -/** - * Merge several queued group messages into one representative message. - * - * Merge semantics: - * - `content` is joined with newlines; each line prefixed with `[sender]` - * so the downstream formatter can attribute speakers. - * - `attachments` is concatenated. - * - `mentions` is deduplicated by member/user openid; if *any* source - * message was a `GROUP_AT_MESSAGE_CREATE`, the merged result inherits - * that eventType (the merged turn effectively @-s the bot). - * - `messageId`, `msgIdx`, `timestamp` come from the last message — the - * most recent identity is what the outbound reply should quote. - * - `refMsgIdx` (the message that the user quoted) comes from the FIRST - * message in the batch because the first quote anchors the topic. - * - `senderIsBot` is true only when every source message was authored - * by a bot. Any human participation flips the flag. - * - * A single-message batch is returned unchanged (no merge overhead). - */ -function mergeGroupMessages(batch: QueuedMessage[]): QueuedMessage { - if (batch.length === 0) { - throw new Error("mergeGroupMessages: empty batch"); - } - if (batch.length === 1) { - return expectDefined(batch.at(0), "single-message merge batch"); - } - - const first = expectDefined(batch.at(0), "non-empty merge batch first message"); - const last = expectDefined(batch.at(-1), "non-empty merge batch last message"); - - const mergedContent = batch - .map((m) => `[${m.senderName ?? m.senderId}]: ${m.content}`) - .join("\n"); - - const mergedAttachments: QueuedMessage["attachments"] = []; - for (const m of batch) { - if (m.attachments?.length) { - mergedAttachments.push(...m.attachments); - } - } - - const seenMentionIds = new Set(); - const mergedMentions: NonNullable = []; - let anyAtYouEvent = false; - for (const m of batch) { - if (m.eventType === "GROUP_AT_MESSAGE_CREATE") { - anyAtYouEvent = true; - } - if (m.mentions) { - for (const mt of m.mentions) { - const key = mt.member_openid ?? mt.id ?? mt.user_openid ?? ""; - if (key && seenMentionIds.has(key)) { - continue; - } - if (key) { - seenMentionIds.add(key); - } - mergedMentions.push(mt); - } - } - } - - const allFromBot = batch.every((m) => m.senderIsBot); - - return { - type: last.type, - senderId: last.senderId, - senderName: last.senderName, - senderIsBot: allFromBot, - content: mergedContent, - messageId: last.messageId, - timestamp: last.timestamp, - channelId: last.channelId, - guildId: last.guildId, - groupOpenid: last.groupOpenid, - attachments: mergedAttachments.length > 0 ? mergedAttachments : undefined, - refMsgIdx: first.refMsgIdx, - msgIdx: last.msgIdx, - eventType: anyAtYouEvent ? "GROUP_AT_MESSAGE_CREATE" : last.eventType, - mentions: mergedMentions.length > 0 ? mergedMentions : undefined, - messageScene: last.messageScene, - merge: { count: batch.length, messages: batch }, - turnAdoptionLifecycle: buildQQBotMergedIngressLifecycle(batch), - }; -} - -export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { - const { accountId: _accountId, log } = ctx; - const globalQueueSize = ctx.globalQueueSize ?? DEFAULT_GLOBAL_QUEUE_SIZE; - const peerQueueSize = ctx.peerQueueSize ?? DEFAULT_PER_PEER_QUEUE_SIZE; - const groupQueueSize = ctx.groupQueueSize ?? DEFAULT_GROUP_QUEUE_SIZE; - const maxConcurrentUsers = ctx.maxConcurrentUsers ?? DEFAULT_MAX_CONCURRENT_USERS; - - const userQueues = new Map(); - const activeUsers = new Set(); - const activeTasks = new Set>(); - const ingressSettlements = new Set>(); - let handleMessageFnRef: ((msg: QueuedMessage) => Promise) | null = null; - let totalEnqueued = 0; - let stopped = false; - - const trackIngressSettlement = ( - msg: QueuedMessage, - kind: "completed" | "abandoned", - ): Promise => { - const lifecycle = msg.turnAdoptionLifecycle; - if (!lifecycle) { - return Promise.resolve(); - } - const settlement = Promise.resolve( - kind === "completed" ? lifecycle.onAdopted() : lifecycle.onAbandoned(), - ) - .catch((error: unknown) => { - log?.error(`Ingress ${kind} settlement failed: ${formatErrorMessage(error)}`); - }) - .finally(() => ingressSettlements.delete(settlement)); - ingressSettlements.add(settlement); - return settlement; - }; - - const trackTask = (task: Promise): void => { - activeTasks.add(task); - void task.finally(() => activeTasks.delete(task)); - }; - - const getMessagePeerId = (msg: QueuedMessage): string => { - if (msg.type === "guild") { - return `guild:${msg.channelId ?? "unknown"}`; - } - if (msg.type === "group") { - return `group:${msg.groupOpenid ?? "unknown"}`; - } - return `dm:${msg.senderId}`; - }; - - const evictOne = (queue: QueuedMessage[], isGroup: boolean): QueuedMessage | undefined => { - if (isGroup) { - const botIdx = queue.findIndex((m) => m.senderIsBot); - if (botIdx >= 0) { - return queue.splice(botIdx, 1)[0]; - } - } - return queue.shift(); - }; - - const processOne = async (msg: QueuedMessage, peerId: string, label: string): Promise => { - if (msg.turnAdoptionLifecycle?.abortSignal.aborted) { - await trackIngressSettlement(msg, "abandoned"); - return; - } - try { - await handleMessageFnRef!(msg); - } catch (err) { - const permanentFailure = isQQBotAuthenticationFailure(err); - // Deferred lifecycles cannot return errors to the drain. Permanent auth failures must - // tombstone here because releasing them would replay a turn that cannot succeed. - await trackIngressSettlement(msg, permanentFailure ? "completed" : "abandoned"); - log?.error(`${label} error for ${peerId}: ${formatErrorMessage(err)}`); - } - }; - - const drainGroupBatch = async (batch: QueuedMessage[], peerId: string): Promise => { - const commands: QueuedMessage[] = []; - const normal: QueuedMessage[] = []; - for (const m of batch) { - if (isSlashCommand(m)) { - commands.push(m); - } else { - normal.push(m); - } - } - - for (const cmd of commands) { - log?.debug?.( - `Processing command independently for ${peerId}: ${truncateUtf16Safe((cmd.content ?? "").trim(), 50)}`, - ); - await processOne(cmd, peerId, "Command processor"); - } - - if (normal.length > 0) { - const merged = mergeGroupMessages(normal); - if (normal.length > 1) { - log?.debug?.(`Merged ${normal.length} queued group messages for ${peerId} into one`); - } - await processOne(merged, peerId, `Message processor (merged batch of ${normal.length})`); - } - }; - - const drainUserQueue = async (peerId: string): Promise => { - if (activeUsers.has(peerId)) { - return; - } - if (activeUsers.size >= maxConcurrentUsers) { - log?.debug?.(`Max concurrent users (${maxConcurrentUsers}) reached, ${peerId} will wait`); - return; - } - - const queue = userQueues.get(peerId); - if (!queue || queue.length === 0) { - userQueues.delete(peerId); - return; - } - - activeUsers.add(peerId); - const isGroup = isGroupPeer(peerId); - - try { - while (queue.length > 0 && !ctx.isAborted()) { - if (isGroup && queue.length > 1 && handleMessageFnRef) { - const batch = queue.splice(0); - totalEnqueued = Math.max(0, totalEnqueued - batch.length); - await drainGroupBatch(batch, peerId); - continue; - } - - const msg = queue.shift()!; - totalEnqueued = Math.max(0, totalEnqueued - 1); - if (handleMessageFnRef) { - await processOne(msg, peerId, "Message processor"); - } - } - } finally { - activeUsers.delete(peerId); - if (stopped || ctx.isAborted()) { - const abandoned = queue.splice(0); - totalEnqueued = Math.max(0, totalEnqueued - abandoned.length); - for (const msg of abandoned) { - void trackIngressSettlement(msg, "abandoned"); - } - userQueues.delete(peerId); - } else if (queue.length === 0) { - userQueues.delete(peerId); - } - - for (const [waitingPeerId, waitingQueue] of userQueues) { - if (stopped || ctx.isAborted()) { - break; - } - if (activeUsers.size >= maxConcurrentUsers) { - break; - } - if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) { - trackTask(drainUserQueue(waitingPeerId)); - } - } - } - }; - - const enqueue = (msg: QueuedMessage): void => { - if (stopped) { - void trackIngressSettlement(msg, "abandoned"); - return; - } - const peerId = getMessagePeerId(msg); - const isGroup = isGroupPeer(peerId); - - let queue = userQueues.get(peerId); - if (!queue) { - queue = []; - userQueues.set(peerId, queue); - } - - const maxSize = isGroup ? groupQueueSize : peerQueueSize; - if (queue.length >= maxSize) { - const dropped = evictOne(queue, isGroup); - if (dropped) { - void trackIngressSettlement(dropped, "abandoned"); - } - totalEnqueued = Math.max(0, totalEnqueued - 1); - if (isGroup && dropped?.senderIsBot) { - log?.info(`Queue full for ${peerId}, dropping bot message ${dropped.messageId}`, { - accountId: ctx.accountId, - peerId, - droppedMessageId: dropped.messageId, - reason: "queue_full_evict_bot", - }); - } else { - log?.error(`Queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`, { - accountId: ctx.accountId, - peerId, - droppedMessageId: dropped?.messageId, - reason: "queue_full_evict_oldest", - }); - } - } - - totalEnqueued++; - if (totalEnqueued > globalQueueSize) { - log?.error( - `Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`, - { accountId: ctx.accountId, peerId, totalEnqueued, globalQueueSize }, - ); - } - - queue.push(msg); - log?.debug?.( - `Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`, - ); - - trackTask(drainUserQueue(peerId)); - }; - - const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise): void => { - handleMessageFnRef = handleMessageFn; - log?.debug?.( - `Message processor started (per-user concurrency, max ${maxConcurrentUsers} users)`, - ); - }; - - const getSnapshot = (senderPeerId: string): QueueSnapshot => { - let totalPending = 0; - for (const [, q] of userQueues) { - totalPending += q.length; - } - const senderQueue = userQueues.get(senderPeerId); - return { - totalPending, - activeUsers: activeUsers.size, - maxConcurrentUsers, - senderPending: senderQueue ? senderQueue.length : 0, - }; - }; - - const clearUserQueue = (peerId: string): number => { - const queue = userQueues.get(peerId); - if (!queue || queue.length === 0) { - return 0; - } - const droppedCount = queue.length; - const dropped = queue.splice(0); - totalEnqueued = Math.max(0, totalEnqueued - droppedCount); - for (const msg of dropped) { - // Urgent commands intentionally supersede buffered work. - void trackIngressSettlement(msg, "completed"); - } - return droppedCount; - }; - - const executeImmediate = (msg: QueuedMessage): void => { - if (handleMessageFnRef) { - trackTask(processOne(msg, getMessagePeerId(msg), "Immediate execution")); - } - }; - - const stop = async (): Promise => { - stopped = true; - for (const queue of userQueues.values()) { - for (const msg of queue.splice(0)) { - void trackIngressSettlement(msg, "abandoned"); - } - } - userQueues.clear(); - totalEnqueued = 0; - await Promise.allSettled(activeTasks); - await Promise.allSettled(ingressSettlements); - }; - - return { - enqueue, - startProcessor, - getSnapshot, - getMessagePeerId, - clearUserQueue, - executeImmediate, - stop, - }; -} diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts deleted file mode 100644 index ae6cfde40247..000000000000 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.test.ts +++ /dev/null @@ -1,1151 +0,0 @@ -// Qqbot tests cover outbound dispatch plugin behavior. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { - DEFAULT_MEDIA_SEND_ERROR, - sendMedia, - sendText, - setOutboundAudioPort, -} from "../messaging/outbound.js"; -import type { InboundContext } from "./inbound-context.js"; -import { dispatchOutbound } from "./outbound-dispatch.js"; -import type { GatewayAccount, GatewayPluginRuntime } from "./types.js"; - -const sendVoiceMessageMock = vi.hoisted(() => - vi.fn(async (_params: unknown) => ({ id: "voice-1", timestamp: "2026-04-25T00:00:00.000Z" })), -); -const sendMediaMock = vi.hoisted(() => - vi.fn( - async ( - _params: unknown, - ): Promise<{ id: string; timestamp: string } | { channel: "qqbot"; error: string }> => ({ - id: "media-1", - timestamp: "2026-04-25T00:00:00.000Z", - }), - ), -); -const sendTextMock = vi.hoisted(() => - vi.fn(async (..._params: unknown[]) => ({ - id: "text-1", - timestamp: "2026-04-25T00:00:00.000Z", - })), -); -const audioFileToSilkBase64Mock = vi.hoisted(() => vi.fn(async () => "silk-base64")); - -vi.mock("../messaging/sender.js", async () => { - // Real error class so prod `instanceof UploadDailyLimitExceededError` checks - // in error paths don't trip vitest's missing-export guard on this mock. - const { UploadDailyLimitExceededError } = - await vi.importActual("../api/media-chunked.js"); - return { - accountToCreds: (account: GatewayAccount) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - }), - buildDeliveryTarget: (target: { type: string; senderId: string; groupOpenid?: string }) => ({ - type: target.type === "group" ? "group" : target.type === "c2c" ? "c2c" : target.type, - id: target.type === "group" ? target.groupOpenid : target.senderId, - }), - initApiConfig: vi.fn(), - sendFileMessage: vi.fn(), - sendImage: vi.fn(), - sendText: sendTextMock, - sendVideoMessage: vi.fn(), - sendVoiceMessage: sendVoiceMessageMock, - sendMedia: sendMediaMock, - UploadDailyLimitExceededError, - withTokenRetry: async (_creds: unknown, fn: () => Promise) => await fn(), - }; -}); - -vi.mock("../utils/image-size.js", async () => { - const actual = - await vi.importActual("../utils/image-size.js"); - return { - ...actual, - getImageSize: vi.fn(async () => ({ width: 640, height: 480 })), - }; -}); - -vi.mock("../utils/audio.js", () => ({ - audioFileToSilkBase64: audioFileToSilkBase64Mock, -})); - -const account: GatewayAccount = { - accountId: "qq-main", - appId: "app", - clientSecret: "secret", - markdownSupport: false, - config: {}, -}; - -function makeInbound(overrides: Partial = {}): InboundContext { - return { - event: { - type: "c2c", - senderId: "user-openid", - messageId: "msg-1", - content: "voice", - timestamp: "2026-04-25T00:00:00.000Z", - }, - route: { sessionKey: "qqbot:c2c:user-openid", accountId: "qq-main" }, - isGroupChat: false, - peerId: "user-openid", - qualifiedTarget: "qqbot:c2c:user-openid", - fromAddress: "qqbot:c2c:user-openid", - agentBody: "voice", - body: "voice", - localMediaPaths: [], - localMediaTypes: [], - remoteMediaUrls: [], - uniqueVoicePaths: [], - uniqueVoiceUrls: [], - uniqueVoiceAsrReferTexts: [], - voiceMediaTypes: [], - hasAsrReferFallback: false, - voiceTranscriptSources: [], - commandAuthorized: false, - blocked: false, - skipped: false, - typing: { keepAlive: null }, - ...overrides, - }; -} - -function makeInboundRuntime( - dispatchReplyWithBufferedBlockDispatcher: (params: unknown) => Promise, - onResolvedContext?: (ctx: Record) => void, - onResolvedTurn?: (turn: Record) => void, -): GatewayPluginRuntime["channel"]["inbound"] { - return { - run: vi.fn(async (rawParams: unknown) => { - const params = rawParams as { - raw: unknown; - adapter: { - ingest: (raw: unknown) => unknown; - resolveTurn: (...args: unknown[]) => unknown; - }; - }; - const input = await params.adapter.ingest(params.raw); - const turn = (await params.adapter.resolveTurn( - input, - { - canStartAgentTurn: true, - kind: "message", - }, - {}, - )) as { - cfg: unknown; - ctxPayload: Record; - record?: Record; - dispatcherOptions?: Record; - delivery: { deliver: unknown; onError?: unknown }; - replyOptions?: unknown; - replyResolver?: unknown; - }; - onResolvedContext?.(turn.ctxPayload); - onResolvedTurn?.(turn as unknown as Record); - return { - dispatchResult: await dispatchReplyWithBufferedBlockDispatcher({ - ctx: turn.ctxPayload, - cfg: turn.cfg, - dispatcherOptions: { - ...turn.dispatcherOptions, - deliver: turn.delivery.deliver, - onError: turn.delivery.onError, - }, - replyOptions: turn.replyOptions, - replyResolver: turn.replyResolver, - }), - }; - }), - }; -} - -type ReplyPayload = { - text?: string; - mediaUrl?: string; - mediaUrls?: string[]; - audioAsVoice?: boolean; -}; -type DeliverReply = (payload: ReplyPayload, info: { kind: string }) => Promise; -interface DispatchOptions { - deliver: DeliverReply; - onSkip?: ( - payload: ReplyPayload, - info: { kind: string; reason: "empty" | "silent" | "heartbeat" }, - ) => void; - onSettled?: () => unknown; - onFreshSettledDelivery?: () => unknown; -} -interface RuntimeOptions { - onFinalize?: (ctx: Record) => void; - onTurn?: (turn: Record) => void; - isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean; - skipFreshSettledDelivery?: boolean; - onDispatch?: (dispatcherOptions: DispatchOptions) => Promise; - onDeliver?: (deliver: DeliverReply) => Promise; -} - -function makeRuntime(params: RuntimeOptions): GatewayPluginRuntime { - const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async (rawParams: unknown) => { - const dispatcherOptions = (rawParams as { dispatcherOptions: DispatchOptions }) - .dispatcherOptions; - if (params.onDispatch) { - await params.onDispatch(dispatcherOptions); - } else { - await params.onDeliver?.(dispatcherOptions.deliver); - } - await dispatcherOptions.onSettled?.(); - if (!params.skipFreshSettledDelivery) { - await dispatcherOptions.onFreshSettledDelivery?.(); - } - return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } }; - }); - return { - state: { - openChannelIngressQueue: () => { - throw new Error("unexpected durable ingress access"); - }, - }, - channel: { - activity: { record: vi.fn() }, - routing: { - resolveAgentRoute: vi.fn(() => ({ - sessionKey: "qqbot:c2c:user-openid", - accountId: "qq-main", - })), - }, - reply: { - dispatchReplyWithBufferedBlockDispatcher, - finalizeInboundContext: vi.fn((rawCtx: Record) => rawCtx), - formatInboundEnvelope: vi.fn(() => "voice"), - resolveEffectiveMessagesConfig: vi.fn(() => ({})), - resolveEnvelopeFormatOptions: vi.fn(() => ({})), - }, - session: { - resolveStorePath: vi.fn(() => "/tmp/openclaw/qqbot-sessions.json"), - recordInboundSession: vi.fn(async () => undefined), - }, - inbound: makeInboundRuntime( - dispatchReplyWithBufferedBlockDispatcher, - params.onFinalize, - params.onTurn, - ), - text: { - chunkMarkdownText: (text: string) => [text], - }, - commands: { - isControlCommandMessage: params.isControlCommandMessage ?? (() => false), - }, - }, - tts: { - textToSpeech: vi.fn(async () => ({ - success: true, - audioPath: "/tmp/openclaw-qqbot/tts.wav", - provider: "test-tts", - outputFormat: "wav", - })), - }, - }; -} - -async function runOutbound( - params: { - runtime?: RuntimeOptions; - inbound?: InboundContext; - cfg?: unknown; - account?: GatewayAccount; - } = {}, -): Promise { - const runtime = makeRuntime(params.runtime ?? {}); - await dispatchOutbound(params.inbound ?? makeInbound(), { - runtime, - cfg: params.cfg ?? {}, - account: params.account ?? account, - }); - return runtime; -} - -async function withTempMedia( - prefix: string, - fileName: string, - run: (media: { tmpRoot: string; filePath: string; realFilePath: string }) => Promise, -): Promise { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - try { - const filePath = path.join(tmpRoot, fileName); - await fs.writeFile(filePath, Buffer.from("report")); - await run({ tmpRoot, filePath, realFilePath: await fs.realpath(filePath) }); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } -} - -function expectLocalFileMediaSent(realFilePath: string): void { - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { localPath: realFilePath }, - target: { id: "user-openid", type: "c2c" }, - }), - ); -} - -describe("dispatchOutbound", () => { - beforeEach(() => { - vi.clearAllMocks(); - setOutboundAudioPort({ - audioFileToSilkBase64: audioFileToSilkBase64Mock, - isAudioFile: (pathOrUrl) => /\.(wav|mp3|ogg|silk)$/i.test(pathOrUrl), - shouldTranscodeVoice: () => true, - waitForFile: vi.fn(async (filePath: string) => { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, Buffer.from("voice")); - return 128; - }), - }); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it.each([ - { - name: "uploads local media from scoped outbound media roots", - prefix: "qqbot-scoped-media-", - fileName: "report.docx", - send: ({ filePath, tmpRoot }: { filePath: string; tmpRoot: string }) => - sendMedia({ - to: "qqbot:c2c:user-openid", - text: "", - mediaUrl: filePath, - accountId: "qq-main", - account, - mediaAccess: { localRoots: [tmpRoot] }, - }), - }, - { - name: "uploads qqmedia text tags from scoped outbound media roots", - prefix: "qqbot-scoped-media-", - fileName: "tagged-report.docx", - send: ({ filePath, tmpRoot }: { filePath: string; tmpRoot: string }) => - sendText({ - to: "qqbot:c2c:user-openid", - text: `${filePath}`, - accountId: "qq-main", - account, - mediaAccess: { localRoots: [tmpRoot] }, - }), - }, - { - name: "resolves relative media paths from the scoped outbound media workspace", - prefix: "qqbot-scoped-workspace-", - fileName: "relative-report.docx", - send: ({ filePath, tmpRoot }: { filePath: string; tmpRoot: string }) => - sendMedia({ - to: "qqbot:c2c:user-openid", - text: "", - mediaUrl: path.basename(filePath), - accountId: "qq-main", - account, - mediaAccess: { localRoots: [tmpRoot], workspaceDir: tmpRoot }, - }), - }, - ])("$name", async ({ prefix, fileName, send }) => { - await withTempMedia(prefix, fileName, async (media) => { - const result = await send(media); - expect(result.error).toBeUndefined(); - expectLocalFileMediaSent(media.realFilePath); - }); - }); - - it("loads scoped media through host read callbacks", async () => { - // macOS tmpdir is a /var -> /private/var symlink; containment compares canonical roots. - const tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-host-read-"))); - try { - const mediaPath = path.join(tmpRoot, "host-report.txt"); - const mediaReadFile = vi.fn(async () => Buffer.from("host report")); - const result = await sendMedia({ - to: "qqbot:c2c:user-openid", - text: "", - mediaUrl: "host-report.txt", - accountId: "qq-main", - account, - mediaAccess: { localRoots: [tmpRoot], workspaceDir: tmpRoot, readFile: mediaReadFile }, - }); - - expect(result.error).toBeUndefined(); - expect(mediaReadFile).toHaveBeenCalledWith(mediaPath); - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: expect.objectContaining({ - buffer: Buffer.from("host report"), - fileName: "host-report.txt", - }), - target: { id: "user-openid", type: "c2c" }, - }), - ); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } - }); - - it("lets missing voice files inside scoped outbound roots reach the voice wait path", async () => { - // Missing-path resolution joins canonical roots, so keep macOS tmpdir canonical here. - const tmpRoot = await fs.realpath( - await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-scoped-voice-")), - ); - try { - const missingVoicePath = path.join(tmpRoot, "pending.wav"); - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ text: `${missingVoicePath}` }, { kind: "block" }); - }, - }, - inbound: makeInbound({ - route: { sessionKey: "qqbot:c2c:user-openid", accountId: "qq-main", agentId: "agent-1" }, - }), - cfg: { agents: { list: [{ id: "agent-1", workspace: tmpRoot }] } }, - }); - expect(audioFileToSilkBase64Mock).toHaveBeenCalledWith(missingVoicePath, undefined); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } - }); - - const mediaTestNames = { - tagPath: "threads agent scoped media roots through gateway qqmedia block replies", - tagRelative: "resolves relative gateway qqmedia block replies against the agent workspace", - urlRelative: "resolves relative block mediaUrl payloads against the agent workspace", - main: "resolves default main route mediaUrl payloads against the main agent workspace", - defaultAgent: - "resolves missing route agent mediaUrl payloads against the configured default agent workspace", - tagVirtual: "maps sandbox /workspace qqmedia block replies to the agent workspace", - tool: "threads agent scoped media roots through gateway tool media forwarding", - payload: "threads agent scoped media roots through gateway QQBOT_PAYLOAD replies", - payloadVirtual: "maps sandbox /workspace QQBOT_PAYLOAD media paths to the agent workspace", - stream: "threads agent scoped media roots through official C2C streaming media tags", - } as const; - const workspaceMediaCases = [ - [mediaTestNames.tagPath, "gateway-report.docx", "tag-path", "agent"], - [mediaTestNames.tagRelative, "relative-report.docx", "tag-relative", "agent"], - [mediaTestNames.urlRelative, "relative-report.docx", "url-relative", "agent"], - [mediaTestNames.main, "main-report.docx", "url-relative", "main"], - [mediaTestNames.defaultAgent, "default-report.docx", "url-relative", "default"], - [mediaTestNames.tagVirtual, "sandbox-report.docx", "tag-virtual", "agent"], - [mediaTestNames.tool, "tool-report.docx", "tool-path", "agent"], - [mediaTestNames.payload, "payload-report.pdf", "payload-path", "agent"], - [mediaTestNames.payloadVirtual, "payload-workspace-report.pdf", "payload-virtual", "agent"], - [mediaTestNames.stream, "stream-report.docx", "tag-path", "agent-stream"], - ] as const; - - async function deliverWorkspaceMedia( - mode: (typeof workspaceMediaCases)[number][2], - deliver: DeliverReply, - filePath: string, - ): Promise { - if (mode === "tool-path") { - await deliver({ text: "final answer" }, { kind: "block" }); - await deliver({ mediaUrl: filePath }, { kind: "tool" }); - return; - } - if (mode === "url-relative") { - await deliver({ mediaUrl: path.basename(filePath) }, { kind: "block" }); - return; - } - const text = - mode === "tag-path" - ? `${filePath}` - : mode === "tag-relative" - ? `${path.basename(filePath)}` - : mode === "tag-virtual" - ? `/workspace/${path.basename(filePath)}` - : `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: mode === "payload-path" ? filePath : `/workspace/${path.basename(filePath)}`, - })}`; - await deliver({ text }, { kind: "block" }); - } - - it.each(workspaceMediaCases)("%s", async (_, fileName, mode, routeKind) => { - const agentId = - routeKind === "main" ? "main" : routeKind === "default" ? "assistant" : "agent-1"; - const routed = routeKind === "agent" || routeKind === "agent-stream"; - const isDefault = routeKind === "default"; - const streaming = routeKind === "agent-stream"; - await withTempMedia("qqbot-workspace-media-", fileName, async (media) => { - let finalized: Record | undefined; - const runtime = await runOutbound({ - runtime: { - onFinalize: (ctx) => (finalized = ctx), - onDeliver: (deliver) => deliverWorkspaceMedia(mode, deliver, media.filePath), - }, - inbound: routed - ? makeInbound({ - route: { sessionKey: "qqbot:c2c:user-openid", accountId: "qq-main", agentId }, - }) - : makeInbound(), - cfg: { - agents: { - list: [ - { - id: agentId, - ...(isDefault ? { default: true } : {}), - workspace: media.tmpRoot, - }, - ], - }, - }, - account: streaming - ? { ...account, config: { streaming: { mode: "partial", nativeTransport: true } } } - : account, - }); - - expectLocalFileMediaSent(media.realFilePath); - if (isDefault) { - expect(runtime.channel.reply.resolveEffectiveMessagesConfig).toHaveBeenCalledWith( - expect.anything(), - agentId, - ); - expect(finalized?.AgentId).toBe(agentId); - } - }); - }); - - it("blocks sandbox /workspace qqmedia paths that escape the agent workspace", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-agent-virtual-root-")); - try { - const workspaceDir = path.join(tmpRoot, "workspace"); - await fs.mkdir(workspaceDir); - await fs.writeFile(path.join(tmpRoot, "outside-report.docx"), Buffer.from("outside")); - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver( - { text: "/workspace/../outside-report.docx" }, - { kind: "block" }, - ); - }, - }, - inbound: makeInbound({ - route: { sessionKey: "qqbot:c2c:user-openid", accountId: "qq-main", agentId: "agent-1" }, - }), - cfg: { agents: { list: [{ id: "agent-1", workspace: workspaceDir }] } }, - }); - - expect(sendMediaMock).not.toHaveBeenCalled(); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([DEFAULT_MEDIA_SEND_ERROR]); - const sentText = String(sendTextMock.mock.calls[0]?.[1]); - expect(sentText).not.toContain(""); - expect(sentText).not.toContain("/workspace/../outside-report.docx"); - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }); - } - }); - - it("sends sanitized fallback when media-only block payload forwarding fails", async () => { - sendMediaMock.mockResolvedValueOnce({ channel: "qqbot", error: "upload failed" }); - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ mediaUrl: "missing-report.pdf" }, { kind: "block" }); - }, - }, - }); - - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([DEFAULT_MEDIA_SEND_ERROR]); - const sentText = String(sendTextMock.mock.calls[0]?.[1]); - expect(sentText).not.toContain("missing-report.pdf"); - }); - - it("does not expose default sandbox roots through gateway qqmedia replies", async () => { - const openClawState = await createOpenClawTestState({ - layout: "state-only", - prefix: "qqbot-agent-root-boundary-", - }); - try { - const workspaceDir = path.join(openClawState.root, "workspace"); - const stateSandboxDir = openClawState.statePath("sandboxes", "other-agent"); - const stateSandboxFile = path.join(stateSandboxDir, "outside-report.docx"); - await fs.mkdir(workspaceDir, { recursive: true }); - await fs.mkdir(stateSandboxDir, { recursive: true }); - await fs.writeFile(stateSandboxFile, Buffer.from("outside")); - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ text: `${stateSandboxFile}` }, { kind: "block" }); - }, - }, - inbound: makeInbound({ - route: { sessionKey: "qqbot:c2c:user-openid", accountId: "qq-main", agentId: "agent-1" }, - }), - cfg: { agents: { list: [{ id: "agent-1", workspace: workspaceDir }] } }, - }); - expect(sendMediaMock).not.toHaveBeenCalled(); - } finally { - await openClawState.cleanup(); - } - }); - - it.each([ - { - name: "keeps waiting past 300s when a slow provider timeout is configured", - text: "late answer", - cfg: { models: { providers: { ollama: { timeoutSeconds: 1800 } } } }, - durable: false, - }, - { - name: "keeps durable settlement with a dispatch that outlives the response watchdog", - text: "late durable answer", - cfg: {}, - durable: true, - }, - ])("$name", async ({ text, cfg, durable }) => { - vi.useFakeTimers(); - const lifecycle = { - abortSignal: new AbortController().signal, - onAdopted: vi.fn(async () => {}), - onDeferred: vi.fn(), - onAdoptionFinalizing: vi.fn(), - onAbandoned: vi.fn(async () => {}), - }; - const inbound = makeInbound(); - if (durable) { - inbound.event.turnAdoptionLifecycle = lifecycle; - } - const runtime = makeRuntime({ - onDeliver: async (deliver) => { - await new Promise((resolve) => { - setTimeout(resolve, 301_000); - }); - await deliver({ text }, { kind: "block" }); - }, - }); - let settled = false; - const dispatchPromise = dispatchOutbound(inbound, { runtime, cfg, account }).finally(() => { - settled = true; - }); - - await vi.advanceTimersByTimeAsync(300_000); - expect(settled).toBe(false); - if (durable) { - expect(lifecycle.onAbandoned).not.toHaveBeenCalled(); - } else { - expect(sendTextMock).not.toHaveBeenCalled(); - } - - await vi.advanceTimersByTimeAsync(1_000); - await dispatchPromise; - expect(sendTextMock).toHaveBeenCalledWith( - expect.anything(), - text, - expect.anything(), - expect.anything(), - ); - }); - - it("marks voice-only inbound as type-only audio facts", async () => { - let finalized: Record | undefined; - await runOutbound({ - runtime: { onFinalize: (ctx) => (finalized = ctx) }, - inbound: makeInbound({ - uniqueVoicePaths: ["/tmp/qqbot/voice.wav"], - voiceMediaTypes: ["audio/wav"], - }), - }); - - expect(finalized?.media).toEqual([expect.objectContaining({ contentType: "audio/wav" })]); - expect(finalized?.QQVoiceAttachmentPaths).toEqual(["/tmp/qqbot/voice.wav"]); - expect(finalized?.MediaPath).toBeUndefined(); - expect(finalized?.MediaPaths).toBeUndefined(); - }); - - it("keeps disjoint local and remote images as separate ordered facts", async () => { - let finalized: Record | undefined; - await runOutbound({ - runtime: { onFinalize: (ctx) => (finalized = ctx) }, - inbound: makeInbound({ - localMediaPaths: ["/tmp/qqbot/local.png"], - localMediaTypes: ["image/png"], - remoteMediaUrls: ["https://example.test/remote.png"], - }), - }); - - expect(finalized?.media).toEqual([ - expect.objectContaining({ - path: "/tmp/qqbot/local.png", - contentType: "image/png", - kind: "image", - }), - // Remote URLs carry no MIME; the explicit kind preserves image understanding. - expect.objectContaining({ url: "https://example.test/remote.png", kind: "image" }), - ]); - }); - - it("synthesizes plain audioAsVoice text as a QQ voice reply", async () => { - const runtime = await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ text: "read this aloud", audioAsVoice: true }, { kind: "block" }); - }, - }, - }); - - expect(runtime.tts.textToSpeech).toHaveBeenCalledWith({ - text: "read this aloud", - cfg: {}, - channel: "qqbot", - accountId: "qq-main", - }); - expect(audioFileToSilkBase64Mock).toHaveBeenCalledWith("/tmp/openclaw-qqbot/tts.wav"); - const sentMedia = sendMediaMock.mock.calls.at(0)?.[0] as - | { kind?: string; source?: unknown; msgId?: string; ttsText?: string } - | undefined; - expect(sentMedia?.kind).toBe("voice"); - expect(sentMedia?.source).toEqual({ base64: "silk-base64" }); - expect(sentMedia?.msgId).toBe("msg-1"); - expect(sentMedia?.ttsText).toBe("read this aloud"); - expect(sendTextMock).not.toHaveBeenCalled(); - }); - - it.each([ - { - name: "delivers text-only tool progress immediately in partial streaming mode", - streaming: { mode: "partial" as const }, - }, - { - name: "delivers text-only tool progress immediately in recommended C2C streaming mode", - streaming: { mode: "partial" as const, nativeTransport: true }, - }, - { - name: "delivers text-only tool progress when nativeTransport is on despite mode off", - streaming: { mode: "off" as const, nativeTransport: true }, - }, - ])("$name", async ({ streaming }) => { - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ text: "Working: checking logs" }, { kind: "tool" }); - await deliver({ text: "final answer" }, { kind: "block" }); - }, - }, - account: { ...account, config: { streaming } }, - }); - - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([ - "Working: checking logs", - "final answer", - ]); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("keeps immediate tool progress media-like text inert with markdown support enabled", async () => { - const progress = "progress ![x](http://internal.example/progress.png)"; - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ text: progress }, { kind: "tool" }); - await deliver({ text: "final answer" }, { kind: "block" }); - }, - }, - account: { ...account, markdownSupport: true, config: { streaming: { mode: "partial" } } }, - }); - - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([progress, "final answer"]); - expect(sendTextMock.mock.calls[0]?.[3]).toMatchObject({ forcePlainText: true }); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("keeps text-only tool progress buffered when streaming is off", async () => { - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ text: "Working: checking logs" }, { kind: "tool" }); - await deliver({ text: "final answer" }, { kind: "block" }); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["final answer"]); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("flushes buffered tool text when non-streaming final block is silent", async () => { - await runOutbound({ - runtime: { - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ text: "first visible tool message" }, { kind: "tool" }); - await deliver({ text: "second visible tool message" }, { kind: "tool" }); - onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" }); - }, - }, - inbound: makeInbound({ - event: { - type: "group", - senderId: "member-openid", - messageId: "msg-group-tool-final-silent", - content: "<@BOT> do it", - timestamp: "2026-04-25T00:00:00.000Z", - groupOpenid: "group-openid", - }, - route: { sessionKey: "qqbot:group:group-openid", accountId: "qq-main" }, - isGroupChat: true, - peerId: "group-openid", - qualifiedTarget: "qqbot:group:group-openid", - fromAddress: "qqbot:group:group-openid", - agentBody: "do it", - body: "[member-openid] do it (@you)", - }), - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([ - "first visible tool message", - "second visible tool message", - ]); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("keeps buffered tool text suppressed when a visible block precedes a silent final skip", async () => { - await runOutbound({ - runtime: { - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ text: "Working: checking logs" }, { kind: "tool" }); - onSkip?.({ text: "NO_REPLY" }, { kind: "final", reason: "silent" }); - await deliver({ text: "final answer" }, { kind: "block" }); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["final answer"]); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("does not re-send tool fallback after timeout when non-streaming final block is silent", async () => { - vi.useFakeTimers(); - await runOutbound({ - runtime: { - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ text: "visible tool message" }, { kind: "tool" }); - await vi.advanceTimersByTimeAsync(60_000); - onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" }); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("waits for fresh settled delivery after a skipped silent block", async () => { - vi.useFakeTimers(); - await runOutbound({ - runtime: { - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ text: "visible tool message" }, { kind: "tool" }); - onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" }); - await vi.advanceTimersByTimeAsync(60_000); - expect(sendTextMock).not.toHaveBeenCalled(); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("does not send stale tool fallback when fresh settled delivery is suppressed", async () => { - vi.useFakeTimers(); - await runOutbound({ - runtime: { - skipFreshSettledDelivery: true, - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ text: "stale visible tool message" }, { kind: "tool" }); - onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" }); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock).not.toHaveBeenCalled(); - expect(sendMediaMock).not.toHaveBeenCalled(); - expect(vi.getTimerCount()).toBe(0); - }); - - it("sends buffered tool text when tool media fallback fails", async () => { - vi.useFakeTimers(); - sendMediaMock.mockResolvedValueOnce({ channel: "qqbot", error: "upload failed" }); - await runOutbound({ - runtime: { - onDispatch: async ({ deliver }) => { - await deliver({ mediaUrl: "https://example.com/progress.png" }, { kind: "tool" }); - await deliver({ text: "visible tool fallback" }, { kind: "tool" }); - await vi.advanceTimersByTimeAsync(60_000); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendMediaMock).toHaveBeenCalledTimes(1); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool fallback"]); - }); - - it("bounds tool media flushes without racing the fallback timer", async () => { - vi.useFakeTimers(); - sendMediaMock.mockImplementationOnce(() => new Promise(() => {})); - sendMediaMock.mockImplementationOnce(() => new Promise(() => {})); - const runtime = makeRuntime({ - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ mediaUrl: "https://example.com/progress-1.png" }, { kind: "tool" }); - await deliver({ mediaUrl: "https://example.com/progress-2.png" }, { kind: "tool" }); - await deliver({ text: "visible tool message" }, { kind: "tool" }); - onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" }); - }, - }); - const dispatchPromise = dispatchOutbound(makeInbound(), { - runtime, - cfg: {}, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - await vi.advanceTimersByTimeAsync(90_000); - await dispatchPromise; - expect(sendMediaMock).toHaveBeenCalledTimes(2); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]); - }); - - it("clears the media timeout after a successful silent-final flush", async () => { - vi.useFakeTimers(); - await runOutbound({ - runtime: { - onDispatch: async ({ deliver, onSkip }) => { - await deliver({ mediaUrl: "https://example.com/progress.png" }, { kind: "tool" }); - onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" }); - }, - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendMediaMock).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); - - it.each([ - { name: "empty text", payload: {} }, - { name: "silent token", payload: { text: "NO_REPLY" } }, - ])("delivers media-only non-streaming final block replies with $name", async ({ payload }) => { - const mediaUrl = "https://example.com/final.png"; - await runOutbound({ - runtime: { - onDeliver: async (deliver) => deliver({ ...payload, mediaUrl }, { kind: "block" }), - }, - account: { ...account, config: { streaming: { mode: "off" } } }, - }); - expect(sendTextMock).not.toHaveBeenCalled(); - expect(sendMediaMock).toHaveBeenCalledWith({ - creds: { appId: "app", clientSecret: "secret" }, - kind: "image", - msgId: "msg-1", - source: { url: mediaUrl }, - target: { id: "user-openid", type: "c2c" }, - }); - }); - - it("delivers media-only final block replies when C2C streaming is enabled", async () => { - const mediaUrl = "https://example.com/final.png"; - await runOutbound({ - runtime: { onDeliver: async (deliver) => deliver({ mediaUrl }, { kind: "block" }) }, - account: { ...account, config: { streaming: { mode: "partial", nativeTransport: true } } }, - }); - expect(sendTextMock).not.toHaveBeenCalled(); - expect(sendMediaMock).toHaveBeenCalledWith({ - creds: { appId: "app", clientSecret: "secret" }, - kind: "image", - msgId: "msg-1", - source: { url: mediaUrl }, - target: { id: "user-openid", type: "c2c" }, - }); - }); - - it("renews pending tool-media fallback when partial progress is delivered", async () => { - vi.useFakeTimers(); - const mediaUrl = "https://example.com/progress.png"; - await runOutbound({ - runtime: { - onDeliver: async (deliver) => { - await deliver({ mediaUrl }, { kind: "tool" }); - await vi.advanceTimersByTimeAsync(59_000); - await deliver({ text: "Working: checking logs" }, { kind: "tool" }); - await vi.advanceTimersByTimeAsync(1_000); - expect(sendMediaMock).not.toHaveBeenCalled(); - await deliver({ text: "final answer" }, { kind: "block" }); - }, - }, - account: { ...account, config: { streaming: { mode: "partial" } } }, - }); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([ - "Working: checking logs", - "final answer", - ]); - expect(sendMediaMock).toHaveBeenCalledTimes(1); - }); - - it("marks recognized C2C framework slash commands as text commands", async () => { - let finalized: Record | undefined; - const runtime = makeRuntime({ - isControlCommandMessage: (text) => text === "/models", - onFinalize: (ctx) => (finalized = ctx), - }); - - await dispatchOutbound( - makeInbound({ - event: { - type: "c2c", - senderId: "user-openid", - messageId: "msg-models", - content: "/models", - timestamp: "2026-04-25T00:00:00.000Z", - }, - agentBody: "/models", - body: "/models", - commandAuthorized: true, - }), - { runtime, cfg: { commands: { text: true } }, account }, - ); - - expect(finalized?.CommandBody).toBe("/models"); - expect(finalized?.CommandAuthorized).toBe(true); - expect(finalized?.CommandSource).toBe("text"); - expect(finalized?.Provider).toBe("qqbot"); - expect(finalized?.Surface).toBe("qqbot"); - expect(finalized?.ChatType).toBe("direct"); - }); - - it.each([ - { - name: "keeps markdown table chunks self-contained across block deliveries", - blocks: [ - ["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"), - ["| 2 | beta |", "| 3 | gamma |"].join("\n"), - ], - expected: [ - ["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"), - ["| Id | Value |", "|---:|---|", "| 2 | beta |", "| 3 | gamma |"].join("\n"), - ], - assertEach: true, - }, - { - name: "waits for a table separator when a block ends after the header", - blocks: ["| Id | Value |", ["|---:|---|", "| 1 | alpha |"].join("\n")], - expected: [["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n")], - }, - { - name: "flushes unfinished markdown table row fragments as plain text fields", - blocks: [ - ["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n"), - "| 10 | analyzeerror_patterns | 无需重试", - ], - expected: [ - ["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n"), - ["Id: 10", "Function: analyzeerror_patterns", "Status: 无需重试"].join("\n"), - ], - }, - { - name: "holds short table rows until a following block completes the columns", - blocks: [ - [ - "| Id | Time | Owner | Note |", - "|---:|---|---|---|", - "| 16 | 40ms | He | ok |", - "| 17 | 100ms |", - ].join("\n"), - "Lin | daily cap |", - ], - expected: [ - ["| Id | Time | Owner | Note |", "|---:|---|---|---|", "| 16 | 40ms | He | ok |"].join( - "\n", - ), - [ - "| Id | Time | Owner | Note |", - "|---:|---|---|---|", - "| 17 | 100ms | Lin | daily cap |", - ].join("\n"), - ], - }, - ])("$name", async ({ blocks, expected, assertEach }) => { - await runOutbound({ - runtime: { - onDispatch: async ({ deliver }) => { - for (const text of blocks) { - await deliver({ text }, { kind: "block" }); - } - }, - }, - }); - - const sentTexts = sendTextMock.mock.calls.map((call) => call[1]); - if (assertEach) { - expect(sendTextMock).toHaveBeenCalledTimes(2); - expect(sentTexts[0]).toBe(expected[0]); - expect(sentTexts[1]).toBe(expected[1]); - } else { - expect(sentTexts).toEqual(expected); - } - }); - - it("persists announce routes only for group and guild turns", async () => { - const cases = [ - ["group", true, { groupOpenid: "group-1001" }, "group-1001", "qqbot:group:group-1001"], - [ - "guild", - true, - { channelId: "channel-2001", guildId: "guild-2001" }, - "channel-2001", - "qqbot:channel:channel-2001", - ], - ["c2c", false, {}, "user-openid", "qqbot:c2c:user-openid"], - ["dm", false, { guildId: "dm-guild-1" }, "user-openid", "qqbot:dm:dm-guild-1"], - ] as const; - - for (const [type, isGroupChat, eventTarget, peerId, qualifiedTarget] of cases) { - let record: Record | undefined; - const sessionKey = `agent:main:qqbot:${type}:${peerId}`; - await runOutbound({ - runtime: { - onTurn: (turn) => { - record = turn.record as Record | undefined; - }, - onDeliver: async (deliver) => { - await deliver({ text: "hello" }, { kind: "block" }); - }, - }, - inbound: makeInbound({ - event: { - type, - senderId: "user-openid", - messageId: `msg-${type}`, - content: "hello", - timestamp: "2026-04-25T00:00:00.000Z", - ...eventTarget, - } as InboundContext["event"], - isGroupChat, - peerId, - qualifiedTarget, - route: { sessionKey, accountId: "qq-main" }, - }), - }); - - expect(record).toBeDefined(); - expect(record?.updateLastRoute).toEqual( - isGroupChat - ? { sessionKey, channel: "qqbot", to: qualifiedTarget, accountId: "qq-main" } - : undefined, - ); - } - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts deleted file mode 100644 index 6b59882b8994..000000000000 --- a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts +++ /dev/null @@ -1,867 +0,0 @@ -/** - * Outbound dispatcher — manage AI reply delivery, tool fallback, and timeouts. - * - * Responsibilities: - * 1. Build ctxPayload and call runtime.dispatchReply - * 2. Tool deliver collection + fallback timeout - * 3. Block deliver pipeline (consumeQuoteRef → media tags → structured payload → plain text) - * 4. Timeout / error handling - * - * Separated from gateway.ts for testability and to keep handleMessage thin. - */ - -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; -import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound"; -import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking"; -import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { createQQBotMarkdownChunker } from "../messaging/markdown-table-chunking.js"; -import { - parseAndSendMediaTags, - sendPlainReply, - sendTextOnlyReply, - TEXT_CHUNK_LIMIT, - type DeliverDeps, -} from "../messaging/outbound-deliver.js"; -import { - sendDocument, - sendMedia, - sendPhoto, - sendVoice, - sendVideoMsg, -} from "../messaging/outbound.js"; -import { - handleStructuredPayload, - sendTextAsVoiceReply, - sendErrorToTarget, - sendWithTokenRetry, - type ReplyDispatcherDeps, -} from "../messaging/reply-dispatcher.js"; -import { StreamingController, shouldUseOfficialC2cStream } from "../messaging/streaming-c2c.js"; -import { audioFileToSilkBase64 } from "../utils/audio.js"; -import type { InboundContext } from "./inbound-context.js"; -import { resolveResponseTimeoutMs } from "./response-timeout.js"; -import type { - GatewayAccount, - EngineLogger, - GatewayPluginRuntime, - OutboundResult, -} from "./types.js"; - -// ============ Config ============ - -// Historical floor for the QQBot outbound response watchdog (5 min). The -// effective wait budget is now derived from existing -// `agents.defaults.timeoutSeconds` and `models.providers..timeoutSeconds` -// via `resolveResponseTimeoutMs(cfg)` — see issue #85267, where a slow -// local ollama/qwen3.5:27b turn was capped at 5 min despite a configured -// 1800s provider timeout. -const TOOL_ONLY_TIMEOUT = 60_000; -const MAX_TOOL_RENEWALS = 3; -const TOOL_MEDIA_SEND_TIMEOUT = 45_000; - -// ============ Dependencies ============ - -interface OutboundDispatchDeps { - runtime: GatewayPluginRuntime; - cfg: unknown; - account: GatewayAccount; - log?: EngineLogger; -} - -type ReplyDeliverPayload = { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - audioAsVoice?: boolean; - isError?: boolean; -}; - -function shouldDeliverToolProgressImmediately( - account: GatewayAccount, - useOfficialC2cStream: boolean, -): boolean { - if (useOfficialC2cStream) { - return true; - } - // Absent streaming keeps tool progress buffered; a configured object opts in - // unless mode is "off". Legacy scalar spellings are doctor-migrated. - const streaming = account.config?.streaming; - return typeof streaming === "object" && streaming !== null && streaming.mode !== "off"; -} - -function immediateToolProgressText(payload: ReplyDeliverPayload): string | undefined { - const text = (payload.text ?? "").trim(); - if (!text || payload.isError || payload.audioAsVoice) { - return undefined; - } - if (payload.mediaUrl || payload.mediaUrls?.length) { - return undefined; - } - return text; -} - -function hasReplyMedia(payload: ReplyDeliverPayload): boolean { - return Boolean(payload.mediaUrl || payload.mediaUrls?.length); -} - -function isSilentBlockReplyText(text: string): boolean { - return !text || text === "[SKIP]" || isSilentReplyPayloadText(text, SILENT_REPLY_TOKEN); -} - -function blockReplyTextForDelivery(payload: ReplyDeliverPayload): string { - const text = payload.text ?? ""; - return isSilentBlockReplyText(text.trim()) ? "" : text; -} - -function isSilentBlockReply(payload: ReplyDeliverPayload): boolean { - return !hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim()); -} - -function isMediaOnlyBlockReply(payload: ReplyDeliverPayload): boolean { - return hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim()); -} - -// ============ dispatchOutbound ============ - -/** - * Dispatch the AI reply for the given inbound context. - * - * Handles tool deliver collection, block deliver pipeline, and timeouts. - * The caller is responsible for stopping typing.keepAlive in `finally`. - */ -export async function dispatchOutbound( - inbound: InboundContext, - deps: OutboundDispatchDeps, -): Promise { - const { runtime, cfg, account, log } = deps; - const { event, qualifiedTarget } = inbound; - - const openClawCfg = cfg as OpenClawConfig; - const routeAgentId = inbound.route.agentId ?? resolveDefaultAgentId(openClawCfg); - const workspaceDir = resolveAgentWorkspaceDir(openClawCfg, routeAgentId); - const gatewayMediaContext = workspaceDir - ? { mediaAccess: { workspaceDir }, mediaLocalRoots: [workspaceDir] } - : {}; - const replyTarget = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - guildId: event.guildId, - groupOpenid: event.groupOpenid, - }; - const replyCtx = { target: replyTarget, account, cfg, log, ...gatewayMediaContext }; - - const sendWithRetry = (sendFn: (token: string) => Promise) => - sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId); - - const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText); - - // ---- Build ctxPayload ---- - const ctxPayload = await buildCtxPayload(inbound, runtime, cfg); - - // ---- Deliver state ---- - let hasResponse = false; - let hasBlockResponse = false; - let hasVisibleBlockResponse = false; - let toolDeliverCount = 0; - const toolTexts: string[] = []; - const toolMediaUrls: string[] = []; - let toolFallbackSent = false; - let toolRenewalCount = 0; - let skippedSilentBlockResponse = false; - let timeoutId: ReturnType | null = null; - let toolOnlyTimeoutId: ReturnType | null = null; - - const markBlockResponse = (): void => { - hasBlockResponse = true; - inbound.typing.keepAlive?.stop(); - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - }; - - // ---- Tool fallback ---- - const sendToolMediaWithTimeout = async ( - mediaUrl: string, - labels: { resultError: string; thrownError: string }, - ): Promise => { - const ac = new AbortController(); - let mediaTimeoutId: ReturnType | null = null; - try { - const result = await Promise.race([ - sendMedia({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - ...gatewayMediaContext, - }).then((r) => { - if (ac.signal.aborted) { - return { channel: "qqbot", error: "suppressed" } as OutboundResult; - } - return r; - }), - new Promise((resolve) => { - mediaTimeoutId = setTimeout(() => { - ac.abort(); - resolve({ channel: "qqbot", error: "timeout" }); - }, TOOL_MEDIA_SEND_TIMEOUT); - }), - ]); - if (result.error) { - log?.error(`${labels.resultError}: ${result.error}`); - } - } catch (err) { - log?.error(`${labels.thrownError}: ${String(err)}`); - } finally { - if (mediaTimeoutId) { - clearTimeout(mediaTimeoutId); - } - } - }; - - const sendToolFallback = async (): Promise => { - if (toolMediaUrls.length > 0) { - for (const mediaUrl of toolMediaUrls) { - await sendToolMediaWithTimeout(mediaUrl, { - resultError: "Tool fallback error", - thrownError: "Tool fallback failed", - }); - } - } - if (toolTexts.length > 0) { - await sendErrorMessage(truncateUtf16Safe(toolTexts.slice(-3).join("\n---\n"), 2000)); - } - }; - - const hasPendingToolFallbackPayload = (): boolean => - toolTexts.length > 0 || toolMediaUrls.length > 0; - - const flushPendingToolDeliveriesOnce = async (): Promise => { - if (toolFallbackSent || !hasPendingToolFallbackPayload()) { - return false; - } - await flushPendingToolDeliveries(); - toolFallbackSent = true; - recordOutbound(); - return true; - }; - - const renewToolOnlyFallback = (): boolean => { - if (toolFallbackSent) { - return false; - } - if (toolOnlyTimeoutId) { - if (toolRenewalCount >= MAX_TOOL_RENEWALS) { - return false; - } - clearTimeout(toolOnlyTimeoutId); - toolRenewalCount++; - } - toolOnlyTimeoutId = setTimeout(() => { - if (!hasBlockResponse && !toolFallbackSent && !skippedSilentBlockResponse) { - toolFallbackSent = true; - void sendToolFallback().catch(() => {}); - } - }, TOOL_ONLY_TIMEOUT); - return true; - }; - - // ---- Timeout promise ---- - // #85267: derive watchdog from existing agent / provider timeout config so - // a longer configured ceiling (e.g. slow local ollama models) is not - // silently undercut by a plugin-local 5-minute cap. - const responseTimeoutMs = resolveResponseTimeoutMs(cfg); - const responseTimeoutError = new Error("Response timeout"); - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - if (!hasResponse) { - reject(responseTimeoutError); - } - }, responseTimeoutMs); - }); - - // ---- Deliver deps ---- - const markdownChunker = createQQBotMarkdownChunker((text, limit) => - runtime.channel.text.chunkMarkdownText(text, limit), - ); - const deliverDeps: DeliverDeps = { - mediaSender: { - sendPhoto: (target, imageUrl) => sendPhoto(target, imageUrl), - sendVoice: (target, voicePath, uploadFormats, transcodeEnabled) => - sendVoice(target, voicePath, uploadFormats, transcodeEnabled), - sendVideoMsg: (target, videoPath) => sendVideoMsg(target, videoPath), - sendDocument: (target, filePath) => sendDocument(target, filePath), - sendMedia: (opts) => sendMedia(opts), - }, - chunkText: (text, limit) => markdownChunker.chunkText(text, limit), - }; - const flushPendingMarkdownText = async (): Promise => { - const pendingChunks = markdownChunker.flushPendingText(TEXT_CHUNK_LIMIT); - if (pendingChunks.length === 0) { - return; - } - const passthroughDeps: DeliverDeps = { - ...deliverDeps, - chunkText: (text) => [text], - }; - for (const chunk of pendingChunks) { - await sendTextOnlyReply( - chunk, - { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }, - { account, qualifiedTarget, log }, - sendWithRetry, - () => undefined, - passthroughDeps, - ); - recordOutbound(); - } - }; - - const replyDeps: ReplyDispatcherDeps = { - tts: { - textToSpeech: (params) => runtime.tts.textToSpeech(params), - audioFileToSilkBase64: async (p) => (await audioFileToSilkBase64(p)) ?? undefined, - }, - }; - - const flushPendingToolDeliveries = async (): Promise => { - if (toolMediaUrls.length > 0) { - const urlsToSend = [...toolMediaUrls]; - toolMediaUrls.length = 0; - for (const mediaUrl of urlsToSend) { - await sendToolMediaWithTimeout(mediaUrl, { - resultError: "Tool media forward error", - thrownError: "Tool media forward failed", - }); - } - } - - if (toolTexts.length > 0) { - const textsToSend = [...toolTexts]; - toolTexts.length = 0; - for (const text of textsToSend) { - await sendTextOnlyReply( - text, - { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }, - { account, qualifiedTarget, log, ...gatewayMediaContext }, - sendWithRetry, - () => undefined, - deliverDeps, - ); - } - } - }; - - const recordOutbound = () => - runtime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - - // ---- Dispatch ---- - const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, routeAgentId); - - const targetType = - event.type === "c2c" - ? ("c2c" as const) - : event.type === "group" - ? ("group" as const) - : ("channel" as const); - const useOfficialC2cStream = shouldUseOfficialC2cStream(account, targetType); - const deliverToolProgressImmediately = shouldDeliverToolProgressImmediately( - account, - useOfficialC2cStream, - ); - let streamingController: StreamingController | null = null; - if (useOfficialC2cStream) { - streamingController = new StreamingController({ - account, - userId: event.senderId, - replyToMsgId: event.messageId, - eventId: event.messageId, - logPrefix: `[qqbot:${account.accountId}:streaming]`, - log, - mediaContext: { - account, - event: { - type: event.type as "c2c" | "group" | "channel", - senderId: event.senderId, - messageId: event.messageId, - groupOpenid: event.groupOpenid, - channelId: event.channelId, - }, - log, - ...gatewayMediaContext, - }, - }); - } - - const dispatchPromise = runtime.channel.inbound.run({ - channel: "qqbot", - accountId: inbound.route.accountId, - raw: inbound, - adapter: { - ingest: () => ({ - id: ctxPayload.MessageSid ?? `${ctxPayload.From}:${Date.now()}`, - rawText: ctxPayload.RawBody ?? "", - textForAgent: ctxPayload.BodyForAgent, - textForCommands: ctxPayload.CommandBody, - raw: inbound, - }), - resolveTurn: () => ({ - cfg: openClawCfg, - channel: "qqbot", - accountId: inbound.route.accountId, - route: { - agentId: routeAgentId, - dmScope: inbound.route.dmScope, - sessionKey: inbound.route.sessionKey, - }, - ctxPayload, - record: { - onRecordError: (err: unknown) => { - log?.error( - `Session metadata update failed: ${err instanceof Error ? err.message : String(err)}`, - ); - }, - ...(inbound.isGroupChat - ? { - updateLastRoute: { - sessionKey: inbound.route.sessionKey, - channel: "qqbot", - to: qualifiedTarget, - accountId: inbound.route.accountId, - }, - } - : {}), - }, - dispatcherOptions: { - responsePrefix: messagesConfig.responsePrefix, - onSkip: ( - _payload: ReplyDeliverPayload, - info: { kind: string; reason: "empty" | "silent" | "heartbeat" }, - ) => { - if ( - !streamingController && - (info.kind === "block" || info.kind === "final") && - (info.reason === "silent" || info.reason === "empty") - ) { - skippedSilentBlockResponse = true; - } - }, - onFreshSettledDelivery: async () => { - if (skippedSilentBlockResponse && !hasVisibleBlockResponse) { - markBlockResponse(); - if (await flushPendingToolDeliveriesOnce()) { - return { visibleReplySent: true }; - } - } - return undefined; - }, - }, - delivery: { - deliver: async (payload: ReplyDeliverPayload, info: { kind: string }) => { - hasResponse = true; - - if (info.kind === "tool") { - toolDeliverCount++; - const toolText = (payload.text ?? "").trim(); - const textOnlyProgress = immediateToolProgressText(payload); - if (!hasBlockResponse && deliverToolProgressImmediately && textOnlyProgress) { - if (toolOnlyTimeoutId || hasPendingToolFallbackPayload()) { - renewToolOnlyFallback(); - } - await sendTextOnlyReply( - textOnlyProgress, - { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }, - { account, qualifiedTarget, log, ...gatewayMediaContext }, - sendWithRetry, - () => undefined, - deliverDeps, - ); - recordOutbound(); - return; - } - if (toolText) { - toolTexts.push(toolText); - } - if (payload.mediaUrls?.length) { - toolMediaUrls.push(...payload.mediaUrls); - } - if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { - toolMediaUrls.push(payload.mediaUrl); - } - - if (hasBlockResponse && toolMediaUrls.length > 0) { - const urlsToSend = [...toolMediaUrls]; - toolMediaUrls.length = 0; - for (const mediaUrl of urlsToSend) { - try { - await sendMedia({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - ...gatewayMediaContext, - }); - } catch {} - } - return; - } - if (toolFallbackSent) { - return; - } - renewToolOnlyFallback(); - return; - } - - markBlockResponse(); - - if (!streamingController && isSilentBlockReply(payload)) { - if (!(await flushPendingToolDeliveriesOnce()) && event.type === "group") { - log?.info( - `Model decided to skip group message (${(payload.text ?? "").trim() || "empty reply"}) from ${event.senderId}`, - ); - } - return; - } - hasVisibleBlockResponse = true; - - if ( - streamingController && - !streamingController.isTerminalPhase && - !isMediaOnlyBlockReply(payload) - ) { - try { - await streamingController.onDeliver(payload); - } catch (err) { - log?.error( - `Streaming deliver error: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - const replyPreview = (payload.text ?? "").trim(); - if ( - event.type === "group" && - (replyPreview === "NO_REPLY" || replyPreview === "[SKIP]") - ) { - log?.info( - `Model decided to skip group message (${replyPreview}) from ${event.senderId}`, - ); - return; - } - - if (streamingController.shouldFallbackToStatic) { - log?.info("Streaming API unavailable, falling back to static for this deliver"); - } else { - recordOutbound(); - return; - } - } - - const quoteRef = event.msgIdx; - let quoteRefUsed = false; - const consumeQuoteRef = (): string | undefined => { - if (quoteRef && !quoteRefUsed) { - quoteRefUsed = true; - return quoteRef; - } - return undefined; - }; - - let replyText = blockReplyTextForDelivery(payload); - const deliverEvent = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }; - const deliverActx = { account, qualifiedTarget, log, ...gatewayMediaContext }; - - // 1. Media tags - const mediaResult = await parseAndSendMediaTags( - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - deliverDeps, - ); - if (mediaResult.handled) { - recordOutbound(); - return; - } - replyText = mediaResult.normalizedText; - - // 2. Structured payload (QQBOT_PAYLOAD:) - const handled = await handleStructuredPayload( - replyCtx, - replyText, - recordOutbound, - replyDeps, - ); - if (handled) { - return; - } - - // 3. Voice-intent plain text - if (payload.audioAsVoice === true && !payload.mediaUrl && !payload.mediaUrls?.length) { - const sentVoice = await sendTextAsVoiceReply(replyCtx, replyText, replyDeps); - if (sentVoice) { - recordOutbound(); - return; - } - } - - // 4. Plain text + images/media - await sendPlainReply( - payload, - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - toolMediaUrls, - deliverDeps, - ); - recordOutbound(); - }, - onError: async (err: unknown) => { - if (streamingController && !streamingController.isTerminalPhase) { - try { - await streamingController.onError(err); - } catch (streamErr) { - const streamErrMsg = - streamErr instanceof Error ? streamErr.message : String(streamErr); - log?.error(`Streaming onError failed: ${streamErrMsg}`); - } - if (!streamingController.shouldFallbackToStatic) { - return; - } - } - const errMsg = err instanceof Error ? err.message : String(err); - log?.error(`Dispatch error: ${errMsg}`); - hasResponse = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }, - }, - replyOptions: { - ...(event.turnAdoptionLifecycle - ? bindIngressLifecycleToReplyOptions(event.turnAdoptionLifecycle) - : {}), - disableBlockStreaming: useOfficialC2cStream - ? true - : account.config?.streaming?.mode === "off", - ...(streamingController - ? { - onPartialReply: async (payload: { text?: string }) => { - try { - return await streamingController.onPartialReply(payload); - } catch (partialErr) { - log?.error( - `Streaming onPartialReply error: ${partialErr instanceof Error ? partialErr.message : String(partialErr)}`, - ); - return false; - } - }, - } - : {}), - }, - }), - }, - }); - - try { - await Promise.race([dispatchPromise, timeoutPromise]); - } catch (error) { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (error === responseTimeoutError && event.turnAdoptionLifecycle) { - // The watchdog cannot cancel a live agent turn. Keep durable settlement with that turn; - // releasing here would let a retry overlap its later replies and adoption callback. - await dispatchPromise; - } else if (event.turnAdoptionLifecycle) { - throw error; - } - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - if ( - toolDeliverCount > 0 && - !hasBlockResponse && - !toolFallbackSent && - !skippedSilentBlockResponse - ) { - toolFallbackSent = true; - await sendToolFallback(); - } - await flushPendingMarkdownText(); - if (streamingController && !streamingController.isTerminalPhase) { - try { - streamingController.markFullyComplete(); - await streamingController.onIdle(); - } catch (finalizeErr) { - log?.error( - `Streaming finalization error: ${finalizeErr instanceof Error ? finalizeErr.message : String(finalizeErr)}`, - ); - try { - await streamingController.abortStreaming(); - } catch { - /* ignore */ - } - } - } - } -} - -// ============ ctxPayload builder ============ - -function resolveCommandSource( - inbound: InboundContext, - runtime: GatewayPluginRuntime, - cfg: unknown, -): "text" | undefined { - const commandBody = inbound.event.content; - if (!runtime.channel.commands?.isControlCommandMessage?.(commandBody, cfg)) { - return undefined; - } - return "text"; -} - -async function buildCtxPayload( - inbound: InboundContext, - runtime: GatewayPluginRuntime, - cfg: unknown, -): Promise { - const { event } = inbound; - const commandSource = resolveCommandSource(inbound, runtime, cfg); - // QQ inbound attachments are images only; remote URLs carry no MIME, so the - // explicit kind keeps image/vision gates from classifying them as generic files. - const imageMedia = [ - ...inbound.localMediaPaths.map((path, index) => ({ - path, - contentType: inbound.localMediaTypes[index], - kind: "image" as const, - })), - ...inbound.remoteMediaUrls.map((url) => ({ url, kind: "image" as const })), - ]; - return buildChannelInboundEventContext({ - channel: "qqbot", - accountId: inbound.route.accountId, - messageId: event.messageId, - timestamp: new Date(event.timestamp).getTime(), - from: inbound.fromAddress, - sender: { - id: event.senderId, - name: event.senderName, - }, - conversation: { - kind: inbound.isGroupChat ? "group" : "direct", - id: inbound.peerId, - }, - route: { - agentId: inbound.route.agentId ?? resolveDefaultAgentId(cfg as OpenClawConfig), - dmScope: inbound.route.dmScope, - routeSessionKey: inbound.route.sessionKey, - accountId: inbound.route.accountId, - }, - reply: { - to: inbound.fromAddress, - }, - message: { - body: inbound.body, - bodyForAgent: inbound.agentBody, - rawBody: event.content, - commandBody: event.content, - }, - access: { - commands: { - authorized: inbound.commandAuthorized, - }, - }, - command: commandSource - ? { - kind: "text-slash", - body: event.content, - authorized: inbound.commandAuthorized, - } - : undefined, - media: - imageMedia.length > 0 - ? imageMedia - : inbound.voiceMediaTypes.map((contentType) => ({ contentType })), - supplemental: { - quote: inbound.replyTo - ? { - id: inbound.replyTo.id, - body: inbound.replyTo.body, - sender: inbound.replyTo.sender, - isQuote: inbound.replyTo.isQuote, - } - : undefined, - groupSystemPrompt: inbound.groupSystemPrompt, - }, - extra: { - QQChannelId: event.channelId, - QQGuildId: event.guildId, - QQGroupOpenid: event.groupOpenid, - QQVoiceAsrReferAvailable: inbound.hasAsrReferFallback, - QQVoiceTranscriptSources: inbound.voiceTranscriptSources, - QQVoiceAttachmentPaths: inbound.uniqueVoicePaths, - QQVoiceAttachmentUrls: inbound.uniqueVoiceUrls, - QQVoiceAsrReferTexts: inbound.uniqueVoiceAsrReferTexts, - QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback", - ...(commandSource ? { CommandSource: commandSource } : {}), - }, - }); -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qqbot/src/engine/gateway/reconnect.ts b/extensions/qqbot/src/engine/gateway/reconnect.ts deleted file mode 100644 index 56be1aea2c64..000000000000 --- a/extensions/qqbot/src/engine/gateway/reconnect.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * WebSocket reconnection state machine and close-code handler. - * - * Encapsulates the reconnect delay scheduling, quick-disconnect detection, - * and close-code interpretation that both plugin versions share. - * - * Zero external dependencies — uses only the constants from `./constants.ts`. - */ - -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import type { EngineLogger } from "../types.js"; -import { - RECONNECT_DELAYS, - RATE_LIMIT_DELAY, - MAX_RECONNECT_ATTEMPTS, - MAX_QUICK_DISCONNECT_COUNT, - QUICK_DISCONNECT_THRESHOLD, - GatewayCloseCode, -} from "./constants.js"; - -/** Actions the caller should take after processing a close event. */ -interface CloseAction { - /** Whether to schedule a reconnect. */ - shouldReconnect: boolean; - /** Custom delay override (ms), or undefined to use the default backoff. */ - reconnectDelay?: number; - /** Whether the session is invalidated and should be cleared. */ - clearSession: boolean; - /** Whether the token should be refreshed before reconnecting. */ - refreshToken: boolean; - /** Whether the bot is fatally blocked (offline/banned) and should stop. */ - fatal: boolean; - /** Human-readable description of the close reason. */ - reason: string; -} - -/** - * Reconnection state machine. - * - * Usage: - * ```ts - * const rs = new ReconnectState('account-1', log); - * // On successful connect: - * rs.onConnected(); - * // On close: - * const action = rs.handleClose(code); - * if (action.shouldReconnect) { - * const delay = rs.getNextDelay(action.reconnectDelay); - * setTimeout(connect, delay); - * } - * ``` - */ -export class ReconnectState { - private attempts = 0; - private lastConnectTime = 0; - private quickDisconnectCount = 0; - - constructor( - private readonly accountId: string, - private readonly log?: EngineLogger, - ) {} - - /** Call when a WebSocket connection is successfully established. */ - onConnected(): void { - this.attempts = 0; - this.lastConnectTime = Date.now(); - } - - /** Whether reconnection attempts are exhausted. */ - isExhausted(): boolean { - return this.attempts >= MAX_RECONNECT_ATTEMPTS; - } - - /** - * Compute the next reconnect delay and increment the attempt counter. - * - * @param customDelay Override from `CloseAction.reconnectDelay`. - * @returns Delay in milliseconds. - */ - getNextDelay(customDelay?: number): number { - const delay = - customDelay ?? - expectDefined( - RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)], - "non-empty reconnect delay schedule", - ); - this.attempts++; - this.log?.debug?.(`Reconnecting ${this.accountId} in ${delay}ms (attempt ${this.attempts})`); - return delay; - } - - /** - * Interpret a WebSocket close code and return the appropriate action. - */ - handleClose(code: number, isAborted: boolean): CloseAction { - // Fatal: bot offline or banned. - if ( - code === GatewayCloseCode.INSUFFICIENT_INTENTS || - code === GatewayCloseCode.DISALLOWED_INTENTS - ) { - const reason = - code === GatewayCloseCode.INSUFFICIENT_INTENTS ? "offline/sandbox-only" : "banned"; - this.log?.error(`Bot is ${reason}. Please contact QQ platform.`); - return { - shouldReconnect: false, - clearSession: false, - refreshToken: false, - fatal: true, - reason, - }; - } - - // Invalid token. - if (code === GatewayCloseCode.AUTH_FAILED) { - this.log?.info(`Invalid token (4004), will refresh token and reconnect`); - return { - shouldReconnect: !isAborted, - clearSession: false, - refreshToken: true, - fatal: false, - reason: "invalid token (4004)", - }; - } - - // Rate limited. - if (code === GatewayCloseCode.RATE_LIMITED) { - this.log?.info(`Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms`); - return { - shouldReconnect: !isAborted, - reconnectDelay: RATE_LIMIT_DELAY, - clearSession: false, - refreshToken: false, - fatal: false, - reason: "rate limited (4008)", - }; - } - - // Session invalid / seq invalid / session timeout. - if ( - code === GatewayCloseCode.INVALID_SESSION || - code === GatewayCloseCode.SEQ_OUT_OF_RANGE || - code === GatewayCloseCode.SESSION_TIMEOUT - ) { - const codeDesc: Record = { - [GatewayCloseCode.INVALID_SESSION]: "session no longer valid", - [GatewayCloseCode.SEQ_OUT_OF_RANGE]: "invalid seq on resume", - [GatewayCloseCode.SESSION_TIMEOUT]: "session timed out", - }; - const reason = expectDefined(codeDesc[code], "recognized session close code"); - this.log?.info(`Error ${code} (${reason}), will re-identify`); - return { - shouldReconnect: !isAborted, - clearSession: true, - refreshToken: true, - fatal: false, - reason, - }; - } - - // Internal server errors. - if (code >= GatewayCloseCode.SERVER_ERROR_START && code <= GatewayCloseCode.SERVER_ERROR_END) { - this.log?.info(`Internal error (${code}), will re-identify`); - return { - shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL, - clearSession: true, - refreshToken: true, - fatal: false, - reason: `internal error (${code})`, - }; - } - - // Quick disconnect detection. - const connectionDuration = Date.now() - this.lastConnectTime; - if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && this.lastConnectTime > 0) { - this.quickDisconnectCount++; - this.log?.debug?.( - `Quick disconnect detected (${connectionDuration}ms), count: ${this.quickDisconnectCount}`, - ); - - if (this.quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { - this.log?.error(`Too many quick disconnects. This may indicate a permission issue.`); - this.quickDisconnectCount = 0; - return { - shouldReconnect: !isAborted && code !== 1000, - reconnectDelay: RATE_LIMIT_DELAY, - clearSession: false, - refreshToken: false, - fatal: false, - reason: "too many quick disconnects", - }; - } - } else { - this.quickDisconnectCount = 0; - } - - // Default: reconnect with backoff. - return { - shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL, - clearSession: false, - refreshToken: false, - fatal: false, - reason: `close code ${code}`, - }; - } -} diff --git a/extensions/qqbot/src/engine/gateway/response-timeout.test.ts b/extensions/qqbot/src/engine/gateway/response-timeout.test.ts deleted file mode 100644 index 03ad6927fa84..000000000000 --- a/extensions/qqbot/src/engine/gateway/response-timeout.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Qqbot tests cover response timeout plugin behavior. -import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { describe, expect, it } from "vitest"; -import { resolveResponseTimeoutMs } from "./response-timeout.js"; - -const DEFAULT_RESPONSE_TIMEOUT_MS = 300_000; - -describe("resolveResponseTimeoutMs", () => { - it("falls back to the historical 5-minute floor when no timeouts configured", () => { - expect(resolveResponseTimeoutMs({})).toBe(DEFAULT_RESPONSE_TIMEOUT_MS); - expect(resolveResponseTimeoutMs(undefined)).toBe(DEFAULT_RESPONSE_TIMEOUT_MS); - expect(resolveResponseTimeoutMs(null)).toBe(DEFAULT_RESPONSE_TIMEOUT_MS); - }); - - it("honors longer agents.defaults.timeoutSeconds", () => { - expect(resolveResponseTimeoutMs({ agents: { defaults: { timeoutSeconds: 900 } } })).toBe( - 900_000, - ); - }); - - it("ignores agents.defaults.timeoutSeconds shorter than the historical floor", () => { - // Issue #85267: a configured 60s agent timeout must not undercut the - // historical 5-minute watchdog floor for previously-working setups. - expect(resolveResponseTimeoutMs({ agents: { defaults: { timeoutSeconds: 60 } } })).toBe( - DEFAULT_RESPONSE_TIMEOUT_MS, - ); - }); - - it("honors models.providers..timeoutSeconds for slow local providers (#85267)", () => { - // Direct repro shape: ollama + qwen3.5:27b with 1800s timeout. Without - // this fix, QQBot capped at 300s and surfaced "LLM request timed out". - expect( - resolveResponseTimeoutMs({ - models: { providers: { ollama: { timeoutSeconds: 1800 } } }, - }), - ).toBe(1_800_000); - }); - - it("takes the maximum across multiple configured providers and agents", () => { - expect( - resolveResponseTimeoutMs({ - agents: { defaults: { timeoutSeconds: 600 } }, - models: { - providers: { - ollama: { timeoutSeconds: 1800 }, - "lm-studio": { timeoutSeconds: 900 }, - openai: { timeoutSeconds: 60 }, - }, - }, - }), - ).toBe(1_800_000); - }); - - it("ignores non-positive or non-numeric timeout values", () => { - expect( - resolveResponseTimeoutMs({ - agents: { defaults: { timeoutSeconds: -1 } }, - models: { - providers: { - ollama: { timeoutSeconds: 0 }, - broken: { timeoutSeconds: "1800" as unknown as number }, - naN: { timeoutSeconds: Number.NaN }, - }, - }, - }), - ).toBe(DEFAULT_RESPONSE_TIMEOUT_MS); - }); - - it("clamps to MAX_TIMER_TIMEOUT_MS for absurd inputs", () => { - const huge = resolveResponseTimeoutMs({ - models: { providers: { ollama: { timeoutSeconds: 10_000_000 } } }, - }); - expect(huge).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS); - expect(huge).toBeGreaterThan(DEFAULT_RESPONSE_TIMEOUT_MS); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/response-timeout.ts b/extensions/qqbot/src/engine/gateway/response-timeout.ts deleted file mode 100644 index b2524d035eaa..000000000000 --- a/extensions/qqbot/src/engine/gateway/response-timeout.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Qqbot plugin module implements response timeout behavior. -import { - finiteSecondsToTimerSafeMilliseconds, - MAX_TIMER_TIMEOUT_MS, -} from "openclaw/plugin-sdk/number-runtime"; - -/** - * QQBot outbound response watchdog timeout resolver. - * - * Background — issue #85267: - * The reporter ran openclaw + ollama + `qwen3.5:27b` (a slow local model) - * with `models.providers.ollama.timeoutSeconds: 1800` and saw the - * QQBot reply path abort at ~5 minutes with "LLM request timed out", - * despite the direct ollama call to the same model working. The - * embedded-runner / idle-timeout layer already honors longer - * provider timeouts (see `src/agents/embedded-agent-runner/run/llm-idle-timeout.ts`), - * but the QQBot outbound dispatcher held an independent hardcoded - * `RESPONSE_TIMEOUT = 300_000` watchdog that quietly undercut the - * configured ceiling. - * - * Fix shape (clawsweeper `clawsweeper:fix-shape-clear`): - * Don't add a new QQBot-only knob. Instead derive the QQBot wait - * budget from the existing agent/provider timeout settings the user - * already configured: - * - `agents.defaults.timeoutSeconds` - * - `models.providers..timeoutSeconds` (max across configured providers) - * Take the maximum and clamp to `[DEFAULT_RESPONSE_TIMEOUT_MS, MAX_TIMER_TIMEOUT_MS]`. - * The default floor preserves the existing 5-minute guard for users - * that have not configured any longer ceiling — i.e. a no-op for - * typical cloud-model deployments. - */ - -/** - * Default QQBot outbound response watchdog when no config override is - * present. Preserves the historical 5-minute guard for unconfigured - * deployments. - */ -const DEFAULT_RESPONSE_TIMEOUT_MS = 300_000; - -interface AgentsDefaultsLike { - timeoutSeconds?: unknown; -} - -interface AgentsBlockLike { - defaults?: AgentsDefaultsLike; -} - -interface ProviderEntryLike { - timeoutSeconds?: unknown; -} - -interface ModelsBlockLike { - providers?: Record | undefined; -} - -interface CfgShape { - agents?: AgentsBlockLike; - models?: ModelsBlockLike; -} - -/** - * Resolve the QQBot outbound response watchdog (ms). - * - * The watchdog is the longest of: - * - `DEFAULT_RESPONSE_TIMEOUT_MS` (5 min, historical floor) - * - `cfg.agents.defaults.timeoutSeconds` converted to ms - * - the maximum `cfg.models.providers..timeoutSeconds` across - * configured providers, converted to ms - * - * Returns at most `MAX_TIMER_TIMEOUT_MS` so the chosen value is always - * a safe `setTimeout` argument. - */ -export function resolveResponseTimeoutMs(cfg: unknown): number { - const candidates: number[] = [DEFAULT_RESPONSE_TIMEOUT_MS]; - - const typed = (cfg ?? {}) as CfgShape; - - const agentDefaultMs = finiteSecondsToTimerSafeMilliseconds( - typed.agents?.defaults?.timeoutSeconds, - ); - if (agentDefaultMs !== undefined) { - candidates.push(agentDefaultMs); - } - - const providers = typed.models?.providers; - if (providers && typeof providers === "object") { - for (const entry of Object.values(providers)) { - const providerMs = finiteSecondsToTimerSafeMilliseconds(entry?.timeoutSeconds); - if (providerMs !== undefined) { - candidates.push(providerMs); - } - } - } - - const chosen = Math.max(...candidates); - return Math.min(chosen, MAX_TIMER_TIMEOUT_MS); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts b/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts deleted file mode 100644 index c3aeedcd7298..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/access-stage.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * Regression test for issue #69546. - * - * The access stage must resolve the agent route against whatever - * `cfg` is passed in on each call, not against a snapshot captured - * once. This test simulates a binding update between two consecutive - * inbound events and asserts the second route reflects the new - * `bindings[]`. - */ - -import { describe, expect, it, vi } from "vitest"; -import type { QQBotInboundAccess } from "../../adapter/index.js"; -import type { InboundPipelineDeps } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; -import type { GatewayAccount, GatewayPluginRuntime } from "../types.js"; -import { runAccessStage } from "./access-stage.js"; - -interface StubBinding { - match: { channel: string; accountPattern: string; peer?: string }; - agentId: string; -} - -interface StubCfg { - bindings: StubBinding[]; -} - -function buildAccount(overrides: Partial = {}): GatewayAccount { - return { - accountId: "study", - appId: "1000000", - clientSecret: "secret", - markdownSupport: false, - config: { - dmPolicy: "open", - groupPolicy: "open", - ...overrides, - }, - }; -} - -function buildEvent(senderId: string): QueuedMessage { - return { - type: "c2c", - senderId, - content: "hi", - messageId: `m-${senderId}`, - timestamp: "0", - }; -} - -function buildRuntime( - resolve: GatewayPluginRuntime["channel"]["routing"]["resolveAgentRoute"], -): GatewayPluginRuntime { - return { - state: { - openChannelIngressQueue: () => { - throw new Error("unexpected durable ingress access"); - }, - }, - channel: { - activity: { record: vi.fn() }, - routing: { resolveAgentRoute: resolve }, - reply: { - dispatchReplyWithBufferedBlockDispatcher: vi.fn(), - resolveEffectiveMessagesConfig: vi.fn(() => ({})), - finalizeInboundContext: vi.fn(), - formatInboundEnvelope: vi.fn(() => ""), - resolveEnvelopeFormatOptions: vi.fn(() => ({})), - }, - session: { - resolveStorePath: vi.fn(() => ""), - recordInboundSession: vi.fn(async () => undefined), - }, - inbound: { run: vi.fn(async () => undefined) }, - text: { chunkMarkdownText: vi.fn(() => []) }, - }, - tts: { textToSpeech: vi.fn() }, - }; -} - -function buildAllowAccess(): QQBotInboundAccess { - return { - senderAccess: { decision: "allow" }, - } as unknown as QQBotInboundAccess; -} - -function buildDeps( - cfg: StubCfg, - runtime: GatewayPluginRuntime, - account: GatewayAccount, -): InboundPipelineDeps { - return { - account, - cfg: cfg as InboundPipelineDeps["cfg"], - runtime, - startTyping: vi.fn(), - adapters: { - access: { - resolveInboundAccess: vi.fn(() => buildAllowAccess()), - resolveSlashCommandAuthorization: vi.fn(() => true), - }, - } as unknown as InboundPipelineDeps["adapters"], - }; -} - -describe("runAccessStage — dynamic cfg routing (#69546)", () => { - it("re-evaluates resolveAgentRoute against the cfg supplied on each call", async () => { - const account = buildAccount(); - const peerId = "480562E9913A985D4A79822A643E27B6"; - - const accountOnly: StubCfg = { - bindings: [{ match: { channel: "qqbot", accountPattern: "study" }, agentId: "study" }], - }; - const withPeer: StubCfg = { - bindings: [ - { - match: { channel: "qqbot", accountPattern: "study", peer: `direct:${peerId}` }, - agentId: "tutor", - }, - { match: { channel: "qqbot", accountPattern: "study" }, agentId: "study" }, - ], - }; - - const captured: Array<{ cfg: unknown; peerId: string }> = []; - const runtime = buildRuntime((params) => { - const cfg = params.cfg as StubCfg; - captured.push({ cfg, peerId: params.peer.id }); - const exact = cfg.bindings.find((b) => b.match.peer === `direct:${params.peer.id}`); - const fallback = cfg.bindings.find((b) => !b.match.peer); - const agent = exact?.agentId ?? fallback?.agentId; - return { sessionKey: `qqbot:${params.peer.id}`, accountId: params.accountId, agentId: agent }; - }); - - const event = buildEvent(peerId); - - const first = await runAccessStage(event, buildDeps(accountOnly, runtime, account)); - expect(first.kind).toBe("allow"); - if (first.kind === "allow") { - expect(first.route.agentId).toBe("study"); - } - - const second = await runAccessStage(event, buildDeps(withPeer, runtime, account)); - expect(second.kind).toBe("allow"); - if (second.kind === "allow") { - expect(second.route.agentId).toBe("tutor"); - } - - expect(captured).toHaveLength(2); - expect(captured[0]?.cfg).toBe(accountOnly); - expect(captured[1]?.cfg).toBe(withPeer); - }); - - it("never reads bindings from a previous cfg reference", async () => { - const account = buildAccount(); - const seenCfgs = new Set(); - const runtime = buildRuntime((params) => { - seenCfgs.add(params.cfg); - return { sessionKey: `s:${params.peer.id}`, accountId: params.accountId }; - }); - - const cfgA: StubCfg = { bindings: [] }; - const cfgB: StubCfg = { bindings: [] }; - const cfgC: StubCfg = { bindings: [] }; - - await runAccessStage(buildEvent("a"), buildDeps(cfgA, runtime, account)); - await runAccessStage(buildEvent("b"), buildDeps(cfgB, runtime, account)); - await runAccessStage(buildEvent("c"), buildDeps(cfgC, runtime, account)); - - expect(seenCfgs.size).toBe(3); - expect(seenCfgs.has(cfgA)).toBe(true); - expect(seenCfgs.has(cfgB)).toBe(true); - expect(seenCfgs.has(cfgC)).toBe(true); - }); - - it.each([ - ["group", true, "group", "GROUP_OPENID", "qqbot:group:GROUP_OPENID"], - ["guild", true, "group", "CHANNEL_ID", "qqbot:channel:CHANNEL_ID"], - ["c2c", false, "direct", "user-openid", "qqbot:c2c:user-openid"], - ["dm", false, "direct", "user-openid", "qqbot:dm:DM_GUILD_ID"], - ] as const)( - "classifies %s route persistence facts", - async (type, isGroupChat, peerKind, peerId, qualifiedTarget) => { - const account = buildAccount(); - let routedPeer: { kind: string; id: string } | undefined; - const runtime = buildRuntime((params) => { - routedPeer = params.peer; - return { sessionKey: `s:${params.peer.id}`, accountId: params.accountId }; - }); - const event = { - type, - senderId: "user-openid", - content: "hi", - messageId: `m-${type}`, - timestamp: "0", - ...(type === "group" ? { groupOpenid: "GROUP_OPENID" } : {}), - ...(type === "guild" ? { channelId: "CHANNEL_ID", guildId: "GUILD_ID" } : {}), - ...(type === "dm" ? { guildId: "DM_GUILD_ID" } : {}), - } as QueuedMessage; - - const result = await runAccessStage(event, buildDeps({ bindings: [] }, runtime, account)); - - expect(result.kind).toBe("allow"); - if (result.kind === "allow") { - expect(result.isGroupChat).toBe(isGroupChat); - expect(result.peerId).toBe(peerId); - expect(result.qualifiedTarget).toBe(qualifiedTarget); - } - expect(routedPeer).toEqual({ kind: peerKind, id: peerId }); - }, - ); -}); diff --git a/extensions/qqbot/src/engine/gateway/stages/access-stage.ts b/extensions/qqbot/src/engine/gateway/stages/access-stage.ts deleted file mode 100644 index 07a7b1d04fb3..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/access-stage.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Qqbot plugin module implements access stage behavior. -import type { QQBotInboundAccess } from "../../adapter/index.js"; -import type { InboundContext, InboundPipelineDeps } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; -import { buildBlockedInboundContext } from "./stub-contexts.js"; - -type AccessStageResult = - | { - kind: "allow"; - isGroupChat: boolean; - peerId: string; - qualifiedTarget: string; - fromAddress: string; - route: InboundContext["route"]; - access: QQBotInboundAccess; - } - | { kind: "block"; context: InboundContext }; - -export async function runAccessStage( - event: QueuedMessage, - deps: InboundPipelineDeps, -): Promise { - const { account, cfg, runtime, log } = deps; - - const isGroupChat = event.type === "guild" || event.type === "group"; - const peerId = resolvePeerId(event, isGroupChat); - const qualifiedTarget = buildQualifiedTarget(event, isGroupChat); - - const route = runtime.channel.routing.resolveAgentRoute({ - cfg, - channel: "qqbot", - accountId: account.accountId, - peer: { kind: isGroupChat ? "group" : "direct", id: peerId }, - }); - - const access = await deps.adapters.access.resolveInboundAccess({ - cfg, - accountId: account.accountId, - isGroup: isGroupChat, - senderId: event.senderId, - conversationId: peerId, - allowFrom: account.config?.allowFrom, - groupAllowFrom: account.config?.groupAllowFrom, - dmPolicy: account.config?.dmPolicy, - groupPolicy: account.config?.groupPolicy, - }); - - if (access.senderAccess.decision !== "allow") { - log?.info( - `Blocked qqbot inbound: decision=${access.senderAccess.decision} reasonCode=${access.senderAccess.reasonCode} ` + - `senderId=${event.senderId} accountId=${account.accountId} isGroup=${isGroupChat}`, - ); - return { - kind: "block", - context: buildBlockedInboundContext({ - event, - route, - isGroupChat, - peerId, - qualifiedTarget, - fromAddress: qualifiedTarget, - access, - }), - }; - } - - return { - kind: "allow", - isGroupChat, - peerId, - qualifiedTarget, - fromAddress: qualifiedTarget, - route, - access, - }; -} - -// ─────────────────────────── Internal helpers ─────────────────────────── - -function resolvePeerId(event: QueuedMessage, isGroupChat: boolean): string { - if (event.type === "guild") { - return event.channelId ?? "unknown"; - } - if (event.type === "group") { - return event.groupOpenid ?? "unknown"; - } - if (isGroupChat) { - return "unknown"; - } - return event.senderId; -} - -function buildQualifiedTarget(event: QueuedMessage, isGroupChat: boolean): string { - if (isGroupChat) { - return event.type === "guild" - ? `qqbot:channel:${event.channelId}` - : `qqbot:group:${event.groupOpenid}`; - } - return event.type === "dm" ? `qqbot:dm:${event.guildId}` : `qqbot:c2c:${event.senderId}`; -} diff --git a/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts b/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts deleted file mode 100644 index ca06923d1a96..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/assembly-stage.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Assembly stage — build the user-turn string the AI sees. - * - * Responsible for: - * - Rendering merged turns (preceding messages in a begin/end block - * + a "current" message). - * - Attaching the sender label + (@you) suffix for group chat. - * - Prepending the group's buffered history via - * {@link buildPendingHistoryContext} when the current turn is - * `@`-activated. - * - Handing out the plain `agentBody` for DM-style turns. - * - * The envelope rendering (Web UI body + dynamic ctx block) lives in - * `envelope-stage.ts`; this stage only produces text that the model - * sees directly. - */ - -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, - type EnvelopeFormatOptions, -} from "openclaw/plugin-sdk/channel-inbound"; -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import { - buildMergedMessageContext, - formatAttachmentTags, - formatMessageContent, - type HistoryEntry, -} from "../../group/history.js"; -import type { InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; - -// ─────────────────────────── buildUserMessage ─────────────────────────── - -interface BuildUserMessageInput { - event: QueuedMessage; - userContent: string; - quotePart: string; - isGroupChat: boolean; - groupInfo?: InboundGroupInfo; -} - -/** - * Compose the user-turn string. For merged group turns, renders a - * preceding block and a current-message suffix; for single turns, - * prefixes the sender label and (@you) suffix as appropriate. - */ -export function buildUserMessage(input: BuildUserMessageInput): string { - const { event, userContent, quotePart, isGroupChat, groupInfo } = input; - - // ---- Merged group turn ---- - if (groupInfo?.isMerged && groupInfo.mergedMessages?.length) { - const preceding = groupInfo.mergedMessages.slice(0, -1); - const lastMsg = expectDefined(groupInfo.mergedMessages.at(-1), "non-empty merged group turn"); - const atYouTag = groupInfo.gate.effectiveWasMentioned ? " (@you)" : ""; - - const envelopeParts = preceding.map((m) => `[${formatSenderLabel(m)}] ${formatSub(m)}`); - const lastPart = `[${formatSenderLabel(lastMsg)}] ${formatSub(lastMsg)}${atYouTag}`; - - return buildMergedMessageContext({ - precedingParts: envelopeParts, - currentMessage: lastPart, - }); - } - - // ---- Single-message turn ---- - const isAtYouTag = isGroupChat ? (groupInfo?.gate.effectiveWasMentioned ? " (@you)" : "") : ""; - const senderPrefix = - event.type === "group" ? `[${formatSenderLabelFrom(event.senderName, event.senderId)}] ` : ""; - - return senderPrefix - ? `${senderPrefix}${quotePart}${userContent}${isAtYouTag}` - : `${quotePart}${userContent}`; -} - -// ─────────────────────────── buildAgentBody ─────────────────────────── - -interface BuildAgentBodyInput { - event: QueuedMessage; - userContent: string; - userMessage: string; - dynamicCtx: string; - isGroupChat: boolean; - groupInfo?: InboundGroupInfo; - deps: InboundPipelineDeps; -} - -/** - * Compose the final `agentBody` the AI receives. - * - * Prepends buffered non-@ chatter via - * {@link buildPendingHistoryContext} when the current turn is - * `@`-activated in a group. Slash-commands bypass all decoration so - * the command parser sees verbatim input. - */ -export function buildAgentBody(input: BuildAgentBodyInput): string { - const { event, userContent, userMessage, dynamicCtx, groupInfo, deps } = input; - - // Slash commands: strip all decoration so the command parser sees raw input. - if (userContent.startsWith("/")) { - return userContent; - } - - const base = `${dynamicCtx}${userMessage}`; - - // Non-group or group-without-history: no mixing in. - if (event.type !== "group" || !event.groupOpenid || !deps.groupHistories || !groupInfo) { - return base; - } - - const envelopeOpts = resolveEnvelopeFormatOptions(deps.cfg); - return deps.adapters.history.buildPendingHistoryContext({ - historyMap: deps.groupHistories, - historyKey: event.groupOpenid, - limit: groupInfo.historyLimit, - currentMessage: base, - formatEntry: (entry) => formatHistoryEntry(entry as HistoryEntry, envelopeOpts), - }); -} - -// ─────────────────────────── Internal ─────────────────────────── - -function formatSub(m: QueuedMessage): string { - return formatMessageContent({ - content: m.content ?? "", - chatType: m.type, - mentions: m.mentions as never, - attachments: m.attachments, - }); -} - -function formatSenderLabel(m: QueuedMessage): string { - return formatSenderLabelFrom(m.senderName, m.senderId); -} - -/** - * Render a "Nick (openid)" label. When `name` already includes `id` - * (e.g. the label was pre-formatted upstream), avoid double-wrapping. - */ -function formatSenderLabelFrom(name: string | undefined, id: string): string { - if (!name) { - return id; - } - return name.includes(id) ? name : `${name} (${id})`; -} - -function formatHistoryEntry(entry: HistoryEntry, envelopeOpts: unknown): string { - const attachmentDesc = formatAttachmentTags(entry.attachments); - const bodyWithAttachments = attachmentDesc ? `${entry.body} ${attachmentDesc}` : entry.body; - return formatInboundEnvelope({ - channel: "qqbot", - from: entry.sender, - timestamp: entry.timestamp, - body: bodyWithAttachments, - chatType: "group", - envelope: envelopeOpts as EnvelopeFormatOptions, - }); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/content-stage.test.ts b/extensions/qqbot/src/engine/gateway/stages/content-stage.test.ts deleted file mode 100644 index 6ffbf74a7447..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/content-stage.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Qqbot tests cover content stage plugin behavior. -import { describe, expect, it } from "vitest"; -import type { QueuedMessage } from "../message-queue.js"; -import { buildUserContent } from "./content-stage.js"; - -function makeEvent(partial: Partial = {}): QueuedMessage { - return { - type: "group", - senderId: "U1", - content: "hello", - messageId: "M1", - timestamp: "2025-01-01T00:00:00.000Z", - groupOpenid: "G1", - ...partial, - }; -} - -describe("content-stage", () => { - describe("buildUserContent", () => { - it("returns plain content when no voice / no mentions", () => { - const out = buildUserContent({ - event: makeEvent({ content: "plain" }), - attachmentInfo: "", - voiceTranscripts: [], - }); - expect(out.parsedContent).toBe("plain"); - expect(out.userContent).toBe("plain"); - }); - - it("appends attachmentInfo after content", () => { - const out = buildUserContent({ - event: makeEvent({ content: "see" }), - attachmentInfo: " [img]", - voiceTranscripts: [], - }); - expect(out.userContent).toBe("see [img]"); - }); - - it("interleaves voice transcripts on their own line", () => { - const out = buildUserContent({ - event: makeEvent({ content: "hi" }), - attachmentInfo: "", - voiceTranscripts: ["hello world"], - }); - // formatVoiceText renders "[Voice message] …" or "[Voice N] …" — the - // important assertion is that voice text ends up in userContent. - expect(out.userContent).toContain("hi"); - expect(out.userContent).toContain("hello world"); - expect(out.userContent.length).toBeGreaterThan(out.parsedContent.length); - }); - - it("strips <@bot> mention tags in group chats", () => { - const out = buildUserContent({ - event: makeEvent({ - type: "group", - content: "<@BOT> help", - mentions: [{ member_openid: "BOT", is_you: true }], - }), - attachmentInfo: "", - voiceTranscripts: [], - }); - expect(out.userContent.trim()).toBe("help"); - }); - - it("replaces <@user> with @nickname in DMs", () => { - const out = buildUserContent({ - event: makeEvent({ - type: "c2c", - content: "hi <@U2> there", - mentions: [{ member_openid: "U2", username: "Alice" }], - }), - attachmentInfo: "", - voiceTranscripts: [], - }); - expect(out.userContent).toBe("hi @Alice there"); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/stages/content-stage.ts b/extensions/qqbot/src/engine/gateway/stages/content-stage.ts deleted file mode 100644 index 34fecf2dbed6..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/content-stage.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Content stage — build the user-visible message body. - * - * Responsible for: - * 1. Parsing QQ emoji tags (`` → `[Emoji: name]`) - * 2. Appending attachment info + voice transcripts - * 3. Stripping `<@openid>` mention tags in group messages - * 4. Replacing `<@openid>` → `@nickname` in DMs (best-effort) - * - * Pure function: same input → same output, no I/O. - */ - -import { stripMentionText } from "../../group/mention.js"; -import { parseFaceTags } from "../../utils/text-parsing.js"; -import { formatVoiceText } from "../../utils/voice-text.js"; -import type { QueuedMention, QueuedMessage } from "../message-queue.js"; - -// ─────────────────────────── Types ─────────────────────────── - -/** Input for {@link buildUserContent}. */ -interface ContentStageInput { - event: QueuedMessage; - /** `attachmentInfo` from the attachment stage — appended verbatim. */ - attachmentInfo: string; - /** Voice transcripts collected from the attachment stage. */ - voiceTranscripts: string[]; -} - -/** Output of {@link buildUserContent}. */ -interface ContentStageOutput { - /** `parseFaceTags(event.content)`. */ - parsedContent: string; - /** Full user-visible content (parsed + voice + attachments + mention cleanup). */ - userContent: string; -} - -// ─────────────────────────── Stage ─────────────────────────── - -/** - * Build both the raw-parsed content and the fully composed user-visible - * body that downstream stages feed to the AI and to the envelope. - */ -export function buildUserContent(input: ContentStageInput): ContentStageOutput { - const { event, attachmentInfo, voiceTranscripts } = input; - - const parsedContent = parseFaceTags(event.content); - const voiceText = formatVoiceText(voiceTranscripts); - - let userContent = voiceText - ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo - : parsedContent + attachmentInfo; - - // Mention cleanup — only for events with mentions attached. - if (event.type === "group" && event.mentions?.length) { - userContent = stripMentionText(userContent, event.mentions as never) ?? userContent; - } else if (event.mentions?.length) { - userContent = replaceMentionsWithNicknames(userContent, event.mentions); - } - - return { parsedContent, userContent }; -} - -// ─────────────────────────── Internal ─────────────────────────── - -function replaceMentionsWithNicknames(text: string, mentions: QueuedMention[]): string { - let out = text; - for (const m of mentions) { - if (m.member_openid && m.username) { - out = out.replace(new RegExp(`<@${escapeRegex(m.member_openid)}>`, "g"), `@${m.username}`); - } - } - return out; -} - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.test.ts b/extensions/qqbot/src/engine/gateway/stages/envelope-stage.test.ts deleted file mode 100644 index e1a2248554a3..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Qqbot tests cover envelope stage plugin behavior. -import { describe, expect, it } from "vitest"; -import type { GroupMessageGateResult } from "../../group/message-gating.js"; -import type { ProcessedAttachments } from "../inbound-attachments.js"; -import type { InboundGroupInfo } from "../inbound-context.js"; -import { - buildDynamicCtx, - buildGroupSystemPrompt, - buildQuotePart, - classifyMedia, -} from "./envelope-stage.js"; - -function makeGate(): GroupMessageGateResult { - return { action: "pass", effectiveWasMentioned: true, shouldBypassMention: false }; -} - -function makeGroupInfo(partial: Partial = {}): InboundGroupInfo { - return { - gate: makeGate(), - activation: "mention", - commandLevel: "safety", - historyLimit: 50, - isMerged: false, - display: { - groupName: "G", - senderLabel: "S", - ...partial, - }, - }; -} - -describe("envelope-stage", () => { - describe("buildQuotePart", () => { - it("returns empty string when no replyTo", () => { - expect(buildQuotePart(undefined)).toBe(""); - }); - - it("wraps a quoted body in begin/end tags", () => { - const out = buildQuotePart({ id: "R1", body: "hello", isQuote: true }); - expect(out).toContain("[Quoted message begins]"); - expect(out).toContain("hello"); - expect(out).toContain("[Quoted message ends]"); - }); - - it("uses a fallback line when body is missing", () => { - const out = buildQuotePart({ id: "R1", isQuote: true }); - expect(out).toContain("Original content unavailable"); - }); - }); - - describe("buildDynamicCtx", () => { - it("returns empty string when every list is empty", () => { - expect( - buildDynamicCtx({ - imageUrls: [], - uniqueVoicePaths: [], - uniqueVoiceUrls: [], - uniqueVoiceAsrReferTexts: [], - }), - ).toBe(""); - }); - - it("renders images / voice / asr when present", () => { - const out = buildDynamicCtx({ - imageUrls: ["https://x/a.png", "https://x/b.png"], - uniqueVoicePaths: ["/tmp/v.wav"], - uniqueVoiceUrls: ["https://x/v.wav"], - uniqueVoiceAsrReferTexts: ["hi", "there"], - }); - expect(out).toContain("- Images: https://x/a.png, https://x/b.png"); - expect(out).toContain("- Voice: /tmp/v.wav, https://x/v.wav"); - expect(out).toContain("- ASR: hi | there"); - // Trailing blank line. - expect(out.endsWith("\n\n")).toBe(true); - }); - }); - - describe("buildGroupSystemPrompt", () => { - it("returns undefined when no prompts exist", () => { - expect(buildGroupSystemPrompt("", undefined)).toBeUndefined(); - }); - - it("joins accountSystemInstruction + introHint + behaviorPrompt", () => { - const out = buildGroupSystemPrompt( - "ACCOUNT", - makeGroupInfo({ introHint: "INTRO", behaviorPrompt: "BEHAVIOR" }), - ); - expect(out).toBe("ACCOUNT\nINTRO\nBEHAVIOR"); - }); - - it("skips undefined parts cleanly", () => { - const out = buildGroupSystemPrompt("", makeGroupInfo({ behaviorPrompt: "B" })); - expect(out).toBe("B"); - }); - }); - - describe("classifyMedia", () => { - const emptyProcessed: ProcessedAttachments = { - attachmentInfo: "", - imageUrls: [], - imageMediaTypes: [], - voiceAttachmentPaths: [], - voiceAttachmentUrls: [], - voiceAsrReferTexts: [], - voiceTranscripts: [], - voiceTranscriptSources: [], - attachmentLocalPaths: [], - }; - - it("separates local from remote image URLs", () => { - const out = classifyMedia({ - ...emptyProcessed, - imageUrls: ["/tmp/a.png", "https://x/b.png", "http://x/c.png"], - imageMediaTypes: ["image/png", "image/jpeg", "image/gif"], - }); - expect(out.localMediaPaths).toEqual(["/tmp/a.png"]); - expect(out.remoteMediaUrls).toEqual(["https://x/b.png", "http://x/c.png"]); - expect(out.remoteMediaTypes).toEqual(["image/jpeg", "image/gif"]); - }); - - it("defaults missing media type to image/png", () => { - // When `imageMediaTypes[i]` is undefined (shorter than imageUrls), - // the classifier substitutes a default. - const out = classifyMedia({ - ...emptyProcessed, - imageUrls: ["https://x/a.png"], - imageMediaTypes: [], - }); - expect(out.remoteMediaTypes).toEqual(["image/png"]); - }); - - it("dedupes voice paths and URLs", () => { - const out = classifyMedia({ - ...emptyProcessed, - voiceAttachmentPaths: ["/a", "/a", "/b"], - voiceAttachmentUrls: ["u1", "u1"], - voiceAsrReferTexts: ["x", "", "x"], - }); - expect(out.uniqueVoicePaths).toEqual(["/a", "/b"]); - expect(out.uniqueVoiceUrls).toEqual(["u1"]); - expect(out.uniqueVoiceAsrReferTexts).toEqual(["x"]); - }); - - it("flags ASR fallback when transcriptSources contains 'asr'", () => { - expect( - classifyMedia({ ...emptyProcessed, voiceTranscriptSources: ["stt", "asr"] }) - .hasAsrReferFallback, - ).toBe(true); - expect( - classifyMedia({ ...emptyProcessed, voiceTranscriptSources: ["stt"] }).hasAsrReferFallback, - ).toBe(false); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts b/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts deleted file mode 100644 index e180d47e2ee8..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Envelope stage — render the Web UI body, the dynamic-context block, - * the final group system prompt, and the media classification arrays. - * - * All logic here is presentation-layer glue: it combines fields built by - * earlier stages into the display-friendly strings the outbound - * dispatcher needs. No decisions / gating. - */ - -import { - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "openclaw/plugin-sdk/channel-inbound"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; -import type { ProcessedAttachments } from "../inbound-attachments.js"; -import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; - -// ─────────────────────────── Envelope body ─────────────────────────── - -interface BuildBodyInput { - event: QueuedMessage; - deps: InboundPipelineDeps; - userContent: string; - isGroupChat: boolean; - imageUrls: string[]; -} - -/** Format the inbound envelope (Web UI body). */ -export function buildBody(input: BuildBodyInput): string { - const { event, deps, userContent, isGroupChat, imageUrls } = input; - const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg as OpenClawConfig); - return formatInboundEnvelope({ - channel: "qqbot", - from: event.senderName ?? event.senderId, - timestamp: new Date(event.timestamp).getTime(), - body: userContent, - ...(imageUrls.length > 0 ? { imageUrls } : {}), - chatType: isGroupChat ? "group" : "direct", - sender: { id: event.senderId, name: event.senderName }, - envelope: envelopeOptions, - }); -} - -// ─────────────────────────── Quote / dynamic ctx ─────────────────────────── - -/** Render the `[Quoted message begins]...[ends]` block (empty if no reply-to). */ -export function buildQuotePart(replyTo?: ReplyToInfo): string { - if (!replyTo) { - return ""; - } - return replyTo.body - ? `[Quoted message begins]\n${replyTo.body}\n[Quoted message ends]\n` - : `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`; -} - -interface BuildDynamicCtxInput { - imageUrls: string[]; - uniqueVoicePaths: string[]; - uniqueVoiceUrls: string[]; - uniqueVoiceAsrReferTexts: string[]; -} - -/** Render the per-message dynamic metadata block (images / voice / ASR). */ -export function buildDynamicCtx(input: BuildDynamicCtxInput): string { - const lines: string[] = []; - if (input.imageUrls.length > 0) { - lines.push(`- Images: ${input.imageUrls.join(", ")}`); - } - if (input.uniqueVoicePaths.length > 0 || input.uniqueVoiceUrls.length > 0) { - lines.push(`- Voice: ${[...input.uniqueVoicePaths, ...input.uniqueVoiceUrls].join(", ")}`); - } - if (input.uniqueVoiceAsrReferTexts.length > 0) { - lines.push(`- ASR: ${input.uniqueVoiceAsrReferTexts.join(" | ")}`); - } - return lines.length > 0 ? lines.join("\n") + "\n\n" : ""; -} - -// ─────────────────────────── System prompt ─────────────────────────── - -/** Combine account-level system prompt with group-specific prompts. */ -export function buildGroupSystemPrompt( - accountSystemInstruction: string, - groupInfo: InboundGroupInfo | undefined, -): string | undefined { - const parts: string[] = []; - if (accountSystemInstruction) { - parts.push(accountSystemInstruction); - } - if (groupInfo?.display.introHint) { - parts.push(groupInfo.display.introHint); - } - if (groupInfo?.display.behaviorPrompt) { - parts.push(groupInfo.display.behaviorPrompt); - } - const combined = parts.filter(Boolean).join("\n"); - return combined || undefined; -} - -// ─────────────────────────── Media classification ─────────────────────────── - -interface MediaClassification { - localMediaPaths: string[]; - localMediaTypes: string[]; - remoteMediaUrls: string[]; - remoteMediaTypes: string[]; - uniqueVoicePaths: string[]; - uniqueVoiceUrls: string[]; - uniqueVoiceAsrReferTexts: string[]; - voiceMediaTypes: string[]; - hasAsrReferFallback: boolean; - voiceTranscriptSources: string[]; -} - -/** Classify image URLs into local vs remote and de-duplicate voice arrays. */ -export function classifyMedia(processed: ProcessedAttachments): MediaClassification { - const localMediaPaths: string[] = []; - const localMediaTypes: string[] = []; - const remoteMediaUrls: string[] = []; - const remoteMediaTypes: string[] = []; - for (let i = 0; i < processed.imageUrls.length; i++) { - const u = processed.imageUrls[i]; - const t = processed.imageMediaTypes[i] ?? "image/png"; - if (u === undefined) { - continue; - } - if (u.startsWith("http://") || u.startsWith("https://")) { - remoteMediaUrls.push(u); - remoteMediaTypes.push(t); - } else { - localMediaPaths.push(u); - localMediaTypes.push(t); - } - } - - const uniqueVoicePaths = uniqueStrings(processed.voiceAttachmentPaths); - const uniqueVoiceUrls = uniqueStrings(processed.voiceAttachmentUrls); - const voiceMediaTypes = [...uniqueVoicePaths, ...uniqueVoiceUrls].map(() => "audio/wav"); - - return { - localMediaPaths, - localMediaTypes, - remoteMediaUrls, - remoteMediaTypes, - uniqueVoicePaths, - uniqueVoiceUrls, - uniqueVoiceAsrReferTexts: uniqueStrings(processed.voiceAsrReferTexts).filter(Boolean), - voiceMediaTypes, - hasAsrReferFallback: processed.voiceTranscriptSources.includes("asr"), - voiceTranscriptSources: processed.voiceTranscriptSources, - }; -} diff --git a/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.test.ts b/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.test.ts deleted file mode 100644 index 23f50db67db4..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// Qqbot tests cover group gate command-level enforcement. -import { describe, expect, it, vi } from "vitest"; - -vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({ - getSessionEntry: vi.fn(() => undefined), - resolveStorePath: vi.fn(() => "/state/agents/default/openclaw-agent.sqlite"), -})); - -import type { QQBotInboundAccess } from "../../adapter/index.js"; -import type { InboundPipelineDeps } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; -import { runGroupGateStage } from "./group-gate-stage.js"; - -function buildGroupEvent(content: string): QueuedMessage { - return { - type: "group", - senderId: "U1", - content, - messageId: "M1", - timestamp: "0", - groupOpenid: "G1", - }; -} - -function buildAccess(): QQBotInboundAccess { - return { - senderAccess: { decision: "allow" }, - commandAccess: { authorized: true }, - } as unknown as QQBotInboundAccess; -} - -function buildDeps(): InboundPipelineDeps { - return { - account: { - accountId: "default", - appId: "1000000", - clientSecret: "secret", - markdownSupport: false, - config: {}, - }, - cfg: { - channels: { - qqbot: { - appId: "1000000", - groups: { - G1: { requireMention: true, commandLevel: "safety" }, - }, - }, - }, - }, - runtime: {} as InboundPipelineDeps["runtime"], - startTyping: vi.fn(), - isControlCommand: (content) => content.trim().startsWith("/"), - adapters: { - mentionGate: { - resolveInboundMentionDecision: vi.fn(() => ({ - effectiveWasMentioned: false, - shouldSkip: true, - shouldBypassMention: false, - implicitMention: false, - })), - }, - } as unknown as InboundPipelineDeps["adapters"], - }; -} - -function setMentionDecision( - deps: InboundPipelineDeps, - decision: ReturnType< - InboundPipelineDeps["adapters"]["mentionGate"]["resolveInboundMentionDecision"] - >, -): void { - const mentionGate = deps.adapters.mentionGate as { - resolveInboundMentionDecision: ReturnType; - }; - mentionGate.resolveInboundMentionDecision.mockReturnValue(decision); -} - -describe("runGroupGateStage", () => { - it("surfaces private-only commands before the mention skip hides them", () => { - const result = runGroupGateStage({ - event: buildGroupEvent("/config: show"), - deps: buildDeps(), - accountId: "default", - sessionKey: "qqbot:group:G1", - userContent: "/config: show", - access: buildAccess(), - }); - - expect(result.kind).toBe("skip"); - if (result.kind === "skip") { - expect(result.skipReason).toBe("private_command_only"); - } - }); - - it("classifies mention-stripped private commands", () => { - const event = buildGroupEvent("<@BOT_OPENID> /config show"); - event.mentions = [ - { - member_openid: "BOT_OPENID", - username: "OpenClaw", - }, - ]; - - const result = runGroupGateStage({ - event, - deps: buildDeps(), - accountId: "default", - sessionKey: "qqbot:group:G1", - userContent: "/config show", - access: buildAccess(), - }); - - expect(result.kind).toBe("skip"); - if (result.kind === "skip") { - expect(result.skipReason).toBe("private_command_only"); - } - }); - - it("enforces command level from accounts.default group config", () => { - const deps = buildDeps(); - deps.cfg = { - channels: { - qqbot: { - appId: "1000000", - groups: { - G1: { requireMention: true, commandLevel: "all" }, - }, - accounts: { - default: { - groups: { - G1: { requireMention: true, commandLevel: "safety" }, - }, - }, - }, - }, - }, - }; - - const result = runGroupGateStage({ - event: buildGroupEvent("/config show"), - deps, - accountId: "default", - sessionKey: "qqbot:group:G1", - userContent: "/config show", - access: buildAccess(), - }); - - expect(result.kind).toBe("skip"); - if (result.kind === "skip") { - expect(result.skipReason).toBe("private_command_only"); - } - }); - - it("does not reply to private commands that only mention someone else", () => { - const deps = buildDeps(); - ( - deps.cfg as { channels: { qqbot: { groups: { G1: { ignoreOtherMentions: boolean } } } } } - ).channels.qqbot.groups.G1.ignoreOtherMentions = true; - setMentionDecision(deps, { - effectiveWasMentioned: false, - shouldSkip: false, - shouldBypassMention: false, - implicitMention: false, - }); - const event = buildGroupEvent("/config @someone"); - event.mentions = [ - { - member_openid: "SOMEONE_OPENID", - username: "Someone", - }, - ]; - - const result = runGroupGateStage({ - event, - deps, - accountId: "default", - sessionKey: "qqbot:group:G1", - userContent: "/config @Someone", - access: buildAccess(), - }); - - expect(result.kind).toBe("skip"); - if (result.kind === "skip") { - expect(result.skipReason).toBe("drop_other_mention"); - } - }); - - it("does not reject urgent stop in strict groups", () => { - const deps = buildDeps(); - ( - deps.cfg as { channels: { qqbot: { groups: { G1: { commandLevel: string } } } } } - ).channels.qqbot.groups.G1.commandLevel = "strict"; - setMentionDecision(deps, { - effectiveWasMentioned: true, - shouldSkip: false, - shouldBypassMention: true, - implicitMention: false, - }); - - const result = runGroupGateStage({ - event: buildGroupEvent("/stop"), - deps, - accountId: "default", - sessionKey: "qqbot:group:G1", - userContent: "/stop", - access: buildAccess(), - }); - - expect(result.kind).toBe("pass"); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts b/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts deleted file mode 100644 index da1affe9e281..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Qqbot plugin module implements group gate stage behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import type { HistoryPort } from "../../adapter/history.port.js"; -import type { QQBotInboundAccess } from "../../adapter/index.js"; -import { classifyCoreCommandForGroup } from "../../commands/command-visibility.js"; -import { DEFAULT_GROUP_PROMPT, resolveGroupSettings } from "../../config/group.js"; -import { resolveGroupActivation } from "../../group/activation.js"; -import { toAttachmentSummaries, type HistoryEntry } from "../../group/history.js"; -import { detectWasMentioned, hasAnyMention, resolveImplicitMention } from "../../group/mention.js"; -import { resolveGroupMessageGate } from "../../group/message-gating.js"; -import { getRefIndex } from "../../ref/store.js"; -import type { InboundContext, InboundGroupInfo, InboundPipelineDeps } from "../inbound-context.js"; -import { isMergedTurn, type QueuedMessage } from "../message-queue.js"; - -interface GroupGatePass { - kind: "pass"; - groupInfo: InboundGroupInfo; -} - -interface GroupGateSkip { - kind: "skip"; - groupInfo: InboundGroupInfo; - skipReason: NonNullable; -} - -type GroupGateStageResult = GroupGatePass | GroupGateSkip; - -interface GroupGateStageInput { - event: QueuedMessage; - deps: InboundPipelineDeps; - accountId: string; - agentId?: string; - sessionKey: string; - userContent: string; - processedAttachments?: import("../inbound-attachments.js").ProcessedAttachments; - access: QQBotInboundAccess; -} - -export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageResult { - const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input; - const groupOpenid = event.groupOpenid!; - const cfg = (deps.cfg ?? {}) as OpenClawConfig; - - const settings = resolveGroupSettings({ cfg, groupOpenid, accountId, agentId }); - const { historyLimit, requireMention, ignoreOtherMentions } = settings.config; - const behaviorPrompt = settings.config.prompt ?? DEFAULT_GROUP_PROMPT; - const groupName = settings.name; - - const explicitWasMentioned = detectWasMentioned({ - eventType: event.eventType, - mentions: event.mentions as never, - content: event.content, - mentionPatterns: settings.mentionPatterns, - }); - const anyMention = hasAnyMention({ - mentions: event.mentions as never, - content: event.content, - }); - const implicitMention = resolveImplicitMention({ - refMsgIdx: event.refMsgIdx, - getRefEntry: (idx) => getRefIndex(idx) ?? null, - }); - - const activation = resolveGroupActivation({ - cfg, - agentId: agentId ?? "default", - sessionKey, - configRequireMention: requireMention, - }); - - const content = (event.content ?? "").trim(); - const isControlCommand = Boolean(deps.isControlCommand?.(content)); - const commandAuthorized = - deps.allowTextCommands !== false && input.access.commandAccess.authorized; - - const gate = resolveGroupMessageGate({ - mentionGatePort: deps.adapters.mentionGate, - ignoreOtherMentions, - hasAnyMention: anyMention, - wasMentioned: explicitWasMentioned, - implicitMention, - allowTextCommands: deps.allowTextCommands !== false, - isControlCommand, - commandAuthorized, - requireMention: activation === "mention", - }); - - const introHint = deps.resolveGroupIntroHint?.({ - cfg, - accountId, - groupId: groupOpenid, - }); - const senderLabel = event.senderName ? `${event.senderName} (${event.senderId})` : event.senderId; - - const groupInfo: InboundGroupInfo = { - gate, - activation, - commandLevel: settings.config.commandLevel, - historyLimit, - isMerged: isMergedTurn(event), - mergedMessages: event.merge?.messages, - display: { - groupName, - senderLabel, - introHint, - behaviorPrompt, - }, - }; - - const commandVisibility = classifyCoreCommandForGroup(userContent, settings.config.commandLevel); - if ( - commandAuthorized && - commandVisibility.visibility === "private" && - gate.action !== "drop_other_mention" - ) { - return { kind: "skip", groupInfo, skipReason: "private_command_only" }; - } - - if (gate.action === "pass") { - return { kind: "pass", groupInfo }; - } - - if (gate.action === "drop_other_mention" || gate.action === "skip_no_mention") { - recordGroupHistory({ - historyMap: deps.groupHistories, - groupOpenid, - historyLimit, - event, - userContent, - historyPort: deps.adapters.history, - localPaths: processedAttachments?.attachmentLocalPaths, - }); - } - - return { kind: "skip", groupInfo, skipReason: gate.action }; -} - -function recordGroupHistory(params: { - historyMap: Map | undefined; - groupOpenid: string; - historyLimit: number; - event: QueuedMessage; - userContent: string; - historyPort: HistoryPort; - localPaths?: Array; -}): void { - const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } = - params; - if (!historyMap || historyLimit <= 0) { - return; - } - - const senderForHistory = event.senderName - ? `${event.senderName} (${event.senderId})` - : event.senderId; - - const entry: HistoryEntry = { - sender: senderForHistory, - body: userContent, - timestamp: new Date(event.timestamp).getTime(), - messageId: event.messageId, - attachments: toAttachmentSummaries(event.attachments, localPaths), - }; - - historyPort.recordPendingHistoryEntry({ - historyMap, - historyKey: groupOpenid, - limit: historyLimit, - entry, - }); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/index.ts b/extensions/qqbot/src/engine/gateway/stages/index.ts deleted file mode 100644 index 8882dc4b99fa..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Inbound pipeline stages — each stage is a pure(-ish) function that - * transforms a subset of the pipeline's state. The main `inbound-pipeline` - * module composes them in order. - * - * Keeping every stage in its own file makes the pipeline's control flow - * obvious and lets each piece be unit-tested against tiny input fixtures - * without spinning up the full gateway. - */ - -export * from "./access-stage.js"; -export * from "./assembly-stage.js"; -export * from "./content-stage.js"; -export * from "./envelope-stage.js"; -export * from "./group-gate-stage.js"; -export * from "./quote-stage.js"; -export * from "./refidx-stage.js"; -export { buildSkippedInboundContext } from "./stub-contexts.js"; diff --git a/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts b/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts deleted file mode 100644 index 71b0dd6e617d..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/quote-stage.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Quote stage — resolve the quoted-reply (`refMsgIdx`) if any. - * - * Three-level fallback mirrors the standalone build: - * 1. RefIndex cache hit → rich ReplyToInfo - * 2. `msg_elements[0]` present → re-process the quoted body - * 3. Otherwise → id-only placeholder so the pipeline still knows it's a reply - */ - -import { - evaluateSupplementalContextVisibility, - resolveChannelContextVisibilityMode, -} from "openclaw/plugin-sdk/context-visibility-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { resolveQQBotEffectivePolicies } from "../../access/resolve-policy.js"; -import { normalizeQQBotSenderId } from "../../access/sender-match.js"; -import type { QQBotDmPolicy, QQBotGroupPolicy } from "../../access/types.js"; -import { - formatMessageReferenceForAgent, - type AttachmentProcessor, -} from "../../ref/format-message-ref.js"; -import { formatRefEntryForAgent, getRefIndex } from "../../ref/store.js"; -import { MSG_TYPE_QUOTE } from "../../utils/text-parsing.js"; -import { formatVoiceText } from "../../utils/voice-text.js"; -import { processAttachments } from "../inbound-attachments.js"; -import type { InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; - -/** - * Resolve the quote metadata for an inbound event. - * - * Returns `undefined` when the event is not a reply at all. - */ -export async function resolveQuote( - event: QueuedMessage, - deps: InboundPipelineDeps, -): Promise { - if (!event.refMsgIdx) { - return undefined; - } - - const { account, log } = deps; - - // ---- Layer 1: cache hit ---- - const refEntry = getRefIndex(event.refMsgIdx); - if (refEntry) { - log?.debug?.( - `Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${refEntry.senderName ?? refEntry.senderId}`, - ); - const includeQuote = await shouldIncludeQuoteContext({ - event, - deps, - senderId: refEntry.senderId, - senderIsCurrentAccountBot: refEntry.isBot === true && refEntry.senderId === account.accountId, - }); - if (!includeQuote) { - log?.debug?.( - `Quote context omitted by qqbot visibility policy: refMsgIdx=${event.refMsgIdx}, sender=${refEntry.senderName ?? refEntry.senderId}`, - ); - return { - id: event.refMsgIdx, - isQuote: true, - }; - } - return { - id: event.refMsgIdx, - body: formatRefEntryForAgent(refEntry), - sender: refEntry.senderName ?? refEntry.senderId, - isQuote: true, - }; - } - - // ---- Layer 2: fall back to msg_elements[0] if this is a quote type ---- - if (event.msgType === MSG_TYPE_QUOTE && event.msgElements?.[0]) { - if (!(await shouldIncludeQuoteContext({ event, deps }))) { - log?.debug?.( - `Quote context omitted by qqbot visibility policy because sender was unavailable: refMsgIdx=${event.refMsgIdx}`, - ); - return { - id: event.refMsgIdx, - isQuote: true, - }; - } - try { - const refElement = event.msgElements[0]; - const refData = { - content: refElement.content ?? "", - attachments: refElement.attachments, - }; - const attachmentProcessor: AttachmentProcessor = { - processAttachments: async (atts, refCtx) => { - const result = await processAttachments( - atts as Array<{ - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - asr_refer_text?: string; - }>, - { - accountId: account.accountId, - cfg: refCtx.cfg, - audioConvert: deps.adapters.audioConvert, - log: refCtx.log, - }, - ); - return { - attachmentInfo: result.attachmentInfo, - voiceTranscripts: result.voiceTranscripts, - voiceTranscriptSources: result.voiceTranscriptSources, - attachmentLocalPaths: result.attachmentLocalPaths, - }; - }, - formatVoiceText: (transcripts) => formatVoiceText(transcripts), - }; - const refPeerId = - event.type === "group" && event.groupOpenid ? event.groupOpenid : event.senderId; - const refBody = await formatMessageReferenceForAgent( - refData, - { appId: account.appId, peerId: refPeerId, cfg: account.config, log }, - attachmentProcessor, - ); - log?.debug?.( - `Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${truncateUtf16Safe(refBody ?? "", 80)}..."`, - ); - return { - id: event.refMsgIdx, - body: refBody || undefined, - isQuote: true, - }; - } catch (refErr) { - log?.error(`Failed to format quoted message from msg_elements: ${String(refErr)}`); - } - } else { - log?.debug?.( - `Quote detected but no cache and msgType=${event.msgType}: refMsgIdx=${event.refMsgIdx}`, - ); - } - - // ---- Layer 3: id-only placeholder ---- - return { - id: event.refMsgIdx, - isQuote: true, - }; -} - -async function shouldIncludeQuoteContext(params: { - event: QueuedMessage; - deps: InboundPipelineDeps; - senderId?: string; - senderIsCurrentAccountBot?: boolean; -}): Promise { - const contextVisibilityMode = resolveChannelContextVisibilityMode({ - cfg: params.deps.cfg, - channel: "qqbot", - accountId: params.deps.account.accountId, - }); - if ( - params.senderIsCurrentAccountBot || - evaluateSupplementalContextVisibility({ - mode: contextVisibilityMode, - kind: "quote", - senderAllowed: false, - }).include - ) { - return true; - } - - const visibilityPolicy = resolveQuoteVisibilityPolicy(params.event, params.deps.account.config); - if (!visibilityPolicy.requiresSenderCheck) { - return evaluateSupplementalContextVisibility({ - mode: contextVisibilityMode, - kind: "quote", - senderAllowed: true, - }).include; - } - - let senderAllowed = false; - if (params.senderId) { - const quotedAccess = await params.deps.adapters.access.resolveInboundAccess({ - cfg: params.deps.cfg, - accountId: params.deps.account.accountId, - isGroup: isGroupConversation(params.event), - senderId: params.senderId, - conversationId: resolveConversationId(params.event), - allowFrom: params.deps.account.config?.allowFrom, - groupAllowFrom: params.deps.account.config?.groupAllowFrom, - dmPolicy: visibilityPolicy.dmPolicy ?? params.deps.account.config?.dmPolicy, - groupPolicy: visibilityPolicy.groupPolicy ?? params.deps.account.config?.groupPolicy, - }); - senderAllowed = quotedAccess.senderAccess.decision === "allow"; - } - - return evaluateSupplementalContextVisibility({ - mode: contextVisibilityMode, - kind: "quote", - senderAllowed, - }).include; -} - -type QuoteVisibilityPolicy = { - requiresSenderCheck: boolean; - dmPolicy?: QQBotDmPolicy; - groupPolicy?: QQBotGroupPolicy; -}; - -function resolveQuoteVisibilityPolicy( - event: QueuedMessage, - config: InboundPipelineDeps["account"]["config"], -): QuoteVisibilityPolicy { - const policies = resolveQQBotEffectivePolicies(config ?? {}); - if (isGroupConversation(event)) { - const groupAllowFrom = resolveGroupQuoteAllowFrom(config); - if (hasUniversalAllowlist(groupAllowFrom)) { - return { requiresSenderCheck: false }; - } - if (policies.groupPolicy === "open") { - return hasRestrictedAllowlist(groupAllowFrom) - ? { requiresSenderCheck: true, groupPolicy: "allowlist" } - : { requiresSenderCheck: false }; - } - if (policies.groupPolicy === "disabled") { - return { requiresSenderCheck: true }; - } - return { requiresSenderCheck: true }; - } - if (policies.dmPolicy === "disabled") { - return { requiresSenderCheck: true }; - } - if (hasUniversalAllowlist(config?.allowFrom)) { - return { requiresSenderCheck: false }; - } - return { - requiresSenderCheck: policies.dmPolicy !== "open" || hasRestrictedAllowlist(config?.allowFrom), - }; -} - -function isGroupConversation(event: QueuedMessage): boolean { - return event.type === "guild" || event.type === "group"; -} - -function resolveConversationId(event: QueuedMessage): string { - if (event.type === "guild") { - return event.channelId ?? "unknown"; - } - if (event.type === "group") { - return event.groupOpenid ?? "unknown"; - } - return event.senderId; -} - -function resolveGroupQuoteAllowFrom( - config: InboundPipelineDeps["account"]["config"], -): Array | undefined { - return config?.groupAllowFrom && config.groupAllowFrom.length > 0 - ? config.groupAllowFrom - : config?.allowFrom; -} - -function hasUniversalAllowlist(list: Array | undefined | null): boolean { - return (list ?? []).some((entry) => normalizeQQBotSenderId(entry) === "*"); -} - -function hasRestrictedAllowlist(list: Array | undefined | null): boolean { - return (list ?? []).some((entry) => { - const normalized = normalizeQQBotSenderId(entry); - return normalized !== "" && normalized !== "*"; - }); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/refidx-stage.ts b/extensions/qqbot/src/engine/gateway/stages/refidx-stage.ts deleted file mode 100644 index 1b6e74efdd92..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/refidx-stage.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * RefIdx persistence stage — writes the current message into the shared - * `refIndex` cache so future quote resolutions can find it. - * - * The stage also attaches voice transcripts (and their source) onto the - * cached attachment summaries so replies-to-this-message can render the - * original audio content inline instead of just a file handle. - * - * Pure data pipeline (no network I/O). Sync return value. - */ - -import { setRefIndex } from "../../ref/store.js"; -import { buildAttachmentSummaries } from "../../utils/text-parsing.js"; -import type { ProcessedAttachments } from "../inbound-attachments.js"; -import type { QueuedMessage } from "../message-queue.js"; - -/** - * Cache the current message under `msgIdx` (or the fallback `refIdx` - * returned by the typing-indicator call) so later quotes resolve. - * - * No-op when neither id is available. - */ -export function writeRefIndex(params: { - event: QueuedMessage; - parsedContent: string; - processed: ProcessedAttachments; - /** Optional refIdx returned by `InputNotify` — used when `msgIdx` is missing. */ - inputNotifyRefIdx?: string; -}): void { - const { event, parsedContent, processed, inputNotifyRefIdx } = params; - - const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx; - if (!currentMsgIdx) { - return; - } - - const attSummaries = buildAttachmentSummaries(event.attachments, processed.attachmentLocalPaths); - if (attSummaries && processed.voiceTranscripts.length > 0) { - let voiceIdx = 0; - for (const att of attSummaries) { - if (att.type === "voice" && voiceIdx < processed.voiceTranscripts.length) { - att.transcript = processed.voiceTranscripts[voiceIdx]; - if (voiceIdx < processed.voiceTranscriptSources.length) { - att.transcriptSource = processed.voiceTranscriptSources[voiceIdx] as - | "stt" - | "asr" - | "tts" - | "fallback"; - } - voiceIdx++; - } - } - } - - setRefIndex(currentMsgIdx, { - content: parsedContent, - senderId: event.senderId, - senderName: event.senderName, - timestamp: new Date(event.timestamp).getTime(), - attachments: attSummaries, - }); -} diff --git a/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts b/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts deleted file mode 100644 index f9eee1d1cb56..000000000000 --- a/extensions/qqbot/src/engine/gateway/stages/stub-contexts.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Qqbot plugin module implements stub contexts behavior. -import type { QQBotInboundAccess } from "../../adapter/index.js"; -import type { InboundContext, InboundGroupInfo } from "../inbound-context.js"; -import type { QueuedMessage } from "../message-queue.js"; -import type { TypingKeepAlive } from "../typing-keepalive.js"; - -interface BaseStubFields { - event: QueuedMessage; - route: InboundContext["route"]; - isGroupChat: boolean; - peerId: string; - qualifiedTarget: string; - fromAddress: string; -} - -function emptyInboundContext(fields: BaseStubFields): InboundContext { - return { - event: fields.event, - route: fields.route, - isGroupChat: fields.isGroupChat, - peerId: fields.peerId, - qualifiedTarget: fields.qualifiedTarget, - fromAddress: fields.fromAddress, - agentBody: "", - body: "", - groupSystemPrompt: undefined, - localMediaPaths: [], - localMediaTypes: [], - remoteMediaUrls: [], - uniqueVoicePaths: [], - uniqueVoiceUrls: [], - uniqueVoiceAsrReferTexts: [], - voiceMediaTypes: [], - hasAsrReferFallback: false, - voiceTranscriptSources: [], - replyTo: undefined, - commandAuthorized: false, - group: undefined, - blocked: false, - skipped: false, - typing: { keepAlive: null }, - inputNotifyRefIdx: undefined, - }; -} - -export function buildBlockedInboundContext( - params: BaseStubFields & { - access: QQBotInboundAccess; - }, -): InboundContext { - return { - ...emptyInboundContext(params), - blocked: true, - blockReason: params.access.senderAccess.reasonCode, - blockReasonCode: params.access.senderAccess.reasonCode, - accessDecision: params.access.senderAccess.decision, - }; -} - -export function buildSkippedInboundContext( - params: BaseStubFields & { - group: InboundGroupInfo; - skipReason: NonNullable; - access: QQBotInboundAccess; - typing: { keepAlive: TypingKeepAlive | null }; - inputNotifyRefIdx?: string; - }, -): InboundContext { - return { - ...emptyInboundContext(params), - group: params.group, - skipped: true, - skipReason: params.skipReason, - accessDecision: params.access.senderAccess.decision, - typing: params.typing, - inputNotifyRefIdx: params.inputNotifyRefIdx, - }; -} diff --git a/extensions/qqbot/src/engine/gateway/types.ts b/extensions/qqbot/src/engine/gateway/types.ts deleted file mode 100644 index 788539ce4531..000000000000 --- a/extensions/qqbot/src/engine/gateway/types.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { ChannelIngressQueue } from "openclaw/plugin-sdk/channel-outbound"; -// Qqbot type declarations define plugin contracts. -import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; -import type { EngineLogger } from "../types.js"; -export type { EngineLogger }; - -import type { GatewayAccount as _GatewayAccount } from "../types.js"; -export type GatewayAccount = _GatewayAccount; - -export interface GatewayPluginRuntime { - state: { - openChannelIngressQueue: (options: { - accountId: string; - }) => ChannelIngressQueue; - }; - channel: { - activity: { - record: (params: { - channel: string; - accountId: string; - direction: "inbound" | "outbound"; - }) => void; - }; - routing: { - resolveAgentRoute: (params: { - cfg: unknown; - channel: string; - accountId: string; - peer: { kind: "group" | "direct"; id: string }; - }) => { - sessionKey: string; - accountId: string; - agentId?: string; - dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; - }; - }; - commands?: { - isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean; - }; - reply: { - dispatchReplyWithBufferedBlockDispatcher: (params: unknown) => Promise; - resolveEffectiveMessagesConfig: ( - cfg: unknown, - agentId?: string, - ) => { responsePrefix?: string }; - finalizeInboundContext: (fields: Record) => unknown; - formatInboundEnvelope: (params: unknown) => string; - resolveEnvelopeFormatOptions: (cfg: unknown) => unknown; - }; - session: { - resolveStorePath: (store: unknown, params: { agentId: string }) => string; - recordInboundSession: (params: unknown) => Promise; - }; - inbound: { - run: (params: unknown) => Promise; - }; - text: { - chunkMarkdownText: (text: string, limit: number) => string[]; - }; - }; - tts: { - textToSpeech: (params: { - text: string; - cfg: unknown; - channel: string; - accountId?: string; - }) => Promise<{ - success: boolean; - audioPath?: string; - provider?: string; - outputFormat?: string; - error?: string; - }>; - }; - config?: { - current: () => Record; - replaceConfigFile: (params: { - nextConfig: unknown; - afterWrite: { mode: "auto" }; - }) => Promise; - }; -} - -export interface OutboundResult { - channel: string; - messageId?: string; - timestamp?: string | number; - error?: string; -} - -export type { RefAttachmentSummary } from "../ref/types.js"; - -export interface WSPayload { - op: number; - d: unknown; - /** Stable delivery id on gateway dispatch envelopes. */ - id?: string; - s?: number; - t?: string; -} - -export type QQBotIngressLifecycle = { - abortSignal: AbortSignal; - onAdopted: () => void | Promise; - onDeferred: () => void; - onAdoptionFinalizing: () => void; - onAbandoned: () => void | Promise; -}; - -interface RawMessageAttachment { - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - asr_refer_text?: string; -} - -interface RawMsgElement { - msg_idx?: string; - content?: string; - attachments?: Array< - RawMessageAttachment & { - height?: number; - width?: number; - size?: number; - } - >; -} - -export interface C2CMessageEvent { - id: string; - content: string; - timestamp: string; - author: { user_openid: string }; - attachments?: RawMessageAttachment[]; - message_scene?: { ext?: string[] }; - message_type?: number; - msg_elements?: RawMsgElement[]; -} - -export interface GuildMessageEvent { - id: string; - content: string; - timestamp: string; - author: { id: string; username?: string }; - channel_id: string; - guild_id: string; - attachments?: RawMessageAttachment[]; - message_scene?: { ext?: string[] }; -} - -export interface GroupMessageEvent { - id: string; - content: string; - timestamp: string; - author: { - member_openid: string; - username?: string; - /** True when the sender is itself a bot. */ - bot?: boolean; - }; - group_openid: string; - attachments?: RawMessageAttachment[]; - /** Optional @mentions list with per-entry is_you / member_openid / nickname. */ - mentions?: Array<{ - scope?: "all" | "single"; - id?: string; - user_openid?: string; - member_openid?: string; - nickname?: string; - username?: string; - bot?: boolean; - /** `true` when this mention targets the bot itself. */ - is_you?: boolean; - }>; - message_scene?: { source?: string; ext?: string[] }; - message_type?: number; - msg_elements?: RawMsgElement[]; -} - -// ============ Gateway Context ============ - -import type { EngineAdapters } from "../adapter/index.js"; - -/** - * Group-chat behaviour options. - * - * Grouped under a dedicated sub-object on {@link CoreGatewayContext} so - * future additions (admin lookup, proactive push, per-group toggles) - * don't keep polluting the top-level context type. - */ -interface GatewayGroupOptions { - /** - * Whether group-chat gating is enabled. Defaults to `true`; set to - * `false` to disable all group processing (e.g. for a DM-only smoke - * test). When disabled, the engine does not allocate a history - * buffer and does not instantiate the session-store reader. - */ - enabled?: boolean; - /** - * Whether the framework has text-based control commands enabled. When - * `false`, the group gate skips the "unauthorized command" check and - * the command-bypass path. - */ - allowTextCommands?: boolean; - /** - * Optional probe that returns true when `content` is a recognised - * control command. Injected to avoid hard-coding a command list in - * the engine. When omitted, no message is treated as a control - * command and the bypass path never activates. - */ - isControlCommand?: (content: string) => boolean; - /** - * Platform hook that contributes a channel-level group intro hint - * (e.g. "当前群: 开发讨论组"). Invoked per-group when building the - * system prompt. - */ - resolveIntroHint?: (params: { - cfg: unknown; - accountId: string; - groupId: string; - }) => string | undefined; -} - -/** Full gateway startup context. */ -export interface CoreGatewayContext { - account: GatewayAccount; - abortSignal: AbortSignal; - cfg: OpenClawConfig; - getCurrentConfig: () => OpenClawConfig; - onReady?: (data: unknown) => void; - /** - * Invoked when a RESUMED event is received after reconnect. - * Falls back to `onReady` when not provided so existing callers - * keep their current behaviour. - */ - onResumed?: (data: unknown) => void; - onError?: (error: Error) => void; - /** - * Invoked when the gateway websocket closes or permanently stops - * (fatal close code / reconnect attempts exhausted). Without this the - * channel status keeps reporting the last `connected: true` snapshot. - */ - onDisconnected?: (info: { reason?: string; fatal?: boolean }) => void; - log?: EngineLogger; - /** PluginRuntime injected by the framework — same object in both versions. */ - runtime: GatewayPluginRuntime; - /** Group-chat tuning options. */ - group?: GatewayGroupOptions; - /** Adapter ports — delegates audio, history, mention gating, commands to bridge implementations. */ - adapters: EngineAdapters; -} diff --git a/extensions/qqbot/src/engine/gateway/typing-keepalive.test.ts b/extensions/qqbot/src/engine/gateway/typing-keepalive.test.ts deleted file mode 100644 index f96955900c30..000000000000 --- a/extensions/qqbot/src/engine/gateway/typing-keepalive.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Qqbot tests cover typing keepalive plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ReplyLimiter } from "../messaging/reply-limiter.js"; -import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js"; - -function createTypingClaim(messageId: string) { - const limiter = new ReplyLimiter({ limit: 5 }); - limiter.record(messageId); // Initial input_notify. - return (id: string, reserve: number) => limiter.claim(id, reserve); -} - -describe("TypingKeepAlive", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("renews C2C typing every 5 seconds with a 10 second input window", async () => { - vi.useFakeTimers(); - const sendInputNotify = vi.fn(async () => undefined); - const keepAlive = new TypingKeepAlive( - async () => "token-1", - vi.fn(), - sendInputNotify, - "openid-1", - "msg-1", - undefined, - createTypingClaim("msg-1"), - ); - - keepAlive.start(); - - await vi.advanceTimersByTimeAsync(4_999); - expect(sendInputNotify).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(sendInputNotify).toHaveBeenCalledTimes(1); - expect(sendInputNotify).toHaveBeenLastCalledWith("token-1", "openid-1", "msg-1", 10); - expect(TYPING_INPUT_SECOND).toBe(10); - - keepAlive.stop(); - await vi.advanceTimersByTimeAsync(5_000); - expect(sendInputNotify).toHaveBeenCalledTimes(1); - }); - - it("caps renewals so long C2C replies keep a final passive reply slot", async () => { - vi.useFakeTimers(); - const sendInputNotify = vi.fn(async () => undefined); - const keepAlive = new TypingKeepAlive( - async () => "token-1", - vi.fn(), - sendInputNotify, - "openid-1", - "msg-1", - undefined, - createTypingClaim("msg-1"), - ); - - keepAlive.start(); - - await vi.advanceTimersByTimeAsync(5_000 * 3); - expect(sendInputNotify).toHaveBeenCalledTimes(3); - - await vi.advanceTimersByTimeAsync(10_000); - expect(sendInputNotify).toHaveBeenCalledTimes(3); - }); - - it("counts token-refresh retry attempts against the renewal budget", async () => { - vi.useFakeTimers(); - const clearCache = vi.fn(); - const sendInputNotify = vi - .fn(async () => undefined) - .mockRejectedValueOnce(new Error("11244 token expired")); - const keepAlive = new TypingKeepAlive( - async () => "token-1", - clearCache, - sendInputNotify, - "openid-1", - "msg-1", - undefined, - createTypingClaim("msg-1"), - ); - - keepAlive.start(); - - // First tick: the failed attempt and its token-refresh retry both claim the shared budget. - await vi.advanceTimersByTimeAsync(5_000); - expect(clearCache).toHaveBeenCalledTimes(1); - expect(sendInputNotify).toHaveBeenCalledTimes(2); - - // Only one renewal attempt remains before the reserved final-reply slot. - await vi.advanceTimersByTimeAsync(20_000); - expect(sendInputNotify).toHaveBeenCalledTimes(3); - }); - - it("suppresses overlapping renewals while a send is still in flight", async () => { - vi.useFakeTimers(); - let release: (() => void) | undefined; - const sendInputNotify = vi.fn( - () => - new Promise((resolve) => { - release = resolve; - }), - ); - const keepAlive = new TypingKeepAlive( - async () => "token-1", - vi.fn(), - sendInputNotify, - "openid-1", - "msg-1", - undefined, - createTypingClaim("msg-1"), - ); - - keepAlive.start(); - - await vi.advanceTimersByTimeAsync(5_000); - expect(sendInputNotify).toHaveBeenCalledTimes(1); - - // A stalled RPC must not double-send or burn extra reply budget. - await vi.advanceTimersByTimeAsync(10_000); - expect(sendInputNotify).toHaveBeenCalledTimes(1); - - release?.(); - // Let the stalled tick settle so the next interval tick is not suppressed. - await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(5_000); - expect(sendInputNotify).toHaveBeenCalledTimes(2); - - keepAlive.stop(); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/typing-keepalive.ts b/extensions/qqbot/src/engine/gateway/typing-keepalive.ts deleted file mode 100644 index 919becd6fb96..000000000000 --- a/extensions/qqbot/src/engine/gateway/typing-keepalive.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Periodically refresh C2C typing state while a response is in progress. - * - * Interval scheduling comes from the core typing keepalive loop; this module - * owns the QQ passive-reply budget accounting and token-refresh retry. - */ - -import { createTypingKeepaliveLoop } from "openclaw/plugin-sdk/channel-outbound"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { claimMessageReply } from "../messaging/outbound-reply.js"; -import type { ReplyLimitResult } from "../messaging/reply-limiter.js"; - -/** Function that sends a typing indicator to one user. */ -type SendInputNotifyFn = ( - token: string, - openid: string, - msgId: string | undefined, - inputSecond: number, -) => Promise; - -/** Refresh every 5s for the QQ API's 10s input-notify window. */ -const TYPING_INTERVAL_MS = 5_000; -export const TYPING_INPUT_SECOND = 10; -const FINAL_REPLY_RESERVE_COUNT = 1; - -export class TypingKeepAlive { - private stopped = false; - // Core loop owns the interval and in-flight tick suppression; budget - // accounting in sendAttempt() decides when it must stop for good. - private readonly loop = createTypingKeepaliveLoop({ - intervalMs: TYPING_INTERVAL_MS, - onTick: () => this.send(), - }); - - constructor( - private readonly getToken: () => Promise, - private readonly clearCache: () => void, - private readonly sendInputNotify: SendInputNotifyFn, - private readonly openid: string, - private readonly msgId: string, - private readonly log?: { debug?: (msg: string) => void }, - private readonly claimPassiveReply: ( - messageId: string, - reserve: number, - ) => ReplyLimitResult = claimMessageReply, - ) {} - - /** Start periodic keep-alive sends. */ - start(): void { - // stop() is a permanent latch: a stopped keepalive must never spend more budget. - if (!this.stopped) { - this.loop.start(); - } - } - - /** Stop periodic keep-alive sends. */ - stop(): void { - this.stopped = true; - this.loop.stop(); - } - - // Never rejects: the core loop does not catch onTick errors. - private async send(): Promise { - try { - const token = await this.getToken(); - await this.sendAttempt(token); - } catch (err) { - try { - this.clearCache(); - const token = await this.getToken(); - await this.sendAttempt(token); - } catch { - this.log?.debug?.( - `Typing keep-alive failed for ${this.openid}: ${formatErrorMessage(err)}`, - ); - } - } - } - - private async sendAttempt(token: string): Promise { - if (this.stopped) { - return; - } - - // Claim before every wire attempt: a failed request may still have consumed - // QQ's msg_id budget, while the final text slot must remain available. - const claim = this.claimPassiveReply(this.msgId, FINAL_REPLY_RESERVE_COUNT); - if (!claim.allowed) { - this.log?.debug?.(`Typing keep-alive budget exhausted for ${this.openid}`); - this.stop(); - return; - } - try { - await this.sendInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND); - this.log?.debug?.(`Typing keep-alive sent to ${this.openid}`); - } finally { - if (claim.remaining <= FINAL_REPLY_RESERVE_COUNT) { - this.log?.debug?.(`Typing keep-alive budget exhausted for ${this.openid}`); - this.stop(); - } - } - } -} diff --git a/extensions/qqbot/src/engine/gateway/ws-client.test.ts b/extensions/qqbot/src/engine/gateway/ws-client.test.ts deleted file mode 100644 index ad1db37c77ba..000000000000 --- a/extensions/qqbot/src/engine/gateway/ws-client.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Qqbot tests cover ws client plugin behavior. -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const webSocketCtorMock = vi.hoisted(() => - vi.fn(function webSocketCtorMockImpl(_url: string, _options?: Record) { - return { readyState: 0 }; - }), -); -const proxyAgentCtorMock = vi.hoisted(() => - vi.fn(function createAmbientNodeProxyAgentMockImpl() { - return { proxied: true }; - }), -); -const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const; -type ProxyEnvKey = (typeof proxyEnvKeys)[number]; - -vi.mock("ws", () => ({ - default: webSocketCtorMock, -})); - -type CreateQQWSClient = typeof import("./ws-client.js").createQQWSClient; -let createQQWSClient: CreateQQWSClient; -let priorProxyEnv: Partial> = {}; - -beforeAll(async () => { - vi.doMock("@openclaw/proxyline", () => ({ - createAmbientNodeProxyAgent: proxyAgentCtorMock, - hasAmbientNodeProxyConfigured: vi.fn(() => - Boolean( - process.env.HTTPS_PROXY ?? - process.env.https_proxy ?? - process.env.HTTP_PROXY ?? - process.env.http_proxy, - ), - ), - })); - ({ createQQWSClient } = await import("./ws-client.js")); -}); - -function expectWebSocketCtorCall(expected: unknown[]): void { - const call = webSocketCtorMock.mock.calls[0]; - if (!call) { - throw new Error("Expected WebSocket constructor call"); - } - expect(call).toEqual(expected); -} - -describe("createQQWSClient", () => { - beforeEach(() => { - priorProxyEnv = {}; - for (const key of proxyEnvKeys) { - priorProxyEnv[key] = process.env[key]; - delete process.env[key]; - } - vi.clearAllMocks(); - }); - - afterEach(() => { - for (const key of proxyEnvKeys) { - const value = priorProxyEnv[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }); - - it("sets a bounded handshake without a proxy agent", async () => { - await createQQWSClient({ - gatewayUrl: "wss://qq.example.test/ws", - userAgent: "openclaw-qqbot-test", - }); - - expect(webSocketCtorMock).toHaveBeenCalledTimes(1); - expect(proxyAgentCtorMock).not.toHaveBeenCalled(); - expectWebSocketCtorCall([ - "wss://qq.example.test/ws", - { - headers: { "User-Agent": "openclaw-qqbot-test" }, - handshakeTimeout: 30_000, - }, - ]); - }); - - it("creates a ws proxy agent when lowercase https_proxy is set", async () => { - process.env.https_proxy = "http://lower-https:8001"; - - await createQQWSClient({ - gatewayUrl: "wss://qq.example.test/ws", - userAgent: "openclaw-qqbot-test", - }); - - expect(webSocketCtorMock).toHaveBeenCalledTimes(1); - expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1); - expectWebSocketCtorCall([ - "wss://qq.example.test/ws", - { - agent: { proxied: true }, - headers: { "User-Agent": "openclaw-qqbot-test" }, - handshakeTimeout: 30_000, - }, - ]); - }); - - it("creates a ws proxy agent when uppercase HTTPS_PROXY is set", async () => { - process.env.HTTPS_PROXY = "http://upper-https:8002"; - - await createQQWSClient({ - gatewayUrl: "wss://qq.example.test/ws", - userAgent: "openclaw-qqbot-test", - }); - - expect(webSocketCtorMock).toHaveBeenCalledTimes(1); - expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1); - expectWebSocketCtorCall([ - "wss://qq.example.test/ws", - { - agent: { proxied: true }, - headers: { "User-Agent": "openclaw-qqbot-test" }, - handshakeTimeout: 30_000, - }, - ]); - }); - - it("falls back to HTTP_PROXY for ws proxy agent creation", async () => { - process.env.HTTP_PROXY = "http://upper-http:8999"; - - await createQQWSClient({ - gatewayUrl: "wss://qq.example.test/ws", - userAgent: "openclaw-qqbot-test", - }); - - expect(webSocketCtorMock).toHaveBeenCalledTimes(1); - expect(proxyAgentCtorMock).toHaveBeenCalledTimes(1); - expectWebSocketCtorCall([ - "wss://qq.example.test/ws", - { - agent: { proxied: true }, - headers: { "User-Agent": "openclaw-qqbot-test" }, - handshakeTimeout: 30_000, - }, - ]); - }); -}); diff --git a/extensions/qqbot/src/engine/gateway/ws-client.ts b/extensions/qqbot/src/engine/gateway/ws-client.ts deleted file mode 100644 index 3a8d9aed8193..000000000000 --- a/extensions/qqbot/src/engine/gateway/ws-client.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Qqbot plugin module implements ws client behavior. -import type { Agent } from "node:http"; -import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared"; -import WebSocket from "ws"; - -// `ws` otherwise waits indefinitely for an HTTP upgrade. Keep the 30s channel -// precedent (Discord, Slack, Signal) so a half-open upgrade eventually closes, -// releases GatewayConnection.isConnecting, and allows reconnects. -const QQBOT_WEBSOCKET_HANDSHAKE_TIMEOUT_MS = 30_000; - -interface QQWSClientOptions { - gatewayUrl: string; - userAgent: string; -} - -export async function createQQWSClient(options: QQWSClientOptions): Promise { - const wsAgent = await resolveAmbientNodeProxyAgent(); - return new WebSocket(options.gatewayUrl, { - headers: { "User-Agent": options.userAgent }, - handshakeTimeout: QQBOT_WEBSOCKET_HANDSHAKE_TIMEOUT_MS, - ...(wsAgent ? { agent: wsAgent } : {}), - }); -} diff --git a/extensions/qqbot/src/engine/group/activation.test.ts b/extensions/qqbot/src/engine/group/activation.test.ts deleted file mode 100644 index 505022641e4e..000000000000 --- a/extensions/qqbot/src/engine/group/activation.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Qqbot tests cover activation plugin behavior. -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const sessionStoreMocks = vi.hoisted(() => ({ - getSessionEntry: vi.fn(), - resolveStorePath: vi.fn(() => "/state/agents/main/openclaw-agent.sqlite"), -})); - -vi.mock("openclaw/plugin-sdk/session-store-runtime", () => sessionStoreMocks); - -import { resolveGroupActivation } from "./activation.js"; - -describe("engine/group/activation", () => { - beforeEach(() => { - sessionStoreMocks.getSessionEntry.mockReset(); - sessionStoreMocks.resolveStorePath.mockClear(); - }); - - it.each([ - { configRequireMention: true, expected: "mention" }, - { configRequireMention: false, expected: "always" }, - ] as const)("falls back to $expected when no override exists", (testCase) => { - expect( - resolveGroupActivation({ - cfg: {}, - agentId: "main", - sessionKey: "missing", - configRequireMention: testCase.configRequireMention, - }), - ).toBe(testCase.expected); - }); - - it.each([ - { raw: "mention", configRequireMention: false, expected: "mention" }, - { raw: "always", configRequireMention: true, expected: "always" }, - { raw: " Always ", configRequireMention: true, expected: "always" }, - { raw: "weird-mode", configRequireMention: true, expected: "mention" }, - ] as const)("resolves session activation $raw as $expected", (testCase) => { - sessionStoreMocks.getSessionEntry.mockReturnValue({ groupActivation: testCase.raw }); - - expect( - resolveGroupActivation({ - cfg: {}, - agentId: "main", - sessionKey: "k1", - configRequireMention: testCase.configRequireMention, - }), - ).toBe(testCase.expected); - expect(sessionStoreMocks.resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "main" }); - expect(sessionStoreMocks.getSessionEntry).toHaveBeenCalledWith({ - storePath: "/state/agents/main/openclaw-agent.sqlite", - agentId: "main", - sessionKey: "k1", - }); - }); - - it("falls back when the session accessor fails", () => { - sessionStoreMocks.getSessionEntry.mockImplementation(() => { - throw new Error("unavailable"); - }); - - expect( - resolveGroupActivation({ - cfg: {}, - agentId: "main", - sessionKey: "k1", - configRequireMention: false, - }), - ).toBe("always"); - }); -}); diff --git a/extensions/qqbot/src/engine/group/activation.ts b/extensions/qqbot/src/engine/group/activation.ts deleted file mode 100644 index b214006efc27..000000000000 --- a/extensions/qqbot/src/engine/group/activation.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Qqbot plugin module implements activation behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - normalizeGroupActivation, - type GroupActivationMode, -} from "openclaw/plugin-sdk/group-activation"; -import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; - -export type { GroupActivationMode } from "openclaw/plugin-sdk/group-activation"; - -export function resolveGroupActivation(params: { - cfg: OpenClawConfig; - agentId: string; - sessionKey: string; - configRequireMention: boolean; -}): GroupActivationMode { - const fallback: GroupActivationMode = params.configRequireMention ? "mention" : "always"; - - try { - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); - const activation = normalizeGroupActivation( - getSessionEntry({ - storePath, - agentId: params.agentId, - sessionKey: params.sessionKey, - })?.groupActivation, - ); - return activation ?? fallback; - } catch { - return fallback; - } -} diff --git a/extensions/qqbot/src/engine/group/history.test.ts b/extensions/qqbot/src/engine/group/history.test.ts deleted file mode 100644 index 58e381642512..000000000000 --- a/extensions/qqbot/src/engine/group/history.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Qqbot tests cover history plugin behavior. -import { describe, expect, it } from "vitest"; -import { - buildMergedMessageContext, - formatAttachmentTags, - formatMessageContent, - toAttachmentSummaries, -} from "./history.js"; - -describe("engine/group/history", () => { - describe("toAttachmentSummaries", () => { - it("returns undefined for empty input", () => { - expect(toAttachmentSummaries()).toBeUndefined(); - expect(toAttachmentSummaries([])).toBeUndefined(); - }); - - it("normalizes raw fields", () => { - const result = toAttachmentSummaries([ - { - content_type: "image/png", - filename: "a.png", - url: "https://x/a.png", - }, - { - content_type: "voice", - asr_refer_text: "hello", - }, - { content_type: "application/pdf", filename: "doc.pdf" }, - { content_type: "weird/thing" }, - ]); - expect(result).toEqual([ - { type: "image", filename: "a.png", transcript: undefined, url: "https://x/a.png" }, - { type: "voice", filename: undefined, transcript: "hello", url: undefined }, - { type: "file", filename: "doc.pdf", transcript: undefined, url: undefined }, - { type: "unknown", filename: undefined, transcript: undefined, url: undefined }, - ]); - }); - }); - - describe("formatAttachmentTags", () => { - it("renders bracketed source tags for entries with a source", () => { - expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe( - "[image: /tmp/a.png]", - ); - expect(formatAttachmentTags([{ type: "image", url: "https://x/b.png" }])).toBe( - "[image: https://x/b.png]", - ); - }); - - it("inlines transcript for voice w/ source", () => { - expect( - formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]), - ).toBe('[voice: /tmp/v.wav] (transcript: "hi")'); - }); - }); - - describe("formatMessageContent", () => { - it("passes content through parseFaceTags (no-op for plain text)", () => { - // parseFaceTags only rewrites the `` tag form; plain - // text must round-trip unchanged so regressions in the pipeline - // don't silently mangle user input. - expect(formatMessageContent({ content: "hello world" })).toBe("hello world"); - }); - - it("strips mentions only for group chat", () => { - expect( - formatMessageContent({ - content: "<@X>hi", - chatType: "group", - mentions: [{ member_openid: "X", is_you: true }], - }), - ).toBe("hi"); - // Non-group: strip is NOT applied. - expect( - formatMessageContent({ - content: "<@X>hi", - chatType: "c2c", - mentions: [{ member_openid: "X", is_you: true }], - }), - ).toBe("<@X>hi"); - }); - - it("appends attachment tags", () => { - expect( - formatMessageContent({ - content: "see", - attachments: [{ content_type: "image/png", url: "https://x/a.png" }], - }), - ).toBe("see [image: https://x/a.png]"); - }); - }); - - describe("buildMergedMessageContext", () => { - it("returns current message unchanged when no preceding parts", () => { - expect(buildMergedMessageContext({ precedingParts: [], currentMessage: "hi" })).toBe("hi"); - }); - - it("wraps preceding parts with tags", () => { - const out = buildMergedMessageContext({ - precedingParts: ["a", "b"], - currentMessage: "c", - }); - expect(out).toContain("[Merged earlier messages — CONTEXT ONLY]"); - expect(out).toContain("a\nb"); - expect(out).toContain("[CURRENT MESSAGE — reply using the context above]"); - expect(out.endsWith("c")).toBe(true); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/group/history.ts b/extensions/qqbot/src/engine/group/history.ts deleted file mode 100644 index 01676b276a48..000000000000 --- a/extensions/qqbot/src/engine/group/history.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** Group-message content and attachment formatting helpers. */ - -import type { RefAttachmentSummary } from "../ref/types.js"; -import { formatAttachmentTags } from "../utils/attachment-tags.js"; -import { parseFaceTags } from "../utils/text-parsing.js"; -import { stripMentionText, type RawMention } from "./mention.js"; - -// Re-export so existing `from "group/history.js"` imports keep working. -export { formatAttachmentTags } from "../utils/attachment-tags.js"; - -// ───────────────────────────── Constants ───────────────────────────── - -/** Tags wrapping merged sub-messages from the queue. */ -const MERGED_CTX_START = "[Merged earlier messages — CONTEXT ONLY]"; -const MERGED_CTX_END = "[CURRENT MESSAGE — reply using the context above]"; - -// ───────────────────────────── Types ───────────────────────────── - -/** - * Attachment descriptor used inside history entries. - * - * Aligned with `RefAttachmentSummary` so the three places that describe - * attachments (group history cache, ref-index store, and the dynamic - * context block on the current message) all share a single shape. - */ -type AttachmentSummary = RefAttachmentSummary; - -/** Raw attachment fields carried in a QQ event (the union we actually read). */ -interface RawAttachment { - content_type: string; - filename?: string; - /** Pre-computed ASR transcription text provided by QQ's gateway. */ - asr_refer_text?: string; - url?: string; -} - -/** One cached history entry. */ -export interface HistoryEntry { - /** Display label for the sender (e.g. "Nick (OPENID)"). */ - sender: string; - /** Message body already stripped / formatted for the AI. */ - body: string; - timestamp?: number; - messageId?: string; - /** Rich-media attachments to render inline on @-activation. */ - attachments?: AttachmentSummary[]; -} - -/** Parameters for {@link formatMessageContent}. */ -interface FormatMessageContentParams { - content: string; - /** Message channel — `stripMentionText` only fires for `"group"`. */ - chatType?: string; - mentions?: RawMention[]; - attachments?: RawAttachment[]; -} - -// ───────────────────────────── Content formatting ───────────────────────────── - -/** Map a raw QQ content-type string onto the normalized attachment type. */ -function inferAttachmentType(contentType?: string): AttachmentSummary["type"] { - const ct = (contentType ?? "").toLowerCase(); - if (ct.startsWith("image/")) { - return "image"; - } - if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr")) { - return "voice"; - } - if (ct.startsWith("video/")) { - return "video"; - } - if (ct.startsWith("application/") || ct.startsWith("text/")) { - return "file"; - } - return "unknown"; -} - -/** - * Convert raw QQ-event attachments into `AttachmentSummary` entries. - * - * When `localPaths` is provided (from `ProcessedAttachments.attachmentLocalPaths`), - * each summary is enriched with the local file path so that history context - * renders the downloaded path instead of the ephemeral QQ CDN URL. - * - * Returns `undefined` (rather than `[]`) when no attachments are provided - * so that callers can omit the field from their result objects. - */ -export function toAttachmentSummaries( - attachments?: RawAttachment[], - localPaths?: Array, -): AttachmentSummary[] | undefined { - if (!attachments?.length) { - return undefined; - } - return attachments.map( - (att, i): AttachmentSummary => ({ - type: inferAttachmentType(att.content_type), - filename: att.filename, - transcript: att.asr_refer_text || undefined, - localPath: localPaths?.[i] || undefined, - url: att.url || undefined, - }), - ); -} - -/** - * Format one sub-message: emoji parsing → mention cleanup → attachment tags. - * - * Used for the merged-message path where several queued messages are - * rendered together. `parseFaceTags` and `stripMentionText` are imported - * directly — both are pure utilities inside the same engine and do not - * warrant DI overhead. - */ -export function formatMessageContent(params: FormatMessageContentParams): string { - let msgContent = parseFaceTags(params.content); - - if (params.chatType === "group" && params.mentions?.length) { - msgContent = stripMentionText(msgContent, params.mentions); - } - - if (params.attachments?.length) { - const attachmentDesc = formatAttachmentTags(toAttachmentSummaries(params.attachments)); - if (attachmentDesc) { - msgContent = `${msgContent} ${attachmentDesc}`; - } - } - - return msgContent; -} - -// ───────────────────────────── Attachment tags ───────────────────────────── -// -// `formatAttachmentTags` lives in `utils/attachment-tags.ts` (the single -// source of truth shared with the ref-index renderer). It is re-exported -// from the top of this file so existing `from "group/history.js"` imports -// continue to work. - -// ───────────────────────────── Public API ───────────────────────────── - -/** - * Wrap a batch of merged messages with begin/end tags and append the - * current user turn at the bottom. - * - * When `precedingParts` is empty, `currentMessage` is returned unchanged. - */ -export function buildMergedMessageContext(params: { - precedingParts: string[]; - currentMessage: string; - lineBreak?: string; -}): string { - const { precedingParts, currentMessage } = params; - if (precedingParts.length === 0) { - return currentMessage; - } - - const lineBreak = params.lineBreak ?? "\n"; - return [MERGED_CTX_START, precedingParts.join(lineBreak), MERGED_CTX_END, currentMessage].join( - lineBreak, - ); -} diff --git a/extensions/qqbot/src/engine/group/mention.test.ts b/extensions/qqbot/src/engine/group/mention.test.ts deleted file mode 100644 index b2784c865368..000000000000 --- a/extensions/qqbot/src/engine/group/mention.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Qqbot tests cover mention plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - detectWasMentioned, - hasAnyMention, - resolveImplicitMention, - stripMentionText, -} from "./mention.js"; - -vi.mock("../utils/log.js", () => ({ - debugWarn: vi.fn(), -})); - -afterEach(() => { - vi.clearAllMocks(); - vi.restoreAllMocks(); -}); - -describe("engine/group/mention", () => { - describe("detectWasMentioned", () => { - it("returns true when mentions contains is_you", () => { - expect(detectWasMentioned({ mentions: [{ is_you: true }] })).toBe(true); - }); - - it("returns true for GROUP_AT_MESSAGE_CREATE even without mentions", () => { - expect(detectWasMentioned({ eventType: "GROUP_AT_MESSAGE_CREATE" })).toBe(true); - }); - - it("matches by mentionPatterns regex", () => { - expect( - detectWasMentioned({ content: "@xiaoke help me", mentionPatterns: ["^@xiaoke"] }), - ).toBe(true); - }); - - it("returns false when no signal matches", () => { - expect( - detectWasMentioned({ - eventType: "GROUP_MESSAGE_CREATE", - mentions: [{ member_openid: "USER1" }], - content: "hello", - mentionPatterns: ["^@bot"], - }), - ).toBe(false); - }); - - it("ignores invalid regex patterns gracefully", () => { - // "[" is an invalid regex; should not throw. - expect(detectWasMentioned({ content: "hi", mentionPatterns: ["[", "@bot"] })).toBe(false); - }); - - it("rejects ReDoS patterns via compileSafeRegexDetailed guard", () => { - // "(a+)+" has nested repetition — catastrophic backtracking on long input. - // The guard must reject it (return false) rather than hang. - const longInput = "a".repeat(64); - expect(detectWasMentioned({ content: longInput, mentionPatterns: ["(a+)+$"] })).toBe(false); - }); - - it("still matches safe patterns after a rejected unsafe one", () => { - // Unsafe pattern is skipped; the next safe pattern should still match. - expect( - detectWasMentioned({ - content: "hello @bot", - mentionPatterns: ["(a+)+$", "@bot"], - }), - ).toBe(true); - }); - - it("emits a debugWarn with the rejection reason for each rejected pattern", async () => { - const { debugWarn } = await import("../utils/log.js"); - detectWasMentioned({ - content: "hi", - mentionPatterns: ["(b+)+$", "[invalid", "@safe"], - }); - expect(debugWarn).toHaveBeenCalledTimes(2); - expect(vi.mocked(debugWarn).mock.calls[0]![0]).toContain("unsafe-nested-repetition"); - expect(vi.mocked(debugWarn).mock.calls[0]![0]).toMatch(/\(b\+\)\+\$/); - expect(vi.mocked(debugWarn).mock.calls[1]![0]).toContain("invalid-regex"); - expect(vi.mocked(debugWarn).mock.calls[1]![0]).toMatch(/\[invalid/); - }); - - it("does not re-warn rejected mentionPatterns on every message", async () => { - const { debugWarn } = await import("../utils/log.js"); - const input = { - content: "hi", - mentionPatterns: ["(c+)+$", "@safe"], - }; - - detectWasMentioned(input); - detectWasMentioned(input); - - expect(debugWarn).toHaveBeenCalledTimes(1); - expect(vi.mocked(debugWarn).mock.calls[0]![0]).toMatch(/\(c\+\)\+\$/); - }); - - it("matches case-insensitively", () => { - expect(detectWasMentioned({ content: "Hello @Bot", mentionPatterns: ["@bot"] })).toBe(true); - }); - - it("skips empty patterns", () => { - expect(detectWasMentioned({ content: "hi", mentionPatterns: ["", " "] })).toBe(false); - }); - - it("returns false when everything is empty", () => { - expect(detectWasMentioned({})).toBe(false); - }); - }); - - describe("hasAnyMention", () => { - it("detects mentions array", () => { - expect(hasAnyMention({ mentions: [{ member_openid: "X" }] })).toBe(true); - }); - - it("detects mention tags in text", () => { - expect(hasAnyMention({ content: "hi <@ABC123>" })).toBe(true); - expect(hasAnyMention({ content: "hi <@!ABC123>" })).toBe(true); - }); - - it("returns false when nothing mentioned", () => { - expect(hasAnyMention({ content: "just a normal message" })).toBe(false); - expect(hasAnyMention({})).toBe(false); - }); - }); - - describe("stripMentionText", () => { - it("removes self-mention tag", () => { - expect(stripMentionText("<@BOTID> hello", [{ member_openid: "BOTID", is_you: true }])).toBe( - "hello", - ); - }); - - it("replaces other-user tag with @nickname", () => { - expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1", nickname: "Alice" }])).toBe( - "hi @Alice", - ); - }); - - it("falls back to username when nickname missing", () => { - expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1", username: "alice" }])).toBe( - "hi @alice", - ); - }); - - it("leaves unknown mentions untouched", () => { - // No display name, so the tag cannot be prettified — keep raw. - expect(stripMentionText("hi <@USER1>", [{ member_openid: "USER1" }])).toBe("hi <@USER1>"); - }); - - it("handles <@!openid> variant", () => { - expect(stripMentionText("hi <@!USER1>", [{ member_openid: "USER1", nickname: "A" }])).toBe( - "hi @A", - ); - }); - - it("returns the original text when no mentions array is provided", () => { - expect(stripMentionText("hi <@X>", [])).toBe("hi <@X>"); - expect(stripMentionText("hi <@X>")).toBe("hi <@X>"); - }); - - it("escapes regex meta-characters in openid", () => { - // Defensive: even if QQ ever sends openids with unusual characters, - // the function should not explode nor produce a bogus regex. - expect(stripMentionText("see <@A.B+C>", [{ member_openid: "A.B+C", nickname: "X" }])).toBe( - "see @X", - ); - }); - }); - - describe("resolveImplicitMention", () => { - it("returns false when refMsgIdx is missing", () => { - expect(resolveImplicitMention({ getRefEntry: () => null })).toBe(false); - }); - - it("returns true when the referenced entry is a bot message", () => { - expect( - resolveImplicitMention({ - refMsgIdx: "R1", - getRefEntry: (id) => (id === "R1" ? { isBot: true } : null), - }), - ).toBe(true); - }); - - it("returns false when ref entry exists but is not a bot", () => { - expect( - resolveImplicitMention({ - refMsgIdx: "R1", - getRefEntry: () => ({ isBot: false }), - }), - ).toBe(false); - }); - - it("returns false when ref entry is missing", () => { - expect(resolveImplicitMention({ refMsgIdx: "R1", getRefEntry: () => null })).toBe(false); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/group/mention.ts b/extensions/qqbot/src/engine/group/mention.ts deleted file mode 100644 index 921f0d9290e2..000000000000 --- a/extensions/qqbot/src/engine/group/mention.ts +++ /dev/null @@ -1,169 +0,0 @@ -// Qqbot plugin module implements mention behavior. -import { - compileSafeRegexDetailed, - type SafeRegexRejectReason, -} from "openclaw/plugin-sdk/security-runtime"; -import { debugWarn } from "../utils/log.js"; -export interface RawMention { - is_you?: boolean; - bot?: boolean; - member_openid?: string; - id?: string; - user_openid?: string; - nickname?: string; - username?: string; - scope?: "all" | "single"; -} - -interface DetectWasMentionedInput { - eventType?: string; - mentions?: RawMention[]; - content?: string; - mentionPatterns?: string[]; -} - -interface HasAnyMentionInput { - mentions?: RawMention[]; - content?: string; -} - -const MENTION_TAG_RE = /<@!?\w+>/; -const MENTION_PATTERN_FLAGS = "i"; -const MAX_MENTION_PATTERN_CACHE_KEYS = 256; -const MAX_MENTION_PATTERN_WARNING_KEYS = 256; -const mentionPatternCompileCache = new Map(); -const rejectedMentionPatternWarningCache = new Set(); - -type MentionPatternRejectReason = Exclude; - -function warnRejectedMentionPattern(pattern: string, reason: MentionPatternRejectReason): void { - const key = `${MENTION_PATTERN_FLAGS}::${reason}::${pattern}`; - if (rejectedMentionPatternWarningCache.has(key)) { - return; - } - rejectedMentionPatternWarningCache.add(key); - if (rejectedMentionPatternWarningCache.size > MAX_MENTION_PATTERN_WARNING_KEYS) { - rejectedMentionPatternWarningCache.clear(); - rejectedMentionPatternWarningCache.add(key); - } - debugWarn(`qqbot: mentionPattern rejected (${reason}): ${pattern}`); -} - -function cacheMentionPatterns(cacheKey: string, regexes: RegExp[]): RegExp[] { - mentionPatternCompileCache.set(cacheKey, regexes); - if (mentionPatternCompileCache.size > MAX_MENTION_PATTERN_CACHE_KEYS) { - mentionPatternCompileCache.clear(); - mentionPatternCompileCache.set(cacheKey, regexes); - } - return regexes; -} - -function compileMentionPatterns(patterns: string[]): RegExp[] { - if (patterns.length === 0) { - return []; - } - const cacheKey = patterns.join("\u001f"); - const cached = mentionPatternCompileCache.get(cacheKey); - if (cached) { - return cached; - } - - const regexes: RegExp[] = []; - for (const pattern of patterns) { - const result = compileSafeRegexDetailed(pattern, MENTION_PATTERN_FLAGS); - if (result.reason === "empty") { - continue; - } - if (result.regex) { - regexes.push(result.regex); - continue; - } - warnRejectedMentionPattern(result.source, result.reason); - } - return cacheMentionPatterns(cacheKey, regexes); -} - -export function detectWasMentioned(input: DetectWasMentionedInput): boolean { - const { eventType, mentions, content, mentionPatterns } = input; - - if (mentions?.some((m) => m.is_you)) { - return true; - } - - if (eventType === "GROUP_AT_MESSAGE_CREATE") { - return true; - } - - if (mentionPatterns?.length && content) { - for (const regex of compileMentionPatterns(mentionPatterns)) { - if (regex.test(content)) { - return true; - } - } - } - - return false; -} - -export function hasAnyMention(input: HasAnyMentionInput): boolean { - if (input.mentions && input.mentions.length > 0) { - return true; - } - if (input.content && MENTION_TAG_RE.test(input.content)) { - return true; - } - return false; -} - -export function stripMentionText(text: string, mentions?: RawMention[]): string { - if (!text || !mentions?.length) { - return text; - } - let cleaned = text; - for (const m of mentions) { - const openid = m.member_openid ?? m.id ?? m.user_openid; - if (!openid) { - continue; - } - const tagRe = new RegExp(`<@!?${escapeRegex(openid)}>`, "g"); - if (m.is_you) { - cleaned = cleaned.replace(tagRe, "").trim(); - } else { - const displayName = m.nickname ?? m.username; - if (displayName) { - cleaned = cleaned.replace(tagRe, `@${displayName}`); - } - } - } - return cleaned; -} - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -// ============ Implicit mention (quoted bot message) ============ - -/** - * Decide whether a quoted-reply should count as an implicit @bot. - * - * When the user quotes an earlier bot message, we treat the new message - * as if it @-ed the bot, even without a literal mention. This lives in - * the mention module (rather than with activation) because semantically - * it answers the same question as `detectWasMentioned`: - * "was the bot addressed by this message?". - * - * The `getRefEntry` callback is injected so this function does not - * depend on the ref-index store implementation — any lookup that - * returns `{ isBot?: boolean }` works. - */ -export function resolveImplicitMention(params: { - refMsgIdx?: string; - getRefEntry: (idx: string) => { isBot?: boolean } | null; -}): boolean { - if (!params.refMsgIdx) { - return false; - } - const refEntry = params.getRefEntry(params.refMsgIdx); - return refEntry?.isBot === true; -} diff --git a/extensions/qqbot/src/engine/group/message-gating.test.ts b/extensions/qqbot/src/engine/group/message-gating.test.ts deleted file mode 100644 index 840072350ae7..000000000000 --- a/extensions/qqbot/src/engine/group/message-gating.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Qqbot tests cover message gating plugin behavior. -import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-mention-gating"; -import { describe, expect, it } from "vitest"; -import type { MentionGatePort } from "../adapter/mention-gate.port.js"; -import { resolveGroupMessageGate, type GroupMessageGateResult } from "./message-gating.js"; - -type GroupMessageGateInput = Parameters[0]; - -// Real SDK-backed port so these tests prove gate parity against the canonical -// mention decision engine, not a stub. -const mentionGatePort: MentionGatePort = { resolveInboundMentionDecision }; - -// Compose a full input so each test can override just the interesting axis. -function input(overrides: Partial): GroupMessageGateInput { - return { - mentionGatePort, - ignoreOtherMentions: false, - hasAnyMention: false, - wasMentioned: false, - implicitMention: false, - allowTextCommands: true, - isControlCommand: false, - commandAuthorized: false, - requireMention: true, - ...overrides, - }; -} - -function expectAction( - result: GroupMessageGateResult, - action: GroupMessageGateResult["action"], -): void { - expect(result.action).toBe(action); -} - -describe("engine/group/message-gating", () => { - describe("Layer 1: ignoreOtherMentions", () => { - it("drops messages that @other users when enabled", () => { - const result = resolveGroupMessageGate( - input({ ignoreOtherMentions: true, hasAnyMention: true }), - ); - expectAction(result, "drop_other_mention"); - }); - - it("does NOT drop when the bot itself was @-ed", () => { - const result = resolveGroupMessageGate( - input({ ignoreOtherMentions: true, hasAnyMention: true, wasMentioned: true }), - ); - expectAction(result, "pass"); - }); - - it("does NOT drop when implicitly mentioned via quote", () => { - const result = resolveGroupMessageGate( - input({ ignoreOtherMentions: true, hasAnyMention: true, implicitMention: true }), - ); - expectAction(result, "pass"); - }); - - it("is inactive when ignoreOtherMentions is off", () => { - const result = resolveGroupMessageGate( - input({ ignoreOtherMentions: false, hasAnyMention: true }), - ); - // Falls through to mention gate — requireMention on, so skipped. - expectAction(result, "skip_no_mention"); - }); - }); - - describe("Layer 2: unauthorized control command", () => { - it("silently blocks an unauthorized /stop", () => { - const result = resolveGroupMessageGate( - input({ isControlCommand: true, commandAuthorized: false }), - ); - expectAction(result, "block_unauthorized_command"); - }); - - it("passes through when sender is authorized", () => { - const result = resolveGroupMessageGate( - input({ isControlCommand: true, commandAuthorized: true, wasMentioned: true }), - ); - expectAction(result, "pass"); - }); - - it("does not trigger when text commands are disabled", () => { - const result = resolveGroupMessageGate( - input({ - allowTextCommands: false, - isControlCommand: true, - commandAuthorized: false, - wasMentioned: true, - }), - ); - // allowTextCommands=false skips the block, so the mention gate decides. - expectAction(result, "pass"); - }); - }); - - describe("Layer 3: mention gating", () => { - it("requires @bot when requireMention is on", () => { - const result = resolveGroupMessageGate(input({ requireMention: true })); - expectAction(result, "skip_no_mention"); - expect(result.effectiveWasMentioned).toBe(false); - }); - - it("passes through when explicitly mentioned", () => { - const result = resolveGroupMessageGate(input({ requireMention: true, wasMentioned: true })); - expectAction(result, "pass"); - expect(result.effectiveWasMentioned).toBe(true); - }); - - it("passes through on implicit mention", () => { - const result = resolveGroupMessageGate( - input({ requireMention: true, implicitMention: true }), - ); - expectAction(result, "pass"); - expect(result.effectiveWasMentioned).toBe(true); - }); - - it("passes through when requireMention is off", () => { - const result = resolveGroupMessageGate(input({ requireMention: false })); - expectAction(result, "pass"); - }); - }); - - describe("command bypass", () => { - it("bypasses mention gate for an authorized control command", () => { - const result = resolveGroupMessageGate( - input({ - requireMention: true, - isControlCommand: true, - commandAuthorized: true, - allowTextCommands: true, - }), - ); - expectAction(result, "pass"); - expect(result.shouldBypassMention).toBe(true); - expect(result.effectiveWasMentioned).toBe(true); - }); - - it("does NOT bypass when the command @-s another user", () => { - const result = resolveGroupMessageGate( - input({ - requireMention: true, - isControlCommand: true, - commandAuthorized: true, - hasAnyMention: true, - }), - ); - expectAction(result, "skip_no_mention"); - expect(result.shouldBypassMention).toBe(false); - }); - - it("is a no-op when requireMention is off", () => { - const result = resolveGroupMessageGate( - input({ - requireMention: false, - isControlCommand: true, - commandAuthorized: true, - }), - ); - expectAction(result, "pass"); - // requireMention=false means bypass is unnecessary (condition 1 fails). - expect(result.shouldBypassMention).toBe(false); - }); - }); - - describe("priority ordering", () => { - it("layer 1 wins over layer 2 (ignoreOtherMentions before block)", () => { - const result = resolveGroupMessageGate( - input({ - ignoreOtherMentions: true, - hasAnyMention: true, - isControlCommand: true, - commandAuthorized: false, - }), - ); - expectAction(result, "drop_other_mention"); - }); - - it("layer 2 wins over layer 3 (unauthorized command before skip)", () => { - const result = resolveGroupMessageGate( - input({ requireMention: true, isControlCommand: true, commandAuthorized: false }), - ); - expectAction(result, "block_unauthorized_command"); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/group/message-gating.ts b/extensions/qqbot/src/engine/group/message-gating.ts deleted file mode 100644 index 77044fa20d4d..000000000000 --- a/extensions/qqbot/src/engine/group/message-gating.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Qqbot plugin module implements message gating behavior. -import type { MentionGatePort } from "../adapter/mention-gate.port.js"; - -type GroupMessageGateAction = - | "drop_other_mention" - | "block_unauthorized_command" - | "skip_no_mention" - | "pass"; - -export interface GroupMessageGateResult { - action: GroupMessageGateAction; - effectiveWasMentioned: boolean; - shouldBypassMention: boolean; -} - -interface GroupMessageGateInput { - mentionGatePort: MentionGatePort; - ignoreOtherMentions: boolean; - hasAnyMention: boolean; - wasMentioned: boolean; - implicitMention: boolean; - allowTextCommands: boolean; - isControlCommand: boolean; - commandAuthorized: boolean; - requireMention: boolean; -} - -/** - * Group gate Layer 1 (ignoreOtherMentions) is QQ-specific and decided here; - * Layer 2+3 (command gating + mention gating + command bypass) delegate to the - * mention gate port backed by the SDK's `resolveInboundMentionDecision`. - */ -export function resolveGroupMessageGate(params: GroupMessageGateInput): GroupMessageGateResult { - if ( - params.ignoreOtherMentions && - params.hasAnyMention && - !params.wasMentioned && - !params.implicitMention - ) { - return { - action: "drop_other_mention", - effectiveWasMentioned: false, - shouldBypassMention: false, - }; - } - - const decision = params.mentionGatePort.resolveInboundMentionDecision({ - facts: { - canDetectMention: true, - wasMentioned: params.wasMentioned, - hasAnyMention: params.hasAnyMention, - implicitMentionKinds: params.implicitMention ? ["reply_to_bot"] : [], - }, - policy: { - isGroup: true, - requireMention: params.requireMention, - allowTextCommands: params.allowTextCommands, - hasControlCommand: params.isControlCommand, - commandAuthorized: params.commandAuthorized, - }, - }); - - if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) { - return { - action: "block_unauthorized_command", - effectiveWasMentioned: false, - shouldBypassMention: false, - }; - } - - if (decision.shouldSkip) { - return { - action: "skip_no_mention", - effectiveWasMentioned: decision.effectiveWasMentioned, - shouldBypassMention: decision.shouldBypassMention, - }; - } - - return { - action: "pass", - effectiveWasMentioned: decision.effectiveWasMentioned, - shouldBypassMention: decision.shouldBypassMention, - }; -} diff --git a/extensions/qqbot/src/engine/messaging/decode-media-path.test.ts b/extensions/qqbot/src/engine/messaging/decode-media-path.test.ts deleted file mode 100644 index bb81a38dc076..000000000000 --- a/extensions/qqbot/src/engine/messaging/decode-media-path.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Qqbot tests cover decode media path plugin behavior. -import { afterEach, describe, expect, it } from "vitest"; -import { decodeMediaPath } from "./decode-media-path.js"; - -const originalHome = process.env.HOME; -const originalUserProfile = process.env.USERPROFILE; - -function restoreEnv(name: "HOME" | "USERPROFILE", value: string | undefined) { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } -} - -afterEach(() => { - restoreEnv("HOME", originalHome); - restoreEnv("USERPROFILE", originalUserProfile); -}); - -describe("decodeMediaPath", () => { - it("preserves Windows home-relative paths with digit segments", () => { - delete process.env.HOME; - process.env.USERPROFILE = String.raw`C:\Users\operator`; - - expect(decodeMediaPath(String.raw`~\1\photo.png`)).toBe( - String.raw`C:\Users\operator\1\photo.png`, - ); - }); - - it("prefers USERPROFILE for Windows home-relative paths when HOME is POSIX-style", () => { - process.env.HOME = "/c/Users/operator"; - process.env.USERPROFILE = String.raw`C:\Users\operator`; - - expect(decodeMediaPath(String.raw`~\1\photo.png`)).toBe( - String.raw`C:\Users\operator\1\photo.png`, - ); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/decode-media-path.ts b/extensions/qqbot/src/engine/messaging/decode-media-path.ts deleted file mode 100644 index 1297b95d64e6..000000000000 --- a/extensions/qqbot/src/engine/messaging/decode-media-path.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Media path decoding utility. - * - * Extracted from `outbound-deliver.ts` — handles tilde expansion, - * octal escape / UTF-8 byte-sequence decoding, and backslash unescaping that - * media tags require. - * - * Zero external dependencies. - */ - -import type { EngineLogger } from "../types.js"; - -function getHomeForTildePath(windowsStyle: boolean): string | undefined { - if (windowsStyle && process.env.USERPROFILE) { - return process.env.USERPROFILE; - } - return process.env.HOME ?? process.env.USERPROFILE; -} - -/** - * Normalize a file path by expanding `~` to the home directory and trimming. - * - * This is a minimal re-implementation of `utils/platform.ts#normalizePath` - * so that `core/` remains self-contained. - */ -function normalizePath(p: string): string { - let result = p.trim(); - if (result.startsWith("file://")) { - result = result.slice("file://".length); - try { - result = decodeURIComponent(result); - } catch { - // Keep the raw string if decoding fails. - } - } - const windowsStyleHomePath = result.startsWith("~\\"); - if (result === "~" || result.startsWith("~/") || windowsStyleHomePath) { - const home = - typeof process !== "undefined" ? getHomeForTildePath(windowsStyleHomePath) : undefined; - if (home) { - result = result === "~" ? home : `${home}${result.slice(1)}`; - } - } - return result; -} - -/** - * Decode a media path by expanding `~` and unescaping octal/UTF-8 byte - * sequences. - * - * @param raw - Raw path string from a media tag. - * @param log - Optional logger for decode diagnostics. - * @returns The decoded, normalized media path. - */ -export function decodeMediaPath(raw: string, log?: EngineLogger): string { - let mediaPath = raw; - mediaPath = normalizePath(mediaPath); - mediaPath = mediaPath.replace(/\\\\/g, "\\"); - - // Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt) - // where backslash-digit sequences like \1, \2 ... \7 are directory separators, - // not octal escape sequences. - const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\"); - try { - const hasOctal = /\\[0-7]{1,3}/.test(mediaPath); - const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath); - - if (!isWinLocal && (hasOctal || hasNonASCII)) { - log?.debug?.(`Decoding path with mixed encoding: ${mediaPath}`); - const decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => { - return String.fromCharCode(Number.parseInt(octal, 8)); - }); - const bytes: number[] = []; - for (let i = 0; i < decoded.length; i++) { - const code = decoded.charCodeAt(i); - if (code <= 0xff) { - bytes.push(code); - } else { - const charBytes = Buffer.from(decoded.charAt(i), "utf8"); - bytes.push(...charBytes); - } - } - const buffer = Buffer.from(bytes); - const utf8Decoded = buffer.toString("utf8"); - if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) { - mediaPath = utf8Decoded; - log?.debug?.(`Successfully decoded path: ${mediaPath}`); - } - } - } catch (decodeErr) { - log?.error(`Path decode error: ${String(decodeErr)}`); - } - - return mediaPath; -} diff --git a/extensions/qqbot/src/engine/messaging/markdown-format.ts b/extensions/qqbot/src/engine/messaging/markdown-format.ts deleted file mode 100644 index d5ec10c71c85..000000000000 --- a/extensions/qqbot/src/engine/messaging/markdown-format.ts +++ /dev/null @@ -1,481 +0,0 @@ -// QQ Bot Markdown formatting declares dialect capabilities and applies shared fallbacks. - -import { - FormatCapabilityProfile, - type MarkdownIR, - markdownToIR, - renderMarkdownIRChunksWithinLimit, - renderMarkdownWithMarkers, - sliceMarkdownIR, -} from "openclaw/plugin-sdk/text-chunking"; - -const QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT = 3600; -const QQBOT_MARKDOWN_ESCAPE_RE = /([\\`*_{}[\]()#+\-.!|>~])/gu; -const ESCAPED_MARKDOWN_RE = /\\[\\`*_{}[\]()#+\-.!|>~]/gu; -const MARKDOWN_ENTITY_RE = /&(?:#\d+|#x[\da-f]+|[a-z][a-z\d]+);/giu; -const PROTECTED_TOKEN_RANGES = [ - [0xe000, 0xf8ff], - [0x3400, 0x9fff], - [0xac00, 0xd7a3], -] as const; -const PROTECTED_TOKEN_RE = /[\u3400-\u9FFF\uAC00-\uD7A3\uE000-\uF8FF]/gu; -const PROTECTED_IMAGE_OVERHEAD_BYTES = 64; - -function resolveQQBotMarkdownChunkLimit(limit: number): number { - return Math.min(limit, QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT); -} - -function utf8ByteLength(text: string): number { - return Buffer.byteLength(text, "utf8"); -} - -const QQBOT_FORMAT_CAPABILITIES = FormatCapabilityProfile.define({ - mechanism: "markdown", - constructs: { - underline: "strip", - spoiler: "strip", - codeInline: "fallback", - codeBlock: "fallback", - codeLanguage: "fallback", - table: "fallback", - }, - chunk: { limit: QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT, unit: "bytes" }, -}); - -const QQBOT_MARKERS = { - bold: { open: "**", close: "**" }, - italic: { open: "*", close: "*" }, - strikethrough: { open: "~~", close: "~~" }, - heading_1: { open: "# ", close: "" }, - heading_2: { open: "## ", close: "" }, - heading_3: { open: "### ", close: "" }, - heading_4: { open: "#### ", close: "" }, - heading_5: { open: "##### ", close: "" }, - heading_6: { open: "###### ", close: "" }, -} as const; - -function createProtectedTokenStore(source: string) { - const normalized = markdownToIR(source, { autolink: false, linkify: false }).text; - const occupied = new Set(); - for (const text of [source, normalized]) { - for (const character of text) { - occupied.add(character); - } - } - const values = new Map(); - const reusable = new Map(); - let rangeIndex = 0; - let codePoint: number = PROTECTED_TOKEN_RANGES[0][0]; - const next = (value: string): string => { - while (rangeIndex < PROTECTED_TOKEN_RANGES.length) { - const range = PROTECTED_TOKEN_RANGES[rangeIndex]; - if (!range) { - break; - } - if (codePoint > range[1]) { - rangeIndex += 1; - codePoint = PROTECTED_TOKEN_RANGES[rangeIndex]?.[0] ?? Number.POSITIVE_INFINITY; - continue; - } - const token = String.fromCharCode(codePoint++); - if (!occupied.has(token) && !values.has(token)) { - values.set(token, value); - return token; - } - } - return value; - }; - return { - next, - reuse: (value: string) => { - const existing = reusable.get(value); - if (existing) { - return existing; - } - const token = next(value); - reusable.set(value, token); - return token; - }, - restore: (text: string) => - text.replace(PROTECTED_TOKEN_RE, (token) => values.get(token) ?? token), - }; -} - -function escapeQQMarkdownSyntax(text: string): string { - return text.replace(QQBOT_MARKDOWN_ESCAPE_RE, "\\$1"); -} - -type TextEdit = { start: number; end: number; text: string }; - -function rewriteMarkdownIR(ir: MarkdownIR, edits: readonly TextEdit[]): MarkdownIR { - if (edits.length === 0) { - return ir; - } - const ordered = [...edits].toSorted((a, b) => a.start - b.start); - let text = ""; - let cursor = 0; - for (const edit of ordered) { - text += ir.text.slice(cursor, edit.start) + edit.text; - cursor = edit.end; - } - text += ir.text.slice(cursor); - - const cumulativeDeltas: number[] = []; - let delta = 0; - for (const edit of ordered) { - delta += edit.text.length - (edit.end - edit.start); - cumulativeDeltas.push(delta); - } - const exactEdits = new Map(ordered.map((edit) => [`${edit.start}:${edit.end}`, edit])); - const mapOffset = (offset: number): number => { - let low = 0; - let high = ordered.length; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if ((ordered[middle]?.end ?? Number.POSITIVE_INFINITY) <= offset) { - low = middle + 1; - } else { - high = middle; - } - } - return offset + (low > 0 ? (cumulativeDeltas[low - 1] ?? 0) : 0); - }; - const mapRange = (range: T): T => { - const exact = exactEdits.get(`${range.start}:${range.end}`); - const start = mapOffset(range.start); - return { ...range, start, end: exact ? start + exact.text.length : mapOffset(range.end) }; - }; - return { - ...ir, - text, - styles: ir.styles.map(mapRange), - links: ir.links.map(mapRange), - ...(ir.annotations ? { annotations: ir.annotations.map(mapRange) } : {}), - ...(ir.listItems - ? { - listItems: ir.listItems.map((item) => ({ - ...item, - ...(item.listMarker ? { listMarker: mapRange(item.listMarker) } : {}), - ...(item.taskMarker ? { taskMarker: mapRange(item.taskMarker) } : {}), - })), - } - : {}), - }; -} - -function prefixQQBotBlockquotes(ir: MarkdownIR): MarkdownIR { - const quoteSpans = ir.styles.filter((span) => span.style === "blockquote"); - const edits = quoteSpans.flatMap((span) => { - const positions: number[] = []; - for (let index = span.start; index < span.end; index += 1) { - if (ir.text[index] === "\n" && index + 1 < span.end) { - positions.push(index + 1); - } - } - return positions.map((position) => ({ start: position, end: position, text: "> " })); - }); - return rewriteMarkdownIR(ir, edits); -} - -function escapeQQFallbackCode( - ir: MarkdownIR, - protectEscape: (escaped: string) => string, -): MarkdownIR { - return rewriteMarkdownIR( - ir, - ir.styles - .filter((span) => span.style === "code" || span.style === "code_block") - .map((span) => ({ - start: span.start, - end: span.end, - text: ir.text - .slice(span.start, span.end) - .replace(QQBOT_MARKDOWN_ESCAPE_RE, (char) => protectEscape(`\\${char}`)), - })), - ); -} - -function specializeProtectedTokensInCode( - ir: MarkdownIR, - tokens: readonly string[], - protectedTokens: ReturnType, -): MarkdownIR { - const codeStyles = ir.styles.filter( - (span) => span.style === "code" || span.style === "code_block", - ); - const edits: TextEdit[] = []; - const protectedSet = new Set(tokens); - for (let start = 0; start < ir.text.length; start += 1) { - const token = ir.text[start] ?? ""; - if ( - protectedSet.has(token) && - codeStyles.some((span) => start >= span.start && start + token.length <= span.end) - ) { - const escaped = escapeQQMarkdownSyntax(protectedTokens.restore(token)); - edits.push({ start, end: start + token.length, text: protectedTokens.reuse(escaped) }); - } - } - return rewriteMarkdownIR(ir, edits); -} - -type ImageCandidateScan = { end: number } | { next: number } | undefined; - -function blankBlockEnd(text: string, index: number): number | undefined { - const match = /^(?:\r?\n)[ \t]*(?:\r?\n)/u.exec(text.slice(index)); - return match ? index + match[0].length : undefined; -} - -function scanQQBotMarkdownImage(text: string, start: number): ImageCandidateScan { - let bracketDepth = 1; - let altEnd: number | undefined; - let fallbackNext: number | undefined; - for (let index = start + 2; index < text.length; index += 1) { - const blankEnd = blankBlockEnd(text, index); - if (blankEnd !== undefined) { - return { next: fallbackNext ?? blankEnd }; - } - if (text[index] === "\\") { - index += 1; - } else if (text.startsWith("![", index)) { - fallbackNext = index; - bracketDepth += 1; - index += 1; - } else if (text[index] === "[") { - bracketDepth += 1; - } else if (text[index] === "]" && --bracketDepth === 0) { - altEnd = index; - break; - } - } - if (altEnd === undefined || text[altEnd + 1] !== "(") { - const next = fallbackNext ?? text.indexOf("![", altEnd === undefined ? start + 2 : altEnd + 1); - return next < 0 ? undefined : { next }; - } - - let parenDepth = 1; - for (let index = altEnd + 2; index < text.length; index += 1) { - const blankEnd = blankBlockEnd(text, index); - if (blankEnd !== undefined) { - return { next: fallbackNext ?? blankEnd }; - } - if (text[index] === "\\") { - index += 1; - } else if (text.startsWith("![", index)) { - fallbackNext = index; - } else if (text[index] === "(") { - parenDepth += 1; - } else if (text[index] === ")" && --parenDepth === 0) { - return { end: index + 1 }; - } - } - return fallbackNext === undefined ? undefined : { next: fallbackNext }; -} - -function protectQQBotMarkdownImages( - text: string, - createToken: (image: string) => string, - byteLimit: number, -): { text: string; tokens: string[] } { - let protectedText = ""; - let cursor = 0; - let searchFrom = 0; - const tokens: string[] = []; - while (searchFrom < text.length) { - const start = text.indexOf("![", searchFrom); - if (start < 0) { - break; - } - const scan = scanQQBotMarkdownImage(text, start); - if (!scan) { - break; - } - if ("next" in scan) { - searchFrom = scan.next; - continue; - } - let slashStart = start; - while (slashStart > cursor && text[slashStart - 1] === "\\") { - slashStart -= 1; - } - const escaped = (start - slashStart) % 2 === 1; - const image = text.slice(start, scan.end); - const protectedSize = Math.max( - utf8ByteLength(image), - utf8ByteLength(escapeQQMarkdownSyntax(image)), - ); - if (protectedSize + PROTECTED_IMAGE_OVERHEAD_BYTES > byteLimit) { - searchFrom = scan.end; - continue; - } - protectedText += text.slice(cursor, escaped ? start - 1 : start); - const token = createToken(escaped ? `\\${image}` : image); - tokens.push(token); - protectedText += token; - cursor = scan.end; - searchFrom = scan.end; - } - return { text: protectedText + text.slice(cursor), tokens }; -} - -function serializeMarkdownDestination(href: string): string { - return `<${href.replace(/([\\<>])/gu, "\\$1")}>`; -} - -function fallbackOversizedQQLinks( - ir: MarkdownIR, - byteLimit: number, - render: (ir: MarkdownIR) => string, - protectEscape: (escaped: string) => string, -): MarkdownIR { - const oversizedIndexes = new Set(); - for (const [index, link] of ir.links.entries()) { - const rendered = render(sliceMarkdownIR(ir, link.start, link.end)); - if (utf8ByteLength(rendered) > byteLimit) { - oversizedIndexes.add(index); - } - } - if (oversizedIndexes.size === 0) { - return ir; - } - const oversized = ir.links.filter((_link, index) => oversizedIndexes.has(index)); - const rewritten = rewriteMarkdownIR( - ir, - oversized.map((link) => ({ - start: link.end, - end: link.end, - text: ` (${link.href.replace(QQBOT_MARKDOWN_ESCAPE_RE, (char) => protectEscape(`\\${char}`))})`, - })), - ); - return { - ...rewritten, - links: rewritten.links.filter((_link, index) => !oversizedIndexes.has(index)), - }; -} - -function fallbackOversizedProtectedImages( - ir: MarkdownIR, - byteLimit: number, - render: (ir: MarkdownIR) => string, - protectedTokens: ReturnType, -): MarkdownIR { - const edits: TextEdit[] = []; - for (let start = 0; start < ir.text.length; start += 1) { - const token = ir.text[start] ?? ""; - const protectedValue = protectedTokens.restore(token); - const escapedLiteral = protectedValue.startsWith("\\!["); - const unescaped = protectedValue.replace(/\\(.)/gu, "$1"); - if ( - /^!?\[[\s\S]*\]\([\s\S]*\)$/u.test(unescaped) && - utf8ByteLength(render(sliceMarkdownIR(ir, start, start + token.length))) > byteLimit - ) { - if (escapedLiteral) { - const literal = protectedValue.replace(QQBOT_MARKDOWN_ESCAPE_RE, (char) => - protectedTokens.reuse(`\\${char}`), - ); - edits.push({ start, end: start + token.length, text: literal }); - continue; - } - const altStart = protectedValue.startsWith("![") ? 2 : 1; - let depth = 1; - let altEnd = altStart; - let alt = ""; - for (; altEnd < protectedValue.length; altEnd += 1) { - if (protectedValue[altEnd] === "\\" && protectedValue[altEnd + 1]) { - alt += protectedValue[++altEnd]; - } else if (protectedValue[altEnd] === "[") { - depth += 1; - alt += "["; - } else if (protectedValue[altEnd] === "]" && --depth === 0) { - break; - } else { - alt += protectedValue[altEnd] ?? ""; - } - } - edits.push({ start, end: start + token.length, text: alt }); - } - } - return rewriteMarkdownIR(ir, edits); -} - -export function formatQQBotMarkdown(markdown: string, limit: number): string[] { - const protectedTokens = createProtectedTokenStore(markdown); - const chunkLimit = resolveQQBotMarkdownChunkLimit(limit); - const images = protectQQBotMarkdownImages(markdown, protectedTokens.reuse, chunkLimit); - const entityTokens: string[] = []; - const entitiesProtected = images.text.replace(MARKDOWN_ENTITY_RE, (entity) => { - const protectedSize = Math.max( - utf8ByteLength(entity), - utf8ByteLength(escapeQQMarkdownSyntax(entity)), - ); - if (protectedSize + PROTECTED_IMAGE_OVERHEAD_BYTES > chunkLimit) { - return entity; - } - const token = protectedTokens.reuse(entity); - entityTokens.push(token); - return token; - }); - const escapeTokens: string[] = []; - const protectedMarkdown = entitiesProtected.replace(ESCAPED_MARKDOWN_RE, (escaped) => { - const token = protectedTokens.reuse(escaped); - escapeTokens.push(token); - return token; - }); - const parsed = markdownToIR(protectedMarkdown, { - autolink: false, - enableSpoilers: true, - enableTaskLists: true, - headingStyle: "rich", - linkify: false, - blockquotePrefix: "", - }); - const specialized = specializeProtectedTokensInCode( - specializeProtectedTokensInCode(parsed, images.tokens, protectedTokens), - [...escapeTokens, ...entityTokens], - protectedTokens, - ); - const renderChunk = (chunk: MarkdownIR): string => - protectedTokens.restore( - renderMarkdownWithMarkers( - chunk, - { - styleMarkers: { - ...QQBOT_MARKERS, - blockquote: { - open: (span: { start: number }) => - chunk.text.slice(span.start, span.start + 2) === "> " ? "" : "> ", - close: "", - }, - }, - escapeText: (text) => text, - buildLink: (link) => ({ - start: link.start, - end: link.end, - open: "[", - close: `](${serializeMarkdownDestination(link.href)})`, - }), - }, - QQBOT_FORMAT_CAPABILITIES, - ), - ); - const formatted = prefixQQBotBlockquotes( - escapeQQFallbackCode(specialized, protectedTokens.reuse), - ); - const imagesSized = fallbackOversizedProtectedImages( - formatted, - chunkLimit, - renderChunk, - protectedTokens, - ); - const ir = fallbackOversizedQQLinks(imagesSized, chunkLimit, renderChunk, protectedTokens.reuse); - const chunks = renderMarkdownIRChunksWithinLimit({ - ir, - limit: chunkLimit, - measureRendered: utf8ByteLength, - renderChunk, - }).map((chunk) => chunk.rendered); - const last = chunks.length - 1; - if (last >= 0) { - chunks[last] = chunks[last]?.trimEnd() ?? ""; - } - return chunks; -} diff --git a/extensions/qqbot/src/engine/messaging/markdown-table-chunking.test.ts b/extensions/qqbot/src/engine/messaging/markdown-table-chunking.test.ts deleted file mode 100644 index b42c9ec6ffda..000000000000 --- a/extensions/qqbot/src/engine/messaging/markdown-table-chunking.test.ts +++ /dev/null @@ -1,524 +0,0 @@ -// QQ Bot Markdown chunking tests cover message-boundary table repair. -import { describe, expect, it } from "vitest"; -import { chunkQQBotMarkdownText, createQQBotMarkdownChunker } from "./markdown-table-chunking.js"; - -const baseChunker = (text: string, limit: number): string[] => - text.length <= limit ? [text] : [text.slice(0, limit), text.slice(limit)]; - -describe("chunkQQBotMarkdownText", () => { - it("falls unsupported inline code back to plain text", () => { - expect(chunkQQBotMarkdownText("Run `openclaw status` now.", 120, baseChunker)).toEqual([ - "Run openclaw status now.", - ]); - }); - - it("preserves transport-owned markdown images beside fallback code", () => { - const image = "![chart #800px #600px](https://example.com/chart.png)"; - expect(chunkQQBotMarkdownText(`Run \`status\`.\n\n${image}`, 200, baseChunker)).toEqual([ - `Run status.\n\n${image}`, - ]); - }); - - it("preserves transport-owned image URLs with balanced parentheses", () => { - const image = "![plot](https://example.com/chart_(final).png)"; - expect(chunkQQBotMarkdownText(`Run \`status\`.\n\n${image}`, 200, baseChunker)).toEqual([ - `Run status.\n\n${image}`, - ]); - }); - - it("preserves images containing nested opener text", () => { - const image = "![plot](https://example.com/a![b].png)"; - expect(chunkQQBotMarkdownText(image, 200, baseChunker)).toEqual([image]); - }); - - it("keeps BMP protected image tokens atomic at the chunk boundary", () => { - const image = "![x](https://example.com/x.png)"; - const output = chunkQQBotMarkdownText(`${"A".repeat(3_597)}${image}`, 3_600, baseChunker); - expect(output.join("")).toBe(`${"A".repeat(3_597)}${image}`); - expect(output.every((chunk) => !chunk.includes("�"))).toBe(true); - }); - - it("keeps escaped images atomic at the chunk boundary", () => { - const image = String.raw`\![x](https://example.com/x.png)`; - const chunks = chunkQQBotMarkdownText(`${"A".repeat(3_599)}${image}`, 3_600, baseChunker); - expect(chunks.join("")).toBe(`${"A".repeat(3_599)}${image}`); - expect(chunks.some((chunk) => chunk.startsWith("!["))).toBe(false); - }); - - it("falls protected images back when final quote context exceeds the limit", () => { - const image = `![x](https://example.com/${"a".repeat(3_400)}.png)`; - const chunks = chunkQQBotMarkdownText(`${"> ".repeat(40)}${image}`, 3_600, baseChunker); - expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 3_600)).toBe(true); - expect(chunks.join("")).toContain("![x]"); - }); - - it("does not hide later code behind malformed images", () => { - const output = chunkQQBotMarkdownText("![x](bad\n\n`code`\n)", 200, baseChunker).join(""); - expect(output).toContain("code"); - expect(output).not.toContain("`code`"); - }); - - it("does not let nested images complete malformed outer candidates", () => { - const output = chunkQQBotMarkdownText( - "![broken `code` ![x](https://e.test/x.png)", - 200, - baseChunker, - ).join(""); - expect(output).not.toContain("`code`"); - expect(output).toContain("![x](https://e.test/x.png)"); - }); - - it("continues image protection after many malformed candidates", () => { - const image = "![plot](https://example.com/chart.png)"; - const chunks = chunkQQBotMarkdownText(`${"![".repeat(81)}${image}`, 500, baseChunker); - expect(chunks.join("")).toContain(image); - }); - - it("does not restore forged protected tokens decoded from character references", () => { - const image = "![x](https://example.com/x.png)"; - const output = chunkQQBotMarkdownText(`󰀀 ${image}`, 200, baseChunker).join(""); - expect(output.startsWith("󰀀 ")).toBe(true); - expect(output.match(/!\[x\]/gu)).toHaveLength(1); - }); - - it("preserves entity-encoded markdown literals", () => { - const source = "**literal**"; - expect(chunkQQBotMarkdownText(source, 200, baseChunker)).toEqual([source]); - }); - - it("restores entities nested inside protected images", () => { - const image = "![x](https://e.test/a?x=1&y=2)"; - expect(chunkQQBotMarkdownText(image, 200, baseChunker)).toEqual([image]); - }); - - it("keeps oversized entities chunkable", () => { - const source = `&#${"1".repeat(300)};`; - const chunks = chunkQQBotMarkdownText(source, 100, baseChunker); - expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 100)).toBe(true); - }); - - it("falls oversized images back to chunkable plain content", () => { - const image = `![x](https://example.com/${"a".repeat(4_000)}.png)`; - const chunks = chunkQQBotMarkdownText(image, 3_600, baseChunker); - expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 3_600)).toBe(true); - expect(chunks.join("")).toBe("x"); - }); - - it("falls oversized links back to chunkable plain content", () => { - const href = `https://example.com/${"a".repeat(4_000)}`; - const escapedHref = href.replaceAll(".", "\\."); - const chunks = chunkQQBotMarkdownText(`[x](${href})`, 3_600, baseChunker); - expect(chunks.every((chunk) => Buffer.byteLength(chunk, "utf8") <= 3_600)).toBe(true); - expect(chunks.join("")).toBe(`x (${escapedHref})`); - }); - - it("removes only the oversized occurrence when link destinations repeat", () => { - const href = `https://e.co/${"a".repeat(3_575)}`; - const escapedHref = href.replaceAll(".", "\\."); - const source = `[x](${href})\n[${"long".repeat(8)}](${href})`; - const output = chunkQQBotMarkdownText(source, 3_600, baseChunker).join(""); - expect(output).toContain(`[x](<${href}>)`); - expect(output).toContain(`${"long".repeat(8)} (${escapedHref})`); - }); - - it("supports more authored escapes than the BMP private-use block", () => { - const source = "\\*".repeat(6_401); - expect(chunkQQBotMarkdownText(source, 3_600, baseChunker).join("")).toBe(source); - }); - - it("escapes markdown-looking inline code after removing code markers", () => { - expect(chunkQQBotMarkdownText("`![x](https://example.com/x.png)`", 200, baseChunker)).toEqual([ - String.raw`\!\[x\]\(https://example\.com/x\.png\)`, - ]); - }); - - it("matches equal-length inline delimiters around shorter backtick runs", () => { - expect( - chunkQQBotMarkdownText("``a `![x](https://example.com/x.png)` b``", 200, baseChunker), - ).toEqual([String.raw`a \`\!\[x\]\(https://example\.com/x\.png\)\` b`]); - }); - - it("preserves escaped literal backticks", () => { - expect(chunkQQBotMarkdownText(String.raw`\`literal\``, 200, baseChunker)).toEqual([ - String.raw`\`literal\``, - ]); - }); - - it("re-escapes protected backslashes inside fallback code", () => { - expect(chunkQQBotMarkdownText("`\\*`", 200, baseChunker)).toEqual([String.raw`\\\*`]); - }); - - it("serializes link destinations with angle brackets", () => { - expect(chunkQQBotMarkdownText("[x](https://host/a)", 200, baseChunker)).toEqual([ - "[x]()", - ]); - }); - - it("keeps every paragraph inside a blockquote", () => { - expect(chunkQQBotMarkdownText("> one\n>\n> two", 200, baseChunker)).toEqual([ - "> one\n> \n> two", - ]); - }); - - it("stops blockquote prefixes before following text", () => { - expect(chunkQQBotMarkdownText("> quoted\n\noutside", 200, baseChunker)).toEqual([ - "> quoted\n\noutside", - ]); - }); - - it("prefixes every chunk of a long blockquote", () => { - const chunks = chunkQQBotMarkdownText(`> ${"a".repeat(5_000)}`, 200, baseChunker); - expect(chunks.length).toBeGreaterThan(1); - expect(chunks.every((chunk) => chunk.startsWith("> "))).toBe(true); - }); - - it("does not duplicate blockquote prefixes at continuation boundaries", () => { - const chunks = chunkQQBotMarkdownText(`> ${"a".repeat(3_597)}\n> second`, 3_600, baseChunker); - expect(chunks.some((chunk) => chunk.startsWith("> > "))).toBe(false); - expect(chunks.join("")).toContain("> second"); - }); - - it("keeps fallback code lines inside a blockquote", () => { - expect(chunkQQBotMarkdownText("> ```\n> one\n> two\n> ```", 200, baseChunker)).toEqual([ - "> one\n> two", - ]); - }); - - it("does not linkify plain filenames", () => { - expect(chunkQQBotMarkdownText("See README.md", 200, baseChunker)).toEqual(["See README.md"]); - }); - - it("keeps nested list indentation out of code fallback", () => { - expect(chunkQQBotMarkdownText("- parent\n - child", 200, baseChunker)).toEqual([ - "• parent\n • child", - ]); - }); - - it("prefixes continuation chunks with the active table header", () => { - const text = [ - "| Id | Value |", - "|---:|---|", - "| 1 | alpha |", - "| 2 | beta |", - "| 3 | gamma |", - ].join("\n"); - - expect(chunkQQBotMarkdownText(text, 45, baseChunker)).toEqual([ - ["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"), - ["| Id | Value |", "|---:|---|", "| 2 | beta |"].join("\n"), - ["| Id | Value |", "|---:|---|", "| 3 | gamma |"].join("\n"), - ]); - }); - - it("keeps table state across streaming block flushes", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect( - chunker.chunkText(["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n"), 120), - ).toEqual([["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n")]); - expect(chunker.chunkText(["| 2 | beta |", "| 3 | gamma |"].join("\n"), 120)).toEqual([ - ["| Id | Value |", "|---:|---|", "| 2 | beta |", "| 3 | gamma |"].join("\n"), - ]); - }); - - it("keeps a possible table header until a later separator confirms the table", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect(chunker.chunkText("| Id | Value |", 120)).toEqual([]); - expect( - chunker.chunkText(["|---:|---|", "| 1 | alpha |", "| 2 | beta |"].join("\n"), 120), - ).toEqual([["| Id | Value |", "|---:|---|", "| 1 | alpha |", "| 2 | beta |"].join("\n")]); - }); - - it("confirms a table when the separator uses one or two dashes, not only three", () => { - // GFM delimiter cells need only one or more dashes; a sub-3-dash separator previously failed - // recognition, so the header and all rows but the last were silently dropped on send. - for (const separator of ["|--|--|", "|-|-|", "|:--|--:|"]) { - const text = ["| Id | Value |", separator, "| 1 | alpha |", "| 2 | beta |"].join("\n"); - expect(chunkQQBotMarkdownText(text, 200, baseChunker)).toEqual([text]); - } - }); - - it("flushes a possible table header as text when the next block is not a separator", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect(chunker.chunkText("| maybe | header |", 120)).toEqual([]); - expect(chunker.chunkText("plain continuation", 120)).toEqual([ - ["| maybe | header |", "plain continuation"].join("\n"), - ]); - }); - - it("does not prefix after a table is closed by a blank line", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - chunker.chunkText(["| Id | Value |", "|---:|---|", "| 1 | alpha |"].join("\n") + "\n\n", 120); - - expect(chunker.chunkText("| not | a continuation |", 120)).toEqual([]); - expect(chunker.flushPendingText(120)).toEqual(["| not | a continuation |"]); - }); - - it("renders an oversized table row as fields instead of splitting the row", () => { - const text = [ - "| Id | Error | Retry |", - "|---|---|---|", - `| 003 | ${"当前无错误信息,处理流程正常运行".repeat(8)} | 当前重试次数为零 |`, - "| 004 | ok | zero |", - ].join("\n"); - - const chunks = chunkQQBotMarkdownText(text, 80, baseChunker); - - expect(chunks[0]).toContain("Id: 003"); - expect(chunks[0]).toContain("Error:"); - expect(chunks.some((chunk) => chunk.startsWith("| 当前无错误信息"))).toBe(false); - expect(chunks.at(-1)).toBe( - ["| Id | Error | Retry |", "|---|---|---|", "| 004 | ok | zero |"].join("\n"), - ); - }); - - it("keeps escaped pipes inside oversized table cells", () => { - const value = "long value ".repeat(12); - const text = ["| Label | Value |", "|---|---|", `| a \\| b | ${value} |`].join("\n"); - - const chunks = chunkQQBotMarkdownText(text, 80, baseChunker); - - expect(chunks.join("\n")).toContain("Label: a | b"); - expect(chunks.join("\n")).toContain("Value: long value"); - }); - - it("buffers a table row fragment across streaming block flushes", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect( - chunker.chunkText( - ["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n"), - 160, - ), - ).toEqual([["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n")]); - - expect(chunker.chunkText("| 5 | generatemonthly_sales", 160)).toEqual([]); - expect(chunker.chunkText("_by_region | ok |", 160)).toEqual([ - [ - "| Id | Function | Status |", - "|---:|---|---|", - "| 5 | generatemonthly_sales_by_region | ok |", - ].join("\n"), - ]); - }); - - it("buffers a pipe-terminated row until it reaches the table column count", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect( - chunker.chunkText( - ["| Id | Time | Owner | Note |", "|---:|---|---|---|", "| 16 | 40ms | He | ok |"].join( - "\n", - ), - 200, - ), - ).toEqual([ - ["| Id | Time | Owner | Note |", "|---:|---|---|---|", "| 16 | 40ms | He | ok |"].join("\n"), - ]); - - expect(chunker.chunkText("| 17 | 100ms |", 200)).toEqual([]); - expect(chunker.chunkText("Lin | daily cap |", 200)).toEqual([ - [ - "| Id | Time | Owner | Note |", - "|---:|---|---|---|", - "| 17 | 100ms | Lin | daily cap |", - ].join("\n"), - ]); - }); - - it("flushes an unfinished table row fragment as plain fields", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - chunker.chunkText( - ["| Id | Function | Status |", "|---:|---|---|", "| 1 | auth | ok |"].join("\n"), - 160, - ); - expect(chunker.chunkText("| 10 | analyzeerror_patterns | 无需重试", 160)).toEqual([]); - - expect(chunker.flushPendingText(160)).toEqual([ - ["Id: 10", "Function: analyzeerror_patterns", "Status: 无需重试"].join("\n"), - ]); - }); - - it("does not emit malformed pipe fragments without table context", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect(chunker.chunkText("| 5 | reportbuilder.ts | generatemonthly_sales", 160)).toEqual([]); - expect(chunker.flushPendingText(160)).toEqual(["5 reportbuilder.ts generatemonthly_sales"]); - }); - - it("falls fenced code blocks back to plain text across streaming block flushes", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect(chunker.chunkText(["```ts", "const a = 1;"].join("\n"), 200)).toEqual([]); - expect(chunker.chunkText(["const b = 2;", "```"].join("\n"), 200)).toEqual([ - ["const a = 1;", "const b = 2;"].join("\n"), - ]); - }); - - it("keeps streamed template-literal backticks as escaped plain text", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect(chunker.chunkText(["```ts", "const value = `hello`;"].join("\n"), 200)).toEqual([]); - expect(chunker.chunkText("```", 200)).toEqual([String.raw`const value = \`hello\`;`]); - }); - - it("keeps markdown-looking streamed fence bodies in code fallback", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - expect(chunker.chunkText(["```", "**literal**"].join("\n"), 200)).toEqual([]); - expect(chunker.chunkText("```", 200)).toEqual([String.raw`\*\*literal\*\*`]); - }); - - it("handles longer fences containing shorter fence examples", () => { - const markdown = ["````md", "```", "inside", "```", "```` "].join("\n"); - expect(chunkQQBotMarkdownText(markdown, 200, baseChunker)).toEqual([ - [String.raw`\`\`\``, "inside", String.raw`\`\`\``].join("\n"), - ]); - }); - - it("escapes markdown-looking indented code after fallback", () => { - const markdown = [" **literal**", " ![x](https://example.com/x.png)"].join("\n"); - expect(chunkQQBotMarkdownText(markdown, 200, baseChunker)).toEqual([ - [String.raw`\*\*literal\*\*`, String.raw`\!\[x\]\(https://example\.com/x\.png\)`].join("\n"), - ]); - }); - - it("joins a fenced code line split across block deliveries", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect( - chunker.chunkText(["```python", " pool_timeout: float = 30."].join("\n"), 200), - ).toEqual([]); - expect( - chunker.chunkText(["0", " def get_dsn(self) -> str:", "```"].join("\n"), 200), - ).toEqual([ - [ - String.raw` pool\_timeout: float = 30\.0`, - String.raw` def get\_dsn\(self\) \-\> str:`, - ].join("\n"), - ]); - }); - - it("keeps long fallback code chunks under the QQ markdown byte safety limit", () => { - const lines = Array.from( - { length: 90 }, - (_, index) => - ` value_${String(index).padStart(3, "0")} = "这是一行用于测试 QQ markdown 不要接近平台截断线的 Python 代码"`, - ); - const text = ["```python", ...lines].join("\n"); - const chunks = chunkQQBotMarkdownText(text, 5000, baseChunker); - - expect(chunks.length).toBeGreaterThan(1); - for (const chunk of chunks) { - expect(Buffer.byteLength(chunk, "utf8")).toBeLessThanOrEqual(3600); - expect(chunk).not.toContain("```"); - } - }); - - it("does not split generated markdown escape pairs across byte chunks", () => { - const chunks = chunkQQBotMarkdownText( - ["```", "*".repeat(5_000), "```"].join("\n"), - 3_600, - baseChunker, - ); - expect(chunks.join("")).toBe("\\*".repeat(5_000)); - expect(chunks.every((chunk) => !/(^|[^\\])(?:\\\\)*\\$/u.test(chunk))).toBe(true); - }); - - it("keeps generated markdown escape pairs atomic at odd byte limits", () => { - const chunks = chunkQQBotMarkdownText(["```", "***", "```"].join("\n"), 3, baseChunker); - expect(chunks.join("")).toBe("\\*".repeat(3)); - expect(chunks.every((chunk) => !chunk.endsWith("\\"))).toBe(true); - }); - - it("allows ASCII fenced chunks past the old 1800 character fallback", () => { - const lines = Array.from( - { length: 90 }, - (_, index) => - ` value_${String(index).padStart(3, "0")} = "ascii markdown budget should use bytes not a short character cap"`, - ); - const chunks = chunkQQBotMarkdownText(["```python", ...lines].join("\n"), 5000, baseChunker); - - expect(chunks.some((chunk) => chunk.length > 1800)).toBe(true); - for (const chunk of chunks) { - expect(Buffer.byteLength(chunk, "utf8")).toBeLessThanOrEqual(3600); - expect(chunk).not.toContain("```"); - } - }); - - it("falls fenced formula blocks back to plain text across streaming block flushes", () => { - const chunker = createQQBotMarkdownChunker((text) => [text]); - - expect(chunker.chunkText(["```math", "E = mc^2"].join("\n"), 200)).toEqual([]); - expect(chunker.chunkText(["a^2 + b^2 = c^2", "```"].join("\n"), 200)).toEqual([ - ["E = mc^2", String.raw`a^2 \+ b^2 = c^2`].join("\n"), - ]); - }); - - it("splits fenced code chunks between lines for every viable limit", () => { - const firstLine = `const value001 = "用于测试代码行保持完整";`; - const secondLine = `const value002 = "用于测试代码行保持完整";`; - const singleLineFenceLength = Buffer.byteLength(["```ts", firstLine, "```"].join("\n")); - const wholeFenceLength = Buffer.byteLength(["```ts", firstLine, secondLine, "```"].join("\n")); - - for (let limit = singleLineFenceLength; limit < wholeFenceLength; limit++) { - const chunker = createQQBotMarkdownChunker(baseChunker); - const chunks = [ - ...chunker.chunkText(["```ts", firstLine, secondLine].join("\n"), limit), - ...chunker.flushPendingText(limit), - ]; - - expect(chunks).toEqual([firstLine, secondLine]); - } - }); - - it("handles prose before and after a table split at row boundaries", () => { - const text = [ - "前置说明第一段,长度足够触发普通文本先发送。", - "前置说明第二段继续解释。", - "| Id | Value |", - "|---:|---|", - "| 1 | alpha |", - "| 2 | beta |", - "后置说明第一段,表格结束后继续普通文字。", - "后置说明第二段。", - ].join("\n"); - - expect(chunkQQBotMarkdownText(text, 180, baseChunker)).toEqual([ - "前置说明第一段,长度足够触发普通文本先发送。\n前置说明第二段继续解释。", - ["| Id | Value |", "|---:|---|", "| 1 | alpha |", "| 2 | beta |"].join("\n"), - "后置说明第一段,表格结束后继续普通文字。\n后置说明第二段。", - ]); - }); -}); - -describe("table-cell splitting", () => { - it("preserves a literal backslash before an oversized cell delimiter", () => { - const text = [ - "| First | Second |", - "|---|---|", - `| a \\\\ | ${"long value ".repeat(12)} |`, - ].join("\n"); - - const chunks = chunkQQBotMarkdownText(text, 80, baseChunker); - - expect(chunks.join("\n")).toContain("First: a \\"); - expect(chunks.join("\n")).toContain("Second: long value"); - }); - - it("unescapes pipes when flushing a partial row in an active table", () => { - const chunker = createQQBotMarkdownChunker(baseChunker); - expect( - chunker.chunkText( - ["| First | Second |", "|---|---|", "| ready | complete |"].join("\n"), - 200, - ), - ).toEqual([["| First | Second |", "|---|---|", "| ready | complete |"].join("\n")]); - - expect(chunker.chunkText("| a \\| b | c", 200)).toEqual([]); - expect(chunker.flushPendingText(200)).toEqual([["First: a | b", "Second: c"].join("\n")]); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/markdown-table-chunking.ts b/extensions/qqbot/src/engine/messaging/markdown-table-chunking.ts deleted file mode 100644 index 92e517593f11..000000000000 --- a/extensions/qqbot/src/engine/messaging/markdown-table-chunking.ts +++ /dev/null @@ -1,581 +0,0 @@ -// QQ Bot Markdown chunking keeps each sent message self-contained. - -import { formatQQBotMarkdown } from "./markdown-format.js"; - -type QQBotBaseMarkdownChunker = (text: string, limit: number) => string[]; - -const QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT = 3600; - -type TableHeader = { - header: string; - separator: string; - cells: string[]; -}; - -type ActiveFence = { - openLine: string; - closeLine: string; - marker: string; -}; - -type QQBotMarkdownChunker = { - chunkText: (text: string, limit: number) => string[]; - flushPendingText: (limit: number) => string[]; -}; - -export function chunkQQBotMarkdownText( - text: string, - limit: number, - baseChunker: QQBotBaseMarkdownChunker, -): string[] { - const chunker = createQQBotMarkdownChunker(baseChunker); - return [...chunker.chunkText(text, limit), ...chunker.flushPendingText(limit)]; -} - -export function createQQBotMarkdownChunker( - baseChunker: QQBotBaseMarkdownChunker, -): QQBotMarkdownChunker { - const state = new QQBotMarkdownChunkingState(baseChunker); - return { - chunkText: (text, limit) => state.chunkText(text, limit), - flushPendingText: (limit) => state.flushPendingText(limit), - }; -} - -class QQBotMarkdownChunkingState { - private activeTable: TableHeader | null = null; - private pendingHeaderLine: string | null = null; - private pendingHeaderCells: string[] = []; - private tableLines: string[] = []; - private textLines: string[] = []; - private pendingRowFragment: string | null = null; - private activeFence: ActiveFence | null = null; - private pendingTextFenceOpenLine: string | null = null; - private pendingFenceLineFragment: string | null = null; - - constructor(private readonly baseChunker: QQBotBaseMarkdownChunker) {} - - chunkText(text: string, limit: number): string[] { - if (!text) { - return []; - } - if (limit <= 0) { - return this.baseChunker(text, limit); - } - const chunkLimit = resolveQQBotMarkdownChunkLimit(limit); - - const chunks: string[] = []; - const textWithPendingRow = this.consumePendingRowPrefix(text); - const textWithPendingFenceLine = this.consumePendingFenceLinePrefix(textWithPendingRow); - const hasTrailingNewline = textWithPendingFenceLine.endsWith("\n"); - const lines = textWithPendingFenceLine.split("\n"); - for (const [index, line] of lines.entries()) { - const isTrailingSplitLine = index === lines.length - 1 && line === ""; - this.consumeLine(line, { - limit: chunkLimit, - chunks, - hasTrailingNewline, - isTrailingSplitLine, - isLastLine: index === lines.length - 1, - }); - } - this.flushText(chunks, chunkLimit); - this.flushTable(chunks); - return chunks; - } - - flushPendingText(limit: number): string[] { - const chunkLimit = resolveQQBotMarkdownChunkLimit(limit); - const chunks: string[] = []; - this.flushPendingRowFragment(chunks, chunkLimit); - this.flushPendingFenceLineFragment(); - this.flushPendingHeaderAsText(); - this.flushText(chunks, chunkLimit); - this.flushTable(chunks); - return chunks; - } - - private consumeLine( - line: string, - params: { - limit: number; - chunks: string[]; - hasTrailingNewline: boolean; - isTrailingSplitLine: boolean; - isLastLine: boolean; - }, - ): void { - const fence = parseFenceLine(line); - if (fence) { - this.endTable(params.chunks); - if (!this.activeFence) { - this.pushTextLine(line); - this.activeFence = fence; - this.clearPendingTableHeader(); - return; - } - if (isClosingFenceLine(line, this.activeFence)) { - this.pushFenceTextLine(line); - this.activeFence = null; - this.clearPendingTableHeader(); - return; - } - this.pushFenceTextLine(line); - this.clearPendingTableHeader(); - return; - } - - if (this.activeFence) { - if (params.isLastLine && !params.hasTrailingNewline) { - this.pendingFenceLineFragment = mergeFenceLineFragments( - this.pendingFenceLineFragment, - line, - ); - return; - } - this.pushFenceTextLine(line); - return; - } - - if ( - isIncompleteTableRowFragment(line) || - (this.activeTable && isShortTableRowLine(line, this.activeTable)) - ) { - if (params.isLastLine) { - this.flushText(params.chunks, params.limit); - this.pendingRowFragment = mergeRowFragments(this.pendingRowFragment, line); - return; - } - this.pushTextLine(renderMalformedPipeLineAsText(line)); - return; - } - - if (this.pendingHeaderLine && isTableSeparatorLine(line)) { - this.flushText(params.chunks, params.limit); - this.activeTable = { - header: this.pendingHeaderLine, - separator: line, - cells: this.pendingHeaderCells, - }; - this.pendingHeaderLine = null; - this.pendingHeaderCells = []; - this.ensureTableHeader(); - return; - } - - if (isTableRowLine(line) && this.activeTable && !isTableSeparatorLine(line)) { - this.flushText(params.chunks, params.limit); - this.appendTableRow(line, params.limit, params.chunks); - return; - } - - if (this.activeTable) { - if (!line.trim() && params.isTrailingSplitLine) { - return; - } - this.endTable(params.chunks); - } - - if (isTableRowLine(line) && !isTableSeparatorLine(line)) { - this.flushText(params.chunks, params.limit); - this.pendingHeaderLine = line; - this.pendingHeaderCells = splitTableCells(line); - return; - } - - this.flushPendingHeaderAsText(); - this.pushTextLine(line); - } - - private pushTextLine(line: string): void { - this.textLines.push(line); - } - - private pushFenceTextLine(line: string): void { - if (this.textLines.length === 0 && this.activeFence) { - this.pendingTextFenceOpenLine = this.activeFence.openLine; - } - this.textLines.push(line); - } - - private appendTableRow(line: string, limit: number, chunks: string[]): void { - const rowMessage = [this.activeTable!.header, this.activeTable!.separator, line].join("\n"); - if (utf8ByteLength(rowMessage) > limit) { - this.dropHeaderOnlyTableChunk(); - this.flushTable(chunks); - this.pushOversizedTableRow(line, limit, chunks); - return; - } - - this.ensureTableHeader(); - const candidate = [...this.tableLines, line].join("\n"); - if (utf8ByteLength(candidate) <= limit) { - this.tableLines.push(line); - return; - } - - this.flushTable(chunks); - this.ensureTableHeader(); - this.tableLines.push(line); - } - - private pushOversizedTableRow(line: string, limit: number, chunks: string[]): void { - const text = renderTableRowAsFields(this.activeTable!.cells, splitTableCells(line)); - pushBaseChunks(chunks, text, limit, this.baseChunker); - } - - private ensureTableHeader(): void { - if (this.tableLines.length > 0 || !this.activeTable) { - return; - } - this.tableLines.push(this.activeTable.header, this.activeTable.separator); - } - - private flushText(chunks: string[], limit: number): void { - if (this.textLines.length === 0) { - return; - } - const continuedFenceOpenLine = this.activeFence?.openLine; - let text = this.textLines.join("\n"); - this.textLines = []; - if (this.pendingTextFenceOpenLine) { - text = `${this.pendingTextFenceOpenLine}\n${text}`; - this.pendingTextFenceOpenLine = null; - } - if (this.activeFence) { - text = `${text}\n${this.activeFence.closeLine}`; - } - if (!text) { - return; - } - chunks.push(...formatQQBotMarkdown(text, limit)); - if (continuedFenceOpenLine) { - this.pendingTextFenceOpenLine = continuedFenceOpenLine; - } - } - - private consumePendingFenceLinePrefix(text: string): string { - if (!this.pendingFenceLineFragment) { - return text; - } - const firstLine = text.split("\n", 1)[0] ?? ""; - const startsWithClosingFence = - this.activeFence && isClosingFenceLine(firstLine, this.activeFence); - const separator = - !startsWithClosingFence && shouldJoinFenceLineFragments(this.pendingFenceLineFragment, text) - ? "" - : "\n"; - const merged = `${this.pendingFenceLineFragment}${separator}${text}`; - this.pendingFenceLineFragment = null; - return merged; - } - - private flushPendingFenceLineFragment(): void { - if (!this.pendingFenceLineFragment) { - return; - } - this.pushFenceTextLine(this.pendingFenceLineFragment); - this.pendingFenceLineFragment = null; - } - - private flushPendingHeaderAsText(): void { - if (!this.pendingHeaderLine) { - return; - } - this.pushTextLine(this.pendingHeaderLine); - this.pendingHeaderLine = null; - this.pendingHeaderCells = []; - } - - private clearPendingTableHeader(): void { - this.pendingHeaderLine = null; - this.pendingHeaderCells = []; - } - - private consumePendingRowPrefix(text: string): string { - if (!this.pendingRowFragment) { - return text; - } - const separator = - this.pendingRowFragment.trimEnd().endsWith("|") && text && !/^[\s|]/.test(text) ? " " : ""; - const merged = `${this.pendingRowFragment}${separator}${text}`; - this.pendingRowFragment = null; - return merged; - } - - private flushPendingRowFragment(chunks: string[], limit: number): void { - if (!this.pendingRowFragment) { - return; - } - const fragment = this.pendingRowFragment; - this.pendingRowFragment = null; - const text = this.activeTable - ? renderTableRowAsFields(this.activeTable.cells, splitPartialTableCells(fragment)) - : renderMalformedPipeLineAsText(fragment); - pushBaseChunks(chunks, text, limit, this.baseChunker); - } - - private flushTable(chunks: string[]): void { - if (this.tableLines.length === 0) { - return; - } - chunks.push(this.tableLines.join("\n")); - this.tableLines = []; - } - - private dropHeaderOnlyTableChunk(): void { - if ( - this.activeTable && - this.tableLines.length === 2 && - this.tableLines[0] === this.activeTable.header && - this.tableLines[1] === this.activeTable.separator - ) { - this.tableLines = []; - } - } - - private endTable(chunks: string[]): void { - this.flushTable(chunks); - this.activeTable = null; - } -} - -function isTableRowLine(line: string): boolean { - const trimmed = line.trim(); - return trimmed.startsWith("|") && trimmed.endsWith("|") && splitTableCells(trimmed).length >= 2; -} - -function resolveQQBotMarkdownChunkLimit(limit: number): number { - return Math.min(limit, QQBOT_MARKDOWN_SAFE_CHUNK_BYTE_LIMIT); -} - -function pushBaseChunks( - chunks: string[], - text: string, - byteLimit: number, - baseChunker: QQBotBaseMarkdownChunker, -): void { - const baseChunks = baseChunker(text, byteLimit).filter(Boolean); - for (let index = 0; index + 1 < baseChunks.length; index += 1) { - const chunk = baseChunks[index] ?? ""; - if (!/(^|[^\\])(?:\\\\)*\\$/u.test(chunk)) { - continue; - } - const next = baseChunks[index + 1] ?? ""; - const firstCodePoint = next.codePointAt(0); - const first = firstCodePoint === undefined ? "" : String.fromCodePoint(firstCodePoint); - if (first && utf8ByteLength(chunk + first) <= byteLimit) { - baseChunks[index] = chunk + first; - baseChunks[index + 1] = next.slice(first.length); - } else { - baseChunks[index] = chunk.slice(0, -1); - baseChunks[index + 1] = `\\${next}`; - } - } - for (const chunk of baseChunks) { - if (!chunk) { - continue; - } - if (utf8ByteLength(chunk) <= byteLimit) { - chunks.push(chunk); - continue; - } - chunks.push(...splitByUtf8ByteLimit(chunk, byteLimit)); - } -} - -function splitByUtf8ByteLimit(text: string, byteLimit: number): string[] { - if (!text) { - return []; - } - const chunks: string[] = []; - let current = ""; - let currentBytes = 0; - const chars = Array.from(text); - for (let index = 0; index < chars.length; index += 1) { - const char = chars[index] ?? ""; - const escapedUnit = char === "\\" && chars[index + 1] ? `${char}${chars[index + 1]}` : ""; - const unit = - escapedUnit && utf8ByteLength(escapedUnit) <= byteLimit ? `${char}${chars[++index]}` : char; - const unitBytes = utf8ByteLength(unit); - if (current && currentBytes + unitBytes > byteLimit) { - chunks.push(current); - current = ""; - currentBytes = 0; - } - current += unit; - currentBytes += unitBytes; - } - if (current) { - chunks.push(current); - } - return chunks; -} - -function utf8ByteLength(text: string): number { - return Buffer.byteLength(text, "utf8"); -} - -function isIncompleteTableRowFragment(line: string): boolean { - const trimmed = line.trim(); - return ( - trimmed.startsWith("|") && !trimmed.endsWith("|") && splitPartialTableCells(trimmed).length >= 2 - ); -} - -function isShortTableRowLine(line: string, table: TableHeader): boolean { - if (!isTableRowLine(line) || isTableSeparatorLine(line)) { - return false; - } - return splitTableCells(line).length < table.cells.length; -} - -function isTableSeparatorLine(line: string): boolean { - if (!isTableRowLine(line)) { - return false; - } - const cells = splitTableCells(line); - // GFM delimiter cells need only one or more hyphens (optionally colon-aligned), so accept "-+", - // not "-{3,}": a valid 1/2-dash separator (e.g. |--|--|) was not recognized here, leaving the - // header pending and silently overwritten by later rows so the table's header and rows vanished. - return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell.trim())); -} - -// Split a markdown table row's inner text on its column delimiters. A -// backslash-escaped pipe (`\|`) is literal cell content per GFM, not a column -// delimiter, so it must not start a new cell; it is unescaped to a bare `|`. -// (`\\` is likewise unescaped to a single backslash so a following `|` still -// delimits.) Splitting on every `|` previously mis-counted columns whenever a -// cell contained an escaped pipe. -function splitTableRowCells(inner: string): string[] { - const cells: string[] = []; - let current = ""; - for (let i = 0; i < inner.length; i++) { - const char = inner[i]; - if (char === "\\" && i + 1 < inner.length) { - const next = inner[i + 1]; - current += next === "|" || next === "\\" ? next : `\\${next}`; - i++; - continue; - } - if (char === "|") { - cells.push(current); - current = ""; - continue; - } - current += char; - } - cells.push(current); - return cells; -} - -function splitTableCells(line: string): string[] { - return splitTableRowCells(line.trim().slice(1, -1)).map((cell) => cell.trim()); -} - -function splitPartialTableCells(line: string): string[] { - return splitTableRowCells(line.trim().replace(/^\|/, "")) - .map((cell) => cell.trim()) - .filter((cell) => cell.length > 0); -} - -function mergeRowFragments(pending: string | null, next: string): string { - return pending ? `${pending}${next}` : next; -} - -function mergeFenceLineFragments(pending: string | null, next: string): string { - return pending ? `${pending}${next}` : next; -} - -function shouldJoinFenceLineFragments(pending: string, next: string): boolean { - if (!next || next.startsWith("\n")) { - return true; - } - const trimmedPending = pending.trimEnd(); - const trimmedNext = next.trimStart(); - if (!trimmedPending || !trimmedNext) { - return true; - } - if (/\d\.$/.test(trimmedPending) && /^\d/.test(trimmedNext)) { - return true; - } - if (/[.([{:,+\-*/%=&|^<>\\]$/.test(trimmedPending)) { - return true; - } - return hasUnclosedQuote(trimmedPending) || hasUnclosedDelimiter(trimmedPending); -} - -function hasUnclosedQuote(line: string): boolean { - let single = false; - let double = false; - let escaped = false; - for (const char of line) { - if (escaped) { - escaped = false; - continue; - } - if (char === "\\") { - escaped = true; - continue; - } - if (char === "'" && !double) { - single = !single; - continue; - } - if (char === '"' && !single) { - double = !double; - } - } - return single || double; -} - -function hasUnclosedDelimiter(line: string): boolean { - const stack: string[] = []; - const pairs: Record = { "(": ")", "[": "]", "{": "}" }; - const closers = new Set(Object.values(pairs)); - for (const char of line) { - if (pairs[char]) { - stack.push(pairs[char]); - continue; - } - if (closers.has(char)) { - if (stack.at(-1) === char) { - stack.pop(); - } - } - } - return stack.length > 0; -} - -function renderMalformedPipeLineAsText(line: string): string { - return splitPartialTableCells(line).join(" "); -} - -function renderTableRowAsFields(headers: string[], cells: string[]): string { - return cells - .map((cell, index) => { - const header = headers[index]?.trim(); - return header ? `${header}: ${cell}` : cell; - }) - .join("\n"); -} - -function parseFenceLine(line: string): ActiveFence | null { - const match = line.match(/^(\s*)(`{3,}|~{3,})/); - if (!match?.[2]) { - return null; - } - return { - openLine: line, - closeLine: `${match[1] ?? ""}${match[2]}`, - marker: match[2], - }; -} - -function isClosingFenceLine(line: string, fence: ActiveFence): boolean { - const markerChar = fence.marker[0] === "`" ? "`" : "~"; - const match = line.match(/^(\s*)(`{3,}|~{3,})\s*$/); - return Boolean( - match?.[2] && match[2][0] === markerChar && match[2].length >= fence.marker.length, - ); -} diff --git a/extensions/qqbot/src/engine/messaging/media-source.ts b/extensions/qqbot/src/engine/messaging/media-source.ts deleted file mode 100644 index 711ec4b82c4e..000000000000 --- a/extensions/qqbot/src/engine/messaging/media-source.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Unified media-source abstraction for the QQ Bot upload pipeline. - * - * All rich-media entry points (sender.ts#sendMedia, outbound.ts#send*, - * reply-dispatcher.ts#handle*Payload) funnel through {@link normalizeSource} - * before reaching the low-level {@link MediaApi}. - * - * ## Why four branches? - * - * - `url` — remote http(s) URL that the QQ server can fetch directly. - * - `base64` — in-memory base64 string (typically from a `data:` URL). - * - `localPath` — on-disk file; kept as a path plus an optional verified - * descriptor so uploaders can avoid reopening a path after validation. - * - `buffer` — in-memory raw bytes (e.g. TTS output, downloaded url-fallback). - * - * ## Security baseline (localPath branch) - * - * `openLocalFile` is the single canonical implementation of "safely open a - * local file for upload" across the plugin. It merges the previously - * inconsistent strategies from `reply-dispatcher.ts` (O_NOFOLLOW + size check) - * and `outbound.ts` (realpath + root containment). Callers are still - * responsible for *root-whitelist* validation (via - * `resolveQQBotPayloadLocalFilePath` / `resolveOutboundMediaPath`) before - * passing the path in; this function enforces *file-level* safety only. - * - * Chunked upload is not implemented in this PR, but the contract here already - * returns `size` metadata so `sendMediaInternal` can route by size without - * reading the whole file first. - */ - -import type { FileHandle } from "node:fs/promises"; -import { FsSafeError, openLocalFileSafely } from "openclaw/plugin-sdk/security-runtime"; -import { MAX_UPLOAD_SIZE, formatFileSize, getMimeType } from "../utils/file-utils.js"; - -// ============ Types ============ - -/** - * Fully normalized media source. Downstream uploaders switch on `kind`. - * - * - `url`: remote URL — upload via `file_data=null; url=...`. - * - `base64`: already-encoded base64 — upload via `file_data=...`. - * - `localPath`: on-disk file — uploaders should prefer `opened` when present - * and only reopen `path` for direct, already-normalized test/helper calls. - * - `buffer`: raw bytes in memory — same as above minus disk I/O. - */ -export type MediaSource = - | { kind: "url"; url: string } - | { kind: "base64"; data: string; mime?: string } - | { kind: "localPath"; path: string; size: number; mime?: string; opened?: OpenedLocalFile } - | { kind: "buffer"; buffer: Buffer; fileName?: string; mime?: string }; - -/** - * Untyped media source accepted from callers. - * - * `url` may be either a remote `http(s)://...` URL or a `data:;base64,...` - * data URL — {@link normalizeSource} transparently resolves the latter to a - * `base64` branch. - */ -export type RawMediaSource = - | { url: string } - | { base64: string; mime?: string } - | { localPath: string } - | { buffer: Buffer; fileName?: string; mime?: string }; - -// ============ data: URL ============ - -const DATA_URL_RE = /^data:([^;,]+);base64,(.+)$/i; - -/** - * Parse a `data:;base64,` URL. - * - * Returns `null` when the string is not a data URL or does not declare - * base64 encoding. Non-base64 data URLs are intentionally rejected because - * the QQ upload API ingests raw base64, not arbitrary URL-encoded payloads. - */ -function tryParseDataUrl(value: string): { mime: string; data: string } | null { - if (!value.startsWith("data:")) { - return null; - } - const m = value.match(DATA_URL_RE); - if (!m) { - return null; - } - const mime = m[1]; - const data = m[2]; - return mime === undefined || data === undefined ? null : { mime, data }; -} - -// ============ Local file safe open ============ - -/** - * Opened handle to a local file, with metadata already validated against - * QQ upload limits. - * - * Callers MUST call {@link OpenedLocalFile.close} (typically in a `finally`). - */ -export interface OpenedLocalFile { - handle: FileHandle; - size: number; - close(): Promise; -} - -/** - * Open a local file for upload with defense-in-depth: - * - * 1. `O_NOFOLLOW` refuses to traverse symlinks (prevents post-whitelist - * symlink swaps / TOCTOU attacks). - * 2. `fstat` on the opened descriptor — NOT `fs.stat` on the path — - * so the size check applies to the exact byte stream we will read. - * 3. Rejects non-regular files (sockets / devices / directories). - * 4. Enforces a caller-specified `maxSize` (default {@link MAX_UPLOAD_SIZE}) - * at open time, so oversized files fail fast without allocating a - * full buffer. Chunked upload callers should pass a larger ceiling - * (e.g. `CHUNKED_UPLOAD_MAX_SIZE` from `utils/file-utils.js`). - * - * The caller receives the open handle plus validated size and is expected - * to either {@link OpenedLocalFile.handle.readFile} (one-shot path) or - * stream via `fs.createReadStream` (chunked path). - */ -export async function openLocalFile( - filePath: string, - opts: { maxSize?: number } = {}, -): Promise { - const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE; - const opened = await openLocalFileSafely({ filePath }).catch((err: unknown) => { - if (err instanceof FsSafeError && err.code === "not-file") { - throw new Error("Path is not a regular file", { cause: err }); - } - throw err; - }); - try { - if (opened.stat.size > maxSize) { - throw new Error( - `File is too large (${formatFileSize(opened.stat.size)}); QQ Bot API limit is ${formatFileSize(maxSize)}`, - ); - } - return { - handle: opened.handle, - size: opened.stat.size, - close: () => opened.handle.close(), - }; - } catch (err) { - // Close the handle on any validation failure to avoid fd leaks. - await opened.handle.close().catch(() => undefined); - throw err; - } -} - -// ============ Normalization ============ - -/** - * Normalize a {@link RawMediaSource} into a {@link MediaSource}. - * - * - Strings passed via `{ url }` that start with `data:` are auto-resolved - * to a `base64` branch (this is the unified `data:` URL support that was - * previously only implemented in `sendImage`). - * - `localPath` branches open the file with {@link openLocalFile} and carry - * that descriptor to the uploader, so later reads use the exact file that - * passed regular-file / O_NOFOLLOW / size validation. - * - `buffer` branches enforce the same ceiling inline. - * - * `maxSize` defaults to {@link MAX_UPLOAD_SIZE} (20MB, one-shot upload limit). - * Callers that dispatch to the chunked uploader should pass a larger ceiling - * (e.g. `CHUNKED_UPLOAD_MAX_SIZE`, or a value derived from - * `getMaxUploadSize(fileType)`). - * - * NOTE: Root-whitelist validation (i.e. "this path must live under the - * allowed QQ Bot media directory") is a caller concern. This function - * assumes the path has already passed such checks. - */ -export async function normalizeSource( - raw: RawMediaSource, - opts: { maxSize?: number } = {}, -): Promise { - const maxSize = opts.maxSize ?? MAX_UPLOAD_SIZE; - - if ("url" in raw) { - const parsed = tryParseDataUrl(raw.url); - if (parsed) { - return { kind: "base64", data: parsed.data, mime: parsed.mime }; - } - return { kind: "url", url: raw.url }; - } - - if ("base64" in raw) { - return { kind: "base64", data: raw.base64, mime: raw.mime }; - } - - if ("localPath" in raw) { - const opened = await openLocalFile(raw.localPath, { maxSize }); - return { - kind: "localPath", - path: raw.localPath, - size: opened.size, - mime: getMimeType(raw.localPath), - opened, - }; - } - - // buffer branch - if (raw.buffer.length > maxSize) { - throw new Error( - `Buffer is too large (${formatFileSize(raw.buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`, - ); - } - return { - kind: "buffer", - buffer: raw.buffer, - fileName: raw.fileName, - mime: raw.mime, - }; -} diff --git a/extensions/qqbot/src/engine/messaging/media-type-detect.ts b/extensions/qqbot/src/engine/messaging/media-type-detect.ts deleted file mode 100644 index 5373861e7070..000000000000 --- a/extensions/qqbot/src/engine/messaging/media-type-detect.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Media type detection — pure functions for classifying files by MIME or extension. - * - * These replace the inline `isImageFile` and `isVideoFile` helpers scattered - * across `outbound.ts`. Centralizing them here keeps detection consistent. - */ - -import { getFileExtension } from "openclaw/plugin-sdk/media-mime"; - -const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]); -const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]); - -/** Check whether a file is an image using MIME first and extension as fallback. */ -export function isImageFile(filePath: string, mimeType?: string): boolean { - if (mimeType?.startsWith("image/")) { - return true; - } - return IMAGE_EXTENSIONS.has(getFileExtension(filePath) ?? ""); -} - -/** Check whether a file is a video using MIME first and extension as fallback. */ -export function isVideoFile(filePath: string, mimeType?: string): boolean { - if (mimeType?.startsWith("video/")) { - return true; - } - return VIDEO_EXTENSIONS.has(getFileExtension(filePath) ?? ""); -} diff --git a/extensions/qqbot/src/engine/messaging/outbound-audio-port.ts b/extensions/qqbot/src/engine/messaging/outbound-audio-port.ts deleted file mode 100644 index 0fa9ab930f5a..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-audio-port.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Qqbot plugin module implements outbound audio port behavior. -import type { OutboundAudioPort } from "../adapter/audio.port.js"; - -let outboundAudioPort: OutboundAudioPort | null = null; - -/** - * Initialize the outbound audio adapter. Called once by gateway startup - * via `adapters.outboundAudio`. - */ -export function setOutboundAudioPort(port: OutboundAudioPort): void { - outboundAudioPort = port; -} - -function getAudio(): OutboundAudioPort { - if (!outboundAudioPort) { - throw new Error("OutboundAudioPort not initialized — call setOutboundAudioPort first"); - } - return outboundAudioPort; -} - -export function audioFileToSilkBase64(p: string, f?: string[]): Promise { - return getAudio().audioFileToSilkBase64(p, f); -} - -export function isAudioFile(p: string, m?: string): boolean { - try { - return getAudio().isAudioFile(p, m); - } catch { - return false; - } -} - -export function shouldTranscodeVoice(p: string): boolean { - return getAudio().shouldTranscodeVoice(p); -} - -export function waitForFile(p: string, ms?: number): Promise { - return getAudio().waitForFile(p, ms); -} diff --git a/extensions/qqbot/src/engine/messaging/outbound-config.test.ts b/extensions/qqbot/src/engine/messaging/outbound-config.test.ts deleted file mode 100644 index 062b63b67ba8..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-config.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { GatewayAccount } from "../types.js"; -import { sendMedia, sendText } from "./outbound.js"; - -function makeAccount(accountId: string): GatewayAccount { - return { - accountId, - appId: "", - clientSecret: "", - markdownSupport: false, - config: {}, - }; -} - -describe("QQBot outbound configuration guidance", () => { - it("returns default-account recovery paths from sendText", async () => { - const result = await sendText({ - account: makeAccount("default"), - to: "user-openid", - text: "hello", - }); - - expect(result.error).toContain("channels.qqbot.appId"); - expect(result.error).toContain("QQBOT_APP_ID and QQBOT_CLIENT_SECRET"); - }); - - it("returns named-account recovery paths from sendMedia", async () => { - const result = await sendMedia({ - account: makeAccount("operations"), - accountId: "operations", - to: "user-openid", - text: "", - mediaUrl: "https://example.com/image.png", - }); - - expect(result.error).toContain("channels.qqbot.accounts.operations.appId"); - expect(result.error).not.toContain("QQBOT_APP_ID"); - expect(result.error).not.toContain("QQBOT_CLIENT_SECRET"); - }); - - it.each([ - ["default", "https://example.com/image.png", "channels.qqbot.appId", true], - [ - "operations", - "report https://example.com/report.pdf", - "channels.qqbot.accounts.operations.appId", - false, - ], - ] as const)( - "preflights tagged media for the %s account", - async (accountId, text, expectedPath, expectsDefaultEnv) => { - const result = await sendText({ - account: makeAccount(accountId), - to: "user-openid", - text, - }); - - expect(result.error).toContain(expectedPath); - if (expectsDefaultEnv) { - expect(result.error).toContain("QQBOT_APP_ID and QQBOT_CLIENT_SECRET"); - } else { - expect(result.error).not.toContain("QQBOT_APP_ID"); - expect(result.error).not.toContain("QQBOT_CLIENT_SECRET"); - } - }, - ); -}); diff --git a/extensions/qqbot/src/engine/messaging/outbound-deliver.test.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.test.ts deleted file mode 100644 index 2546d92f6584..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-deliver.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { GatewayAccount } from "../types.js"; - -const { sendTextMock, senderSendMediaMock } = vi.hoisted(() => ({ - sendTextMock: vi.fn(), - senderSendMediaMock: vi.fn(), -})); - -vi.mock("./sender.js", () => ({ - accountToCreds: (account: { appId: string; clientSecret: string }) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - }), - buildDeliveryTarget: (target: { - type: string; - senderId: string; - groupOpenid?: string; - guildId?: string; - channelId?: string; - }) => ({ - type: target.type === "group" ? "group" : target.type === "c2c" ? "c2c" : target.type, - id: - target.type === "group" - ? target.groupOpenid - : target.type === "dm" - ? target.guildId - : target.type === "guild" - ? target.channelId - : target.senderId, - }), - sendMedia: senderSendMediaMock, - sendText: sendTextMock, - withTokenRetry: async (_creds: unknown, fn: (token: string) => Promise) => - await fn("token"), -})); - -import { parseAndSendMediaTags, sendPlainReply } from "./outbound-deliver.js"; -import { DEFAULT_MEDIA_SEND_ERROR } from "./outbound-types.js"; - -const account: GatewayAccount = { - accountId: "qq-main", - appId: "app", - clientSecret: "secret", - markdownSupport: false, - config: {}, -}; - -const event = { - type: "c2c" as const, - senderId: "user-openid", - messageId: "msg-1", -}; - -const mediaAccess = { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", -}; - -function makeLog() { - return { - info: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; -} - -function makeMediaSender() { - return { - sendPhoto: vi.fn(async () => ({ channel: "qqbot", messageId: "image-1" })), - sendVoice: vi.fn(async () => ({ channel: "qqbot", messageId: "voice-1" })), - sendVideoMsg: vi.fn(async () => ({ channel: "qqbot", messageId: "video-1" })), - sendDocument: vi.fn(async () => ({ channel: "qqbot", messageId: "file-1" })), - sendMedia: vi.fn( - async (_opts: { - mediaUrl: string; - }): Promise< - { channel: "qqbot"; messageId: string } | { channel: "qqbot"; error: string } - > => ({ channel: "qqbot", messageId: "media-1" }), - ), - }; -} - -function makeActx() { - return { - account, - qualifiedTarget: "qqbot:c2c:user-openid", - log: makeLog(), - mediaAccess, - }; -} - -const sendWithRetry = async (sendFn: (token: string) => Promise): Promise => - await sendFn("token"); - -const chunkText = (text: string) => [text]; - -describe("outbound deliver sandbox media", () => { - beforeEach(() => { - vi.clearAllMocks(); - sendTextMock.mockResolvedValue({ id: "text-1", timestamp: 123 }); - senderSendMediaMock.mockResolvedValue({ id: "media-1", timestamp: 123 }); - }); - - it("passes scoped media access for qqmedia tags and sends a sanitized fallback on failure", async () => { - const mediaSender = makeMediaSender(); - mediaSender.sendMedia.mockResolvedValue({ channel: "qqbot", error: "upload failed" }); - - const result = await parseAndSendMediaTags( - "/workspace/missing-report.pdf", - event, - makeActx(), - sendWithRetry, - vi.fn(() => undefined), - { mediaSender, chunkText }, - ); - - expect(result.handled).toBe(true); - expect(mediaSender.sendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "/workspace/missing-report.pdf", - mediaAccess, - }), - ); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([DEFAULT_MEDIA_SEND_ERROR]); - }); - - it("auto-routes relative payload media with scoped media access and a sanitized fallback", async () => { - const mediaSender = makeMediaSender(); - mediaSender.sendMedia.mockResolvedValue({ channel: "qqbot", error: "upload failed" }); - - await sendPlainReply( - { mediaUrl: "missing-report.pdf" }, - "", - event, - makeActx(), - sendWithRetry, - vi.fn(() => undefined), - [], - { mediaSender, chunkText }, - ); - - expect(mediaSender.sendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - mediaUrl: "missing-report.pdf", - mediaAccess, - }), - ); - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([DEFAULT_MEDIA_SEND_ERROR]); - }); - - it("continues text chunk delivery after a failed chunk", async () => { - const actx = makeActx(); - sendTextMock.mockRejectedValueOnce(new Error("first failed")); - - await sendPlainReply( - {}, - "first second", - event, - actx, - sendWithRetry, - vi.fn(() => undefined), - [], - { mediaSender: makeMediaSender(), chunkText: () => ["first", "second"] }, - ); - - expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["first", "second"]); - expect(actx.log.error).toHaveBeenCalledWith("Send failed: first failed"); - }); - - it("continues automatic media delivery after returned and thrown errors", async () => { - const mediaSender = makeMediaSender(); - mediaSender.sendMedia - .mockResolvedValueOnce({ channel: "qqbot", error: "rejected" }) - .mockRejectedValueOnce(new Error("network failed")) - .mockResolvedValueOnce({ channel: "qqbot", messageId: "media-3" }); - - await sendPlainReply( - { mediaUrls: ["first.pdf", "second.pdf", "third.pdf"] }, - "", - event, - makeActx(), - sendWithRetry, - vi.fn(() => undefined), - [], - { mediaSender, chunkText }, - ); - - expect(mediaSender.sendMedia.mock.calls.map((call) => call[0].mediaUrl)).toEqual([ - "first.pdf", - "second.pdf", - "third.pdf", - ]); - expect(sendTextMock).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts deleted file mode 100644 index 1bc4f4f88828..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts +++ /dev/null @@ -1,964 +0,0 @@ -/** - * Outbound delivery helpers — core/ version. - * - * Uses the unified `sender.ts` business function layer for all text and - * image sending. Media sends (photo/voice/video/file) are injected via - * `DeliverDeps.mediaSender`. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - sendPayloadMediaSequence, - sendPayloadTextChunkSequence, -} from "openclaw/plugin-sdk/reply-payload"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { GatewayAccount } from "../types.js"; -import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js"; -import { normalizeMediaTags } from "../utils/media-tags.js"; -import { isLocalPath as isLocalFilePath } from "../utils/platform.js"; -import { filterInternalMarkers } from "../utils/text-parsing.js"; -import { decodeMediaPath } from "./decode-media-path.js"; -import { DEFAULT_MEDIA_SEND_ERROR, type OutboundMediaAccessContext } from "./outbound-types.js"; -import { raceWithTimeout } from "./race-with-timeout.js"; -import { - sendText as senderSendText, - sendMedia as senderSendMedia, - withTokenRetry, - buildDeliveryTarget, - accountToCreds, -} from "./sender.js"; - -// ---- Injected dependency interfaces ---- - -/** Media target context — describes where to send media. */ -interface MediaTargetContext extends OutboundMediaAccessContext { - targetType: "c2c" | "group" | "channel" | "dm"; - targetId: string; - account: GatewayAccount; - replyToId?: string; -} - -/** Media send result. */ -interface MediaSendResult { - channel?: string; - error?: string; - messageId?: string; -} - -/** Media sender interface — implemented by the upper-layer outbound.ts module. */ -interface MediaSender { - sendPhoto(target: MediaTargetContext, imageUrl: string): Promise; - sendVoice( - target: MediaTargetContext, - voicePath: string, - uploadFormats?: string[], - transcodeEnabled?: boolean, - ): Promise; - sendVideoMsg(target: MediaTargetContext, videoPath: string): Promise; - sendDocument(target: MediaTargetContext, filePath: string): Promise; - sendMedia( - opts: { - to: string; - text: string; - mediaUrl: string; - accountId: string; - replyToId: string; - account: GatewayAccount; - } & OutboundMediaAccessContext, - ): Promise; -} - -/** Delivery dependencies — injected when calling parseAndSendMediaTags / sendPlainReply. */ -export interface DeliverDeps { - mediaSender: MediaSender; - /** Text chunker — delegates to `runtime.channel.text.chunkMarkdownText`. */ - chunkText: (text: string, limit: number) => string[]; -} - -// ---- Exported types ---- - -/** Maximum text length for a single QQ Bot message. */ -export const TEXT_CHUNK_LIMIT = 5000; - -interface DeliverEventContext { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - messageId: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; - msgIdx?: string; -} - -interface DeliverAccountContext extends OutboundMediaAccessContext { - account: GatewayAccount; - qualifiedTarget: string; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -/** Wrapper that retries when the access token expires. */ -type SendWithRetryFn = (sendFn: (token: string) => Promise) => Promise; - -/** Consume a quote ref exactly once. */ -type ConsumeQuoteRefFn = () => string | undefined; - -// ---- Internal helpers ---- - -function resolveMediaTargetContext( - event: DeliverEventContext, - actx: DeliverAccountContext, -): MediaTargetContext { - const { account } = actx; - return { - targetType: - event.type === "c2c" - ? "c2c" - : event.type === "group" - ? "group" - : event.type === "dm" - ? "dm" - : "channel", - targetId: - event.type === "c2c" - ? event.senderId - : event.type === "group" - ? event.groupOpenid! - : event.type === "dm" - ? event.guildId! - : event.channelId!, - account, - replyToId: event.messageId, - ...(actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {}), - ...(actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {}), - ...(actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}), - }; -} - -function isHttpUrl(value: string): boolean { - return value.startsWith("http://") || value.startsWith("https://"); -} - -function isImageDataUrl(value: string): boolean { - return value.startsWith("data:image/"); -} - -function isBareRelativeMediaPath(value: string): boolean { - const trimmed = value.trim(); - return ( - Boolean(trimmed) && - !trimmed.startsWith("#") && - !trimmed.startsWith("//") && - !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) - ); -} - -async function autoMediaBatch(params: { - qualifiedTarget: string; - account: GatewayAccount; - replyToId: string; - mediaUrls: string[]; - mediaSender: MediaSender; - mediaAccess?: OutboundMediaAccessContext["mediaAccess"]; - mediaLocalRoots?: OutboundMediaAccessContext["mediaLocalRoots"]; - mediaReadFile?: OutboundMediaAccessContext["mediaReadFile"]; - log?: DeliverAccountContext["log"]; - onResultError: (mediaUrl: string, error: string) => string; - onThrownError: (mediaUrl: string, error: string) => string; - onSuccess?: (mediaUrl: string) => string | undefined; -}): Promise { - let sentCount = 0; - await sendPayloadMediaSequence({ - text: "", - mediaUrls: params.mediaUrls, - send: async ({ mediaUrl }) => - await sendWithResultLogging({ - run: async () => - await params.mediaSender.sendMedia({ - to: params.qualifiedTarget, - text: "", - mediaUrl, - accountId: params.account.accountId, - replyToId: params.replyToId, - account: params.account, - ...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}), - ...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}), - ...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}), - }), - log: params.log, - onSuccess: params.onSuccess ? () => params.onSuccess?.(mediaUrl) : undefined, - onError: (error) => params.onResultError(mediaUrl, error), - onThrownError: (error) => params.onThrownError(mediaUrl, error), - }), - onResult: (sent) => { - if (sent) { - sentCount++; - } - }, - }); - return sentCount; -} - -// ---- Text chunk sending ---- - -async function sendTextChunkToTarget(params: { - account: GatewayAccount; - event: DeliverEventContext; - token: string; - text: string; - consumeQuoteRef: ConsumeQuoteRefFn; - allowDm: boolean; - forcePlainText?: boolean; -}): Promise { - const { account, event, text, consumeQuoteRef, allowDm, forcePlainText } = params; - const ref = consumeQuoteRef(); - const target = buildDeliveryTarget(event); - if (target.type === "dm" && !allowDm) { - return undefined; - } - const creds = accountToCreds(account); - return await senderSendText(target, text, creds, { - msgId: event.messageId, - messageReference: ref, - forcePlainText, - }); -} - -async function sendTextChunks( - text: string, - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, - deps: DeliverDeps, -): Promise { - const { account, log } = actx; - const chunks = deps.chunkText(text, TEXT_CHUNK_LIMIT); - await sendTextChunksWithRetry({ - account, - event, - chunks, - sendWithRetry, - consumeQuoteRef, - allowDm: true, - log, - onSuccess: (chunk) => - `Sent text chunk (${chunk.length}/${text.length} chars): ${truncateUtf16Safe(chunk, 50)}...`, - onError: (err) => `Failed to send text chunk: ${formatErrorMessage(err)}`, - }); -} - -export async function sendTextOnlyReply( - text: string, - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, - deps: DeliverDeps, -): Promise { - const safeText = filterInternalMarkers(text).trim(); - if (!safeText) { - return; - } - const { account, log } = actx; - const chunks = deps.chunkText(safeText, TEXT_CHUNK_LIMIT); - await sendTextChunksWithRetry({ - account, - event, - chunks, - sendWithRetry, - consumeQuoteRef, - allowDm: true, - forcePlainText: true, - log, - onSuccess: (chunk) => - `Sent text-only chunk (${chunk.length}/${safeText.length} chars): ${truncateUtf16Safe(chunk, 50)}...`, - onError: (err) => `Failed to send text-only chunk: ${formatErrorMessage(err)}`, - }); -} - -async function sendTextChunksWithRetry(params: { - account: GatewayAccount; - event: DeliverEventContext; - chunks: string[]; - sendWithRetry: SendWithRetryFn; - consumeQuoteRef: ConsumeQuoteRefFn; - allowDm: boolean; - forcePlainText?: boolean; - log?: DeliverAccountContext["log"]; - onSuccess: (chunk: string) => string; - onError: (err: unknown) => string; -}): Promise { - const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, forcePlainText, log } = - params; - await sendPayloadTextChunkSequence({ - chunks, - send: async ({ text }) => { - try { - await sendWithRetry((token) => - sendTextChunkToTarget({ - account, - event, - token, - text, - consumeQuoteRef, - allowDm, - forcePlainText, - }), - ); - log?.info(params.onSuccess(text)); - } catch (err) { - log?.error(params.onError(err)); - } - }, - }); -} - -// ---- Result logging helpers ---- - -async function sendWithResultLogging(params: { - run: () => Promise; - log?: DeliverAccountContext["log"]; - onSuccess?: () => string | undefined; - onError: (error: string) => string; - onThrownError?: (error: string) => string; -}): Promise { - try { - const result = await params.run(); - if (result.error) { - params.log?.error(params.onError(result.error)); - return false; - } - const successMessage = params.onSuccess?.(); - if (successMessage) { - params.log?.info(successMessage); - } - return true; - } catch (err) { - const error = formatErrorMessage(err); - params.log?.error((params.onThrownError ?? params.onError)(error)); - return false; - } -} - -async function sendPhotoWithLogging(params: { - target: MediaTargetContext; - imageUrl: string; - mediaSender: MediaSender; - log?: DeliverAccountContext["log"]; - onSuccess?: (imageUrl: string) => string | undefined; - onError: (error: string) => string; -}): Promise { - return await sendWithResultLogging({ - run: async () => await params.mediaSender.sendPhoto(params.target, params.imageUrl), - log: params.log, - onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : undefined, - onError: params.onError, - }); -} - -/** Send voice with a 45s timeout guard. */ -async function sendVoiceWithTimeout( - target: MediaTargetContext, - voicePath: string, - account: GatewayAccount, - mediaSender: MediaSender, - log: DeliverAccountContext["log"], -): Promise { - const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats; - const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; - const voiceTimeout = 45_000; - try { - const result = await raceWithTimeout( - (timeoutState) => - mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => { - if (timeoutState.timedOut) { - log?.debug?.(`sendVoice completed after timeout, suppressing late delivery`); - return { - channel: "qqbot", - error: "Voice send completed after timeout (suppressed)", - }; - } - return r; - }), - voiceTimeout, - () => ({ - channel: "qqbot", - error: "Voice send timed out and was skipped", - }), - ); - if (result.error) { - log?.error(`sendVoice error: ${result.error}`); - return false; - } - return true; - } catch (err) { - log?.error(`sendVoice unexpected error: ${formatErrorMessage(err)}`); - return false; - } -} - -// ============ Public API ============ - -/** - * Parse media tags from the reply text and send them in order. - * - * @returns `true` when media tags were found and handled; `false` when the caller - * should continue through the plain-text pipeline. - */ -export async function parseAndSendMediaTags( - replyText: string, - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, - deps: DeliverDeps, -): Promise<{ handled: boolean; normalizedText: string }> { - const { account, log } = actx; - - const text = normalizeMediaTags(replyText); - - const mediaTagRegex = - /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; - const mediaTagMatches = [...text.matchAll(mediaTagRegex)]; - - if (mediaTagMatches.length === 0) { - return { handled: false, normalizedText: text }; - } - - const tagCounts = mediaTagMatches.reduce>((acc, m) => { - const t = normalizeLowercaseStringOrEmpty(m[1]); - acc[t] = (acc[t] ?? 0) + 1; - return acc; - }, {}); - log?.debug?.( - `Detected media tags: ${Object.entries(tagCounts) - .map(([k, v]) => `${v} <${k}>`) - .join(", ")}`, - ); - - type QueueItem = { - type: "text" | "image" | "voice" | "video" | "file" | "media"; - content: string; - }; - const sendQueue: QueueItem[] = []; - - let lastIndex = 0; - const regex2 = - /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; - let match; - - while ((match = regex2.exec(text)) !== null) { - const textBefore = text - .slice(lastIndex, match.index) - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (textBefore) { - sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) }); - } - - const tagName = normalizeLowercaseStringOrEmpty(match[1]); - const mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log); - - if (mediaPath) { - const typeMap: Record = { - qqmedia: "media", - qqvoice: "voice", - qqvideo: "video", - qqfile: "file", - }; - const itemType = typeMap[tagName] ?? "image"; - sendQueue.push({ type: itemType, content: mediaPath }); - log?.debug?.(`Found ${itemType} in <${tagName}>: ${mediaPath}`); - } - - lastIndex = match.index + match[0].length; - } - - const textAfter = text - .slice(lastIndex) - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (textAfter) { - sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) }); - } - - log?.debug?.(`Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); - - const mediaTarget = resolveMediaTargetContext(event, actx); - let deliveredVisibleOutput = false; - - for (const item of sendQueue) { - if (item.type === "text") { - await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps); - if (item.content.trim()) { - deliveredVisibleOutput = true; - } - } else if (item.type === "image") { - const sent = await sendPhotoWithLogging({ - target: mediaTarget, - imageUrl: item.content, - mediaSender: deps.mediaSender, - log, - onError: (error) => `sendPhoto error: ${error}`, - }); - deliveredVisibleOutput = deliveredVisibleOutput || sent; - } else if (item.type === "voice") { - const sent = await sendVoiceWithTimeout( - mediaTarget, - item.content, - account, - deps.mediaSender, - log, - ); - deliveredVisibleOutput = deliveredVisibleOutput || sent; - } else if (item.type === "video") { - const sent = await sendWithResultLogging({ - run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content), - log, - onError: (error) => `sendVideoMsg error: ${error}`, - }); - deliveredVisibleOutput = deliveredVisibleOutput || sent; - } else if (item.type === "file") { - const sent = await sendWithResultLogging({ - run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content), - log, - onError: (error) => `sendDocument error: ${error}`, - }); - deliveredVisibleOutput = deliveredVisibleOutput || sent; - } else if (item.type === "media") { - const sent = await sendWithResultLogging({ - run: async () => - await deps.mediaSender.sendMedia({ - to: actx.qualifiedTarget, - text: "", - mediaUrl: item.content, - accountId: account.accountId, - replyToId: event.messageId, - account, - ...(actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {}), - ...(actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {}), - ...(actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}), - }), - log, - onError: (error) => `sendMedia(auto) error: ${error}`, - }); - deliveredVisibleOutput = deliveredVisibleOutput || sent; - } - } - - if (!deliveredVisibleOutput) { - await sendTextChunks( - DEFAULT_MEDIA_SEND_ERROR, - event, - actx, - sendWithRetry, - consumeQuoteRef, - deps, - ); - return { handled: true, normalizedText: "" }; - } - - return { handled: true, normalizedText: text }; -} - -// ---- Plain reply ---- - -interface PlainReplyPayload { - text?: string; - mediaUrls?: string[]; - mediaUrl?: string; - audioAsVoice?: boolean; -} - -/** - * Send a reply that does not contain structured media tags. - * Handles markdown image embeds, Base64 media, plain-text chunking, and local media routing. - */ -export async function sendPlainReply( - payload: PlainReplyPayload, - replyText: string, - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, - toolMediaUrls: string[], - deps: DeliverDeps, -): Promise { - const { account, qualifiedTarget, log } = actx; - - const collectedImageUrls: string[] = []; - const localMediaToSend: string[] = []; - - const collectImageUrl = ( - url: string | undefined | null, - allowBareRelativeMedia = false, - ): boolean => { - if (!url) { - return false; - } - const isRemoteHttpUrl = isHttpUrl(url); - const isDataUrl = isImageDataUrl(url); - if (isRemoteHttpUrl || isDataUrl) { - if (!collectedImageUrls.includes(url)) { - collectedImageUrls.push(url); - log?.debug?.( - `Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : truncateUtf16Safe(url, 80) + "..."}`, - ); - } - return true; - } - if (isLocalFilePath(url) || (allowBareRelativeMedia && isBareRelativeMediaPath(url))) { - if (!localMediaToSend.includes(url)) { - localMediaToSend.push(url); - log?.debug?.(`Collected local media for auto-routing: ${url}`); - } - return true; - } - return false; - }; - - if (payload.mediaUrls?.length) { - for (const url of payload.mediaUrls) { - collectImageUrl(url, true); - } - } - if (payload.mediaUrl) { - collectImageUrl(payload.mediaUrl, true); - } - - // Extract markdown images. - const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi; - const mdMatches = [...replyText.matchAll(mdImageRegex)]; - for (const m of mdMatches) { - const url = m[2]?.trim(); - if (url && !collectedImageUrls.includes(url)) { - if (isHttpUrl(url)) { - collectedImageUrls.push(url); - log?.debug?.(`Extracted HTTP image from markdown: ${truncateUtf16Safe(url, 80)}...`); - } else if (isLocalFilePath(url)) { - if (!localMediaToSend.includes(url)) { - localMediaToSend.push(url); - log?.debug?.(`Collected local media from markdown for auto-routing: ${url}`); - } - } - } - } - - // Extract bare image URLs. - const bareUrlRegex = - /(?]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi; - const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)]; - for (const m of bareUrlMatches) { - const url = m[1]; - if (url && !collectedImageUrls.includes(url)) { - collectedImageUrls.push(url); - log?.debug?.(`Extracted bare image URL: ${truncateUtf16Safe(url, 80)}...`); - } - } - - const useMarkdown = account.markdownSupport; - log?.debug?.(`Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`); - - let textWithoutImages = filterInternalMarkers(replyText); - - for (const m of mdMatches) { - const url = m[2]?.trim(); - if (url && !isHttpUrl(url) && !isLocalFilePath(url)) { - textWithoutImages = textWithoutImages.replace(m[0], "").trim(); - } - } - - if (useMarkdown) { - await sendMarkdownReply( - textWithoutImages, - collectedImageUrls, - mdMatches, - bareUrlMatches, - event, - actx, - sendWithRetry, - consumeQuoteRef, - deps, - ); - } else { - await sendPlainTextReply( - textWithoutImages, - collectedImageUrls, - mdMatches, - bareUrlMatches, - event, - actx, - sendWithRetry, - consumeQuoteRef, - deps, - ); - } - - const hasVisibleTextOrInlineImage = Boolean( - textWithoutImages.trim() || collectedImageUrls.length > 0, - ); - let sentMediaCount = 0; - let sentFailureFallback = false; - - // Send local media collected from payload.mediaUrl or markdown local paths. - if (localMediaToSend.length > 0) { - log?.debug?.(`Sending ${localMediaToSend.length} local media via sendMedia auto-routing`); - sentMediaCount += await autoMediaBatch({ - qualifiedTarget, - account, - replyToId: event.messageId, - mediaUrls: localMediaToSend, - mediaSender: deps.mediaSender, - ...(actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {}), - ...(actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {}), - ...(actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}), - log, - onSuccess: (mediaPath) => `Sent local media: ${mediaPath}`, - onResultError: (mediaPath, error) => `sendMedia(auto) error for ${mediaPath}: ${error}`, - onThrownError: (mediaPath, error) => `sendMedia(auto) failed for ${mediaPath}: ${error}`, - }); - if (!hasVisibleTextOrInlineImage && sentMediaCount === 0) { - await sendTextChunks( - DEFAULT_MEDIA_SEND_ERROR, - event, - actx, - sendWithRetry, - consumeQuoteRef, - deps, - ); - sentFailureFallback = true; - } - } - - // Forward media gathered during the tool phase. - if (toolMediaUrls.length > 0) { - log?.debug?.( - `Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`, - ); - sentMediaCount += await autoMediaBatch({ - qualifiedTarget, - account, - replyToId: event.messageId, - mediaUrls: toolMediaUrls, - mediaSender: deps.mediaSender, - ...(actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {}), - ...(actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {}), - ...(actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}), - log, - onSuccess: (mediaUrl) => `Forwarded tool media: ${truncateUtf16Safe(mediaUrl, 80)}...`, - onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`, - onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`, - }); - if (!hasVisibleTextOrInlineImage && sentMediaCount === 0 && !sentFailureFallback) { - await sendTextChunks( - DEFAULT_MEDIA_SEND_ERROR, - event, - actx, - sendWithRetry, - consumeQuoteRef, - deps, - ); - } - toolMediaUrls.length = 0; - } -} - -// ---- Markdown reply ---- - -async function sendMarkdownReply( - textWithoutImages: string, - imageUrls: string[], - mdMatches: RegExpMatchArray[], - bareUrlMatches: RegExpMatchArray[], - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, - deps: DeliverDeps, -): Promise { - const { account, log } = actx; - - const httpImageUrls: string[] = []; - const base64ImageUrls: string[] = []; - for (const url of imageUrls) { - if (isImageDataUrl(url)) { - base64ImageUrls.push(url); - } else if (isHttpUrl(url)) { - httpImageUrls.push(url); - } - } - log?.debug?.( - `Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`, - ); - - // Send Base64 images via Rich Media API. - if (base64ImageUrls.length > 0) { - log?.debug?.(`Sending ${base64ImageUrls.length} image(s) via Rich Media API...`); - for (const imageUrl of base64ImageUrls) { - try { - const target = buildDeliveryTarget(event); - const creds = accountToCreds(account); - if (target.type === "c2c" || target.type === "group") { - await withTokenRetry(creds, async () => { - await senderSendMedia({ - target, - creds, - kind: "image", - source: { url: imageUrl }, - msgId: event.messageId, - }); - }); - } else { - log?.debug?.(`${target.type} does not support rich media, skipping Base64 image`); - } - log?.debug?.(`Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`); - } catch (imgErr) { - log?.error(`Failed to send Base64 image via Rich Media API: ${String(imgErr)}`); - } - } - } - - // Handle public image URLs — format as markdown images with dimensions. - const existingMdUrls = new Set(mdMatches.flatMap((m) => (m[2] === undefined ? [] : [m[2]]))); - const imagesToAppend: string[] = []; - - for (const url of httpImageUrls) { - if (!existingMdUrls.has(url)) { - try { - const size = await getImageSize(url); - imagesToAppend.push(formatQQBotMarkdownImage(url, size)); - log?.debug?.( - `Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${truncateUtf16Safe(url, 60)}...`, - ); - } catch (err) { - log?.debug?.(`Failed to get image size, using default: ${formatErrorMessage(err)}`); - imagesToAppend.push(formatQQBotMarkdownImage(url, null)); - } - } - } - - // Backfill dimensions for existing markdown images. - let result = textWithoutImages; - for (const m of mdMatches) { - const fullMatch = m[0]; - const imgUrl = m[2]; - if (fullMatch === undefined || imgUrl === undefined) { - continue; - } - const isRemoteHttpUrl = isHttpUrl(imgUrl); - if (isRemoteHttpUrl && !hasQQBotImageSize(fullMatch)) { - try { - const size = await getImageSize(imgUrl); - result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size)); - log?.debug?.( - `Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${truncateUtf16Safe(imgUrl, 60)}...`, - ); - } catch (err) { - log?.debug?.( - `Failed to get image size for existing md, using default: ${formatErrorMessage(err)}`, - ); - result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null)); - } - } - } - - // Remove bare image URLs from text body. - for (const m of bareUrlMatches) { - result = result.replace(m[0], "").trim(); - } - - // Append markdown images. - if (imagesToAppend.length > 0) { - result = result.trim(); - result = result ? result + "\n\n" + imagesToAppend.join("\n") : imagesToAppend.join("\n"); - } - - // Send markdown text. - if (result.trim()) { - const mdChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT); - await sendTextChunksWithRetry({ - account, - event, - chunks: mdChunks, - sendWithRetry, - consumeQuoteRef, - allowDm: true, - log, - onSuccess: (chunk) => - `Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, - onError: (err) => `Failed to send markdown message chunk: ${formatErrorMessage(err)}`, - }); - } -} - -// ---- Plain-text reply ---- - -async function sendPlainTextReply( - textWithoutImages: string, - imageUrls: string[], - mdMatches: RegExpMatchArray[], - bareUrlMatches: RegExpMatchArray[], - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, - deps: DeliverDeps, -): Promise { - const { account, log } = actx; - - const imgMediaTarget = resolveMediaTargetContext(event, actx); - - let result = textWithoutImages; - for (const m of mdMatches) { - result = result.replace(m[0], "").trim(); - } - for (const m of bareUrlMatches) { - result = result.replace(m[0], "").trim(); - } - - // QQ group messages reject some dotted bare URLs, so filter them first. - if (result && event.type !== "c2c") { - result = result.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2"); - } - - try { - for (const imageUrl of imageUrls) { - await sendPhotoWithLogging({ - target: imgMediaTarget, - imageUrl, - mediaSender: deps.mediaSender, - log, - onSuccess: (nextImageUrl) => - `Sent image via sendPhoto: ${truncateUtf16Safe(nextImageUrl, 80)}...`, - onError: (error) => `Failed to send image: ${error}`, - }); - } - - if (result.trim()) { - const plainChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT); - await sendTextChunksWithRetry({ - account, - event, - chunks: plainChunks, - sendWithRetry, - consumeQuoteRef, - allowDm: false, - log, - onSuccess: (chunk) => - `Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, - onError: (err) => `Send failed: ${formatErrorMessage(err)}`, - }); - } - } catch (err) { - log?.error(`Send failed: ${formatErrorMessage(err)}`); - } -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-path.ts b/extensions/qqbot/src/engine/messaging/outbound-media-path.ts deleted file mode 100644 index 76e00b121718..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-media-path.ts +++ /dev/null @@ -1,95 +0,0 @@ -import path from "node:path"; -import type { OutboundMediaAccessContext } from "./outbound-types.js"; - -export function mergeMediaLocalRoots( - ...groups: Array -): string[] | undefined { - const roots = groups - .flatMap((group) => group ?? []) - .map((root) => root.trim()) - .filter(Boolean); - return roots.length > 0 ? Array.from(new Set(roots)) : undefined; -} - -export function resolveOutboundMediaLocalRoots( - ctx: OutboundMediaAccessContext, -): string[] | undefined { - return mergeMediaLocalRoots(ctx.mediaAccess?.localRoots, ctx.mediaLocalRoots); -} - -export function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { - const resolvedRoot = path.resolve(rootPath); - if (resolvedRoot === path.parse(resolvedRoot).root) { - return false; - } - const relative = path.relative(resolvedRoot, path.resolve(candidatePath)); - return ( - relative === "" || (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - -function resolvePathInsideWorkspace( - workspaceDir: string, - pathWithinWorkspace: string, -): string | null { - const mappedPath = path.resolve(workspaceDir, pathWithinWorkspace); - return isPathWithinRoot(mappedPath, workspaceDir) ? mappedPath : null; -} - -function isVirtualWorkspacePath(normalizedPath: string): boolean { - return normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/"); -} - -export function resolveWorkspaceScopedLocalRoots( - roots: readonly string[] | undefined, - workspaceDir?: string, -): string[] | undefined { - if (!roots?.length) { - return undefined; - } - const scopedRoots = roots - .map((root) => root.trim()) - .filter(Boolean) - .map((root) => - workspaceDir && isVirtualWorkspacePath(root) - ? resolveWorkspacePathCandidate(root, workspaceDir) - : root, - ) - .filter((root): root is string => Boolean(root)); - return scopedRoots.length > 0 ? Array.from(new Set(scopedRoots)) : undefined; -} - -export function resolveWorkspacePathCandidate( - normalizedPath: string, - workspaceDir?: string, -): string | null { - if (!workspaceDir) { - return isVirtualWorkspacePath(normalizedPath) ? null : normalizedPath; - } - if (normalizedPath === "/workspace") { - return workspaceDir; - } - if (normalizedPath.startsWith("/workspace/")) { - return resolvePathInsideWorkspace(workspaceDir, normalizedPath.slice("/workspace/".length)); - } - if (path.isAbsolute(normalizedPath)) { - return normalizedPath; - } - return resolvePathInsideWorkspace(workspaceDir, normalizedPath); -} - -export function resolveWorkspacePathCandidates( - normalizedPath: string, - workspaceDir?: string, -): string[] { - const mappedPath = resolveWorkspacePathCandidate(normalizedPath, workspaceDir); - if (!mappedPath) { - return []; - } - if (mappedPath === normalizedPath) { - return [normalizedPath]; - } - return path.isAbsolute(normalizedPath) && !isVirtualWorkspacePath(normalizedPath) - ? [normalizedPath, mappedPath] - : [mappedPath]; -} diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts deleted file mode 100644 index 48bb08bbaaa2..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.test.ts +++ /dev/null @@ -1,692 +0,0 @@ -// Qqbot tests cover outbound-media-send host-read error handling behavior. -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; - -const { audioPortMock } = vi.hoisted(() => ({ - audioPortMock: { - audioFileToSilkBase64: vi.fn(), - isAudioFile: vi.fn(), - shouldTranscodeVoice: vi.fn(), - waitForFile: vi.fn(), - }, -})); - -vi.mock("openclaw/plugin-sdk/outbound-media", () => ({ - loadOutboundMediaFromUrl: vi.fn(), -})); - -vi.mock("../adapter/index.js", () => ({ - getPlatformAdapter: () => ({ getTempDir: () => "/tmp" }), -})); - -vi.mock("./outbound-audio-port.js", () => ({ - audioFileToSilkBase64: audioPortMock.audioFileToSilkBase64, - isAudioFile: audioPortMock.isAudioFile, - shouldTranscodeVoice: audioPortMock.shouldTranscodeVoice, - waitForFile: audioPortMock.waitForFile, -})); - -const { MockUploadDailyLimitExceededError } = vi.hoisted(() => { - class HoistedUploadDailyLimitExceededError extends Error { - override readonly name = "UploadDailyLimitExceededError"; - - constructor( - readonly filePath: string, - readonly fileSize: number, - message: string, - ) { - super(message); - } - } - return { MockUploadDailyLimitExceededError: HoistedUploadDailyLimitExceededError }; -}); - -vi.mock("./sender.js", () => ({ - accountToCreds: (account: { appId: string; clientSecret: string }) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - }), - initApiConfig: vi.fn(), - sendMedia: vi.fn(), - sendText: vi.fn(), - UploadDailyLimitExceededError: MockUploadDailyLimitExceededError, -})); - -import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; -import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; -import { - resolveOutboundMediaLocalRoots, - resolveWorkspaceScopedLocalRoots, -} from "./outbound-media-path.js"; -import { - resolveOutboundMediaPath, - sendDocument, - sendPhoto, - sendVideoMsg, - sendVoice, -} from "./outbound-media-send.js"; -import { OUTBOUND_ERROR_CODES } from "./outbound-types.js"; -import { sendMedia as sendOutboundMedia } from "./outbound.js"; -import { sendMedia as senderSendMedia } from "./sender.js"; - -vi.mock("openclaw/plugin-sdk/security-runtime", { spy: true }); - -const mockedLoadOutboundMediaFromUrl = vi.mocked(loadOutboundMediaFromUrl); -const mockedSenderSendMedia = vi.mocked(senderSendMedia); - -let openclawHome: string; -let originalOpenClawHome: string | undefined; - -function makeCtx() { - return { - targetType: "c2c" as const, - targetId: "user-openid", - account: { - accountId: "qq-main", - appId: "app-x", - clientSecret: "secret-x", - markdownSupport: false, - config: {}, - }, - mediaAccess: { - localRoots: ["/tmp/openclaw-sandbox"], - workspaceDir: "/tmp/workspace", - readFile: async () => Buffer.from("report"), - }, - mediaLocalRoots: ["/tmp/openclaw-sandbox"], - mediaReadFile: async () => Buffer.from("report"), - }; -} - -beforeEach(async () => { - vi.clearAllMocks(); - originalOpenClawHome = process.env.OPENCLAW_HOME; - // realpath: macOS tmpdir is a /var -> /private/var symlink and trusted-root - // resolution returns canonicalized paths that assertions compare against. - openclawHome = await fs.realpath( - await fs.mkdtemp(path.join(os.tmpdir(), "qqbot-host-read-voice-")), - ); - process.env.OPENCLAW_HOME = openclawHome; - audioPortMock.audioFileToSilkBase64.mockResolvedValue(undefined); - audioPortMock.isAudioFile.mockReturnValue(true); - audioPortMock.shouldTranscodeVoice.mockReturnValue(false); - audioPortMock.waitForFile.mockResolvedValue(12); -}); - -afterEach(async () => { - if (originalOpenClawHome === undefined) { - delete process.env.OPENCLAW_HOME; - } else { - process.env.OPENCLAW_HOME = originalOpenClawHome; - } - if (openclawHome) { - await fs.rm(openclawHome, { recursive: true, force: true }); - } -}); - -describe("resolveOutboundMediaPath", () => { - it("maps virtual /workspace paths before checking host local roots", () => { - const resolveLocalPathSpy = vi - .mocked(resolveLocalPathFromRootsSync) - .mockImplementation(({ filePath }) => - filePath === "/tmp/agent-workspace/attachments/report.docx" - ? { path: "/tmp/agent-workspace/attachments/report.docx", root: "/tmp/agent-workspace" } - : null, - ); - try { - const result = resolveOutboundMediaPath("/workspace/attachments/report.docx", "media", { - extraLocalRoots: ["/workspace/attachments", "/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - allowMissingLocalPath: true, - }); - - expect(result).toEqual({ - ok: true, - mediaPath: "/tmp/agent-workspace/attachments/report.docx", - }); - expect(resolveLocalPathSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ filePath: "/workspace/attachments/report.docx" }), - ); - expect(resolveLocalPathSpy).toHaveBeenCalledWith( - expect.objectContaining({ filePath: "/tmp/agent-workspace/attachments/report.docx" }), - ); - } finally { - resolveLocalPathSpy.mockRestore(); - } - }); - - it("resolves relative paths only against the virtual workspace", () => { - const resolveLocalPathSpy = vi - .mocked(resolveLocalPathFromRootsSync) - .mockImplementation(({ filePath }) => - filePath === "/tmp/agent-workspace/report.docx" - ? { path: "/tmp/agent-workspace/report.docx", root: "/tmp/agent-workspace" } - : null, - ); - try { - const result = resolveOutboundMediaPath("report.docx", "media", { - extraLocalRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - allowMissingLocalPath: true, - }); - - expect(result).toEqual({ ok: true, mediaPath: "/tmp/agent-workspace/report.docx" }); - expect(resolveLocalPathSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ filePath: "report.docx" }), - ); - } finally { - resolveLocalPathSpy.mockRestore(); - } - }); - - it("does not treat workspaceDir as an allowed host absolute root", () => { - expect( - resolveOutboundMediaLocalRoots({ - mediaAccess: { - localRoots: ["/tmp/openclaw-sandbox"], - workspaceDir: "/tmp/agent-workspace", - }, - mediaLocalRoots: ["/tmp/openclaw-sandbox"], - }), - ).toEqual(["/tmp/openclaw-sandbox"]); - }); - - it("maps only authorized virtual workspace roots for host-read loading", () => { - expect( - resolveWorkspaceScopedLocalRoots( - ["/workspace/attachments", "/tmp/openclaw-sandbox", "/workspace/../media"], - "/tmp/agent-workspace", - ), - ).toEqual(["/tmp/agent-workspace/attachments", "/tmp/openclaw-sandbox"]); - }); - - it.each(["/workspace/../media/secret.pdf", "../media/secret.pdf"])( - "rejects virtual workspace escapes before checking sibling media roots: %s", - (mediaPath) => { - const resolveLocalPathSpy = vi - .mocked(resolveLocalPathFromRootsSync) - .mockImplementation(({ filePath }) => - filePath === "/tmp/media/secret.pdf" - ? { path: "/tmp/media/secret.pdf", root: "/tmp/media" } - : null, - ); - try { - const result = resolveOutboundMediaPath(mediaPath, "media", { - extraLocalRoots: ["/tmp/media", "/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - }); - - expect(result.ok).toBe(false); - expect(resolveLocalPathSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ filePath: "/tmp/media/secret.pdf" }), - ); - } finally { - resolveLocalPathSpy.mockRestore(); - } - }, - ); -}); - -describe("trySendViaHostRead error handling", () => { - it("returns OutboundResult.error when loadOutboundMediaFromUrl rejects", async () => { - mockedLoadOutboundMediaFromUrl.mockRejectedValue(new Error("sandbox host read failed")); - - const result = await sendPhoto(makeCtx(), "/tmp/openclaw-sandbox/report.docx"); - - expect(result).toMatchObject({ channel: "qqbot", error: expect.any(String) }); - expect(result.error).toContain("sandbox host read failed"); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("falls back to normal local sends for trusted media paths outside host-read roots", async () => { - const trustedMediaDir = path.join(openclawHome, ".openclaw", "media", "qqbot"); - await fs.mkdir(trustedMediaDir, { recursive: true }); - const trustedMediaPath = path.join(trustedMediaDir, "trusted-report.docx"); - await fs.writeFile(trustedMediaPath, Buffer.from("trusted report")); - mockedLoadOutboundMediaFromUrl.mockRejectedValue(new Error("sandbox host read failed")); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendDocument(makeCtx(), trustedMediaPath); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "media-1" }); - expect(mockedLoadOutboundMediaFromUrl).not.toHaveBeenCalled(); - expect(mockedSenderSendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { localPath: trustedMediaPath }, - }), - ); - }); - - it("rejects host-read image sends when the loaded media is not an image", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.pdf", - contentType: "application/pdf", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendPhoto(makeCtx(), "/workspace/report.pdf"); - - expect(result).toMatchObject({ - channel: "qqbot", - error: expect.stringContaining("Unsupported image"), - }); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("rejects host-read video sends when the loaded media is not a video", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.pdf", - contentType: "application/pdf", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendVideoMsg(makeCtx(), "/workspace/report.pdf"); - - expect(result).toMatchObject({ - channel: "qqbot", - error: expect.stringContaining("Unsupported video"), - }); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("rejects host-read voice sends when the loaded media is not audio", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.pdf", - contentType: "application/pdf", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "voice-1", timestamp: 123 }); - - const result = await sendVoice(makeCtx(), "/workspace/report.pdf", [".mp3"], true); - - expect(result).toMatchObject({ - channel: "qqbot", - error: expect.stringContaining("Unsupported voice"), - }); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("rejects empty host-read file buffers before upload", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.alloc(0), - kind: "document", - fileName: "empty.pdf", - contentType: "application/pdf", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendDocument(makeCtx(), "/workspace/empty.pdf"); - - expect(result).toMatchObject({ - channel: "qqbot", - error: expect.stringContaining("File is empty"), - }); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("returns OutboundResult.error when senderSendMedia rejects", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("image"), - kind: "image", - fileName: "chart.png", - contentType: "image/png", - }); - mockedSenderSendMedia.mockRejectedValue(new Error("qq upload quota exceeded")); - - const result = await sendPhoto(makeCtx(), "/tmp/openclaw-sandbox/chart.png"); - - expect(result).toMatchObject({ channel: "qqbot", error: expect.any(String) }); - expect(result.error).toContain("qq upload quota exceeded"); - }); - - it("preserves daily upload quota metadata from senderSendMedia", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.docx", - contentType: "application/octet-stream", - }); - mockedSenderSendMedia.mockRejectedValue( - new MockUploadDailyLimitExceededError("", 2048, "daily quota"), - ); - - const result = await sendDocument(makeCtx(), "report.docx"); - - expect(result).toMatchObject({ - channel: "qqbot", - errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED, - qqBizCode: 40093002, - }); - expect(result.error).toContain("/tmp/workspace/report.docx"); - expect(result.error).not.toContain(""); - }); - - it("maps sandbox /workspace paths before host-read media loading", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.docx", - contentType: "application/octet-stream", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendDocument(makeCtx(), "/workspace/report.docx"); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "media-1" }); - expect(mockedLoadOutboundMediaFromUrl).toHaveBeenCalledWith( - "/tmp/workspace/report.docx", - expect.objectContaining({ - mediaAccess: expect.objectContaining({ - localRoots: ["/tmp/openclaw-sandbox"], - workspaceDir: "/tmp/workspace", - }), - workspaceDir: "/tmp/workspace", - }), - ); - }); - - it("does not host-read virtual /workspace paths without a workspaceDir", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.docx", - contentType: "application/octet-stream", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendPhoto( - { - ...makeCtx(), - mediaAccess: { - localRoots: ["/tmp/openclaw-sandbox"], - readFile: async () => Buffer.from("report"), - }, - mediaLocalRoots: [], - }, - "/workspace/report.docx", - ); - - expect(result).toMatchObject({ channel: "qqbot", error: expect.any(String) }); - expect(mockedLoadOutboundMediaFromUrl).not.toHaveBeenCalled(); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("does not host-read relative paths without a workspaceDir", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("image"), - kind: "image", - fileName: "chart.png", - contentType: "image/png", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendPhoto( - { - ...makeCtx(), - mediaAccess: { - localRoots: ["/tmp/openclaw-sandbox"], - readFile: async () => Buffer.from("image"), - }, - mediaLocalRoots: [], - }, - "chart.png", - ); - - expect(result).toMatchObject({ channel: "qqbot", error: expect.any(String) }); - expect(mockedLoadOutboundMediaFromUrl).not.toHaveBeenCalled(); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("does not host-read virtual /workspace escapes through sibling local roots", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("secret"), - kind: "document", - fileName: "secret.pdf", - contentType: "application/pdf", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendDocument( - { - ...makeCtx(), - mediaAccess: { - localRoots: ["/media"], - workspaceDir: "/tmp/workspace", - readFile: async () => Buffer.from("secret"), - }, - mediaLocalRoots: [], - }, - "/workspace/../media/secret.pdf", - ); - - expect(result).toMatchObject({ channel: "qqbot", error: expect.any(String) }); - expect(mockedLoadOutboundMediaFromUrl).not.toHaveBeenCalled(); - expect(mockedSenderSendMedia).not.toHaveBeenCalled(); - }); - - it("maps virtual /workspace host-read paths through the scoped workspace", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("report"), - kind: "document", - fileName: "report.docx", - contentType: "application/octet-stream", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendDocument( - { - ...makeCtx(), - mediaAccess: { - localRoots: ["/workspace/attachments"], - workspaceDir: "/tmp/agent-workspace", - readFile: async () => Buffer.from("report"), - }, - mediaLocalRoots: ["/workspace/attachments"], - }, - "/workspace/attachments/report.docx", - ); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "media-1" }); - expect(mockedLoadOutboundMediaFromUrl).not.toHaveBeenCalledWith( - "/workspace/attachments/report.docx", - expect.anything(), - ); - expect(mockedLoadOutboundMediaFromUrl).toHaveBeenCalledWith( - "/tmp/agent-workspace/attachments/report.docx", - expect.objectContaining({ - mediaAccess: expect.objectContaining({ - localRoots: ["/tmp/agent-workspace/attachments"], - workspaceDir: "/tmp/agent-workspace", - }), - workspaceDir: "/tmp/agent-workspace", - }), - ); - }); - - it("loads virtual-root workspace media through the real outbound loader", async () => { - const actualOutboundMedia = await vi.importActual< - typeof import("openclaw/plugin-sdk/outbound-media") - >("openclaw/plugin-sdk/outbound-media"); - mockedLoadOutboundMediaFromUrl.mockImplementation(actualOutboundMedia.loadOutboundMediaFromUrl); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - const workspaceDir = path.join(openclawHome, "agent-workspace"); - const reportPath = path.join(workspaceDir, "attachments", "report.txt"); - await fs.mkdir(path.dirname(reportPath), { recursive: true }); - await fs.writeFile(reportPath, "hello"); - const readFile = async (filePath: string) => await fs.readFile(filePath); - - const result = await sendDocument( - { - ...makeCtx(), - mediaAccess: { - localRoots: ["/workspace/attachments"], - workspaceDir, - readFile, - }, - mediaLocalRoots: ["/workspace/attachments"], - mediaReadFile: readFile, - }, - "/workspace/attachments/report.txt", - ); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "media-1" }); - expect(mockedSenderSendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: expect.objectContaining({ - buffer: Buffer.from("hello"), - fileName: "report.txt", - }), - }), - ); - }); - - it("auto-routes extensionless host-read images by loaded media kind", async () => { - audioPortMock.isAudioFile.mockReturnValue(false); - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("image bytes"), - kind: "image", - fileName: "chart", - contentType: "image/png", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "media-1", timestamp: 123 }); - - const result = await sendOutboundMedia({ - to: "qqbot:c2c:user-openid", - text: "", - mediaUrl: "chart", - accountId: "qq-main", - replyToId: "msg-1", - account: makeCtx().account, - mediaAccess: { - localRoots: ["/tmp/workspace"], - workspaceDir: "/tmp/workspace", - readFile: async () => Buffer.from("image bytes"), - }, - }); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "media-1" }); - expect(mockedLoadOutboundMediaFromUrl).toHaveBeenCalledWith( - "/tmp/workspace/chart", - expect.objectContaining({ - mediaAccess: expect.objectContaining({ workspaceDir: "/tmp/workspace" }), - }), - ); - expect(mockedSenderSendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "image", - source: expect.objectContaining({ - buffer: Buffer.from("image bytes"), - fileName: "chart", - }), - }), - ); - }); - - it("auto-routes extensionless host-read audio by loaded media kind", async () => { - audioPortMock.isAudioFile.mockReturnValue(false); - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("audio bytes"), - kind: "audio", - fileName: "clip", - contentType: "audio/mpeg", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "voice-1", timestamp: 123 }); - - const result = await sendOutboundMedia({ - to: "qqbot:c2c:user-openid", - text: "", - mediaUrl: "clip", - accountId: "qq-main", - replyToId: "msg-1", - account: makeCtx().account, - mediaAccess: { - localRoots: ["/tmp/workspace"], - workspaceDir: "/tmp/workspace", - readFile: async () => Buffer.from("audio bytes"), - }, - }); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "voice-1" }); - expect(mockedLoadOutboundMediaFromUrl).toHaveBeenCalledWith( - "/tmp/workspace/clip", - expect.objectContaining({ - mediaAccess: expect.objectContaining({ workspaceDir: "/tmp/workspace" }), - }), - ); - expect(mockedSenderSendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "voice", - source: { base64: Buffer.from("audio bytes").toString("base64") }, - localPathForMeta: expect.stringMatching(/clip-.*\.mp3$/), - }), - ); - }); - - it("stages host-read audio before using the voice upload path", async () => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("audio bytes"), - kind: "audio", - fileName: "clip.mp3", - contentType: "audio/mpeg", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "voice-1", timestamp: 123 }); - - const result = await sendVoice(makeCtx(), "clip.mp3", [".mp3"], true); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "voice-1" }); - expect(mockedLoadOutboundMediaFromUrl).toHaveBeenCalledWith( - "/tmp/workspace/clip.mp3", - expect.objectContaining({ - maxBytes: expect.any(Number), - mediaAccess: expect.objectContaining({ - localRoots: ["/tmp/openclaw-sandbox"], - workspaceDir: "/tmp/workspace", - }), - }), - ); - expect(audioPortMock.waitForFile).toHaveBeenCalledWith(expect.stringMatching(/clip-.*\.mp3$/)); - expect(mockedSenderSendMedia).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "voice", - source: { base64: Buffer.from("audio bytes").toString("base64") }, - localPathForMeta: expect.stringMatching(/clip-.*\.mp3$/), - }), - ); - }); - - it.each([ - ["single-encoded", "%2e%2e%2f".repeat(5) + "escape.mp3"], - ["double-encoded", "%252e%252e%252f".repeat(5) + "escape.mp3"], - ])("confines %s host-read voice filenames to the staging root", async (_label, fileName) => { - mockedLoadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("audio bytes"), - kind: "audio", - fileName, - contentType: "audio/mpeg", - }); - mockedSenderSendMedia.mockResolvedValue({ id: "voice-1", timestamp: 123 }); - - const result = await sendVoice(makeCtx(), "clip.mp3", [".mp3"], true); - - expect(result).toMatchObject({ channel: "qqbot", messageId: "voice-1" }); - const stagedPath = mockedSenderSendMedia.mock.calls[0]?.[0].localPathForMeta; - expect(stagedPath).toEqual(expect.any(String)); - const stagedDir = path.join(openclawHome, ".openclaw", "media", "qqbot", "host-read", "voice"); - const relativePath = path.relative(stagedDir, stagedPath as string); - expect(relativePath).not.toMatch(/^\.\.(?:[\\/]|$)/); - expect(path.isAbsolute(relativePath)).toBe(false); - await expect(fs.readFile(stagedPath as string)).resolves.toEqual(Buffer.from("audio bytes")); - await expect(fs.readdir(openclawHome)).resolves.not.toContain( - expect.stringMatching(/^escape-.*\.mp3$/), - ); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts deleted file mode 100644 index 1383c483e7e4..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts +++ /dev/null @@ -1,969 +0,0 @@ -/** - * Low-level outbound media sends (photo, voice, video, document) and path resolution. - */ - -import { randomUUID } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { extensionForMime, type MediaKind } from "openclaw/plugin-sdk/media-mime"; -import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; -import { - pathExistsSync, - resolveLocalPathFromRootsSync, - sanitizeUntrustedFileName, - writeExternalFileWithinRoot, -} from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { GatewayAccount } from "../types.js"; -import { MediaFileType } from "../types.js"; -import { - checkFileSize, - downloadFile, - fileExistsAsync, - formatFileSize, - getImageMimeType, - getMaxUploadSize, - readFileAsync, -} from "../utils/file-utils.js"; -import { debugError, debugLog, debugWarn } from "../utils/log.js"; -import { - getQQBotDataDir, - getQQBotMediaDir, - isLocalPath as isLocalFilePath, - normalizePath, -} from "../utils/platform.js"; -import { sanitizeFileName } from "../utils/string-normalize.js"; -import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js"; -import { - isPathWithinRoot, - mergeMediaLocalRoots, - resolveOutboundMediaLocalRoots, - resolveWorkspacePathCandidate, - resolveWorkspacePathCandidates, - resolveWorkspaceScopedLocalRoots, -} from "./outbound-media-path.js"; -import { - buildDailyLimitExceededResult, - buildFileTooLargeResult, -} from "./outbound-result-helpers.js"; -import type { - MediaTargetContext, - OutboundMediaAccessContext, - OutboundResult, -} from "./outbound-types.js"; -import { - accountToCreds, - sendMedia as senderSendMedia, - sendText as senderSendText, - UploadDailyLimitExceededError, - type DeliveryTarget, -} from "./sender.js"; -import { parseTarget as coreParseTarget } from "./target-parser.js"; -import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js"; - -/** Parse a qqbot target into a structured delivery target. */ -export function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } { - const timestamp = new Date().toISOString(); - debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`); - const parsed = coreParseTarget(to); - debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`); - return parsed; -} - -// Structured media send helpers shared by gateway delivery and sendText. - -/** Build a media target from a normal outbound context. */ -export function buildMediaTarget( - ctx: { - to: string; - account: GatewayAccount; - replyToId?: string | null; - } & OutboundMediaAccessContext, -): MediaTargetContext { - const target = parseTarget(ctx.to); - const mediaLocalRoots = resolveOutboundMediaLocalRoots(ctx); - return { - targetType: target.type, - targetId: target.id, - account: ctx.account, - replyToId: ctx.replyToId ?? undefined, - ...(mediaLocalRoots ? { mediaLocalRoots } : {}), - ...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}), - ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}), - }; -} - -/** Return true when public URLs should be passed through directly. */ -function shouldDirectUploadUrl(account: GatewayAccount): boolean { - return account.config?.urlDirectUpload !== false; -} - -type QQBotMediaKind = "image" | "voice" | "video" | "file" | "media"; -type LoadedOutboundMedia = Awaited>; - -const qqBotMediaKindLabel: Record = { - image: "Image", - voice: "Voice", - video: "Video", - file: "File", - media: "Media", -}; - -type ResolvedOutboundMediaPath = { ok: true; mediaPath: string } | { ok: false; error: string }; -type ResolveOutboundMediaPathOptions = { - allowMissingLocalPath?: boolean; - extraLocalRoots?: string[]; - workspaceDir?: string; -}; -type SendDocumentOptions = { - allowQQBotDataDownloads?: boolean; -}; - -function isHttpUrl(pathValue: string): boolean { - return pathValue.startsWith("http://") || pathValue.startsWith("https://"); -} - -function isDataUrl(pathValue: string): boolean { - return pathValue.startsWith("data:"); -} - -function isHttpOrDataSource(pathValue: string): boolean { - return isHttpUrl(pathValue) || isDataUrl(pathValue); -} - -function resolveMissingPathWithinRoots( - normalizedPath: string, - allowedRoots: readonly string[], -): string | null { - const resolvedCandidate = path.resolve(normalizedPath); - if (pathExistsSync(resolvedCandidate)) { - return null; - } - return ( - resolveLocalPathFromRootsSync({ - filePath: resolvedCandidate, - roots: allowedRoots, - label: "QQ Bot local roots", - allowMissing: true, - })?.path ?? null - ); -} - -function isPathWithinAnyRoot( - candidatePath: string, - allowedRoots: readonly string[] | undefined, -): boolean { - return ( - allowedRoots?.some((root) => root.trim() && isPathWithinRoot(candidatePath, root)) ?? false - ); -} - -function resolveExistingPathWithinRoots( - normalizedPath: string, - allowedRoots: readonly string[], -): string | null { - return ( - resolveLocalPathFromRootsSync({ - filePath: normalizedPath, - roots: allowedRoots, - label: "QQ Bot local roots", - })?.path ?? null - ); -} - -function resolveOutboundMediaReadFile(ctx: OutboundMediaAccessContext) { - return ctx.mediaAccess?.readFile ?? ctx.mediaReadFile; -} - -function resolveHostReadMediaAccess( - ctx: OutboundMediaAccessContext, -): OutboundMediaAccessContext["mediaAccess"] | undefined { - const mediaLocalRoots = resolveWorkspaceScopedLocalRoots( - resolveOutboundMediaLocalRoots(ctx), - ctx.mediaAccess?.workspaceDir, - ); - if (!ctx.mediaAccess && !mediaLocalRoots) { - return undefined; - } - const { localRoots: _localRoots, ...mediaAccessWithoutRoots } = ctx.mediaAccess ?? {}; - return { - ...mediaAccessWithoutRoots, - ...(mediaLocalRoots ? { localRoots: mediaLocalRoots } : {}), - }; -} - -function mediaFileTypeForKind(mediaKind: QQBotMediaKind): MediaFileType { - switch (mediaKind) { - case "image": - return MediaFileType.IMAGE; - case "voice": - return MediaFileType.VOICE; - case "video": - return MediaFileType.VIDEO; - default: - return MediaFileType.FILE; - } -} - -function senderKindForLoadedMedia( - mediaKind: QQBotMediaKind, - loadedKind: MediaKind | undefined, -): "image" | "video" | "file" | null { - if (mediaKind === "image") { - return loadedKind === "image" ? "image" : null; - } - if (mediaKind === "video") { - return loadedKind === "video" ? "video" : null; - } - if (mediaKind === "file") { - return "file"; - } - if (loadedKind === "image") { - return "image"; - } - if (loadedKind === "video") { - return "video"; - } - return "file"; -} - -function resolveHostReadMediaPath(ctx: MediaTargetContext, mediaPath: string): string | null { - const normalizedPath = normalizePath(mediaPath); - if (path.isAbsolute(normalizedPath)) { - const isVirtualWorkspacePath = - normalizedPath === "/workspace" || normalizedPath.startsWith("/workspace/"); - if (isVirtualWorkspacePath) { - return ctx.mediaAccess?.workspaceDir - ? resolveWorkspacePathCandidate(normalizedPath, ctx.mediaAccess.workspaceDir) - : null; - } - if (isPathWithinAnyRoot(normalizedPath, resolveOutboundMediaLocalRoots(ctx))) { - return normalizedPath; - } - return null; - } - if (!ctx.mediaAccess?.workspaceDir) { - return null; - } - return resolveWorkspacePathCandidate(normalizedPath, ctx.mediaAccess.workspaceDir); -} - -async function stageLoadedHostReadVoice( - mediaPath: string, - loaded: LoadedOutboundMedia, -): Promise { - const stagedDir = getQQBotMediaDir("host-read", "voice"); - // Decode QQ escapes once before applying portable basename policy. Decoding - // again after basename can recreate traversal separators at the write boundary. - const normalizedFileName = sanitizeFileName( - loaded.fileName || path.basename(mediaPath) || "voice", - ); - const safeFileName = sanitizeUntrustedFileName(normalizedFileName, "voice"); - const ext = path.extname(safeFileName); - const inferredExt = extensionForMime(loaded.contentType); - const baseName = path.basename(safeFileName, ext) || "voice"; - const staged = await writeExternalFileWithinRoot({ - rootDir: stagedDir, - path: `${baseName}-${randomUUID()}${ext || inferredExt || ".bin"}`, - write: async (tempPath) => await writeFile(tempPath, loaded.buffer), - }); - return staged.path; -} - -async function stageHostReadVoice( - ctx: MediaTargetContext, - mediaPath: string, -): Promise { - const mediaReadFile = resolveOutboundMediaReadFile(ctx); - if (!mediaReadFile || isHttpOrDataSource(mediaPath)) { - return null; - } - const hostReadMediaPath = resolveHostReadMediaPath(ctx, mediaPath); - if (!hostReadMediaPath) { - return null; - } - const mediaAccess = resolveHostReadMediaAccess(ctx); - const loaded = await loadOutboundMediaFromUrl(hostReadMediaPath, { - maxBytes: getMaxUploadSize(MediaFileType.VOICE), - mediaAccess, - mediaReadFile, - workspaceDir: mediaAccess?.workspaceDir, - }); - if (loaded.kind !== "audio") { - throw new Error(`Unsupported voice media type: ${loaded.kind ?? "unknown"}`); - } - return await stageLoadedHostReadVoice(mediaPath, loaded); -} - -async function trySendViaHostRead( - ctx: MediaTargetContext, - mediaPath: string, - mediaKind: QQBotMediaKind, -): Promise { - const mediaReadFile = resolveOutboundMediaReadFile(ctx); - if (!mediaReadFile || isHttpOrDataSource(mediaPath)) { - return null; - } - const hostReadMediaPath = resolveHostReadMediaPath(ctx, mediaPath); - if (!hostReadMediaPath) { - return null; - } - const mediaAccess = resolveHostReadMediaAccess(ctx); - try { - const loaded = await loadOutboundMediaFromUrl(hostReadMediaPath, { - maxBytes: getMaxUploadSize(mediaFileTypeForKind(mediaKind)), - mediaAccess, - mediaReadFile, - workspaceDir: mediaAccess?.workspaceDir, - }); - const kind = senderKindForLoadedMedia(mediaKind, loaded.kind); - if (!kind) { - return { - channel: "qqbot", - error: `Unsupported ${mediaKind} media type: ${loaded.kind ?? "unknown"}`, - }; - } - if (loaded.buffer.length === 0) { - return { channel: "qqbot", error: `File is empty: ${hostReadMediaPath}` }; - } - if (mediaKind === "media" && loaded.kind === "audio") { - const directUploadFormats = ctx.account.config?.audioFormatPolicy?.uploadDirectFormats; - const transcodeEnabled = ctx.account.config?.audioFormatPolicy?.transcodeEnabled !== false; - const stagedPath = await stageLoadedHostReadVoice(mediaPath, loaded); - return await sendVoiceFromLocal(ctx, stagedPath, directUploadFormats, transcodeEnabled); - } - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (target.type !== "c2c" && target.type !== "group") { - return { - channel: "qqbot", - error: `${qqBotMediaKindLabel[mediaKind]} not supported in channel`, - }; - } - const r = await senderSendMedia({ - target, - creds, - kind, - source: { - buffer: loaded.buffer, - ...(loaded.fileName ? { fileName: sanitizeFileName(loaded.fileName) } : {}), - ...(loaded.contentType ? { mime: loaded.contentType } : {}), - }, - msgId: ctx.replyToId, - ...(kind === "file" && loaded.fileName - ? { fileName: sanitizeFileName(loaded.fileName) } - : {}), - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } catch (err) { - if (err instanceof UploadDailyLimitExceededError) { - return buildDailyLimitExceededResult( - err.filePath === "" - ? new UploadDailyLimitExceededError(hostReadMediaPath, err.fileSize, err.message) - : err, - ); - } - return { - channel: "qqbot", - error: formatErrorMessage(err), - }; - } -} - -export async function sendAutoDetectedMedia( - ctx: MediaTargetContext, - mediaPath: string, -): Promise { - const hostReadResult = await trySendViaHostRead(ctx, mediaPath, "media"); - if (hostReadResult) { - return hostReadResult; - } - return await sendDocument(ctx, mediaPath); -} - -export function resolveOutboundMediaPath( - rawPath: string, - mediaKind: QQBotMediaKind, - options: ResolveOutboundMediaPathOptions = {}, -): ResolvedOutboundMediaPath { - const normalizedPath = normalizePath(rawPath); - if (isHttpOrDataSource(normalizedPath)) { - return { ok: true, mediaPath: normalizedPath }; - } - const candidatePaths = resolveWorkspacePathCandidates(normalizedPath, options.workspaceDir); - - for (const candidatePath of candidatePaths) { - const allowedPath = resolveTrustedOutboundMediaPath(candidatePath, { - allowMissing: options.allowMissingLocalPath, - }); - if (allowedPath) { - return { ok: true, mediaPath: allowedPath }; - } - - if (options.extraLocalRoots && options.extraLocalRoots.length > 0) { - const extraAllowedPath = resolveExistingPathWithinRoots( - candidatePath, - options.extraLocalRoots, - ); - if (extraAllowedPath) { - return { ok: true, mediaPath: extraAllowedPath }; - } - } - } - - if (options.allowMissingLocalPath) { - const missingRoots = mergeMediaLocalRoots([getQQBotMediaDir()], options.extraLocalRoots); - if (missingRoots) { - for (const candidatePath of candidatePaths) { - const allowedMissingPath = resolveMissingPathWithinRoots(candidatePath, missingRoots); - if (allowedMissingPath) { - return { ok: true, mediaPath: allowedMissingPath }; - } - } - } - } - - debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`); - return { - ok: false, - error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`, - }; -} - -/** - * Send a photo from a local file, public URL, or Base64 data URL. - */ -export async function sendPhoto( - ctx: MediaTargetContext, - imagePath: string, -): Promise { - const hostReadResult = await trySendViaHostRead(ctx, imagePath, "image"); - if (hostReadResult) { - return hostReadResult; - } - const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image", { - extraLocalRoots: resolveOutboundMediaLocalRoots(ctx), - workspaceDir: ctx.mediaAccess?.workspaceDir, - }); - if (!resolvedMediaPath.ok) { - return { channel: "qqbot", error: resolvedMediaPath.error }; - } - const mediaPath = resolvedMediaPath.mediaPath; - const isLocal = isLocalFilePath(mediaPath); - const isHttp = isHttpUrl(mediaPath); - const isData = isDataUrl(mediaPath); - - // Force a local download before upload when direct URL upload is disabled. - if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto"); - if (localFile) { - return await sendPhotoFromLocal(ctx, localFile); - } - return { - channel: "qqbot", - error: `Failed to download image: ${truncateUtf16Safe(mediaPath, 80)}`, - }; - } - - if (isLocal) { - return await sendPhotoFromLocal(ctx, mediaPath); - } - - if (!isHttp && !isData) { - return { - channel: "qqbot", - error: `Unsupported image source: ${truncateUtf16Safe(mediaPath, 50)}`, - }; - } - - // Remote URL or data: URL — try direct upload first, fall back to - // download-then-local on failure. - try { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "image", - source: { url: mediaPath }, - msgId: ctx.replyToId, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - - if (isHttp) { - const r = await senderSendText(target, `![](${mediaPath})`, creds, { - msgId: ctx.replyToId, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendPhoto: channel does not support local/Base64 images`); - return { channel: "qqbot", error: "Channel does not support local/Base64 images" }; - } catch (err) { - const msg = formatErrorMessage(err); - - // Fall back to plugin-managed download + local upload when QQ fails to - // fetch the URL directly. One-shot, non-recursive. - if (isHttp && !isData) { - debugWarn( - `sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, - ); - const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto"); - if (localFile) { - return await sendPhotoFromLocal(ctx, localFile); - } - } - - debugError(`sendPhoto failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** Send a photo from a validated local file path. */ -async function sendPhotoFromLocal( - ctx: MediaTargetContext, - mediaPath: string, -): Promise { - if (!(await fileExistsAsync(mediaPath))) { - return { channel: "qqbot", error: "Image not found" }; - } - const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.IMAGE)); - if (!sizeCheck.ok) { - return buildFileTooLargeResult(MediaFileType.IMAGE, sizeCheck.size); - } - const mimeType = getImageMimeType(mediaPath); - if (!mimeType) { - const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath)); - return { channel: "qqbot", error: `Unsupported image format: ${ext}` }; - } - debugLog(`sendPhoto: local (${formatFileSize(sizeCheck.size)})`); - - try { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "image", - source: { localPath: mediaPath }, - msgId: ctx.replyToId, - localPathForMeta: mediaPath, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendPhoto: channel does not support local images`); - return { channel: "qqbot", error: "Channel does not support local/Base64 images" }; - } catch (err) { - if (err instanceof UploadDailyLimitExceededError) { - debugError(`sendPhoto (local): daily upload quota exceeded`); - return buildDailyLimitExceededResult(err); - } - const msg = formatErrorMessage(err); - debugError(`sendPhoto (local) failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** - * Send voice from either a local file or a public URL. - * - * URL handling respects `urlDirectUpload`, and local files are transcoded when needed. - */ -export async function sendVoice( - ctx: MediaTargetContext, - voicePath: string, - directUploadFormats?: string[], - transcodeEnabled = true, -): Promise { - let stagedHostReadVoice: string | null; - try { - stagedHostReadVoice = await stageHostReadVoice(ctx, voicePath); - } catch (err) { - return { channel: "qqbot", error: formatErrorMessage(err) }; - } - const resolvedMediaPath = stagedHostReadVoice - ? { ok: true as const, mediaPath: stagedHostReadVoice } - : resolveOutboundMediaPath(voicePath, "voice", { - allowMissingLocalPath: true, - extraLocalRoots: resolveOutboundMediaLocalRoots(ctx), - workspaceDir: ctx.mediaAccess?.workspaceDir, - }); - if (!resolvedMediaPath.ok) { - return { channel: "qqbot", error: resolvedMediaPath.error }; - } - const mediaPath = resolvedMediaPath.mediaPath; - const isHttp = isHttpUrl(mediaPath); - - if (isHttp) { - if (shouldDirectUploadUrl(ctx.account)) { - try { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "voice", - source: { url: mediaPath }, - msgId: ctx.replyToId, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendVoice: voice not supported in channel`); - return { channel: "qqbot", error: "Voice not supported in channel" }; - } catch (err) { - const msg = formatErrorMessage(err); - debugWarn( - `sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`, - ); - } - } else { - debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`); - } - - const localFile = await downloadToFallbackDir(mediaPath, "sendVoice"); - if (localFile) { - return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled); - } - return { - channel: "qqbot", - error: `Failed to download audio: ${truncateUtf16Safe(mediaPath, 80)}`, - }; - } - - return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled); -} - -/** Send voice from a local file. */ -async function sendVoiceFromLocal( - ctx: MediaTargetContext, - mediaPath: string, - directUploadFormats: string[] | undefined, - transcodeEnabled: boolean, -): Promise { - // TTS can still be flushing the file to disk, so wait for a stable file first. - const fileSize = await waitForFile(mediaPath); - if (fileSize === 0) { - return { channel: "qqbot", error: "Voice generate failed" }; - } - if (fileSize > getMaxUploadSize(MediaFileType.VOICE)) { - return buildFileTooLargeResult(MediaFileType.VOICE, fileSize); - } - - // Re-check containment after the file appears to prevent symlink-race escapes. - const extraLocalRoots = resolveOutboundMediaLocalRoots(ctx); - const safeMediaPath = - resolveTrustedOutboundMediaPath(mediaPath) ?? - (extraLocalRoots ? resolveExistingPathWithinRoots(mediaPath, extraLocalRoots) : null); - if (!safeMediaPath) { - debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`); - return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" }; - } - - const needsTranscode = shouldTranscodeVoice(safeMediaPath); - - if (needsTranscode && !transcodeEnabled) { - const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath)); - debugLog( - `sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, - ); - return { - channel: "qqbot", - error: `Voice transcoding is disabled and format ${ext} cannot be uploaded directly`, - }; - } - - try { - const silkBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats); - let uploadBase64 = silkBase64; - - if (!uploadBase64) { - const buf = await readFileAsync(safeMediaPath); - uploadBase64 = buf.toString("base64"); - debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`); - } else { - debugLog(`sendVoice: SILK ready (${fileSize} bytes)`); - } - - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "voice", - source: { base64: uploadBase64 }, - msgId: ctx.replyToId, - localPathForMeta: safeMediaPath, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendVoice: voice not supported in channel`); - return { channel: "qqbot", error: "Voice not supported in channel" }; - } catch (err) { - if (err instanceof UploadDailyLimitExceededError) { - debugError(`sendVoice (local): daily upload quota exceeded`); - return buildDailyLimitExceededResult(err); - } - const msg = formatErrorMessage(err); - debugError(`sendVoice (local) failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** Send video from either a public URL or a local file. */ -export async function sendVideoMsg( - ctx: MediaTargetContext, - videoPath: string, -): Promise { - const hostReadResult = await trySendViaHostRead(ctx, videoPath, "video"); - if (hostReadResult) { - return hostReadResult; - } - const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video", { - extraLocalRoots: resolveOutboundMediaLocalRoots(ctx), - workspaceDir: ctx.mediaAccess?.workspaceDir, - }); - if (!resolvedMediaPath.ok) { - return { channel: "qqbot", error: resolvedMediaPath.error }; - } - const mediaPath = resolvedMediaPath.mediaPath; - const isHttp = isHttpUrl(mediaPath); - - if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); - if (localFile) { - return await sendVideoFromLocal(ctx, localFile); - } - return { - channel: "qqbot", - error: `Failed to download video: ${truncateUtf16Safe(mediaPath, 80)}`, - }; - } - - try { - if (isHttp) { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "video", - source: { url: mediaPath }, - msgId: ctx.replyToId, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendVideoMsg: video not supported in channel`); - return { channel: "qqbot", error: "Video not supported in channel" }; - } - - return await sendVideoFromLocal(ctx, mediaPath); - } catch (err) { - const msg = formatErrorMessage(err); - - if (isHttp) { - debugWarn( - `sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, - ); - const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); - if (localFile) { - return await sendVideoFromLocal(ctx, localFile); - } - } - - debugError(`sendVideoMsg failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** Send video from a local file. */ -async function sendVideoFromLocal( - ctx: MediaTargetContext, - mediaPath: string, -): Promise { - if (!(await fileExistsAsync(mediaPath))) { - return { channel: "qqbot", error: "Video not found" }; - } - const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.VIDEO)); - if (!sizeCheck.ok) { - return buildFileTooLargeResult(MediaFileType.VIDEO, sizeCheck.size); - } - debugLog(`sendVideoMsg: local video (${formatFileSize(sizeCheck.size)})`); - - try { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "video", - source: { localPath: mediaPath }, - msgId: ctx.replyToId, - localPathForMeta: mediaPath, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendVideoMsg: video not supported in channel`); - return { channel: "qqbot", error: "Video not supported in channel" }; - } catch (err) { - if (err instanceof UploadDailyLimitExceededError) { - debugError(`sendVideoMsg (local): daily upload quota exceeded`); - return buildDailyLimitExceededResult(err); - } - const msg = formatErrorMessage(err); - debugError(`sendVideoMsg (local) failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** Send a file from a local path or public URL. */ -export async function sendDocument( - ctx: MediaTargetContext, - filePath: string, - options: SendDocumentOptions = {}, -): Promise { - const hostReadResult = await trySendViaHostRead(ctx, filePath, "file"); - if (hostReadResult) { - return hostReadResult; - } - const extraLocalRoots = mergeMediaLocalRoots( - options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : undefined, - resolveOutboundMediaLocalRoots(ctx), - ); - const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", { - extraLocalRoots, - workspaceDir: ctx.mediaAccess?.workspaceDir, - }); - if (!resolvedMediaPath.ok) { - return { channel: "qqbot", error: resolvedMediaPath.error }; - } - const mediaPath = resolvedMediaPath.mediaPath; - const isHttp = isHttpUrl(mediaPath); - const fileName = sanitizeFileName(path.basename(mediaPath)); - - if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); - if (localFile) { - return await sendDocumentFromLocal(ctx, localFile); - } - return { - channel: "qqbot", - error: `Failed to download file: ${truncateUtf16Safe(mediaPath, 80)}`, - }; - } - - try { - if (isHttp) { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "file", - source: { url: mediaPath }, - msgId: ctx.replyToId, - ...(fileName ? { fileName } : {}), - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendDocument: file not supported in channel`); - return { channel: "qqbot", error: "File not supported in channel" }; - } - - return await sendDocumentFromLocal(ctx, mediaPath); - } catch (err) { - const msg = formatErrorMessage(err); - - if (isHttp) { - debugWarn( - `sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, - ); - const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); - if (localFile) { - return await sendDocumentFromLocal(ctx, localFile); - } - } - - debugError(`sendDocument failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** Send a file from local storage. */ -async function sendDocumentFromLocal( - ctx: MediaTargetContext, - mediaPath: string, -): Promise { - const fileName = sanitizeFileName(path.basename(mediaPath)); - - if (!(await fileExistsAsync(mediaPath))) { - return { channel: "qqbot", error: "File not found" }; - } - const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.FILE)); - if (!sizeCheck.ok) { - return buildFileTooLargeResult(MediaFileType.FILE, sizeCheck.size); - } - if (sizeCheck.size === 0) { - return { channel: "qqbot", error: `File is empty: ${mediaPath}` }; - } - debugLog(`sendDocument: local file (${formatFileSize(sizeCheck.size)})`); - - try { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (target.type === "c2c" || target.type === "group") { - const r = await senderSendMedia({ - target, - creds, - kind: "file", - source: { localPath: mediaPath }, - msgId: ctx.replyToId, - fileName, - localPathForMeta: mediaPath, - }); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } - debugLog(`sendDocument: file not supported in channel`); - return { channel: "qqbot", error: "File not supported in channel" }; - } catch (err) { - if (err instanceof UploadDailyLimitExceededError) { - debugError(`sendDocument (local): daily upload quota exceeded`); - return buildDailyLimitExceededResult(err); - } - const msg = formatErrorMessage(err); - debugError(`sendDocument (local) failed: ${msg}`); - return { channel: "qqbot", error: msg }; - } -} - -/** Download a remote file into the fallback media directory. */ -async function downloadToFallbackDir(httpUrl: string, caller: string): Promise { - try { - const downloadDir = getQQBotMediaDir("downloads", "url-fallback"); - const localFile = await downloadFile(httpUrl, downloadDir); - if (!localFile) { - debugError(`${caller} fallback: download also failed for ${truncateUtf16Safe(httpUrl, 80)}`); - return null; - } - debugLog(`${caller} fallback: downloaded → ${localFile}`); - return localFile; - } catch (err) { - debugError(`${caller} fallback download error:`, err); - return null; - } -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qqbot/src/engine/messaging/outbound-reply.ts b/extensions/qqbot/src/engine/messaging/outbound-reply.ts deleted file mode 100644 index 17bdb5a597a8..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-reply.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Qqbot plugin module implements outbound reply behavior. -import { debugLog } from "../utils/log.js"; -import { ReplyLimiter, type ReplyLimitResult } from "./reply-limiter.js"; - -const replyLimiter = new ReplyLimiter(); - -export type { ReplyLimitResult }; - -export const MESSAGE_REPLY_LIMIT = 5; - -export function checkMessageReplyLimit(messageId: string): ReplyLimitResult { - return replyLimiter.checkLimit(messageId); -} - -export function recordMessageReply(messageId: string): void { - replyLimiter.record(messageId); - debugLog( - `[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`, - ); -} - -/** Reserve one slot before a passive request so concurrent sends share one budget. */ -export function claimMessageReply(messageId: string, reserve = 0): ReplyLimitResult { - const result = replyLimiter.claim(messageId, reserve); - if (result.allowed) { - debugLog( - `[qqbot] claimMessageReply: ${messageId}, remaining=${result.remaining}/${MESSAGE_REPLY_LIMIT}`, - ); - } - return result; -} - -export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } { - return replyLimiter.getStats(); -} - -export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } { - return replyLimiter.getConfig(); -} diff --git a/extensions/qqbot/src/engine/messaging/outbound-result-helpers.ts b/extensions/qqbot/src/engine/messaging/outbound-result-helpers.ts deleted file mode 100644 index 4c1ff1b48bd0..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-result-helpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Qqbot helper module supports outbound result helpers behavior. -import path from "node:path"; -import { UPLOAD_PREPARE_FALLBACK_CODE } from "../api/retry.js"; -import { MediaFileType } from "../types.js"; -import { formatFileSize, getFileTypeName, getMaxUploadSize } from "../utils/file-utils.js"; -import { - DEFAULT_MEDIA_SEND_ERROR, - OUTBOUND_ERROR_CODES, - type OutboundResult, -} from "./outbound-types.js"; -import { UploadDailyLimitExceededError } from "./sender.js"; - -/** - * Convert a media send result into a user-facing message. - */ -export function resolveUserFacingMediaError( - result: Pick, -): string { - if (!result.error) { - return DEFAULT_MEDIA_SEND_ERROR; - } - if (result.qqBizCode === UPLOAD_PREPARE_FALLBACK_CODE) { - return result.error; - } - switch (result.errorCode) { - case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE: - case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED: - return result.error; - default: - return DEFAULT_MEDIA_SEND_ERROR; - } -} - -export function buildDailyLimitExceededResult(err: UploadDailyLimitExceededError): OutboundResult { - const dir = path.dirname(err.filePath); - const name = path.basename(err.filePath); - const size = formatFileSize(err.fileSize); - return { - channel: "qqbot", - error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`, - errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED, - qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE, - }; -} - -export function buildFileTooLargeResult(fileType: MediaFileType, fileSize: number): OutboundResult { - const typeName = getFileTypeName(fileType); - const limit = getMaxUploadSize(fileType); - const limitMB = Math.round(limit / (1024 * 1024)); - return { - channel: "qqbot", - error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。`, - errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE, - }; -} diff --git a/extensions/qqbot/src/engine/messaging/outbound-types.ts b/extensions/qqbot/src/engine/messaging/outbound-types.ts deleted file mode 100644 index 7909083ae63d..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound-types.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Qqbot plugin module implements outbound types behavior. -import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound"; -import type { GatewayAccount } from "../types.js"; - -export type OutboundMediaAccessContext = { - mediaAccess?: { - localRoots?: readonly string[]; - workspaceDir?: string; - readFile?: (filePath: string) => Promise; - }; - mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; -}; - -export interface OutboundContext extends OutboundMediaAccessContext { - to: string; - text: string; - accountId?: string | null; - replyToId?: string | null; - account: GatewayAccount; -} - -export interface MediaOutboundContext extends OutboundContext { - mediaUrl: string; - mimeType?: string; -} - -/** - * Stable error codes for outbound media send results. - */ -export const OUTBOUND_ERROR_CODES = { - FILE_TOO_LARGE: "file_too_large", - UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded", -} as const; - -export type OutboundErrorCode = (typeof OUTBOUND_ERROR_CODES)[keyof typeof OUTBOUND_ERROR_CODES]; - -export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。"; - -export interface OutboundResult { - channel: string; - messageId?: string; - receipt?: MessageReceipt; - timestamp?: string | number; - error?: string; - errorCode?: OutboundErrorCode; - qqBizCode?: number; - refIdx?: string; -} - -/** Normalized target information for media sends. */ -export interface MediaTargetContext extends OutboundMediaAccessContext { - targetType: "c2c" | "group" | "channel" | "dm"; - targetId: string; - account: GatewayAccount; - replyToId?: string; - logPrefix?: string; -} diff --git a/extensions/qqbot/src/engine/messaging/outbound.ts b/extensions/qqbot/src/engine/messaging/outbound.ts deleted file mode 100644 index 5d635405ecae..000000000000 --- a/extensions/qqbot/src/engine/messaging/outbound.ts +++ /dev/null @@ -1,430 +0,0 @@ -/** - * Outbound messaging — aggregates reply limits, audio port, media sends, and text orchestration. - */ - -export { setOutboundAudioPort } from "./outbound-audio-port.js"; -export type { - OutboundContext, - MediaOutboundContext, - OutboundResult, - OutboundErrorCode, - MediaTargetContext, -} from "./outbound-types.js"; -export { OUTBOUND_ERROR_CODES, DEFAULT_MEDIA_SEND_ERROR } from "./outbound-types.js"; - -export { - checkMessageReplyLimit, - recordMessageReply, - getMessageReplyStats, - getMessageReplyConfig, - MESSAGE_REPLY_LIMIT, -} from "./outbound-reply.js"; -export type { ReplyLimitResult } from "./outbound-reply.js"; - -export { resolveUserFacingMediaError } from "./outbound-result-helpers.js"; - -export { - buildMediaTarget, - parseTarget, - resolveOutboundMediaPath, - sendDocument, - sendPhoto, - sendVideoMsg, - sendVoice, -} from "./outbound-media-send.js"; - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { qqbotNotConfiguredMessage } from "../config/setup-guidance.js"; -import type { GatewayAccount } from "../types.js"; -import type { EngineLogger } from "../types.js"; -import { debugError, debugLog, debugWarn } from "../utils/log.js"; -import { normalizeMediaTags } from "../utils/media-tags.js"; -import { decodeCronPayload } from "../utils/payload.js"; -import { decodeMediaPath } from "./decode-media-path.js"; -import { - isImageFile as coreIsImageFile, - isVideoFile as coreIsVideoFile, -} from "./media-type-detect.js"; -import { isAudioFile } from "./outbound-audio-port.js"; -import { - buildMediaTarget, - parseTarget, - resolveOutboundMediaPath, - sendAutoDetectedMedia, - sendDocument, - sendPhoto, - sendVideoMsg, - sendVoice, -} from "./outbound-media-send.js"; -import type { - MediaOutboundContext, - MediaTargetContext, - OutboundContext, - OutboundResult, -} from "./outbound-types.js"; -import { - initApiConfig, - accountToCreds, - sendText as senderSendText, - type DeliveryTarget, -} from "./sender.js"; - -const isImageFile = coreIsImageFile; -const isVideoFile = coreIsVideoFile; - -const mediaPathDecodeLog = { - info: (message: string) => debugLog(`[qqbot] sendText: ${message}`), - error: (message: string) => debugError(`[qqbot] sendText: ${message}`), - debug: (message: string) => debugLog(`[qqbot] sendText: ${message}`), -} satisfies EngineLogger; - -/** - * Send text, optionally falling back from passive reply mode to proactive mode. - * - * Also supports inline media tags such as `...`. - */ -export async function sendText(ctx: OutboundContext): Promise { - const { to, account } = ctx; - const { replyToId } = ctx; - let { text } = ctx; - - initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); - - debugLog( - "[qqbot] sendText ctx:", - JSON.stringify( - { to, text: truncateUtf16Safe(text, 50), replyToId, accountId: account.accountId }, - null, - 2, - ), - ); - - text = normalizeMediaTags(text); - - const mediaTagRegex = - /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; - const mediaTagMatches = text.match(mediaTagRegex); - - if (!replyToId && (!text || text.trim().length === 0)) { - debugError("[qqbot] sendText error: proactive message content cannot be empty"); - return { - channel: "qqbot", - error: "Proactive messages require non-empty content (--message cannot be empty)", - }; - } - - if (!account.appId || !account.clientSecret) { - return { channel: "qqbot", error: qqbotNotConfiguredMessage(account.accountId) }; - } - - if (mediaTagMatches && mediaTagMatches.length > 0) { - debugLog(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`); - - const sendQueue: Array<{ - type: "text" | "image" | "voice" | "video" | "file" | "media"; - content: string; - }> = []; - - let lastIndex = 0; - const mediaTagRegexWithIndex = - /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; - let match; - - while ((match = mediaTagRegexWithIndex.exec(text)) !== null) { - const textBefore = text - .slice(lastIndex, match.index) - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (textBefore) { - sendQueue.push({ type: "text", content: textBefore }); - } - - const tagName = normalizeLowercaseStringOrEmpty(match[1]); - - const mediaPath = decodeMediaPath( - normalizeOptionalString(match[2]) ?? "", - mediaPathDecodeLog, - ); - - if (mediaPath) { - if (tagName === "qqmedia") { - sendQueue.push({ type: "media", content: mediaPath }); - debugLog(`[qqbot] sendText: Found auto-detect media in : ${mediaPath}`); - } else if (tagName === "qqvoice") { - sendQueue.push({ type: "voice", content: mediaPath }); - debugLog(`[qqbot] sendText: Found voice path in : ${mediaPath}`); - } else if (tagName === "qqvideo") { - sendQueue.push({ type: "video", content: mediaPath }); - debugLog(`[qqbot] sendText: Found video URL in : ${mediaPath}`); - } else if (tagName === "qqfile") { - sendQueue.push({ type: "file", content: mediaPath }); - debugLog(`[qqbot] sendText: Found file path in : ${mediaPath}`); - } else { - sendQueue.push({ type: "image", content: mediaPath }); - debugLog(`[qqbot] sendText: Found image path in : ${mediaPath}`); - } - } - - lastIndex = match.index + match[0].length; - } - - const textAfter = text - .slice(lastIndex) - .replace(/\n{3,}/g, "\n\n") - .trim(); - if (textAfter) { - sendQueue.push({ type: "text", content: textAfter }); - } - - debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); - - const mediaTarget = buildMediaTarget({ - to, - account, - replyToId, - mediaAccess: ctx.mediaAccess, - mediaLocalRoots: ctx.mediaLocalRoots, - mediaReadFile: ctx.mediaReadFile, - }); - let lastResult: OutboundResult = { channel: "qqbot" }; - - for (const item of sendQueue) { - try { - if (item.type === "text") { - const target = parseTarget(to); - const creds = accountToCreds(account); - const deliveryTarget: DeliveryTarget = { - type: target.type === "channel" ? "channel" : target.type, - id: target.id, - }; - const result = await senderSendText(deliveryTarget, item.content, creds, { - msgId: replyToId ?? undefined, - }); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - debugLog(`[qqbot] sendText: Sent text part: ${truncateUtf16Safe(item.content, 30)}...`); - } else if (item.type === "image") { - lastResult = await sendPhoto(mediaTarget, item.content); - } else if (item.type === "voice") { - lastResult = await sendVoice( - mediaTarget, - item.content, - undefined, - account.config?.audioFormatPolicy?.transcodeEnabled !== false, - ); - } else if (item.type === "video") { - lastResult = await sendVideoMsg(mediaTarget, item.content); - } else if (item.type === "file") { - lastResult = await sendDocument(mediaTarget, item.content); - } else if (item.type === "media") { - lastResult = await sendMedia({ - to, - text: "", - mediaUrl: item.content, - accountId: account.accountId, - replyToId, - account, - mediaAccess: ctx.mediaAccess, - mediaLocalRoots: ctx.mediaLocalRoots, - mediaReadFile: ctx.mediaReadFile, - }); - } - } catch (err) { - const errMsg = formatErrorMessage(err); - debugError(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`); - lastResult = { channel: "qqbot", error: errMsg }; - } - } - - return lastResult; - } - - if (!replyToId) { - debugLog(`[qqbot] sendText: sending proactive message to ${to}, length=${text.length}`); - } - - try { - const target = parseTarget(to); - const creds = accountToCreds(account); - const deliveryTarget: DeliveryTarget = { - type: target.type === "channel" ? "channel" : target.type, - id: target.id, - }; - debugLog("[qqbot] sendText target:", JSON.stringify(target)); - - const result = await senderSendText(deliveryTarget, text, creds, { - msgId: replyToId ?? undefined, - }); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } catch (err) { - const message = formatErrorMessage(err); - return { channel: "qqbot", error: message }; - } -} - -/** Send rich media, auto-routing by media type and source. */ -export async function sendMedia(ctx: MediaOutboundContext): Promise { - const { to, text, replyToId, account, mimeType } = ctx; - - initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); - - if (!account.appId || !account.clientSecret) { - return { channel: "qqbot", error: qqbotNotConfiguredMessage(account.accountId) }; - } - if (!ctx.mediaUrl) { - return { channel: "qqbot", error: "mediaUrl is required for sendMedia" }; - } - - const target = buildMediaTarget({ - to, - account, - replyToId, - mediaAccess: ctx.mediaAccess, - mediaLocalRoots: ctx.mediaLocalRoots, - mediaReadFile: ctx.mediaReadFile, - }); - const shouldResolveLocalMediaPath = !ctx.mediaAccess?.readFile && !ctx.mediaReadFile; - const resolvedMediaPath = shouldResolveLocalMediaPath - ? resolveOutboundMediaPath(ctx.mediaUrl, "media", { - allowMissingLocalPath: true, - extraLocalRoots: target.mediaLocalRoots ? [...target.mediaLocalRoots] : undefined, - workspaceDir: target.mediaAccess?.workspaceDir, - }) - : { ok: true as const, mediaPath: ctx.mediaUrl }; - if (!resolvedMediaPath.ok) { - return { channel: "qqbot", error: resolvedMediaPath.error }; - } - const mediaUrl = resolvedMediaPath.mediaPath; - - if (isAudioFile(mediaUrl, mimeType)) { - const formats = account.config?.audioFormatPolicy?.uploadDirectFormats; - const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; - const result = await sendVoice(target, mediaUrl, formats, transcodeEnabled); - if (!result.error) { - if (text?.trim()) { - await sendTextAfterMedia(target, text); - } - return result; - } - const voiceError = result.error; - debugWarn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`); - const fallback = await sendDocument(target, mediaUrl); - if (!fallback.error) { - if (text?.trim()) { - await sendTextAfterMedia(target, text); - } - return fallback; - } - return { channel: "qqbot", error: `voice: ${voiceError} | fallback file: ${fallback.error}` }; - } - - if (isVideoFile(mediaUrl, mimeType)) { - const result = await sendVideoMsg(target, mediaUrl); - if (!result.error && text?.trim()) { - await sendTextAfterMedia(target, text); - } - return result; - } - - if ( - !isImageFile(mediaUrl, mimeType) && - !isAudioFile(mediaUrl, mimeType) && - !isVideoFile(mediaUrl, mimeType) - ) { - const result = await sendAutoDetectedMedia(target, mediaUrl); - if (!result.error && text?.trim()) { - await sendTextAfterMedia(target, text); - } - return result; - } - - const result = await sendPhoto(target, mediaUrl); - if (!result.error && text?.trim()) { - await sendTextAfterMedia(target, text); - } - return result; -} - -async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): Promise { - try { - const creds = accountToCreds(ctx.account); - const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - await senderSendText(target, text, creds, { msgId: ctx.replyToId }); - } catch (err) { - debugError(`[qqbot] sendTextAfterMedia failed: ${formatErrorMessage(err)}`); - } -} - -export async function sendProactiveMessage( - account: GatewayAccount, - to: string, - content: string, -): Promise { - return sendText({ account, to, text: content }); -} - -export async function sendCronMessage( - account: GatewayAccount, - to: string, - message: string, -): Promise { - const timestamp = new Date().toISOString(); - debugLog(`[${timestamp}] [qqbot] sendCronMessage: to=${to}, message length=${message.length}`); - - const cronResult = decodeCronPayload(message); - - if (cronResult.isCronPayload) { - if (cronResult.error) { - debugError( - `[${timestamp}] [qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`, - ); - return { - channel: "qqbot", - error: `Failed to decode cron payload: ${cronResult.error}`, - }; - } - - if (cronResult.payload) { - const payload = cronResult.payload; - debugLog( - `[${timestamp}] [qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}, content length=${payload.content.length}`, - ); - - const targetTo = - payload.targetType === "group" ? `group:${payload.targetAddress}` : payload.targetAddress; - - debugLog( - `[${timestamp}] [qqbot] sendCronMessage: sending proactive message to targetTo=${targetTo}`, - ); - - const result = await sendText({ account, to: targetTo, text: payload.content }); - - if (result.error) { - debugError( - `[${timestamp}] [qqbot] sendCronMessage: proactive message failed, error=${result.error}`, - ); - } else { - debugLog(`[${timestamp}] [qqbot] sendCronMessage: proactive message sent successfully`); - } - - return result; - } - } - - debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`); - return await sendText({ account, to, text: message }); -} diff --git a/extensions/qqbot/src/engine/messaging/race-with-timeout.test.ts b/extensions/qqbot/src/engine/messaging/race-with-timeout.test.ts deleted file mode 100644 index 78b0f15b8322..000000000000 --- a/extensions/qqbot/src/engine/messaging/race-with-timeout.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { raceWithTimeout } from "./race-with-timeout.js"; - -interface VoiceSendResult { - channel: string; - error?: string; - messageId?: string; -} - -describe("raceWithTimeout", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("clears the voice-send timeout after delivery resolves", async () => { - vi.useFakeTimers(); - const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); - - await expect( - raceWithTimeout( - async () => ({ channel: "qqbot", messageId: "voice-1" }), - 45_000, - () => ({ channel: "qqbot", error: "Voice send timed out and was skipped" }), - ), - ).resolves.toEqual({ channel: "qqbot", messageId: "voice-1" }); - - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); - - it("clears the voice-send timeout after delivery rejects", async () => { - vi.useFakeTimers(); - const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); - const failure = new Error("voice send failed"); - - await expect( - raceWithTimeout( - async () => { - throw failure; - }, - 45_000, - () => ({ channel: "qqbot", error: "Voice send timed out and was skipped" }), - ), - ).rejects.toBe(failure); - - expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); - expect(vi.getTimerCount()).toBe(0); - }); - - it("marks late delivery settlement after the timeout wins", async () => { - vi.useFakeTimers(); - let resolveDelivery: (result: VoiceSendResult) => void = () => {}; - const delivery = new Promise((resolve) => { - resolveDelivery = resolve; - }); - let lateResult: Promise | undefined; - - const result = raceWithTimeout( - (state) => { - lateResult = delivery.then((value) => - state.timedOut - ? { channel: "qqbot", error: "Voice send completed after timeout (suppressed)" } - : value, - ); - return lateResult; - }, - 45_000, - () => ({ channel: "qqbot", error: "Voice send timed out and was skipped" }), - ); - - await vi.advanceTimersByTimeAsync(45_000); - await expect(result).resolves.toEqual({ - channel: "qqbot", - error: "Voice send timed out and was skipped", - }); - - resolveDelivery({ channel: "qqbot", messageId: "voice-late" }); - await expect(lateResult).resolves.toEqual({ - channel: "qqbot", - error: "Voice send completed after timeout (suppressed)", - }); - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/race-with-timeout.ts b/extensions/qqbot/src/engine/messaging/race-with-timeout.ts deleted file mode 100644 index 1a833d492689..000000000000 --- a/extensions/qqbot/src/engine/messaging/race-with-timeout.ts +++ /dev/null @@ -1,34 +0,0 @@ -interface TimeoutRaceState { - readonly timedOut: boolean; -} - -export async function raceWithTimeout( - operation: (state: TimeoutRaceState) => Promise, - timeoutMs: number, - onTimeout: () => T, -): Promise { - let timedOut = false; - let timeout: ReturnType | undefined; - const state: TimeoutRaceState = { - get timedOut() { - return timedOut; - }, - }; - - try { - return await Promise.race([ - operation(state), - new Promise((resolve) => { - timeout = setTimeout(() => { - timedOut = true; - resolve(onTimeout()); - }, timeoutMs); - }), - ]); - } finally { - // Successful sends must release the guard timer or Node stays alive until it fires. - if (timeout !== undefined) { - clearTimeout(timeout); - } - } -} diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.test.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.test.ts deleted file mode 100644 index 164229ff494a..000000000000 --- a/extensions/qqbot/src/engine/messaging/reply-dispatcher.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { openLocalFileMock, resolveLocalPathFromRootsSyncMock, sendMediaMock, sendTextMock } = - vi.hoisted(() => ({ - openLocalFileMock: vi.fn(), - resolveLocalPathFromRootsSyncMock: vi.fn(), - sendMediaMock: vi.fn(), - sendTextMock: vi.fn(), - })); - -vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ - resolveLocalPathFromRootsSync: resolveLocalPathFromRootsSyncMock, -})); - -vi.mock("./media-source.js", () => ({ - openLocalFile: openLocalFileMock, -})); - -vi.mock("./sender.js", () => ({ - accountToCreds: (account: { appId: string; clientSecret: string }) => ({ - appId: account.appId, - clientSecret: account.clientSecret, - }), - buildDeliveryTarget: (target: { type: string; senderId: string; groupOpenid?: string }) => ({ - type: target.type === "group" ? "group" : target.type === "c2c" ? "c2c" : target.type, - id: target.type === "group" ? target.groupOpenid : target.senderId, - }), - sendMedia: sendMediaMock, - sendText: sendTextMock, - withTokenRetry: async (_creds: unknown, fn: () => Promise) => await fn(), -})); - -vi.mock("./trusted-media-path.js", () => ({ - resolveTrustedOutboundMediaPath: vi.fn(() => null), -})); - -import { handleStructuredPayload } from "./reply-dispatcher.js"; - -function makeReplyContext() { - return { - target: { - type: "c2c" as const, - senderId: "user-openid", - messageId: "msg-1", - }, - account: { - accountId: "qq-main", - appId: "app-x", - clientSecret: "secret-x", - markdownSupport: false, - config: {}, - }, - cfg: {}, - mediaAccess: { - localRoots: ["/workspace/attachments"], - workspaceDir: "/tmp/agent-workspace", - }, - mediaLocalRoots: ["/workspace/attachments"], - log: { - info: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, - }; -} - -describe("handleStructuredPayload", () => { - beforeEach(() => { - vi.clearAllMocks(); - openLocalFileMock.mockResolvedValue({ - size: 12, - handle: { readFile: vi.fn() }, - close: vi.fn(), - }); - sendMediaMock.mockResolvedValue({ id: "media-1", timestamp: 123 }); - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/attachments/report.pdf" - ? { path: "/tmp/agent-workspace/attachments/report.pdf" } - : null, - ); - }); - - it("maps virtual /workspace payload paths through the scoped workspace", async () => { - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/attachments/report.pdf" - ? { path: "/tmp/agent-workspace/attachments/report.pdf" } - : null, - ); - - const handled = await handleStructuredPayload( - makeReplyContext(), - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: "/workspace/attachments/report.pdf", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(resolveLocalPathFromRootsSyncMock).not.toHaveBeenCalledWith( - expect.objectContaining({ filePath: "/workspace/attachments/report.pdf" }), - ); - expect(resolveLocalPathFromRootsSyncMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: "/tmp/agent-workspace/attachments/report.pdf", - roots: ["/tmp/agent-workspace/attachments"], - }), - ); - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { localPath: "/tmp/agent-workspace/attachments/report.pdf" }, - }), - ); - }); - - it("resolves relative payload paths only against the virtual workspace", async () => { - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/report.pdf" - ? { path: "/tmp/agent-workspace/report.pdf" } - : null, - ); - - const handled = await handleStructuredPayload( - makeReplyContext(), - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: "report.pdf", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(resolveLocalPathFromRootsSyncMock).not.toHaveBeenCalledWith( - expect.objectContaining({ filePath: "report.pdf" }), - ); - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { localPath: "/tmp/agent-workspace/report.pdf" }, - }), - ); - }); - - it("loads structured file payloads through host-read callbacks", async () => { - const mediaReadFile = vi.fn(async () => Buffer.from("host report")); - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/report.pdf" - ? { path: "/tmp/agent-workspace/report.pdf" } - : null, - ); - openLocalFileMock.mockRejectedValue(new Error("host filesystem unavailable")); - - const handled = await handleStructuredPayload( - { - ...makeReplyContext(), - mediaAccess: { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - readFile: mediaReadFile, - }, - mediaLocalRoots: [], - }, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: "report.pdf", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(mediaReadFile).toHaveBeenCalledWith("/tmp/agent-workspace/report.pdf"); - expect(openLocalFileMock).not.toHaveBeenCalled(); - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { - buffer: Buffer.from("host report"), - fileName: "report.pdf", - }, - }), - ); - }); - - it("allows structured file payloads that only exist behind host-read callbacks", async () => { - const mediaReadFile = vi.fn(async () => Buffer.from("host report")); - resolveLocalPathFromRootsSyncMock.mockImplementation( - ({ filePath, allowMissing }: { filePath: string; allowMissing?: boolean }) => - filePath === "/tmp/agent-workspace/report.pdf" && allowMissing === true - ? { path: "/tmp/agent-workspace/report.pdf" } - : null, - ); - openLocalFileMock.mockRejectedValue(new Error("host filesystem unavailable")); - - const handled = await handleStructuredPayload( - { - ...makeReplyContext(), - mediaAccess: { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - readFile: mediaReadFile, - }, - mediaLocalRoots: [], - }, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: "report.pdf", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(resolveLocalPathFromRootsSyncMock).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: "/tmp/agent-workspace/report.pdf", - allowMissing: true, - }), - ); - expect(mediaReadFile).toHaveBeenCalledWith("/tmp/agent-workspace/report.pdf"); - expect(openLocalFileMock).not.toHaveBeenCalled(); - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { - buffer: Buffer.from("host report"), - fileName: "report.pdf", - }, - }), - ); - }); - - it("falls back to local structured file sends when host-read callbacks cannot read them", async () => { - const mediaReadFile = vi.fn(async () => { - throw new Error("host read unavailable"); - }); - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/report.pdf" - ? { path: "/tmp/agent-workspace/report.pdf" } - : null, - ); - - const handled = await handleStructuredPayload( - { - ...makeReplyContext(), - mediaAccess: { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - readFile: mediaReadFile, - }, - mediaLocalRoots: [], - }, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: "report.pdf", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(mediaReadFile).toHaveBeenCalledWith("/tmp/agent-workspace/report.pdf"); - expect(openLocalFileMock).toHaveBeenCalledWith( - "/tmp/agent-workspace/report.pdf", - expect.objectContaining({ maxSize: expect.any(Number) }), - ); - expect(sendMediaMock).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "file", - source: { localPath: "/tmp/agent-workspace/report.pdf" }, - }), - ); - }); - - it("does not leak local image paths when falling back to DM markdown", async () => { - const pngBuffer = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00, - ]); - const mediaReadFile = vi.fn(async () => pngBuffer); - const ctx = { - ...makeReplyContext(), - target: { - type: "dm" as const, - senderId: "user-openid", - guildId: "guild-1", - messageId: "msg-1", - }, - mediaAccess: { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - readFile: mediaReadFile, - }, - mediaLocalRoots: [], - }; - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/chart.png" - ? { path: "/tmp/agent-workspace/chart.png" } - : null, - ); - - const handled = await handleStructuredPayload( - ctx, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "image", - source: "file", - path: "chart.png", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - const markdown = String(sendTextMock.mock.calls[0]?.[1]); - expect(markdown).toContain("data:image/png;base64,"); - expect(markdown).not.toContain("/tmp/agent-workspace/chart.png"); - expect(markdown).not.toContain("chart.png"); - expect(sendMediaMock).not.toHaveBeenCalled(); - }); - - it("rejects structured image host-read buffers that are not images", async () => { - const mediaReadFile = vi.fn(async () => Buffer.from("%PDF-1.7\n")); - const ctx = { - ...makeReplyContext(), - mediaAccess: { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - readFile: mediaReadFile, - }, - mediaLocalRoots: [], - }; - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/fake.png" - ? { path: "/tmp/agent-workspace/fake.png" } - : null, - ); - - const handled = await handleStructuredPayload( - ctx, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "image", - source: "file", - path: "fake.png", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(mediaReadFile).toHaveBeenCalledWith("/tmp/agent-workspace/fake.png"); - expect(sendMediaMock).not.toHaveBeenCalled(); - expect(ctx.log.error).toHaveBeenCalledWith(expect.stringContaining("not an image")); - }); - - it("rejects empty structured image buffers from host-read callbacks", async () => { - const mediaReadFile = vi.fn(async () => Buffer.alloc(0)); - const ctx = { - ...makeReplyContext(), - mediaAccess: { - localRoots: ["/tmp/agent-workspace"], - workspaceDir: "/tmp/agent-workspace", - readFile: mediaReadFile, - }, - mediaLocalRoots: [], - }; - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/agent-workspace/empty.png" - ? { path: "/tmp/agent-workspace/empty.png" } - : null, - ); - - const handled = await handleStructuredPayload( - ctx, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "image", - source: "file", - path: "empty.png", - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(mediaReadFile).toHaveBeenCalledWith("/tmp/agent-workspace/empty.png"); - expect(sendMediaMock).not.toHaveBeenCalled(); - expect(ctx.log.error).toHaveBeenCalledWith(expect.stringContaining("File is empty")); - }); - - it.each(["/workspace/../media/secret.pdf", "../media/secret.pdf"])( - "rejects virtual workspace payload escapes before checking sibling media roots: %s", - async (payloadPath) => { - const ctx = { - ...makeReplyContext(), - mediaAccess: { - localRoots: ["/tmp/media"], - workspaceDir: "/tmp/agent-workspace", - }, - mediaLocalRoots: ["/tmp/media"], - }; - resolveLocalPathFromRootsSyncMock.mockImplementation(({ filePath }: { filePath: string }) => - filePath === "/tmp/media/secret.pdf" ? { path: "/tmp/media/secret.pdf" } : null, - ); - - const handled = await handleStructuredPayload( - ctx, - `QQBOT_PAYLOAD:${JSON.stringify({ - type: "media", - mediaType: "file", - source: "file", - path: payloadPath, - })}`, - vi.fn(), - ); - - expect(handled).toBe(true); - expect(resolveLocalPathFromRootsSyncMock).not.toHaveBeenCalledWith( - expect.objectContaining({ filePath: "/tmp/media/secret.pdf" }), - ); - expect(sendMediaMock).not.toHaveBeenCalled(); - expect(ctx.log.error).toHaveBeenCalledWith( - "Blocked file payload local path outside QQ Bot media storage", - ); - }, - ); -}); diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts deleted file mode 100644 index cd9b461d87fa..000000000000 --- a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts +++ /dev/null @@ -1,715 +0,0 @@ -/** - * Reply dispatcher — structured payload handling and text routing. - * - * Uses the unified `sender.ts` business function layer for all message - * sending. TTS is injected via `ReplyDispatcherDeps`. - */ - -import crypto from "node:crypto"; -import path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { MediaFileType, type GatewayAccount } from "../types.js"; -import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js"; -import { - parseQQBotPayload, - encodePayloadForCron, - isCronReminderPayload, - isMediaPayload, - type MediaPayload, -} from "../utils/payload.js"; -import { normalizePath } from "../utils/platform.js"; -import { sanitizeFileName } from "../utils/string-normalize.js"; -import { openLocalFile } from "./media-source.js"; -import { - resolveOutboundMediaLocalRoots, - resolveWorkspacePathCandidates, - resolveWorkspaceScopedLocalRoots, -} from "./outbound-media-path.js"; -import type { OutboundMediaAccessContext } from "./outbound-types.js"; -import { - sendText as senderSendText, - sendMedia as senderSendMedia, - withTokenRetry, - buildDeliveryTarget, - accountToCreds, -} from "./sender.js"; -import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js"; - -// ---- Injected dependencies ---- - -/** TTS provider interface — injected from the outer layer. */ -interface TTSProvider { - /** Framework TTS: text → audio file path. */ - textToSpeech(params: { - text: string; - cfg: unknown; - channel: string; - accountId?: string; - }): Promise<{ - success: boolean; - audioPath?: string; - provider?: string; - outputFormat?: string; - error?: string; - }>; - /** Convert any audio file to SILK base64. */ - audioFileToSilkBase64(audioPath: string): Promise; -} - -/** Dependencies injected into reply-dispatcher functions. */ -export interface ReplyDispatcherDeps { - tts: TTSProvider; -} - -// ---- Exported types ---- - -interface MessageTarget { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - messageId: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; -} - -interface ReplyContext extends OutboundMediaAccessContext { - target: MessageTarget; - account: GatewayAccount; - cfg: unknown; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -// ---- Token retry (delegated to sender.ts) ---- - -/** Send a message and retry once if the token appears to have expired. */ -export async function sendWithTokenRetry( - appId: string, - clientSecret: string, - sendFn: (token: string) => Promise, - log?: ReplyContext["log"], - accountId?: string, -): Promise { - return withTokenRetry({ appId, clientSecret }, sendFn, log, accountId); -} - -// ---- Text routing ---- - -/** Route a text message to the correct QQ target type. */ -async function sendTextToTarget(ctx: ReplyContext, text: string, refIdx?: string): Promise { - const { target, account } = ctx; - const deliveryTarget = buildDeliveryTarget(target); - const creds = accountToCreds(account); - await withTokenRetry( - creds, - async () => { - await senderSendText(deliveryTarget, text, creds, { - msgId: target.messageId, - messageReference: refIdx, - }); - }, - ctx.log, - account.accountId, - ); -} - -/** Best-effort delivery for error text back to the user. */ -export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise { - try { - await sendTextToTarget(ctx, errorText); - } catch (sendErr) { - ctx.log?.error(`Failed to send error message: ${String(sendErr)}`); - } -} - -// ---- Structured payload handling ---- - -/** - * Handle a structured payload prefixed with `QQBOT_PAYLOAD:`. - * Returns true when the reply was handled here, otherwise false. - */ -export async function handleStructuredPayload( - ctx: ReplyContext, - replyText: string, - recordActivity: () => void, - deps?: ReplyDispatcherDeps, -): Promise { - const { account: _account, log } = ctx; - const payloadResult = parseQQBotPayload(replyText); - - if (!payloadResult.isPayload) { - return false; - } - - if (payloadResult.error) { - log?.error(`Payload parse error: ${payloadResult.error}`); - return true; - } - - if (!payloadResult.payload) { - return true; - } - - const parsedPayload = payloadResult.payload; - const unknownPayload = payloadResult.payload as unknown; - log?.info(`Detected structured payload, type: ${parsedPayload.type}`); - - if (isCronReminderPayload(parsedPayload)) { - log?.debug?.(`Processing cron_reminder payload`); - const cronMessage = encodePayloadForCron(parsedPayload); - const confirmText = `⏰ Reminder scheduled. It will be sent at the configured time: "${parsedPayload.content}"`; - try { - await sendTextToTarget(ctx, confirmText); - log?.debug?.(`Cron reminder confirmation sent, cronMessage: ${cronMessage}`); - } catch (err) { - log?.error(`Failed to send cron confirmation: ${formatErrorMessage(err)}`); - } - recordActivity(); - return true; - } - - if (isMediaPayload(parsedPayload)) { - log?.debug?.(`Processing media payload, mediaType: ${parsedPayload.mediaType}`); - - if (parsedPayload.mediaType === "image") { - await handleImagePayload(ctx, parsedPayload); - } else if (parsedPayload.mediaType === "audio") { - await handleAudioPayload(ctx, parsedPayload, deps); - } else if (parsedPayload.mediaType === "video") { - await handleVideoPayload(ctx, parsedPayload); - } else if (parsedPayload.mediaType === "file") { - await handleFilePayload(ctx, parsedPayload); - } else { - log?.error(`Unknown media type: ${JSON.stringify(parsedPayload.mediaType)}`); - } - recordActivity(); - return true; - } - - const payloadType = - typeof unknownPayload === "object" && - unknownPayload !== null && - "type" in unknownPayload && - typeof unknownPayload.type === "string" - ? unknownPayload.type - : "unknown"; - log?.error(`Unknown payload type: ${payloadType}`); - return true; -} - -// ---- Media payload handlers ---- - -type StructuredPayloadMediaType = "image" | "video" | "file"; - -function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string { - return mediaType.charAt(0).toUpperCase() + mediaType.slice(1); -} - -function validateStructuredPayloadLocalPath( - ctx: ReplyContext, - payloadPath: string, - mediaType: StructuredPayloadMediaType, -): string | null { - const candidatePaths = resolveWorkspacePathCandidates( - normalizePath(payloadPath), - ctx.mediaAccess?.workspaceDir, - ); - const localRoots = resolveWorkspaceScopedLocalRoots( - resolveOutboundMediaLocalRoots(ctx), - ctx.mediaAccess?.workspaceDir, - ); - const allowMissingHostRead = Boolean(resolveStructuredPayloadReadFile(ctx)); - for (const candidatePath of candidatePaths) { - const allowedPath = resolveTrustedOutboundMediaPath(candidatePath, { - allowMissing: allowMissingHostRead, - }); - if (allowedPath) { - return allowedPath; - } - - if (localRoots) { - const scopedPath = resolveLocalPathFromRootsSync({ - filePath: candidatePath, - roots: localRoots, - label: "QQ Bot local roots", - allowMissing: allowMissingHostRead, - })?.path; - if (scopedPath) { - return scopedPath; - } - } - } - - ctx.log?.error(`Blocked ${mediaType} payload local path outside QQ Bot media storage`); - return null; -} - -function isRemoteHttpUrl(p: string): boolean { - return /^https?:\/\//i.test(p); -} - -function isInlineImageDataUrl(p: string): boolean { - return /^data:image\/[^;]+;base64,/i.test(p); -} - -function resolveStructuredPayloadPath( - ctx: ReplyContext, - payload: MediaPayload, - mediaType: StructuredPayloadMediaType, -): { path: string; isHttpUrl: boolean } | null { - const originalPath = payload.path ?? ""; - const normalizedPath = normalizePath(originalPath); - const isHttpUrl = isRemoteHttpUrl(normalizedPath); - const resolvedPath = isHttpUrl - ? normalizedPath - : validateStructuredPayloadLocalPath(ctx, originalPath, mediaType); - if (!resolvedPath) { - return null; - } - if (!resolvedPath.trim()) { - ctx.log?.error( - `[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`, - ); - return null; - } - return { path: resolvedPath, isHttpUrl }; -} - -function sanitizeForLog(value: string, maxLen = 200): string { - return truncateUtf16Safe(value.replace(/[\r\n\t]/g, " ").replaceAll("\0", " "), maxLen); -} - -function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string { - if (!isHttpUrl) { - return ""; - } - try { - const url = new URL(pathValue); - url.username = ""; - url.password = ""; - const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12); - return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`); - } catch { - return ""; - } -} - -function resolveStructuredPayloadReadFile(ctx: OutboundMediaAccessContext) { - return ctx.mediaAccess?.readFile ?? ctx.mediaReadFile; -} - -function assertBufferWithinTypeLimit(buffer: Buffer, fileType: MediaFileType): void { - const maxSize = getMaxUploadSize(fileType); - if (buffer.length > maxSize) { - throw new Error( - `File is too large (${formatFileSize(buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`, - ); - } -} - -function imageBufferMatchesMime(buffer: Buffer, mimeType: string): boolean { - if (mimeType === "image/png") { - return buffer - .subarray(0, 8) - .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); - } - if (mimeType === "image/jpeg") { - return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; - } - if (mimeType === "image/gif") { - const header = buffer.subarray(0, 6).toString("ascii"); - return header === "GIF87a" || header === "GIF89a"; - } - if (mimeType === "image/webp") { - return ( - buffer.subarray(0, 4).toString("ascii") === "RIFF" && - buffer.subarray(8, 12).toString("ascii") === "WEBP" - ); - } - if (mimeType === "image/bmp") { - return buffer.subarray(0, 2).toString("ascii") === "BM"; - } - return false; -} - -async function readLocalFileForInlineBase64( - ctx: ReplyContext, - filePath: string, - fileType: MediaFileType, -): Promise { - const mediaReadFile = resolveStructuredPayloadReadFile(ctx); - if (mediaReadFile) { - let buffer: Buffer | null = null; - try { - buffer = await mediaReadFile(filePath); - } catch (err) { - ctx.log?.debug?.(`Structured payload host read failed: ${formatErrorMessage(err)}`); - } - if (buffer !== null) { - assertBufferWithinTypeLimit(buffer, fileType); - if (buffer.length === 0) { - throw new Error(`File is empty: ${filePath}`); - } - return buffer; - } - } - const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) }); - try { - return await opened.handle.readFile(); - } finally { - await opened.close(); - } -} - -async function readPayloadFileBuffer( - ctx: ReplyContext, - filePath: string, - fileType: MediaFileType, -): Promise { - const mediaReadFile = resolveStructuredPayloadReadFile(ctx); - if (!mediaReadFile) { - return null; - } - let buffer: Buffer; - try { - buffer = await mediaReadFile(filePath); - } catch (err) { - ctx.log?.debug?.(`Structured payload host read failed: ${formatErrorMessage(err)}`); - return null; - } - assertBufferWithinTypeLimit(buffer, fileType); - if (buffer.length === 0) { - throw new Error(`File is empty: ${filePath}`); - } - return buffer; -} - -async function assertLocalFileWithinTypeLimit( - filePath: string, - fileType: MediaFileType, -): Promise { - const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) }); - try { - return opened.size; - } finally { - await opened.close(); - } -} - -async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise { - const { target, account, log } = ctx; - const normalizedPath = normalizePath(payload.path); - let imageUrl: string | null; - if (payload.source === "file") { - imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image"); - } else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) { - imageUrl = normalizedPath; - } else { - log?.error( - `Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`, - ); - return; - } - if (!imageUrl) { - return; - } - const originalImagePath = payload.source === "file" ? imageUrl : undefined; - - if (payload.source === "file") { - try { - const fileBuffer = await readLocalFileForInlineBase64(ctx, imageUrl, MediaFileType.IMAGE); - const mimeType = getImageMimeType(imageUrl); - if (!mimeType) { - const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl)); - log?.error(`Unsupported image format: ${ext}`); - return; - } - if (!imageBufferMatchesMime(fileBuffer, mimeType)) { - throw new Error(`File is not an image: ${imageUrl}`); - } - const base64Data = fileBuffer.toString("base64"); - imageUrl = `data:${mimeType};base64,${base64Data}`; - log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`); - } catch (readErr) { - log?.error( - `Failed to read local image: ${ - readErr instanceof Error ? readErr.message : JSON.stringify(readErr) - }`, - ); - return; - } - } - - try { - const deliveryTarget = buildDeliveryTarget(target); - const creds = accountToCreds(account); - - await withTokenRetry( - creds, - async () => { - if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") { - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "image", - source: { url: imageUrl }, - msgId: target.messageId, - localPathForMeta: originalImagePath, - }); - } else if (deliveryTarget.type === "dm") { - await senderSendText(deliveryTarget, `![](${imageUrl})`, creds, { - msgId: target.messageId, - }); - } else { - await senderSendText(deliveryTarget, `![](${imageUrl})`, creds, { - msgId: target.messageId, - }); - } - }, - log, - account.accountId, - ); - log?.debug?.(`Sent image via media payload`); - - if (payload.caption) { - await sendTextToTarget(ctx, payload.caption); - } - } catch (err) { - log?.error(`Failed to send image: ${formatErrorMessage(err)}`); - } -} - -async function handleAudioPayload( - ctx: ReplyContext, - payload: MediaPayload, - deps?: ReplyDispatcherDeps, -): Promise { - const ttsText = payload.caption || payload.path; - await sendTextAsVoiceReply(ctx, ttsText, deps); -} - -export async function sendTextAsVoiceReply( - ctx: ReplyContext, - text: string | undefined, - deps?: ReplyDispatcherDeps, -): Promise { - const { target, account, cfg, log } = ctx; - if (!deps) { - log?.error(`TTS deps not provided, cannot handle audio payload`); - return false; - } - try { - const ttsText = text; - if (!ttsText?.trim()) { - log?.error(`Voice missing text`); - return false; - } - - log?.debug?.(`TTS: "${truncateUtf16Safe(ttsText, 50)}..."`); - const ttsResult = await deps.tts.textToSpeech({ - text: ttsText, - cfg, - channel: "qqbot", - accountId: account.accountId, - }); - if (!ttsResult.success || !ttsResult.audioPath) { - log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`); - return false; - } - - const providerLabel = ttsResult.provider ?? "unknown"; - log?.debug?.( - `TTS returned: provider=${providerLabel}, format=${ttsResult.outputFormat}, path=${ttsResult.audioPath}`, - ); - - const silkBase64 = await deps.tts.audioFileToSilkBase64(ttsResult.audioPath); - if (!silkBase64) { - log?.error(`Failed to convert TTS audio to SILK`); - return false; - } - const silkPath = ttsResult.audioPath; - - log?.debug?.(`TTS done (${providerLabel}), file: ${silkPath}`); - - const deliveryTarget = buildDeliveryTarget(target); - const creds = accountToCreds(account); - - await withTokenRetry( - creds, - async () => { - if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") { - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "voice", - source: { base64: silkBase64 }, - msgId: target.messageId, - ttsText, - localPathForMeta: silkPath, - }); - } else { - log?.error(`Voice not supported in ${deliveryTarget.type}, sending text fallback`); - await senderSendText(deliveryTarget, ttsText, creds, { msgId: target.messageId }); - } - }, - log, - account.accountId, - ); - log?.debug?.(`Voice message sent`); - return true; - } catch (err) { - log?.error(`TTS/voice send failed: ${formatErrorMessage(err)}`); - return false; - } -} - -async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise { - const { target, account, log } = ctx; - try { - const resolved = resolveStructuredPayloadPath(ctx, payload, "video"); - if (!resolved) { - return; - } - const videoPath = resolved.path; - const isHttpUrl = resolved.isHttpUrl; - - log?.debug?.(`Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`); - - const deliveryTarget = buildDeliveryTarget(target); - const creds = accountToCreds(account); - - if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") { - log?.error(`Video not supported in ${deliveryTarget.type}`); - return; - } - - await withTokenRetry( - creds, - async () => { - if (isHttpUrl) { - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "video", - source: { url: videoPath }, - msgId: target.messageId, - }); - } else { - const payloadBuffer = await readPayloadFileBuffer(ctx, videoPath, MediaFileType.VIDEO); - if (payloadBuffer) { - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "video", - source: { - buffer: payloadBuffer, - fileName: sanitizeFileName(path.basename(videoPath)), - }, - msgId: target.messageId, - }); - return; - } - const size = await assertLocalFileWithinTypeLimit(videoPath, MediaFileType.VIDEO); - log?.debug?.( - `Video local (${formatFileSize(size)}): ${describeMediaTargetForLog(videoPath, false)}`, - ); - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "video", - source: { localPath: videoPath }, - msgId: target.messageId, - localPathForMeta: videoPath, - }); - } - }, - log, - account.accountId, - ); - log?.debug?.(`Video message sent`); - - if (payload.caption) { - await sendTextToTarget(ctx, payload.caption); - } - } catch (err) { - log?.error(`Video send failed: ${formatErrorMessage(err)}`); - } -} - -async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise { - const { target, account, log } = ctx; - try { - const resolved = resolveStructuredPayloadPath(ctx, payload, "file"); - if (!resolved) { - return; - } - const filePath = resolved.path; - const isHttpUrl = resolved.isHttpUrl; - - const fileName = sanitizeFileName(path.basename(filePath)); - log?.debug?.( - `File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`, - ); - - const deliveryTarget = buildDeliveryTarget(target); - const creds = accountToCreds(account); - - if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") { - log?.error(`File not supported in ${deliveryTarget.type}`); - return; - } - - await withTokenRetry( - creds, - async () => { - if (isHttpUrl) { - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "file", - source: { url: filePath }, - msgId: target.messageId, - fileName, - }); - } else { - const payloadBuffer = await readPayloadFileBuffer(ctx, filePath, MediaFileType.FILE); - if (payloadBuffer) { - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "file", - source: { buffer: payloadBuffer, fileName }, - msgId: target.messageId, - fileName, - }); - return; - } - const size = await assertLocalFileWithinTypeLimit(filePath, MediaFileType.FILE); - log?.debug?.( - `File local (${formatFileSize(size)}): ${describeMediaTargetForLog(filePath, false)}`, - ); - await senderSendMedia({ - target: deliveryTarget, - creds, - kind: "file", - source: { localPath: filePath }, - msgId: target.messageId, - fileName, - localPathForMeta: filePath, - }); - } - }, - log, - account.accountId, - ); - log?.debug?.(`File message sent`); - } catch (err) { - log?.error(`File send failed: ${formatErrorMessage(err)}`); - } -} diff --git a/extensions/qqbot/src/engine/messaging/reply-limiter.test.ts b/extensions/qqbot/src/engine/messaging/reply-limiter.test.ts deleted file mode 100644 index 658f428ea3e1..000000000000 --- a/extensions/qqbot/src/engine/messaging/reply-limiter.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ReplyLimiter } from "./reply-limiter.js"; - -describe("ReplyLimiter", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("shares five atomic claims while typing reserves the final reply", () => { - const limiter = new ReplyLimiter({ limit: 5 }); - - expect(limiter.claim("msg-1", 1)).toMatchObject({ allowed: true, remaining: 4 }); - expect(limiter.claim("msg-1", 1)).toMatchObject({ allowed: true, remaining: 3 }); - expect(limiter.claim("msg-1", 1)).toMatchObject({ allowed: true, remaining: 2 }); - expect(limiter.claim("msg-1", 1)).toMatchObject({ allowed: true, remaining: 1 }); - expect(limiter.claim("msg-1", 1)).toMatchObject({ - allowed: false, - remaining: 1, - fallbackReason: "limit_exceeded", - }); - - expect(limiter.claim("msg-1")).toMatchObject({ allowed: true, remaining: 0 }); - expect(limiter.claim("msg-1")).toMatchObject({ - allowed: false, - remaining: 0, - fallbackReason: "limit_exceeded", - }); - expect(limiter.getStats()).toEqual({ trackedMessages: 1, totalReplies: 5 }); - }); - - it("does not reopen an expired passive reply window", () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const limiter = new ReplyLimiter({ ttlMs: 60_000 }); - expect(limiter.claim("msg-1").allowed).toBe(true); - - vi.setSystemTime(60_001); - expect(limiter.claim("msg-1")).toMatchObject({ - allowed: false, - remaining: 0, - fallbackReason: "expired", - }); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/reply-limiter.ts b/extensions/qqbot/src/engine/messaging/reply-limiter.ts deleted file mode 100644 index 160c83a4cd75..000000000000 --- a/extensions/qqbot/src/engine/messaging/reply-limiter.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Passive reply limiter — enforce per-message reply count and TTL limits. - * - * QQ Bot restricts how many passive replies can be sent in response to a - * single inbound message (5 per hour by default). This module tracks reply - * counts and determines whether the next reply should be passive or - * fall back to proactive mode. - * - * The module is a **class** with zero I/O dependencies, fully supporting - * multi-account concurrent operation via separate instances. - */ - -/** Configuration for the reply limiter. */ -interface ReplyLimiterConfig { - /** Maximum passive replies per message. Defaults to 5. */ - limit?: number; - /** TTL in milliseconds for the passive reply window. Defaults to 1 hour. */ - ttlMs?: number; - /** Maximum number of tracked messages before eviction. Defaults to 10000. */ - maxTrackedMessages?: number; -} - -/** Result of a passive-reply limit check. */ -export interface ReplyLimitResult { - /** Whether a passive reply is still allowed. */ - allowed: boolean; - /** Number of remaining passive replies. */ - remaining: number; - /** Whether the caller should fall back to proactive mode. */ - shouldFallbackToProactive: boolean; - /** Reason for the fallback. */ - fallbackReason?: "expired" | "limit_exceeded"; - /** Human-readable diagnostic message. */ - message?: string; -} - -interface ReplyRecord { - count: number; - firstReplyAt: number; -} - -const DEFAULT_LIMIT = 5; -const DEFAULT_TTL_MS = 60 * 60 * 1000; -const DEFAULT_MAX_TRACKED = 10_000; - -/** - * Per-account reply limiter with automatic eviction. - * - * Usage: - * ```ts - * const limiter = new ReplyLimiter({ limit: 5, ttlMs: 3600000 }); - * const claim = limiter.claim(messageId); - * if (claim.allowed) { - * await sendPassiveReply(...); - * } else if (claim.shouldFallbackToProactive) { - * await sendProactiveMessage(...); - * } - * ``` - */ -export class ReplyLimiter { - private readonly limit: number; - private readonly ttlMs: number; - private readonly maxTracked: number; - private readonly tracker = new Map(); - - constructor(config?: ReplyLimiterConfig) { - this.limit = config?.limit ?? DEFAULT_LIMIT; - this.ttlMs = config?.ttlMs ?? DEFAULT_TTL_MS; - this.maxTracked = config?.maxTrackedMessages ?? DEFAULT_MAX_TRACKED; - } - - /** Check whether a passive reply is allowed while leaving `reserve` slots unused. */ - checkLimit(messageId: string, reserve = 0): ReplyLimitResult { - const now = Date.now(); - this.evictIfNeeded(now); - - const record = this.tracker.get(messageId); - - if (!record) { - if (this.limit > reserve) { - return { - allowed: true, - remaining: this.limit, - shouldFallbackToProactive: false, - }; - } - return { - allowed: false, - remaining: this.limit, - shouldFallbackToProactive: true, - fallbackReason: "limit_exceeded", - message: `Passive reply budget reserved (${reserve} of ${this.limit} remaining); sending proactively instead`, - }; - } - - if (now - record.firstReplyAt > this.ttlMs) { - return { - allowed: false, - remaining: 0, - shouldFallbackToProactive: true, - fallbackReason: "expired", - message: `Message is older than ${this.ttlMs / (60 * 60 * 1000)}h; sending as a proactive message instead`, - }; - } - - const remaining = this.limit - (record?.count ?? 0); - if (remaining <= reserve) { - return { - allowed: false, - remaining, - shouldFallbackToProactive: true, - fallbackReason: "limit_exceeded", - message: - reserve > 0 - ? `Passive reply budget reserved (${reserve} of ${this.limit} remaining); sending proactively instead` - : `Passive reply limit reached (${this.limit} per hour); sending proactively instead`, - }; - } - - return { - allowed: true, - remaining, - shouldFallbackToProactive: false, - }; - } - - /** Atomically reserve one passive-reply slot before starting the request. */ - claim(messageId: string, reserve = 0): ReplyLimitResult { - const check = this.checkLimit(messageId, reserve); - if (!check.allowed) { - return check; - } - this.record(messageId); - return { ...check, remaining: check.remaining - 1 }; - } - - /** Record one passive reply against a message. */ - record(messageId: string): void { - const now = Date.now(); - const existing = this.tracker.get(messageId); - - if (!existing) { - this.tracker.set(messageId, { count: 1, firstReplyAt: now }); - } else if (now - existing.firstReplyAt > this.ttlMs) { - this.tracker.set(messageId, { count: 1, firstReplyAt: now }); - } else { - existing.count++; - } - } - - /** Return diagnostic stats. */ - getStats(): { trackedMessages: number; totalReplies: number } { - let totalReplies = 0; - for (const record of this.tracker.values()) { - totalReplies += record.count; - } - return { trackedMessages: this.tracker.size, totalReplies }; - } - - /** Return limiter configuration. */ - getConfig(): { limit: number; ttlMs: number; ttlHours: number } { - return { - limit: this.limit, - ttlMs: this.ttlMs, - ttlHours: this.ttlMs / (60 * 60 * 1000), - }; - } - - /** Clear all tracked records. */ - clear(): void { - this.tracker.clear(); - } - - /** Opportunistically evict expired records to keep the tracker bounded. */ - private evictIfNeeded(now: number): void { - if (this.tracker.size <= this.maxTracked) { - return; - } - for (const [id, rec] of this.tracker) { - if (now - rec.firstReplyAt > this.ttlMs) { - this.tracker.delete(id); - } - } - } -} diff --git a/extensions/qqbot/src/engine/messaging/sender.test.ts b/extensions/qqbot/src/engine/messaging/sender.test.ts deleted file mode 100644 index c0027bea0179..000000000000 --- a/extensions/qqbot/src/engine/messaging/sender.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { TokenManager } from "../api/token.js"; -import { ApiError } from "../types.js"; -import { registerAccount, withTokenRetry } from "./sender.js"; - -describe("QQBot token retry", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("refreshes when QQ reports an expired token as HTTP 500 with business code 11244", async () => { - const getAccessToken = vi - .spyOn(TokenManager.prototype, "getAccessToken") - .mockResolvedValueOnce("expired-token") - .mockResolvedValueOnce("fresh-token"); - const clearCache = vi.spyOn(TokenManager.prototype, "clearCache"); - const send = vi - .fn<(token: string) => Promise>() - // Keep the message free of retry keywords so the structured code is the only signal. - .mockRejectedValueOnce(new ApiError("credential rejected", 500, "/gateway", 11244)) - .mockResolvedValueOnce("sent"); - const logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn() }; - registerAccount("retry-app", { logger }); - - await expect( - withTokenRetry({ appId: "retry-app", clientSecret: "secret" }, send, logger), - ).resolves.toBe("sent"); - - expect(getAccessToken).toHaveBeenCalledTimes(2); - expect(clearCache).toHaveBeenCalledWith("retry-app"); - expect(send).toHaveBeenNthCalledWith(1, "expired-token"); - expect(send).toHaveBeenNthCalledWith(2, "fresh-token"); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/sender.ts b/extensions/qqbot/src/engine/messaging/sender.ts deleted file mode 100644 index 1877e081d414..000000000000 --- a/extensions/qqbot/src/engine/messaging/sender.ts +++ /dev/null @@ -1,789 +0,0 @@ -/** - * Unified message sender — per-account resource management + business function layer. - * - * This module is the **single entry point** for all QQ Bot API operations. - * - * ## Architecture - * - * Each account gets its own isolated resource stack: - * - * ``` - * accountRegistry: Map - * - * AccountContext { - * logger — per-account prefixed logger - * client — per-account ApiClient - * tokenMgr — per-account TokenManager - * mediaApi — per-account MediaApi - * messageApi — per-account MessageApi - * } - * ``` - * - * Upper-layer callers (gateway, outbound, reply-dispatcher, proactive) - * always go through exported functions that resolve the correct - * `AccountContext` by appId. - */ - -import os from "node:os"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { ApiClient } from "../api/api-client.js"; -import { isQQBotTokenAuthenticationFailure } from "../api/auth-errors.js"; -import { ChunkedMediaApi as ChunkedMediaApiClass } from "../api/media-chunked.js"; -import { downloadDirectUploadUrl, MediaApi as MediaApiClass } from "../api/media.js"; -import type { Credentials } from "../api/messages.js"; -import { MessageApi as MessageApiClass } from "../api/messages.js"; -import { getNextMsgSeq } from "../api/routes.js"; -import { TokenManager } from "../api/token.js"; -import { - ApiError, - MediaFileType, - type ChatScope, - type EngineLogger, - type MessageResponse, - type OutboundMeta, - type UploadMediaResponse, -} from "../types.js"; -import { getMaxUploadSize, LARGE_FILE_THRESHOLD } from "../utils/file-utils.js"; -import { debugLog, debugError, debugWarn } from "../utils/log.js"; -import { sanitizeFileName } from "../utils/string-normalize.js"; -import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "../utils/upload-cache.js"; -import { normalizeSource, type MediaSource, type RawMediaSource } from "./media-source.js"; -import { claimMessageReply } from "./outbound-reply.js"; - -// ============ Re-exported types ============ - -export { UploadDailyLimitExceededError } from "../api/media-chunked.js"; - -// ============ Plugin User-Agent ============ - -let pluginVersion = "unknown"; -let openclawVersion = "unknown"; - -/** Build the User-Agent string from the current plugin and framework versions. */ -function buildUserAgent(): string { - return `QQBotPlugin/${pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${openclawVersion})`; -} - -/** Return the current User-Agent string. */ -export function getPluginUserAgent(): string { - return buildUserAgent(); -} - -/** - * Initialize sender with the plugin version. - * Must be called once during startup before any API calls. - */ -export function initSender(options: { pluginVersion?: string; openclawVersion?: string }): void { - if (options.pluginVersion) { - pluginVersion = options.pluginVersion; - } - if (options.openclawVersion) { - openclawVersion = options.openclawVersion; - } -} - -/** Update the OpenClaw framework version in the User-Agent (called after runtime injection). */ -export function setOpenClawVersion(version: string): void { - if (version) { - openclawVersion = version; - } -} - -// ============ Per-account resource management ============ - -/** Complete resource context for a single account. */ -interface AccountContext { - logger: EngineLogger; - client: ApiClient; - tokenMgr: TokenManager; - mediaApi: MediaApiClass; - chunkedMediaApi: ChunkedMediaApiClass; - messageApi: MessageApiClass; - markdownSupport: boolean; -} - -/** Per-appId account registry — each account owns all its resources. */ -const accountRegistry = new Map(); - -/** Fallback logger for unregistered accounts (CLI / test scenarios). */ -const fallbackLogger: EngineLogger = { - info: (msg: string) => debugLog(msg), - error: (msg: string) => debugError(msg), - warn: (msg: string) => debugWarn(msg), - debug: (msg: string) => debugLog(msg), -}; - -/** - * Build a full resource stack for a given logger. - * - * Shared by both `registerAccount` (explicit registration) and - * `resolveAccount` (lazy fallback for unregistered accounts). - */ -function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): AccountContext { - const client = new ApiClient({ logger, userAgent: buildUserAgent }); - const tokenMgr = new TokenManager({ logger, userAgent: buildUserAgent }); - // The one-shot and chunked uploaders share the same cache adapter so repeat - // sends of identical bytes hit the same `file_info` regardless of which - // path the first send used. - const sharedUploadCache = { - computeHash: computeFileHash, - get: (hash: string, scope: string, targetId: string, fileType: number) => - getCachedFileInfo(hash, scope as ChatScope, targetId, fileType), - set: ( - hash: string, - scope: string, - targetId: string, - fileType: number, - fileInfo: string, - fileUuid: string, - ttl: number, - ) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl), - }; - const mediaApi = new MediaApiClass(client, tokenMgr, { - logger, - uploadCache: sharedUploadCache, - sanitizeFileName, - }); - const chunkedMediaApi = new ChunkedMediaApiClass(client, tokenMgr, { - logger, - uploadCache: sharedUploadCache, - sanitizeFileName, - }); - const messageApi = new MessageApiClass(client, tokenMgr, { - markdownSupport, - logger, - }); - - return { logger, client, tokenMgr, mediaApi, chunkedMediaApi, messageApi, markdownSupport }; -} - -/** - * Register an account — atomically sets up all per-appId resources. - * - * Must be called once per account during gateway startup. - * Creates a complete isolated resource stack (ApiClient, TokenManager, - * MediaApi, MessageApi) with the per-account logger. - */ -export function registerAccount( - appId: string, - options: { - logger: EngineLogger; - markdownSupport?: boolean; - }, -): void { - const key = appId.trim(); - const md = options.markdownSupport === true; - accountRegistry.set(key, buildAccountContext(options.logger, md)); -} - -/** - * Initialize per-app API behavior such as markdown support. - * - * If the account was already registered via `registerAccount()`, updates its - * MessageApi with the new markdown setting while preserving the existing - * logger and resource stack. Otherwise creates a new context. - */ -export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void { - const key = appId.trim(); - const md = options.markdownSupport === true; - const existing = accountRegistry.get(key); - if (existing) { - // Re-create only MessageApi with updated config, reuse existing stack. - existing.messageApi = new MessageApiClass(existing.client, existing.tokenMgr, { - markdownSupport: md, - logger: existing.logger, - }); - existing.markdownSupport = md; - } else { - accountRegistry.set(key, buildAccountContext(fallbackLogger, md)); - } -} - -/** - * Resolve the AccountContext for a given appId. - * - * If the account was registered via `registerAccount()`, returns the - * pre-built context. Otherwise lazily creates a fallback context. - */ -function resolveAccount(appId: string): AccountContext { - const key = appId.trim(); - let ctx = accountRegistry.get(key); - if (!ctx) { - ctx = buildAccountContext(fallbackLogger, false); - accountRegistry.set(key, ctx); - } - return ctx; -} - -// ============ Instance getters (for advanced callers) ============ - -/** Get the MessageApi instance for the given appId. */ -export function getMessageApi(appId: string): MessageApiClass { - return resolveAccount(appId).messageApi; -} - -// ============ Per-appId config ============ - -type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void; - -/** Register an outbound-message hook scoped to one appId. */ -export function onMessageSent(appId: string, callback: OnMessageSentCallback): void { - resolveAccount(appId).messageApi.onMessageSent(callback); -} - -// ============ Token management ============ - -export async function getAccessToken(appId: string, clientSecret: string): Promise { - return resolveAccount(appId).tokenMgr.getAccessToken(appId, clientSecret); -} - -export function clearTokenCache(appId?: string): void { - if (appId) { - resolveAccount(appId).tokenMgr.clearCache(appId); - } else { - for (const ctx of accountRegistry.values()) { - ctx.tokenMgr.clearCache(); - } - } -} - -export function startBackgroundTokenRefresh( - appId: string, - clientSecret: string, - options?: { - refreshAheadMs?: number; - randomOffsetMs?: number; - minRefreshIntervalMs?: number; - retryDelayMs?: number; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; - }, -): void { - resolveAccount(appId).tokenMgr.startBackgroundRefresh(appId, clientSecret, options); -} - -export function stopBackgroundTokenRefresh(appId?: string): void { - if (appId) { - resolveAccount(appId).tokenMgr.stopBackgroundRefresh(appId); - } else { - for (const ctx of accountRegistry.values()) { - ctx.tokenMgr.stopBackgroundRefresh(); - } - } -} - -// ============ Gateway URL ============ - -export async function getGatewayUrl(accessToken: string, appId: string): Promise { - const data = await resolveAccount(appId).client.request<{ url: string }>( - accessToken, - "GET", - "/gateway", - ); - return data.url; -} - -// ============ Interaction ============ - -/** Acknowledge an INTERACTION_CREATE event via PUT /interactions/{id}. */ -export async function acknowledgeInteraction( - creds: AccountCreds, - interactionId: string, - code: 0 | 1 | 2 | 3 | 4 | 5 = 0, - data?: Record, -): Promise { - const ctx = resolveAccount(creds.appId); - const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret); - await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, { - code, - ...(data ? { data } : {}), - }); -} - -// ============ Types ============ - -/** Delivery target resolved from event context. */ -export interface DeliveryTarget { - type: "c2c" | "group" | "channel" | "dm"; - id: string; -} - -/** Account credentials for API authentication. */ -interface AccountCreds { - appId: string; - clientSecret: string; -} - -// ============ Token retry ============ - -/** - * Execute an API call with automatic retry when QQ rejects the access token. - * - * Primary signals are the structured HTTP status and QQ business code. A string - * fallback remains for non-`ApiError` paths (e.g. synthetic errors from - * custom adapters), but logs a warning so such cases can be surfaced. - */ -export async function withTokenRetry( - creds: AccountCreds, - sendFn: (token: string) => Promise, - log?: EngineLogger, - _accountId?: string, -): Promise { - try { - const token = await getAccessToken(creds.appId, creds.clientSecret); - return await sendFn(token); - } catch (err) { - const isStructuredAuthFailure = - err instanceof ApiError && isQQBotTokenAuthenticationFailure(err.httpStatus, err.bizCode); - if (isStructuredAuthFailure) { - log?.debug?.(`QQBot access token rejected, refreshing...`); - clearTokenCache(creds.appId); - const newToken = await getAccessToken(creds.appId, creds.clientSecret); - return await sendFn(newToken); - } - - // String fallback — retain for non-ApiError code paths but make it visible. - const errMsg = formatErrorMessage(err); - const looksLike401 = - errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token"); - if (looksLike401) { - log?.warn?.( - `Token retry triggered by string heuristic (err is not ApiError). ` + - `Consider propagating ApiError end-to-end. msg=${truncateUtf16Safe(errMsg, 120)}`, - ); - clearTokenCache(creds.appId); - const newToken = await getAccessToken(creds.appId, creds.clientSecret); - return await sendFn(newToken); - } - throw err; - } -} - -// ============ Media hook helper ============ - -/** - * Notify the MessageApi onMessageSent hook after a media send. - */ -function notifyMediaHook(appId: string, result: MessageResponse, meta: OutboundMeta): void { - const refIdx = result.ext_info?.ref_idx; - if (refIdx) { - resolveAccount(appId).messageApi.notifyMessageSent(refIdx, meta); - } -} - -// ============ Text sending ============ - -/** - * Send a text message to any QQ target type. - * - * Automatically routes to the correct API method based on target type. - * Handles passive (with msgId) and proactive (without msgId) modes. - */ -export async function sendText( - target: DeliveryTarget, - content: string, - creds: AccountCreds, - opts?: { msgId?: string; messageReference?: string; forcePlainText?: boolean }, -): Promise { - const ctx = resolveAccount(creds.appId); - const api = ctx.messageApi; - const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret }; - let msgId = opts?.msgId; - - // MessageApi issues one POST. Higher-level token retries re-enter sendText, - // so every retry and target type claims another slot before reaching the wire. - if (msgId) { - const passive = claimMessageReply(msgId); - if (!passive.allowed) { - ctx.logger.warn?.( - `Passive reply unavailable for ${target.type}; falling back to a send without msg_id: ${passive.message}`, - ); - msgId = undefined; - } - } - - if (target.type === "c2c" || target.type === "group") { - const scope: ChatScope = target.type; - if (msgId) { - return api.sendMessage(scope, target.id, content, c, { - msgId, - messageReference: opts?.messageReference, - forcePlainText: opts?.forcePlainText, - }); - } - return api.sendProactiveMessage(scope, target.id, content, c, { - forcePlainText: opts?.forcePlainText, - }); - } - - if (target.type === "dm") { - return api.sendDmMessage({ guildId: target.id, content, creds: c, msgId }); - } - - return api.sendChannelMessage({ channelId: target.id, content, creds: c, msgId }); -} - -// ============ Input notify ============ - -/** - * Send a typing indicator to a C2C user. - */ -export async function sendInputNotify(opts: { - openid: string; - creds: AccountCreds; - msgId?: string; - inputSecond?: number; -}): Promise<{ refIdx?: string }> { - const api = resolveAccount(opts.creds.appId).messageApi; - const c: Credentials = { appId: opts.creds.appId, clientSecret: opts.creds.clientSecret }; - return api.sendInputNotify({ - openid: opts.openid, - creds: c, - msgId: opts.msgId, - inputSecond: opts.inputSecond, - }); -} - -/** - * Raw-token input notify — compatible with TypingKeepAlive's callback signature. - */ -export function createRawInputNotifyFn( - appId: string, -): ( - token: string, - openid: string, - msgId: string | undefined, - inputSecond: number, -) => Promise { - return async (token, openid, msgId, inputSecond) => { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - return resolveAccount(appId).client.request(token, "POST", `/v2/users/${openid}/messages`, { - msg_type: 6, - input_notify: { input_type: 1, input_second: inputSecond }, - msg_seq: msgSeq, - ...(msgId ? { msg_id: msgId } : {}), - }); - }; -} - -// ============ Media sending (unified) ============ - -/** Rich-media kind accepted by {@link sendMedia}. */ -type MediaKind = "image" | "voice" | "video" | "file"; - -/** Map a {@link MediaKind} to the wire-level {@link MediaFileType} code. */ -const KIND_TO_FILE_TYPE: Record = { - image: MediaFileType.IMAGE, - voice: MediaFileType.VOICE, - video: MediaFileType.VIDEO, - file: MediaFileType.FILE, -}; - -/** - * Options for the unified {@link sendMedia} API. - * - * This replaces the legacy four-method surface - * (`sendImage / sendVoiceMessage / sendVideoMessage / sendFileMessage`). - */ -interface SendMediaOptions { - /** Delivery target. Only `c2c` and `group` support rich media. */ - target: DeliveryTarget; - /** Account credentials. */ - creds: AccountCreds; - /** Media kind (drives `file_type`, meta, and content semantics). */ - kind: MediaKind; - /** Media source — URL, base64, on-disk path, or in-memory buffer. */ - source: RawMediaSource; - /** Passive reply message ID; omit for proactive sends. */ - msgId?: string; - /** - * Accompanying text. Only honored for `image` / `video` kinds — the QQ - * API ignores it for voice/file. - */ - content?: string; - /** Override the server-visible file name (FILE kind only). */ - fileName?: string; - /** Original TTS text — recorded in {@link OutboundMeta.ttsText} for voice. */ - ttsText?: string; - /** - * Local path to record in {@link OutboundMeta.mediaLocalPath}. Usually set - * by adapters that already downloaded the source to disk; otherwise - * inferred automatically when `source` is `{ localPath }`. - */ - localPathForMeta?: string; - /** - * Original URL to record in {@link OutboundMeta.mediaUrl}. Usually set by - * adapters that downloaded a remote URL before uploading; otherwise - * inferred automatically when `source` is `{ url }` (non-data URL). - */ - origUrlForMeta?: string; -} - -/** - * Upload and send a rich-media message to any C2C or Group target. - * - * This is the **single** rich-media entry point for the plugin. All adapter - * layers (outbound.ts, reply-dispatcher.ts, outbound-deliver.ts, - * bridge/commands, gateway/outbound-dispatch.ts) funnel through here. - * - * Dispatch structure: - * - * ``` - * sendMedia(opts) - * └─ sendMediaInternal(ctx, opts) - * ├─ normalizeSource ← unified data:URL parsing + O_NOFOLLOW file safety - * ├─ uploadOnce ← one-shot upload via MediaApi (chunked hook TBD) - * ├─ sendMediaMessage - * └─ notifyMediaHook ← meta assembled per kind - * ``` - * - * Future chunked upload will slot into the dispatch without touching callers. - */ -export async function sendMedia(opts: SendMediaOptions): Promise { - if (!supportsRichMedia(opts.target.type)) { - throw new Error(`Media sending not supported for target type: ${opts.target.type}`); - } - const ctx = resolveAccount(opts.creds.appId); - return sendMediaInternal(ctx, opts); -} - -/** - * Assemble an {@link OutboundMeta} record from the normalized source and the - * caller-provided overrides. - * - * The meta layout is identical across kinds except: - * - `image` / `video` carry `text` (the accompanying content string). - * - `voice` carries `ttsText` (original TTS input, if any). - */ -function buildOutboundMeta(opts: SendMediaOptions, source: MediaSource): OutboundMeta { - const meta: OutboundMeta = { - mediaType: opts.kind, - }; - - if (opts.kind === "image" || opts.kind === "video") { - if (opts.content) { - meta.text = opts.content; - } - } - if (opts.kind === "voice" && opts.ttsText) { - meta.ttsText = opts.ttsText; - } - - // Prefer explicit caller overrides; otherwise derive from the source. - const inferredUrl = source.kind === "url" ? source.url : undefined; - const mediaUrl = opts.origUrlForMeta ?? inferredUrl; - if (mediaUrl) { - meta.mediaUrl = mediaUrl; - } - - const inferredLocal = source.kind === "localPath" ? source.path : undefined; - const mediaLocalPath = opts.localPathForMeta ?? inferredLocal; - if (mediaLocalPath) { - meta.mediaLocalPath = mediaLocalPath; - } - - return meta; -} - -/** - * Core dispatch for rich media. Not exported — callers must go through - * {@link sendMedia}. - * - * Upload dispatch lives in {@link dispatchUpload}: sources smaller than - * {@link LARGE_FILE_THRESHOLD} (or not supporting chunked transport, i.e. - * url/base64) go to {@link MediaApi.uploadMedia}; larger `localPath` / - * `buffer` sources go to {@link ChunkedMediaApi.uploadChunked}. - */ -async function sendMediaInternal( - ctx: AccountContext, - opts: SendMediaOptions, -): Promise { - const scope: ChatScope = opts.target.type as ChatScope; - const c: Credentials = { - appId: opts.creds.appId, - clientSecret: opts.creds.clientSecret, - }; - - // The outbound layer enforces per-file-type ceilings; normalizeSource's - // default is the smaller one-shot limit. We pass the chunked limit here - // to let the dispatcher decide per source.size whether to route to the - // chunked uploader. Upstream (outbound/sendPhoto etc.) remains the - // authoritative size-by-file-type gate. - const source = await normalizeSource(opts.source, { - maxSize: Number.MAX_SAFE_INTEGER, - }); - - try { - const uploadResult = await dispatchUpload( - ctx, - scope, - opts.target.id, - KIND_TO_FILE_TYPE[opts.kind], - source, - c, - opts.fileName, - ); - - // Content is semantically meaningful only for image / video — the voice - // and file APIs ignore it. - const msgContent = opts.kind === "image" || opts.kind === "video" ? opts.content : undefined; - - // Uploads do not spend the reply budget; the following message POST does. - // Claim here so every media path and retry shares the text/typing ledger. - let msgId = opts.msgId; - if (msgId) { - const passive = claimMessageReply(msgId); - if (!passive.allowed) { - ctx.logger.warn?.( - `Passive media reply unavailable for ${scope}; falling back to proactive send: ${passive.message}`, - ); - msgId = undefined; - } - } - - const result = await ctx.mediaApi.sendMediaMessage( - scope, - opts.target.id, - uploadResult.file_info, - c, - { - msgId, - content: msgContent, - }, - ); - - notifyMediaHook(opts.creds.appId, result, buildOutboundMeta(opts, source)); - return result; - } finally { - if (source.kind === "localPath") { - await source.opened?.close().catch(() => undefined); - } - } -} - -/** - * Upload a {@link MediaSource} via the one-shot or chunked path, chosen by - * size + kind. - * - * Routing rules (kept here as the single source of truth so callers need - * not know which endpoint was used): - * - * - `url` / `base64`: always one-shot — the server accepts these directly - * and the chunked endpoint has no representation for them. - * - `localPath` / `buffer` with `size >= LARGE_FILE_THRESHOLD`: chunked. - * - Everything else: one-shot. - */ -async function dispatchUpload( - ctx: AccountContext, - scope: ChatScope, - targetId: string, - fileType: MediaFileType, - source: MediaSource, - creds: Credentials, - fileName?: string, -): Promise { - switch (source.kind) { - case "url": { - const buffer = await downloadDirectUploadUrl(source.url, { - maxBytes: getMaxUploadSize(fileType), - }); - if (buffer.length >= LARGE_FILE_THRESHOLD) { - return ctx.chunkedMediaApi.uploadChunked({ - scope, - targetId, - fileType, - source: { kind: "buffer", buffer, fileName }, - creds, - fileName, - }); - } - return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, { - buffer, - fileName, - }); - } - case "base64": - return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, { - fileData: source.data, - fileName, - }); - case "localPath": - if (source.size >= LARGE_FILE_THRESHOLD) { - return ctx.chunkedMediaApi.uploadChunked({ - scope, - targetId, - fileType, - source, - creds, - fileName, - }); - } - if (source.opened) { - return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, { - buffer: await source.opened.handle.readFile(), - fileName, - }); - } - return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, { - localPath: source.path, - fileName, - }); - case "buffer": - if (source.buffer.length >= LARGE_FILE_THRESHOLD) { - return ctx.chunkedMediaApi.uploadChunked({ - scope, - targetId, - fileType, - source, - creds, - fileName: fileName ?? source.fileName, - }); - } - return ctx.mediaApi.uploadMedia(scope, targetId, fileType, creds, { - buffer: source.buffer, - fileName: fileName ?? source.fileName, - }); - default: { - const exhaustive: never = source; - throw new Error( - `dispatchUpload: unsupported MediaSource kind: ${JSON.stringify(exhaustive)}`, - ); - } - } -} - -// ============ Helpers ============ - -/** Build a DeliveryTarget from event context fields. */ -export function buildDeliveryTarget(event: { - type: "c2c" | "guild" | "dm" | "group"; - senderId: string; - channelId?: string; - guildId?: string; - groupOpenid?: string; -}): DeliveryTarget { - switch (event.type) { - case "c2c": - return { type: "c2c", id: event.senderId }; - case "group": - return { type: "group", id: event.groupOpenid! }; - case "dm": - return { type: "dm", id: event.guildId! }; - default: - return { type: "channel", id: event.channelId! }; - } -} - -/** Build AccountCreds from a GatewayAccount. */ -export function accountToCreds(account: { appId: string; clientSecret: string }): AccountCreds { - return { appId: account.appId, clientSecret: account.clientSecret }; -} - -/** Check whether a target type supports rich media (C2C and Group only). */ -function supportsRichMedia(targetType: string): boolean { - return targetType === "c2c" || targetType === "group"; -} diff --git a/extensions/qqbot/src/engine/messaging/streaming-c2c.ts b/extensions/qqbot/src/engine/messaging/streaming-c2c.ts deleted file mode 100644 index e03377b02731..000000000000 --- a/extensions/qqbot/src/engine/messaging/streaming-c2c.ts +++ /dev/null @@ -1,1204 +0,0 @@ -/** - * QQ Bot Streaming Message Controller - * - * Core principles: - * 1. Never mutate original content (no trim, no strip) to avoid PREFIX MISMATCH. - * 2. Media tags are sent synchronously — wait for completion before proceeding. - * 3. When a rich-media tag (including an unclosed prefix) is encountered, - * terminate the active streaming session first, then handle the media. - * 4. Whitespace-only chunk handling: - * - First chunk is whitespace → defer sending (do not open a stream), but retain content. - * - Interrupted by a media tag or ended while still whitespace-only → skip sending. - * - Ended with an active streaming session (prior non-whitespace chunks exist) → send the whitespace chunk. - * 5. Reply boundary detection uses prefix matching (not just length reduction): - * if the new text is not a prefix continuation of the last processed text, - * it is treated as a new message. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { getNextMsgSeq } from "../api/routes.js"; -import type { GatewayAccount } from "../types.js"; -import { - StreamInputMode, - StreamInputState, - StreamContentType, - type MessageResponse, -} from "../types.js"; -import { normalizeMediaTags } from "../utils/media-tags.js"; -import { claimMessageReply } from "./outbound-reply.js"; -import type { OutboundMediaAccessContext } from "./outbound-types.js"; -import type { MediaTargetContext } from "./outbound.js"; -import { getMessageApi } from "./sender.js"; -import { - stripIncompleteMediaTag, - findFirstClosedMediaTag, - executeSendQueue, - type SendQueueItem, - type MediaSendContext, -} from "./streaming-media-send.js"; - -// ============ 常量 ============ - -/** 流式消息节流常量(毫秒) */ -const THROTTLE_CONSTANTS = { - /** 默认节流间隔 */ - DEFAULT_MS: 500, - /** 最小节流间隔 */ - MIN_MS: 300, - /** 长间隔阈值:超过此时间后的首次 flush 延迟处理 */ - LONG_GAP_THRESHOLD_MS: 2000, - /** 长间隔后的批处理窗口 */ - BATCH_AFTER_GAP_MS: 300, -} as const; - -/** 流式状态机阶段 */ -type StreamingPhase = "idle" | "streaming" | "completed" | "aborted"; - -/** 终态集合 */ -const TERMINAL_PHASES = new Set(["completed", "aborted"]); - -/** 允许的状态转换 */ -const PHASE_TRANSITIONS: Record> = { - idle: new Set(["streaming", "aborted"]), - streaming: new Set(["idle", "completed", "aborted"]), // idle: 首分片发送失败时可回退 - completed: new Set(), - aborted: new Set(), -}; - -// ============ FlushController ============ - -/** - * 节流刷新控制器(纯调度原语,不含业务逻辑) - */ -class FlushController { - private doFlush: () => Promise; - private flushInProgress = false; - private flushResolvers: Array<() => void> = []; - private needsReflush = false; - private pendingFlushTimer: ReturnType | null = null; - private lastUpdateTime = 0; - private isCompleted = false; - private isReady = false; - - constructor(doFlush: () => Promise) { - this.doFlush = doFlush; - } - - /** 标记为已完成 —— 当前 flush 之后不再调度新 flush */ - complete(): void { - this.isCompleted = true; - } - - /** 取消待执行的延迟 flush */ - cancelPendingFlush(): void { - if (this.pendingFlushTimer) { - clearTimeout(this.pendingFlushTimer); - this.pendingFlushTimer = null; - } - } - - /** 等待当前进行中的 flush 完成 */ - waitForFlush(): Promise { - if (!this.flushInProgress) { - return Promise.resolve(); - } - return new Promise((resolve) => { - this.flushResolvers.push(resolve); - }); - } - - /** 取消所有 pending timer + 等待正在执行的 flush 完成,确保 flush 活动彻底停止 */ - async cancelPendingAndWait(): Promise { - this.cancelPendingFlush(); - this.needsReflush = false; - await this.waitForFlush(); - // flush 完成后可能又触发了 reflush timer,再次清理 - this.cancelPendingFlush(); - this.needsReflush = false; - } - - /** 标记流式会话就绪(首次 API 调用成功后) */ - setReady(ready: boolean): void { - this.isReady = ready; - if (ready) { - this.lastUpdateTime = Date.now(); - } - } - - get ready(): boolean { - return this.isReady; - } - - /** 重置为初始状态(用于流式会话恢复) */ - reset(doFlush: () => Promise): void { - this.cancelPendingFlush(); - this.doFlush = doFlush; - this.flushInProgress = false; - this.flushResolvers = []; - this.needsReflush = false; - this.lastUpdateTime = 0; - this.isCompleted = false; - this.isReady = false; - } - - /** 执行一次 flush(互斥锁 + 冲突时 reflush) */ - async flush(): Promise { - if (!this.isReady || this.flushInProgress || this.isCompleted) { - if (this.flushInProgress && !this.isCompleted) { - this.needsReflush = true; - } - return; - } - - this.flushInProgress = true; - this.needsReflush = false; - this.lastUpdateTime = Date.now(); - - try { - await this.doFlush(); - this.lastUpdateTime = Date.now(); - } finally { - this.flushInProgress = false; - const resolvers = this.flushResolvers; - this.flushResolvers = []; - for (const resolve of resolvers) { - resolve(); - } - - // flush 期间有新事件到达 → 立即跟进 - if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) { - this.needsReflush = false; - this.pendingFlushTimer = setTimeout(() => { - this.pendingFlushTimer = null; - void this.flush(); - }, 0); - } - } - } - - /** 节流入口:根据 throttleMs 控制 flush 频率 */ - async throttledUpdate(throttleMs: number): Promise { - if (!this.isReady) { - return; - } - - const now = Date.now(); - const elapsed = now - this.lastUpdateTime; - - if (elapsed >= throttleMs) { - this.cancelPendingFlush(); - if (elapsed > THROTTLE_CONSTANTS.LONG_GAP_THRESHOLD_MS) { - // 长间隔后首次 flush 延迟,等待更多文本积累 - this.lastUpdateTime = now; - this.pendingFlushTimer = setTimeout(() => { - this.pendingFlushTimer = null; - void this.flush(); - }, THROTTLE_CONSTANTS.BATCH_AFTER_GAP_MS); - } else { - await this.flush(); - } - } else if (!this.pendingFlushTimer) { - // 在节流窗口内 → 延迟 flush - const delay = throttleMs - elapsed; - this.pendingFlushTimer = setTimeout(() => { - this.pendingFlushTimer = null; - void this.flush(); - }, delay); - } - } -} - -// ============ StreamingController ============ - -/** StreamingController 的依赖注入 */ -interface StreamingControllerDeps { - /** QQ Bot 账户配置 */ - account: GatewayAccount; - /** 目标用户 openid(流式 API 仅支持 C2C) */ - userId: string; - /** 被动回复的消息 ID */ - replyToMsgId: string; - /** 事件 ID */ - eventId: string; - /** 日志前缀 */ - logPrefix?: string; - /** 日志对象(直接传 gateway 的 log) */ - log?: { - info(msg: string): void; - error(msg: string): void; - warn?(msg: string): void; - debug?(msg: string): void; - }; - /** - * 媒体发送上下文(用于在流式模式下发送富媒体) - * 如果不提供,遇到媒体标签时会抛出错误导致 fallback - */ - mediaContext?: StreamingMediaContext; -} - -/** - * QQ Bot 流式消息控制器 - * - * 管理 C2C 流式消息的完整生命周期: - * 1. idle: 初始状态,等待首次文本 - * 2. streaming: 流式发送中,通过 API 逐步更新消息内容 - * 3. completed: 正常完成,已发送 input_state="10" - * 4. aborted: 中止(进程退出/错误) - * - * 富媒体标签处理流程: - * 当检测到富媒体标签时: - * 1. 将标签前的文本通过流式发完 → 结束当前流式会话 (input_state="10") - * 2. 同步等待媒体发送完成 - * 3. 创建新的流式会话 → 继续发送标签后的剩余文本 - */ -export class StreamingController { - // ---- 状态机 ---- - private phase: StreamingPhase = "idle"; - - // ---- 核心文本状态 ---- - /** - * 最后一次收到的完整 normalized 全量文本。 - * - onPartialReply 每次更新(回复边界时会拼接前缀) - * - performFlush 从 sentIndex 开始切片来获取当前会话的显示内容 - * - onIdle 校验时用于前缀匹配 - */ - private lastNormalizedFull = ""; - /** - * 最后一次收到的完整原始文本(未经 normalize)。 - * 仅用于回复边界检测——原始文本在 partial reply 过程中是稳定递增的, - * 不会因为 normalizeMediaTags 对未闭合标签的处理差异导致前缀不匹配。 - */ - private lastRawFull = ""; - /** - * 边界拼接前缀:检测到新回复时,将之前的全部内容 + "\n\n" 存为前缀。 - * 后续回调传入的 text 都会自动加上此前缀来还原完整文本。 - * 为 null 表示当前没有发生过边界拼接。 - */ - private boundaryPrefix: string | null = null; - /** - * 在 lastNormalizedFull 中已经"消费"到的位置。 - * "消费"包括:已通过流式发送并终结的文本段、已处理的媒体标签。 - * - 每次流式会话终结(endCurrentStreamIfNeeded)后推进到终结点 - * - 每次媒体标签处理后推进到标签结束位置 - * - resetStreamSession 后,新的流式会话从 sentIndex 开始 - */ - private sentIndex = 0; - - // ---- 流式会话 ---- - private streamMsgId: string | null = null; - /** 当前流式会话的 msg_seq,同一会话内所有 chunk 共享;null 表示需要重新生成 */ - private msgSeq: number | null = null; - private streamIndex = 0; - private dispatchFullyComplete = false; - - // ---- 串行队列:确保 onPartialReply / onIdle 严格按序执行 ---- - /** Promise 链,回调的实际逻辑都挂到链尾,保证串行 */ - private callbackChain: Promise = Promise.resolve(); - - // ---- 互斥:首个到达的回调锁定控制权 ---- - /** - * 记录首先到达的回调来源,后续其他来源的回调将被忽略。 - * - null: 尚未确定 - * - 非 null: 已锁定,只有相同来源的回调才允许继续执行 - */ - private firstCallbackSource: string | null = null; - - /** - * 尝试获取回调互斥锁。 - * - 尚未锁定 → 锁定为 source,返回 true - * - 已锁定且来源相同 → 返回 true - * - 已锁定且来源不同 → 返回 false(调用方应跳过) - */ - private acquireCallbackLock(source: string): boolean { - if (this.firstCallbackSource === null) { - this.firstCallbackSource = source; - this.logInfo(`acquireCallbackLock: locked to "${source}"`); - return true; - } - if (this.firstCallbackSource === source) { - return true; - } - this.logDebug( - `acquireCallbackLock: rejected "${source}" (locked by "${this.firstCallbackSource}")`, - ); - return false; - } - - // ---- 降级 ---- - /** 成功发送的流式分片数或媒体数(用于 onDeliver 互斥判断 + 降级判断) */ - private sentStreamChunkCount = 0; - /** 是否成功发送过至少一个媒体文件 */ - private sentMediaCount = 0; - - // ---- 启动锁 ---- - private startingPromise: Promise | null = null; - - // ---- 子控制器 ---- - private flush: FlushController; - - // ---- 配置 ---- - private throttleMs: number; - - // ---- 注入依赖 ---- - private deps: StreamingControllerDeps; - - constructor(deps: StreamingControllerDeps) { - this.deps = deps; - this.flush = new FlushController(() => this.performFlush()); - this.throttleMs = THROTTLE_CONSTANTS.DEFAULT_MS; - if (this.throttleMs < THROTTLE_CONSTANTS.MIN_MS) { - this.throttleMs = THROTTLE_CONSTANTS.MIN_MS; - } - } - - // ------------------------------------------------------------------ - // 公共访问器 - // ------------------------------------------------------------------ - - get isTerminalPhase(): boolean { - return TERMINAL_PHASES.has(this.phase); - } - - get currentPhase(): StreamingPhase { - return this.phase; - } - - /** - * 是否应降级到非流式(普通消息)发送 - * - * 条件:流式会话进入终态,且从未成功发出过任何一个流式分片或媒体 - */ - get shouldFallbackToStatic(): boolean { - return this.isTerminalPhase && this.sentStreamChunkCount === 0; - } - - /** debug 用:暴露发送计数给 gateway 日志 */ - get sentChunkCount_debug(): number { - return this.sentStreamChunkCount; - } - - // ------------------------------------------------------------------ - // 状态机 - // ------------------------------------------------------------------ - - private transition(to: StreamingPhase, source: string, reason?: string): boolean { - const from = this.phase; - if (from === to) { - return false; - } - if (!PHASE_TRANSITIONS[from].has(to)) { - this.logWarn(`phase transition rejected: ${from} → ${to} (source: ${source})`); - return false; - } - this.phase = to; - this.logInfo( - `phase: ${from} → ${to} (source: ${source}${reason ? `, reason: ${reason}` : ""})`, - ); - if (TERMINAL_PHASES.has(to)) { - this.onEnterTerminalPhase(); - } - return true; - } - - private onEnterTerminalPhase(): void { - this.flush.cancelPendingFlush(); - this.flush.complete(); - } - - private get prefix(): string { - return this.deps.logPrefix ?? "[qqbot:streaming]"; - } - - private logInfo(msg: string): void { - const m = `${this.prefix} ${msg}`; - const engineLog = this.deps.log; - if (engineLog) { - engineLog.info?.(m); - } else { - console.log(m); - } - } - private logError(msg: string): void { - const m = `${this.prefix} ${msg}`; - const engineLog = this.deps.log; - if (engineLog) { - engineLog.error?.(m); - } else { - console.error(m); - } - } - private logWarn(msg: string): void { - const m = `${this.prefix} ${msg}`; - const engineLog = this.deps.log; - if (engineLog) { - if (engineLog.warn) { - engineLog.warn(m); - } else { - engineLog.info?.(m); - } - } else { - console.warn(m); - } - } - private logDebug(msg: string): void { - const m = `${this.prefix} ${msg}`; - const engineLog = this.deps.log; - if (engineLog) { - engineLog.debug?.(m); - } else { - console.debug(m); - } - } - - // ------------------------------------------------------------------ - // SDK 回调绑定 - // ------------------------------------------------------------------ - - /** - * 处理 onPartialReply 回调(流式文本全量更新) - * - * ★ 通过 Promise 链严格串行化:前一次处理完成后才执行下一次, - * 避免并发交叉导致的状态不一致。 - * - * payload.text 是从头到尾的完整当前文本(每次回调都是全量)。 - * 核心逻辑:normalize → 更新 lastNormalizedFull → 从 sentIndex 开始 processMediaTags - */ - async onPartialReply(payload: { text?: string }): Promise { - if (this.isTerminalPhase) { - return false; - } - if (!payload.text) { - return false; - } - - // ★ 互斥锁在入口检查:如果已被 deliver 锁定,直接跳过,无需排队 - if (!this.acquireCallbackLock("partial")) { - return false; - } - - // 将实际逻辑挂到 Promise 链尾部,保证串行执行 - this.callbackChain = this.callbackChain.then( - () => this.handlePartialReply(payload), - (err: unknown) => { - // 上一次如果异常,不阻塞后续调用 - this.logError(`onPartialReply chain error: ${formatErrorMessage(err)}`); - return this.handlePartialReply(payload); - }, - ); - await this.callbackChain; - return this.sentStreamChunkCount > 0 || this.streamMsgId !== null; - } - - /** onPartialReply 的实际逻辑(由 callbackChain 保证串行调用) */ - private async handlePartialReply(payload: { text?: string }): Promise { - this.logDebug( - `onPartialReply: rawLen=${payload.text?.length ?? 0}, phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}, firstCB=${this.firstCallbackSource}`, - ); - if (this.isTerminalPhase) { - this.logDebug(`onPartialReply: skipped (terminal phase)`); - return; - } - - const text = payload.text ?? ""; - if (!text) { - this.logDebug(`onPartialReply: skipped (empty text)`); - return; - } - - // ★ 如果之前已发生过边界拼接,将前缀加上还原完整文本 - const fullText = this.boundaryPrefix !== null ? this.boundaryPrefix + text : text; - - // ★ 回复边界检测:用原始文本做前缀比较,避免 normalizeMediaTags 对未闭合标签 - // 的不稳定处理导致误判(normalize 后的文本在 partial reply 的不同阶段可能产生 - // 完全不同的结果,从而使 startsWith 始终失败,导致 boundary 被反复触发) - // 检测到新回复时,直接在之前内容后追加两个换行再拼接新内容,继续在同一流式会话中发送 - if (this.lastRawFull && fullText.length > 0 && !fullText.startsWith(this.lastRawFull)) { - this.logInfo( - `onPartialReply: reply boundary detected — raw prefix mismatch (new len=${fullText.length}, prev len=${this.lastRawFull.length}), appending with separator`, - ); - - // 记住拼接前缀:之前的全部内容 + "\n\n",后续回调的 text 都会自动加上此前缀 - this.boundaryPrefix = this.lastRawFull + "\n\n"; - const merged = this.boundaryPrefix + text; - this.lastRawFull = merged; - this.lastNormalizedFull = normalizeMediaTags(merged); - - await this.processMediaTags(this.lastNormalizedFull); - return; - } - - // 正常增长:更新原始文本和 normalize 后的文本 - this.lastRawFull = fullText; - this.lastNormalizedFull = normalizeMediaTags(fullText); - - // ★ 核心:从 sentIndex 开始,处理增量文本(串行队列保证不会并发进入) - await this.processMediaTags(this.lastNormalizedFull); - } - - /** - * 处理 deliver 回调 - * - * ★ 与 onPartialReply 互斥:首先到达的回调锁定控制权,后到的被忽略。 - */ - async onDeliver(payload: { text?: string }): Promise { - const rawLen = payload.text?.length ?? 0; - const preview = truncateUtf16Safe(payload.text ?? "", 60).replace(/\n/g, "\\n"); - this.logDebug( - `onDeliver: rawLen=${rawLen}, phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}, sentChunks=${this.sentStreamChunkCount}, firstCB=${this.firstCallbackSource}, preview="${preview}"`, - ); - if (this.isTerminalPhase) { - this.logDebug(`onDeliver: skipped (terminal phase)`); - return; - } - - const text = payload.text ?? ""; - if (!text.trim()) { - this.logDebug(`onDeliver: skipped (empty text)`); - return; - } - - // ★ 互斥锁 - if (!this.acquireCallbackLock("deliver")) { - return; - } - - this.logInfo(`onDeliver: deliver in control, falling back to static`); - this.transition("aborted", "onDeliver", "deliver_arrived_first_fallback_to_static"); - } - - /** - * 处理 onIdle 回调(分发完成时调用) - * - * ★ 挂到 callbackChain 上,保证在所有 onPartialReply 执行完之后才执行。 - * - * onIdle 会传入最终的全量文本。如果该文本**包含**之前存储的 lastNormalizedFull, - * 说明一致,继续处理剩余内容;否则忽略(防止 onIdle 修改文本导致的不一致)。 - */ - async onIdle(payload?: { text?: string }): Promise { - if (!this.dispatchFullyComplete) { - this.logDebug(`onIdle: skipped (dispatch not fully complete)`); - return; - } - if (this.isTerminalPhase) { - return; - } - - // 挂到串行队列尾部,等所有 onPartialReply 执行完再处理 - this.callbackChain = this.callbackChain.then( - () => this.handleIdle(payload), - (err: unknown) => { - this.logError(`onIdle chain error: ${formatErrorMessage(err)}`); - return this.handleIdle(payload); - }, - ); - return this.callbackChain; - } - - /** onIdle 的实际逻辑(由 callbackChain 保证在 onPartialReply 之后执行) */ - private async handleIdle(payload?: { text?: string }): Promise { - this.logDebug( - `onIdle: dispatchFullyComplete=${this.dispatchFullyComplete}, phase=${this.phase}, streamChunks=${this.sentStreamChunkCount}, mediaCount=${this.sentMediaCount}, sentIndex=${this.sentIndex}`, - ); - if (this.isTerminalPhase) { - this.logDebug(`onIdle: skipped (terminal phase)`); - return; - } - - // ★ onIdle 文本校验:如果传了文本,检查是否包含之前的全量文本 - if (payload?.text) { - const idleNormalized = normalizeMediaTags(payload.text); - if (idleNormalized.includes(this.lastNormalizedFull)) { - // onIdle 文本包含之前的全量 → 一致,使用 onIdle 的文本作为最终全量 - this.logDebug( - `onIdle: text contains lastNormalizedFull, updating (${this.lastNormalizedFull.length} → ${idleNormalized.length})`, - ); - this.lastNormalizedFull = idleNormalized; - } else if (this.lastNormalizedFull.includes(idleNormalized)) { - // 之前的全量包含 onIdle 文本 → onIdle 文本是子集,保留之前的 - this.logDebug(`onIdle: lastNormalizedFull contains idle text, keeping current`); - } else { - // 不一致 → 忽略 onIdle - this.logWarn( - `onIdle: text mismatch with lastNormalizedFull, ignoring onIdle (idle len=${idleNormalized.length}, last len=${this.lastNormalizedFull.length})`, - ); - // 虽然忽略文本处理,但仍需要终结当前流式会话 - await this.finalizeOnIdle(); - return; - } - } - - // ★ 处理 sentIndex 之后的剩余内容 - const remaining = this.lastNormalizedFull.slice(this.sentIndex); - if (remaining) { - const hasClosedTag = findFirstClosedMediaTag(remaining); - if (hasClosedTag) { - this.logDebug(`onIdle: unprocessed media tags in remaining text, processing now`); - await this.processMediaTags(this.lastNormalizedFull); - if (this.isTerminalPhase) { - return; - } - } - } - - await this.finalizeOnIdle(); - } - - /** - * onIdle 的终结逻辑:终结流式会话或标记完成/降级 - */ - private async finalizeOnIdle(): Promise { - // 等待正在进行的流式启动请求完成 - if (this.startingPromise) { - this.logDebug(`finalizeOnIdle: waiting for pending stream start`); - await this.startingPromise; - } - if (this.isTerminalPhase) { - return; - } - - // 等待所有 pending flush 完成 - await this.flush.waitForFlush(); - - // ---- 判断如何终结 ---- - if (this.streamMsgId) { - // 有活跃流式会话 → 发终结分片 - this.transition("completed", "onIdle", "normal"); - try { - // 当前会话的显示内容 = sentIndex 之后的纯文本(去掉未闭合标签) - const sessionText = this.lastNormalizedFull.slice(this.sentIndex); - const [safeText] = stripIncompleteMediaTag(sessionText); - this.logDebug(`finalizeOnIdle: sending DONE chunk, len=${safeText.length}`); - await this.sendStreamChunk(safeText, StreamInputState.DONE, "onIdle"); - this.logInfo(`streaming completed, final text length: ${safeText.length}`); - } catch (err) { - this.logError(`failed to send final stream chunk: ${formatErrorMessage(err)}`); - } - } else if (this.sentStreamChunkCount > 0) { - // 没有活跃流式会话,但之前发过流式分片或媒体 → 正常完成 - this.logInfo( - `finalizeOnIdle: no active stream session, but sent ${this.sentStreamChunkCount} chunks (including ${this.sentMediaCount} media), marking completed`, - ); - this.transition("completed", "onIdle", "no_active_session_but_sent"); - } else { - // 什么都没发过 → 降级 - this.logInfo(`no chunk or media sent, marking fallback to static`); - this.transition("aborted", "onIdle", "fallback_to_static_nothing_sent"); - } - } - - /** - * 处理错误 - */ - async onError(err: unknown): Promise { - this.logError(`reply error: ${formatErrorMessage(err)}`); - - if (this.isTerminalPhase) { - return; - } - - // 等待正在进行的流式启动请求完成 - if (this.startingPromise) { - this.logDebug(`onError: waiting for pending stream start`); - await this.startingPromise; - } - - if (this.isTerminalPhase) { - return; - } - - // 如果从未发出任何内容 → 降级 - if (this.sentStreamChunkCount === 0) { - this.logInfo(`no chunk or media sent, marking fallback to static for error handling`); - this.transition("aborted", "onError", "fallback_to_static_error"); - return; - } - - // 如果有活跃流式会话,发送错误终结分片 - if (this.streamMsgId) { - try { - const sessionText = this.lastNormalizedFull.slice(this.sentIndex); - const [safeText] = stripIncompleteMediaTag(sessionText); - const errorText = safeText - ? `${safeText}\n\n---\n**Error**: 生成响应时发生错误。` - : "**Error**: 生成响应时发生错误。"; - await this.sendStreamChunk(errorText, StreamInputState.DONE, "onError"); - } catch (sendErr) { - this.logError(`failed to send error stream chunk: ${formatErrorMessage(sendErr)}`); - } - } - - this.transition("completed", "onError", "error"); - await this.flush.waitForFlush(); - } - - // ------------------------------------------------------------------ - // 外部控制 - // ------------------------------------------------------------------ - - /** 标记分发已全部完成 */ - markFullyComplete(): void { - this.dispatchFullyComplete = true; - } - - /** 中止流式消息 */ - async abortStreaming(): Promise { - if (!this.transition("aborted", "abortStreaming", "abort")) { - return; - } - - await this.flush.waitForFlush(); - - if (this.streamMsgId) { - try { - const sessionText = this.lastNormalizedFull.slice(this.sentIndex); - const [safeText] = stripIncompleteMediaTag(sessionText); - const abortText = safeText || "(已中止)"; - await this.sendStreamChunk(abortText, StreamInputState.DONE, "abortStreaming"); - this.logInfo(`streaming aborted, sent final chunk`); - } catch (err) { - this.logError(`abort send failed: ${formatErrorMessage(err)}`); - } - } - } - - // ------------------------------------------------------------------ - // 内部:富媒体标签中断/恢复 - // ------------------------------------------------------------------ - - /** - * 处理富媒体标签(循环消费模型) - * - * 从 sentIndex 开始,对增量文本: - * 1. 优先找闭合标签 → 终结当前流式 → 同步发媒体 → 推进 sentIndex → reset → 继续 - * 2. 没有闭合标签但有未闭合前缀 → 标签前的安全文本仍需通过流式发送 → 推进 sentIndex → 等待标签闭合 - * 3. 纯文本 → 触发流式发送(performFlush 会动态计算要发的内容) - */ - private async processMediaTags(normalizedFull: string): Promise { - try { - // ---- 1. 循环消费所有已闭合的媒体标签 ---- - while (true) { - if (this.isTerminalPhase) { - return; - } - - const incremental = normalizedFull.slice(this.sentIndex); - const found = findFirstClosedMediaTag(incremental); - - if (!found) { - break; - } - - this.logInfo( - `processMediaTags: found <${found.tagName}> at offset ${this.sentIndex}, textBefore="${truncateUtf16Safe(found.textBefore, 40)}"`, - ); - - // ---- 1.1 终结当前流式会话(如果有的话) ---- - // endCurrentStreamIfNeeded 会用 sentIndex 到标签前文本结束的位置来发送终结分片 - // 先临时推进 sentIndex 到标签前文本结束的位置(用于终结分片的内容计算) - // 不,我们不需要推进——endCurrentStreamIfNeeded 发的是从 sentIndex 开始到当前文本前部分 - // 实际上需要把 textBefore 的内容加入到当前会话的显示范围 - // 终结时 performFlush/sendStreamChunk 用 lastNormalizedFull.slice(sentIndex) 中 textBefore 之前的部分 - // 但 endCurrentStreamIfNeeded 需要知道要发到哪里…… - - // 简化:计算标签前文本在全量中的结束位置 - const textBeforeEndInFull = this.sentIndex + found.textBefore.length; - - await this.endCurrentStreamIfNeeded("processMediaTags:closedTag", textBeforeEndInFull); - if (this.isTerminalPhase) { - return; - } - - // ---- 1.2 同步发送媒体文件 ---- - if (found.mediaPath && this.deps.mediaContext) { - const item: SendQueueItem = { type: found.itemType, content: found.mediaPath }; - this.logDebug( - `processMediaTags: sending ${found.itemType}: ${truncateUtf16Safe(found.mediaPath, 80)}`, - ); - await sendMediaQueue([item], this.deps.mediaContext); - this.sentMediaCount++; - this.sentStreamChunkCount++; - this.logDebug( - `processMediaTags: media sent, sentMediaCount=${this.sentMediaCount}, sentStreamChunkCount=${this.sentStreamChunkCount}`, - ); - } else if (found.mediaPath && !this.deps.mediaContext) { - this.logWarn(`processMediaTags: no mediaContext provided, cannot send ${found.itemType}`); - } - - // ---- 1.3 推进 sentIndex,重置流式状态 ---- - this.sentIndex += found.tagEndIndex; - this.logDebug(`processMediaTags: sentIndex updated to ${this.sentIndex}`); - this.resetStreamSession(); - } - - // ---- 循环结束:没有更多闭合标签 ---- - const remaining = normalizedFull.slice(this.sentIndex); - - if (!remaining) { - this.logDebug(`processMediaTags: no remaining text after media tags`); - return; - } - - // ---- 2. 检查是否有未闭合的标签前缀 ---- - const [safeText, hasIncomplete] = stripIncompleteMediaTag(remaining); - - if (hasIncomplete) { - this.logDebug( - `processMediaTags: incomplete tag detected, safe text len=${safeText.length}, remaining len=${remaining.length}`, - ); - // 不终结流式会话!继续正常流式发送安全文本部分(performFlush 中也有 - // stripIncompleteMediaTag 保护,会自动只发送安全部分)。 - // 等下次 onPartialReply 带来更多文本后,标签会闭合或被识别为非媒体标签。 - } - - // ---- 3. 文本 → 触发流式发送 ---- - // performFlush 会动态计算 lastNormalizedFull.slice(sentIndex) 的安全部分来发送 - this.logDebug( - `processMediaTags: ${hasIncomplete ? "incomplete tag, sending safe text" : "pure text"}, remaining len=${remaining.length}`, - ); - - if (!remaining.trim()) { - // 纯空白文本 → 不启动流式 - this.logDebug(`processMediaTags: pure whitespace, skipping stream start`); - return; - } - - await this.ensureStreamingStarted(normalizedFull.length); - if (this.isTerminalPhase) { - return; - } - await this.flush.throttledUpdate(this.throttleMs); - } catch (err) { - this.logError(`processMediaTags failed: ${formatErrorMessage(err)}`); - } - } - - /** - * 终结当前流式会话(如果有的话) - * - * @param caller 调用者标识(日志用) - * @param textEndInFull 本次终结需要发送到的全量文本位置(不含)。 - * 终结分片的内容 = lastNormalizedFull.slice(sentIndex, textEndInFull) - * - * 逻辑: - * - 有活跃 streamMsgId → 等待 flush 完成 → 发 DONE 分片终结 - * - 没有 streamMsgId 但有非空白文本 → 启动流式 → 立即终结 - * - 纯空白且无活跃流式 → 不发送 - */ - private async endCurrentStreamIfNeeded(caller: string, textEndInFull: number): Promise { - // 先等待启动完成 - if (this.startingPromise) { - this.logDebug(`${caller}: waiting for pending stream start`); - await this.startingPromise; - } - - // 停止所有 flush 活动 - await this.flush.cancelPendingAndWait(); - - // 计算当前会话要发的文本 - const sessionText = this.lastNormalizedFull.slice(this.sentIndex, textEndInFull); - const [safeText] = stripIncompleteMediaTag(sessionText); - - if (this.streamMsgId) { - // 有活跃流式会话 → 终结它 - try { - await this.sendStreamChunk(safeText, StreamInputState.DONE, caller); - this.logDebug(`${caller}: current stream session ended`); - } catch (err) { - this.logError(`${caller}: failed to end stream: ${formatErrorMessage(err)}`); - } - } else if (safeText && safeText.trim()) { - // 没有活跃流式会话,但有非空白文本未发送 → 启动流式 → 立即终结 - // 先临时存储到 pendingSessionText 以便 doStartStreaming 使用 - this.pendingSessionText = safeText; - await this.ensureStreamingStarted(textEndInFull); - this.pendingSessionText = null; - if (this.isTerminalPhase) { - return; - } - if (this.startingPromise) { - await this.startingPromise; - } - if (this.streamMsgId) { - try { - await this.sendStreamChunk(safeText, StreamInputState.DONE, caller); - this.logDebug(`${caller}: started and ended stream for pre-tag text`); - } catch (err) { - this.logError(`${caller}: failed to send pre-tag text: ${formatErrorMessage(err)}`); - } - } - } - // 如果纯空白且没有活跃流式 → 不发送 - } - - /** 临时存储 endCurrentStreamIfNeeded 需要立即发送的文本(用于 doStartStreaming) */ - private pendingSessionText: string | null = null; - - /** - * 重置流式会话状态(用于媒体中断后恢复) - * - * 只重置会话相关状态,不重置 sentIndex 和 dispatch 标记。 - * 新流式会话从当前 sentIndex 开始(performFlush 动态计算内容)。 - */ - private resetStreamSession(): void { - const prevPhase = this.phase; - this.phase = "idle"; - this.logDebug( - `phase: ${prevPhase} → idle (source: resetStreamSession, forced reset for media resume)`, - ); - this.streamMsgId = null; - this.streamIndex = 0; - this.msgSeq = null; - this.startingPromise = null; - this.flush.reset(() => this.performFlush()); - // 注意:不重置 sentIndex、lastNormalizedFull、dispatchFullyComplete、sentStreamChunkCount、sentMediaCount - } - - // ------------------------------------------------------------------ - // 内部:流式会话管理 - // ------------------------------------------------------------------ - - /** 确保流式会话已开始(首次调用创建;并发调用者会等待首次完成) */ - private async ensureStreamingStarted(textEndInFull: number): Promise { - if (this.streamMsgId || this.isTerminalPhase) { - return; - } - - if (this.startingPromise) { - this.logDebug(`ensureStreamingStarted: waiting for pending start request`); - await this.startingPromise; - return; - } - - if (!this.transition("streaming", "ensureStreamingStarted")) { - return; - } - - this.startingPromise = this.doStartStreaming(textEndInFull); - try { - await this.startingPromise; - } finally { - this.startingPromise = null; - } - } - - /** 实际执行流式启动逻辑 */ - private async doStartStreaming(textEndInFull: number): Promise { - try { - // 计算当前会话要发送的文本 - // 优先使用 pendingSessionText(endCurrentStreamIfNeeded 需要立即发送的文本) - // 否则使用调用处预先确定的 sentIndex → textEndInFull 范围 - const sessionText = - this.pendingSessionText ?? this.lastNormalizedFull.slice(this.sentIndex, textEndInFull); - const [safeText] = stripIncompleteMediaTag(sessionText); - - // 全空白文本 → 不开启流式,退回 idle - if (!safeText?.trim()) { - this.logDebug(`doStartStreaming: skipped (session text is empty or whitespace-only)`); - this.transition("idle", "doStartStreaming", "whitespace_only_text"); - return; - } - const firstText = safeText; - // A stream session is one passive reply: claim once before its first - // POST, then reuse the same msg_seq for all later chunks in the session. - const passive = claimMessageReply(this.deps.replyToMsgId); - if (!passive.allowed) { - this.logWarn(`stream budget unavailable; falling back to static delivery`); - this.transition("aborted", "doStartStreaming", "passive_budget_exhausted"); - return; - } - const resp = await this.sendStreamChunk( - firstText, - StreamInputState.GENERATING, - "doStartStreaming", - ); - - if (!resp.id) { - throw new Error(`Stream API returned no id: ${JSON.stringify(resp)}`); - } - - this.streamMsgId = resp.id; - this.flush.setReady(true); - this.logInfo(`stream started, stream_msg_id=${resp.id}`); - } catch (err) { - this.logError(`failed to start streaming: ${formatErrorMessage(err)}`); - this.transition("idle", "doStartStreaming", "start_failed_will_retry"); - } - } - - /** 发送一个流式分片(不做任何文本修改) */ - private async sendStreamChunk( - content: string, - inputState: StreamInputState, - caller: string, - ): Promise { - this.logDebug( - `sendStreamChunk: caller=${caller}, inputState=${inputState}, contentLen=${content.length}, streamMsgId=${this.streamMsgId}, index=${this.streamIndex}`, - ); - - // 同一流式会话内所有 chunk 共享同一个 msgSeq;新会话首次发送时生成 - if (this.msgSeq === null) { - this.msgSeq = getNextMsgSeq(this.deps.replyToMsgId); - } - const currentIndex = this.streamIndex++; - - const api = getMessageApi(this.deps.account.appId); - const creds = { - appId: this.deps.account.appId, - clientSecret: this.deps.account.clientSecret, - }; - const resp = await api.sendC2CStreamMessage(creds, this.deps.userId, { - input_mode: StreamInputMode.REPLACE, - input_state: inputState, - content_type: StreamContentType.MARKDOWN, - content_raw: content, - event_id: this.deps.eventId, - msg_id: this.deps.replyToMsgId, - stream_msg_id: this.streamMsgId ?? undefined, - msg_seq: this.msgSeq, - index: currentIndex, - }); - - // 分片发送成功 - this.sentStreamChunkCount++; - - return resp; - } - - // ------------------------------------------------------------------ - // 内部:flush 实现 - // ------------------------------------------------------------------ - - /** 执行一次实际的流式内容更新 */ - private async performFlush(): Promise { - this.logDebug( - `performFlush: phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}`, - ); - if (!this.streamMsgId || this.isTerminalPhase) { - this.logDebug( - `performFlush: skipped (streamMsgId=${this.streamMsgId}, terminal=${this.isTerminalPhase})`, - ); - return; - } - - // 动态计算当前会话要发送的文本 = 从 sentIndex 开始的增量 - const sessionText = this.lastNormalizedFull.slice(this.sentIndex); - if (!sessionText) { - this.logDebug(`performFlush: skipped (empty session text)`); - return; - } - - // 安全检查:确保不会把未闭合的媒体标签前缀发给用户 - const [safeText, hasIncomplete] = stripIncompleteMediaTag(sessionText); - if (hasIncomplete) { - this.logDebug( - `flush: detected incomplete media tag, sending safe text (${safeText.length}/${sessionText.length} chars)`, - ); - } - if (!safeText) { - this.logDebug(`performFlush: skipped (safeText empty after stripIncompleteMediaTag)`); - return; - } - - this.logDebug(`performFlush: sending chunk, safeText len=${safeText.length}`); - try { - await this.sendStreamChunk(safeText, StreamInputState.GENERATING, "performFlush"); - this.logDebug(`performFlush: chunk sent OK, sentStreamChunks=${this.sentStreamChunkCount}`); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.logError(`stream flush failed, will retry on next scheduled flush: ${msg}`); - } - } -} - -// ============ 辅助函数 ============ - -// ============ 流式媒体发送 ============ - -/** 流式媒体发送上下文(由 gateway 注入到 StreamingController) */ -interface StreamingMediaContext extends OutboundMediaAccessContext { - /** 账户信息 */ - account: GatewayAccount; - /** 事件信息 */ - event: { - type: "c2c" | "group" | "channel"; - senderId: string; - messageId: string; - groupOpenid?: string; - channelId?: string; - }; - /** 日志 */ - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -/** - * 将 StreamingMediaContext 转换为公共的 MediaSendContext - */ -function toMediaSendContext(ctx: StreamingMediaContext): MediaSendContext { - const { account, event, log } = ctx; - const mediaAccessContext: OutboundMediaAccessContext = { - ...(ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}), - ...(ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {}), - ...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}), - }; - - const mediaTarget: MediaTargetContext = { - targetType: event.type, - targetId: - event.type === "c2c" - ? event.senderId - : event.type === "group" - ? event.groupOpenid! - : event.channelId!, - account, - replyToId: event.messageId, - logPrefix: `[qqbot:${account.accountId}]`, - ...mediaAccessContext, - }; - - const qualifiedTarget = - event.type === "group" ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`; - - return { - mediaTarget, - qualifiedTarget, - account, - replyToId: event.messageId, - log, - ...mediaAccessContext, - }; -} - -/** - * 按顺序发送媒体队列中的所有项(流式场景专用) - */ -async function sendMediaQueue(queue: SendQueueItem[], ctx: StreamingMediaContext): Promise { - const sendCtx = toMediaSendContext(ctx); - - await executeSendQueue(queue, sendCtx, { - // 流式场景下跳过 inter-tag 文本(由新流式会话处理) - skipInterTagText: true, - }); -} - -// ============ 流式模式判断 ============ - -/** - * 是否对私聊走 QQ 官方 C2C `stream_messages` 流式 API。 - * - `streaming.nativeTransport: true` 启用;仅 C2C 场景生效。 - * - 旧的 `streaming: true` 布尔与 `c2cStreamApi` 键由 `openclaw doctor --fix` 迁移。 - */ -export function shouldUseOfficialC2cStream( - account: GatewayAccount, - targetType: "c2c" | "group" | "channel", -): boolean { - if (targetType !== "c2c") { - return false; - } - return account.config?.streaming?.nativeTransport === true; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qqbot/src/engine/messaging/streaming-media-send.ts b/extensions/qqbot/src/engine/messaging/streaming-media-send.ts deleted file mode 100644 index 05808fea369f..000000000000 --- a/extensions/qqbot/src/engine/messaging/streaming-media-send.ts +++ /dev/null @@ -1,561 +0,0 @@ -/** - * 富媒体标签解析与发送队列 - * - * 提供媒体标签(qqimg / qqvoice / qqvideo / qqfile / qqmedia)的检测、 - * 拆分、路径编码修复,以及统一的发送队列执行器。 - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { GatewayAccount } from "../types.js"; -import { normalizePath } from "../utils/platform.js"; -import type { OutboundMediaAccessContext } from "./outbound-types.js"; -import { - sendPhoto, - sendVoice, - sendVideoMsg, - sendDocument, - sendMedia as sendMediaAuto, - DEFAULT_MEDIA_SEND_ERROR, - resolveUserFacingMediaError, - type MediaTargetContext, -} from "./outbound.js"; -import { raceWithTimeout } from "./race-with-timeout.js"; - -// ============ 类型定义 ============ - -/** 发送队列项 */ -export interface SendQueueItem { - type: "text" | "image" | "voice" | "video" | "file" | "media"; - content: string; -} - -/** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */ -const MEDIA_TAG_REGEX = - /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi; - -/** 创建一个新的全局标签正则实例(每次调用 reset lastIndex) */ -function createMediaTagRegex(): RegExp { - return new RegExp(MEDIA_TAG_REGEX.source, MEDIA_TAG_REGEX.flags); -} - -/** 媒体发送上下文(统一的,供流式和普通模式共用) */ -export interface MediaSendContext extends OutboundMediaAccessContext { - /** 媒体目标上下文(用于 sendPhoto/sendVoice 等) */ - mediaTarget: MediaTargetContext; - /** qualifiedTarget(格式 "qqbot:c2c:xxx" 或 "qqbot:group:xxx",用于 sendMediaAuto) */ - qualifiedTarget: string; - /** 账户配置 */ - account: GatewayAccount; - /** 事件消息 ID(用于被动回复) */ - replyToId?: string; - /** 日志 */ - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -// ============ 路径编码修复 ============ - -/** - * 修复路径编码问题(双反斜杠、八进制转义、UTF-8 双重编码) - * - * 这是由于 LLM 输出路径时可能引入的编码问题: - * - Markdown 转义导致双反斜杠 - * - 八进制转义序列(来自某些 shell 工具的输出) - * - UTF-8 双重编码(中文路径经过多层处理后的乱码) - * - * 此方法在 gateway.ts deliver 回调、outbound.ts sendText、 - * streaming.ts sendMediaQueue 中共用。 - */ -function fixPathEncoding( - mediaPath: string, - log?: { debug?: (msg: string) => void; error?: (msg: string) => void }, -): string { - // 1. 双反斜杠 -> 单反斜杠(Markdown 转义) - let result = mediaPath.replace(/\\\\/g, "\\"); - - // Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt) - // where backslash-digit sequences like \1, \2 ... \7 are directory separators, - // not octal escape sequences. - const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\"); - // 2. 八进制转义序列 + UTF-8 双重编码修复 - try { - const hasOctal = /\\[0-7]{1,3}/.test(result); - const hasNonASCII = /[\u0080-\u00FF]/.test(result); - - if (!isWinLocal && (hasOctal || hasNonASCII)) { - log?.debug?.(`Decoding path with mixed encoding: ${result}`); - - // Step 1: 将八进制转义转换为字节 - const decoded = result.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => - String.fromCharCode(Number.parseInt(octal, 8)), - ); - - // Step 2: 提取所有字节(包括 Latin-1 字符) - const bytes: number[] = []; - for (let i = 0; i < decoded.length; i++) { - const code = decoded.charCodeAt(i); - if (code <= 0xff) { - bytes.push(code); - } else { - const charBytes = Buffer.from(decoded.charAt(i), "utf8"); - bytes.push(...charBytes); - } - } - - // Step 3: 尝试按 UTF-8 解码 - const buffer = Buffer.from(bytes); - const utf8Decoded = buffer.toString("utf8"); - - if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) { - result = utf8Decoded; - log?.debug?.(`Successfully decoded path: ${result}`); - } - } - } catch (decodeErr) { - log?.error?.(`Path decode error: ${formatErrorMessage(decodeErr)}`); - } - - return result; -} - -// ============ 代码块检测 ============ - -/** - * 判断文本中给定位置是否处于围栏代码块内(``` 块)。 - * - * 围栏代码块:行首 ``` 开始,到下一个行首 ``` 结束(或文本末尾) - * - * @param text 完整文本 - * @param position 要检测的位置(字符索引) - * @returns 如果 position 在围栏代码块内返回 true - */ -function isInsideCodeBlock(text: string, position: number): boolean { - const fenceRegex = /^(`{3,})[^\n]*$/gm; - let fenceMatch: RegExpExecArray | null; - let openFence: { pos: number; ticks: number } | null = null; - - while ((fenceMatch = fenceRegex.exec(text)) !== null) { - const ticksText = fenceMatch[1]; - if (ticksText === undefined) { - continue; - } - const ticks = ticksText.length; - if (!openFence) { - openFence = { pos: fenceMatch.index, ticks }; - } else if (ticks >= openFence.ticks) { - // 闭合围栏 - if (position >= openFence.pos && position < fenceMatch.index + fenceMatch[0].length) { - return true; - } - openFence = null; - } - } - // 未闭合的围栏一直延伸到文本末尾 - if (openFence && position >= openFence.pos) { - return true; - } - - return false; -} - -// ============ 媒体标签解析 ============ - -/** findFirstClosedMediaTag 的返回值 */ -interface FirstClosedMediaTag { - /** 标签前的纯文本 */ - textBefore: string; - /** 标签类型(小写,如 "qqvoice") */ - tagName: string; - /** 标签内的媒体路径(已 trim、修复编码) */ - mediaPath: string; - /** 标签在输入文本中的结束索引(紧接标签后的第一个字符位置) */ - tagEndIndex: number; - /** 映射后的发送队列项类型 */ - itemType: SendQueueItem["type"]; -} - -/** - * 在文本中查找**第一个**完整闭合的媒体标签 - * - * 只匹配一个标签就停止,用于流式场景的"循环消费"模式: - * 每次处理一个标签,更新偏移,再找下一个。 - * - * @param text 待检查的文本(应已 normalize 过) - * @returns 第一个闭合标签的信息,没有则返回 null - */ -export function findFirstClosedMediaTag( - text: string, - log?: { - info?: (msg: string) => void; - debug?: (msg: string) => void; - error?: (msg: string) => void; - }, -): FirstClosedMediaTag | null { - const regex = createMediaTagRegex(); - let match: RegExpExecArray | null; - - while ((match = regex.exec(text)) !== null) { - // 跳过代码块内的媒体标签 - if (isInsideCodeBlock(text, match.index)) { - log?.debug?.( - `findFirstClosedMediaTag: skipping <${match[1]}> at index ${match.index} (inside code block)`, - ); - continue; - } - - const textBefore = text.slice(0, match.index); - const rawTagName = match[1]; - if (rawTagName === undefined) { - continue; - } - const tagName = rawTagName.toLowerCase(); - let mediaPath = match[2]?.trim() ?? ""; - - mediaPath = normalizePath(mediaPath); - mediaPath = fixPathEncoding(mediaPath, log); - - const typeMap: Record = { - qqimg: "image", - qqvoice: "voice", - qqvideo: "video", - qqfile: "file", - qqmedia: "media", - }; - - return { - textBefore, - tagName, - mediaPath, - tagEndIndex: match.index + match[0].length, - itemType: typeMap[tagName] ?? "image", - }; - } - - return null; -} - -// ============ 发送队列执行 ============ - -/** - * 统一执行发送队列 - * - * 遍历 sendQueue,按类型调用对应的发送函数。 - * 文本项通过 onSendText 回调处理(不同场景的文本发送方式不同)。 - * 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。 - */ -export async function executeSendQueue( - queue: SendQueueItem[], - ctx: MediaSendContext, - options: { - /** 文本发送回调(每种场景的文本发送方式不同) */ - onSendText?: (text: string) => Promise; - /** 是否跳过 inter-tag 文本(流式模式下通常跳过,由新流式会话处理) */ - skipInterTagText?: boolean; - } = {}, -): Promise { - const { - mediaTarget, - qualifiedTarget, - account, - replyToId, - log, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - } = ctx; - const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`; - - /** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */ - const sendFallbackText = async (errorMsg: string): Promise => { - if (!options.onSendText) { - log?.info(`${prefix} executeSendQueue: no onSendText handler, cannot send fallback text`); - return; - } - try { - await options.onSendText(errorMsg); - } catch (fallbackErr) { - log?.error( - `${prefix} executeSendQueue: fallback text send failed: ${formatErrorMessage(fallbackErr)}`, - ); - } - }; - - for (const item of queue) { - try { - if (item.type === "text") { - if (options.skipInterTagText) { - log?.info( - `${prefix} executeSendQueue: skipping inter-tag text (${item.content.length} chars)`, - ); - continue; - } - if (options.onSendText) { - await options.onSendText(item.content); - } else { - log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`); - } - continue; - } - - log?.info( - `${prefix} executeSendQueue: sending ${item.type}: ${truncateUtf16Safe(item.content, 80)}...`, - ); - - if (item.type === "image") { - const result = await sendPhoto(mediaTarget, item.content); - if (result.error) { - log?.error(`${prefix} sendPhoto error: ${result.error}`); - await sendFallbackText(resolveUserFacingMediaError(result)); - } - } else if (item.type === "voice") { - const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats; - const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; - const voiceTimeout = 45_000; - try { - const result = await raceWithTimeout( - () => sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled), - voiceTimeout, - () => ({ channel: "qqbot", error: "语音发送超时,已跳过" }), - ); - if (result.error) { - log?.error(`${prefix} sendVoice error: ${result.error}`); - await sendFallbackText(resolveUserFacingMediaError(result)); - } - } catch (err) { - log?.error(`${prefix} sendVoice unexpected error: ${formatErrorMessage(err)}`); - await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR); - } - } else if (item.type === "video") { - const result = await sendVideoMsg(mediaTarget, item.content); - if (result.error) { - log?.error(`${prefix} sendVideoMsg error: ${result.error}`); - await sendFallbackText(resolveUserFacingMediaError(result)); - } - } else if (item.type === "file") { - const result = await sendDocument(mediaTarget, item.content); - if (result.error) { - log?.error(`${prefix} sendDocument error: ${result.error}`); - await sendFallbackText(resolveUserFacingMediaError(result)); - } - } else if (item.type === "media") { - const result = await sendMediaAuto({ - to: qualifiedTarget, - text: "", - mediaUrl: item.content, - accountId: account.accountId, - replyToId, - account, - ...(mediaAccess ? { mediaAccess } : {}), - ...(mediaLocalRoots ? { mediaLocalRoots } : {}), - ...(mediaReadFile ? { mediaReadFile } : {}), - }); - if (result.error) { - log?.error(`${prefix} sendMedia(auto) error: ${result.error}`); - await sendFallbackText(resolveUserFacingMediaError(result)); - } - } - } catch (err) { - log?.error( - `${prefix} executeSendQueue: failed to send ${item.type}: ${formatErrorMessage(err)}`, - ); - await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR); - } - } -} - -/** - * 检测文本中是否有未闭合的媒体标签,如果有则截断到安全位置。 - * - * 流式输出中 LLM 逐 token 吐出媒体标签,中间态不应直接发给用户。 - * 只检查最后一行,从右到左扫描 `<`,找到第一个有意义的媒体标签片段并判断是否完整。 - * - * 核心原则:截断只能截到**开标签**前面;闭合标签前缀若找不到对应开标签则原样返回。 - */ -export function stripIncompleteMediaTag(text: string): [safeText: string, hasIncomplete: boolean] { - if (!text) { - return [text, false]; - } - - const lastNL = text.lastIndexOf("\n"); - const lastLine = lastNL === -1 ? text : text.slice(lastNL + 1); - if (!lastLine) { - return [text, false]; - } // 以换行结尾,安全 - - const lineStart = lastNL === -1 ? 0 : lastNL + 1; - - // ---- 媒体标签名判断 ---- - const MEDIA_NAMES = [ - "qq", - "img", - "image", - "pic", - "photo", - "voice", - "audio", - "video", - "file", - "doc", - "media", - "attach", - "send", - "document", - "picture", - "qqvoice", - "qqaudio", - "qqvideo", - "qqimg", - "qqimage", - "qqfile", - "qqpic", - "qqphoto", - "qqmedia", - "qqattach", - "qqsend", - "qqdocument", - "qqpicture", - ]; - const isMedia = (n: string) => MEDIA_NAMES.includes(n.toLowerCase()); - const couldBeMedia = (n: string) => { - const l = n.toLowerCase(); - return MEDIA_NAMES.some((m) => m.startsWith(l)); - }; - - /** 截断到 lastLine 中位置 pos 之前,返回 [safe, true] */ - const cutAt = (pos: number): [string, true] => [text.slice(0, lineStart + pos).trimEnd(), true]; - - /** 检查 lastLine 中位置 pos 处的媒体开标签后面是否有完整闭合标签 */ - const hasClosingAfter = (pos: number, name: string): boolean => { - const rest = lastLine.slice(pos + 1); // < 之后 - const gt = rest.search(/[>>]/); - if (gt < 0) { - return false; - } - const after = rest.slice(gt + 1); - return new RegExp(`[<\uFF1C]/${name}\\s*[>\uFF1E]`, "i").test(after); - }; - - // ---- 回溯状态 ---- - // 遇到不完整的闭合标签/孤立 < 时,记录并继续往左找对应的开标签 - let searchTag: string | null = null; // 要找的开标签名,"*" = 来自孤立 < - let searchIsClosing = false; // 触发回溯的是闭合类(= 0; i--) { - const ch = lastLine.charAt(i); - if (ch !== "<" && ch !== "\uFF1C") { - continue; - } - - const after = lastLine.slice(i + 1); - const isClosing = after.startsWith("/"); - const nameStr = isClosing ? after.slice(1) : after; - const nameMatch = nameStr.match(/^(\w+)/); - - // ======== 回溯模式:正在找对应的开标签 ======== - if (searchTag) { - if (!nameMatch || isClosing) { - continue; - } - const candidateName = nameMatch[1]; - if (candidateName === undefined) { - continue; - } - const cand = candidateName.toLowerCase(); - if (!isMedia(cand)) { - continue; - } - // 跳过已有完整闭合对的开标签 - if (hasClosingAfter(i, cand)) { - continue; - } - - if (searchTag === "*") { - return cutAt(i); // 通配:任何未闭合的媒体开标签都匹配 - } - // 精确/前缀匹配(闭合标签名可能不完整,如 >]/.test(restAfterName); - - // --- 不是媒体标签(也不是前缀) --- - if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT)) { - continue; - } - - // --- 标签未闭合(无 >),还在输入中 --- - if (!hasGT) { - if (isClosing) { - // 不完整闭合标签(如 ,是完整的 --- - if (isClosing) { - return [text, false]; - } // 完整闭合标签 → 安全 - - // 完整开标签 ,检查后面有无对应 - if (hasClosingAfter(i, tag)) { - return [text, false]; - } - return cutAt(i); // 无闭合 → 截断 - } - - // ---- 循环结束,处理回溯未命中 ---- - if (searchTag) { - if (!searchIsClosing) { - // 来自孤立 <,前面没有媒体开标签 → 截断到那个 < 前面 - return cutAt(fallbackPos); - } - // 来自闭合类( { - it.each([ - { to: "qqbot:C2C:OpenIdCase", expected: { type: "c2c", id: "OpenIdCase" } }, - { to: "QQBOT:Group:GroupOpenId", expected: { type: "group", id: "GroupOpenId" } }, - { to: "CHANNEL:ChannelId", expected: { type: "channel", id: "ChannelId" } }, - ])("parses $to without changing identifier bytes", ({ to, expected }) => { - expect(parseTarget(to)).toEqual(expected); - }); - - it("defaults bare IDs to c2c", () => { - expect(parseTarget("bare-openid")).toEqual({ type: "c2c", id: "bare-openid" }); - }); - - it("rejects type prefixes with empty IDs regardless of case", () => { - expect(() => parseTarget("qqbot:c2c:")).toThrow(/missing user ID/); - expect(() => parseTarget("qqbot:Group:")).toThrow(/missing group ID/); - expect(() => parseTarget("CHANNEL:")).toThrow(/missing channel ID/); - expect(() => parseTarget("qqbot:")).toThrow(/empty ID/); - }); -}); - -describe("normalizeTarget", () => { - it.each([ - ["qqbot:Group:GroupOpenId", "qqbot:group:GroupOpenId"], - ["C2C:OpenId", "qqbot:c2c:OpenId"], - ["qqbot:channel:ChannelId", "qqbot:channel:ChannelId"], - ["qqbot:0123456789abcdef0123456789abcdef", "qqbot:c2c:0123456789abcdef0123456789abcdef"], - [ - "QQBOT:01234567-89ab-cdef-0123-456789abcdef", - "qqbot:c2c:01234567-89ab-cdef-0123-456789abcdef", - ], - ])("normalizes %s to %s", (to, normalized) => { - expect(looksLikeQQBotTarget(to)).toBe(true); - expect(normalizeTarget(to)).toBe(normalized); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/target-parser.ts b/extensions/qqbot/src/engine/messaging/target-parser.ts deleted file mode 100644 index a98048056e3a..000000000000 --- a/extensions/qqbot/src/engine/messaging/target-parser.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * QQ Bot target address parser — parse "qqbot:c2c:xxx" style addresses - * into structured delivery targets. - * - * All functions are **pure** (no side effects, no I/O), making them easy - * to test and safe to share between the built-in and standalone versions. - */ - -/** Supported target types. */ -type TargetType = "c2c" | "group" | "channel"; - -/** Parsed delivery target. */ -interface ParsedTarget { - type: TargetType; - id: string; -} - -const TYPED_TARGET_RE = /^(c2c|group|channel):/i; - -function parseTypedTarget(value: string): ParsedTarget | undefined { - const match = TYPED_TARGET_RE.exec(value); - if (!match?.[1]) { - return undefined; - } - return { - type: match[1].toLowerCase() as TargetType, - id: value.slice(match[0].length), - }; -} - -/** - * Parse a qqbot target string into a structured delivery target. - * - * Supported formats: - * - `qqbot:c2c:openid` → C2C direct message - * - `qqbot:group:groupid` → Group message - * - `qqbot:channel:channelid` → Channel message - * - `c2c:openid` → C2C (without qqbot: prefix) - * - `group:groupid` → Group (without qqbot: prefix) - * - `channel:channelid` → Channel (without qqbot: prefix) - * - `openid` → C2C (bare openid, default) - * - * @param to - Raw target string. - * @returns Parsed target with type and id. - * @throws {Error} When the target format is invalid. - */ -export function parseTarget(to: string): ParsedTarget { - const id = to.replace(/^qqbot:/i, ""); - const typedTarget = parseTypedTarget(id); - if (typedTarget) { - if (!typedTarget.id) { - const idKind = typedTarget.type === "c2c" ? "user" : typedTarget.type; - throw new Error(`Invalid ${typedTarget.type} target format: ${to} - missing ${idKind} ID`); - } - return typedTarget; - } - - if (!id) { - throw new Error(`Invalid target format: ${to} - empty ID after removing qqbot: prefix`); - } - - // Default to C2C when no type prefix is present. - return { type: "c2c", id }; -} - -/** - * Normalize a QQ Bot target string into the canonical `qqbot:...` form. - * - * Returns `undefined` when the target does not look like a QQ Bot address. - */ -export function normalizeTarget(target: string): string | undefined { - const id = target.replace(/^qqbot:/i, ""); - const typedTarget = parseTypedTarget(id); - if (typedTarget) { - return `qqbot:${typedTarget.type}:${typedTarget.id}`; - } - // 32-char hex openid - if (/^[0-9a-fA-F]{32}$/.test(id)) { - return `qqbot:c2c:${id}`; - } - // UUID-format openid - if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) { - return `qqbot:c2c:${id}`; - } - return undefined; -} - -/** - * Return true when the string looks like a QQ Bot target ID. - */ -export function looksLikeQQBotTarget(id: string): boolean { - return normalizeTarget(id) !== undefined; -} diff --git a/extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts b/extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts deleted file mode 100644 index d3e9e5b95123..000000000000 --- a/extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Qqbot tests cover trusted outbound media-path root resolution. -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; -import { afterEach, describe, expect, it } from "vitest"; -import { resolveOutboundMediaPath } from "./outbound-media-send.js"; -import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js"; - -const cleanupPaths: string[] = []; - -afterEach(() => { - while (cleanupPaths.length > 0) { - const target = cleanupPaths.pop(); - if (target) { - fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); - } - } -}); - -function makeTtsStyleVoiceFile(): string { - // Mirrors cron auto-TTS: the TTS runtime writes the voice file under the preferred - // OpenClaw temp root, which is outside the QQ Bot media storage tree. - const tmpRoot = resolvePreferredOpenClawTmpDir(); - const ttsDir = makeTrackedDir(tmpRoot, "tts-"); - const voicePath = path.join(ttsDir, "voice-123.mp3"); - fs.writeFileSync(voicePath, "audio"); - return voicePath; -} - -function makeTrackedDir(parentDir: string, prefix: string): string { - const dir = path.join(parentDir, `${prefix}${randomUUID()}`); - fs.mkdirSync(dir); - cleanupPaths.push(dir); - return dir; -} - -describe("resolveTrustedOutboundMediaPath", () => { - it("trusts framework media under OpenClaw's hardened temp root", () => { - const voicePath = makeTtsStyleVoiceFile(); - expect(resolveTrustedOutboundMediaPath(voicePath)).toBe(fs.realpathSync(voicePath)); - }); - - it("rejects local media outside every trusted root", () => { - const outsideDir = makeTrackedDir(os.tmpdir(), "qq-out-of-root-"); - const strayPath = path.join(outsideDir, "stray.mp3"); - fs.writeFileSync(strayPath, "audio"); - - expect(resolveTrustedOutboundMediaPath(strayPath)).toBeNull(); - }); - - it("accepts a not-yet-flushed temp file only when allowMissing is set", () => { - const tmpRoot = resolvePreferredOpenClawTmpDir(); - const ttsDir = makeTrackedDir(tmpRoot, "tts-pending-"); - const pendingPath = path.join(ttsDir, "voice-pending.mp3"); - - expect(resolveTrustedOutboundMediaPath(pendingPath)).toBeNull(); - expect(resolveTrustedOutboundMediaPath(pendingPath, { allowMissing: true })).not.toBeNull(); - }); -}); - -describe("resolveOutboundMediaPath", () => { - it("resolves a cron/TTS voice file under the temp root end to end", () => { - // Both the initial resolve and the voice send re-check funnel through - // resolveTrustedOutboundMediaPath, so this gate now passes for temp media. - const voicePath = makeTtsStyleVoiceFile(); - const resolved = resolveOutboundMediaPath(voicePath, "voice", { - allowMissingLocalPath: true, - }); - - expect(resolved.ok).toBe(true); - expect(resolved.ok && resolved.mediaPath).toBe(fs.realpathSync(voicePath)); - }); -}); diff --git a/extensions/qqbot/src/engine/messaging/trusted-media-path.ts b/extensions/qqbot/src/engine/messaging/trusted-media-path.ts deleted file mode 100644 index b48e8a9d2d7c..000000000000 --- a/extensions/qqbot/src/engine/messaging/trusted-media-path.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; -import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; -import { resolveQQBotPayloadLocalFilePath } from "../utils/platform.js"; - -// The temp root is process-stable, so resolve it once. Only the success value is -// cached: a transient provisioning failure returns null without poisoning later -// calls. -let cachedTrustedTmpRoot: string | undefined; -function trustedOpenClawTmpRoot(): string | null { - if (cachedTrustedTmpRoot === undefined) { - try { - cachedTrustedTmpRoot = resolvePreferredOpenClawTmpDir(); - } catch { - return null; - } - } - return cachedTrustedTmpRoot; -} - -/** - * Resolve a local outbound media path against every trusted root, returning the - * canonical path or null when it sits outside all of them. - * - * QQBot is the only channel that root-sandboxes outbound local files, and the - * same check runs at three sites (`resolveOutboundMediaPath`, the voice send - * re-check, and structured-payload validation), so they must all agree or a file - * accepted at one gate is rejected at the next. Beyond the QQ Bot media storage - * roots, this also trusts OpenClaw's permission-hardened temp root, where - * framework scratch media is written (e.g. cron auto-TTS voice files). Core - * already treats that temp root as a sanctioned media root (`buildMediaLocalRoots`); - * without it here, auto-routed sends are dropped and cron delivery silently loses - * the message. - * - * `allowMissing` lets callers accept a not-yet-flushed temp file (e.g. TTS still - * writing) under the temp root; existence is then enforced later by the voice - * send re-check before upload. - */ -export function resolveTrustedOutboundMediaPath( - p: string, - options: { allowMissing?: boolean } = {}, -): string | null { - const storageRootPath = resolveQQBotPayloadLocalFilePath(p); - if (storageRootPath) { - return storageRootPath; - } - - const tmpRoot = trustedOpenClawTmpRoot(); - if (!tmpRoot) { - return null; - } - return ( - resolveLocalPathFromRootsSync({ - filePath: p, - roots: [tmpRoot], - label: "OpenClaw temp media root", - allowMissing: options.allowMissing === true, - })?.path ?? null - ); -} diff --git a/extensions/qqbot/src/engine/object-record.ts b/extensions/qqbot/src/engine/object-record.ts deleted file mode 100644 index c78f59c7dbcf..000000000000 --- a/extensions/qqbot/src/engine/object-record.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; - -/** Reads QQBot config objects, including array-backed legacy values. */ -export function readQqbotObjectRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" ? asRecord(value) : undefined; -} diff --git a/extensions/qqbot/src/engine/ref/format-message-ref.ts b/extensions/qqbot/src/engine/ref/format-message-ref.ts deleted file mode 100644 index cc4d0f820bf6..000000000000 --- a/extensions/qqbot/src/engine/ref/format-message-ref.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Format a message_reference (from msg_elements[0]) into text for model context. - * - * This handles the cache-miss path: when a user quotes a message we haven't - * cached in the ref-index store, we fall back to the msg_elements[0] data - * pushed by the QQ platform. - * - * The heavy lifting (attachment download, STT, etc.) is delegated to an - * injected `AttachmentProcessor` so this module stays framework-agnostic. - */ - -import type { EngineLogger } from "../types.js"; -import { parseFaceTags, buildAttachmentSummaries } from "../utils/text-parsing.js"; -import { formatRefEntryForAgent } from "./format-ref-entry.js"; -import type { RefAttachmentSummary } from "./types.js"; - -// ============ Injected dependency ============ - -/** Attachment download & voice transcription — injected from the outer layer. */ -export interface AttachmentProcessor { - processAttachments( - attachments: - | Array<{ - content_type: string; - url: string; - filename?: string; - height?: number; - width?: number; - size?: number; - voice_wav_url?: string; - asr_refer_text?: string; - }> - | undefined, - ctx: { appId: string; peerId?: string; cfg: unknown; log?: EngineLogger }, - ): Promise<{ - attachmentInfo: string; - voiceTranscripts: string[]; - voiceTranscriptSources: string[]; - attachmentLocalPaths: Array; - }>; - - formatVoiceText(voiceTranscripts: string[]): string; -} - -// ============ Public API ============ - -/** - * Format a quoted message reference into human-readable text for model context. - * - * This mirrors the independent version's `formatMessageReferenceForAgent` — - * processing attachments (download + STT) and combining them with parsed text. - * - * @param ref - The msg_elements[0] data from the QQ push event. - * @param ctx - Context containing appId, peerId, config, and logger. - * @param processor - Injected attachment processor (download + voice transcription). - */ -export async function formatMessageReferenceForAgent( - ref: - | { - content?: string; - attachments?: Array<{ - content_type: string; - url: string; - filename?: string; - height?: number; - width?: number; - size?: number; - voice_wav_url?: string; - asr_refer_text?: string; - }>; - } - | undefined, - ctx: { - appId: string; - peerId?: string; - cfg: unknown; - log?: EngineLogger; - }, - processor: AttachmentProcessor, -): Promise { - if (!ref) { - return ""; - } - - // Process attachments (download images, transcribe voice, etc.) - const processed = await processor.processAttachments(ref.attachments, ctx); - const { attachmentInfo, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = - processed; - - // Format voice transcript text - const voiceText = processor.formatVoiceText(voiceTranscripts); - - // Parse QQ face tags into readable text - const parsedContent = parseFaceTags(ref.content ?? ""); - - // Combine text content with voice transcript and attachment info - const userContent = voiceText - ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo - : parsedContent + attachmentInfo; - - // Build attachment summaries and inject voice transcripts - const attSummaries = buildAttachmentSummaries( - ref.attachments as Array<{ - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - }>, - attachmentLocalPaths, - ); - if (attSummaries && voiceTranscripts.length > 0) { - let voiceIdx = 0; - for (const att of attSummaries) { - if (att.type === "voice" && voiceIdx < voiceTranscripts.length) { - att.transcript = voiceTranscripts[voiceIdx]; - if (voiceIdx < voiceTranscriptSources.length) { - att.transcriptSource = voiceTranscriptSources[ - voiceIdx - ] as RefAttachmentSummary["transcriptSource"]; - } - voiceIdx++; - } - } - } - - // Format using the same function as the cache-hit path - const refEntry = { - content: userContent.trim(), - senderId: "", - timestamp: Date.now(), - attachments: attSummaries, - }; - - const formattedAttachments = formatRefEntryForAgent(refEntry); - // If formatRefEntryForAgent already includes the content, use it directly. - // Otherwise combine manually. - if (formattedAttachments !== "[empty message]") { - return formattedAttachments; - } - - return userContent.trim() || ""; -} diff --git a/extensions/qqbot/src/engine/ref/format-ref-entry.test.ts b/extensions/qqbot/src/engine/ref/format-ref-entry.test.ts deleted file mode 100644 index 4c44cbc8ac88..000000000000 --- a/extensions/qqbot/src/engine/ref/format-ref-entry.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Qqbot tests cover format ref entry plugin behavior. -import { describe, expect, it } from "vitest"; -import { formatRefEntryForAgent } from "./format-ref-entry.js"; -import type { RefIndexEntry } from "./types.js"; - -function makeEntry(overrides: Partial = {}): RefIndexEntry { - return { - content: "hello", - senderId: "user-1", - timestamp: 1, - ...overrides, - }; -} - -describe("engine/ref/format-ref-entry", () => { - it("formats text and attachment hints for model context", () => { - const formatted = formatRefEntryForAgent( - makeEntry({ - content: "see these", - attachments: [ - { - type: "image", - filename: "photo.png", - localPath: "/tmp/photo.png", - }, - { - type: "voice", - transcript: "spoken words", - transcriptSource: "asr", - url: "https://example.test/voice.amr", - }, - { - type: "file", - filename: "notes.txt", - }, - ], - }), - ); - - expect(formatted).toBe( - 'see these [image: /tmp/photo.png] [voice: https://example.test/voice.amr] (transcript: "spoken words") [source: platform ASR] [file: notes.txt]', - ); - }); - - it("keeps voice attachments visible when no transcript exists", () => { - expect( - formatRefEntryForAgent( - makeEntry({ - content: "", - attachments: [{ type: "voice", localPath: "/tmp/voice.wav" }], - }), - ), - ).toBe("[voice: /tmp/voice.wav]"); - }); - - it("returns an explicit empty marker for blank entries", () => { - expect(formatRefEntryForAgent(makeEntry({ content: " ", attachments: [] }))).toBe( - "[empty message]", - ); - }); -}); diff --git a/extensions/qqbot/src/engine/ref/format-ref-entry.ts b/extensions/qqbot/src/engine/ref/format-ref-entry.ts deleted file mode 100644 index 14681f0a386e..000000000000 --- a/extensions/qqbot/src/engine/ref/format-ref-entry.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Format a ref-index entry into text suitable for model context. - * - * Delegates all attachment rendering to the shared - * `utils/attachment-tags.ts::renderAttachmentTags` (with `mode: "ref"`) - * so the quoted-message preview and the current-message history use - * identical wording for identical attachment types. - */ - -import { renderAttachmentTags } from "../utils/attachment-tags.js"; -import type { RefIndexEntry } from "./types.js"; - -/** Format a ref-index entry into text suitable for model context. */ -export function formatRefEntryForAgent(entry: RefIndexEntry): string { - const parts: string[] = []; - - if (entry.content.trim()) { - parts.push(entry.content); - } - - const attachmentTags = renderAttachmentTags(entry.attachments, { mode: "ref" }); - if (attachmentTags) { - parts.push(attachmentTags); - } - - return parts.join(" ") || "[empty message]"; -} diff --git a/extensions/qqbot/src/engine/ref/store.test.ts b/extensions/qqbot/src/engine/ref/store.test.ts deleted file mode 100644 index 38bafdf0c6cb..000000000000 --- a/extensions/qqbot/src/engine/ref/store.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Qqbot tests cover store plugin behavior. -import fs from "node:fs"; -import path from "node:path"; -import { - resolvePreferredOpenClawTmpDir, - tempWorkspaceSync, - type TempWorkspaceSync, -} from "openclaw/plugin-sdk/temp-path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - installQQBotRuntimeForStateTests, - resetQQBotStateTestRuntime, -} from "../../test-support/runtime.js"; -import type { RefIndexEntry } from "./types.js"; - -const tempWorkspaces: TempWorkspaceSync[] = []; - -function refIndexFile(homeDir: string): string { - return path.join(homeDir, ".openclaw", "qqbot", "data", "ref-index.jsonl"); -} - -async function useMockHome(homeDir: string): Promise { - vi.doMock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, homedir: () => homeDir }, - homedir: () => homeDir, - }; - }); -} - -function entry(content = "hello"): RefIndexEntry { - return { - content, - senderId: "user-1", - senderName: "User", - timestamp: Date.now(), - isBot: false, - }; -} - -describe("engine/ref/store", () => { - beforeEach(async () => { - vi.resetModules(); - const stateWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-state-", - }); - const homeWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-home-", - }); - tempWorkspaces.push(stateWorkspace, homeWorkspace); - const stateDir = stateWorkspace.dir; - const homeDir = homeWorkspace.dir; - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - vi.stubEnv("HOME", homeDir); - await useMockHome(homeDir); - installQQBotRuntimeForStateTests(stateDir); - }); - - afterEach(() => { - resetQQBotStateTestRuntime(); - vi.doUnmock("node:os"); - vi.resetModules(); - vi.unstubAllEnvs(); - for (const workspace of tempWorkspaces.splice(0)) { - workspace.cleanup(); - } - }); - - it("round-trips ref-index rows through SQLite without writing JSONL", async () => { - const { getRefIndex, setRefIndex } = await import("./store.js"); - const homeDir = process.env.HOME!; - - setRefIndex("ref-1", entry("from-sqlite")); - - expect(getRefIndex("ref-1")?.content).toBe("from-sqlite"); - expect(fs.existsSync(refIndexFile(homeDir))).toBe(false); - }); - - it("omits undefined optional fields before writing to SQLite", async () => { - const { getRefIndex, setRefIndex } = await import("./store.js"); - - setRefIndex("ref-optional", { - content: "plain inbound", - senderId: "user-1", - senderName: undefined, - timestamp: Date.now(), - isBot: undefined, - attachments: [ - { - type: "image", - filename: undefined, - contentType: undefined, - transcript: undefined, - localPath: "/tmp/image.png", - }, - ], - }); - - expect(getRefIndex("ref-optional")).toEqual({ - content: "plain inbound", - senderId: "user-1", - timestamp: expect.any(Number), - attachments: [{ type: "image", localPath: "/tmp/image.png" }], - }); - }); - - it("keeps ref-index persistence best-effort when SQLite is unavailable", async () => { - resetQQBotStateTestRuntime(); - const { getRefIndex, setRefIndex } = await import("./store.js"); - - expect(() => setRefIndex("ref-unavailable", entry("ignored"))).not.toThrow(); - expect(getRefIndex("ref-unavailable")).toBeNull(); - }); -}); diff --git a/extensions/qqbot/src/engine/ref/store.ts b/extensions/qqbot/src/engine/ref/store.ts deleted file mode 100644 index 473b7dde960b..000000000000 --- a/extensions/qqbot/src/engine/ref/store.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Ref-index store — SQLite KV-backed store for message reference index. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { debugError } from "../utils/log.js"; -import { buildQQBotStateKey, openQQBotSyncKeyedStore } from "../utils/sqlite-state.js"; -import type { RefAttachmentSummary, RefIndexEntry } from "./types.js"; - -// Re-export the formatter for convenience. -export { formatRefEntryForAgent } from "./format-ref-entry.js"; - -const MAX_ENTRIES = 50000; -const TTL_MS = 7 * 24 * 60 * 60 * 1000; -const REF_INDEX_NAMESPACE = "ref-index"; - -type StoredRefIndexEntry = RefIndexEntry & { - createdAt: number; -}; - -function createRefIndexStore() { - return openQQBotSyncKeyedStore({ - namespace: REF_INDEX_NAMESPACE, - maxEntries: MAX_ENTRIES, - defaultTtlMs: TTL_MS, - }); -} - -function refIndexStateKey(refIdx: string): string { - return buildQQBotStateKey("ref-index", refIdx); -} - -function toStoredAttachment(attachment: RefAttachmentSummary): RefAttachmentSummary { - return { - type: attachment.type, - ...(attachment.filename !== undefined ? { filename: attachment.filename } : {}), - ...(attachment.contentType !== undefined ? { contentType: attachment.contentType } : {}), - ...(attachment.transcript !== undefined ? { transcript: attachment.transcript } : {}), - ...(attachment.transcriptSource !== undefined - ? { transcriptSource: attachment.transcriptSource } - : {}), - ...(attachment.localPath !== undefined ? { localPath: attachment.localPath } : {}), - ...(attachment.url !== undefined ? { url: attachment.url } : {}), - }; -} - -function toStoredRefIndexEntry(entry: RefIndexEntry, createdAt: number): StoredRefIndexEntry { - return { - content: entry.content, - senderId: entry.senderId, - ...(entry.senderName !== undefined ? { senderName: entry.senderName } : {}), - timestamp: entry.timestamp, - ...(entry.isBot !== undefined ? { isBot: entry.isBot } : {}), - ...(entry.attachments ? { attachments: entry.attachments.map(toStoredAttachment) } : {}), - createdAt, - }; -} - -function toRefIndexEntry(entry: StoredRefIndexEntry): RefIndexEntry { - return { - content: entry.content, - senderId: entry.senderId, - ...(entry.senderName !== undefined ? { senderName: entry.senderName } : {}), - timestamp: entry.timestamp, - ...(entry.isBot !== undefined ? { isBot: entry.isBot } : {}), - ...(entry.attachments ? { attachments: entry.attachments.map(toStoredAttachment) } : {}), - }; -} - -/** Persist a refIdx mapping for one message. */ -export function setRefIndex(refIdx: string, entry: RefIndexEntry): void { - try { - const now = Date.now(); - createRefIndexStore().register(refIndexStateKey(refIdx), toStoredRefIndexEntry(entry, now), { - ttlMs: TTL_MS, - }); - } catch (err) { - debugError(`[ref-index-store] Failed to persist ref index: ${formatErrorMessage(err)}`); - } -} - -/** Look up one quoted message by refIdx. */ -export function getRefIndex(refIdx: string): RefIndexEntry | null { - try { - const store = createRefIndexStore(); - const key = refIndexStateKey(refIdx); - const entry = store.lookup(key); - if (!entry) { - return null; - } - if (Date.now() - entry.createdAt > TTL_MS) { - store.delete(key); - return null; - } - return toRefIndexEntry(entry); - } catch (err) { - debugError(`[ref-index-store] Failed to read ref index: ${formatErrorMessage(err)}`); - return null; - } -} - -/** Compact the store before process exit when needed. */ -export function flushRefIndex(): void { - // SQLite writes are synchronous; no JSONL compaction remains. -} diff --git a/extensions/qqbot/src/engine/ref/types.ts b/extensions/qqbot/src/engine/ref/types.ts deleted file mode 100644 index 505900b19337..000000000000 --- a/extensions/qqbot/src/engine/ref/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Ref-index types shared between both plugin versions. - * - * These types define the structure of quoted-message metadata - * persisted by the ref-index store. - */ - -/** Summary stored for one quoted message. */ -export interface RefIndexEntry { - content: string; - senderId: string; - senderName?: string; - timestamp: number; - isBot?: boolean; - attachments?: RefAttachmentSummary[]; -} - -/** Attachment summary persisted alongside a ref index entry. */ -export interface RefAttachmentSummary { - type: "image" | "voice" | "video" | "file" | "unknown"; - filename?: string; - contentType?: string; - transcript?: string; - transcriptSource?: "stt" | "asr" | "tts" | "fallback"; - localPath?: string; - url?: string; -} diff --git a/extensions/qqbot/src/engine/session/known-users.test.ts b/extensions/qqbot/src/engine/session/known-users.test.ts deleted file mode 100644 index 60acfa9337a0..000000000000 --- a/extensions/qqbot/src/engine/session/known-users.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Qqbot tests cover known users plugin behavior. -import { createPluginStateSyncKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import { - resolvePreferredOpenClawTmpDir, - tempWorkspaceSync, - type TempWorkspaceSync, -} from "openclaw/plugin-sdk/temp-path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - installQQBotRuntimeForStateTests, - resetQQBotStateTestRuntime, -} from "../../test-support/runtime.js"; - -type KnownUser = { - openid: string; - type: "c2c" | "group"; - nickname?: string; - groupOpenid?: string; - accountId: string; - firstSeenAt: number; - lastSeenAt: number; - interactionCount: number; -}; - -const tempWorkspaces: TempWorkspaceSync[] = []; - -async function useMockHome(homeDir: string): Promise { - vi.doMock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, homedir: () => homeDir }, - homedir: () => homeDir, - }; - }); -} - -function knownUserRows(stateDir: string): KnownUser[] { - const store = createPluginStateSyncKeyedStoreForTests("qqbot", { - namespace: "known-users", - maxEntries: 100_000, - env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, - }); - return store.entries().map((entry) => entry.value); -} - -describe("engine/session/known-users", () => { - beforeEach(async () => { - vi.resetModules(); - const stateWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-state-", - }); - const homeWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-home-", - }); - tempWorkspaces.push(stateWorkspace, homeWorkspace); - const stateDir = stateWorkspace.dir; - const homeDir = homeWorkspace.dir; - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - vi.stubEnv("HOME", homeDir); - await useMockHome(homeDir); - installQQBotRuntimeForStateTests(stateDir); - }); - - afterEach(() => { - resetQQBotStateTestRuntime(); - vi.doUnmock("node:os"); - vi.resetModules(); - vi.unstubAllEnvs(); - for (const workspace of tempWorkspaces.splice(0)) { - workspace.cleanup(); - } - }); - - it("records known users in SQLite and flushes synchronously", async () => { - const { flushKnownUsers, recordKnownUser } = await import("./known-users.js"); - const stateDir = process.env.OPENCLAW_STATE_DIR!; - - recordKnownUser({ - openid: "user-1", - type: "c2c", - nickname: "First", - accountId: "acct-1", - }); - recordKnownUser({ - openid: "user-1", - type: "c2c", - nickname: "Second", - accountId: "acct-1", - }); - flushKnownUsers(); - - expect(knownUserRows(stateDir)).toMatchObject([ - { - openid: "user-1", - nickname: "Second", - interactionCount: 2, - }, - ]); - }); - - it("keeps known-user tracking best-effort when SQLite is unavailable", async () => { - resetQQBotStateTestRuntime(); - const { recordKnownUser } = await import("./known-users.js"); - - expect(() => - recordKnownUser({ - openid: "user-1", - type: "c2c", - accountId: "acct-1", - }), - ).not.toThrow(); - }); -}); diff --git a/extensions/qqbot/src/engine/session/known-users.ts b/extensions/qqbot/src/engine/session/known-users.ts deleted file mode 100644 index 063af907df9d..000000000000 --- a/extensions/qqbot/src/engine/session/known-users.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Known user tracking — SQLite KV-backed store. - */ - -import crypto from "node:crypto"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import type { ChatScope } from "../types.js"; -import { debugLog, debugError } from "../utils/log.js"; -import { openQQBotSyncKeyedStore } from "../utils/sqlite-state.js"; - -/** Persisted record for a user who has interacted with the bot. */ -interface KnownUser { - openid: string; - type: ChatScope; - nickname?: string; - groupOpenid?: string; - accountId: string; - firstSeenAt: number; - lastSeenAt: number; - interactionCount: number; -} - -function makeUserKey(user: Partial): string { - const base = `${user.accountId}:${user.type}:${user.openid}`; - return user.type === "group" && user.groupOpenid ? `${base}:${user.groupOpenid}` : base; -} - -const KNOWN_USERS_NAMESPACE = "known-users"; -const MAX_KNOWN_USERS = 100_000; - -function createKnownUsersStore() { - return openQQBotSyncKeyedStore({ - namespace: KNOWN_USERS_NAMESPACE, - maxEntries: MAX_KNOWN_USERS, - }); -} - -function knownUserStateKey(key: string): string { - return crypto.createHash("sha256").update(key).digest("hex"); -} - -function toStoredKnownUser(user: KnownUser): KnownUser { - return { - openid: user.openid, - type: user.type, - ...(user.nickname ? { nickname: user.nickname } : {}), - ...(user.groupOpenid ? { groupOpenid: user.groupOpenid } : {}), - accountId: user.accountId, - firstSeenAt: user.firstSeenAt, - lastSeenAt: user.lastSeenAt, - interactionCount: user.interactionCount, - }; -} - -/** Flush pending writes immediately, typically during shutdown. */ -export function flushKnownUsers(): void { - // SQLite writes are synchronous; no pending JSON flush remains. -} - -/** Record a known user whenever a message is received. */ -export function recordKnownUser(user: { - openid: string; - type: ChatScope; - nickname?: string; - groupOpenid?: string; - accountId: string; -}): void { - try { - const store = createKnownUsersStore(); - const key = makeUserKey(user); - const stateKey = knownUserStateKey(key); - const now = Date.now(); - const existing = store.lookup(stateKey); - - if (existing) { - const next: KnownUser = { - ...existing, - lastSeenAt: now, - interactionCount: existing.interactionCount + 1, - }; - if (user.nickname && user.nickname !== existing.nickname) { - next.nickname = user.nickname; - } - store.register(stateKey, toStoredKnownUser(next)); - } else { - store.register( - stateKey, - toStoredKnownUser({ - openid: user.openid, - type: user.type, - nickname: user.nickname, - groupOpenid: user.groupOpenid, - accountId: user.accountId, - firstSeenAt: now, - lastSeenAt: now, - interactionCount: 1, - }), - ); - debugLog(`[known-users] New user: ${user.openid} (${user.type})`); - } - } catch (err) { - debugError(`[known-users] Failed to record user: ${formatErrorMessage(err)}`); - } -} diff --git a/extensions/qqbot/src/engine/session/session-store.test.ts b/extensions/qqbot/src/engine/session/session-store.test.ts deleted file mode 100644 index dff8bb09663a..000000000000 --- a/extensions/qqbot/src/engine/session/session-store.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Qqbot tests cover session store plugin behavior. -import fs from "node:fs"; -import path from "node:path"; -import { - resolvePreferredOpenClawTmpDir, - tempWorkspaceSync, - type TempWorkspaceSync, -} from "openclaw/plugin-sdk/temp-path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - installQQBotRuntimeForStateTests, - resetQQBotStateTestRuntime, -} from "../../test-support/runtime.js"; -type SessionState = Parameters<(typeof import("./session-store.js"))["saveSession"]>[0]; - -const tempWorkspaces: TempWorkspaceSync[] = []; - -async function useMockHome(homeDir: string): Promise { - vi.doMock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, homedir: () => homeDir }, - homedir: () => homeDir, - }; - }); -} - -async function useStateAndHome(): Promise<{ stateDir: string; homeDir: string }> { - const stateWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-state-", - }); - const homeWorkspace = tempWorkspaceSync({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-home-", - }); - tempWorkspaces.push(stateWorkspace, homeWorkspace); - const stateDir = stateWorkspace.dir; - const homeDir = homeWorkspace.dir; - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - vi.stubEnv("HOME", homeDir); - await useMockHome(homeDir); - installQQBotRuntimeForStateTests(stateDir); - return { stateDir, homeDir }; -} - -function sessionPath(homeDir: string, accountId: string): string { - const encodedId = Buffer.from(accountId, "utf8").toString("base64url"); - return path.join(homeDir, ".openclaw", "qqbot", "sessions", `session-${encodedId}.json`); -} - -function writeLegacySession(homeDir: string, state: SessionState): string { - const filePath = sessionPath(homeDir, state.accountId); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`); - return filePath; -} - -function makeSession(overrides: Partial = {}): SessionState { - return { - sessionId: "session-1", - lastSeq: 42, - lastConnectedAt: Date.now(), - intentLevelIndex: 0, - accountId: "acct-1", - savedAt: Date.now(), - appId: "app-1", - ...overrides, - }; -} - -describe("engine/session/session-store", () => { - beforeEach(async () => { - vi.resetModules(); - await useStateAndHome(); - }); - - afterEach(async () => { - const { clearSession } = await import("./session-store.js"); - clearSession("acct-1"); - resetQQBotStateTestRuntime(); - vi.doUnmock("node:os"); - vi.resetModules(); - vi.unstubAllEnvs(); - for (const workspace of tempWorkspaces.splice(0)) { - workspace.cleanup(); - } - }); - - it("round-trips gateway sessions through SQLite without creating JSON files", async () => { - const { loadSession, saveSession } = await import("./session-store.js"); - const homeDir = process.env.HOME!; - - saveSession(makeSession()); - - expect(loadSession("acct-1", "app-1")?.sessionId).toBe("session-1"); - expect(fs.existsSync(sessionPath(homeDir, "acct-1"))).toBe(false); - }); - - it("does not import legacy JSON session cache files", async () => { - const { loadSession } = await import("./session-store.js"); - const homeDir = process.env.HOME!; - const legacyPath = writeLegacySession(homeDir, makeSession({ sessionId: "legacy-session" })); - - expect(loadSession("acct-1", "app-1")).toBeNull(); - expect(fs.existsSync(legacyPath)).toBe(true); - }); - - it("deletes mismatched appId sessions from SQLite", async () => { - const { loadSession, saveSession } = await import("./session-store.js"); - saveSession(makeSession({ appId: "app-a" })); - - expect(loadSession("acct-1", "app-b")).toBeNull(); - expect(loadSession("acct-1", "app-a")).toBeNull(); - }); -}); diff --git a/extensions/qqbot/src/engine/session/session-store.ts b/extensions/qqbot/src/engine/session/session-store.ts deleted file mode 100644 index 13c7e6650f59..000000000000 --- a/extensions/qqbot/src/engine/session/session-store.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Gateway session persistence — SQLite KV-backed store. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { debugLog, debugError } from "../utils/log.js"; -import { buildQQBotStateKey, openQQBotSyncKeyedStore } from "../utils/sqlite-state.js"; - -/** Persisted gateway session state. */ -interface SessionState { - sessionId: string | null; - lastSeq: number | null; - lastConnectedAt: number; - intentLevelIndex: number; - accountId: string; - savedAt: number; - appId?: string; -} - -const SESSION_EXPIRE_TIME = 5 * 60 * 1000; -const SAVE_THROTTLE_MS = 1000; -const SESSION_NAMESPACE = "gateway-sessions"; -const MAX_SESSIONS = 1000; - -const throttleState = new Map< - string, - { - pendingState: SessionState | null; - lastSaveTime: number; - throttleTimer: ReturnType | null; - } ->(); - -function createSessionStore() { - return openQQBotSyncKeyedStore({ - namespace: SESSION_NAMESPACE, - maxEntries: MAX_SESSIONS, - defaultTtlMs: SESSION_EXPIRE_TIME, - }); -} - -function sessionKey(accountId: string): string { - return buildQQBotStateKey("gateway-session", accountId); -} - -function toStoredSessionState(state: SessionState): SessionState { - return { - sessionId: state.sessionId, - lastSeq: state.lastSeq, - lastConnectedAt: state.lastConnectedAt, - intentLevelIndex: state.intentLevelIndex, - accountId: state.accountId, - savedAt: state.savedAt, - ...(state.appId ? { appId: state.appId } : {}), - }; -} - -/** Load a saved session, rejecting expired or mismatched appId entries. */ -export function loadSession(accountId: string, expectedAppId?: string): SessionState | null { - try { - const store = createSessionStore(); - const state = store.lookup(sessionKey(accountId)); - if (!state) { - return null; - } - - const now = Date.now(); - - if (now - state.savedAt > SESSION_EXPIRE_TIME) { - debugLog( - `[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`, - ); - store.delete(sessionKey(accountId)); - return null; - } - - if (expectedAppId && state.appId && state.appId !== expectedAppId) { - debugLog( - `[session-store] appId mismatch for ${accountId}: saved=${state.appId}, current=${expectedAppId}. Discarding stale session.`, - ); - store.delete(sessionKey(accountId)); - return null; - } - - if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) { - debugLog(`[session-store] Invalid session data for ${accountId}`); - store.delete(sessionKey(accountId)); - return null; - } - - debugLog( - `[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, appId=${state.appId ?? "unknown"}, age=${Math.round((now - state.savedAt) / 1000)}s`, - ); - return state; - } catch (err) { - debugError( - `[session-store] Failed to load session for ${accountId}: ${formatErrorMessage(err)}`, - ); - return null; - } -} - -/** Save session state with throttling. */ -export function saveSession(state: SessionState): void { - const { accountId } = state; - let throttle = throttleState.get(accountId); - if (!throttle) { - throttle = { pendingState: null, lastSaveTime: 0, throttleTimer: null }; - throttleState.set(accountId, throttle); - } - - const now = Date.now(); - const timeSinceLastSave = now - throttle.lastSaveTime; - - if (timeSinceLastSave >= SAVE_THROTTLE_MS) { - doSaveSession(state); - throttle.lastSaveTime = now; - throttle.pendingState = null; - if (throttle.throttleTimer) { - clearTimeout(throttle.throttleTimer); - throttle.throttleTimer = null; - } - } else { - throttle.pendingState = state; - if (!throttle.throttleTimer) { - const delay = SAVE_THROTTLE_MS - timeSinceLastSave; - throttle.throttleTimer = setTimeout(() => { - const t = throttleState.get(accountId); - if (t?.pendingState) { - doSaveSession(t.pendingState); - t.lastSaveTime = Date.now(); - t.pendingState = null; - } - if (t) { - t.throttleTimer = null; - } - }, delay); - } - } -} - -function doSaveSession(state: SessionState): void { - try { - const stateToSave: SessionState = { ...state, savedAt: Date.now() }; - createSessionStore().register(sessionKey(state.accountId), toStoredSessionState(stateToSave), { - ttlMs: SESSION_EXPIRE_TIME, - }); - debugLog( - `[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`, - ); - } catch (err) { - debugError( - `[session-store] Failed to save session for ${state.accountId}: ${formatErrorMessage(err)}`, - ); - } -} - -/** Clear a saved session and any pending throttle state. */ -export function clearSession(accountId: string): void { - const throttle = throttleState.get(accountId); - if (throttle) { - if (throttle.throttleTimer) { - clearTimeout(throttle.throttleTimer); - } - throttleState.delete(accountId); - } - try { - const cleared = createSessionStore().delete(sessionKey(accountId)); - if (cleared) { - debugLog(`[session-store] Cleared session for ${accountId}`); - } - } catch (err) { - debugError( - `[session-store] Failed to clear session for ${accountId}: ${formatErrorMessage(err)}`, - ); - } -} diff --git a/extensions/qqbot/src/engine/tools/channel-api.test.ts b/extensions/qqbot/src/engine/tools/channel-api.test.ts deleted file mode 100644 index 9b34697b148b..000000000000 --- a/extensions/qqbot/src/engine/tools/channel-api.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -// Qqbot tests cover channel-api tool behavior. - -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js"; - -const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetchWithSsrFGuard: fetchWithSsrFGuardMock, - }; -}); - -import { executeChannelApi } from "./channel-api.js"; - -function qqbotCfg(qqbot: Record): OpenClawConfig { - return { channels: { qqbot } } as OpenClawConfig; -} - -function cancelTrackedResponse( - text: string, - init: ResponseInit, -): { - response: Response; - wasCanceled: () => boolean; -} { - let canceled = false; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(text)); - }, - cancel() { - canceled = true; - }, - }); - return { - response: new Response(stream, init), - wasCanceled: () => canceled, - }; -} - -describe("executeChannelApi", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - fetchWithSsrFGuardMock.mockReset(); - }); - - it("uses guarded QQ API fetches and releases successful responses", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(JSON.stringify({ id: "guild-1" }), { status: 200 }), - release, - }); - - const result = await executeChannelApi( - { method: "GET", path: "/users/@me/guilds", query: { limit: "1" } }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - success: true, - status: 200, - path: "/users/@me/guilds", - data: { id: "guild-1" }, - }); - expect(release).toHaveBeenCalledTimes(1); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ - url: "https://api.sgroup.qq.com/users/@me/guilds?limit=1", - init: { - method: "GET", - headers: { - Authorization: "QQBot token-1", - "Content-Type": "application/json", - }, - signal: expect.any(AbortSignal), - }, - auditContext: "qqbot-channel-api", - policy: { - hostnameAllowlist: ["api.sgroup.qq.com"], - allowRfc2544BenchmarkRange: true, - }, - }); - }); - - it.each([ - { label: "successful", responseInit: { status: 200 } }, - { - label: "error", - responseInit: { status: 503, statusText: "Service Unavailable" }, - }, - ])("keeps the request deadline through $label response body reads", async ({ responseInit }) => { - vi.useFakeTimers(); - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockImplementationOnce(async ({ init }: { init?: RequestInit }) => { - const signal = init?.signal; - if (!(signal instanceof AbortSignal)) { - throw new Error("expected channel API request signal"); - } - const body = new ReadableStream({ - start(controller) { - signal.addEventListener("abort", () => controller.error(signal.reason), { - once: true, - }); - }, - }); - return { - response: new Response(body, responseInit), - release, - }; - }); - - const resultPromise = executeChannelApi( - { method: "GET", path: "/guilds/123/channels" }, - { accessToken: "token-1" }, - ); - await vi.advanceTimersByTimeAsync(30_000); - - const result = await resultPromise; - expect(result.details).toEqual({ - error: "Request timed out after 30000ms", - path: "/guilds/123/channels", - }); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("clears the request deadline when guarded fetch fails before headers", async () => { - vi.useFakeTimers(); - fetchWithSsrFGuardMock.mockRejectedValueOnce(new Error("offline")); - - const result = await executeChannelApi( - { method: "GET", path: "/guilds/123/channels" }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - error: "Network error: offline", - path: "/guilds/123/channels", - }); - expect(vi.getTimerCount()).toBe(0); - }); - - it("does not label an unrelated body abort as a request timeout", async () => { - const release = vi.fn(async () => {}); - const bodyError = new Error("upstream body aborted"); - bodyError.name = "AbortError"; - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response( - new ReadableStream({ - start(controller) { - controller.error(bodyError); - }, - }), - { status: 200 }, - ), - release, - }); - - const result = await executeChannelApi( - { method: "GET", path: "/guilds/123/channels" }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - error: "upstream body aborted", - path: "/guilds/123/channels", - }); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("blocks guild listing when qqbot groups are scoped", async () => { - const result = await executeChannelApi( - { method: "GET", path: "/users/@me/guilds" }, - { - accessToken: "token-1", - cfg: qqbotCfg({ groups: { G1: {} } }), - }, - ); - - expect(result.details).toEqual({ - error: "QQ channel API guild listing is unavailable while qqbot groups are scoped.", - path: "/users/@me/guilds", - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("blocks guild paths when qqbot groups are scoped", async () => { - const result = await executeChannelApi( - { method: "GET", path: "/guilds/G1/channels" }, - { - accessToken: "token-1", - cfg: qqbotCfg({ groups: { G1: {} } }), - }, - ); - - expect(result.details).toEqual({ - error: "QQ channel API guild paths are unavailable while qqbot groups are scoped.", - path: "/guilds/G1/channels", - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("blocks channel paths when qqbot groups are scoped", async () => { - const result = await executeChannelApi( - { method: "GET", path: "/channels/C1/threads" }, - { - accessToken: "token-1", - cfg: qqbotCfg({ groups: { C1: {} } }), - }, - ); - - expect(result.details).toEqual({ - error: "QQ channel API channel paths are unavailable while qqbot groups are scoped.", - path: "/channels/C1/threads", - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("allows guild paths with wildcard qqbot groups", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(JSON.stringify({ id: "channel-1" }), { status: 200 }), - release, - }); - - const result = await executeChannelApi( - { method: "GET", path: "/guilds/G1/channels" }, - { - accessToken: "token-1", - cfg: qqbotCfg({ groups: { "*": {} } }), - }, - ); - - expect(result.details).toMatchObject({ - success: true, - status: 200, - path: "/guilds/G1/channels", - }); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://api.sgroup.qq.com/guilds/G1/channels", - }), - ); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("allows global guild listing with wildcard qqbot groups", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(JSON.stringify([{ id: "guild-1" }]), { status: 200 }), - release, - }); - - const result = await executeChannelApi( - { method: "GET", path: "/users/@me/guilds" }, - { - accessToken: "token-1", - cfg: qqbotCfg({ groups: { "*": {} } }), - }, - ); - - expect(result.details).toMatchObject({ - success: true, - status: 200, - path: "/users/@me/guilds", - }); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://api.sgroup.qq.com/users/@me/guilds", - }), - ); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("bounds error bodies without using response.text()", async () => { - const release = vi.fn(async () => {}); - const tracked = cancelTrackedResponse(`${"channel api unavailable ".repeat(1024)}tail`, { - status: 503, - statusText: "Service Unavailable", - headers: { "content-type": "text/plain" }, - }); - const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: tracked.response, - release, - }); - - const result = await executeChannelApi( - { method: "GET", path: "/guilds/123/channels" }, - { accessToken: "token-1" }, - ); - - expect(result.details).toMatchObject({ - error: "503 Service Unavailable", - status: 503, - path: "/guilds/123/channels", - }); - const bodyPreview = (result.details as { details?: unknown }).details; - expect(typeof bodyPreview).toBe("string"); - expect(bodyPreview).toContain("channel api unavailable"); - expect(bodyPreview).not.toContain("tail"); - expect(tracked.wasCanceled()).toBe(true); - expect(textSpy).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("bounds successful response bodies without using response.text()", async () => { - const release = vi.fn(async () => {}); - const streamed = createStreamingResponse({ - chunkCount: 32, - chunkSize: 1024 * 1024, - text: "x", - headers: { "content-type": "application/json" }, - }); - const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded")); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: streamed.response, - release, - }); - - const result = await executeChannelApi( - { method: "GET", path: "/guilds/123/channels" }, - { accessToken: "token-1" }, - ); - - expect(result.details).toMatchObject({ - error: "QQ channel API response: text response exceeds 16777216 bytes", - path: "/guilds/123/channels", - }); - expect(streamed.getReadCount()).toBeLessThan(32); - expect(streamed.wasCanceled()).toBe(true); - expect(textSpy).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("requires confirmation before DELETE requests", async () => { - const result = await executeChannelApi( - { method: "DELETE", path: "/channels/123" }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - error: - "DELETE requests require confirmed=true after the user confirms the exact QQ resource.", - path: "/channels/123", - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("allows confirmed DELETE requests", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(null, { status: 204, statusText: "No Content" }), - release, - }); - - const result = await executeChannelApi( - { method: "DELETE", path: "/channels/123", confirmed: true }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - success: true, - status: 204, - path: "/channels/123", - }); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://api.sgroup.qq.com/channels/123", - init: expect.objectContaining({ method: "DELETE" }), - }), - ); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("requires separate confirmation before bulk announcement deletes", async () => { - const result = await executeChannelApi( - { method: "DELETE", path: "/guilds/123/announces/all", confirmed: true }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - error: - "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.", - path: "/guilds/123/announces/all", - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("requires bulk confirmation for encoded all announcement sentinel", async () => { - const result = await executeChannelApi( - { method: "DELETE", path: "/guilds/123/announces/%61%6c%6c", confirmed: true }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - error: - "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation.", - path: "/guilds/123/announces/%61%6c%6c", - }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("rejects encoded path separators before fetch", async () => { - const result = await executeChannelApi( - { method: "GET", path: "/guilds/123%2fannounces" }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ error: "path contains encoded path separators" }); - expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); - }); - - it("allows bulk announcement deletes after both confirmations", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuardMock.mockResolvedValueOnce({ - response: new Response(null, { status: 204, statusText: "No Content" }), - release, - }); - - const result = await executeChannelApi( - { - method: "DELETE", - path: "/guilds/123/announces/all", - confirmed: true, - bulkConfirmed: true, - }, - { accessToken: "token-1" }, - ); - - expect(result.details).toEqual({ - success: true, - status: 204, - path: "/guilds/123/announces/all", - }); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://api.sgroup.qq.com/guilds/123/announces/all", - init: expect.objectContaining({ method: "DELETE" }), - }), - ); - expect(release).toHaveBeenCalledTimes(1); - }); -}); diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts deleted file mode 100644 index 2a535a081146..000000000000 --- a/extensions/qqbot/src/engine/tools/channel-api.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * QQ Channel API proxy tool core logic. - * QQ 频道 API 代理工具核心逻辑。 - * - * Provides an authenticated HTTP proxy for the QQ Open Platform channel - * APIs. The caller (old tools/channel.ts shell) resolves the access - * token and passes it in; this module handles URL building, path - * validation, fetch, and structured response formatting. - */ - -import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { - readProviderTextResponse, - readResponseTextLimited, -} from "openclaw/plugin-sdk/provider-http"; -import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { jsonResult as json } from "openclaw/plugin-sdk/tool-results"; -import { debugLog, debugError } from "../utils/log.js"; - -const API_BASE = "https://api.sgroup.qq.com"; -const DEFAULT_TIMEOUT_MS = 30000; -const CHANNEL_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024; - -function resolveChannelApiSsrfPolicy(url: string): SsrFPolicy { - return { - hostnameAllowlist: [new URL(url).hostname], - allowRfc2544BenchmarkRange: true, - }; -} - -/** - * Channel API call parameters. - * 频道 API 调用参数。 - */ -export interface ChannelApiParams { - method: string; - path: string; - body?: Record; - query?: Record; - confirmed?: boolean; - bulkConfirmed?: boolean; -} - -/** - * JSON Schema for AI tool parameters (used by framework registration). - * AI Tool 参数的 JSON Schema 定义(供框架注册使用)。 - */ -export const ChannelApiSchema = { - type: "object", - properties: { - method: { - type: "string", - description: - "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE. " + - "Use DELETE and other mutating methods only after explicit user intent and target confirmation.", - enum: ["GET", "POST", "PUT", "PATCH", "DELETE"], - }, - path: { - type: "string", - description: - "API path without the host. Replace placeholders with concrete values. " + - "Examples: /users/@me/guilds, /guilds/{guild_id}/channels, /channels/{channel_id}.", - }, - body: { - type: "object", - description: - "JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it. " + - "For write requests, include only fields the user explicitly asked to change.", - }, - query: { - type: "object", - description: - "URL query parameters as key/value pairs appended to the path. " + - 'For example, { "limit": "100", "after": "0" } becomes ?limit=100&after=0.', - additionalProperties: { type: "string" }, - }, - confirmed: { - type: "boolean", - description: - "Required true for DELETE requests after the user confirms the exact QQ resource to delete.", - }, - bulkConfirmed: { - type: "boolean", - description: - "Required true in addition to confirmed for bulk DELETE requests such as deleting all announcements.", - }, - }, - required: ["method", "path"], -} as const; - -/** - * Build the full API URL from base + path + query params. - * 拼接 API 基地址 + 路径 + 查询参数。 - */ -function buildUrl(path: string, query?: Record): string { - let url = `${API_BASE}${path}`; - if (query && Object.keys(query).length > 0) { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(query)) { - if (value !== undefined && value !== null && value !== "") { - params.set(key, value); - } - } - const qs = params.toString(); - if (qs) { - url += `?${qs}`; - } - } - return url; -} - -/** - * Validate API path format; returns an error string or null if valid. - * 校验 API 路径格式,返回错误描述或 null(合法)。 - */ -function validatePath(path: string): string | null { - if (!path.startsWith("/")) { - return "path must start with /"; - } - if (path.includes("..") || path.includes("//")) { - return "path must not contain .. or //"; - } - if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") { - return "path contains unsupported characters"; - } - for (const segment of path.split("/").slice(1)) { - let decodedSegment: string; - try { - decodedSegment = decodeURIComponent(segment); - } catch { - return "path contains invalid percent encoding"; - } - if (decodedSegment.includes("/") || decodedSegment.includes("\\")) { - return "path contains encoded path separators"; - } - if (decodedSegment === "." || decodedSegment === "..") { - return "path must not contain . or .. segments"; - } - } - return null; -} - -function decodePathSegments(path: string): string[] | null { - try { - return path - .replace(/\/+$/, "") - .split("/") - .slice(1) - .map((segment) => decodeURIComponent(segment)); - } catch { - return null; - } -} - -type ChannelApiPathTarget = - | { kind: "guild-list" } - | { kind: "guild"; id: string } - | { kind: "channel"; id: string } - | { kind: "unverified" }; - -function resolvePathTarget(path: string): ChannelApiPathTarget { - const segments = decodePathSegments(path); - if (!segments || segments.length === 0) { - return { kind: "unverified" }; - } - - const [scope, firstId, second] = segments; - if ( - scope?.toLowerCase() === "users" && - firstId?.toLowerCase() === "@me" && - second?.toLowerCase() === "guilds" - ) { - return { kind: "guild-list" }; - } - if (scope?.toLowerCase() === "guilds" && firstId) { - return { kind: "guild", id: firstId }; - } - if (scope?.toLowerCase() === "channels" && firstId) { - return { kind: "channel", id: firstId }; - } - return { kind: "unverified" }; -} - -function validateConfiguredTargetScope( - path: string, - options: ChannelApiExecuteOptions, -): string | null { - if (!options.cfg) { - return null; - } - - const basePolicy = resolveChannelGroupPolicy({ - cfg: options.cfg, - channel: "qqbot", - accountId: options.accountId, - groupIdCaseInsensitive: true, - }); - if (!basePolicy.allowlistEnabled && basePolicy.allowed) { - return null; - } - - const target = resolvePathTarget(path); - if (target.kind === "guild-list") { - return basePolicy.allowed - ? null - : "QQ channel API guild listing is unavailable while qqbot groups are scoped."; - } - if (target.kind === "unverified") { - return basePolicy.allowed - ? null - : "QQ channel API path target cannot be verified against configured qqbot groups."; - } - - return basePolicy.allowed - ? null - : `QQ channel API ${target.kind} paths are unavailable while qqbot groups are scoped.`; -} - -function isBulkAnnouncementDeletePath(path: string): boolean { - const segments = decodePathSegments(path); - return Boolean( - segments && - segments.length === 4 && - segments[0]?.toLowerCase() === "guilds" && - segments[2]?.toLowerCase() === "announces" && - segments[3]?.toLowerCase() === "all", - ); -} - -function validateDeleteConfirmation(params: ChannelApiParams): string | null { - if (params.method.toUpperCase() !== "DELETE") { - return null; - } - if (!params.confirmed) { - return "DELETE requests require confirmed=true after the user confirms the exact QQ resource."; - } - if (isBulkAnnouncementDeletePath(params.path) && !params.bulkConfirmed) { - return "Deleting all announcements requires bulkConfirmed=true after a separate bulk-delete confirmation."; - } - return null; -} - -/** - * Options provided by the caller when executing a channel API request. - * 执行频道 API 请求时由调用方提供的选项。 - */ -interface ChannelApiExecuteOptions { - accessToken: string; - cfg?: OpenClawConfig; - accountId?: string | null; -} - -/** - * Execute a channel API proxy request. - * 执行频道 API 代理请求。 - * - * The caller provides the access token; this function handles - * URL building, path validation, HTTP fetch, and structured - * response formatting suitable for AI tool output. - */ -export async function executeChannelApi( - params: ChannelApiParams, - options: ChannelApiExecuteOptions, -) { - if (!params.method) { - return json({ error: "method is required" }); - } - if (!params.path) { - return json({ error: "path is required" }); - } - - const method = params.method.toUpperCase(); - if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) { - return json({ - error: `Unsupported HTTP method: ${method}. Allowed values: GET, POST, PUT, PATCH, DELETE`, - }); - } - - const pathError = validatePath(params.path); - if (pathError) { - return json({ error: pathError }); - } - - const scopeError = validateConfiguredTargetScope(params.path, options); - if (scopeError) { - return json({ error: scopeError, path: params.path }); - } - - const confirmationError = validateDeleteConfirmation({ ...params, method }); - if (confirmationError) { - return json({ error: confirmationError, path: params.path }); - } - - if ( - (method === "GET" || method === "DELETE") && - params.body && - Object.keys(params.body).length > 0 - ) { - debugLog(`[qqbot-channel-api] ${method} request with body, body will be ignored`); - } - - try { - const url = buildUrl(params.path, params.query); - const headers: Record = { - Authorization: `QQBot ${options.accessToken}`, - "Content-Type": "application/json", - }; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); - - const fetchOptions: RequestInit = { - method, - headers, - signal: controller.signal, - }; - - if (params.body && ["POST", "PUT", "PATCH"].includes(method)) { - fetchOptions.body = JSON.stringify(params.body); - } - - debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`); - - let release: (() => Promise) | undefined; - let receivedResponse = false; - try { - const guarded = await fetchWithSsrFGuard({ - url, - init: fetchOptions, - auditContext: "qqbot-channel-api", - policy: resolveChannelApiSsrfPolicy(url), - }); - release = guarded.release; - receivedResponse = true; - const res = guarded.response; - - debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`); - - const rawBody = res.ok - ? await readProviderTextResponse(res, "QQ channel API response", { - chunkTimeoutMs: DEFAULT_TIMEOUT_MS, - }) - : await readResponseTextLimited(res, CHANNEL_API_ERROR_BODY_LIMIT_BYTES, { - chunkTimeoutMs: DEFAULT_TIMEOUT_MS, - }); - if (!rawBody || rawBody.trim() === "") { - if (res.ok) { - return json({ success: true, status: res.status, path: params.path }); - } - return json({ - error: `API returned ${res.status} ${res.statusText}`, - status: res.status, - path: params.path, - }); - } - - let parsed: unknown; - try { - parsed = JSON.parse(rawBody); - } catch { - parsed = rawBody; - } - - if (!res.ok) { - const errMsg = - typeof parsed === "object" && parsed && "message" in parsed - ? String((parsed as { message?: unknown }).message) - : `${res.status} ${res.statusText}`; - debugError(`[qqbot-channel-api] Error [${method} ${params.path}]: ${errMsg}`); - return json({ - error: errMsg, - status: res.status, - path: params.path, - details: parsed, - }); - } - - return json({ - success: true, - status: res.status, - path: params.path, - data: parsed, - }); - } catch (err) { - if (controller.signal.aborted && err instanceof Error && err.name === "AbortError") { - debugError(`[qqbot-channel-api] <<< Request timeout after ${DEFAULT_TIMEOUT_MS}ms`); - return json({ - error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms`, - path: params.path, - }); - } - if (!receivedResponse) { - debugError("[qqbot-channel-api] <<< Network error:", err); - return json({ - error: `Network error: ${formatErrorMessage(err)}`, - path: params.path, - }); - } - return json({ - error: formatErrorMessage(err), - path: params.path, - }); - } finally { - clearTimeout(timeoutId); - await release?.(); - } - } catch (err) { - return json({ - error: formatErrorMessage(err), - path: params.path, - }); - } -} diff --git a/extensions/qqbot/src/engine/tools/remind-logic.test.ts b/extensions/qqbot/src/engine/tools/remind-logic.test.ts deleted file mode 100644 index c2119cdf9fa6..000000000000 --- a/extensions/qqbot/src/engine/tools/remind-logic.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -// Qqbot tests cover remind logic plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { executeScheduledRemind, type RemindCronAction } from "./remind-logic.js"; - -describe("engine/tools/remind-logic", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - describe("executeScheduledRemind", () => { - it("runs cron.add directly for relative reminders", async () => { - const calls: RemindCronAction[] = []; - const before = Date.now(); - const result = await executeScheduledRemind( - { action: "add", content: "test reminder", to: "qqbot:c2c:123", time: "5m" }, - {}, - async (params) => { - calls.push(params); - return { id: "job-1" }; - }, - ); - - expect(calls).toHaveLength(1); - const call = calls[0]; - expect(call?.action).toBe("add"); - if (call?.action !== "add") { - throw new Error("expected add cron action"); - } - expect(call.job.name).toBe("Reminder: test reminder"); - expect(call.job.schedule.kind).toBe("at"); - if (call.job.schedule.kind !== "at") { - throw new Error("expected at schedule"); - } - if (!("deleteAfterRun" in call.job)) { - throw new Error("expected one-shot reminder job"); - } - const scheduledAtMs = Date.parse(call.job.schedule.at); - expect(scheduledAtMs).toBeGreaterThanOrEqual(before + 5 * 60_000); - expect(scheduledAtMs).toBeLessThanOrEqual(Date.now() + 5 * 60_000 + 1_000); - expect(call.job.sessionTarget).toBe("isolated"); - expect(call.job.wakeMode).toBe("now"); - expect(call.job.deleteAfterRun).toBe(true); - expect(call.job.payload).toEqual({ - kind: "agentTurn", - message: expect.stringContaining("test reminder"), - toolsAllow: [], - }); - expect(call.job.delivery).toEqual({ - mode: "announce", - channel: "qqbot", - to: "qqbot:c2c:123", - accountId: "default", - }); - expect(result.details).toEqual({ - ok: true, - action: "add", - summary: '⏰ Reminder in 5m: "test reminder"', - cronResult: { id: "job-1" }, - }); - }); - - it.each([ - { - name: "uses the Gateway timezone when omitted", - timezone: undefined, - expectedSchedule: { kind: "cron", expr: "0 9 * * *" }, - expectedSummary: '⏰ Recurring reminder: "test reminder" (0 9 * * *, tz=gateway local)', - }, - { - name: "preserves an explicit IANA timezone", - timezone: " America/New_York ", - expectedSchedule: { - kind: "cron", - expr: "0 9 * * *", - tz: "America/New_York", - }, - expectedSummary: '⏰ Recurring reminder: "test reminder" (0 9 * * *, tz=America/New_York)', - }, - ])("$name for recurring reminders", async ({ timezone, expectedSchedule, expectedSummary }) => { - const calls: RemindCronAction[] = []; - const result = await executeScheduledRemind( - { - action: "add", - content: "test reminder", - to: "qqbot:c2c:123", - time: "0 9 * * *", - ...(timezone ? { timezone } : {}), - }, - {}, - async (params) => { - calls.push(params); - return { id: "job-cron" }; - }, - ); - - const call = calls[0]; - expect(call?.action).toBe("add"); - if (call?.action !== "add") { - throw new Error("expected add cron action"); - } - expect(call.job.schedule).toEqual(expectedSchedule); - expect(result.details).toEqual({ - ok: true, - action: "add", - summary: expectedSummary, - cronResult: { id: "job-cron" }, - }); - }); - - it("runs cron list and remove through the scheduler", async () => { - const calls: unknown[] = []; - await executeScheduledRemind({ action: "list" }, {}, async (params) => { - calls.push(params); - return { jobs: [] }; - }); - await executeScheduledRemind({ action: "remove", jobId: "job-1" }, {}, async (params) => { - calls.push(params); - return { ok: true }; - }); - - expect(calls).toEqual([{ action: "list" }, { action: "remove", jobId: "job-1" }]); - }); - - it("does not call scheduler when validation fails", async () => { - const result = await executeScheduledRemind({ action: "add", time: "5m" }, {}, async () => { - throw new Error("should not run"); - }); - - expect((result.details as { error: string }).error).toContain("content"); - }); - - it("returns a clear error when Gateway cron fails", async () => { - const result = await executeScheduledRemind( - { action: "remove", jobId: "job-1" }, - {}, - async () => { - throw new Error("gateway unavailable"); - }, - ); - - expect(result.details).toEqual({ - error: "Failed to run Gateway cron action: gateway unavailable", - action: "remove", - }); - }); - - it("rejects relative reminders whose scheduled time exceeds the Date range", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(8_640_000_000_000_000)); - - const result = await executeScheduledRemind( - { action: "add", content: "test reminder", to: "qqbot:c2c:123", time: "5m" }, - {}, - async () => ({ id: "unexpected" }), - ); - - expect(result.details).toEqual({ - error: "Reminder time is outside the supported Date range", - }); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/tools/remind-logic.ts b/extensions/qqbot/src/engine/tools/remind-logic.ts deleted file mode 100644 index 47e75af53319..000000000000 --- a/extensions/qqbot/src/engine/tools/remind-logic.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -// Qqbot plugin module implements remind logic behavior. -import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { jsonResult as json } from "openclaw/plugin-sdk/tool-results"; - -/** - * QQBot reminder tool core logic. - * QQBot 提醒工具核心逻辑。 - * - * Pure functions for time parsing, cron detection, job building, - * and remind execution. The framework registration shell - * (bridge/tools/remind.ts) delegates all business logic here and - * supplies request-level context fallbacks (`to`, `accountId`). - */ - -/** - * Reminder tool input parameters. - * 提醒工具的输入参数。 - */ -export interface RemindParams { - action: "add" | "list" | "remove"; - content?: string; - to?: string; - time?: string; - timezone?: string; - name?: string; - jobId?: string; -} - -/** - * Context supplied by the bridge layer so the engine can remain free of - * framework / AsyncLocalStorage dependencies. `fallbackTo` and - * `fallbackAccountId` are consulted only when the corresponding AI-supplied - * parameter is missing. - */ -interface RemindExecuteContext { - fallbackTo?: string; - fallbackAccountId?: string; -} - -export type RemindCronAction = - | { action: "list" } - | { action: "remove"; jobId: string } - | { - action: "add"; - job: ReturnType["job"] | ReturnType["job"]; - }; - -type RemindCronScheduler = (params: RemindCronAction) => Promise; - -type RemindCronPlan = - | { - ok: true; - action: RemindParams["action"]; - cronAction: RemindCronAction; - summary?: string; - } - | { - ok: false; - error: string; - }; - -/** - * JSON Schema for AI tool parameters (used by framework registration). - * AI Tool 参数的 JSON Schema 定义(供框架注册使用)。 - */ -export const RemindSchema = { - type: "object", - properties: { - action: { - type: "string", - description: - "Action type. add=create a reminder only after explicit user request, list=show reminders, remove=delete a reminder by confirmed job ID.", - enum: ["add", "list", "remove"], - }, - content: { - type: "string", - description: - 'Reminder content, for example "drink water" or "join the meeting". Required when action=add.', - }, - to: { - type: "string", - description: - "Optional delivery target. The runtime automatically resolves the current " + - "conversation target, so you usually do not need to supply this. " + - "Direct-message format: qqbot:c2c:user_openid. Group format: qqbot:group:group_openid.", - }, - time: { - type: "string", - description: - "Time description. Supported formats:\n" + - '1. Relative time, for example "5m", "1h", "1h30m", or "2d"\n' + - '2. Cron expression, for example "0 8 * * *" or "0 9 * * 1-5"\n' + - "Values containing spaces are treated as cron expressions; everything else is treated as a one-shot relative delay.\n" + - "Required when action=add. Ask for clarification before scheduling if the time is ambiguous.", - }, - timezone: { - type: "string", - description: - "Optional IANA timezone used for cron reminders. Include it when the user provides or confirms a timezone; if omitted, Gateway cron uses the host timezone.", - }, - name: { - type: "string", - description: "Optional reminder job name. Defaults to the first 20 characters of content.", - }, - jobId: { - type: "string", - description: "Job ID to remove. Required when action=remove; fetch it with list first.", - }, - }, - required: ["action"], -} as const; - -/** - * Parse a relative time string into milliseconds. - * 解析相对时间字符串为毫秒数。 - * - * Supports: "5m", "1h", "1h30m", "2d", "45s", plain number (as minutes). - * - * @returns Milliseconds or null if unparseable. - */ -function parseRelativeTime(timeStr: string): number | null { - const s = timeStr.trim().toLowerCase(); - if (/^\d+$/.test(s)) { - return Number.parseInt(s, 10) * 60_000; - } - - let totalMs = 0; - let matched = false; - let consumed = 0; - const regex = /(\d+(?:\.\d+)?)\s*(d|h|m|s)\s*/g; - let match: RegExpExecArray | null; - while ((match = regex.exec(s)) !== null) { - if (match.index !== consumed) { - return null; - } - matched = true; - consumed = regex.lastIndex; - const valueText = match[1]; - const unit = match[2]; - if (valueText === undefined || unit === undefined) { - return null; - } - const value = Number.parseFloat(valueText); - switch (unit) { - case "d": - totalMs += value * 86_400_000; - break; - case "h": - totalMs += value * 3_600_000; - break; - case "m": - totalMs += value * 60_000; - break; - case "s": - totalMs += value * 1_000; - break; - } - } - return matched && consumed === s.length ? Math.round(totalMs) : null; -} - -/** - * Check whether a time string is a cron expression (3–6 space-separated fields). - * 判断时间字符串是否为 cron 表达式。 - */ -function isCronExpression(timeStr: string): boolean { - const parts = timeStr.trim().split(/\s+/); - if (parts.length < 3 || parts.length > 6) { - return false; - } - return parts.every((p) => /^[0-9*?/,LW#-]/.test(p)); -} - -/** - * Generate a cron job name from reminder content (first 20 chars). - * 根据提醒内容生成 cron job 名称。 - */ -function generateJobName(content: string): string { - const trimmed = content.trim(); - const short = trimmed.length > 20 ? `${truncateUtf16Safe(trimmed, 20)}…` : trimmed; - return `Reminder: ${short}`; -} - -/** Build the reminder system prompt sent to the AI. */ -function buildReminderPrompt(content: string): string { - return ( - `You are a warm reminder assistant. Please remind the user about: ${content}. ` + - `Requirements: (1) do not reply with HEARTBEAT_OK (2) do not explain who you are ` + - `(3) output a direct and caring reminder message (4) you may add a short encouraging line ` + - `(5) keep it within 2-3 sentences (6) use a small amount of emoji.` - ); -} - -/** Build cron job params for a one-shot delayed reminder. */ -function buildOnceJob(params: RemindParams, atMs: number, to: string, accountId: string) { - const content = params.content!; - const name = params.name || generateJobName(content); - return { - action: "add" as const, - job: { - name, - schedule: { kind: "at" as const, at: new Date(atMs).toISOString() }, - sessionTarget: "isolated" as const, - wakeMode: "now" as const, - deleteAfterRun: true, - payload: { - kind: "agentTurn" as const, - message: buildReminderPrompt(content), - // The scheduled turn only renders reminder text; delivery is host-owned. - toolsAllow: [], - }, - delivery: { - mode: "announce" as const, - channel: "qqbot" as const, - to, - accountId, - }, - }, - }; -} - -/** Build cron job params for a recurring cron reminder. */ -function buildCronJob(params: RemindParams, to: string, accountId: string) { - const content = params.content!; - const name = params.name || generateJobName(content); - const timezone = params.timezone?.trim(); - return { - action: "add" as const, - job: { - name, - schedule: { - kind: "cron" as const, - expr: params.time!.trim(), - ...(timezone ? { tz: timezone } : {}), - }, - sessionTarget: "isolated" as const, - wakeMode: "now" as const, - payload: { - kind: "agentTurn" as const, - message: buildReminderPrompt(content), - // The scheduled turn only renders reminder text; delivery is host-owned. - toolsAllow: [], - }, - delivery: { - mode: "announce" as const, - channel: "qqbot" as const, - to, - accountId, - }, - }, - }; -} - -/** Format a delay in milliseconds as a short string (e.g. "5m", "1h30m"). */ -function formatDelay(ms: number): string { - const totalSeconds = Math.round(ms / 1000); - if (totalSeconds < 60) { - return `${totalSeconds}s`; - } - const totalMinutes = Math.round(ms / 60_000); - if (totalMinutes < 60) { - return `${totalMinutes}m`; - } - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - if (minutes === 0) { - return `${hours}h`; - } - return `${hours}h${minutes}m`; -} - -function prepareRemindCronAction( - params: RemindParams, - ctx: RemindExecuteContext = {}, -): RemindCronPlan { - if (params.action === "list") { - return { ok: true, action: "list", cronAction: { action: "list" } }; - } - - if (params.action === "remove") { - if (!params.jobId) { - return { ok: false, error: "jobId is required when action=remove. Use action=list first." }; - } - return { - ok: true, - action: "remove", - cronAction: { action: "remove", jobId: params.jobId }, - }; - } - - if (!params.content) { - return { ok: false, error: "content is required when action=add" }; - } - const resolvedTo = params.to || ctx.fallbackTo; - if (!resolvedTo) { - return { - ok: false, - error: - "Unable to determine delivery target for action=add. " + - "The reminder can only be scheduled from within an active conversation.", - }; - } - if (!params.time) { - return { ok: false, error: "time is required when action=add" }; - } - const resolvedAccountId = ctx.fallbackAccountId || "default"; - - if (isCronExpression(params.time)) { - const timezone = params.timezone?.trim(); - return { - ok: true, - action: "add", - cronAction: buildCronJob(params, resolvedTo, resolvedAccountId), - summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${timezone || "gateway local"})`, - }; - } - - const delayMs = parseRelativeTime(params.time); - if (delayMs == null) { - return { - ok: false, - error: `Could not parse time format: ${params.time}. Use values like 5m, 1h, 1h30m, or a cron expression.`, - }; - } - if (delayMs < 30_000) { - return { ok: false, error: "Reminder delay must be at least 30 seconds" }; - } - const atMs = resolveExpiresAtMsFromDurationMs(delayMs); - if (atMs === undefined) { - return { ok: false, error: "Reminder time is outside the supported Date range" }; - } - - return { - ok: true, - action: "add", - cronAction: buildOnceJob(params, atMs, resolvedTo, resolvedAccountId), - summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${params.content}"`, - }; -} - -export async function executeScheduledRemind( - params: RemindParams, - ctx: RemindExecuteContext, - scheduler: RemindCronScheduler, -) { - const plan = prepareRemindCronAction(params, ctx); - if (!plan.ok) { - return json({ error: plan.error }); - } - - try { - const cronResult = await scheduler(plan.cronAction); - return json({ - ok: true, - action: plan.action, - summary: plan.summary, - cronResult, - }); - } catch (error) { - return json({ - error: `Failed to run Gateway cron action: ${formatErrorMessage(error)}`, - action: plan.action, - }); - } -} diff --git a/extensions/qqbot/src/engine/types.ts b/extensions/qqbot/src/engine/types.ts deleted file mode 100644 index ce74c622290e..000000000000 --- a/extensions/qqbot/src/engine/types.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Core API layer public types. - * - * These types are independent of the root `src/types.ts` and only define - * what the `core/api/` modules need. The old `src/types.ts` remains - * untouched for backward compatibility. - */ - -// ============ Structured API Error ============ - -/** - * Structured API error with HTTP status, path, and optional business error code. - * - * Compared to the old `api.ts` which throws plain `Error`, this carries - * machine-readable fields for downstream retry/fallback decisions. - */ -export class ApiError extends Error { - override readonly name = "ApiError"; - - constructor( - message: string, - /** HTTP status code returned by the QQ Open Platform. */ - public readonly httpStatus: number, - /** API path that produced the error (e.g. `/v2/users/{id}/messages`). */ - public readonly path: string, - /** Business error code from the response body (`code` or `err_code`). */ - public readonly bizCode?: number, - /** Original error message from the response body. */ - public readonly bizMessage?: string, - ) { - super(message); - } -} - -// ============ Logger ============ - -/** - * Unified logger interface used across all engine/ modules. - * - * Replaces the previously fragmented ApiLogger, GatewayLogger, ReconnectLogger, - * MessageRefLogger, PathLogger, and SenderLogger interfaces. - * - * `info` and `error` are required; `warn` and `debug` are optional because - * some callers (e.g. the framework-injected `ctx.log`) may not provide them. - */ -export interface EngineLogger { - info: (msg: string, meta?: Record) => void; - error: (msg: string, meta?: Record) => void; - warn?: (msg: string, meta?: Record) => void; - debug?: (msg: string, meta?: Record) => void; -} - -// ============ Chat Scope ============ - -/** Chat scope used to unify C2C/Group path construction. */ -export type ChatScope = "c2c" | "group"; - -// ============ Message Response ============ - -/** Standard message send response from the QQ Open Platform. */ -export interface MessageResponse { - id: string; - timestamp: number | string; - /** Reference index for future quoting. */ - ext_info?: { - ref_idx?: string; - }; -} - -// ============ Media Types ============ - -/** QQ Open Platform media file type codes. */ -export enum MediaFileType { - IMAGE = 1, - VIDEO = 2, - VOICE = 3, - FILE = 4, -} - -/** Media upload response from the QQ Open Platform. */ -export interface UploadMediaResponse { - file_uuid: string; - file_info: string; - ttl: number; - id?: string; -} - -/** Structured metadata recorded for outbound messages. */ -export interface OutboundMeta { - /** Message text content. */ - text?: string; - /** Media type tag. */ - mediaType?: "image" | "voice" | "video" | "file"; - /** Remote URL of the media source. */ - mediaUrl?: string; - /** Local file path of the media source. */ - mediaLocalPath?: string; - /** Original TTS text (voice messages only). */ - ttsText?: string; -} - -// ============ API Client Config ============ - -/** Configuration for the core HTTP client. */ -export interface ApiClientConfig { - /** Base URL for the QQ Open Platform REST API. */ - baseUrl?: string; - /** Default request timeout in milliseconds. */ - defaultTimeoutMs?: number; - /** File upload request timeout in milliseconds. */ - fileUploadTimeoutMs?: number; - /** Logger instance. */ - logger?: EngineLogger; - /** User-Agent header value, or a getter function for dynamic resolution. */ - userAgent?: string | (() => string); -} - -// ============ Chunked Upload Types ============ - -/** Individual upload part metadata. */ -export interface UploadPart { - /** Part index (1-based). */ - index: number; - /** Pre-signed upload URL. */ - presigned_url: string; -} - -/** Response from the upload_prepare endpoint. */ -export interface UploadPrepareResponse { - /** Upload task identifier. */ - upload_id: string; - /** Block size in bytes. */ - block_size: number; - /** Pre-signed upload parts. */ - parts: UploadPart[]; - /** Server-suggested upload concurrency. */ - concurrency?: number; - /** Server-suggested retry timeout for upload_part_finish (seconds). */ - retry_timeout?: number; -} - -/** File hash information for upload_prepare. */ -export interface UploadPrepareHashes { - /** Whole-file MD5 (hex). */ - md5: string; - /** Whole-file SHA1 (hex). */ - sha1: string; - /** MD5 of the first 10,002,432 bytes (hex). */ - md5_10m: string; -} - -// ============ Stream Message Types ============ - -/** Stream message input mode (C2C stream_messages API). */ -export const StreamInputMode = { - /** Each chunk replaces full message content. */ - REPLACE: "replace", -} as const; -export type StreamInputMode = (typeof StreamInputMode)[keyof typeof StreamInputMode]; - -/** Stream message input state (numeric per QQ Open Platform). */ -export const StreamInputState = { - GENERATING: 1, - DONE: 10, -} as const; -export type StreamInputState = (typeof StreamInputState)[keyof typeof StreamInputState]; - -/** Stream message content type. */ -export const StreamContentType = { - MARKDOWN: "markdown", -} as const; -export type StreamContentType = (typeof StreamContentType)[keyof typeof StreamContentType]; - -/** Stream message request body for `/v2/users/{openid}/stream_messages`. */ -export interface StreamMessageRequest { - input_mode: StreamInputMode; - input_state: StreamInputState; - content_type: StreamContentType; - content_raw: string; - event_id: string; - msg_id: string; - stream_msg_id?: string; - msg_seq: number; - index: number; -} - -// ============ Inline Keyboard Types ============ - -/** Inline keyboard button for approval/interaction flows. */ -export interface KeyboardButton { - id: string; - render_data: { - label: string; - visited_label: string; - style: number; - }; - action: { - type: number; - permission: { type: number }; - data: string; - click_limit?: number; - }; - group_id?: string; -} - -/** - * Inline keyboard structure attached to messages. - * Sent as the `keyboard` field in the message body: - * `{ "keyboard": { "content": { "rows": [...] } } }` - */ -export interface InlineKeyboard { - content: { - rows: Array<{ buttons: KeyboardButton[] }>; - }; -} - -// ============ Interaction Event Types ============ - -/** Button interaction event (INTERACTION_CREATE). */ -export interface InteractionEvent { - /** Event ID — used to acknowledge the interaction (PUT /interactions/{id}). */ - id: string; - /** Event sub-type: 11=message button, 12=c2c quick menu. */ - type: number; - /** Scene identifier: c2c / group / guild. */ - scene?: string; - /** Chat type: 0=guild, 1=group, 2=c2c. */ - chat_type?: number; - timestamp?: string; - guild_id?: string; - channel_id?: string; - /** C2C user openid (c2c scene only). */ - user_openid?: string; - /** Group openid (group scene only). */ - group_openid?: string; - /** Group member openid (group scene only). */ - group_member_openid?: string; - version: number; - data: { - type: number; - resolved: { - button_data?: string; - button_id?: string; - user_id?: string; - feature_id?: string; - message_id?: string; - }; - }; -} - -// ============ Account Config View ============ - -import type { QQBotDmPolicy, QQBotGroupPolicy } from "./access/types.js"; - -/** - * Typed view of known per-account configuration fields. - * - * Used for `as QQBotAccountConfigView` casts when reading fields from - * the raw `Record` config. The actual config type - * stays `Record` to avoid schema incompatibility. - */ -export interface QQBotAccountConfigView { - allowFrom?: Array; - groupAllowFrom?: Array; - dmPolicy?: QQBotDmPolicy; - groupPolicy?: QQBotGroupPolicy; - groups?: Record>; - streaming?: { - mode?: string; - nativeTransport?: boolean; - }; - audioFormatPolicy?: { - uploadDirectFormats?: string[]; - transcodeEnabled?: boolean; - }; -} - -// ============ Gateway Account ============ - -/** - * Resolved account configuration — shared across gateway/ and messaging/ layers. - * - * Lifted here from gateway/types.ts to eliminate the circular type dependency - * where messaging/ had to import from gateway/. - */ -export interface GatewayAccount { - accountId: string; - appId: string; - clientSecret: string; - markdownSupport: boolean; - systemPrompt?: string; - config: Record & { - allowFrom?: Array; - groupAllowFrom?: Array; - dmPolicy?: "open" | "allowlist" | "disabled"; - groupPolicy?: "open" | "allowlist" | "disabled"; - streaming?: { - mode?: string; - /** When true, use QQ's official C2C `stream_messages` API for DMs. */ - nativeTransport?: boolean; - }; - audioFormatPolicy?: { - uploadDirectFormats?: string[]; - transcodeEnabled?: boolean; - }; - }; -} diff --git a/extensions/qqbot/src/engine/utils/attachment-tags.test.ts b/extensions/qqbot/src/engine/utils/attachment-tags.test.ts deleted file mode 100644 index 081fedba6295..000000000000 --- a/extensions/qqbot/src/engine/utils/attachment-tags.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Qqbot tests cover attachment tags plugin behavior. -import { describe, expect, it } from "vitest"; -import type { RefAttachmentSummary as AttachmentSummary } from "../ref/types.js"; -import { formatAttachmentTags, renderAttachmentTags } from "./attachment-tags.js"; - -describe("engine/utils/attachment-tags", () => { - // ────────────────────────── shared body (mode-agnostic) ────────────────────────── - - describe("shared tag body", () => { - it("returns empty string for missing/empty input", () => { - expect(formatAttachmentTags()).toBe(""); - expect(formatAttachmentTags([])).toBe(""); - }); - - it("renders bracketed source tags when a path/url is present", () => { - expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe( - "[image: /tmp/a.png]", - ); - expect(formatAttachmentTags([{ type: "file", url: "https://x/y.pdf" }])).toBe( - "[file: https://x/y.pdf]", - ); - }); - - it("inlines voice transcript only for voice attachments", () => { - expect( - formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]), - ).toBe('[voice: /tmp/v.wav] (transcript: "hi")'); - // Non-voice attachments never get the transcript suffix even if one - // is present on the summary. - expect( - formatAttachmentTags([ - { type: "image", localPath: "/tmp/i.png", transcript: "unused" } as AttachmentSummary, - ]), - ).toBe("[image: /tmp/i.png]"); - }); - - it("falls back to bracketed tags when no source is available", () => { - expect(formatAttachmentTags([{ type: "image" }])).toBe("[image]"); - expect(formatAttachmentTags([{ type: "image", filename: "a.png" }])).toBe("[image: a.png]"); - expect(formatAttachmentTags([{ type: "voice" }])).toBe("[voice]"); - expect(formatAttachmentTags([{ type: "voice", transcript: "t" }])).toBe( - '[voice (transcript: "t")]', - ); - expect(formatAttachmentTags([{ type: "video" }])).toBe("[video]"); - expect(formatAttachmentTags([{ type: "file", filename: "b.pdf" }])).toBe("[file: b.pdf]"); - expect(formatAttachmentTags([{ type: "unknown" }])).toBe("[attachment]"); - }); - - it("joins multiple entries with newline in inline mode", () => { - expect( - formatAttachmentTags([ - { type: "image", localPath: "/tmp/a.png" }, - { type: "voice", transcript: "hi" }, - ]), - ).toBe('[image: /tmp/a.png]\n[voice (transcript: "hi")]'); - }); - }); - - // ────────────────────────── ref mode = body + source suffix ────────────────────────── - - describe("ref mode consistency with inline", () => { - it("produces the same body as inline for non-voice attachments", () => { - const att: AttachmentSummary[] = [ - { type: "image", localPath: "/tmp/a.png" }, - { type: "file", filename: "b.pdf" }, - ]; - // Rendered one at a time so separator differences don't matter. - for (const a of att) { - expect(renderAttachmentTags([a], { mode: "inline" })).toBe( - renderAttachmentTags([a], { mode: "ref" }), - ); - } - }); - - it("produces the same body as inline for voice without transcriptSource", () => { - const cases: AttachmentSummary[] = [ - { type: "voice" }, - { type: "voice", transcript: "hi" }, - { type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }, - ]; - for (const a of cases) { - expect(renderAttachmentTags([a], { mode: "inline" })).toBe( - renderAttachmentTags([a], { mode: "ref" }), - ); - } - }); - - it("appends ' [source: …]' ONLY for voice + transcript + transcriptSource in ref mode", () => { - // ref mode: suffix appears. - expect( - renderAttachmentTags( - [{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }], - { mode: "ref" }, - ), - ).toBe('[voice: /tmp/v.wav] (transcript: "hi") [source: local STT]'); - - // inline mode: suffix NEVER appears, even with transcriptSource set. - expect( - renderAttachmentTags( - [{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }], - { mode: "inline" }, - ), - ).toBe('[voice: /tmp/v.wav] (transcript: "hi")'); - }); - - it("omits the source suffix when transcriptSource is missing (both modes identical)", () => { - const att: AttachmentSummary = { type: "voice", transcript: "hi" }; - expect(renderAttachmentTags([att], { mode: "ref" })).toBe( - renderAttachmentTags([att], { mode: "inline" }), - ); - }); - - it("joins with space in ref mode", () => { - expect( - renderAttachmentTags( - [ - { type: "image", filename: "a.png" }, - { type: "voice", transcript: "hi" }, - ], - { mode: "ref" }, - ), - ).toBe('[image: a.png] [voice (transcript: "hi")]'); - }); - }); - - // ────────────────────────── Prompt-contract regression guards ────────────────────────── - - describe("prompt contract", () => { - it("renders each transcript-source label", () => { - const expected = { - stt: "local STT", - asr: "platform ASR", - tts: "TTS source", - fallback: "fallback text", - } as const; - for (const [transcriptSource, label] of Object.entries(expected)) { - expect( - renderAttachmentTags( - [ - { - type: "voice", - transcript: "hello", - transcriptSource: transcriptSource as AttachmentSummary["transcriptSource"], - }, - ], - { mode: "ref" }, - ), - ).toContain(`[source: ${label}]`); - } - }); - - it("uses the single canonical keyword 'transcript:' (never 'content:')", () => { - // If anyone reintroduces 'content:' the regex below will match and fail the test. - const samples = [ - formatAttachmentTags([{ type: "voice", transcript: "t" }]), - renderAttachmentTags([{ type: "voice", transcript: "t", transcriptSource: "asr" }], { - mode: "ref", - }), - ]; - for (const s of samples) { - expect(s).toMatch(/transcript:/); - expect(s).not.toMatch(/content:/); - } - }); - - it("uses the single canonical type label 'voice' (never 'voice message')", () => { - const samples = [ - renderAttachmentTags([{ type: "voice" }], { mode: "inline" }), - renderAttachmentTags([{ type: "voice", transcript: "hi" }], { mode: "ref" }), - ]; - for (const s of samples) { - expect(s).not.toMatch(/voice message/); - } - }); - }); - - // ────────────────────────── Options ────────────────────────── - - describe("options", () => { - it("respects a custom separator", () => { - expect( - renderAttachmentTags( - [ - { type: "image", filename: "a" }, - { type: "video", filename: "b" }, - ], - { mode: "inline", separator: " | " }, - ), - ).toBe("[image: a] | [video: b]"); - }); - - it("returns the emptyFallback when input is empty", () => { - expect(renderAttachmentTags(undefined, { mode: "ref", emptyFallback: "(none)" })).toBe( - "(none)", - ); - expect(renderAttachmentTags([], { mode: "inline", emptyFallback: "" })).toBe(""); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/attachment-tags.ts b/extensions/qqbot/src/engine/utils/attachment-tags.ts deleted file mode 100644 index ece9953a6a18..000000000000 --- a/extensions/qqbot/src/engine/utils/attachment-tags.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Single source of truth for rendering attachment summaries as - * human-readable tags that the LLM sees. - * - * There is exactly ONE vocabulary shared by every consumer: - * - * • Type labels: `image` / `voice` / `video` / `file` / `attachment` - * • Keyword for voice text: `transcript:` (never `content:`) - * • With source: `[{type}: {source}]` - * • Without source: `[{type}]` or `[{type}: {filename}]` - * - * Both consumers (group history / current inbound event, and the ref-index - * quoted-message block) call the same function with the same vocabulary. - * They differ only on two orthogonal dimensions: - * - * 1. `transcriptSource` — ref mode appends `[source: local STT]` (or - * similar) after a voice transcript so the model knows where the - * text came from. Inline mode omits this (the current turn knows - * its own STT provenance). - * - * 2. Separator — inline joins with `\n` (history replay is multi-line), - * ref joins with a space (quoted block is rendered inline). - * - * These are the ONLY permitted differences between modes. Any new - * decoration must be added in both modes or behind an explicit option - * documented here, otherwise the model ends up learning two dialects. - * - * Zero external dependencies — pure string formatting. - */ - -import type { RefAttachmentSummary } from "../ref/types.js"; - -// ============ Types ============ - -/** Canonical attachment shape shared by history entries and ref entries. */ -type AttachmentSummary = RefAttachmentSummary; - -/** - * Rendering mode. - * - * - `"inline"`: current turn + history replay. No transcript-source tag. - * Tags are separated by newlines. - * - `"ref"`: quoted-message block. Appends `[source: …]` to voice - * transcripts when `transcriptSource` is present. Tags are separated - * by spaces so the block fits on one line. - */ -type RenderMode = "inline" | "ref"; - -/** Human-readable labels for transcript provenance (prompt contract). */ -const TRANSCRIPT_SOURCE_LABELS: Record< - NonNullable, - string -> = { - stt: "local STT", - asr: "platform ASR", - tts: "TTS source", - fallback: "fallback text", -}; - -/** Options controlling how the tag list is rendered. */ -interface RenderOptions { - mode: RenderMode; - /** Separator between tags. Defaults per mode: inline=`\n`, ref=` `. */ - separator?: string; - /** Returned when `attachments` is empty/undefined. Defaults to `""`. */ - emptyFallback?: string; -} - -// ============ Public API ============ - -/** - * Render a list of attachments into an LLM-facing tag string. - * - * Shared grammar (both modes): - * - * ``` - * attachment_with_source := "[" TYPE_LABEL ": " SOURCE "]" [voice_suffix] - * voice_suffix := ' (transcript: "' TEXT '")' [source_suffix] - * attachment_no_source := "[" TYPE_LABEL [": " FILENAME] [voice_suffix_bare] "]" [source_suffix_bare] - * voice_suffix_bare := ' (transcript: "' TEXT '")' - * source_suffix := " [source: " LABEL "]" ← ref mode only - * source_suffix_bare := " [source: " LABEL "]" ← ref mode only - * TYPE_LABEL := "image" | "voice" | "video" | "file" | "attachment" - * ``` - * - * The **only** mode-dependent decoration is the `source_suffix` (present - * in `ref`, absent in `inline`). Every other token is identical. - */ -export function renderAttachmentTags( - attachments: readonly AttachmentSummary[] | undefined, - options: RenderOptions, -): string { - if (!attachments?.length) { - return options.emptyFallback ?? ""; - } - - const parts: string[] = []; - for (const att of attachments) { - parts.push(renderOne(att, options.mode)); - } - - const separator = options.separator ?? (options.mode === "ref" ? " " : "\n"); - return parts.join(separator); -} - -/** - * Shorthand for `renderAttachmentTags(attachments, { mode: "inline" })`. - * - * Kept as the primary entry point for group history / current-turn - * rendering where the terse inline form is always wanted. - */ -export function formatAttachmentTags(attachments?: readonly AttachmentSummary[]): string { - return renderAttachmentTags(attachments, { mode: "inline" }); -} - -// ============ Internal ============ - -/** - * Render a single attachment. - * - * The function is split into two orthogonal concerns: - * - `renderBody`: the shared "[type: source]…" or "[type…]" string. - * - `renderSourceSuffix`: ref-mode-only `" [source: …]"` tail. - * - * Both consumers produce the same body; only the suffix differs. - */ -function renderOne(att: AttachmentSummary, mode: RenderMode): string { - const body = renderBody(att); - const suffix = mode === "ref" ? renderSourceSuffix(att) : ""; - return body + suffix; -} - -/** Shared, mode-agnostic body of the tag. */ -function renderBody(att: AttachmentSummary): string { - const source = att.localPath || att.url; - const voiceSuffix = - att.type === "voice" && att.transcript ? ` (transcript: "${att.transcript}")` : ""; - const label = labelForType(att.type); - - if (source) { - return `[${label}: ${source}]${voiceSuffix}`; - } - - const namePart = att.filename ? `: ${att.filename}` : ""; - return `[${label}${namePart}${voiceSuffix}]`; -} - -/** - * Ref-mode-only tail that records where a voice transcript came from. - * Empty string when the attachment isn't a transcribed voice message. - */ -function renderSourceSuffix(att: AttachmentSummary): string { - if (att.type !== "voice" || !att.transcript || !att.transcriptSource) { - return ""; - } - const label = TRANSCRIPT_SOURCE_LABELS[att.transcriptSource] ?? att.transcriptSource; - return ` [source: ${label}]`; -} - -/** Canonical single-word label for each attachment type. */ -function labelForType(type: AttachmentSummary["type"]): string { - switch (type) { - case "image": - return "image"; - case "voice": - return "voice"; - case "video": - return "video"; - case "file": - return "file"; - default: - return "attachment"; - } -} diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts deleted file mode 100644 index 11bd3f16a513..000000000000 --- a/extensions/qqbot/src/engine/utils/audio.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Qqbot tests cover audio plugin behavior. -import { describe, expect, it } from "vitest"; -import { isVoiceAttachment, isAudioFile, shouldTranscodeVoice } from "./audio.js"; - -describe("engine/utils/audio", () => { - describe("isVoiceAttachment", () => { - it("detects voice content_type", () => { - expect(isVoiceAttachment({ content_type: "voice" })).toBe(true); - }); - - it("detects audio/* content_type", () => { - expect(isVoiceAttachment({ content_type: "audio/silk" })).toBe(true); - expect(isVoiceAttachment({ content_type: "audio/amr" })).toBe(true); - }); - - it("detects voice file extensions", () => { - expect(isVoiceAttachment({ filename: "msg.amr" })).toBe(true); - expect(isVoiceAttachment({ filename: "msg.silk" })).toBe(true); - expect(isVoiceAttachment({ filename: "msg.slk" })).toBe(true); - expect(isVoiceAttachment({ filename: "msg.slac" })).toBe(true); - }); - - it("treats content_type case-insensitively", () => { - expect(isVoiceAttachment({ content_type: "Voice" })).toBe(true); - expect(isVoiceAttachment({ content_type: "Audio/Silk" })).toBe(true); - expect(isVoiceAttachment({ content_type: "Image/PNG" })).toBe(false); - }); - - it("rejects non-voice attachments", () => { - expect(isVoiceAttachment({ content_type: "image/png" })).toBe(false); - expect(isVoiceAttachment({ filename: "photo.jpg" })).toBe(false); - }); - - it("handles missing fields", () => { - expect(isVoiceAttachment({})).toBe(false); - }); - }); - - describe("isAudioFile", () => { - it.each([ - ".silk", - ".slk", - ".amr", - ".wav", - ".mp3", - ".ogg", - ".opus", - ".aac", - ".flac", - ".m4a", - ".wma", - ".pcm", - ])("recognizes %s as audio", (ext) => { - expect(isAudioFile(`file${ext}`)).toBe(true); - }); - - it("recognizes audio MIME types", () => { - expect(isAudioFile("file.bin", "audio/mpeg")).toBe(true); - expect(isAudioFile("file.bin", "voice")).toBe(true); - }); - - it("rejects non-audio files", () => { - expect(isAudioFile("photo.jpg")).toBe(false); - expect(isAudioFile("doc.pdf")).toBe(false); - }); - - it("is case-insensitive on extensions", () => { - expect(isAudioFile("file.MP3")).toBe(true); - expect(isAudioFile("file.Wav")).toBe(true); - }); - }); - - describe("shouldTranscodeVoice", () => { - it("returns false for QQ native MIME types", () => { - expect(shouldTranscodeVoice("file.bin", "audio/silk")).toBe(false); - expect(shouldTranscodeVoice("file.bin", "audio/amr")).toBe(false); - expect(shouldTranscodeVoice("file.bin", "audio/wav")).toBe(false); - expect(shouldTranscodeVoice("file.bin", "audio/mp3")).toBe(false); - }); - - it("returns false for QQ native extensions", () => { - expect(shouldTranscodeVoice("voice.silk")).toBe(false); - expect(shouldTranscodeVoice("voice.amr")).toBe(false); - expect(shouldTranscodeVoice("voice.wav")).toBe(false); - expect(shouldTranscodeVoice("voice.mp3")).toBe(false); - }); - - it("returns true for non-native audio formats", () => { - expect(shouldTranscodeVoice("voice.ogg")).toBe(true); - expect(shouldTranscodeVoice("voice.opus")).toBe(true); - expect(shouldTranscodeVoice("voice.flac")).toBe(true); - expect(shouldTranscodeVoice("voice.aac")).toBe(true); - }); - - it("returns false for non-audio files", () => { - expect(shouldTranscodeVoice("photo.jpg")).toBe(false); - expect(shouldTranscodeVoice("doc.txt")).toBe(false); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/audio.ts b/extensions/qqbot/src/engine/utils/audio.ts deleted file mode 100644 index 6f9ca9ca6a7d..000000000000 --- a/extensions/qqbot/src/engine/utils/audio.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Audio format conversion utilities. - * 音频格式转换工具。 - * - * Handles SILK ↔ PCM ↔ WAV ↔ MP3 conversions for QQ Bot voice messaging. - * Uses WASM decoders (silk-wasm, mpg123-decoder) and direct QQ-native uploads - * without launching native subprocesses. - * - * Self-contained within engine/ — no framework SDK dependency. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import { readRegularFileSync } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { debugLog, debugError, debugWarn } from "./log.js"; - -type SilkWasm = typeof import("silk-wasm"); -let silkWasmPromise: Promise | null = null; - -/** Lazy-load the silk-wasm module (singleton cache; returns null on failure). */ -function loadSilkWasm(): Promise { - if (silkWasmPromise) { - return silkWasmPromise; - } - silkWasmPromise = import("silk-wasm").catch((err: unknown) => { - debugWarn( - `[audio-convert] silk-wasm not available; SILK encode/decode disabled (${formatErrorMessage(err)})`, - ); - return null; - }); - return silkWasmPromise; -} - -/** Wrap raw PCM s16le data into a standard WAV file. */ -function pcmToWav( - pcmData: Uint8Array, - sampleRate: number, - channels = 1, - bitsPerSample = 16, -): Buffer { - const byteRate = sampleRate * channels * (bitsPerSample / 8); - const blockAlign = channels * (bitsPerSample / 8); - const dataSize = pcmData.length; - const headerSize = 44; - const fileSize = headerSize + dataSize; - - const buffer = Buffer.alloc(fileSize); - - buffer.write("RIFF", 0); - buffer.writeUInt32LE(fileSize - 8, 4); - buffer.write("WAVE", 8); - - buffer.write("fmt ", 12); - buffer.writeUInt32LE(16, 16); - buffer.writeUInt16LE(1, 20); - buffer.writeUInt16LE(channels, 22); - buffer.writeUInt32LE(sampleRate, 24); - buffer.writeUInt32LE(byteRate, 28); - buffer.writeUInt16LE(blockAlign, 32); - buffer.writeUInt16LE(bitsPerSample, 34); - - buffer.write("data", 36); - buffer.writeUInt32LE(dataSize, 40); - Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize); - - return buffer; -} - -/** Strip the AMR header that may be present in QQ voice payloads. */ -function stripAmrHeader(buf: Buffer): Buffer { - const AMR_HEADER = Buffer.from("#!AMR\n"); - if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) { - return buf.subarray(6); - } - return buf; -} - -/** Convert a SILK or AMR voice file to WAV format. */ -export async function convertSilkToWav( - inputPath: string, - outputDir?: string, -): Promise<{ wavPath: string; duration: number } | null> { - let fileBuf: Buffer; - try { - fileBuf = readRegularFileSync({ filePath: inputPath }).buffer; - } catch { - return null; - } - - const strippedBuf = stripAmrHeader(fileBuf); - const silk = await loadSilkWasm(); - if (!silk || !silk.isSilk(strippedBuf)) { - return null; - } - - const sampleRate = 24000; - const result = await silk.decode(strippedBuf, sampleRate); - const wavBuffer = pcmToWav(result.data, sampleRate); - - const dir = outputDir || path.dirname(inputPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - const baseName = path.basename(inputPath, path.extname(inputPath)); - const wavPath = path.join(dir, `${baseName}.wav`); - fs.writeFileSync(wavPath, wavBuffer); - - return { wavPath, duration: result.duration }; -} - -/** Check whether an attachment is a voice file (by MIME type or extension). */ -export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean { - // MIME types are case-insensitive (RFC 2045) and relays may emit mixed-case - // values; the bare "voice" platform sentinel gets the same treatment. - // Compare lowercased like the extension check below. - const contentType = normalizeLowercaseStringOrEmpty(att.content_type); - if (contentType === "voice" || contentType.startsWith("audio/")) { - return true; - } - const ext = att.filename ? normalizeLowercaseStringOrEmpty(path.extname(att.filename)) : ""; - return [".amr", ".silk", ".slk", ".slac"].includes(ext); -} - -/** Check whether a file path is a known audio format. */ -export function isAudioFile(filePath: string, mimeType?: string): boolean { - if (mimeType) { - if (mimeType === "voice" || mimeType.startsWith("audio/")) { - return true; - } - } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); - return [ - ".silk", - ".slk", - ".amr", - ".wav", - ".mp3", - ".ogg", - ".opus", - ".aac", - ".flac", - ".m4a", - ".wma", - ".pcm", - ].includes(ext); -} - -const QQ_NATIVE_VOICE_MIMES = new Set([ - "audio/silk", - "audio/amr", - "audio/wav", - "audio/wave", - "audio/x-wav", - "audio/mpeg", - "audio/mp3", -]); - -const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]); - -/** Check whether a voice file needs transcoding for upload (QQ-native formats skip it). */ -export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean { - if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) { - return false; - } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); - if (QQ_NATIVE_VOICE_EXTS.has(ext)) { - return false; - } - return isAudioFile(filePath, mimeType); -} - -const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"]; - -function normalizeFormats(formats: string[]): string[] { - return formats.map((f) => { - const lower = normalizeLowercaseStringOrEmpty(f); - return lower.startsWith(".") ? lower : `.${lower}`; - }); -} - -/** - * Convert a local audio file to Base64-encoded SILK for QQ API upload. - * - * Attempts conversion via direct QQ-native upload → WASM decoders → null fallback chain. - */ -export async function audioFileToSilkBase64( - filePath: string, - directUploadFormats?: string[], -): Promise { - let buf: Buffer; - try { - buf = readRegularFileSync({ filePath }).buffer; - } catch { - return null; - } - - if (buf.length === 0) { - debugError(`[audio-convert] file is empty: ${filePath}`); - return null; - } - - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); - const uploadFormats = directUploadFormats - ? normalizeFormats(directUploadFormats) - : QQ_NATIVE_UPLOAD_FORMATS; - if (uploadFormats.includes(ext)) { - debugLog(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`); - return buf.toString("base64"); - } - - const stripped = stripAmrHeader(buf); - const silk = await loadSilkWasm(); - if (silk?.isSilk(buf) || silk?.isSilk(stripped)) { - debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`); - return buf.toString("base64"); - } - - const targetRate = 24000; - - debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`); - - if (ext === ".pcm") { - const silkBuffer = await pcmToSilk(buf, targetRate); - return silkBuffer.toString("base64"); - } - - if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) { - const wavInfo = parseWavFallback(buf); - if (wavInfo) { - const silkBuffer = await pcmToSilk(wavInfo, targetRate); - return silkBuffer.toString("base64"); - } - } - - if (ext === ".mp3" || ext === ".mpeg") { - const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate); - if (pcmBuf) { - const silkBuffer = await pcmToSilk(pcmBuf, targetRate); - debugLog(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`); - return silkBuffer.toString("base64"); - } - } - - debugError( - `[audio-convert] unsupported format without native subprocess conversion: ${ext}. Use QQ-native voice formats or WAV/MP3/PCM inputs.`, - ); - return null; -} - -/** - * Wait for a file to appear and stabilize, then return its final size. - * - * Polls at `pollMs` intervals; returns 0 on timeout or persistent empty file. - */ -export async function waitForFile( - filePath: string, - timeoutMs = 30000, - pollMs = 500, -): Promise { - const start = Date.now(); - let lastSize = -1; - let stableCount = 0; - let fileExists = false; - let fileAppearedAt = 0; - let pollCount = 0; - - const emptyGiveUpMs = 10000; - const noFileGiveUpMs = 15000; - - while (Date.now() - start < timeoutMs) { - pollCount++; - try { - const stat = fs.statSync(filePath); - if (!fileExists) { - fileExists = true; - fileAppearedAt = Date.now(); - debugLog( - `[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`, - ); - } - if (stat.size > 0) { - if (stat.size === lastSize) { - stableCount++; - if (stableCount >= 2) { - debugLog( - `[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`, - ); - return stat.size; - } - } else { - stableCount = 0; - } - lastSize = stat.size; - } else if (Date.now() - fileAppearedAt > emptyGiveUpMs) { - debugError( - `[audio-convert] waitForFile: file still empty after ${emptyGiveUpMs}ms, giving up: ${path.basename(filePath)}`, - ); - return 0; - } - } catch { - if (!fileExists && Date.now() - start > noFileGiveUpMs) { - debugError( - `[audio-convert] waitForFile: file never appeared after ${noFileGiveUpMs}ms, giving up: ${path.basename(filePath)}`, - ); - return 0; - } - } - await new Promise((r) => { - setTimeout(r, pollMs); - }); - } - - try { - const finalStat = fs.statSync(filePath); - if (finalStat.size > 0) { - debugWarn( - `[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`, - ); - return finalStat.size; - } - debugError( - `[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`, - ); - } catch { - debugError( - `[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`, - ); - } - return 0; -} - -/** Encode PCM s16le data into SILK format. */ -async function pcmToSilk(pcmBuffer: Buffer, sampleRate: number): Promise { - const silk = await loadSilkWasm(); - if (!silk) { - throw new Error("silk-wasm is not available; cannot encode PCM to SILK"); - } - const result = await silk.encode(pcmBuffer, sampleRate); - return Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength); -} - -/** Decode MP3 to PCM via mpg123-decoder WASM. */ -async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { - try { - const { MPEGDecoder } = await import("mpg123-decoder"); - debugLog(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`); - const decoder = new MPEGDecoder(); - await decoder.ready; - - const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)); - decoder.free(); - - if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) { - debugError( - `[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`, - ); - return null; - } - - debugLog( - `[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`, - ); - - let floatMono: Float32Array; - if (decoded.channelData.length === 1) { - floatMono = expectDefined(decoded.channelData.at(0), "single decoded MP3 channel"); - } else { - floatMono = new Float32Array(decoded.samplesDecoded); - const channels = decoded.channelData.length; - for (let i = 0; i < decoded.samplesDecoded; i++) { - let sum = 0; - for (const channel of decoded.channelData) { - sum += expectDefined(channel.at(i), "decoded MP3 channel sample"); - } - floatMono[i] = sum / channels; - } - } - - const s16 = new Uint8Array(floatMono.length * 2); - const view = new DataView(s16.buffer); - for (let i = 0; i < floatMono.length; i++) { - const sample = expectDefined(floatMono.at(i), "mono MP3 sample index"); - const clamped = Math.max(-1, Math.min(1, sample)); - const val = clamped < 0 ? clamped * 32768 : clamped * 32767; - view.setInt16(i * 2, Math.round(val), true); - } - - let pcm: Uint8Array = s16; - if (decoded.sampleRate !== targetRate) { - const inputSamples = s16.length / 2; - const outputSamples = Math.round((inputSamples * targetRate) / decoded.sampleRate); - const output = new Uint8Array(outputSamples * 2); - const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength); - const outView = new DataView(output.buffer, output.byteOffset, output.byteLength); - for (let i = 0; i < outputSamples; i++) { - const srcIdx = (i * decoded.sampleRate) / targetRate; - const idx0 = Math.floor(srcIdx); - const idx1 = Math.min(idx0 + 1, inputSamples - 1); - const frac = srcIdx - idx0; - const s0 = inView.getInt16(idx0 * 2, true); - const s1 = inView.getInt16(idx1 * 2, true); - const sample = Math.round(s0 + (s1 - s0) * frac); - outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true); - } - pcm = output; - } - - return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength); - } catch (err) { - debugError(`[audio-convert] WASM MP3 decode failed: ${formatErrorMessage(err)}`); - if (err instanceof Error && err.stack) { - debugError(`[audio-convert] stack: ${err.stack}`); - } - return null; - } -} - -/** Parse a standard PCM WAV and extract mono 24 kHz PCM data. */ -function parseWavFallback(buf: Buffer): Buffer | null { - if (buf.length < 44) { - return null; - } - if (buf.toString("ascii", 0, 4) !== "RIFF") { - return null; - } - if (buf.toString("ascii", 8, 12) !== "WAVE") { - return null; - } - if (buf.toString("ascii", 12, 16) !== "fmt ") { - return null; - } - - const audioFormat = buf.readUInt16LE(20); - if (audioFormat !== 1) { - return null; - } - - const channels = buf.readUInt16LE(22); - const sampleRate = buf.readUInt32LE(24); - const bitsPerSample = buf.readUInt16LE(34); - if (bitsPerSample !== 16) { - return null; - } - - let offset = 36; - while (offset < buf.length - 8) { - const chunkId = buf.toString("ascii", offset, offset + 4); - const chunkSize = buf.readUInt32LE(offset + 4); - if (chunkId === "data") { - const dataStart = offset + 8; - const dataEnd = Math.min(dataStart + chunkSize, buf.length); - let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart); - - if (channels > 1) { - const samplesPerCh = pcm.length / (2 * channels); - const mono = new Uint8Array(samplesPerCh * 2); - const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); - const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength); - for (let i = 0; i < samplesPerCh; i++) { - let sum = 0; - for (let ch = 0; ch < channels; ch++) { - sum += inV.getInt16((i * channels + ch) * 2, true); - } - outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true); - } - pcm = mono; - } - - const targetRate = 24000; - if (sampleRate !== targetRate) { - const inSamples = pcm.length / 2; - const outSamples = Math.round((inSamples * targetRate) / sampleRate); - const out = new Uint8Array(outSamples * 2); - const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength); - const outV = new DataView(out.buffer, out.byteOffset, out.byteLength); - for (let i = 0; i < outSamples; i++) { - const src = (i * sampleRate) / targetRate; - const i0 = Math.floor(src); - const i1 = Math.min(i0 + 1, inSamples - 1); - const f = src - i0; - const s0 = inV.getInt16(i0 * 2, true); - const s1 = inV.getInt16(i1 * 2, true); - outV.setInt16( - i * 2, - Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))), - true, - ); - } - pcm = out; - } - - return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength); - } - offset += 8 + chunkSize; - } - - return null; -} diff --git a/extensions/qqbot/src/engine/utils/diagnostics.test.ts b/extensions/qqbot/src/engine/utils/diagnostics.test.ts deleted file mode 100644 index fe99fce8305f..000000000000 --- a/extensions/qqbot/src/engine/utils/diagnostics.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const platformMocks = vi.hoisted(() => ({ - checkSilkWasmAvailable: vi.fn(async () => true), - getHomeDir: vi.fn(() => ""), - getQQBotDataDir: vi.fn(() => ""), - getTempDir: vi.fn(() => ""), - isWindows: vi.fn(() => true), -})); -const debugLogMock = vi.hoisted(() => vi.fn()); - -vi.mock("./platform.js", () => platformMocks); -vi.mock("./log.js", () => ({ debugLog: debugLogMock })); - -import { runDiagnostics } from "./diagnostics.js"; - -describe("QQBot startup diagnostics", () => { - const tempRoots: string[] = []; - - afterEach(() => { - vi.unstubAllEnvs(); - vi.clearAllMocks(); - for (const root of tempRoots.splice(0)) { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it("does not probe legacy storage or recommend an unsupported override", async () => { - const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-diagnostics-")); - tempRoots.push(testRoot); - const windowsHome = path.join(testRoot, "Users", "张 家豪"); - const openclawHome = path.join(testRoot, "OpenClaw Home"); - fs.mkdirSync(windowsHome, { recursive: true }); - fs.mkdirSync(openclawHome, { recursive: true }); - vi.stubEnv("HOME", windowsHome); - vi.stubEnv("USERPROFILE", windowsHome); - vi.stubEnv("OPENCLAW_HOME", openclawHome); - - const legacyDataDir = path.join(windowsHome, ".openclaw", "qqbot"); - platformMocks.getHomeDir.mockReturnValue(windowsHome); - platformMocks.getTempDir.mockReturnValue(path.join(openclawHome, "tmp")); - platformMocks.getQQBotDataDir.mockImplementation(() => { - fs.mkdirSync(legacyDataDir, { recursive: true }); - return legacyDataDir; - }); - - const report = await runDiagnostics(); - const output = [JSON.stringify(report), ...debugLogMock.mock.calls.flat()].join("\n"); - - expect(report.homeDir).toBe(windowsHome); - expect(report).not.toHaveProperty("dataDir"); - expect(platformMocks.getQQBotDataDir).not.toHaveBeenCalled(); - expect(fs.existsSync(legacyDataDir)).toBe(false); - expect(output).not.toContain("Data dir"); - expect(output).not.toContain("QQBOT_DATA_DIR"); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/diagnostics.ts b/extensions/qqbot/src/engine/utils/diagnostics.ts deleted file mode 100644 index 1c131942acb6..000000000000 --- a/extensions/qqbot/src/engine/utils/diagnostics.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Gateway startup diagnostics — extracted from utils/platform.ts. - * - * Depends on utils/platform.ts for detection functions, but no plugin-sdk. - */ - -import * as os from "node:os"; -import { debugLog } from "./log.js"; -import { getHomeDir, getTempDir, checkSilkWasmAvailable } from "./platform.js"; - -interface DiagnosticReport { - platform: string; - arch: string; - nodeVersion: string; - homeDir: string; - tempDir: string; - silkWasm: boolean; - warnings: string[]; -} - -/** - * Run startup diagnostics and return an environment report. - * Called during gateway startup to log environment details and warnings. - */ -export async function runDiagnostics(): Promise { - const warnings: string[] = []; - - const platform = `${process.platform} (${os.release()})`; - const arch = process.arch; - const nodeVersion = process.version; - const homeDir = getHomeDir(); - const tempDir = getTempDir(); - - const silkWasm = await checkSilkWasmAvailable(); - if (!silkWasm) { - warnings.push( - "⚠️ silk-wasm is unavailable. QQ voice send/receive will not work. Ensure Node.js >= 16 and WASM support are available.", - ); - } - - const report: DiagnosticReport = { - platform, - arch, - nodeVersion, - homeDir, - tempDir, - silkWasm, - warnings, - }; - - debugLog("=== QQBot Environment Diagnostics ==="); - debugLog(` Platform: ${platform} (${arch})`); - debugLog(` Node: ${nodeVersion}`); - debugLog(` Home: ${homeDir}`); - debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`); - if (warnings.length > 0) { - debugLog(" --- Warnings ---"); - for (const w of warnings) { - debugLog(` ${w}`); - } - } - debugLog("======================"); - - return report; -} diff --git a/extensions/qqbot/src/engine/utils/file-utils.test.ts b/extensions/qqbot/src/engine/utils/file-utils.test.ts deleted file mode 100644 index 5939133a555b..000000000000 --- a/extensions/qqbot/src/engine/utils/file-utils.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Qqbot tests cover file utils plugin behavior. -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -async function createSymlinkedFile(targetPath: string, linkPath: string): Promise { - try { - await fs.promises.writeFile(targetPath, "image-bytes"); - await fs.promises.symlink(targetPath, linkPath, "file"); - return true; - } catch { - await fs.promises.rm(linkPath, { force: true }); - await fs.promises.rm(targetPath, { force: true }); - return false; - } -} - -const adapterMocks = vi.hoisted(() => ({ - fetchMedia: vi.fn(), -})); - -vi.mock("../adapter/index.js", () => ({ - getPlatformAdapter: () => ({ - fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args), - }), -})); - -import { - checkFileSize, - downloadFile, - fileExistsAsync, - formatFileSize, - getImageMimeType, - getMimeType, - readFileAsync, -} from "./file-utils.js"; - -describe("formatFileSize", () => { - it("preserves compact binary-scaled upload labels", () => { - expect(formatFileSize(512)).toBe("512B"); - expect(formatFileSize(1536)).toBe("1.5KB"); - expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0MB"); - }); -}); - -describe("qqbot file-utils MIME helpers", () => { - it("uses the shared media MIME table for extension inference", () => { - expect(getMimeType("voice.mp3")).toBe("audio/mpeg"); - expect(getMimeType("clip.webm")).toBe("video/webm"); - expect(getMimeType("clip.avi")).toBe("video/x-msvideo"); - expect(getMimeType("clip.mkv")).toBe("video/x-matroska"); - expect(getMimeType("archive.unknown")).toBe("application/octet-stream"); - }); - - it("keeps the image-only gate for image MIME inference", () => { - expect(getImageMimeType("photo.PNG")).toBe("image/png"); - expect(getImageMimeType("clip.webm")).toBeNull(); - expect(getImageMimeType("archive.unknown")).toBeNull(); - }); -}); - -describe("qqbot file-utils downloadFile", () => { - let tempDir: string; - - beforeEach(async () => { - adapterMocks.fetchMedia.mockReset(); - tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "qqbot-file-utils-")); - }); - - afterEach(async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }); - - it("downloads through the guarded media adapter with the qqbot SSRF policy", async () => { - adapterMocks.fetchMedia.mockResolvedValueOnce({ - buffer: Buffer.from("image-bytes"), - contentType: "image/png", - fileName: "remote.png", - }); - - const savedPath = await downloadFile( - "https://media.qq.com/assets/photo.png", - tempDir, - "photo.png", - ); - - if (!savedPath) { - throw new Error("expected QQBot media file path"); - } - expect(savedPath).toMatch(/photo_\d+_[0-9a-f]{6}\.png$/); - expect(await fs.promises.readFile(savedPath, "utf8")).toBe("image-bytes"); - expect(adapterMocks.fetchMedia).toHaveBeenCalledWith({ - url: "https://media.qq.com/assets/photo.png", - filePathHint: "photo.png", - ssrfPolicy: { - hostnameAllowlist: [ - "*.qpic.cn", - "*.qq.com", - "*.weiyun.com", - "*.qq.com.cn", - "*.ugcimg.cn", - "*.myqcloud.com", - "*.tencentcos.cn", - "*.tencentcos.com", - ], - allowRfc2544BenchmarkRange: true, - }, - responseHeaderTimeoutMs: 120_000, - }); - }); - - it("rejects non-HTTPS URLs before attempting a fetch", async () => { - const savedPath = await downloadFile("http://media.qq.com/assets/photo.png", tempDir); - - expect(savedPath).toBeNull(); - expect(adapterMocks.fetchMedia).not.toHaveBeenCalled(); - }); - - it("rejects symlinked local media helpers", async ({ skip }) => { - const targetPath = path.join(tempDir, "target.png"); - const linkPath = path.join(tempDir, "link.png"); - if (!(await createSymlinkedFile(targetPath, linkPath))) { - skip("file symlinks are unavailable on this host"); - } - - expect(checkFileSize(linkPath).ok).toBe(false); - await expect(readFileAsync(linkPath)).rejects.toThrow(/symbolic link|symlink|regular file/i); - await expect(fileExistsAsync(linkPath)).resolves.toBe(false); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts deleted file mode 100644 index 77a95ff1e6f7..000000000000 --- a/extensions/qqbot/src/engine/utils/file-utils.ts +++ /dev/null @@ -1,219 +0,0 @@ -// Qqbot helper module supports file utils behavior. -import crypto from "node:crypto"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime"; -import { formatByteSize } from "openclaw/plugin-sdk/number-runtime"; -import { - openLocalFileSafely, - readRegularFile, - statRegularFileSync, -} from "openclaw/plugin-sdk/security-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { getPlatformAdapter } from "../adapter/index.js"; -import type { SsrfPolicyConfig } from "../adapter/types.js"; -import { MediaFileType } from "../types.js"; - -/** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */ -export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; - -/** Absolute upper bound enforced on the chunked upload path (matches server policy). */ -const CHUNKED_UPLOAD_MAX_SIZE = 100 * 1024 * 1024; - -/** Threshold used to treat an upload as a large file (dispatch to chunked path). */ -export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; - -/** - * Per-{@link MediaFileType} upload metadata: the QQ Open Platform size - * ceiling and the Chinese display name used in user-facing error messages. - * - * Keyed by the enum value so call sites read as - * `MEDIA_FILE_TYPE_INFO[MediaFileType.IMAGE].maxSize`, and adding a new - * type forces both fields to be supplied in a single place. - */ -const MEDIA_FILE_TYPE_INFO: Record = { - [MediaFileType.IMAGE]: { maxSize: 30 * 1024 * 1024, name: "图片" }, - [MediaFileType.VIDEO]: { maxSize: 100 * 1024 * 1024, name: "视频" }, - [MediaFileType.VOICE]: { maxSize: 20 * 1024 * 1024, name: "语音" }, - [MediaFileType.FILE]: { maxSize: 100 * 1024 * 1024, name: "文件" }, -}; - -/** Return the Chinese display name for a media file type code. Defaults to "文件". */ -export function getFileTypeName(fileType: number): string { - return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.name ?? "文件"; -} - -/** Return the upload ceiling for a given media file type. Defaults to 100MB. */ -export function getMaxUploadSize(fileType: number): number { - return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.maxSize ?? CHUNKED_UPLOAD_MAX_SIZE; -} - -const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [ - // QQ rich media - "*.qpic.cn", - "*.qq.com", - "*.weiyun.com", - "*.qq.com.cn", - - // QQ Bot - "*.ugcimg.cn", - - // Tencent Cloud COS - "*.myqcloud.com", - "*.tencentcos.cn", - "*.tencentcos.com", -]; - -const QQBOT_MEDIA_SSRF_POLICY: SsrfPolicyConfig = { - hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST, - allowRfc2544BenchmarkRange: true, -}; - -const QQBOT_REMOTE_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 120_000; - -/** Result of local file-size validation. */ -interface FileSizeCheckResult { - ok: boolean; - size: number; - error?: string; -} - -/** Validate that a file is within the allowed upload size. */ -export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult { - try { - const result = statRegularFileSync(filePath); - if (result.missing) { - throw Object.assign(new Error(`File not found: ${filePath}`), { code: "ENOENT" }); - } - if (result.stat.size > maxSize) { - const sizeMB = (result.stat.size / (1024 * 1024)).toFixed(1); - const limitMB = (maxSize / (1024 * 1024)).toFixed(0); - return { - ok: false, - size: result.stat.size, - error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`, - }; - } - return { ok: true, size: result.stat.size }; - } catch (err) { - return { - ok: false, - size: 0, - error: `Failed to read file metadata: ${formatErrorMessage(err)}`, - }; - } -} - -/** Read file contents asynchronously. */ -export async function readFileAsync(filePath: string): Promise { - return (await readRegularFile({ filePath })).buffer; -} - -/** Check file readability asynchronously. */ -export async function fileExistsAsync(filePath: string): Promise { - const opened = await openLocalFileSafely({ filePath }).catch(() => null); - if (!opened) { - return false; - } - try { - return true; - } catch { - return false; - } finally { - await opened.handle.close().catch(() => undefined); - } -} - -/** Format a byte count into a human-readable size string. */ -export function formatFileSize(bytes: number): string { - return formatByteSize(bytes, { - style: "legacy-binary", - maxUnit: "mega", - separator: "", - fractionDigits: (_value, unit) => (unit === "byte" ? null : 1), - }); -} - -/** Infer a MIME type from the file extension. */ -export function getMimeType(filePath: string): string { - return mimeTypeFromFilePath(filePath) ?? "application/octet-stream"; -} - -/** Extensions accepted as image uploads by the QQ Bot media pipeline. */ -const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]); - -/** - * Return the image MIME type for a local file path, or `null` if the - * extension is not in the supported image whitelist. - * - * Use this instead of `getMimeType` when the caller must enforce - * "image formats only" as a business rule (e.g. constructing a - * `data:image/...;base64,` URL). - */ -export function getImageMimeType(filePath: string): string | null { - const ext = path.extname(filePath).toLowerCase(); - if (!IMAGE_EXTENSIONS.has(ext)) { - return null; - } - const mime = mimeTypeFromFilePath(filePath); - return mime?.startsWith("image/") ? mime : null; -} - -/** Download a remote file into a local directory. */ -export async function downloadFile( - url: string, - destDir: string, - originalFilename?: string, -): Promise { - try { - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - return null; - } - if (parsedUrl.protocol !== "https:") { - return null; - } - - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); - } - - const fetched = await getPlatformAdapter().fetchMedia({ - url: parsedUrl.toString(), - filePathHint: originalFilename, - ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, - responseHeaderTimeoutMs: QQBOT_REMOTE_MEDIA_RESPONSE_HEADER_TIMEOUT_MS, - }); - - let filename = normalizeOptionalString(originalFilename) ?? ""; - if (!filename) { - filename = - (normalizeOptionalString(fetched.fileName) ?? path.basename(parsedUrl.pathname)) || - "download"; - } - - const ts = Date.now(); - const ext = path.extname(filename); - const base = path.basename(filename, ext) || "file"; - const rand = crypto.randomBytes(3).toString("hex"); - const safeFilename = `${base}_${ts}_${rand}${ext}`; - - const destPath = path.join(destDir, safeFilename); - await fs.promises.writeFile(destPath, fetched.buffer); - return destPath; - } catch (err) { - console.error( - `[qqbot:downloadFile] FAILED url=${url.slice(0, 120)} error=${err instanceof Error ? err.message : String(err)}`, - ); - if (err instanceof Error && err.stack) { - console.error(`[qqbot:downloadFile] stack=${err.stack.split("\n").slice(0, 3).join(" | ")}`); - } - if (err instanceof Error && err.cause) { - console.error(`[qqbot:downloadFile] cause=${formatErrorMessage(err.cause)}`); - } - return null; - } -} diff --git a/extensions/qqbot/src/engine/utils/format.test.ts b/extensions/qqbot/src/engine/utils/format.test.ts deleted file mode 100644 index e78e15694b2b..000000000000 --- a/extensions/qqbot/src/engine/utils/format.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -// Qqbot tests cover format plugin behavior. -import { describe, expect, it } from "vitest"; -import { formatDuration } from "./format.js"; - -describe("engine/utils/format", () => { - describe("formatErrorMessage", () => { - it("extracts message from Error instances", () => { - expect(formatErrorMessage(new Error("boom"))).toBe("boom"); - }); - - it("returns strings as-is", () => { - expect(formatErrorMessage("plain text")).toBe("plain text"); - }); - - it("traverses the .cause chain", () => { - const inner = new Error("inner"); - const outer = new Error("outer", { cause: inner }); - expect(formatErrorMessage(outer)).toBe("outer | inner"); - }); - - it("handles string cause", () => { - const err = new Error("outer", { cause: "string cause" }); - expect(formatErrorMessage(err)).toBe("outer | string cause"); - }); - - it("stringifies numbers", () => { - expect(formatErrorMessage(42)).toBe("42"); - }); - - it("stringifies null", () => { - expect(formatErrorMessage(null)).toBe("null"); - }); - - it("stringifies undefined", () => { - expect(formatErrorMessage(undefined)).toBe("undefined"); - }); - - it("JSON-stringifies plain objects", () => { - expect(formatErrorMessage({ code: 500 })).toBe("status=unknown code=500"); - }); - }); - - describe("formatDuration", () => { - it("formats zero", () => { - expect(formatDuration(0)).toBe("0s"); - }); - - it("formats sub-minute durations as seconds", () => { - expect(formatDuration(45_000)).toBe("45s"); - }); - - it("formats exactly 60 seconds as 1m", () => { - expect(formatDuration(60_000)).toBe("1m"); - }); - - it("formats mixed minutes and seconds", () => { - expect(formatDuration(90_000)).toBe("1m 30s"); - }); - - it("formats exact minutes without trailing seconds", () => { - expect(formatDuration(300_000)).toBe("5m"); - }); - - it("rounds sub-second values", () => { - expect(formatDuration(1_499)).toBe("1s"); - expect(formatDuration(1_500)).toBe("2s"); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/format.ts b/extensions/qqbot/src/engine/utils/format.ts deleted file mode 100644 index 0abc63f63dad..000000000000 --- a/extensions/qqbot/src/engine/utils/format.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * General formatting and string utilities. - * 通用格式化与字符串工具。 - * - * Pure utility functions, with duration presentation from the plugin-local dependency. - */ -import prettyMilliseconds from "pretty-ms"; - -/** Format a millisecond duration into a human-readable string (e.g. "5m 30s"). */ -export function formatDuration(durationMs: number): string { - if (durationMs <= 0) { - return "0s"; - } - const roundedMs = - durationMs < 1000 ? Math.round(durationMs) : Math.round(durationMs / 1000) * 1000; - return prettyMilliseconds(roundedMs, { - unitCount: 2, - }); -} diff --git a/extensions/qqbot/src/engine/utils/image-size.test.ts b/extensions/qqbot/src/engine/utils/image-size.test.ts deleted file mode 100644 index b8b321056944..000000000000 --- a/extensions/qqbot/src/engine/utils/image-size.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Qqbot tests cover image size plugin behavior. -import { Buffer } from "node:buffer"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const adapterMocks = vi.hoisted(() => ({ - fetchMedia: vi.fn(), - debugLog: vi.fn(), -})); - -vi.mock("../adapter/index.js", () => ({ - getPlatformAdapter: () => ({ - fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args), - }), -})); - -vi.mock("./log.js", () => ({ - debugLog: (...args: unknown[]) => adapterMocks.debugLog(...args), -})); - -import { getImageSize } from "./image-size.js"; - -function parseImageSize(buffer: Buffer) { - return getImageSize(`data:image/png;base64,${buffer.toString("base64")}`); -} - -/** Build a minimal valid PNG header with the given dimensions. */ -function buildPngHeader(width: number, height: number): Buffer { - const buf = Buffer.alloc(24); - // PNG signature - buf[0] = 0x89; - buf[1] = 0x50; - buf[2] = 0x4e; - buf[3] = 0x47; - buf[4] = 0x0d; - buf[5] = 0x0a; - buf[6] = 0x1a; - buf[7] = 0x0a; - // IHDR chunk length - buf.writeUInt32BE(13, 8); - // "IHDR" - buf.write("IHDR", 12, "ascii"); - // Width and height - buf.writeUInt32BE(width, 16); - buf.writeUInt32BE(height, 20); - return buf; -} - -describe("getImageSize URL handling", () => { - beforeEach(() => { - adapterMocks.fetchMedia.mockReset(); - adapterMocks.debugLog.mockReset(); - }); - - describe("fetchMedia options contract", () => { - it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => { - adapterMocks.fetchMedia.mockResolvedValueOnce({ - buffer: buildPngHeader(800, 600), - contentType: "image/png", - }); - - await getImageSize("https://cdn.example.com/photo.png"); - - expect(adapterMocks.fetchMedia).toHaveBeenCalledOnce(); - const opts = adapterMocks.fetchMedia.mock.calls[0]?.[0]; - - expect(opts.url).toBe("https://cdn.example.com/photo.png"); - expect(opts.maxBytes).toBe(65_536); - expect(opts.maxRedirects).toBe(0); - // Generic public-network-only policy: no hostname allowlist - expect(opts.ssrfPolicy).toStrictEqual({}); - expect(opts.requestInit.headers).toEqual({ - Range: "bytes=0-65535", - "User-Agent": "QQBot-Image-Size-Detector/1.0", - }); - }); - - it("passes an abort signal through requestInit", async () => { - adapterMocks.fetchMedia.mockResolvedValueOnce({ - buffer: buildPngHeader(100, 100), - }); - - await getImageSize("https://cdn.example.com/img.png"); - - const opts = adapterMocks.fetchMedia.mock.calls[0]?.[0]; - expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal); - }); - }); - - describe("SSRF blocking (adapter.fetchMedia rejects)", () => { - it("returns null when adapter.fetchMedia throws for loopback", async () => { - adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: loopback address")); - - const result = await getImageSize("https://127.0.0.1/img.png"); - - expect(result).toBeNull(); - }); - - it("returns null when adapter.fetchMedia throws for IPv6 loopback", async () => { - adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: loopback address")); - - const result = await getImageSize("https://[::1]/img.png"); - - expect(result).toBeNull(); - }); - - it("returns null when adapter.fetchMedia throws for link-local/metadata", async () => { - adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: link-local address")); - - const result = await getImageSize("https://169.254.169.254/latest/meta-data/"); - - expect(result).toBeNull(); - }); - - it("returns null when adapter.fetchMedia throws for RFC1918 addresses", async () => { - adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("SSRF blocked: private address")); - - const result = await getImageSize("https://10.0.0.1/img.png"); - - expect(result).toBeNull(); - }); - - it("returns null on http error from adapter.fetchMedia", async () => { - adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden")); - - const result = await getImageSize("https://cdn.example.com/forbidden.png"); - - expect(result).toBeNull(); - }); - }); - - describe("happy path", () => { - it("returns parsed dimensions for a valid PNG", async () => { - adapterMocks.fetchMedia.mockResolvedValueOnce({ - buffer: buildPngHeader(1920, 1080), - contentType: "image/png", - }); - - const size = await getImageSize("https://cdn.example.com/banner.png"); - - expect(size).toEqual({ width: 1920, height: 1080 }); - }); - - it("returns null when the buffer is not a recognized image format", async () => { - adapterMocks.fetchMedia.mockResolvedValueOnce({ - buffer: Buffer.from("not an image"), - contentType: "text/html", - }); - - const size = await getImageSize("https://cdn.example.com/notimage.html"); - - expect(size).toBeNull(); - }); - - it("logs fetched URLs without splitting surrogate pairs", async () => { - adapterMocks.fetchMedia.mockResolvedValueOnce({ - buffer: buildPngHeader(800, 600), - contentType: "image/png", - }); - const base = "https://cdn.example.com/"; - const urlPrefix = `${base}${"x".repeat(59 - base.length)}`; - - await getImageSize(`${urlPrefix}😀.png`); - - expect(adapterMocks.debugLog).toHaveBeenCalledWith( - `[image-size] Got size from URL: 800x600 - ${urlPrefix}...`, - ); - - adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("probe failed")); - await getImageSize(`${urlPrefix}😀.png`); - expect(adapterMocks.debugLog).toHaveBeenLastCalledWith( - `[image-size] Error fetching ${urlPrefix}...: probe failed`, - ); - }); - }); -}); - -describe("parseImageSize", () => { - it("parses PNG dimensions", async () => { - const size = await parseImageSize(buildPngHeader(640, 480)); - expect(size).toEqual({ width: 640, height: 480 }); - }); - - it("returns null for unrecognized data", async () => { - await expect(parseImageSize(Buffer.from("hello"))).resolves.toBeNull(); - }); - - it("returns null for empty buffer", async () => { - await expect(parseImageSize(Buffer.alloc(0))).resolves.toBeNull(); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/image-size.ts b/extensions/qqbot/src/engine/utils/image-size.ts deleted file mode 100644 index 73f652f7be9b..000000000000 --- a/extensions/qqbot/src/engine/utils/image-size.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Image dimension helpers for QQ Bot markdown image syntax. - * - * QQ Bot markdown images use `![#widthpx #heightpx](url)`. - */ - -import { Buffer } from "node:buffer"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { getPlatformAdapter } from "../adapter/index.js"; -import type { SsrfPolicyConfig } from "../adapter/types.js"; -import { debugLog } from "./log.js"; - -interface ImageSize { - width: number; - height: number; -} - -/** Default dimensions used when probing fails. */ -const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 }; - -/** - * Parse image dimensions from the PNG header. - */ -function parsePngSize(buffer: Buffer): ImageSize | null { - // PNG signature: 89 50 4E 47 0D 0A 1A 0A - if (buffer.length < 24) { - return null; - } - if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { - return null; - } - // The IHDR chunk begins at byte 8, with width/height at 16..23. - const width = buffer.readUInt32BE(16); - const height = buffer.readUInt32BE(20); - return { width, height }; -} - -/** Parse image dimensions from JPEG SOF0/SOF2 markers. */ -function parseJpegSize(buffer: Buffer): ImageSize | null { - // JPEG signature: FF D8 FF - if (buffer.length < 4) { - return null; - } - if (buffer[0] !== 0xff || buffer[1] !== 0xd8) { - return null; - } - - let offset = 2; - while (offset < buffer.length - 9) { - if (buffer[offset] !== 0xff) { - offset++; - continue; - } - - const marker = buffer[offset + 1]; - // SOF0 (0xC0) and SOF2 (0xC2) contain dimensions. - if (marker === 0xc0 || marker === 0xc2) { - // Layout: FF C0 length(2) precision(1) height(2) width(2) - if (offset + 9 <= buffer.length) { - const height = buffer.readUInt16BE(offset + 5); - const width = buffer.readUInt16BE(offset + 7); - return { width, height }; - } - } - - // Skip the current block. - if (offset + 3 < buffer.length) { - const blockLength = buffer.readUInt16BE(offset + 2); - offset += 2 + blockLength; - } else { - break; - } - } - - return null; -} - -/** Parse image dimensions from the GIF header. */ -function parseGifSize(buffer: Buffer): ImageSize | null { - if (buffer.length < 10) { - return null; - } - const signature = buffer.toString("ascii", 0, 6); - if (signature !== "GIF87a" && signature !== "GIF89a") { - return null; - } - const width = buffer.readUInt16LE(6); - const height = buffer.readUInt16LE(8); - return { width, height }; -} - -/** Parse image dimensions from WebP headers. */ -function parseWebpSize(buffer: Buffer): ImageSize | null { - if (buffer.length < 30) { - return null; - } - - // Check the RIFF and WEBP signatures. - const riff = buffer.toString("ascii", 0, 4); - const webp = buffer.toString("ascii", 8, 12); - if (riff !== "RIFF" || webp !== "WEBP") { - return null; - } - - const chunkType = buffer.toString("ascii", 12, 16); - - // VP8 (lossy) - if (chunkType === "VP8 ") { - // The VP8 frame header starts at byte 23 and uses the 9D 01 2A signature. - if (buffer.length >= 30 && buffer.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) { - const width = buffer.readUInt16LE(26) & 0x3fff; - const height = buffer.readUInt16LE(28) & 0x3fff; - return { width, height }; - } - } - - // VP8L (lossless) - if (chunkType === "VP8L") { - // VP8L signature: 0x2F - if (buffer.length >= 25 && buffer[20] === 0x2f) { - const bits = buffer.readUInt32LE(21); - const width = (bits & 0x3fff) + 1; - const height = ((bits >> 14) & 0x3fff) + 1; - return { width, height }; - } - } - - // VP8X (extended format) - if (chunkType === "VP8X") { - if (buffer.length >= 30) { - // Width and height live at 24..26 and 27..29 as 24-bit little-endian values. - const width = buffer.readUIntLE(24, 3) + 1; - const height = buffer.readUIntLE(27, 3) + 1; - return { width, height }; - } - } - - return null; -} - -/** Parse image dimensions from raw image bytes. */ -function parseImageSize(buffer: Buffer): ImageSize | null { - // Try each supported image format in sequence. - return ( - parsePngSize(buffer) ?? parseJpegSize(buffer) ?? parseGifSize(buffer) ?? parseWebpSize(buffer) - ); -} - -/** - * SSRF policy for image-dimension probing. Generic public-network-only blocking - * (no hostname allowlist) because markdown image URLs can legitimately point to - * any public host, not just QQ-owned CDNs. - */ -const IMAGE_PROBE_SSRF_POLICY: SsrfPolicyConfig = {}; - -/** - * Fetch image dimensions from a public URL using only the first 64 KB. - * - * Uses {@link readRemoteMediaBuffer} with SSRF guard to block probes against - * private/reserved/loopback/link-local/metadata destinations. - */ -async function getImageSizeFromUrl(url: string, timeoutMs = 5000): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - - try { - const { buffer } = await getPlatformAdapter().fetchMedia({ - url, - maxBytes: 65_536, - maxRedirects: 0, - ssrfPolicy: IMAGE_PROBE_SSRF_POLICY, - requestInit: { - signal: controller.signal, - headers: { - Range: "bytes=0-65535", - "User-Agent": "QQBot-Image-Size-Detector/1.0", - }, - }, - }); - - const size = parseImageSize(buffer); - if (size) { - debugLog( - `[image-size] Got size from URL: ${size.width}x${size.height} - ${truncateUtf16Safe(url, 60)}...`, - ); - } - return size; - } finally { - clearTimeout(timeoutId); - } - } catch (err) { - debugLog( - `[image-size] Error fetching ${truncateUtf16Safe(url, 60)}...: ${formatErrorMessage(err)}`, - ); - return null; - } -} - -/** Parse image dimensions from a Base64 data URL. */ -function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null { - try { - // Format: data:image/png;base64,xxxxx - const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/); - if (!matches) { - return null; - } - - const base64Data = matches[1]; - if (base64Data === undefined) { - return null; - } - const buffer = Buffer.from(base64Data, "base64"); - - const size = parseImageSize(buffer); - if (size) { - debugLog(`[image-size] Got size from Base64: ${size.width}x${size.height}`); - } - - return size; - } catch (err) { - debugLog(`[image-size] Error parsing Base64: ${formatErrorMessage(err)}`); - return null; - } -} - -/** - * Resolve image dimensions from either an HTTP URL or a Base64 data URL. - */ -export async function getImageSize(source: string): Promise { - if (source.startsWith("data:")) { - return getImageSizeFromDataUrl(source); - } - - if (source.startsWith("http://") || source.startsWith("https://")) { - return getImageSizeFromUrl(source); - } - - return null; -} - -/** Format a markdown image with QQ Bot width/height annotations. */ -export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string { - const { width, height } = size ?? DEFAULT_IMAGE_SIZE; - return `![#${width}px #${height}px](${url})`; -} - -/** Return true when markdown already contains QQ Bot size annotations. */ -export function hasQQBotImageSize(markdownImage: string): boolean { - return /!\[#\d+px\s+#\d+px\]/.test(markdownImage); -} diff --git a/extensions/qqbot/src/engine/utils/log.test.ts b/extensions/qqbot/src/engine/utils/log.test.ts deleted file mode 100644 index 476f8b3554c0..000000000000 --- a/extensions/qqbot/src/engine/utils/log.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Qqbot tests cover log plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { debugLog } from "./log.js"; - -const originalDebug = process.env.QQBOT_DEBUG; - -afterEach(() => { - if (originalDebug === undefined) { - delete process.env.QQBOT_DEBUG; - } else { - process.env.QQBOT_DEBUG = originalDebug; - } - vi.restoreAllMocks(); -}); - -describe("QQBot debug logging", () => { - it("sanitizes arguments before debug console output", () => { - process.env.QQBOT_DEBUG = "1"; - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - debugLog("prefix", "line one\nline two"); - - expect(logSpy).toHaveBeenCalledWith("prefix line one line two"); - }); - - it.each(["0", "false", "off", "no"])( - "does not enable debug logging for QQBOT_DEBUG=%s", - (value) => { - process.env.QQBOT_DEBUG = value; - const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - - debugLog("private message text"); - - expect(logSpy).not.toHaveBeenCalled(); - }, - ); -}); diff --git a/extensions/qqbot/src/engine/utils/log.ts b/extensions/qqbot/src/engine/utils/log.ts deleted file mode 100644 index 4878d9e37299..000000000000 --- a/extensions/qqbot/src/engine/utils/log.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * QQBot debug logging utilities. - * QQBot 调试日志工具。 - * - * Only outputs when the QQBOT_DEBUG environment variable is set, - * preventing user message content from leaking in production logs. - */ - -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; - -function isQqbotDebugEnabled(): boolean { - const value = process.env.QQBOT_DEBUG; - if (typeof value !== "string") { - return false; - } - switch (value.trim().toLowerCase()) { - case "1": - case "on": - case "true": - case "yes": - return true; - default: - return false; - } -} - -const isDebug = () => isQqbotDebugEnabled(); -const MAX_LOG_VALUE_CHARS = 4096; - -function sanitizeDebugLogValue(value: unknown): string { - let text: string; - if (typeof value === "string") { - text = value; - } else if (value instanceof Error) { - text = value.stack || value.message; - } else { - try { - text = JSON.stringify(value) ?? String(value); - } catch { - text = String(value); - } - } - - const sanitized = text - .replace(/\p{Cc}/gu, " ") - .replace(/\s+/g, " ") - .trim(); - if (sanitized.length <= MAX_LOG_VALUE_CHARS) { - return sanitized; - } - return `${truncateUtf16Safe(sanitized, MAX_LOG_VALUE_CHARS)}...`; -} - -function formatDebugLogArgs(args: unknown[]): string { - return args.map(sanitizeDebugLogValue).join(" "); -} - -/** Debug-level log; only outputs when QQBOT_DEBUG is enabled. */ -export function debugLog(...args: unknown[]): void { - if (isDebug()) { - console.log(formatDebugLogArgs(args).replace(/\n|\r/g, "")); - } -} - -/** Debug-level warning; only outputs when QQBOT_DEBUG is enabled. */ -export function debugWarn(...args: unknown[]): void { - if (isDebug()) { - console.warn(formatDebugLogArgs(args).replace(/\n|\r/g, "")); - } -} - -/** Debug-level error; only outputs when QQBOT_DEBUG is enabled. */ -export function debugError(...args: unknown[]): void { - if (isDebug()) { - console.error(formatDebugLogArgs(args).replace(/\n|\r/g, "")); - } -} diff --git a/extensions/qqbot/src/engine/utils/media-tags.test.ts b/extensions/qqbot/src/engine/utils/media-tags.test.ts deleted file mode 100644 index b18f3d3b10d1..000000000000 --- a/extensions/qqbot/src/engine/utils/media-tags.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Qqbot tests cover media tags plugin behavior. -import { describe, expect, it } from "vitest"; -import { normalizeMediaTags } from "./media-tags.js"; - -describe("media-tags with HTML entities", () => { - it("extracts URL from entity-encoded fuzzy tag", () => { - const input = "<qqimg>https://example.com/a.png</qqimg>"; - expect(normalizeMediaTags(input)).toBe("https://example.com/a.png"); - }); - - it("extracts URL from mixed entity+plain tag", () => { - const input = "<qqimg>https://example.com/b.png"; - expect(normalizeMediaTags(input)).toBe("https://example.com/b.png"); - }); - - it("extracts file from entity-encoded self-closing tag", () => { - const input = '<qqmedia file="https://example.com/c.zip" />'; - expect(normalizeMediaTags(input)).toBe("https://example.com/c.zip"); - }); - - it("does not match invalid input", () => { - const input = "no tag here"; - expect(normalizeMediaTags(input)).toBe(input); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/media-tags.ts b/extensions/qqbot/src/engine/utils/media-tags.ts deleted file mode 100644 index de675c1a1772..000000000000 --- a/extensions/qqbot/src/engine/utils/media-tags.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Media tag normalization for QQ Bot messages. - * - * Normalizes malformed ``, ``, etc. tags emitted by - * smaller models into canonical wrapped-tag format. - * - * Zero external dependencies. - */ - -/** Lowercase and trim a string, returning empty string for falsy input. */ -function lc(s: string): string { - return (s ?? "").toLowerCase().trim(); -} - -/** Expand `~` prefix to the process home directory. */ -function expandTilde(p: string): string { - if (!p) { - return p; - } - const home = - typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined; - if (!home) { - return p; - } - if (p === "~") { - return home; - } - if (p.startsWith("~/") || p.startsWith("~\\")) { - return `${home}/${p.slice(2)}`; - } - return p; -} - -// Canonical media tags. `qqmedia` is the generic auto-routing tag. -const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const; - -// Lowercased aliases that should normalize to the canonical tag set. -const TAG_ALIASES: Record = { - qq_img: "qqimg", - qqimage: "qqimg", - qq_image: "qqimg", - qqpic: "qqimg", - qq_pic: "qqimg", - qqpicture: "qqimg", - qq_picture: "qqimg", - qqphoto: "qqimg", - qq_photo: "qqimg", - img: "qqimg", - image: "qqimg", - pic: "qqimg", - picture: "qqimg", - photo: "qqimg", - qq_voice: "qqvoice", - qqaudio: "qqvoice", - qq_audio: "qqvoice", - voice: "qqvoice", - audio: "qqvoice", - qq_video: "qqvideo", - video: "qqvideo", - qq_file: "qqfile", - qqdoc: "qqfile", - qq_doc: "qqfile", - file: "qqfile", - doc: "qqfile", - document: "qqfile", - qq_media: "qqmedia", - media: "qqmedia", - attachment: "qqmedia", - attach: "qqmedia", - qqattachment: "qqmedia", - qq_attachment: "qqmedia", - qqsend: "qqmedia", - qq_send: "qqmedia", - send: "qqmedia", -}; - -const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)]; -ALL_TAG_NAMES.sort((a, b) => b.length - a.length); - -const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|"); - -const LEFT_BRACKET = "(?:[<\uff1c\u003c]|<)"; -const RIGHT_BRACKET = "(?:[>\uff1e\u003e]|>)"; - -/** Match self-closing media-tag syntax with file/src/path/url attributes. */ -const SELF_CLOSING_TAG_REGEX = new RegExp( - "`?" + - LEFT_BRACKET + - "\\s*(" + - TAG_NAME_PATTERN + - ")" + - "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" + - "\\s+(?:file|src|path|url)\\s*=\\s*" + - "[\"']?" + - "([^\"'\\s>\uff1e]+?)" + - "[\"']?" + - "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" + - "\\s*/?" + - "\\s*" + - RIGHT_BRACKET + - "`?", - "gi", -); - -/** Match malformed wrapped media tags that should be normalized. */ -const FUZZY_MEDIA_TAG_REGEX = new RegExp( - "`?" + - LEFT_BRACKET + - "\\s*(" + - TAG_NAME_PATTERN + - ")\\s*" + - RIGHT_BRACKET + - "[\"']?\\s*" + - "([^<\uff1c<\uff1e>\"'`]+?)" + - "\\s*[\"']?" + - LEFT_BRACKET + - "\\s*/?\\s*(?:" + - TAG_NAME_PATTERN + - ")\\s*" + - RIGHT_BRACKET + - "`?", - "gi", -); - -/** Normalize a raw tag name into the canonical tag set. */ -function resolveTagName(raw: string): (typeof VALID_TAGS)[number] { - const lower = lc(raw); - if ((VALID_TAGS as readonly string[]).includes(lower)) { - return lower as (typeof VALID_TAGS)[number]; - } - return TAG_ALIASES[lower] ?? "qqimg"; -} - -/** Match wrapped tags whose bodies need newline and tab cleanup. */ -const MULTILINE_TAG_CLEANUP = new RegExp( - "(" + - LEFT_BRACKET + - "\\s*(?:" + - TAG_NAME_PATTERN + - ")\\s*" + - RIGHT_BRACKET + - ")" + - "([\\s\\S]*?)" + - "(" + - LEFT_BRACKET + - "\\s*/?\\s*(?:" + - TAG_NAME_PATTERN + - ")\\s*" + - RIGHT_BRACKET + - ")", - "gi", -); - -/** Normalize malformed media-tag output into canonical wrapped tags. */ -export function normalizeMediaTags(text: string): string { - const normalizeWrappedTag = (_match: string, rawTag: string, content: string): string => { - const tag = resolveTagName(rawTag); - const trimmed = content.trim(); - if (!trimmed) { - return _match; - } - const expanded = expandTilde(trimmed); - return `<${tag}>${expanded}`; - }; - - let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, normalizeWrappedTag); - - cleaned = cleaned.replace( - MULTILINE_TAG_CLEANUP, - (_m, open: string, body: string, close: string) => { - const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " "); - return open + flat + close; - }, - ); - - return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, normalizeWrappedTag); -} diff --git a/extensions/qqbot/src/engine/utils/payload.test.ts b/extensions/qqbot/src/engine/utils/payload.test.ts deleted file mode 100644 index b856f31ec32f..000000000000 --- a/extensions/qqbot/src/engine/utils/payload.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Qqbot tests cover payload plugin behavior. -import { describe, expect, it } from "vitest"; -import { - decodeCronPayload, - encodePayloadForCron, - isCronReminderPayload, - isMediaPayload, - parseQQBotPayload, -} from "./payload.js"; - -type CronReminderPayload = Parameters[0]; - -describe("engine/utils/payload", () => { - it("returns original text for non-payload replies", () => { - const result = parseQQBotPayload(" plain reply "); - - expect(result).toEqual({ isPayload: false, text: " plain reply " }); - }); - - it("parses a media payload", () => { - const result = parseQQBotPayload( - 'QQBOT_PAYLOAD: {"type":"media","mediaType":"image","source":"url","path":"https://example.test/a.png","caption":"cap"}', - ); - - expect(result.isPayload).toBe(true); - expect(result.payload).toEqual({ - type: "media", - mediaType: "image", - source: "url", - path: "https://example.test/a.png", - caption: "cap", - }); - expect(result.payload && isMediaPayload(result.payload)).toBe(true); - }); - - it("rejects malformed or incomplete payloads", () => { - expect(parseQQBotPayload("QQBOT_PAYLOAD:").error).toBe("Payload body is empty"); - expect(parseQQBotPayload("QQBOT_PAYLOAD: {bad json").error).toContain("Failed to parse JSON"); - expect(parseQQBotPayload('QQBOT_PAYLOAD: {"type":"media","mediaType":"image"}').error).toBe( - "media payload is missing required fields (mediaType, source, path)", - ); - }); - - it("round-trips cron reminder payloads through the stored format", () => { - const payload: CronReminderPayload = { - type: "cron_reminder", - content: "standup", - targetType: "group", - targetAddress: "group-openid", - originalMessageId: "msg-1", - }; - - const encoded = encodePayloadForCron(payload); - expect(encoded).toMatch(/^QQBOT_CRON:/); - - const decoded = decodeCronPayload(encoded); - expect(decoded).toEqual({ isCronPayload: true, payload }); - expect(decoded.payload && isCronReminderPayload(decoded.payload)).toBe(true); - }); - - it("reports cron decode errors without throwing", () => { - expect(decodeCronPayload("plain")).toEqual({ isCronPayload: false }); - expect(decodeCronPayload("QQBOT_CRON:").error).toBe("Cron payload body is empty"); - expect(decodeCronPayload("QQBOT_CRON:AAA@@@").error).toBe( - "Failed to decode cron payload: Cron payload body is not valid base64", - ); - - const wrongType = Buffer.from('{"type":"media"}', "utf-8").toString("base64"); - expect(decodeCronPayload(`QQBOT_CRON:${wrongType}`).error).toBe( - "Expected type cron_reminder but got media", - ); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/payload.ts b/extensions/qqbot/src/engine/utils/payload.ts deleted file mode 100644 index 0b8aadbc726e..000000000000 --- a/extensions/qqbot/src/engine/utils/payload.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Structured payload parsing and encoding for QQ Bot messages. - * - * Handles `QQBOT_PAYLOAD:` (model-emitted structured payloads) and - * `QQBOT_CRON:` (persisted cron reminder payloads). - * - * Zero external dependencies. - */ - -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import type { ChatScope } from "../types.js"; - -/** Structured reminder payload emitted by the model. */ -interface CronReminderPayload { - type: "cron_reminder"; - content: string; - targetType: ChatScope; - targetAddress: string; - originalMessageId?: string; -} - -/** Structured media payload emitted by the model. */ -export interface MediaPayload { - type: "media"; - mediaType: "image" | "audio" | "video" | "file"; - source: "url" | "file"; - path: string; - caption?: string; -} - -type QQBotPayload = CronReminderPayload | MediaPayload; - -/** Result of parsing model output into a structured payload. */ -interface ParseResult { - isPayload: boolean; - payload?: QQBotPayload; - text?: string; - error?: string; -} - -const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:"; -const CRON_PREFIX = "QQBOT_CRON:"; - -function normalizeBase64ForCompare(value: string): string { - return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/"); -} - -function decodeStrictBase64Utf8(value: string): string { - const buffer = Buffer.from(value, "base64"); - if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) { - throw new Error("Cron payload body is not valid base64"); - } - return buffer.toString("utf-8"); -} - -/** Parse model output that may start with the QQ Bot structured payload prefix. */ -export function parseQQBotPayload(text: string): ParseResult { - const trimmedText = text.trim(); - - if (!trimmedText.startsWith(PAYLOAD_PREFIX)) { - return { isPayload: false, text }; - } - - const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim(); - - if (!jsonContent) { - return { isPayload: true, error: "Payload body is empty" }; - } - - try { - const payload = JSON.parse(jsonContent) as QQBotPayload; - - if (!payload.type) { - return { isPayload: true, error: "Payload is missing the type field" }; - } - - if (payload.type === "cron_reminder") { - if (!payload.content || !payload.targetType || !payload.targetAddress) { - return { - isPayload: true, - error: - "cron_reminder payload is missing required fields (content, targetType, targetAddress)", - }; - } - } else if (payload.type === "media") { - if (!payload.mediaType || !payload.source || !payload.path) { - return { - isPayload: true, - error: "media payload is missing required fields (mediaType, source, path)", - }; - } - } - - return { isPayload: true, payload }; - } catch (e) { - return { isPayload: true, error: `Failed to parse JSON: ${formatErrorMessage(e)}` }; - } -} - -/** Encode a cron reminder payload into the stored cron-message format. */ -export function encodePayloadForCron(payload: CronReminderPayload): string { - const jsonString = JSON.stringify(payload); - const base64 = Buffer.from(jsonString, "utf-8").toString("base64"); - return `${CRON_PREFIX}${base64}`; -} - -/** Decode a stored cron payload. */ -export function decodeCronPayload(message: string): { - isCronPayload: boolean; - payload?: CronReminderPayload; - error?: string; -} { - const trimmedMessage = message.trim(); - - if (!trimmedMessage.startsWith(CRON_PREFIX)) { - return { isCronPayload: false }; - } - - const base64Content = trimmedMessage.slice(CRON_PREFIX.length); - - if (!base64Content) { - return { isCronPayload: true, error: "Cron payload body is empty" }; - } - - try { - const jsonString = decodeStrictBase64Utf8(base64Content); - const payload = JSON.parse(jsonString) as CronReminderPayload; - - if (payload.type !== "cron_reminder") { - return { - isCronPayload: true, - error: `Expected type cron_reminder but got ${String(payload.type)}`, - }; - } - - if (!payload.content || !payload.targetType || !payload.targetAddress) { - return { isCronPayload: true, error: "Cron payload is missing required fields" }; - } - - return { isCronPayload: true, payload }; - } catch (e) { - return { - isCronPayload: true, - error: `Failed to decode cron payload: ${formatErrorMessage(e)}`, - }; - } -} - -/** Type guard for cron reminder payloads. */ -export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload { - return payload.type === "cron_reminder"; -} - -/** Type guard for media payloads. */ -export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload { - return payload.type === "media"; -} diff --git a/extensions/qqbot/src/engine/utils/platform-storage-laziness.test.ts b/extensions/qqbot/src/engine/utils/platform-storage-laziness.test.ts deleted file mode 100644 index 7d0e268f647d..000000000000 --- a/extensions/qqbot/src/engine/utils/platform-storage-laziness.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Qqbot tests cover platform storage laziness plugin behavior. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - installQQBotRuntimeForStateTests, - resetQQBotStateTestRuntime, -} from "../../test-support/runtime.js"; - -const createdHomes: string[] = []; - -async function useMockHome(homeDir: string): Promise { - vi.stubEnv("HOME", homeDir); - vi.resetModules(); - vi.doMock("node:os", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - default: { ...actual, homedir: () => homeDir }, - homedir: () => homeDir, - }; - }); -} - -function makeHome(): string { - const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-home-")); - createdHomes.push(homeDir); - return homeDir; -} - -describe("qqbot storage laziness", () => { - afterEach(() => { - resetQQBotStateTestRuntime(); - vi.doUnmock("node:os"); - vi.unstubAllEnvs(); - vi.resetModules(); - for (const home of createdHomes.splice(0)) { - fs.rmSync(home, { recursive: true, force: true }); - } - }); - - it("does not create ~/.openclaw/qqbot from module imports or read-only probes", async () => { - const homeDir = makeHome(); - const stateDir = makeHome(); - await useMockHome(homeDir); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - installQQBotRuntimeForStateTests(stateDir); - - const qqbotRoot = path.join(homeDir, ".openclaw", "qqbot"); - - await import("../session/session-store.js"); - await import("../session/known-users.js"); - await import("../ref/store.js"); - const { loadCredentialBackup } = await import("../config/credential-backup.js"); - - expect(loadCredentialBackup("default")).toBeNull(); - expect(fs.existsSync(qqbotRoot)).toBe(false); - }); - - it("creates storage when qqbot persists runtime state", async () => { - const homeDir = makeHome(); - const stateDir = makeHome(); - await useMockHome(homeDir); - vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - installQQBotRuntimeForStateTests(stateDir); - - const qqbotRoot = path.join(homeDir, ".openclaw", "qqbot"); - const sqlitePath = path.join(stateDir, "state", "openclaw.sqlite"); - const { saveCredentialBackup } = await import("../config/credential-backup.js"); - - saveCredentialBackup("default", "123456", "secret"); - - expect(fs.existsSync(sqlitePath)).toBe(true); - expect(fs.existsSync(path.join(qqbotRoot, "data", "credential-backup-default.json"))).toBe( - false, - ); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/platform.test.ts b/extensions/qqbot/src/engine/utils/platform.test.ts deleted file mode 100644 index 5ece4d59f6f3..000000000000 --- a/extensions/qqbot/src/engine/utils/platform.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -// Qqbot tests cover platform plugin behavior. -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getHomeDir, - getQQBotDataDir, - getQQBotMediaPath, - resolveQQBotPayloadLocalFilePath, -} from "./platform.js"; - -function getQQBotDataPath(...subPaths: string[]): string { - return getQQBotDataDir(...subPaths); -} - -function resolveQQBotLocalMediaPath(p: string): string | null { - return resolveQQBotPayloadLocalFilePath(p); -} - -describe("qqbot local media path remapping", () => { - const createdPaths: string[] = []; - - function createOpenClawTestRoot() { - const actualHome = getHomeDir(); - const openclawDir = path.join(actualHome, ".openclaw"); - fs.mkdirSync(openclawDir, { recursive: true }); - const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-")); - createdPaths.push(testRoot); - return { actualHome, testRootName: path.basename(testRoot) }; - } - - function createQqbotMediaFile(fileName: string) { - const { actualHome, testRootName } = createOpenClawTestRoot(); - const mediaFile = path.join( - actualHome, - ".openclaw", - "media", - "qqbot", - "downloads", - testRootName, - fileName, - ); - fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); - fs.writeFileSync(mediaFile, "image", "utf8"); - createdPaths.push(path.dirname(mediaFile)); - return { actualHome, testRootName, mediaFile }; - } - - afterEach(() => { - vi.restoreAllMocks(); - for (const target of createdPaths.splice(0)) { - fs.rmSync(target, { recursive: true, force: true }); - } - }); - - it("remaps missing workspace media paths to the real media directory", () => { - const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("example.png"); - - const missingWorkspacePath = path.join( - actualHome, - ".openclaw", - "workspace", - "qqbot", - "downloads", - testRootName, - "example.png", - ); - - expect(resolveQQBotLocalMediaPath(missingWorkspacePath)).toBe(mediaFile); - }); - - it("leaves existing media paths unchanged", () => { - const { mediaFile } = createQqbotMediaFile("existing.png"); - - expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile); - }); - - it("blocks structured payload files outside QQ Bot storage", () => { - const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-platform-outside-")); - createdPaths.push(outsideRoot); - - const outsideFile = path.join(outsideRoot, "secret.txt"); - fs.writeFileSync(outsideFile, "secret", "utf8"); - - expect(resolveQQBotPayloadLocalFilePath(outsideFile)).toBeNull(); - }); - - it("blocks structured payload paths that escape QQ Bot media via '..'", () => { - const escapedPath = path.join( - getHomeDir(), - ".openclaw", - "media", - "qqbot", - "..", - "..", - "qqbot-escape.txt", - ); - - expect(resolveQQBotPayloadLocalFilePath(escapedPath)).toBeNull(); - }); - - it("allows structured payload files inside the QQ Bot media directory", () => { - const { mediaFile } = createQqbotMediaFile("allowed.png"); - - expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); - }); - - it("allows structured payload files inside sibling OpenClaw media subdirectories", () => { - // Core helpers such as `saveMediaBuffer(..., "outbound", ...)` place framework - // attachments under sibling directories of `media/qqbot/`. The plugin must - // trust the shared `~/.openclaw/media` root so auto-routed sends can access - // those files without the path-outside-storage guard firing. - const actualHome = getHomeDir(); - const outboundDir = path.join(actualHome, ".openclaw", "media", "outbound"); - fs.mkdirSync(outboundDir, { recursive: true }); - const outboundFile = fs.mkdtempSync(path.join(outboundDir, "qqbot-outbound-")); - const mediaFile = path.join(outboundFile, "tts.mp3"); - fs.writeFileSync(mediaFile, "audio", "utf8"); - createdPaths.push(outboundFile); - - expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); - }); - - it("blocks structured payload files inside the QQ Bot data directory", () => { - const { actualHome, testRootName } = createOpenClawTestRoot(); - - const dataFile = path.join( - actualHome, - ".openclaw", - "qqbot", - "sessions", - testRootName, - "session.json", - ); - fs.mkdirSync(path.dirname(dataFile), { recursive: true }); - fs.writeFileSync(dataFile, "{}", "utf8"); - createdPaths.push(path.dirname(dataFile)); - - expect(resolveQQBotPayloadLocalFilePath(dataFile)).toBeNull(); - }); - - it("allows legacy workspace paths when they remap into QQ Bot media storage", () => { - const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("legacy.png"); - - const missingWorkspacePath = path.join( - actualHome, - ".openclaw", - "workspace", - "qqbot", - "downloads", - testRootName, - "legacy.png", - ); - - expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile)); - }); -}); - -// Regression coverage for https://github.com/openclaw/openclaw/issues/83562 — -// when HOME and OPENCLAW_HOME diverge (Docker, multi-user hosts), QQ Bot media -// paths must be anchored on OPENCLAW_HOME so files written under -// `$OPENCLAW_HOME/.openclaw/media/qqbot/` are accepted by the outbound -// allowlist. -// -// Tests intentionally do NOT mock `os.homedir()` — the helper reads it via -// `import * as os from "node:os"` which `vi.spyOn` cannot reliably intercept -// across the ESM/CJS interop boundary. Instead each test treats the real OS -// home as the baseline and only varies `process.env.OPENCLAW_HOME`. -describe("qqbot media path resolution honors OPENCLAW_HOME (#83562)", () => { - const tempPaths: string[] = []; - const realOsHome = getHomeDir(); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.restoreAllMocks(); - for (const target of tempPaths.splice(0)) { - fs.rmSync(target, { recursive: true, force: true }); - } - }); - - function makeFakeOpenclawHome(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-oc-home-")); - tempPaths.push(dir); - return dir; - } - - function isPathInsideOrEqual(candidate: string, parent: string): boolean { - const relative = path.relative(parent, candidate); - return ( - relative === "" || - (relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); - } - - it("accepts files under $OPENCLAW_HOME/.openclaw/media/qqbot when OPENCLAW_HOME differs from HOME", () => { - const fakeOpenclawHome = makeFakeOpenclawHome(); - vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); - - const mediaFile = path.join(fakeOpenclawHome, ".openclaw", "media", "qqbot", "repro.png"); - // Sanity: the fixture must not be accepted by the previous HOME media root. - // On Windows, `os.tmpdir()` commonly lives under the user profile, so a raw - // HOME-prefix assertion would make this test fail for the wrong reason. - const oldHomeMediaRoot = path.join(realOsHome, ".openclaw", "media", "qqbot"); - expect(isPathInsideOrEqual(mediaFile, oldHomeMediaRoot)).toBe(false); - fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); - fs.writeFileSync(mediaFile, "image", "utf8"); - - expect(getQQBotMediaPath()).toBe(path.join(fakeOpenclawHome, ".openclaw", "media", "qqbot")); - expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); - }); - - it("expands tilde-prefixed OPENCLAW_HOME against the OS home", () => { - // Use a unique subdirectory name so we can clean it up safely without - // touching anything that exists under the real home. - const sub = `qqbot-tilde-${process.pid}-${Date.now()}`; - const expectedHome = path.join(realOsHome, sub); - tempPaths.push(expectedHome); - vi.stubEnv("OPENCLAW_HOME", `~/${sub}`); - - expect(getQQBotMediaPath()).toBe(path.join(expectedHome, ".openclaw", "media", "qqbot")); - - const mediaFile = path.join(expectedHome, ".openclaw", "media", "qqbot", "tilde.png"); - fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); - fs.writeFileSync(mediaFile, "image", "utf8"); - - expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); - }); - - it("falls back to OS home when OPENCLAW_HOME is unset (no regression)", () => { - vi.stubEnv("OPENCLAW_HOME", ""); - - expect(getQQBotMediaPath()).toBe(path.join(realOsHome, ".openclaw", "media", "qqbot")); - }); - - it("treats sentinel strings 'undefined' and 'null' as unset", () => { - for (const sentinel of ["undefined", "null"]) { - vi.stubEnv("OPENCLAW_HOME", sentinel); - expect(getQQBotMediaPath()).toBe(path.join(realOsHome, ".openclaw", "media", "qqbot")); - } - }); - - it("keeps persisted QQ Bot data anchored on the OS home (compatibility)", () => { - const fakeOpenclawHome = makeFakeOpenclawHome(); - vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); - - // Persisted state (sessions, known users, refs) must NOT migrate when an - // operator adds OPENCLAW_HOME — otherwise existing deployments would lose - // their session state. Only the media root follows OPENCLAW_HOME. - expect(getQQBotDataPath()).toBe(path.join(realOsHome, ".openclaw", "qqbot")); - }); - - it("rejects files that live under HOME tree when OPENCLAW_HOME is the active root", () => { - const fakeOpenclawHome = makeFakeOpenclawHome(); - vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); - - // File under the HOME-side mirror — exactly the path that *worked* on - // current main and *broke* the OPENCLAW_HOME setup. After the fix the - // active media root is OPENCLAW_HOME, so a file under HOME is no longer - // implicitly allowed unless it remaps via the existing workspace fallback. - // Use a unique subdirectory so we never collide with real user media. - const stale = `qqbot-stale-${process.pid}-${Date.now()}.png`; - const homeOnlyFile = path.join(realOsHome, ".openclaw", "media", "qqbot", stale); - tempPaths.push(homeOnlyFile); - fs.mkdirSync(path.dirname(homeOnlyFile), { recursive: true }); - fs.writeFileSync(homeOnlyFile, "image", "utf8"); - - expect(resolveQQBotPayloadLocalFilePath(homeOnlyFile)).toBeNull(); - }); - - it("remaps workspace paths under either HOME or OPENCLAW_HOME to the OPENCLAW_HOME media root", () => { - const fakeOpenclawHome = makeFakeOpenclawHome(); - vi.stubEnv("OPENCLAW_HOME", fakeOpenclawHome); - - const baseName = `remap-${process.pid}-${Date.now()}`; - - // Real file lives under the OPENCLAW_HOME media tree. - const mediaFile = path.join( - fakeOpenclawHome, - ".openclaw", - "media", - "qqbot", - "downloads", - baseName, - "remap.png", - ); - fs.mkdirSync(path.dirname(mediaFile), { recursive: true }); - fs.writeFileSync(mediaFile, "image", "utf8"); - - // Agent that only knows the HOME-relative workspace path should still - // resolve to the real file thanks to the dual-tree workspace fallback. - const homeWorkspaceDir = path.join(realOsHome, ".openclaw", "workspace", "qqbot"); - const homeWorkspacePath = path.join(homeWorkspaceDir, "downloads", baseName, "remap.png"); - // Track for cleanup; we only created the unique baseName subdir indirectly - // through resolveQQBotLocalMediaPath, which does NOT actually create the - // HOME-side path, so nothing to clean up there beyond the OPENCLAW_HOME tree. - expect(resolveQQBotLocalMediaPath(homeWorkspacePath)).toBe(fs.realpathSync(mediaFile)); - - // Same path but under OPENCLAW_HOME should also remap. - const openclawWorkspacePath = path.join( - fakeOpenclawHome, - ".openclaw", - "workspace", - "qqbot", - "downloads", - baseName, - "remap.png", - ); - expect(resolveQQBotLocalMediaPath(openclawWorkspacePath)).toBe(fs.realpathSync(mediaFile)); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/platform.ts b/extensions/qqbot/src/engine/utils/platform.ts deleted file mode 100644 index dc85d7676ce8..000000000000 --- a/extensions/qqbot/src/engine/utils/platform.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * Cross-platform path and detection helpers for core/ modules. - * - * Provides home/data/media directory helpers, platform detection, - * silk-wasm availability checks — all without importing `openclaw/plugin-sdk`. - * The temp-directory fallback is delegated to the PlatformAdapter. - */ - -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { getPlatformAdapter } from "../adapter/index.js"; -import { debugLog, debugWarn } from "./log.js"; - -/** - * Resolve the current user's OS home directory safely across platforms. - * - * Priority: - * 1. `os.homedir()` - * 2. `$HOME` or `%USERPROFILE%` - * 3. PlatformAdapter.getTempDir() as a last resort - * - * This is the *operating-system* home and intentionally ignores - * `OPENCLAW_HOME`. QQ Bot still checks this tree for legacy state imports and - * media-path remaps from older releases. - */ -export function getHomeDir(): string { - try { - const home = os.homedir(); - if (home && fs.existsSync(home)) { - return home; - } - } catch { - /* fallback */ - } - - const envHome = process.env.HOME || process.env.USERPROFILE; - if (envHome && fs.existsSync(envHome)) { - return envHome; - } - - return getPlatformAdapter().getTempDir(); -} - -/** - * Resolve the effective OpenClaw home directory. - * - * Mirrors the contract from core (`src/infra/home-dir.ts::resolveEffectiveHomeDir`) - * so QQ Bot media roots live under the same tree the rest of OpenClaw treats as - * `~`. The extension cannot import the core helper directly (it is a separate - * package with `openclaw` as a peer dependency), so this re-implements the - * minimal contract: - * - * 1. `OPENCLAW_HOME` when set (with `~` / `~/...` expanded against the OS home). - * 2. Otherwise fall back to {@link getHomeDir} so existing single-home - * deployments are unaffected. - * - * Empty / `"undefined"` / `"null"` strings are treated as unset to match how - * core normalizes the variable. - */ -function resolveOpenClawHome(): string { - const raw = process.env.OPENCLAW_HOME?.trim(); - if (!raw || raw === "undefined" || raw === "null") { - return getHomeDir(); - } - - if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) { - const osHome = getHomeDir(); - if (raw === "~") { - return osHome; - } - return path.join(osHome, raw.slice(2)); - } - - return raw; -} - -/** - * Return a legacy path under `~/.openclaw/qqbot` without creating it. - * - * Current QQ Bot runtime state lives in plugin SQLite KV. This path remains for - * legacy imports and media-path remaps from older releases. - */ -function getQQBotDataPath(...subPaths: string[]): string { - return path.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths); -} - -/** Return a path under `~/.openclaw/qqbot`, creating it on demand. */ -export function getQQBotDataDir(...subPaths: string[]): string { - const dir = getQQBotDataPath(...subPaths); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - return dir; -} - -/** - * Return a path under `/.openclaw/media/qqbot` without creating it. - * - * Unlike `getQQBotDataPath`, this lives under OpenClaw's core media allowlist - * so downloaded images and audio can be accessed by framework media tooling. - * The base honors `OPENCLAW_HOME` (when set) so files written by agents into - * the OpenClaw-managed media tree are reachable by this plugin even when - * `HOME` and `OPENCLAW_HOME` differ (Docker, multi-user hosts). Fixes #83562. - */ -export function getQQBotMediaPath(...subPaths: string[]): string { - return path.join(resolveOpenClawHome(), ".openclaw", "media", "qqbot", ...subPaths); -} - -/** Return a path under `/.openclaw/media/qqbot`, creating it on demand. */ -export function getQQBotMediaDir(...subPaths: string[]): string { - const dir = getQQBotMediaPath(...subPaths); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - return dir; -} - -/** - * Return `/.openclaw/media`, OpenClaw's shared media root. - * - * This mirrors the directory that core's `buildMediaLocalRoots` exposes as an - * allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a - * QQ Bot payload root lets the plugin trust framework-produced files that live - * in sibling subdirectories such as `outbound/` (written by - * `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping - * the check anchored to a single, well-known directory. Like - * {@link getQQBotMediaPath}, the base honors `OPENCLAW_HOME`. - */ -function getOpenClawMediaDir(): string { - return path.join(resolveOpenClawHome(), ".openclaw", "media"); -} - -export function isWindows(): boolean { - return process.platform === "win32"; -} - -/** Return the preferred temporary directory. */ -export function getTempDir(): string { - return getPlatformAdapter().getTempDir(); -} - -// ---- silk-wasm detection ---- - -let silkWasmAvailable: boolean | null = null; - -/** Check whether silk-wasm can run in the current environment. */ -export async function checkSilkWasmAvailable(): Promise { - if (silkWasmAvailable !== null) { - return silkWasmAvailable; - } - try { - const { isSilk } = await import("silk-wasm"); - isSilk(new Uint8Array(0)); - silkWasmAvailable = true; - debugLog("[platform] silk-wasm: available"); - } catch (err) { - silkWasmAvailable = false; - debugWarn(`[platform] silk-wasm: NOT available (${formatErrorMessage(err)})`); - } - return silkWasmAvailable; -} - -// ---- Tilde expansion and path normalization ---- - -/** Expand `~` to the current user's home directory. */ -function expandTilde(p: string): string { - if (!p) { - return p; - } - if (p === "~") { - return getHomeDir(); - } - if (p.startsWith("~/") || p.startsWith("~\\")) { - return path.join(getHomeDir(), p.slice(2)); - } - return p; -} - -/** Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`. */ -export function normalizePath(p: string): string { - let result = p.trim(); - if (result.startsWith("file://")) { - result = result.slice("file://".length); - try { - result = decodeURIComponent(result); - } catch { - // Keep the raw string if decoding fails. - } - } - return expandTilde(result); -} - -// ---- Local path detection ---- - -/** Return true when the string looks like a local filesystem path rather than a URL. */ -export function isLocalPath(p: string): boolean { - if (!p) { - return false; - } - if (p.startsWith("file://")) { - return true; - } - if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { - return true; - } - if (p.startsWith("/")) { - return true; - } - if (/^[a-zA-Z]:[\\/]/.test(p)) { - return true; - } - if (p.startsWith("\\\\")) { - return true; - } - if (p.startsWith("./") || p.startsWith("../")) { - return true; - } - if (p.startsWith(".\\") || p.startsWith("..\\")) { - return true; - } - return false; -} - -// ---- QQBot media path resolution ---- - -function isPathWithinRoot(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -/** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */ -function resolveQQBotLocalMediaPath(p: string): string { - const normalized = normalizePath(p); - if (!isLocalPath(normalized) || fs.existsSync(normalized)) { - return normalized; - } - - const osHomeDir = getHomeDir(); - const openclawHomeDir = resolveOpenClawHome(); - const mediaRoot = getQQBotMediaPath(); - const dataRoot = getQQBotDataPath(); - // When OPENCLAW_HOME differs from HOME we have to consider workspace roots - // under both trees: agents may be configured with `~`-relative paths (HOME) - // or with the OpenClaw-managed home tree. Deduplicate when they match. - const workspaceRoots = Array.from( - new Set([ - path.join(osHomeDir, ".openclaw", "workspace", "qqbot"), - path.join(openclawHomeDir, ".openclaw", "workspace", "qqbot"), - ]), - ); - const candidateRoots = [ - ...workspaceRoots.map((from) => ({ from, to: mediaRoot })), - { from: dataRoot, to: mediaRoot }, - { from: mediaRoot, to: dataRoot }, - ]; - - for (const { from, to } of candidateRoots) { - if (!isPathWithinRoot(normalized, from)) { - continue; - } - const relative = path.relative(from, normalized); - const candidate = path.join(to, relative); - if (fs.existsSync(candidate)) { - debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`); - return candidate; - } - } - - return normalized; -} - -/** - * Resolve a structured-payload local file path and enforce that it stays within - * QQ Bot-owned storage roots. - */ -export function resolveQQBotPayloadLocalFilePath(p: string): string | null { - const candidate = resolveQQBotLocalMediaPath(p); - if (!candidate.trim()) { - return null; - } - - const resolvedCandidate = path.resolve(candidate); - if (!fs.existsSync(resolvedCandidate)) { - return null; - } - - const canonicalCandidate = fs.realpathSync(resolvedCandidate); - // Trust both the QQ Bot-owned subdirectory and OpenClaw's shared `~/.openclaw/media` - // root. Core helpers like `saveMediaBuffer(..., "outbound", ...)` place framework - // attachments under sibling directories (e.g. `media/outbound/`) that are already - // part of the core media allowlist; we mirror that so auto-routed sends work - // without leaving the plugin's trust boundary. - const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaPath()]; - - for (const root of allowedRoots) { - const resolvedRoot = path.resolve(root); - const canonicalRoot = fs.existsSync(resolvedRoot) - ? fs.realpathSync(resolvedRoot) - : resolvedRoot; - if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) { - return canonicalCandidate; - } - } - - return null; -} diff --git a/extensions/qqbot/src/engine/utils/request-context.ts b/extensions/qqbot/src/engine/utils/request-context.ts deleted file mode 100644 index 674222f784f4..000000000000 --- a/extensions/qqbot/src/engine/utils/request-context.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Request-level context using AsyncLocalStorage. - * - * Provides ambient context (accountId, target openid, chat type, etc.) - * throughout the request lifecycle without explicit parameter threading. - * - * Gateway establishes the scope around each inbound message via - * `runWithRequestContext()`; any async code within that scope (including - * AI agent calls and tool `execute` callbacks) can retrieve the current - * request via `getRequestContext()` without racing with concurrent - * inbound messages. - * - * This is a pure Node.js module with zero framework dependencies, - * making it trivially portable between the built-in and standalone - * versions of QQBot. - */ - -import { AsyncLocalStorage } from "node:async_hooks"; - -/** Context values available during one inbound message handling cycle. */ -interface RequestContext { - /** The account ID handling this request. */ - accountId: string; - /** - * Fully qualified delivery target, e.g. `qqbot:c2c:` or - * `qqbot:group:`. This is what downstream code (e.g. the - * `qqbot_remind` tool building a cron job) uses verbatim. - */ - target?: string; - /** The target openid (C2C) or group openid (group). */ - targetId?: string; - /** Chat type of the originating event. */ - chatType?: "c2c" | "group" | "guild" | "dm" | "channel"; -} - -const store = new AsyncLocalStorage(); - -/** - * Execute an async function with request-scoped context. - * - * All code running within `fn` (including nested async calls) can - * retrieve the context via `getRequestContext()`. - * - * @param ctx - The context to attach to this request. - * @param fn - The async function to run within the context. - * @returns The return value of `fn`. - */ -export function runWithRequestContext(ctx: RequestContext, fn: () => T): T { - return store.run(ctx, fn); -} - -/** - * Retrieve the current request context. - * - * Returns `undefined` when called outside of a `runWithRequestContext` - * scope. - */ -export function getRequestContext(): RequestContext | undefined { - return store.getStore(); -} diff --git a/extensions/qqbot/src/engine/utils/sqlite-state.ts b/extensions/qqbot/src/engine/utils/sqlite-state.ts deleted file mode 100644 index 78e80ba7fc9e..000000000000 --- a/extensions/qqbot/src/engine/utils/sqlite-state.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Qqbot plugin module implements sqlite state behavior. -import type { - OpenKeyedStoreOptions, - PluginStateSyncKeyedStore, -} from "openclaw/plugin-sdk/plugin-state-runtime"; -import { getQQBotRuntime } from "../../bridge/runtime.js"; -export { buildQQBotStateKey } from "./state-keys.js"; - -type QQBotSyncStoreOptions = OpenKeyedStoreOptions & { - stateDir?: string; -}; - -function resolveStoreEnv(options: QQBotSyncStoreOptions): NodeJS.ProcessEnv | undefined { - if (!options.stateDir) { - return options.env; - } - return { - ...(options.env ?? process.env), - OPENCLAW_STATE_DIR: options.stateDir, - }; -} - -export function openQQBotSyncKeyedStore( - options: QQBotSyncStoreOptions, -): PluginStateSyncKeyedStore { - return getQQBotRuntime().state.openSyncKeyedStore({ - namespace: options.namespace, - maxEntries: options.maxEntries, - ...(options.defaultTtlMs != null ? { defaultTtlMs: options.defaultTtlMs } : {}), - ...(resolveStoreEnv(options) ? { env: resolveStoreEnv(options) } : {}), - }); -} diff --git a/extensions/qqbot/src/engine/utils/state-keys.ts b/extensions/qqbot/src/engine/utils/state-keys.ts deleted file mode 100644 index 24a984630fc9..000000000000 --- a/extensions/qqbot/src/engine/utils/state-keys.ts +++ /dev/null @@ -1,5 +0,0 @@ -import crypto from "node:crypto"; - -export function buildQQBotStateKey(...parts: string[]): string { - return crypto.createHash("sha256").update(JSON.stringify(parts)).digest("hex"); -} diff --git a/extensions/qqbot/src/engine/utils/string-normalize.ts b/extensions/qqbot/src/engine/utils/string-normalize.ts deleted file mode 100644 index c8bd84ee5a36..000000000000 --- a/extensions/qqbot/src/engine/utils/string-normalize.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Filename normalization specific to QQ Bot's upload API. - -/** - * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably. - * - * Decodes percent-escaped names, converts Unicode to NFC, and strips - * ASCII control characters. - */ -export function sanitizeFileName(name: string): string { - if (!name) { - return name; - } - let result = name.trim(); - if (result.includes("%")) { - try { - result = decodeURIComponent(result); - } catch { - // Keep the raw value if it is not valid percent-encoding. - } - } - result = result.normalize("NFC"); - result = result.replace(/\p{Cc}/gu, ""); - return result; -} diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts deleted file mode 100644 index b8ab8c1da690..000000000000 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -// Qqbot tests cover stt plugin behavior. -import * as fs from "node:fs"; -import * as path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { withTempDir } from "openclaw/plugin-sdk/test-env"; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const ssrfRuntimeMocks = vi.hoisted(() => ({ - fetchWithSsrFGuard: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: ssrfRuntimeMocks.fetchWithSsrFGuard, -})); - -afterAll(() => { - vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime"); - vi.resetModules(); -}); - -import { resolveSTTConfig, transcribeAudio } from "./stt.js"; - -function cancelTrackedResponse( - text: string, - init: ResponseInit, -): { - response: Response; - wasCanceled: () => boolean; -} { - let canceled = false; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(text)); - }, - cancel() { - canceled = true; - }, - }); - return { - response: new Response(stream, init), - wasCanceled: () => canceled, - }; -} - -function largeTranscriptionJsonResponse(params: { chunkCount: number; chunkSize: number }): { - response: Response; - getReadCount: () => number; -} { - let chunkIndex = 0; - const encoder = new TextEncoder(); - const chunks = [ - '{"text":"', - ...Array.from({ length: params.chunkCount }, () => "a".repeat(params.chunkSize)), - '"}', - ]; - const stream = new ReadableStream({ - pull(controller) { - if (chunkIndex >= chunks.length) { - controller.close(); - return; - } - controller.enqueue(encoder.encode(chunks[chunkIndex])); - chunkIndex += 1; - }, - }); - return { - response: new Response(stream, { - status: 200, - headers: { "content-type": "application/json" }, - }), - getReadCount: () => chunkIndex, - }; -} - -function requireFirstSsrfRequest(): { - url?: unknown; - auditContext?: unknown; - init?: RequestInit; - timeoutMs?: unknown; -} { - const [call] = ssrfRuntimeMocks.fetchWithSsrFGuard.mock.calls; - if (!call) { - throw new Error("expected QQBot STT fetch call"); - } - return call[0] as { - url?: unknown; - auditContext?: unknown; - init?: RequestInit; - timeoutMs?: unknown; - }; -} - -describe("engine/utils/stt", () => { - beforeEach(() => { - ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset(); - ssrfRuntimeMocks.fetchWithSsrFGuard.mockImplementation( - async ({ url, init }: { url: string; init?: RequestInit }) => ({ - response: await fetch(url, init), - release: vi.fn(async () => {}), - }), - ); - }); - - afterEach(() => { - ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset(); - vi.unstubAllGlobals(); - }); - - it("resolves plugin STT config and falls back to provider credentials", () => { - const cfg = { - channels: { - qqbot: { - stt: { - provider: "openai", - baseUrl: "https://api.example.test/v1///", - model: "whisper-large", - }, - }, - }, - models: { - providers: { - openai: { - apiKey: "provider-key", - timeoutSeconds: 45, - }, - }, - }, - }; - - expect(resolveSTTConfig(cfg)).toEqual({ - baseUrl: "https://api.example.test/v1", - apiKey: "provider-key", - model: "whisper-large", - timeoutMs: 45_000, - }); - }); - - it("falls back to a generic framework media model when plugin STT is disabled", () => { - const cfg = { - channels: { qqbot: { stt: { enabled: false, apiKey: "ignored" } } }, - tools: { - media: { - models: [ - { - provider: "local", - baseUrl: "https://stt.example.test/", - model: "sense", - }, - ], - audio: { - timeoutSeconds: 90, - }, - }, - }, - models: { - providers: { - local: { apiKey: "local-key", timeoutSeconds: 120 }, - }, - }, - }; - - expect(resolveSTTConfig(cfg)).toEqual({ - baseUrl: "https://stt.example.test", - apiKey: "local-key", - model: "sense", - timeoutMs: 90_000, - }); - - Object.assign(expectDefined(cfg.tools.media.models[0], "QQBot STT model"), { - timeoutSeconds: 75, - }); - expect(resolveSTTConfig(cfg)?.timeoutMs).toBe(75_000); - }); - - it("returns null when no usable STT credentials are configured", () => { - expect(resolveSTTConfig({ channels: { qqbot: { stt: { baseUrl: "https://x.test" } } } })).toBe( - null, - ); - expect(resolveSTTConfig({})).toBe(null); - }); - - it("posts audio to OpenAI-compatible transcription endpoint", async () => { - await withTempDir("openclaw-qqbot-stt-", async (tmpDir) => { - const audioPath = path.join(tmpDir, "voice.wav"); - fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); - - const release = vi.fn(async () => {}); - ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({ - response: Response.json({ - text: "hello from audio", - }), - release, - }); - - const transcript = await transcribeAudio(audioPath, { - channels: { - qqbot: { - stt: { - baseUrl: "https://api.example.test/v1/", - apiKey: "secret", - model: "whisper-1", - }, - }, - }, - }); - - expect(transcript).toBe("hello from audio"); - expect(ssrfRuntimeMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1); - const request = requireFirstSsrfRequest(); - expect(request.url).toBe("https://api.example.test/v1/audio/transcriptions"); - expect(request.auditContext).toBe("qqbot-stt"); - expect(request.timeoutMs).toBe(60_000); - expect(request.init?.method).toBe("POST"); - expect(request.init?.headers).toEqual({ Authorization: "Bearer secret" }); - expect(request.init?.body).toBeInstanceOf(FormData); - const body = request.init?.body as FormData; - expect(body.get("model")).toBe("whisper-1"); - const file = body.get("file"); - expect(file).toBeInstanceOf(File); - expect((file as File).name).toBe("voice.wav"); - expect((file as File).type).toBe("audio/wav"); - expect(new Uint8Array(await (file as File).arrayBuffer())).toEqual( - new Uint8Array([1, 2, 3, 4]), - ); - expect(release).toHaveBeenCalledTimes(1); - }); - }); - - it("bounds successful STT JSON responses before parsing", async () => { - await withTempDir("openclaw-qqbot-stt-success-limit-", async (tmpDir) => { - const audioPath = path.join(tmpDir, "voice.wav"); - fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); - - const release = vi.fn(async () => {}); - const streamed = largeTranscriptionJsonResponse({ - chunkCount: 18, - chunkSize: 1024 * 1024, - }); - ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({ - response: streamed.response, - release, - }); - - let error: unknown; - try { - await transcribeAudio(audioPath, { - channels: { - qqbot: { - stt: { - baseUrl: "https://api.example.test/v1/", - apiKey: "secret", - model: "whisper-1", - }, - }, - }, - }); - } catch (caught) { - error = caught; - } - - expect(String(error)).toContain("qqbot.stt: JSON response exceeds 16777216 bytes"); - expect(streamed.getReadCount()).toBeLessThan(20); - expect(release).toHaveBeenCalledTimes(1); - }); - }); - - it("bounds STT error bodies on a UTF-16 boundary without using response.text()", async () => { - await withTempDir("openclaw-qqbot-stt-error-", async (tmpDir) => { - const audioPath = path.join(tmpDir, "voice.wav"); - fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4])); - - const release = vi.fn(async () => {}); - const safePrefix = "x".repeat(299); - const tracked = cancelTrackedResponse(`${safePrefix}🎉${"tail".repeat(4096)}`, { - status: 503, - statusText: "Service Unavailable", - headers: { "content-type": "text/plain" }, - }); - const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded")); - ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({ - response: tracked.response, - release, - }); - - let error: unknown; - try { - await transcribeAudio(audioPath, { - channels: { - qqbot: { - stt: { - baseUrl: "https://api.example.test/v1/", - apiKey: "secret", - model: "whisper-1", - }, - }, - }, - }); - } catch (caught) { - error = caught; - } - - expect((error as Error).message).toBe(`STT failed (HTTP 503): ${safePrefix}`); - expect(tracked.wasCanceled()).toBe(true); - expect(textSpy).not.toHaveBeenCalled(); - expect(release).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts deleted file mode 100644 index 6281a1ee00a7..000000000000 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * OpenAI-compatible STT (Speech-to-Text) configuration and transcription. - * - * Uses canonical Plugin SDK coercion helpers plus QQ-specific filename sanitization. - */ - -import * as fs from "node:fs"; -import path from "node:path"; -import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime"; -import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime"; -import { - readProviderJsonResponse, - readResponseTextLimited, -} from "openclaw/plugin-sdk/provider-http"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - normalizeOptionalString, - readStringField, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; -import { sanitizeFileName } from "./string-normalize.js"; - -const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; -const DEFAULT_STT_TIMEOUT_MS = 60_000; - -interface STTConfig { - baseUrl: string; - apiKey: string; - model: string; - timeoutMs: number; -} - -function resolveSTTTimeoutMs(...timeoutSeconds: unknown[]): number { - for (const value of timeoutSeconds) { - const timeoutMs = finiteSecondsToTimerSafeMilliseconds(value); - if (timeoutMs !== undefined) { - return timeoutMs; - } - } - return DEFAULT_STT_TIMEOUT_MS; -} - -/** Resolve the STT configuration from the nested config object. */ -export function resolveSTTConfig(cfg: Record): STTConfig | null { - const channels = asOptionalObjectRecord(cfg.channels); - const qqbot = asOptionalObjectRecord(channels?.qqbot); - const channelStt = asOptionalObjectRecord(qqbot?.stt); - const models = asOptionalObjectRecord(cfg.models); - const providers = asOptionalObjectRecord(models?.providers); - - // Prefer plugin-specific STT config. - if (channelStt && channelStt.enabled !== false) { - const providerId = readStringField(channelStt, "provider") ?? "openai"; - const providerCfg = asOptionalObjectRecord(providers?.[providerId]); - const baseUrl = - readStringField(channelStt, "baseUrl") ?? readStringField(providerCfg, "baseUrl"); - const apiKey = readStringField(channelStt, "apiKey") ?? readStringField(providerCfg, "apiKey"); - const model = readStringField(channelStt, "model") ?? "whisper-1"; - if (baseUrl && apiKey) { - return { - baseUrl: baseUrl.replace(/\/+$/, ""), - apiKey, - model, - timeoutMs: resolveSTTTimeoutMs(providerCfg?.timeoutSeconds), - }; - } - } - - // Fall back to framework-level audio model config. - const tools = asOptionalObjectRecord(cfg.tools); - const media = asOptionalObjectRecord(tools?.media); - const audio = asOptionalObjectRecord(media?.audio); - const mediaModels = Array.isArray(media?.models) ? media.models : []; - const audioModelEntry = mediaModels - .map((entry) => asOptionalObjectRecord(entry)) - .find((entry) => !Array.isArray(entry?.capabilities) || entry.capabilities.includes("audio")); - if (audioModelEntry) { - const providerId = readStringField(audioModelEntry, "provider") ?? "openai"; - const providerCfg = asOptionalObjectRecord(providers?.[providerId]); - const baseUrl = - readStringField(audioModelEntry, "baseUrl") ?? readStringField(providerCfg, "baseUrl"); - const apiKey = - readStringField(audioModelEntry, "apiKey") ?? readStringField(providerCfg, "apiKey"); - const model = readStringField(audioModelEntry, "model") ?? "whisper-1"; - if (baseUrl && apiKey) { - return { - baseUrl: baseUrl.replace(/\/+$/, ""), - apiKey, - model, - timeoutMs: resolveSTTTimeoutMs( - audioModelEntry.timeoutSeconds, - audio?.timeoutSeconds, - providerCfg?.timeoutSeconds, - ), - }; - } - } - - return null; -} - -/** Send audio to an OpenAI-compatible STT endpoint and return the transcript. */ -export async function transcribeAudio( - audioPath: string, - cfg: Record, -): Promise { - const sttCfg = resolveSTTConfig(cfg); - if (!sttCfg) { - return null; - } - - const fileBuffer = fs.readFileSync(audioPath); - const fileName = sanitizeFileName(path.basename(audioPath)); - const mime = mimeTypeFromFilePath(fileName) ?? "application/octet-stream"; - - const form = new FormData(); - form.append("file", new Blob([fileBuffer], { type: mime }), fileName); - form.append("model", sttCfg.model); - - const { response: resp, release } = await fetchWithSsrFGuard({ - url: `${sttCfg.baseUrl}/audio/transcriptions`, - auditContext: "qqbot-stt", - timeoutMs: sttCfg.timeoutMs, - init: { - method: "POST", - headers: { Authorization: `Bearer ${sttCfg.apiKey}` }, - body: form, - }, - }); - try { - if (!resp.ok) { - const detail = await readResponseTextLimited(resp, STT_ERROR_BODY_LIMIT_BYTES).catch( - () => "", - ); - throw new Error(`STT failed (HTTP ${resp.status}): ${truncateUtf16Safe(detail, 300)}`); - } - - const result = await readProviderJsonResponse<{ text?: string }>(resp, "qqbot.stt"); - return normalizeOptionalString(result.text) ?? null; - } finally { - await release(); - } -} diff --git a/extensions/qqbot/src/engine/utils/text-parsing.test.ts b/extensions/qqbot/src/engine/utils/text-parsing.test.ts deleted file mode 100644 index 9168b6a3779b..000000000000 --- a/extensions/qqbot/src/engine/utils/text-parsing.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Qqbot tests cover text parsing plugin behavior. -import { describe, expect, it, vi } from "vitest"; -import { parseFaceTags } from "./text-parsing.js"; - -describe("parseFaceTags", () => { - it("returns empty string when input is undefined", () => { - expect(parseFaceTags(undefined)).toBe(""); - }); - - it("returns empty string when input is null", () => { - expect(parseFaceTags(null)).toBe(""); - }); - - it("returns empty string when input is empty string", () => { - expect(parseFaceTags("")).toBe(""); - }); - - it("skips oversized base64 ext payloads before decoding", () => { - const oversizedBase64 = "A".repeat(100_000); - const tag = ``; - const bufferFromSpy = vi.spyOn(Buffer, "from"); - - try { - expect(parseFaceTags(tag)).toBe("[Emoji: unknown emoji]"); - expect(bufferFromSpy).not.toHaveBeenCalledWith(oversizedBase64, "base64"); - } finally { - bufferFromSpy.mockRestore(); - } - }); -}); diff --git a/extensions/qqbot/src/engine/utils/text-parsing.ts b/extensions/qqbot/src/engine/utils/text-parsing.ts deleted file mode 100644 index ee385ce142dd..000000000000 --- a/extensions/qqbot/src/engine/utils/text-parsing.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Text parsing utilities — zero external dependency. - * - * Contains pure functions for message text processing. - */ - -import type { RefAttachmentSummary } from "../ref/types.js"; - -// ============ Internal markers ============ - -const INTERNAL_MARKER_RE = /\[internal:?\s*[^\]]*\]|\[debug:?\s*[^\]]*\]|\[system:?\s*[^\]]*\]/gi; - -/** Remove internal markers like `[internal:...]`, `[debug:...]`, `[system:...]`. */ -export function filterInternalMarkers(text: string | undefined | null): string { - if (!text) { - return ""; - } - return text.replace(INTERNAL_MARKER_RE, "").trim(); -} - -// ============ Ref indices ============ - -/** QQ 引用(回复)消息类型常量。 */ -export const MSG_TYPE_QUOTE = 103; - -/** - * Parse message_scene.ext to extract refMsgIdx and msgIdx. - * - * Supports both ext prefix formats: - * - `ref_msg_idx=` / `msg_idx=` (platform native format) - * - `refMsgIdx:` / `msgIdx:` (legacy internal format) - * - * When `messageType` equals `MSG_TYPE_QUOTE` (103) and `msgElements` is - * provided, `msgElements[0].msg_idx` takes precedence over the ext-parsed - * `refMsgIdx` value — the element-level index is more authoritative for - * quote messages. - */ -export function parseRefIndices( - ext?: string[], - messageType?: number, - msgElements?: Array<{ msg_idx?: string }>, -): { refMsgIdx?: string; msgIdx?: string } { - let refMsgIdx: string | undefined; - let msgIdx: string | undefined; - - if (ext && ext.length > 0) { - for (const item of ext) { - if (typeof item !== "string") { - continue; - } - // Platform native format: ref_msg_idx= / msg_idx= - if (item.startsWith("ref_msg_idx=")) { - refMsgIdx = item.slice("ref_msg_idx=".length).trim(); - } else if (item.startsWith("msg_idx=")) { - msgIdx = item.slice("msg_idx=".length).trim(); - } - // Legacy internal format: refMsgIdx: / msgIdx: - else if (item.startsWith("refMsgIdx:")) { - refMsgIdx = item.slice("refMsgIdx:".length).trim(); - } else if (item.startsWith("msgIdx:")) { - msgIdx = item.slice("msgIdx:".length).trim(); - } - } - } - - // For quote messages, msg_elements[0].msg_idx is more authoritative. - if (messageType === MSG_TYPE_QUOTE) { - const refElement = msgElements?.[0]; - if (refElement?.msg_idx) { - refMsgIdx = refElement.msg_idx; - } - } - - return { refMsgIdx, msgIdx }; -} - -// ============ Face tags ============ - -const MAX_FACE_EXT_BYTES = 64 * 1024; - -/** Estimate Base64 decoded byte size (replaces plugin-sdk estimateBase64DecodedBytes). */ -function estimateBase64Size(base64: string): number { - const len = base64.length; - const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; - return Math.ceil((len * 3) / 4) - padding; -} - -/** Replace QQ face tags with readable text labels. */ -export function parseFaceTags(text: string | undefined | null): string { - if (!text) { - return ""; - } - - return text.replace(//g, (_match, ext: string) => { - try { - if (estimateBase64Size(ext) > MAX_FACE_EXT_BYTES) { - return "[Emoji: unknown emoji]"; - } - const decoded = Buffer.from(ext, "base64").toString("utf-8"); - const parsed = JSON.parse(decoded); - const faceName = parsed.text || "unknown emoji"; - return `[Emoji: ${faceName}]`; - } catch { - return _match; - } - }); -} - -// ============ Attachment summaries ============ - -/** Lowercase a string safely (replaces plugin-sdk normalizeLowercaseStringOrEmpty). */ -function lc(s: string | undefined | null): string { - return (s ?? "").toLowerCase(); -} - -/** Build attachment summaries for ref-index caching. */ -export function buildAttachmentSummaries( - attachments?: Array<{ - content_type: string; - url: string; - filename?: string; - voice_wav_url?: string; - }>, - localPaths?: Array, -): RefAttachmentSummary[] | undefined { - if (!attachments || attachments.length === 0) { - return undefined; - } - - return attachments.map((att, idx) => { - const ct = lc(att.content_type); - let type: RefAttachmentSummary["type"] = "unknown"; - if (ct.startsWith("image/")) { - type = "image"; - } else if ( - ct === "voice" || - ct.startsWith("audio/") || - ct.includes("silk") || - ct.includes("amr") - ) { - type = "voice"; - } else if (ct.startsWith("video/")) { - type = "video"; - } else if (ct.startsWith("application/") || ct.startsWith("text/")) { - type = "file"; - } - - return { - type, - filename: att.filename, - contentType: att.content_type, - localPath: localPaths?.[idx] ?? undefined, - }; - }); -} diff --git a/extensions/qqbot/src/engine/utils/upload-cache.test.ts b/extensions/qqbot/src/engine/utils/upload-cache.test.ts deleted file mode 100644 index b989596869b9..000000000000 --- a/extensions/qqbot/src/engine/utils/upload-cache.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Qqbot tests cover upload cache plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - debugLog: vi.fn(), -})); - -vi.mock("./log.js", () => ({ - debugLog: (...args: unknown[]) => mocks.debugLog(...args), -})); - -import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./upload-cache.js"; - -describe("qqbot upload-cache", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - mocks.debugLog.mockReset(); - }); - - it("reuses cached file info before expiry", () => { - const hash = computeFileHash("qqbot-cache-hit"); - - setCachedFileInfo(hash, "group", "target-hit", 1, "file-info-hit", "uuid-hit", 3600); - - expect(getCachedFileInfo(hash, "group", "target-hit", 1)).toBe("file-info-hit"); - }); - - it("drops cached file info when the current clock is invalid", () => { - const hash = computeFileHash("qqbot-invalid-clock"); - setCachedFileInfo(hash, "group", "target-invalid-clock", 1, "file-info-invalid", "uuid", 3600); - vi.spyOn(Date, "now").mockReturnValue(Number.NaN); - - expect(getCachedFileInfo(hash, "group", "target-invalid-clock", 1)).toBeNull(); - }); - - it("does not cache file info when ttl expiry exceeds the Date range", () => { - vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000); - const hash = computeFileHash("qqbot-overflow"); - - setCachedFileInfo(hash, "group", "target-overflow", 1, "file-info-overflow", "uuid", 3600); - - expect(getCachedFileInfo(hash, "group", "target-overflow", 1)).toBeNull(); - }); - - it("logs cache keys without splitting surrogate pairs", () => { - const hash = computeFileHash("qqbot-surrogate-key"); - const keyPrefix = `${hash}:group:`; - - setCachedFileInfo(hash, "group", "😀target", 1, "file-info", "uuid-safe", 3600); - expect(getCachedFileInfo(hash, "group", "😀target", 1)).toBe("file-info"); - - expect(mocks.debugLog).toHaveBeenNthCalledWith( - 1, - `[upload-cache] Cache SET: key=${keyPrefix}..., ttl=3540s, uuid=uuid-safe`, - ); - expect(mocks.debugLog).toHaveBeenNthCalledWith( - 2, - `[upload-cache] Cache HIT: key=${keyPrefix}..., fileUuid=uuid-safe`, - ); - }); -}); diff --git a/extensions/qqbot/src/engine/utils/upload-cache.ts b/extensions/qqbot/src/engine/utils/upload-cache.ts deleted file mode 100644 index 7387bd4d5bcd..000000000000 --- a/extensions/qqbot/src/engine/utils/upload-cache.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Cache `file_info` values returned by the QQ Bot API so identical uploads can be reused - * before the server-side TTL expires. - */ - -import * as crypto from "node:crypto"; -import { - isFutureDateTimestampMs, - resolveExpiresAtMsFromDurationSeconds, -} from "openclaw/plugin-sdk/number-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import type { ChatScope } from "../types.js"; -import { debugLog } from "./log.js"; - -interface CacheEntry { - fileInfo: string; - fileUuid: string; - expiresAt: number; -} - -const cache = new Map(); -const MAX_CACHE_SIZE = 500; - -/** Compute an MD5 hash used as part of the cache key. */ -export function computeFileHash(data: string | Buffer): string { - const content = typeof data === "string" ? data : data; - return crypto.createHash("md5").update(content).digest("hex"); -} - -/** Build the in-memory cache key. */ -function buildCacheKey( - contentHash: string, - scope: string, - targetId: string, - fileType: number, -): string { - return `${contentHash}:${scope}:${targetId}:${fileType}`; -} - -/** Look up a cached `file_info` value. */ -export function getCachedFileInfo( - contentHash: string, - scope: ChatScope, - targetId: string, - fileType: number, -): string | null { - const key = buildCacheKey(contentHash, scope, targetId, fileType); - const entry = cache.get(key); - - if (!entry) { - return null; - } - - if (!isFutureDateTimestampMs(entry.expiresAt)) { - cache.delete(key); - return null; - } - - debugLog( - `[upload-cache] Cache HIT: key=${truncateUtf16Safe(key, 40)}..., fileUuid=${entry.fileUuid}`, - ); - return entry.fileInfo; -} - -/** Store an upload result in the cache. */ -export function setCachedFileInfo( - contentHash: string, - scope: ChatScope, - targetId: string, - fileType: number, - fileInfo: string, - fileUuid: string, - ttl: number, -): void { - if (cache.size >= MAX_CACHE_SIZE) { - const now = Date.now(); - for (const [k, v] of cache) { - if (!isFutureDateTimestampMs(v.expiresAt, { nowMs: now })) { - cache.delete(k); - } - } - if (cache.size >= MAX_CACHE_SIZE) { - const keys = Array.from(cache.keys()); - for (const key of keys.slice(0, Math.ceil(keys.length / 2))) { - cache.delete(key); - } - } - } - - const key = buildCacheKey(contentHash, scope, targetId, fileType); - const safetyMargin = 60; - const effectiveTtl = Math.max(ttl - safetyMargin, 10); - const expiresAt = resolveExpiresAtMsFromDurationSeconds(effectiveTtl); - if (expiresAt === undefined) { - cache.delete(key); - return; - } - - cache.set(key, { - fileInfo, - fileUuid, - expiresAt, - }); - - debugLog( - `[upload-cache] Cache SET: key=${truncateUtf16Safe(key, 40)}..., ttl=${effectiveTtl}s, uuid=${fileUuid}`, - ); -} diff --git a/extensions/qqbot/src/engine/utils/voice-text.ts b/extensions/qqbot/src/engine/utils/voice-text.ts deleted file mode 100644 index 3e2d22cb5de2..000000000000 --- a/extensions/qqbot/src/engine/utils/voice-text.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Voice transcript formatting utility. - * - * Zero external dependencies — pure string formatting. - */ - -/** Format voice transcripts into user-visible text. */ -export function formatVoiceText(transcripts: string[]): string { - if (transcripts.length === 0) { - return ""; - } - return transcripts.length === 1 - ? `[Voice message] ${transcripts[0]}` - : transcripts.map((t, i) => `[Voice ${i + 1}] ${t}`).join("\n"); -} diff --git a/extensions/qqbot/src/exec-approvals.test.ts b/extensions/qqbot/src/exec-approvals.test.ts deleted file mode 100644 index 2bad6c2684ec..000000000000 --- a/extensions/qqbot/src/exec-approvals.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Qqbot tests cover exec approvals plugin behavior. -import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerPlatformAdapter, type PlatformAdapter } from "./engine/adapter/index.js"; -import { authorizeQQBotApprovalAction, matchesQQBotApprovalAccount } from "./exec-approvals.js"; - -describe("authorizeQQBotApprovalAction", () => { - beforeEach(() => { - registerPlatformAdapter({ - validateRemoteUrl: vi.fn(async () => undefined), - resolveSecret: vi.fn(async (value: unknown) => - typeof value === "string" ? value : undefined, - ), - downloadFile: vi.fn(async () => "/tmp/file"), - fetchMedia: vi.fn(async () => { - throw new Error("unused"); - }), - getTempDir: () => "/tmp", - hasConfiguredSecret: (value: unknown) => typeof value === "string" && value.length > 0, - normalizeSecretInputString: (value: unknown) => - typeof value === "string" ? value : undefined, - resolveSecretInputString: ({ value }: { value: unknown }) => - typeof value === "string" ? value : undefined, - } as PlatformAdapter); - }); - - it("marks unconfigured exec approval fallback authorization as implicit", () => { - const result = authorizeQQBotApprovalAction({ - cfg: { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - }, - }, - } as OpenClawConfig, - accountId: "default", - senderId: "ATTACKER_OPENID", - approvalKind: "exec", - }); - - expect(result).toEqual({ authorized: true }); - expect(isImplicitSameChatApprovalAuthorization(result)).toBe(true); - }); - - it("keeps configured approver authorization explicit", () => { - const result = authorizeQQBotApprovalAction({ - cfg: { - channels: { - qqbot: { - appId: "app", - clientSecret: "secret", - execApprovals: { - enabled: true, - approvers: ["OWNER_OPENID"], - }, - }, - }, - } as OpenClawConfig, - accountId: "default", - senderId: "OWNER_OPENID", - approvalKind: "exec", - }); - - expect(result).toEqual({ authorized: true }); - expect(isImplicitSameChatApprovalAuthorization(result)).toBe(false); - }); - - it("reports each configured account as a raw route candidate", () => { - const cfg = { - channels: { - qqbot: { - accounts: { - default: { - appId: "default-app", - clientSecret: "default-secret", - execApprovals: { enabled: true, approvers: ["OWNER"] }, - }, - ops: { - appId: "ops-app", - clientSecret: "ops-secret", - execApprovals: { enabled: true, approvers: ["OWNER"] }, - }, - }, - }, - }, - } as OpenClawConfig; - const request = { - id: "req-unbound", - request: { command: "echo hi", turnSourceChannel: "qqbot" }, - }; - - expect(matchesQQBotApprovalAccount({ cfg, accountId: "default", request })).toBe(true); - expect(matchesQQBotApprovalAccount({ cfg, accountId: "ops", request })).toBe(true); - }); -}); diff --git a/extensions/qqbot/src/exec-approvals.ts b/extensions/qqbot/src/exec-approvals.ts deleted file mode 100644 index 8e4603d78b66..000000000000 --- a/extensions/qqbot/src/exec-approvals.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Qqbot plugin module implements exec approvals behavior. -import { - markImplicitSameChatApprovalAuthorization, - resolveApprovalApprovers, -} from "openclaw/plugin-sdk/approval-auth-runtime"; -import { - createChannelExecApprovalProfile, - isChannelExecApprovalClientEnabledFromConfig, - matchesApprovalRequestFilters, -} from "openclaw/plugin-sdk/approval-client-runtime"; -import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime"; -import type { - ExecApprovalRequest, - PluginApprovalRequest, -} from "openclaw/plugin-sdk/approval-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./bridge/config.js"; -import type { QQBotExecApprovalConfig } from "./types.js"; - -function normalizeApproverId(value: string | number): string | undefined { - const trimmed = normalizeOptionalString(String(value)); - return trimmed || undefined; -} - -export function resolveQQBotExecApprovalConfig(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): QQBotExecApprovalConfig | undefined { - const account = resolveQQBotAccount(params.cfg, params.accountId); - const config = account.config.execApprovals; - if (!config) { - return undefined; - } - return { - ...config, - enabled: account.enabled && account.secretSource !== "none" ? config.enabled : false, - }; -} - -function getQQBotExecApprovalApprovers(params: { - cfg: OpenClawConfig; - accountId?: string | null; -}): string[] { - const accountConfig = resolveQQBotAccount(params.cfg, params.accountId).config; - return resolveApprovalApprovers({ - explicit: resolveQQBotExecApprovalConfig(params)?.approvers, - allowFrom: accountConfig.allowFrom, - normalizeApprover: normalizeApproverId, - }); -} - -function isQQBotExecApprovalAccountEligible(params: { - cfg: OpenClawConfig; - accountId: string; - request: ExecApprovalRequest | PluginApprovalRequest; -}): boolean { - const account = resolveQQBotAccount(params.cfg, params.accountId); - if (!account.enabled || account.secretSource === "none") { - return false; - } - const config = resolveQQBotExecApprovalConfig(params); - return ( - isChannelExecApprovalClientEnabledFromConfig({ - enabled: config?.enabled, - approverCount: getQQBotExecApprovalApprovers(params).length, - }) && - matchesApprovalRequestFilters({ - request: params.request.request, - agentFilter: config?.agentFilter, - sessionFilter: config?.sessionFilter, - fallbackAgentIdFromSessionKey: true, - }) - ); -} - -function matchesQQBotRequestAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: ExecApprovalRequest | PluginApprovalRequest; -}): boolean { - const accountId = params.accountId ?? resolveDefaultQQBotAccountId(params.cfg); - return doesApprovalRequestSelectChannelAccount({ - ...params, - channel: "qqbot", - defaultAccountId: resolveDefaultQQBotAccountId(params.cfg), - eligibleAccountIds: isQQBotExecApprovalAccountEligible({ ...params, accountId }) - ? [accountId] - : [], - }); -} - -function matchesQQBotFallbackRequestAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: ExecApprovalRequest | PluginApprovalRequest; -}): boolean { - const accountId = params.accountId ?? resolveDefaultQQBotAccountId(params.cfg); - const account = resolveQQBotAccount(params.cfg, accountId); - return doesApprovalRequestSelectChannelAccount({ - ...params, - channel: "qqbot", - defaultAccountId: resolveDefaultQQBotAccountId(params.cfg), - eligibleAccountIds: account.enabled && account.secretSource !== "none" ? [accountId] : [], - }); -} - -/** - * Minimal structural shape required to evaluate per-account ownership. - * - * The SDK types (`ExecApprovalRequest` / `PluginApprovalRequest`) and the - * channel-local approval request types (see `engine/approval/index.ts`) - * share the same logical fields but differ on bookkeeping metadata - * (e.g. `createdAtMs`), so we accept any object exposing the relevant - * routing fields. Consumers can pass either flavor safely. - */ -type QQBotApprovalAccountOwnershipRequest = { - request: { - sessionKey?: string | null; - turnSourceChannel?: string | null; - turnSourceTo?: string | null; - turnSourceAccountId?: string | null; - }; -}; - -/** - * Unified per-account ownership check used by both the profile and - * fallback approval paths. Dispatches to the profile rules when the - * current account has `execApprovals` configured, otherwise uses the - * fallback rules. - * - * This is the single source of truth for "does this QQBot handler own - * this approval request?" and is consumed by both the capability - * gate (shouldHandle) and the lazy native runtime adapter. - */ -export function matchesQQBotApprovalAccount(params: { - cfg: OpenClawConfig; - accountId?: string | null; - request: QQBotApprovalAccountOwnershipRequest; -}): boolean { - const normalized = { - cfg: params.cfg, - accountId: params.accountId, - request: params.request as unknown as ExecApprovalRequest | PluginApprovalRequest, - }; - if (resolveQQBotExecApprovalConfig(normalized) !== undefined) { - return matchesQQBotRequestAccount(normalized); - } - return matchesQQBotFallbackRequestAccount(normalized); -} - -const qqbotExecApprovalProfile = createChannelExecApprovalProfile({ - resolveConfig: resolveQQBotExecApprovalConfig, - resolveApprovers: getQQBotExecApprovalApprovers, - matchesRequestAccount: matchesQQBotRequestAccount, - fallbackAgentIdFromSessionKey: true, - requireClientEnabledForLocalPromptSuppression: false, -}); - -export const isQQBotExecApprovalClientEnabled = qqbotExecApprovalProfile.isClientEnabled; -const isQQBotExecApprovalApprover = qqbotExecApprovalProfile.isApprover; -const isQQBotExecApprovalAuthorizedSender = qqbotExecApprovalProfile.isAuthorizedSender; -export const shouldHandleQQBotExecApprovalRequest = qqbotExecApprovalProfile.shouldHandleRequest; - -export function authorizeQQBotApprovalAction(params: { - cfg: OpenClawConfig; - accountId?: string | null; - senderId?: string | null; - approvalKind: "exec" | "plugin"; -}): { authorized: boolean; reason?: string } { - if (resolveQQBotExecApprovalConfig(params) === undefined) { - return markImplicitSameChatApprovalAuthorization({ authorized: true }); - } - - const authorized = - params.approvalKind === "plugin" - ? isQQBotExecApprovalApprover(params) - : isQQBotExecApprovalAuthorizedSender(params); - return authorized - ? { authorized: true } - : { authorized: false, reason: "You are not authorized to approve this request." }; -} diff --git a/extensions/qqbot/src/group-policy.test.ts b/extensions/qqbot/src/group-policy.test.ts deleted file mode 100644 index 9b46a490ef6a..000000000000 --- a/extensions/qqbot/src/group-policy.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - buildChannelGroupsScopeTree, - resolveScopeKeyCaseInsensitive, -} from "openclaw/plugin-sdk/channel-policy"; -// Qqbot tests cover shared group tool policy behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it } from "vitest"; -import { qqbotPlugin } from "./channel.js"; -import { resolveQQBotGroupToolPolicy } from "./group-policy.js"; - -describe("qqbot group tool policy", () => { - it("prefers an exact group key over a case-insensitive match", () => { - const cfg = { - channels: { - qqbot: { - groups: { - g1: { tools: { allow: ["case-insensitive"] } }, - G1: { tools: { deny: ["exact"] } }, - }, - }, - }, - } as OpenClawConfig; - - expect(resolveQQBotGroupToolPolicy({ cfg, groupId: "G1" })).toStrictEqual({ - deny: ["exact"], - }); - }); - - it("resolves toolsBySender before group tools", () => { - const cfg = { - channels: { - qqbot: { - groups: { - G1: { - tools: { allow: ["read"] }, - toolsBySender: { - "id:alice": { deny: ["*"] }, - }, - }, - }, - }, - }, - } as OpenClawConfig; - - expect( - resolveQQBotGroupToolPolicy({ - cfg, - groupId: "G1", - senderId: "alice", - }), - ).toStrictEqual({ deny: ["*"] }); - }); - - it("uses a case-insensitive group key when no exact key exists", () => { - const cfg = { - channels: { - qqbot: { - groups: { - Group_OPENID: { - tools: { allow: ["read"] }, - toolsBySender: { - "id:alice": { deny: ["*"] }, - }, - }, - }, - }, - }, - } as OpenClawConfig; - - expect( - resolveQQBotGroupToolPolicy({ - cfg, - groupId: "group_openid", - senderId: "alice", - }), - ).toStrictEqual({ deny: ["*"] }); - }); - - it("keeps wildcard defaults out of case-insensitive scope matching", () => { - const cfg = { - channels: { - qqbot: { - groups: { - "*": { tools: { deny: ["default"] } }, - }, - }, - }, - } as OpenClawConfig; - const tree = buildChannelGroupsScopeTree(cfg, "qqbot"); - - expect(resolveScopeKeyCaseInsensitive(tree, "*")).toBeUndefined(); - expect(resolveQQBotGroupToolPolicy({ cfg, groupId: "*" })).toStrictEqual({ - deny: ["default"], - }); - }); - - it("registers the resolver on the channel plugin", () => { - const cfg = { - channels: { - qqbot: { - groups: { - G1: { tools: { deny: ["*"] } }, - }, - }, - }, - } as OpenClawConfig; - - expect(qqbotPlugin.groups?.resolveToolPolicy?.({ cfg, groupId: "G1" })).toStrictEqual({ - deny: ["*"], - }); - }); -}); diff --git a/extensions/qqbot/src/group-policy.ts b/extensions/qqbot/src/group-policy.ts deleted file mode 100644 index 754a381b34bc..000000000000 --- a/extensions/qqbot/src/group-policy.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Qqbot plugin module implements group tool policy behavior. -import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; -import { - buildChannelGroupsScopeTree, - resolveScopeKeyCaseInsensitive, - resolveScopeToolsPolicy, - type GroupToolPolicyConfig, -} from "openclaw/plugin-sdk/channel-policy"; - -export function resolveQQBotGroupToolPolicy( - params: ChannelGroupContext, -): GroupToolPolicyConfig | undefined { - const tree = buildChannelGroupsScopeTree(params.cfg, "qqbot", params.accountId); - const scopeKey = resolveScopeKeyCaseInsensitive(tree, params.groupId); - return resolveScopeToolsPolicy({ - ...params, - tree, - path: scopeKey ? [scopeKey] : [], - messageProvider: "qqbot", - }); -} diff --git a/extensions/qqbot/src/manifest-schema.test.ts b/extensions/qqbot/src/manifest-schema.test.ts deleted file mode 100644 index fdc1e6ff4a54..000000000000 --- a/extensions/qqbot/src/manifest-schema.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Qqbot tests cover manifest schema plugin behavior. -import fs from "node:fs"; -import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime"; -import { describe, expect, it } from "vitest"; - -const manifest = JSON.parse( - fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), -) as { configSchema: Record }; -const manifestConfigSchemaCacheKey = "qqbot.manifest.config-schema"; - -describe("qqbot manifest schema", () => { - it("accepts top-level speech overrides", () => { - const result = validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: manifestConfigSchemaCacheKey, - value: { - tts: { - provider: "openai", - baseUrl: "https://example.com/v1", - apiKey: "tts-key", - model: "gpt-4o-mini-tts", - voice: "alloy", - authStyle: "api-key", - queryParams: { - format: "wav", - }, - speed: 1.1, - }, - stt: { - provider: "openai", - baseUrl: "https://example.com/v1", - apiKey: "stt-key", - model: "whisper-1", - }, - }, - }); - - expect(result.ok).toBe(true); - }); - - it("accepts defaultAccount", () => { - const result = validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: manifestConfigSchemaCacheKey, - value: { - defaultAccount: "bot2", - accounts: { - bot2: { - appId: "654321", - }, - }, - }, - }); - - expect(result.ok).toBe(true); - }); - - it("validates context visibility modes", () => { - expect( - validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: manifestConfigSchemaCacheKey, - value: { contextVisibility: "allowlist_quote" }, - }).ok, - ).toBe(true); - expect( - validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: manifestConfigSchemaCacheKey, - value: { accounts: { bot2: { contextVisibility: "allowlist" } } }, - }).ok, - ).toBe(true); - expect( - validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: manifestConfigSchemaCacheKey, - value: { contextVisibility: "allowlistt" }, - }).ok, - ).toBe(false); - expect( - validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: manifestConfigSchemaCacheKey, - value: { accounts: { bot2: { contextVisibility: "allowlistt" } } }, - }).ok, - ).toBe(false); - }); -}); diff --git a/extensions/qqbot/src/qqbot-test-support.ts b/extensions/qqbot/src/qqbot-test-support.ts deleted file mode 100644 index 263d75565e13..000000000000 --- a/extensions/qqbot/src/qqbot-test-support.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Qqbot plugin module implements qqbot test support behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; - -export function makeQqbotSecretRefConfig(): OpenClawConfig { - return { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - }, - }, - } as OpenClawConfig; -} - -export function makeQqbotDefaultAccountConfig(): OpenClawConfig { - return { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { appId: "123456" }, - }, - }, - }, - } as OpenClawConfig; -} diff --git a/extensions/qqbot/src/secret-contract.test.ts b/extensions/qqbot/src/secret-contract.test.ts deleted file mode 100644 index 1728f4e1a1b7..000000000000 --- a/extensions/qqbot/src/secret-contract.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Qqbot tests cover secret contract plugin behavior. -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - applyResolvedAssignments, - createResolverContext, - resolveSecretRefValues, -} from "openclaw/plugin-sdk/secret-ref-runtime"; -import { describe, expect, it } from "vitest"; -import { collectRuntimeConfigAssignments } from "./secret-contract.js"; - -async function resolveQqbotSecretAssignments( - sourceConfig: OpenClawConfig, - env: NodeJS.ProcessEnv, -): Promise { - const resolvedConfig: OpenClawConfig = structuredClone(sourceConfig); - const context = createResolverContext({ sourceConfig, env }); - - collectRuntimeConfigAssignments({ - config: resolvedConfig, - defaults: sourceConfig.secrets?.defaults, - context, - }); - - const resolved = await resolveSecretRefValues( - context.assignments.map((assignment) => assignment.ref), - { - config: sourceConfig, - env: context.env, - cache: context.cache, - }, - ); - applyResolvedAssignments({ assignments: context.assignments, resolved }); - - expect(context.warnings).toStrictEqual([]); - return resolvedConfig; -} - -describe("qqbot secret contract", () => { - it("resolves top-level clientSecret SecretRefs even when clientSecretFile is configured", async () => { - const resolvedConfig = await resolveQqbotSecretAssignments( - { - channels: { - qqbot: { - enabled: true, - appId: "123456", - clientSecret: { source: "env", provider: "default", id: "QQBOT_CLIENT_SECRET" }, - clientSecretFile: "/ignored/by/runtime", - }, - }, - } as OpenClawConfig, - { QQBOT_CLIENT_SECRET: "resolved-top-level-secret" }, - ); - - expect(resolvedConfig.channels?.qqbot?.clientSecret).toBe("resolved-top-level-secret"); - }); - - it("resolves account clientSecret SecretRefs even when account clientSecretFile is configured", async () => { - const resolvedConfig = await resolveQqbotSecretAssignments( - { - channels: { - qqbot: { - enabled: true, - accounts: { - bot2: { - enabled: true, - appId: "654321", - clientSecret: { source: "env", provider: "default", id: "QQBOT_BOT2_SECRET" }, - clientSecretFile: "/ignored/by/runtime", - }, - }, - }, - }, - } as OpenClawConfig, - { QQBOT_BOT2_SECRET: "resolved-bot2-secret" }, - ); - - expect(resolvedConfig.channels?.qqbot?.accounts?.bot2?.clientSecret).toBe( - "resolved-bot2-secret", - ); - }); - - it("keeps the implicit default account top-level clientSecret active with named accounts", async () => { - const resolvedConfig = await resolveQqbotSecretAssignments( - { - channels: { - qqbot: { - enabled: true, - appId: "123456", - clientSecret: { source: "env", provider: "default", id: "QQBOT_DEFAULT_SECRET" }, - accounts: { - bot2: { - enabled: true, - appId: "654321", - clientSecret: { source: "env", provider: "default", id: "QQBOT_BOT2_SECRET" }, - }, - }, - }, - }, - } as OpenClawConfig, - { - QQBOT_DEFAULT_SECRET: "resolved-default-secret", - QQBOT_BOT2_SECRET: "resolved-bot2-secret", - }, - ); - - expect(resolvedConfig.channels?.qqbot?.clientSecret).toBe("resolved-default-secret"); - expect(resolvedConfig.channels?.qqbot?.accounts?.bot2?.clientSecret).toBe( - "resolved-bot2-secret", - ); - }); -}); diff --git a/extensions/qqbot/src/secret-contract.ts b/extensions/qqbot/src/secret-contract.ts deleted file mode 100644 index 74ba57e35e3e..000000000000 --- a/extensions/qqbot/src/secret-contract.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Qqbot plugin module implements secret contract behavior. -import { - collectConditionalChannelFieldAssignments, - createChannelSecretTargetRegistryEntries, - getChannelSurface, - hasConfiguredSecretInputValue, - type ResolverContext, - type SecretDefaults, -} from "openclaw/plugin-sdk/channel-secret-basic-runtime"; - -const DEFAULT_ACCOUNT_ID = "default"; - -export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntries({ - channelKey: "qqbot", - account: ["clientSecret"], - channel: ["clientSecret"], -}); - -function hasTopLevelAppId(qqbot: Record): boolean { - if (typeof qqbot.appId === "string") { - return qqbot.appId.trim().length > 0; - } - return typeof qqbot.appId === "number"; -} - -export function collectRuntimeConfigAssignments(params: { - config: { channels?: Record }; - defaults?: SecretDefaults; - context: ResolverContext; -}): void { - const resolved = getChannelSurface(params.config, "qqbot"); - if (!resolved) { - return; - } - - const { channel: qqbot, surface } = resolved; - const hasExplicitDefaultAccount = surface.accounts.some( - ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - ); - - collectConditionalChannelFieldAssignments({ - channelKey: "qqbot", - field: "clientSecret", - channel: qqbot, - surface, - defaults: params.defaults, - context: params.context, - topLevelActiveWithoutAccounts: true, - topLevelInheritedAccountActive: ({ accountId, account, enabled }) => { - if (accountId === DEFAULT_ACCOUNT_ID) { - return enabled && !hasConfiguredSecretInputValue(account.clientSecret, params.defaults); - } - return !hasExplicitDefaultAccount && hasTopLevelAppId(qqbot); - }, - accountActive: ({ enabled }) => enabled, - topInactiveReason: "no enabled QQ Bot default surface uses this top-level clientSecret.", - accountInactiveReason: "QQ Bot account is disabled.", - }); -} - -export const channelSecrets = { - secretTargetRegistryEntries, - collectRuntimeConfigAssignments, -}; diff --git a/extensions/qqbot/src/state-migrations.test.ts b/extensions/qqbot/src/state-migrations.test.ts deleted file mode 100644 index de3847db6a7c..000000000000 --- a/extensions/qqbot/src/state-migrations.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { - createPluginStateKeyedStoreForTests, - resetPluginStateStoreForTests, -} from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import type { - OpenKeyedStoreOptions, - PluginDoctorStateMigrationContext, - PluginStateKeyedStore, -} from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { - resolvePreferredOpenClawTmpDir, - tempWorkspace, - type TempWorkspace, -} from "openclaw/plugin-sdk/temp-path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { stateMigrations } from "../doctor-contract-api.js"; -import { buildQQBotStateKey } from "./engine/utils/state-keys.js"; - -function requireStateMigration(index: number) { - return expectDefined(stateMigrations[index], `QQBot state migration ${index}`); -} - -type CredentialBackup = { - accountId: string; - appId: string; - clientSecret: string; - savedAt: string; -}; - -const tempWorkspaces: TempWorkspace[] = []; - -async function writeJson(filePath: string, value: unknown): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { - return { - openPluginStateKeyedStore(options: OpenKeyedStoreOptions) { - return createPluginStateKeyedStoreForTests("qqbot", { - ...options, - env: options.env ?? env, - }); - }, - }; -} - -function createEvictingDoctorContext(params: { - values: Map; - evictedKey: string; -}): PluginDoctorStateMigrationContext { - let shouldEvict = true; - const store: PluginStateKeyedStore = { - async register(key, value) { - params.values.set(key, value); - if (shouldEvict) { - shouldEvict = false; - params.values.delete(params.evictedKey); - } - }, - async registerIfAbsent(key, value) { - if (params.values.has(key)) { - return false; - } - await store.register(key, value); - return true; - }, - async lookup(key) { - return params.values.get(key); - }, - async consume(key) { - const value = params.values.get(key); - params.values.delete(key); - return value; - }, - async delete(key) { - return params.values.delete(key); - }, - async entries() { - return [...params.values].map(([key, value]) => ({ key, value, createdAt: 0 })); - }, - async clear() { - params.values.clear(); - }, - }; - return { - openPluginStateKeyedStore() { - return store as unknown as PluginStateKeyedStore; - }, - }; -} - -describe("qqbot doctor state migration", () => { - let stateDir = ""; - let env: NodeJS.ProcessEnv; - - beforeEach(async () => { - resetPluginStateStoreForTests(); - const workspace = await tempWorkspace({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-state-", - }); - tempWorkspaces.push(workspace); - stateDir = workspace.dir; - env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - }); - - afterEach(async () => { - resetPluginStateStoreForTests(); - await Promise.all(tempWorkspaces.splice(0).map((workspace) => workspace.cleanup())); - }); - - function migrationParams() { - return { - config: {}, - env, - stateDir, - oauthDir: path.join(stateDir, "oauth"), - context: createDoctorContext(env), - }; - } - - it("imports an active-state credential backup and archives the source", async () => { - const sourcePath = path.join(stateDir, "qqbot", "data", "credential-backup-default.json"); - const backup: CredentialBackup = { - accountId: "default", - appId: "app-1", - clientSecret: "secret-1", - savedAt: "2026-06-02T00:00:00.000Z", - }; - await writeJson(sourcePath, backup); - - const migration = requireStateMigration(0); - await expect(migration.detectLegacyState(migrationParams())).resolves.toMatchObject({ - preview: [expect.stringContaining("QQBot credential backups: 1 file")], - }); - await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({ - changes: [ - "Migrated 1 QQBot credential backup -> plugin state", - expect.stringContaining("Archived QQBot credential backup legacy source"), - ], - warnings: [], - }); - - await expect(fs.access(sourcePath)).rejects.toThrow(); - await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined(); - if (process.platform !== "win32") { - expect((await fs.stat(`${sourcePath}.migrated`)).mode & 0o777).toBe(0o600); - } - await expect( - createDoctorContext(env) - .openPluginStateKeyedStore({ - namespace: "credential-backups", - maxEntries: 1000, - }) - .lookup(buildQQBotStateKey("credential-backup", "default")), - ).resolves.toEqual(backup); - }); - - it("prefers per-account backups over the legacy singleton", async () => { - const dataDir = path.join(stateDir, "qqbot", "data"); - const singlePath = path.join(dataDir, "credential-backup.json"); - const accountPath = path.join(dataDir, "credential-backup-default.json"); - await writeJson(singlePath, { - accountId: "default", - appId: "stale-app", - clientSecret: "stale-secret", - savedAt: "2026-06-01T00:00:00.000Z", - }); - await writeJson(accountPath, { - accountId: "default", - appId: "current-app", - clientSecret: "current-secret", - savedAt: "2026-06-02T00:00:00.000Z", - }); - - const result = await requireStateMigration(0).migrateLegacyState(migrationParams()); - - expect(result.warnings).toEqual([]); - await expect( - createDoctorContext(env) - .openPluginStateKeyedStore({ - namespace: "credential-backups", - maxEntries: 1000, - }) - .lookup(buildQQBotStateKey("credential-backup", "default")), - ).resolves.toMatchObject({ appId: "current-app", clientSecret: "current-secret" }); - await expect(fs.access(`${singlePath}.migrated`)).resolves.toBeUndefined(); - await expect(fs.access(`${accountPath}.migrated`)).resolves.toBeUndefined(); - }); - - it("ignores mismatched per-account backup filenames", async () => { - await writeJson(path.join(stateDir, "qqbot", "data", "credential-backup-other.json"), { - accountId: "default", - appId: "wrong-app", - clientSecret: "wrong-secret", - savedAt: "2026-06-02T00:00:00.000Z", - }); - - await expect(requireStateMigration(0).detectLegacyState(migrationParams())).resolves.toBeNull(); - }); - - it("does not scan credential backups outside the active state directory", async () => { - const homeWorkspace = await tempWorkspace({ - rootDir: resolvePreferredOpenClawTmpDir(), - prefix: "qqbot-home-", - }); - tempWorkspaces.push(homeWorkspace); - const homeDir = homeWorkspace.dir; - env.HOME = homeDir; - await writeJson( - path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"), - { - accountId: "default", - appId: "other-state-app", - clientSecret: "other-state-secret", - savedAt: "2026-06-02T00:00:00.000Z", - }, - ); - - await expect(requireStateMigration(0).detectLegacyState(migrationParams())).resolves.toBeNull(); - }); - - it("restores credential state and preserves sources when plugin capacity evicts a row", async () => { - const sourcePath = path.join(stateDir, "qqbot", "data", "credential-backup-new.json"); - await writeJson(sourcePath, { - accountId: "new", - appId: "new-app", - clientSecret: "new-secret", - savedAt: "2026-06-02T00:00:00.000Z", - }); - const existingKey = buildQQBotStateKey("credential-backup", "existing"); - const incomingKey = buildQQBotStateKey("credential-backup", "new"); - const existingBackup: CredentialBackup = { - accountId: "existing", - appId: "existing-app", - clientSecret: "existing-secret", - savedAt: "2026-06-01T00:00:00.000Z", - }; - const values = new Map([[existingKey, existingBackup]]); - const params = migrationParams(); - params.context = createEvictingDoctorContext({ values, evictedKey: existingKey }); - - const result = await requireStateMigration(0).migrateLegacyState(params); - - expect(result.changes).toEqual([]); - expect(result.warnings).toEqual([expect.stringContaining("plugin state capacity evicted")]); - expect(values).toEqual(new Map([[existingKey, existingBackup]])); - expect(values.has(incomingKey)).toBe(false); - await expect(fs.access(sourcePath)).resolves.toBeUndefined(); - await expect(fs.access(`${sourcePath}.migrated`)).rejects.toThrow(); - }); - - it("does not migrate QQBot runtime caches", async () => { - await writeJson(path.join(stateDir, "qqbot", "sessions", "session-default.json"), { - sessionId: "session-1", - }); - await writeJson(path.join(stateDir, "qqbot", "data", "known-users.json"), []); - await fs.writeFile(path.join(stateDir, "qqbot", "data", "ref-index.jsonl"), "{}\n"); - - await expect(requireStateMigration(0).detectLegacyState(migrationParams())).resolves.toBeNull(); - }); -}); diff --git a/extensions/qqbot/src/state-migrations.ts b/extensions/qqbot/src/state-migrations.ts deleted file mode 100644 index 6653a7cc1886..000000000000 --- a/extensions/qqbot/src/state-migrations.ts +++ /dev/null @@ -1,277 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { - legacyStateFileExists, - type PluginDoctorStateMigration, - type PluginStateKeyedStore, -} from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { buildQQBotStateKey } from "./engine/utils/state-keys.js"; - -type CredentialBackup = { - accountId: string; - appId: string; - clientSecret: string; - savedAt: string; -}; - -type CredentialBackupCandidate = { - sourcePath: string; - expectedSafeAccountId?: string; -}; - -type LegacyCredentialBackup = { - sourcePath: string; - key: string; - value: CredentialBackup; -}; - -const CREDENTIAL_BACKUPS_NAMESPACE = "credential-backups"; -const MAX_CREDENTIAL_BACKUPS = 1000; - -function safeName(id: string): string { - return id.replace(/[^a-zA-Z0-9._-]/g, "_"); -} - -async function readCredentialBackup(filePath: string): Promise { - try { - const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as Partial; - if ( - typeof parsed.accountId !== "string" || - typeof parsed.appId !== "string" || - typeof parsed.clientSecret !== "string" || - !parsed.accountId || - !parsed.appId || - !parsed.clientSecret - ) { - return null; - } - return { - accountId: parsed.accountId, - appId: parsed.appId, - clientSecret: parsed.clientSecret, - savedAt: - typeof parsed.savedAt === "string" && parsed.savedAt - ? parsed.savedAt - : new Date(0).toISOString(), - }; - } catch { - return null; - } -} - -function credentialBackupKey(accountId: string): string { - return buildQQBotStateKey("credential-backup", accountId); -} - -async function credentialBackupCandidates(stateDir: string): Promise { - const dataDir = path.join(stateDir, "qqbot", "data"); - const accountFiles: CredentialBackupCandidate[] = []; - try { - for (const entry of await fs.readdir(dataDir, { withFileTypes: true })) { - if ( - entry.isFile() && - entry.name.startsWith("credential-backup-") && - entry.name.endsWith(".json") - ) { - accountFiles.push({ - sourcePath: path.join(dataDir, entry.name), - expectedSafeAccountId: entry.name.slice("credential-backup-".length, -".json".length), - }); - } - } - } catch { - // Missing legacy directory means there is nothing to import. - } - accountFiles.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath)); - - const singlePath = path.join(dataDir, "credential-backup.json"); - return (await legacyStateFileExists(singlePath)) - ? [...accountFiles, { sourcePath: singlePath }] - : accountFiles; -} - -async function readLegacyCredentialBackups(stateDir: string): Promise { - const backups: LegacyCredentialBackup[] = []; - for (const candidate of await credentialBackupCandidates(stateDir)) { - const value = await readCredentialBackup(candidate.sourcePath); - if ( - !value || - (candidate.expectedSafeAccountId !== undefined && - safeName(value.accountId) !== candidate.expectedSafeAccountId) - ) { - continue; - } - backups.push({ - sourcePath: candidate.sourcePath, - key: credentialBackupKey(value.accountId), - value, - }); - } - return backups; -} - -async function archiveLegacySource(params: { - sourcePath: string; - changes: string[]; - warnings: string[]; -}): Promise { - const archivedPath = `${params.sourcePath}.migrated`; - if (await legacyStateFileExists(archivedPath)) { - params.warnings.push( - `Left QQBot credential backup in place because ${archivedPath} already exists`, - ); - return; - } - try { - await fs.chmod(params.sourcePath, 0o600); - } catch (err) { - params.warnings.push(`Failed securing QQBot credential backup legacy source: ${String(err)}`); - return; - } - try { - await fs.rename(params.sourcePath, archivedPath); - try { - await fs.chmod(archivedPath, 0o600); - } catch (err) { - params.warnings.push( - `Failed securing archived QQBot credential backup legacy source: ${String(err)}`, - ); - } - params.changes.push(`Archived QQBot credential backup legacy source -> ${archivedPath}`); - } catch (err) { - params.warnings.push(`Failed archiving QQBot credential backup: ${String(err)}`); - } -} - -function sameCredentialBackup( - left: CredentialBackup | undefined, - right: CredentialBackup, -): boolean { - return ( - left?.accountId === right.accountId && - left.appId === right.appId && - left.clientSecret === right.clientSecret && - left.savedAt === right.savedAt - ); -} - -async function rollbackCredentialImports( - store: PluginStateKeyedStore, - inserted: ReadonlyMap, - existing: ReadonlyMap, -): Promise { - // Doctor can overlap gateway writes. Remove only unchanged rows from this - // attempt, then restore only snapshot rows that capacity eviction removed. - for (const [key, value] of [...inserted].toReversed()) { - if (sameCredentialBackup(await store.lookup(key), value)) { - await store.delete(key); - } - } - for (const [key, value] of existing) { - if ((await store.lookup(key)) === undefined) { - await store.registerIfAbsent(key, value); - } - } -} - -function findMissingKey(expected: ReadonlySet, actual: ReadonlySet): string | null { - for (const key of expected) { - if (!actual.has(key)) { - return key; - } - } - return null; -} - -export const stateMigrations: PluginDoctorStateMigration[] = [ - { - id: "qqbot-credential-backups-json-to-plugin-state", - label: "QQBot credential backups", - async detectLegacyState(params) { - const backups = await readLegacyCredentialBackups(params.stateDir); - if (backups.length === 0) { - return null; - } - return { - preview: [ - `- QQBot credential backups: ${backups.length} ${backups.length === 1 ? "file" : "files"} -> plugin state (${CREDENTIAL_BACKUPS_NAMESPACE})`, - ], - }; - }, - async migrateLegacyState(params) { - const changes: string[] = []; - const warnings: string[] = []; - const backups = await readLegacyCredentialBackups(params.stateDir); - if (backups.length === 0) { - return { changes, warnings }; - } - - // Per-account files are ordered before the old singleton, so the newer - // account-scoped snapshot wins if both exist for the same account. - const selectedByKey = new Map(); - for (const backup of backups) { - if (!selectedByKey.has(backup.key)) { - selectedByKey.set(backup.key, backup); - } - } - - const store = params.context.openPluginStateKeyedStore({ - namespace: CREDENTIAL_BACKUPS_NAMESPACE, - maxEntries: MAX_CREDENTIAL_BACKUPS, - }); - const existingEntries = await store.entries(); - const existingValues = new Map(existingEntries.map((entry) => [entry.key, entry.value])); - const existingKeys = new Set(existingValues.keys()); - const missing = [...selectedByKey.values()].filter((backup) => !existingKeys.has(backup.key)); - const available = MAX_CREDENTIAL_BACKUPS - existingKeys.size; - if (missing.length > available) { - warnings.push( - `Skipped QQBot credential backup migration because plugin state has room for ${available} of ${missing.length} missing entries; left legacy sources in place`, - ); - return { changes, warnings }; - } - - const expectedKeys = new Set(existingKeys); - const inserted = new Map(); - for (const backup of missing) { - try { - if (await store.registerIfAbsent(backup.key, backup.value)) { - inserted.set(backup.key, backup.value); - } - const nextExpectedKeys = new Set(expectedKeys).add(backup.key); - const liveKeys = new Set((await store.entries()).map((entry) => entry.key)); - const missingKey = findMissingKey(nextExpectedKeys, liveKeys); - if (missingKey) { - await rollbackCredentialImports(store, inserted, existingValues); - warnings.push( - `Stopped QQBot credential backup migration because plugin state capacity evicted ${missingKey}; restored credential state and left legacy sources in place`, - ); - return { changes, warnings }; - } - expectedKeys.add(backup.key); - } catch (err) { - try { - await rollbackCredentialImports(store, inserted, existingValues); - } catch (rollbackErr) { - warnings.push( - `Failed restoring QQBot credential state after migration error: ${String(rollbackErr)}`, - ); - } - warnings.push( - `Failed migrating QQBot credential backup: ${String(err)}; left legacy sources in place`, - ); - return { changes, warnings }; - } - } - if (inserted.size > 0) { - changes.push( - `Migrated ${inserted.size} QQBot credential ${inserted.size === 1 ? "backup" : "backups"} -> plugin state`, - ); - } - for (const backup of backups) { - await archiveLegacySource({ sourcePath: backup.sourcePath, changes, warnings }); - } - return { changes, warnings }; - }, - }, -]; diff --git a/extensions/qqbot/src/test-support/runtime.ts b/extensions/qqbot/src/test-support/runtime.ts deleted file mode 100644 index dd5200540122..000000000000 --- a/extensions/qqbot/src/test-support/runtime.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Qqbot plugin module implements runtime behavior. -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; -import { - createPluginStateKeyedStoreForTests, - createPluginStateSyncKeyedStoreForTests, - resetPluginStateStoreForTests, -} from "openclaw/plugin-sdk/plugin-state-test-runtime"; -import { setQQBotRuntime } from "../bridge/runtime.js"; - -function stateEnv(stateDir: string, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - return { - ...(env ?? process.env), - OPENCLAW_STATE_DIR: stateDir, - }; -} - -export function installQQBotRuntimeForStateTests(stateDir: string): void { - resetPluginStateStoreForTests(); - setQQBotRuntime({ - version: "test", - state: { - resolveStateDir: () => stateDir, - openKeyedStore: (options: OpenKeyedStoreOptions) => - createPluginStateKeyedStoreForTests("qqbot", { - ...options, - env: stateEnv(stateDir, options.env), - }), - openSyncKeyedStore: (options: OpenKeyedStoreOptions) => - createPluginStateSyncKeyedStoreForTests("qqbot", { - ...options, - env: stateEnv(stateDir, options.env), - }), - openChannelIngressQueue: () => { - throw new Error("openChannelIngressQueue is not configured for QQBot state tests"); - }, - }, - } as unknown as PluginRuntime); -} - -export function resetQQBotStateTestRuntime(): void { - resetPluginStateStoreForTests(); - const unavailable = (): never => { - throw new Error("QQBot state test runtime is not installed"); - }; - setQQBotRuntime({ - version: "test", - state: { - resolveStateDir: unavailable, - openKeyedStore: unavailable, - openSyncKeyedStore: unavailable, - openChannelIngressQueue: unavailable, - }, - } as unknown as PluginRuntime); -} diff --git a/extensions/qqbot/src/types.ts b/extensions/qqbot/src/types.ts deleted file mode 100644 index 89e6d69a75be..000000000000 --- a/extensions/qqbot/src/types.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/channel-policy"; -// Qqbot type declarations define plugin contracts. -import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; -import type { QQBotDmPolicy, QQBotGroupPolicy } from "./engine/access/index.js"; -import type { QQBotGroupCommandLevel } from "./engine/config/group.js"; - -export type { QQBotDmPolicy, QQBotGroupPolicy }; - -/** QQ Bot base config. */ -export interface QQBotConfig { - appId: string; - clientSecret?: SecretInput; - clientSecretFile?: string; -} - -/** Resolved QQ Bot account config used at runtime. */ -export interface ResolvedQQBotAccount { - accountId: string; - name?: string; - enabled: boolean; - appId: string; - clientSecret: string; - secretSource: "config" | "file" | "env" | "none"; - /** Additional system prompt text. */ - systemPrompt?: string; - /** Whether markdown output is enabled. Defaults to true. */ - markdownSupport: boolean; - config: QQBotAccountConfig; -} - -/** QQBot-native exec approval delivery + approver authorization. */ -export interface QQBotExecApprovalConfig { - enabled?: boolean | "auto"; - approvers?: string[]; - agentFilter?: string[]; - sessionFilter?: string[]; - target?: "dm" | "channel" | "both"; -} - -interface QQBotGroupConfig { - requireMention?: boolean; - commandLevel?: QQBotGroupCommandLevel; - ignoreOtherMentions?: boolean; - historyLimit?: number; - name?: string; - prompt?: string; - tools?: GroupToolPolicyConfig; - toolsBySender?: Record; -} - -/** QQ Bot account config from user settings. */ -export interface QQBotAccountConfig { - enabled?: boolean; - name?: string; - appId?: string; - clientSecret?: SecretInput; - clientSecretFile?: string; - /** - * Sender allowlist for direct-message access control and command - * authorization. Entries accept raw openids, `qqbot:OPENID` prefixed - * form, and the `"*"` wildcard. Matching is case-insensitive. - * - * Semantics depend on {@link dmPolicy}: - * - `dmPolicy="open"` (default when allowFrom is empty or contains `"*"`) - * — everyone can DM the bot; the list only influences command gating. - * - `dmPolicy="allowlist"` (default when a non-wildcard list is configured) - * — only listed openids may DM the bot; other DMs are dropped. - * - `dmPolicy="disabled"` — all DMs are dropped regardless of this list. - * - * For group access, see {@link groupAllowFrom} / {@link groupPolicy}. - */ - allowFrom?: string[]; - /** - * Group-scoped sender allowlist. If omitted, group access falls back to - * {@link allowFrom}. Set explicitly when the group whitelist needs to - * differ from the DM whitelist. - */ - groupAllowFrom?: string[]; - /** - * DM access policy. Defaults: - * - omitted + allowFrom empty/wildcard → `"open"` - * - omitted + allowFrom non-wildcard → `"allowlist"` - */ - dmPolicy?: QQBotDmPolicy; - /** - * Group access policy. Defaults mirror {@link dmPolicy}: if either - * `groupAllowFrom` or `allowFrom` has a non-wildcard entry the policy - * is `"allowlist"`, otherwise `"open"`. - */ - groupPolicy?: QQBotGroupPolicy; - /** Optional system prompt prepended to user messages. */ - systemPrompt?: string; - /** Whether markdown output is enabled. Defaults to true. */ - markdownSupport?: boolean; - /** QQBot-native exec approval delivery + approver authorization. */ - execApprovals?: QQBotExecApprovalConfig; - /** - * Audio format policy covering inbound STT and outbound upload behavior. - */ - audioFormatPolicy?: AudioFormatPolicy; - /** - * Whether public URLs should be uploaded to QQ directly. Defaults to true. - */ - urlDirectUpload?: boolean; - /** - * Upgrade guide URL returned by `/bot-upgrade`. - */ - upgradeUrl?: string; - /** - * Upgrade command mode. - * - "doc": show an upgrade guide link - * - "hot-reload": run an in-place npm update flow - */ - upgradeMode?: "doc" | "hot-reload"; - /** - * Block streaming + optional QQ C2C official stream API. - * - `mode` "partial" (default) enables block streaming; "off" disables it. - * - `nativeTransport: true` uses QQ's official C2C `stream_messages` API for DMs. - * Legacy `streaming: true|false` scalars and the `c2cStreamApi` key migrate - * via `openclaw doctor --fix`. - */ - streaming?: { - mode?: "off" | "partial"; - nativeTransport?: boolean; - }; - groups?: Record; -} - -/** Audio format policy controlling which formats can skip transcoding. */ -export interface AudioFormatPolicy { - /** - * Formats supported directly by the STT provider. - */ - sttDirectFormats?: string[]; - /** - * Formats QQ accepts directly for outbound uploads. - */ - uploadDirectFormats?: string[]; - /** - * Whether outbound audio transcoding is enabled. Defaults to true. - */ - transcodeEnabled?: boolean; -} - -/** Rich-media attachment metadata. */ -export interface MessageAttachment { - content_type: string; - filename?: string; - height?: number; - width?: number; - size?: number; - url: string; - voice_wav_url?: string; - asr_refer_text?: string; -} - -/** C2C message event payload. */ -export interface C2CMessageEvent { - author: { - id: string; - union_openid: string; - user_openid: string; - }; - content: string; - id: string; - timestamp: string; - message_scene?: { - source: string; - /** ext can contain ref_msg_idx and msg_idx values. */ - ext?: string[]; - }; - attachments?: MessageAttachment[]; -} - -/** Guild @-message event payload. */ -export interface GuildMessageEvent { - id: string; - channel_id: string; - guild_id: string; - content: string; - timestamp: string; - author: { - id: string; - username?: string; - bot?: boolean; - }; - member?: { - nick?: string; - joined_at?: string; - }; - attachments?: MessageAttachment[]; -} - -/** Group @-message event payload. */ -export interface GroupMessageEvent { - author: { - id: string; - member_openid: string; - }; - content: string; - id: string; - timestamp: string; - group_id: string; - group_openid: string; - message_scene?: { - source: string; - ext?: string[]; - }; - attachments?: MessageAttachment[]; -} - -/** WebSocket event payload. */ -export interface WSPayload { - op: number; - d?: unknown; - s?: number; - t?: string; -} diff --git a/extensions/qqbot/tools-api.ts b/extensions/qqbot/tools-api.ts deleted file mode 100644 index 8ae1c0d61901..000000000000 --- a/extensions/qqbot/tools-api.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Narrow tool-discovery entrypoint for qqbot tools. -export { registerQQBotTools } from "./src/bridge/tools/index.js"; diff --git a/extensions/qqbot/tsconfig.json b/extensions/qqbot/tsconfig.json deleted file mode 100644 index c40eba47b3b4..000000000000 --- a/extensions/qqbot/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../tsconfig.package-boundary.base.json" -} diff --git a/package.json b/package.json index 3eb523e47d70..f37a82a99ebb 100644 --- a/package.json +++ b/package.json @@ -311,7 +311,6 @@ "!dist/extensions/parallel/**", "!dist/extensions/perplexity/**", "!dist/extensions/qianfan/**", - "!dist/extensions/qqbot/**", "!dist/extensions/raft/**", "!dist/extensions/pixverse/**", "!dist/extensions/qa-channel/**", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc69e89ec2b9..b567698b434b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1667,40 +1667,6 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk - extensions/qqbot: - dependencies: - '@tencent-connect/qqbot-connector': - specifier: 1.2.0 - version: 1.2.0 - mpg123-decoder: - specifier: 1.0.3 - version: 1.0.3 - p-map: - specifier: 7.0.6 - version: 7.0.6 - pretty-ms: - specifier: 9.3.0 - version: 9.3.0 - silk-wasm: - specifier: 3.7.1 - version: 3.7.1 - ws: - specifier: 8.21.1 - version: 8.21.1 - zod: - specifier: 4.4.3 - version: 4.4.3 - devDependencies: - '@openclaw/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk - '@types/ws': - specifier: 8.18.1 - version: 8.18.1 - openclaw: - specifier: workspace:* - version: link:../.. - extensions/qwen: devDependencies: '@openclaw/plugin-sdk': @@ -5230,10 +5196,6 @@ packages: '@tanstack/virtual-core@3.17.6': resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==} - '@tencent-connect/qqbot-connector@1.2.0': - resolution: {integrity: sha512-FAOUCvgxP4M9UiYbHrtVgJ7BavSlh1CHJPjeEQuTAkP9MZ91lX4yATqv6I/lB9yp15nVq+G2DaGSSJwQTSoSsA==} - engines: {node: '>=18.0.0'} - '@thi.ng/bitstream@2.4.54': resolution: {integrity: sha512-uInkAJge5O0bWWEaYKrQpMccPbFg0z6eIA5NDCJXPm7l3rjlDje6RBHBXll3LiQz9Y051EdzlAEQRaB5hEifdg==} engines: {node: '>=18'} @@ -12386,10 +12348,6 @@ snapshots: '@tanstack/virtual-core@3.17.6': {} - '@tencent-connect/qqbot-connector@1.2.0': - dependencies: - qrcode-terminal: 0.12.0 - '@thi.ng/bitstream@2.4.54': dependencies: '@thi.ng/errors': 2.6.16 diff --git a/scripts/check-no-raw-channel-fetch.mts b/scripts/check-no-raw-channel-fetch.mts index f00e4ebb41ac..98303c9e45f7 100644 --- a/scripts/check-no-raw-channel-fetch.mts +++ b/scripts/check-no-raw-channel-fetch.mts @@ -52,11 +52,6 @@ const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("qa-lab", "web/src/http.ts", 24), bundledPluginCallsite("qa-lab", "web/src/http.ts", 32), bundledPluginCallsite("qa-lab", "web/src/http.ts", 43), - bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 124), - bundledPluginCallsite("qqbot", "src/engine/api/media-chunked.ts", 554), - bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211), - bundledPluginCallsite("qqbot", "src/engine/tools/channel-api.ts", 178), - bundledPluginCallsite("qqbot", "src/engine/utils/stt.ts", 87), bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224), bundledPluginCallsite("slack", "src/monitor/media.ts", 106), bundledPluginCallsite("slack", "src/monitor/media.ts", 125), diff --git a/scripts/check-session-accessor-boundary.mts b/scripts/check-session-accessor-boundary.mts index 6d1785a591b8..7a546aa7e3da 100644 --- a/scripts/check-session-accessor-boundary.mts +++ b/scripts/check-session-accessor-boundary.mts @@ -167,7 +167,6 @@ export const migratedBundledPluginSessionAccessorFiles = new Set([ "extensions/mattermost/src/mattermost/model-picker.ts", "extensions/matrix/src/matrix/monitor/handler.ts", "extensions/matrix/src/session-route.ts", - "extensions/qqbot/src/engine/group/activation.ts", "extensions/slack/src/monitor/slash.ts", "extensions/telegram/src/bot-core.ts", "extensions/telegram/src/bot-handlers.runtime.ts", diff --git a/scripts/e2e/docker-selected-plugins.sh b/scripts/e2e/docker-selected-plugins.sh index 7640c19e527e..ce1e24f10747 100755 --- a/scripts/e2e/docker-selected-plugins.sh +++ b/scripts/e2e/docker-selected-plugins.sh @@ -49,7 +49,7 @@ else echo "Proving manifest ids and known dependency-only plugins remain stageable..." docker_build_run docker-selected-plugins-dependency-only \ --target workspace-deps \ - --build-arg OPENCLAW_EXTENSIONS=whatsapp,qqbot,kimi \ + --build-arg OPENCLAW_EXTENSIONS=whatsapp,kimi \ -t "$DEPENDENCY_ONLY_IMAGE" \ -f "$ROOT_DIR/Dockerfile" \ "$ROOT_DIR" @@ -57,7 +57,7 @@ else docker_e2e_docker_run_cmd run --rm \ --entrypoint sh \ "$DEPENDENCY_ONLY_IMAGE" \ - -c 'test -f /out/extensions/whatsapp/package.json && test -f /out/extensions/qqbot/package.json && test -f /out/extensions/kimi-coding/package.json && grep -qx kimi-coding /out/openclaw-selected-plugin-dirs' + -c 'test -f /out/extensions/whatsapp/package.json && test -f /out/extensions/kimi-coding/package.json && grep -qx kimi-coding /out/openclaw-selected-plugin-dirs' echo "Building selected-plugin runtime image: $IMAGE_NAME" docker_build_run docker-selected-plugins-build \ diff --git a/scripts/generate-plugin-inventory-doc.mts b/scripts/generate-plugin-inventory-doc.mts index 17ebcacbc2ff..d40f7d223009 100644 --- a/scripts/generate-plugin-inventory-doc.mts +++ b/scripts/generate-plugin-inventory-doc.mts @@ -568,6 +568,53 @@ function enumerateTopLevelPluginManifests() { }); } +type ExternalPluginDocsInventorySeedEntry = { + openclaw?: { + channel?: NonNullable["channel"]; + channelHostConfig?: { + docsInventory?: { + package?: PluginPackageJson; + manifest?: PluginManifest; + }; + }; + }; +}; + +function collectExternalPluginDocsInventoryEntries(): PluginSourceEntry[] { + const seed = readJsonPath(path.join(ROOT, "scripts/lib/official-external-channel-seed.json")) as { + entries?: ExternalPluginDocsInventorySeedEntry[]; + }; + const entries: PluginSourceEntry[] = []; + for (const entry of Array.isArray(seed.entries) ? seed.entries : []) { + const inventory = entry?.openclaw?.channelHostConfig?.docsInventory; + const packageMetadata = inventory?.package; + const manifest = inventory?.manifest; + if (!inventory) { + continue; + } + if ( + typeof packageMetadata?.name !== "string" || + typeof manifest?.id !== "string" || + !entry?.openclaw?.channel + ) { + throw new Error("external plugin docs inventory metadata is incomplete"); + } + entries.push({ + dirName: manifest.id, + id: manifest.id, + manifest, + packageJson: { + ...packageMetadata, + openclaw: { + ...packageMetadata.openclaw, + channel: entry.openclaw.channel, + }, + }, + }); + } + return entries; +} + function collectPluginRecords() { const rootPackageJson = readJsonPath(path.join(ROOT, "package.json")) as { files?: unknown[] }; const excludedDirs = collectExcludedPackagedExtensionDirs(rootPackageJson); @@ -575,6 +622,27 @@ function collectPluginRecords() { assertPluginInventoryCoverage(sourceEntries, enumerateTopLevelPluginManifests()); const records = sourceEntries.map((entry) => createPluginRecord(entry, excludedDirs)); + const sourceIds = new Set(sourceEntries.map((entry) => entry.id)); + for (const { + dirName, + id, + manifest, + packageJson, + } of collectExternalPluginDocsInventoryEntries()) { + if (sourceIds.has(id)) { + continue; + } + records.push({ + description: resolveDescription({ dirName, id, manifest, packageJson }), + docs: resolveDocs({ dirName, id, manifest, packageJson }), + id, + installRoute: resolveInstallRoute(packageJson, "external"), + name: humanizeId(id), + packageName: packageJson.name ?? "-", + status: "external", + surface: resolvePluginSurface(manifest), + }); + } return records.toSorted((left, right) => left.id.localeCompare(right.id)); } diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 6a832c2c4d29..123bc26455ac 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -1676,73 +1676,157 @@ } }, { - "name": "@openclaw/qqbot", - "version": "2026.8.1", - "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", - "source": "official", + "name": "@tencent-connect/openclaw-qqbot", + "description": "OpenClaw QQ Bot channel plugin by the Tencent Connect team.", + "source": "external", "kind": "channel", "openclaw": { + "plugin": { + "id": "openclaw-qqbot", + "label": "QQ Bot" + }, "contracts": { "tools": [ - "qqbot_channel_api", + "qqbot_platform_api", "qqbot_remind" ] }, "channel": { "id": "qqbot", - "configuredState": { - "env": { - "anyOf": [ - "QQBOT_APP_ID", - "QQBOT_CLIENT_SECRET" - ] - } - }, - "approvalFlags": [ - "native" - ], "label": "QQ Bot", "selectionLabel": "QQ Bot (Official API)", "detailLabel": "QQ Bot", "docsPath": "/channels/qqbot", "docsLabel": "qqbot", "blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.", - "systemImage": "bubble.left.and.bubble.right", - "setup": { - "fields": [ - { - "key": "token", - "kind": "string", - "sensitive": true, - "cli": { - "flags": "--token ", - "description": "QQBot app id and client secret" + "envVars": [ + "QQBOT_APP_ID", + "QQBOT_CLIENT_SECRET" + ], + "approvalFlags": [ + "native" + ], + "doctorCapabilities": { + "openDmRequiresAllowFromWildcard": false + }, + "systemImage": "bubble.left.and.bubble.right" + }, + "channelSecrets": { + "fields": [ + { + "field": "clientSecret", + "activationField": "appId", + "activationEnv": "QQBOT_APP_ID" + } + ] + }, + "channelHostConfig": { + "docsSource": "official", + "compatibilityMigration": "qqbot.tencent-2.0-compatibility", + "schemaAllOf": [ + { + "not": { + "required": [ + "defaultAccount" + ] + }, + "properties": { + "allowFrom": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "not": { + "const": "*" + } + }, + { + "anyOf": [ + { + "const": "openclaw:approval-disabled" + }, + { + "type": "string", + "pattern": "^[^a-z]*$" + } + ] + } + ] + } + }, + "accounts": { + "type": "object", + "not": { + "required": [ + "default" + ] + }, + "additionalProperties": { + "type": "object", + "properties": { + "allowFrom": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "not": { + "const": "*" + } + }, + { + "anyOf": [ + { + "const": "openclaw:approval-disabled" + }, + { + "type": "string", + "pattern": "^[^a-z]*$" + } + ] + } + ] + } + } + }, + "required": [ + "allowFrom" + ] + } } }, - { - "key": "tokenFile", - "kind": "string", - "sensitive": true, - "cli": { - "flags": "--token-file ", - "description": "QQBot client secret file" - } - }, - { - "key": "useEnv", - "kind": "boolean", - "cli": { - "flags": "--use-env", - "description": "Use QQBOT environment credentials" + "required": [ + "allowFrom" + ] + } + ] + }, + "channelConfigs": { + "qqbot": { + "label": "QQ Bot", + "description": "QQ Bot API conversation channel.", + "preferOver": [ + "qqbot" + ], + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "appId": { + "type": "string" + }, + "clientSecret": { + "type": "string" } } - ] + } } }, "install": { - "npmSpec": "@openclaw/qqbot", + "npmSpec": "@tencent-connect/openclaw-qqbot@2.0.1", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.10" + "expectedIntegrity": "sha512-2010PaCummeQaxerLtaGfQ/5HChiXaW/KpTERid7V/1zyTs46S2ACi0hgZQ1SB7tH0t1InWr8tzVBJV/pLss3Q==" } } }, diff --git a/scripts/lib/official-external-channel-seed.json b/scripts/lib/official-external-channel-seed.json index 7290a071dc60..e9a50fcab04f 100644 --- a/scripts/lib/official-external-channel-seed.json +++ b/scripts/lib/official-external-channel-seed.json @@ -82,6 +82,168 @@ } } }, + { + "name": "@tencent-connect/openclaw-qqbot", + "description": "OpenClaw QQ Bot channel plugin by the Tencent Connect team.", + "source": "external", + "kind": "channel", + "openclaw": { + "plugin": { + "id": "openclaw-qqbot", + "label": "QQ Bot" + }, + "contracts": { + "tools": ["qqbot_platform_api", "qqbot_remind"] + }, + "channel": { + "id": "qqbot", + "label": "QQ Bot", + "selectionLabel": "QQ Bot (Official API)", + "detailLabel": "QQ Bot", + "docsPath": "/channels/qqbot", + "docsLabel": "qqbot", + "blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.", + "envVars": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"], + "approvalFlags": ["native"], + "doctorCapabilities": { + "openDmRequiresAllowFromWildcard": false + }, + "systemImage": "bubble.left.and.bubble.right" + }, + "channelSecrets": { + "fields": [ + { + "field": "clientSecret", + "activationField": "appId", + "activationEnv": "QQBOT_APP_ID" + } + ] + }, + "channelHostConfig": { + "docsSource": "official", + "docsInventory": { + "package": { + "name": "@openclaw/qqbot", + "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", + "openclaw": { + "install": { + "npmSpec": "@openclaw/qqbot", + "defaultChoice": "npm" + }, + "release": { + "publishToClawHub": true, + "publishToNpm": true + } + } + }, + "manifest": { + "id": "qqbot", + "description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.", + "channels": ["qqbot"], + "contracts": { + "tools": [] + }, + "skills": ["./skills"] + } + }, + "compatibilityMigration": "qqbot.tencent-2.0-compatibility", + "schemaAllOf": [ + { + "not": { + "required": ["defaultAccount"] + }, + "properties": { + "allowFrom": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "not": { + "const": "*" + } + }, + { + "anyOf": [ + { + "const": "openclaw:approval-disabled" + }, + { + "type": "string", + "pattern": "^[^a-z]*$" + } + ] + } + ] + } + }, + "accounts": { + "type": "object", + "not": { + "required": ["default"] + }, + "additionalProperties": { + "type": "object", + "properties": { + "allowFrom": { + "type": "array", + "minItems": 1, + "items": { + "allOf": [ + { + "not": { + "const": "*" + } + }, + { + "anyOf": [ + { + "const": "openclaw:approval-disabled" + }, + { + "type": "string", + "pattern": "^[^a-z]*$" + } + ] + } + ] + } + } + }, + "required": ["allowFrom"] + } + } + }, + "required": ["allowFrom"] + } + ] + }, + "channelConfigs": { + "qqbot": { + "label": "QQ Bot", + "description": "QQ Bot API conversation channel.", + "preferOver": ["qqbot"], + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "appId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + } + } + } + } + }, + "install": { + "npmSpec": "@tencent-connect/openclaw-qqbot@2.0.1", + "defaultChoice": "npm", + "expectedIntegrity": "sha512-2010PaCummeQaxerLtaGfQ/5HChiXaW/KpTERid7V/1zyTs46S2ACi0hgZQ1SB7tH0t1InWr8tzVBJV/pLss3Q==" + } + } + }, { "name": "@tencent-weixin/openclaw-weixin", "description": "OpenClaw Weixin channel plugin by the Tencent Weixin team.", diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index d1ee39b9fd83..09370ef250d0 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -271,7 +271,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +3: add canonical coercion exports while retaining the shipped asString compatibility name. // +2: add high-use coercion primitives while retaining shipped object-record exports. // +2: channel-neutral location and provider-update hook contracts. - 4870, + // +1: QQBot 2.0.1 operator-approval Gateway client compatibility export. + 4871, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -334,7 +335,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: add the account-aware native approval request selector. // +3: add canonical coercion exports while retaining the shipped asString compatibility name. // +2: add high-use callable coercion primitives while retaining shipped object-record exports. - 2924, + // +1: QQBot 2.0.1 operator-approval Gateway client compatibility export. + 2925, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/write-official-channel-catalog.mts b/scripts/write-official-channel-catalog.mts index 52af99858b0d..3788512139be 100644 --- a/scripts/write-official-channel-catalog.mts +++ b/scripts/write-official-channel-catalog.mts @@ -22,6 +22,7 @@ type CatalogEntry = Partial; contracts?: Record; channel: Record; + channelHostConfig?: Record; channelConfigs?: Record; providerEndpoints?: Array>; install: CatalogInstall; @@ -224,6 +225,24 @@ function setUniqueCatalogEntry( entriesByChannelId.set(channelKey, { entry, owner }); } +function stripSeedOnlyDocsMetadata(entry: CatalogEntry): CatalogEntry { + const hostConfig = isRecord(entry.openclaw.channelHostConfig) + ? entry.openclaw.channelHostConfig + : null; + if (!hostConfig || !("docsInventory" in hostConfig)) { + return entry; + } + const runtimeHostConfig = { ...hostConfig }; + delete runtimeHostConfig.docsInventory; + return { + ...entry, + openclaw: { + ...entry.openclaw, + channelHostConfig: runtimeHostConfig, + }, + }; +} + /** * Collects publishable channel catalog entries from bundled and external channels. * @internal Directly tested script implementation detail. @@ -254,7 +273,7 @@ export function buildOfficialChannelCatalog(params: CatalogParams = {}): { } satisfies CatalogEntry; setUniqueCatalogEntry( seedEntriesByChannelId, - catalogEntry, + stripSeedOnlyDocsMetadata(catalogEntry), `scripts/lib/official-external-channel-seed.json package "${trimString(entry.name)}"`, ); } @@ -311,10 +330,19 @@ export function checkOfficialChannelCatalogSource(params: CatalogParams = {}) { } function toChannelDocsEntry( - entry: { source?: string; openclaw: { channel: Record } }, + entry: { + source?: string; + openclaw: { + channel: Record; + channelHostConfig?: Record; + }; + }, sourceOverride?: ChannelDocsSource, ) { const channel = isRecord(entry.openclaw.channel) ? entry.openclaw.channel : null; + const hostConfig = isRecord(entry.openclaw.channelHostConfig) + ? entry.openclaw.channelHostConfig + : null; const exposure = channel && isRecord(channel.exposure) ? channel.exposure : null; if (!channel || exposure?.docs === false) { return null; @@ -324,7 +352,7 @@ function toChannelDocsEntry( return null; } const docsPath = trimString(channel.docsPath) || `/channels/${id}`; - const source = sourceOverride ?? trimString(entry.source); + const source = sourceOverride ?? (trimString(hostConfig?.docsSource) || trimString(entry.source)); return { id, docsPath, diff --git a/security/opengrep/precise.yml b/security/opengrep/precise.yml index 255cc055e732..51b60486e18b 100644 --- a/security/opengrep/precise.yml +++ b/security/opengrep/precise.yml @@ -965,34 +965,6 @@ rules: } } heredocLine = ""; - - id: ghsa-66r7-m7xm-v49h.qqbot-outbound-media-unvalidated-local-path - languages: - - typescript - - javascript - severity: ERROR - message: QQBot outbound helper resolves local media paths without media-root boundary validation. - patterns: - - pattern-either: - - pattern: | - const $MEDIA = resolveQQBotLocalMediaPath(normalizePath(...)); - - pattern: | - const $MEDIA = resolveQQBotLocalMediaPath($PATH); - - pattern-inside: | - export async function $FN(...){ - ... - } - - metavariable-regex: - metavariable: $FN - regex: ^(sendPhoto|sendVoice|sendVideoMsg|sendDocument|sendMedia|sendVoiceMessage)$ - - pattern-not-inside: | - const $RESOLVED = resolveOutboundMediaPath(...); - ... - metadata: - ghsa: GHSA-66R7-M7XM-V49H - advisory-url: https://github.com/openclaw/openclaw/security/advisories/GHSA-66R7-M7XM-V49H - detector-bucket: precise - source-run: 2026-04-17T07-37-10Z - source-rule-id: qqbot-outbound-media-unvalidated-local-path - id: ghsa-6g25-pc82-vfwp.oauth-state-reuses-pkce-verifier languages: - typescript @@ -1617,46 +1589,6 @@ rules: detector-bucket: precise source-run: 2026-04-17T07-37-10Z source-rule-id: openclaw-skill-env-host-injection - - id: ghsa-846p-hgpv-vphc.qqbot-outbound-local-read-without-boundary-check - message: QQ Bot outbound local media handling reads a user-influenced path after resolveQQBotLocalMediaPath() without resolveOutboundMediaPath()/resolveQQBotPayloadLocalFilePath() boundary enforcement. - severity: ERROR - languages: - - typescript - - javascript - patterns: - - pattern-either: - - pattern: | - $MP = resolveQQBotLocalMediaPath(normalizePath($RAW)); - ... - readFileAsync($MP) - - pattern: | - $MP = resolveQQBotLocalMediaPath(normalizePath($RAW)); - ... - audioFileToSilkBase64($MP, ...) - - pattern: | - $MP = resolveQQBotLocalMediaPath(normalizePath($RAW)); - ... - checkFileSize($MP) - - pattern-not: | - $RES = resolveOutboundMediaPath($RAW, ..., ...) - ... - - pattern-not: | - $SAFE = resolveQQBotPayloadLocalFilePath($MP) - ... - - pattern-not: | - if (!resolveQQBotPayloadLocalFilePath($MP)) { - ... - } - metadata: - category: security - technology: - - qqbot - confidence: medium - ghsa: GHSA-846P-HGPV-VPHC - advisory-url: https://github.com/openclaw/openclaw/security/advisories/GHSA-846P-HGPV-VPHC - detector-bucket: precise - source-run: 2026-04-17T07-37-10Z - source-rule-id: qqbot-outbound-local-read-without-boundary-check - id: ghsa-8689-gm9g-jgr6.plivo-v3-replay-key-uses-unsorted-url languages: - typescript diff --git a/src/channels/bundled-channel-catalog-read.fail-soft.test.ts b/src/channels/bundled-channel-catalog-read.fail-soft.test.ts index 5493fcfd47bc..99a5c9dd4382 100644 --- a/src/channels/bundled-channel-catalog-read.fail-soft.test.ts +++ b/src/channels/bundled-channel-catalog-read.fail-soft.test.ts @@ -8,7 +8,7 @@ afterEach(() => { }); describe("listBundledChannelCatalogEntries discovery failures", () => { - it("falls back when bundled package metadata is unavailable during import", async () => { + it("falls back to bundled official metadata when package metadata is unavailable", async () => { vi.doMock("../infra/openclaw-root.js", () => ({ resolveOpenClawPackageRootSync: () => null, resolveOpenClawPackageRoot: async () => null, @@ -22,6 +22,9 @@ describe("listBundledChannelCatalogEntries discovery failures", () => { "./bundled-channel-catalog-read.js?scope=discovery-fail-soft", ); - expect(catalog.listBundledChannelCatalogEntries()).toStrictEqual([]); + expect(catalog.listBundledChannelCatalogEntries().map((entry) => entry.id)).toContain("qqbot"); + expect(catalog.findBundledChannelCatalogMetadata("qqbot")?.approvalFlags).toStrictEqual([ + "native", + ]); }); }); diff --git a/src/channels/bundled-channel-catalog-read.test.ts b/src/channels/bundled-channel-catalog-read.test.ts index 25befe32f925..e081e7e12d2a 100644 --- a/src/channels/bundled-channel-catalog-read.test.ts +++ b/src/channels/bundled-channel-catalog-read.test.ts @@ -27,6 +27,12 @@ vi.mock("../plugins/channel-catalog-registry.js", () => ({ listChannelCatalogEntries: listChannelCatalogEntriesMock, })); +const bundledOfficialExternalCatalogEntriesMock = vi.hoisted((): unknown[] => []); + +vi.mock("../plugins/official-external-plugin-bundled-catalogs.js", () => ({ + BUNDLED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_ENTRIES: bundledOfficialExternalCatalogEntriesMock, +})); + // The channel-catalog.json fallback still walks package roots via // resolveOpenClawPackageRootSync. Isolate from the real repo by mocking // moduleUrl/argv1 resolution to null and deriving only from the tmp cwd. @@ -61,6 +67,7 @@ afterEach(() => { process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = originalTrustBundledPluginsDir; } cleanupTempDirs(tempDirs); + bundledOfficialExternalCatalogEntriesMock.length = 0; vi.restoreAllMocks(); vi.mocked(resolveBundledPluginsDir).mockReset(); listChannelCatalogEntriesMock.mockReset(); @@ -217,7 +224,7 @@ describe("listBundledChannelCatalogEntries", () => { label: "Telegram", }); seedGeneratedChannelCatalog(root, { - packageName: "@openclaw/qqbot", + packageName: "@tencent-connect/openclaw-qqbot", id: "qqbot", label: "QQ Bot", docsPath: "/channels/qqbot", @@ -231,6 +238,28 @@ describe("listBundledChannelCatalogEntries", () => { expect(ids.has("telegram")).toBe(true); }); + it("uses bundled external channel metadata before a dist catalog exists", () => { + seedRoot("bcr-bundled-external-"); + bundledOfficialExternalCatalogEntriesMock.push({ + name: "@tencent-connect/openclaw-qqbot", + openclaw: { + channel: { + id: "qqbot", + label: "QQ Bot", + docsPath: "/channels/qqbot", + approvalFlags: ["native"], + doctorCapabilities: { openDmRequiresAllowFromWildcard: false }, + }, + }, + }); + useBundledPluginsDir(undefined); + + expect(findBundledChannelCatalogMetadata("qqbot")).toMatchObject({ + approvalFlags: ["native"], + doctorCapabilities: { openDmRequiresAllowFromWildcard: false }, + }); + }); + it("finds doctor capabilities from the generated catalog when the package is excluded", () => { const root = seedRoot("bcr-generated-doctor-"); useBundledPluginsDir(undefined); diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 070b6e6e034e..7f10399c5203 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -11,6 +11,7 @@ import { tryReadJsonSync } from "../infra/json-files.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import type { PluginPackageChannel } from "../plugins/manifest.js"; +import { BUNDLED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_ENTRIES } from "../plugins/official-external-plugin-bundled-catalogs.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js"; type ChannelCatalogEntryLike = { @@ -73,12 +74,15 @@ function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] { } function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] { + const bundledExternalEntries = BUNDLED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_ENTRIES.filter( + (entry): entry is ChannelCatalogEntryLike => typeof entry === "object" && entry !== null, + ); for (const packageRoot of listPackageRoots()) { const candidate = path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH); const cached = officialCatalogFileCache.get(candidate); if (cached !== undefined) { if (cached) { - return cached; + return [...bundledExternalEntries, ...cached]; } continue; } @@ -92,11 +96,14 @@ function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] { ? (payload.entries as ChannelCatalogEntryLike[]) : []; officialCatalogFileCache.set(candidate, entries); - return entries; + // The source catalog is available before dist/channel-catalog.json exists and carries + // promotion metadata for external channels. Keep it first so a stale local dist artifact + // cannot hide current metadata; the generated dist catalog still contributes bundled rows. + return [...bundledExternalEntries, ...entries]; } officialCatalogFileCache.set(candidate, null); } - return []; + return bundledExternalEntries; } function isChannelCatalogEntryLike( diff --git a/src/channels/plugins/configured-state.test.ts b/src/channels/plugins/configured-state.test.ts index ac01c0de90ab..9b388a61d3c7 100644 --- a/src/channels/plugins/configured-state.test.ts +++ b/src/channels/plugins/configured-state.test.ts @@ -23,7 +23,6 @@ describe("bundled channel configured-state metadata", () => { "msteams", "nextcloud-talk", "nostr", - "qqbot", "raft", "slack", "sms", diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 08afe30e2f1d..3d3d3c5ac945 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -43,7 +43,6 @@ const GUARDED_CHANNEL_EXTENSIONS = new Set([ "msteams", "nostr", "nextcloud-talk", - "qqbot", "signal", "slack", "synology-chat", @@ -170,7 +169,6 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [ "ollama", "open-prose", "copilot-proxy", - "qqbot", "sglang", "zai", "signal", diff --git a/src/channels/turn/message-turn-guardrails.test.ts b/src/channels/turn/message-turn-guardrails.test.ts index 4637ed1cc630..96bf7735a7ef 100644 --- a/src/channels/turn/message-turn-guardrails.test.ts +++ b/src/channels/turn/message-turn-guardrails.test.ts @@ -36,7 +36,6 @@ const historyWindowFiles = [ "extensions/line/src/group-history.ts", "extensions/mattermost/src/mattermost/monitor-posts.ts", "extensions/msteams/src/monitor-handler/message-handler.ts", - "extensions/qqbot/src/bridge/sdk-adapter.ts", "extensions/signal/src/monitor/event-handler.ts", "extensions/slack/src/monitor/message-handler/prepare.ts", "extensions/telegram/src/bot-message-dispatch-context.ts", diff --git a/src/cli/plugins-location-bridges.test.ts b/src/cli/plugins-location-bridges.test.ts index 7e85dc42d48f..480f6c1b957f 100644 --- a/src/cli/plugins-location-bridges.test.ts +++ b/src/cli/plugins-location-bridges.test.ts @@ -163,6 +163,36 @@ describe("listPersistedBundledPluginLocationBridges", () => { ]); }); + it("targets the renamed official plugin id when externalizing a bundled plugin", async () => { + readPersistedInstalledPluginIndexMock.mockResolvedValue( + makeIndex({ + pluginId: "qqbot", + manifestPath: "/app/dist/extensions/qqbot/openclaw.plugin.json", + manifestHash: "hash", + source: "/app/dist/extensions/qqbot/index.js", + rootDir: "/app/dist/extensions/qqbot", + origin: "bundled", + enabled: true, + startup: startupInfo, + compat: [], + packageInstall: { warnings: [] }, + }), + ); + loadPluginManifestRegistryForInstalledIndexMock.mockReturnValue(makeRegistry("qqbot")); + + await expect(listPersistedBundledPluginLocationBridges({})).resolves.toEqual([ + { + bundledPluginId: "qqbot", + pluginId: "openclaw-qqbot", + preferredSource: "npm", + npmSpec: "@tencent-connect/openclaw-qqbot@2.0.1", + expectedIntegrity: + "sha512-2010PaCummeQaxerLtaGfQ/5HChiXaW/KpTERid7V/1zyTs46S2ACi0hgZQ1SB7tH0t1InWr8tzVBJV/pLss3Q==", + channelIds: ["qqbot"], + }, + ]); + }); + it.each([ ["byteplus", "@openclaw/byteplus-provider", true], ["duckduckgo", "@openclaw/duckduckgo-plugin", false], diff --git a/src/cli/plugins-location-bridges.ts b/src/cli/plugins-location-bridges.ts index 13a47fa4476b..8e1487c2c97b 100644 --- a/src/cli/plugins-location-bridges.ts +++ b/src/cli/plugins-location-bridges.ts @@ -9,6 +9,7 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { getOfficialExternalPluginCatalogEntry, getOfficialExternalPluginCatalogManifest, + resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, } from "../plugins/official-external-plugin-catalog.js"; @@ -22,8 +23,8 @@ function buildBridgeFromPersistedBundledRecord( manifest?: PluginManifestRecord, ): ExternalizedBundledPluginBridge | null { // Relocation is derived from the previous persisted registry, not a hardcoded - // table. A plugin moving from bundled to npm keeps the same plugin id; the old - // registry row is the proof that this user actually had it bundled/enabled. + // table. The old registry row proves that this user had the plugin bundled; + // official catalog metadata owns the external package id when it was renamed. if (record.origin !== "bundled" || !record.enabled) { return null; } @@ -31,7 +32,13 @@ function buildBridgeFromPersistedBundledRecord( const officialInstall = officialEntry ? resolveOfficialExternalPluginInstall(officialEntry) : null; - const npmSpec = officialInstall?.npmSpec?.trim() ?? record.packageInstall?.npm?.spec; + const officialNpmSpec = officialInstall?.npmSpec?.trim(); + const npmSpec = officialNpmSpec ?? record.packageInstall?.npm?.spec; + // The catalog integrity pin only covers the catalog's own npm spec, never a + // persisted record fallback spec. + const expectedIntegrity = officialNpmSpec + ? officialInstall?.expectedIntegrity?.trim() + : undefined; const clawhubSpec = officialInstall?.clawhubSpec?.trim(); if (!npmSpec && !clawhubSpec) { return null; @@ -39,6 +46,9 @@ function buildBridgeFromPersistedBundledRecord( const officialChannelId = officialEntry ? getOfficialExternalPluginCatalogManifest(officialEntry)?.channel?.id?.trim() : undefined; + const externalPluginId = officialEntry + ? resolveOfficialExternalPluginId(officialEntry)?.trim() + : undefined; const channelIds = manifest?.channels.length ? manifest.channels : officialChannelId @@ -46,10 +56,11 @@ function buildBridgeFromPersistedBundledRecord( : []; return { bundledPluginId: record.pluginId, - pluginId: record.pluginId, + pluginId: externalPluginId || record.pluginId, preferredSource: officialInstall?.defaultChoice === "clawhub" && clawhubSpec ? "clawhub" : "npm", ...(npmSpec ? { npmSpec } : {}), + ...(expectedIntegrity ? { expectedIntegrity } : {}), ...(clawhubSpec ? { clawhubSpec } : {}), ...(record.enabledByDefault ? { enabledByDefault: true } : {}), ...(channelIds.length ? { channelIds } : {}), diff --git a/src/commands/channel-setup/config-compatibility.test.ts b/src/commands/channel-setup/config-compatibility.test.ts new file mode 100644 index 000000000000..31f991bb48a3 --- /dev/null +++ b/src/commands/channel-setup/config-compatibility.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { normalizeExternalChannelSetupConfig } from "./config-compatibility.js"; + +describe("normalizeExternalChannelSetupConfig", () => { + it("normalizes Tencent 2.0 setup defaults through the host compatibility migration", () => { + const previous = { + channels: { + qqbot: { + appId: "app-id", + clientSecret: "secret", + allowFrom: ["*"], + }, + }, + }; + + const next = normalizeExternalChannelSetupConfig({ cfg: previous, channel: "qqbot" }); + + expect(next).toMatchObject({ + channels: { + qqbot: { + appId: "app-id", + clientSecret: "secret", + dmPolicy: "open", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + expect(previous.channels.qqbot).toEqual({ + appId: "app-id", + clientSecret: "secret", + allowFrom: ["*"], + }); + }); + + it("leaves channels without a host compatibility migration unchanged", () => { + const cfg = { channels: { telegram: { enabled: true } } }; + + expect(normalizeExternalChannelSetupConfig({ cfg, channel: "telegram" })).toBe(cfg); + }); +}); diff --git a/src/commands/channel-setup/config-compatibility.ts b/src/commands/channel-setup/config-compatibility.ts new file mode 100644 index 000000000000..aec6dbdbd78a --- /dev/null +++ b/src/commands/channel-setup/config-compatibility.ts @@ -0,0 +1,27 @@ +// Applies host-owned compatibility migrations to external channel setup output. +import type { ChannelId } from "../../channels/plugins/types.public.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveOfficialExternalChannelCompatibilityMigration } from "../../plugins/official-external-plugin-catalog.js"; +import { LEGACY_CONFIG_MIGRATIONS } from "../doctor/shared/legacy-config-migrations.js"; + +export function normalizeExternalChannelSetupConfig(params: { + cfg: OpenClawConfig; + channel: ChannelId; +}): OpenClawConfig { + const migrationId = resolveOfficialExternalChannelCompatibilityMigration(params.channel); + if (!migrationId) { + return params.cfg; + } + const migration = LEGACY_CONFIG_MIGRATIONS.find((candidate) => candidate.id === migrationId); + if (!migration) { + throw new Error( + `Official external channel ${params.channel} references unknown compatibility migration ${migrationId}`, + ); + } + + // Setup plugins may return config that shares nested objects with the previous + // snapshot. Clone before the migration mutates its narrowly owned channel data. + const next = structuredClone(params.cfg) as OpenClawConfig; + migration.apply(next as Record, []); + return next; +} diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 2025d646c77d..df6df0e1316f 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -1102,6 +1102,53 @@ describe("channelsAddCommand", () => { expect(runtime.exit).not.toHaveBeenCalled(); }); + it("normalizes external channel compatibility before a non-interactive write", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "openclaw-qqbot", + plugin: { + ...createChannelTestPluginBase({ id: "qqbot", label: "QQ Bot" }), + setup: { + applyAccountConfig: ({ cfg, input }: ApplyAccountConfigParams) => { + const [appId, clientSecret] = input.token?.split(":") ?? []; + return { + ...cfg, + channels: { + ...cfg.channels, + qqbot: { + appId, + clientSecret, + allowFrom: ["*"], + }, + }, + }; + }, + }, + }, + source: "test", + }, + ]), + ); + + await channelsAddCommand( + { + channel: "qqbot", + token: "app-id:secret", + }, + runtime, + { hasFlags: true }, + ); + + expect(writtenChannel("qqbot")).toMatchObject({ + appId: "app-id", + clientSecret: "secret", + dmPolicy: "open", + allowFrom: ["openclaw:approval-disabled"], + }); + }); + it("uses setup-entry snapshots when an already loaded channel plugin has no setup adapter", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry( diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index e1c8e46aee79..0a026b2fdbd1 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -24,6 +24,7 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { WizardCancelledError } from "../../wizard/prompts.js"; +import { normalizeExternalChannelSetupConfig } from "../channel-setup/config-compatibility.js"; import { channelLabel } from "./runtime-label.js"; import { requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; @@ -293,7 +294,7 @@ async function channelsAddCommandImpl( ? { beforePersistentEffect: params.beforePersistentEffect } : {}), }); - nextConfig = applied.nextConfig; + nextConfig = normalizeExternalChannelSetupConfig({ cfg: applied.nextConfig, channel }); await params?.beforePersistentEffect?.(); const committed = await commitConfigWithPendingPluginInstalls({ diff --git a/src/commands/doctor/channel-capabilities.ts b/src/commands/doctor/channel-capabilities.ts index e2dffc1142e5..33d3b210d1ad 100644 --- a/src/commands/doctor/channel-capabilities.ts +++ b/src/commands/doctor/channel-capabilities.ts @@ -11,6 +11,7 @@ type DoctorGroupModel = "sender" | "route" | "hybrid"; type DoctorChannelCapabilities = { dmAllowFromMode: ChannelDmAllowFromMode; + openDmRequiresAllowFromWildcard?: boolean; groupModel: DoctorGroupModel; groupAllowFromFallbackToAllowFrom: boolean; warnOnEmptyGroupSenderAllowlist: boolean; @@ -29,6 +30,9 @@ function mergeDoctorChannelCapabilities( return { dmAllowFromMode: capabilities?.dmAllowFromMode ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.dmAllowFromMode, + ...(typeof capabilities?.openDmRequiresAllowFromWildcard === "boolean" + ? { openDmRequiresAllowFromWildcard: capabilities.openDmRequiresAllowFromWildcard } + : {}), groupModel: capabilities?.groupModel ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupModel, groupAllowFromFallbackToAllowFrom: capabilities?.groupAllowFromFallbackToAllowFrom ?? diff --git a/src/commands/doctor/shared/legacy-config-migrations.qqbot-account.ts b/src/commands/doctor/shared/legacy-config-migrations.qqbot-account.ts new file mode 100644 index 000000000000..064310b078ef --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.qqbot-account.ts @@ -0,0 +1,228 @@ +// Account and credential migrations for the Tencent QQBot 2.0 cutover. +import { getRecord } from "../../../config/legacy.shared.js"; +import { isBlockedObjectKey } from "../../../infra/prototype-keys.js"; +import { hasOwnKey } from "./legacy-config-record-shared.js"; + +function hasEnvironmentValue(name: "QQBOT_APP_ID" | "QQBOT_CLIENT_SECRET"): boolean { + return Boolean(process.env[name]?.trim()); +} + +export function shouldCreateEnvironmentOnlyQQBotConfig(raw: Record): boolean { + const channels = getRecord(raw.channels); + return Boolean( + (raw.channels === undefined || channels) && + !getRecord(channels?.qqbot) && + hasEnvironmentValue("QQBOT_APP_ID") && + hasEnvironmentValue("QQBOT_CLIENT_SECRET"), + ); +} + +export function listQQBotConfigEntries(qqbot: Record): Array<{ + entry: Record; + path: string; + aliasSuffix?: string; + inheritedEntry?: Record; +}> { + // The legacy default account merged channels.qqbot with accounts.default. + // Snapshot the root before migration so account overrides are evaluated + // against the policy users actually had before the root entry is rewritten. + const rootSnapshot = structuredClone(qqbot); + const entries: Array<{ + entry: Record; + path: string; + aliasSuffix?: string; + inheritedEntry?: Record; + }> = [{ entry: qqbot, path: "channels.qqbot" }]; + const accounts = getRecord(qqbot.accounts); + if (!accounts) { + return entries; + } + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = getRecord(accountValue); + if (account) { + entries.push({ + entry: account, + path: `channels.qqbot.accounts.${accountId}`, + aliasSuffix: accountId, + inheritedEntry: accountId === "default" ? rootSnapshot : undefined, + }); + } + } + return entries; +} + +export function migrateDefaultAccount(qqbot: Record, changes: string[]): void { + const accounts = getRecord(qqbot.accounts); + const configuredDefaultAccount = + typeof qqbot.defaultAccount === "string" ? qqbot.defaultAccount.trim() : ""; + const normalizedDefaultAccount = configuredDefaultAccount.toLowerCase(); + const defaultAccount = getRecord(accounts?.default); + if (configuredDefaultAccount && normalizedDefaultAccount !== "default") { + // The bundled plugin lowercased defaultAccount before lookup. Preserve that + // exact selection rule so case-colliding account credentials cannot switch. + const selectedAccountId = normalizedDefaultAccount; + const selectedAccount = getRecord(accounts?.[selectedAccountId]); + if (!selectedAccount) { + delete qqbot.defaultAccount; + changes.push( + `Removed invalid channels.qqbot.defaultAccount=${configuredDefaultAccount}; the bundled plugin already fell back to its normal account selection order.`, + ); + return; + } + if (qqbot.appId || hasEnvironmentValue("QQBOT_APP_ID") || defaultAccount) { + // Tencent cannot select a named account while retaining a distinct root + // default account. Leave the selector for the host schema to fail closed. + return; + } + const reorderedAccounts: Record = { + [selectedAccountId]: selectedAccount, + }; + for (const [accountId, account] of Object.entries(accounts ?? {})) { + if (accountId !== selectedAccountId && !isBlockedObjectKey(accountId)) { + reorderedAccounts[accountId] = account; + } + } + // Integer-index keys enumerate before ordinary keys regardless of insertion + // order. Keep defaultAccount so host validation fails closed instead of + // silently switching Tencent 2.0 to a different account. + if (Object.keys(reorderedAccounts)[0] !== selectedAccountId) { + return; + } + qqbot.accounts = reorderedAccounts; + delete qqbot.defaultAccount; + changes.push( + `Moved channels.qqbot.accounts.${selectedAccountId} to the first account position and removed defaultAccount so Tencent QQBot 2.0 preserves the selected named default.`, + ); + return; + } + if (!accounts || !defaultAccount) { + if (hasOwnKey(qqbot, "defaultAccount")) { + delete qqbot.defaultAccount; + changes.push( + "Removed channels.qqbot.defaultAccount=default because Tencent QQBot 2.0 selects the root account directly.", + ); + } + return; + } + // The bundled plugin overlaid accounts.default on the root account. Tencent + // 2.0 reads the default account only from the root, so flatten before runtime. + for (const [key, value] of Object.entries(defaultAccount)) { + if (key !== "accounts" && !isBlockedObjectKey(key)) { + qqbot[key] = value; + } + } + delete accounts.default; + if (Object.keys(accounts).length === 0) { + delete qqbot.accounts; + } + delete qqbot.defaultAccount; + changes.push( + "Moved channels.qqbot.accounts.default overrides to channels.qqbot for Tencent QQBot 2.0 default-account resolution.", + ); +} + +function normalizeProviderAliasSegment(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || "account"; +} + +function isMatchingFileProvider(value: unknown, filePath: string): boolean { + const provider = getRecord(value); + return Boolean( + provider && + provider.source === "file" && + provider.path === filePath && + provider.mode === "singleValue", + ); +} + +function allocateFileProviderAlias(params: { + raw: Record; + filePath: string; + aliasSuffix?: string; +}): string | undefined { + let secrets = getRecord(params.raw.secrets); + if (!secrets) { + if (params.raw.secrets !== undefined) { + return undefined; + } + secrets = {}; + params.raw.secrets = secrets; + } + let providers = getRecord(secrets.providers); + if (!providers) { + if (secrets.providers !== undefined) { + return undefined; + } + providers = {}; + secrets.providers = providers; + } + const suffix = params.aliasSuffix ? `-${normalizeProviderAliasSegment(params.aliasSuffix)}` : ""; + const base = `qqbot${suffix}-client-secret`.slice(0, 60).replace(/-+$/g, ""); + for (let index = 1; index <= 999; index += 1) { + const alias = index === 1 ? base : `${base.slice(0, 60 - String(index).length)}-${index}`; + const existing = providers[alias]; + if (existing === undefined) { + providers[alias] = { + source: "file", + path: params.filePath, + mode: "singleValue", + }; + return alias; + } + if (isMatchingFileProvider(existing, params.filePath)) { + return alias; + } + } + return undefined; +} + +export function migrateClientSecretFile(params: { + raw: Record; + entry: Record; + path: string; + aliasSuffix?: string; + changes: string[]; +}): void { + if (!hasOwnKey(params.entry, "clientSecretFile")) { + return; + } + if (params.entry.clientSecret !== undefined) { + delete params.entry.clientSecretFile; + params.changes.push( + `Removed ${params.path}.clientSecretFile (${params.path}.clientSecret already set).`, + ); + return; + } + const filePath = + typeof params.entry.clientSecretFile === "string" ? params.entry.clientSecretFile.trim() : ""; + if (!filePath) { + params.entry.enabled = false; + delete params.entry.clientSecretFile; + params.changes.push( + `Removed invalid ${params.path}.clientSecretFile and disabled this QQBot account.`, + ); + return; + } + const provider = allocateFileProviderAlias({ + raw: params.raw, + filePath, + aliasSuffix: params.aliasSuffix, + }); + if (!provider) { + params.entry.enabled = false; + params.changes.push( + `Disabled ${params.path} because its clientSecretFile could not be migrated while secrets.providers has an incompatible shape.`, + ); + return; + } + params.entry.clientSecret = { source: "file", provider, id: "value" }; + delete params.entry.clientSecretFile; + params.changes.push( + `Moved ${params.path}.clientSecretFile → ${params.path}.clientSecret using file provider ${provider}.`, + ); +} diff --git a/src/commands/doctor/shared/legacy-config-migrations.qqbot.test.ts b/src/commands/doctor/shared/legacy-config-migrations.qqbot.test.ts new file mode 100644 index 000000000000..a28ea2b06f6f --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.qqbot.test.ts @@ -0,0 +1,630 @@ +import { describe, expect, it, vi } from "vitest"; +import { widenOfficialExternalChannelSecretSchema } from "../../../config/official-external-channel-secret-schema.js"; +import { validateJsonSchemaValue } from "../../../plugins/schema-validator.js"; +import { LEGACY_CONFIG_MIGRATIONS_QQBOT } from "./legacy-config-migrations.qqbot.js"; +import { maybeRepairOpenPolicyAllowFrom } from "./open-policy-allowfrom.js"; + +function migrate(raw: Record) { + const config = structuredClone(raw); + const changes: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS_QQBOT) { + migration.apply(config, changes); + } + return { config, changes }; +} + +describe("Tencent QQBot 2.0 config migrations", () => { + it("creates a safe config shell for environment-only credentials", () => { + vi.stubEnv("QQBOT_APP_ID", "environment-app"); + vi.stubEnv("QQBOT_CLIENT_SECRET", "placeholder"); + try { + const result = migrate({}); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + enabled: true, + dmPolicy: "open", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + expect(JSON.stringify(result.config)).not.toContain("placeholder"); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("converts root and account clientSecretFile values to file-backed SecretRefs", () => { + const result = migrate({ + channels: { + qqbot: { + appId: "root-app", + clientSecretFile: "/run/secrets/qqbot-root", + accounts: { + ops: { + appId: "ops-app", + clientSecretFile: "/run/secrets/qqbot-ops", + }, + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + clientSecret: { + source: "file", + provider: "qqbot-client-secret", + id: "value", + }, + accounts: { + ops: { + clientSecret: { + source: "file", + provider: "qqbot-ops-client-secret", + id: "value", + }, + }, + }, + }, + }, + secrets: { + providers: { + "qqbot-client-secret": { + source: "file", + path: "/run/secrets/qqbot-root", + mode: "singleValue", + }, + "qqbot-ops-client-secret": { + source: "file", + path: "/run/secrets/qqbot-ops", + mode: "singleValue", + }, + }, + }, + }); + expect(JSON.stringify(result.config)).not.toContain("clientSecretFile"); + }); + + it("preserves an existing provider collision with a deterministic suffix", () => { + const result = migrate({ + secrets: { + providers: { + "qqbot-client-secret": { + source: "file", + path: "/other/secret", + mode: "singleValue", + }, + }, + }, + channels: { + qqbot: { + clientSecretFile: "/run/secrets/qqbot", + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + clientSecret: { + source: "file", + provider: "qqbot-client-secret-2", + id: "value", + }, + }, + }, + secrets: { + providers: { + "qqbot-client-secret": { path: "/other/secret" }, + "qqbot-client-secret-2": { path: "/run/secrets/qqbot" }, + }, + }, + }); + }); + + it("intersects explicit approval users with an existing restrictive chat allowlist", () => { + const result = migrate({ + channels: { + qqbot: { + allowFrom: ["chat-admin", "shared-admin"], + execApprovals: { + approvers: ["approval-admin", "shared-admin"], + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["SHARED-ADMIN"], + }, + }, + }); + expect(JSON.stringify(result.config)).not.toContain("execApprovals"); + }); + + it("keeps an explicit empty DM allowlist restrictive when approvers were configured", () => { + const result = migrate({ + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: [], + execApprovals: { approvers: ["admin"] }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + }); + + it("uses the legacy command operator override without promoting chat-only users", () => { + const raw = { + commands: { allowFrom: { qqbot: ["operator"] } }, + channels: { + qqbot: { + allowFrom: ["operator", "chat-only"], + }, + }, + }; + const operatorRule = LEGACY_CONFIG_MIGRATIONS_QQBOT[0]?.legacyRules?.find((rule) => + rule.message.includes("commands.allowFrom approval operators"), + ); + + expect(operatorRule?.match?.(raw.channels.qqbot, raw)).toBe(true); + + const result = migrate(raw); + + expect(result.config).toMatchObject({ + channels: { qqbot: { allowFrom: ["OPERATOR"] } }, + }); + expect( + operatorRule?.match?.((result.config.channels as { qqbot: unknown }).qqbot, result.config), + ).toBe(false); + expect(migrate(result.config).changes).toEqual([]); + }); + + it("locks approvals when command operators and restrictive chat access do not overlap", () => { + const result = migrate({ + commands: { allowFrom: { "*": ["operator"] } }, + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: ["chat-only"], + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + }); + + it("normalizes prefixed approvers before intersecting chat access", () => { + const result = migrate({ + channels: { + qqbot: { + allowFrom: ["qqbot:user123"], + execApprovals: { + approvers: ["QQBot:USER123"], + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { qqbot: { allowFrom: ["USER123"] } }, + }); + }); + + it("locks implicit wildcard approvals while preserving open DMs", () => { + const wildcard = migrate({ channels: { qqbot: { allowFrom: ["*"] } } }); + const missing = migrate({ channels: { qqbot: { appId: "app" } } }); + const mixed = migrate({ + channels: { qqbot: { dmPolicy: "allowlist", allowFrom: ["*", "admin"] } }, + }); + + expect(wildcard.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["openclaw:approval-disabled"], + dmPolicy: "open", + }, + }, + }); + expect(missing.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["openclaw:approval-disabled"], + dmPolicy: "open", + }, + }, + }); + expect(mixed.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["ADMIN"], + dmPolicy: "open", + }, + }, + }); + }); + + it("remains valid and idempotent through the later open-policy doctor repair", () => { + const migrated = migrate({ channels: { qqbot: { allowFrom: ["*"] } } }); + const repaired = maybeRepairOpenPolicyAllowFrom(migrated.config); + const repairedAgain = maybeRepairOpenPolicyAllowFrom(repaired.config); + const schema = widenOfficialExternalChannelSecretSchema({ + channelId: "qqbot", + schema: { type: "object", additionalProperties: true }, + }); + + expect(repaired.changes).toEqual([]); + expect(repaired.config).toMatchObject({ + channels: { + qqbot: { + dmPolicy: "open", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + expect(repairedAgain).toEqual({ config: repaired.config, changes: [] }); + expect( + validateJsonSchemaValue({ + cacheKey: "qqbot-doctor-order-regression", + schema: schema ?? {}, + value: (repaired.config.channels as { qqbot: unknown }).qqbot, + }).ok, + ).toBe(true); + }); + + it("keeps an explicit empty DM allowlist restrictive", () => { + const result = migrate({ + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: [], + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + }); + + it("flattens accounts.default with its overrides while preserving restrictive policy", () => { + const inherited = migrate({ + channels: { + qqbot: { + dmPolicy: "allowlist", + allowFrom: [], + defaultAccount: "default", + accounts: { + default: { appId: "default-app" }, + }, + }, + }, + }); + const overridden = migrate({ + channels: { + qqbot: { + allowFrom: [], + accounts: { + default: { appId: "default-app", dmPolicy: "allowlist" }, + }, + }, + }, + }); + + expect(inherited.config).toMatchObject({ + channels: { + qqbot: { + appId: "default-app", + dmPolicy: "allowlist", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + expect(overridden.config).toMatchObject({ + channels: { + qqbot: { + appId: "default-app", + dmPolicy: "allowlist", + allowFrom: ["openclaw:approval-disabled"], + }, + }, + }); + }); + + it("preserves a named default account by moving it to Tencent's first-account position", () => { + const result = migrate({ + channels: { + qqbot: { + defaultAccount: "Ops", + accounts: { + secondary: { appId: "secondary-app", allowFrom: ["SECONDARY"] }, + ops: { appId: "ops-app", allowFrom: ["ops-user"] }, + }, + }, + }, + }); + const qqbot = (result.config.channels as { qqbot: Record }).qqbot; + const accounts = qqbot.accounts as Record; + + expect(Object.keys(accounts)).toEqual(["ops", "secondary"]); + expect(qqbot).not.toHaveProperty("defaultAccount"); + expect(accounts.ops).toMatchObject({ appId: "ops-app", allowFrom: ["OPS-USER"] }); + }); + + it("preserves the bundled plugin's lowercase selection for case-colliding accounts", () => { + const result = migrate({ + channels: { + qqbot: { + defaultAccount: "Ops", + accounts: { + Ops: { appId: "uppercase-app", allowFrom: ["UPPER"] }, + ops: { appId: "lowercase-app", allowFrom: ["LOWER"] }, + }, + }, + }, + }); + const qqbot = (result.config.channels as { qqbot: Record }).qqbot; + const accounts = qqbot.accounts as Record; + + expect(Object.keys(accounts)).toEqual(["ops", "Ops"]); + expect(accounts.ops?.appId).toBe("lowercase-app"); + }); + + it("fails closed when integer account keys prevent preserving a named default", () => { + const result = migrate({ + channels: { + qqbot: { + defaultAccount: "ops", + accounts: { + "123": { appId: "numeric-app", allowFrom: ["NUMERIC"] }, + ops: { appId: "ops-app", allowFrom: ["OPS"] }, + }, + }, + }, + }); + const qqbot = (result.config.channels as { qqbot: Record }).qqbot; + + expect(qqbot.defaultAccount).toBe("ops"); + expect(Object.keys(qqbot.accounts as Record)).toEqual(["123", "ops"]); + }); + + it("locks native approvals when Tencent cannot represent the previous policy", () => { + const result = migrate({ + channels: { + qqbot: { + allowFrom: ["*"], + execApprovals: { + enabled: false, + approvers: ["admin"], + }, + accounts: { + filtered: { + execApprovals: { + approvers: ["admin"], + agentFilter: ["ops"], + }, + }, + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["openclaw:approval-disabled"], + dmPolicy: "open", + accounts: { + filtered: { + allowFrom: ["openclaw:approval-disabled"], + dmPolicy: "open", + }, + }, + }, + }, + }); + }); + + it("preserves open DMs while narrowing wildcard approval access", () => { + const result = migrate({ + channels: { + qqbot: { + allowFrom: ["*"], + execApprovals: { approvers: ["admin"] }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["ADMIN"], + dmPolicy: "open", + }, + }, + }); + }); + + it("strips the legacy channel prefix from allowFrom IDs", () => { + const result = migrate({ + channels: { + qqbot: { + allowFrom: ["qqbot:USER123", "QQBot:USER456", "user789", "*"], + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + allowFrom: ["USER123", "USER456", "USER789"], + dmPolicy: "open", + }, + }, + }); + }); + + it("maps retired native streaming switches without enabling transport", () => { + const result = migrate({ + channels: { + qqbot: { + streaming: { mode: "off", c2cStreamApi: true }, + accounts: { + staticOnly: { streaming: { mode: "partial", nativeTransport: false } }, + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + streaming: { mode: "partial" }, + accounts: { + staticOnly: { streaming: { mode: "off" } }, + }, + }, + }, + }); + expect(JSON.stringify(result.config)).not.toContain("nativeTransport"); + expect(JSON.stringify(result.config)).not.toContain("c2cStreamApi"); + }); + + it("keeps a restrictive allowFrom fallback when no explicit approvers were configured", () => { + const result = migrate({ + channels: { + qqbot: { + allowFrom: ["admin"], + execApprovals: { enabled: "auto" }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { qqbot: { allowFrom: ["ADMIN"] } }, + }); + }); + + it("maps current OpenClaw group tool policies to Tencent scalar policies", () => { + const result = migrate({ + channels: { + qqbot: { + groups: { + full: { tools: { allow: [] } }, + wildcard: { tools: { allow: ["*"] } }, + empty: { tools: {} }, + emptyDeny: { tools: { deny: [] } }, + additiveOnly: { tools: { alsoAllow: ["read"] } }, + restricted: { tools: { deny: ["write", "exec", "read"] } }, + restrictedEmptyAllow: { + tools: { allow: [], deny: ["write", "exec", "read"] }, + }, + none: { tools: { deny: ["*"] } }, + custom: { tools: { allow: ["read"] } }, + senderSpecific: { toolsBySender: { admin: { allow: [] } } }, + coexist: { toolPolicy: "full", tools: { deny: ["*"] } }, + coexistSender: { + toolPolicy: "full", + toolsBySender: { admin: { allow: [] } }, + }, + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + groups: { + full: { toolPolicy: "full" }, + wildcard: { toolPolicy: "full" }, + empty: { toolPolicy: "full" }, + emptyDeny: { toolPolicy: "full" }, + additiveOnly: { toolPolicy: "full" }, + restricted: { toolPolicy: "restricted" }, + restrictedEmptyAllow: { toolPolicy: "restricted" }, + none: { toolPolicy: "none" }, + custom: { toolPolicy: "none" }, + senderSpecific: { toolPolicy: "none" }, + coexist: { toolPolicy: "none" }, + coexistSender: { toolPolicy: "none" }, + }, + }, + }, + }); + expect(JSON.stringify(result.config)).not.toContain('"tools"'); + expect(JSON.stringify(result.config)).not.toContain("toolsBySender"); + }); + + it("removes all command levels and locks accounts with restrictive group commands", () => { + const result = migrate({ + channels: { + qqbot: { + groups: { + public: { commandLevel: "all" }, + sensitive: { commandLevel: "safety" }, + }, + accounts: { + default: { + groupPolicy: "open", + }, + unrestricted: { + groups: { "*": { commandLevel: "all" } }, + }, + strict: { + groups: { "*": { commandLevel: "strict" } }, + }, + }, + }, + }, + }); + + expect(result.config).toMatchObject({ + channels: { + qqbot: { + groupPolicy: "disabled", + groups: { + public: {}, + sensitive: {}, + }, + accounts: { + unrestricted: { + groups: { "*": {} }, + }, + strict: { + groupPolicy: "disabled", + groups: { "*": {} }, + }, + }, + }, + }, + }); + expect(JSON.stringify(result.config)).not.toContain("commandLevel"); + }); +}); diff --git a/src/commands/doctor/shared/legacy-config-migrations.qqbot.ts b/src/commands/doctor/shared/legacy-config-migrations.qqbot.ts new file mode 100644 index 000000000000..0c4861fa613d --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.qqbot.ts @@ -0,0 +1,536 @@ +// One-time QQBot migrations for the Tencent 2.0 external plugin boundary. +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "../../../config/legacy.shared.js"; +import { + listQQBotConfigEntries, + migrateClientSecretFile, + migrateDefaultAccount, + shouldCreateEnvironmentOnlyQQBotConfig, +} from "./legacy-config-migrations.qqbot-account.js"; +import { hasOwnKey } from "./legacy-config-record-shared.js"; + +const APPROVALS_DISABLED_SENTINEL = "openclaw:approval-disabled"; + +function hasQQBotEntryMatching( + value: unknown, + predicate: (entry: Record, inheritedEntry?: Record) => boolean, +): boolean { + const qqbot = getRecord(value); + return Boolean( + qqbot && + listQQBotConfigEntries(qqbot).some(({ entry, inheritedEntry }) => + predicate(entry, inheritedEntry), + ), + ); +} + +function normalizeIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return [ + ...new Set( + value + .filter((item): item is string | number => ["string", "number"].includes(typeof item)) + .map((item) => String(item).trim()) + .filter(Boolean), + ), + ]; +} + +function normalizeLegacyAllowFrom(value: unknown): string[] { + return [ + ...new Set( + normalizeIds(value).map((id) => { + const unprefixed = id.replace(/^qqbot:/i, ""); + if (unprefixed === "*" || unprefixed === APPROVALS_DISABLED_SENTINEL) { + return unprefixed; + } + // The bundled plugin compared QQ OpenIDs case-insensitively, while Tencent + // 2.0 expects its canonical uppercase form for runtime allowlist checks. + return unprefixed.toUpperCase(); + }), + ), + ]; +} + +function resolveLegacyQQBotCommandsAllowFrom(raw: Record): string[] | undefined { + const commands = getRecord(raw.commands); + const allowFrom = getRecord(commands?.allowFrom); + if (!allowFrom) { + return undefined; + } + if (Array.isArray(allowFrom.qqbot)) { + return normalizeLegacyAllowFrom(allowFrom.qqbot); + } + return Array.isArray(allowFrom["*"]) ? normalizeLegacyAllowFrom(allowFrom["*"]) : undefined; +} + +function hasConfiguredFilter(value: unknown): boolean { + return Array.isArray(value) ? value.length > 0 : value !== undefined; +} + +function migrateExecApprovals(params: { + entry: Record; + path: string; + changes: string[]; + inheritedEntry?: Record; + commandsAllowFrom?: string[]; +}): void { + const hasOwnLegacyConfig = hasOwnKey(params.entry, "execApprovals"); + const hasLegacyConfig = hasOwnLegacyConfig || params.inheritedEntry?.execApprovals !== undefined; + const hasOwnPolicyOverride = + hasOwnLegacyConfig || + hasOwnKey(params.entry, "allowFrom") || + hasOwnKey(params.entry, "dmPolicy"); + if (params.inheritedEntry && !hasOwnPolicyOverride) { + return; + } + const legacy = getRecord( + hasOwnLegacyConfig ? params.entry.execApprovals : params.inheritedEntry?.execApprovals, + ); + const allowFromValue = hasOwnKey(params.entry, "allowFrom") + ? params.entry.allowFrom + : params.inheritedEntry?.allowFrom; + const dmPolicy = hasOwnKey(params.entry, "dmPolicy") + ? params.entry.dmPolicy + : params.inheritedEntry?.dmPolicy; + const existingAllowFrom = normalizeLegacyAllowFrom(allowFromValue); + const explicitApprovers = normalizeLegacyAllowFrom(legacy?.approvers); + const allowFromWasOpen = existingAllowFrom.length === 0 || existingAllowFrom.includes("*"); + const preserveOpenDm = + dmPolicy === "open" || + (dmPolicy === undefined && allowFromWasOpen) || + (dmPolicy === "allowlist" && existingAllowFrom.includes("*")); + if (!hasLegacyConfig) { + if (params.commandsAllowFrom !== undefined) { + const commandApprovers = params.commandsAllowFrom.filter((id) => id !== "*"); + const restrictiveChatAllowFrom = new Set(existingAllowFrom.filter((id) => id !== "*")); + const safeApprovers = + existingAllowFrom.length > 0 && !existingAllowFrom.includes("*") + ? commandApprovers.filter((id) => restrictiveChatAllowFrom.has(id)) + : commandApprovers; + const nextAllowFrom = + safeApprovers.length > 0 ? safeApprovers : [APPROVALS_DISABLED_SENTINEL]; + const needsOpenDm = preserveOpenDm && dmPolicy !== "open"; + if ( + existingAllowFrom.length === nextAllowFrom.length && + existingAllowFrom.every((id, index) => id === nextAllowFrom[index]) && + !needsOpenDm + ) { + return; + } + params.entry.allowFrom = nextAllowFrom; + if (needsOpenDm) { + params.entry.dmPolicy = "open"; + } + params.changes.push( + `Secured ${params.path}.allowFrom for Tencent QQBot 2.0 native approvals using the previous commands.allowFrom operator list${safeApprovers.length > 0 ? " intersected with restrictive chat access" : "; no safely representable operator remained, so approvals were locked"}.`, + ); + return; + } + if (!allowFromWasOpen) { + return; + } + const explicitAllowFrom = existingAllowFrom.filter((id) => id !== "*"); + params.entry.allowFrom = + explicitAllowFrom.length > 0 ? explicitAllowFrom : [APPROVALS_DISABLED_SENTINEL]; + if (preserveOpenDm) { + params.entry.dmPolicy = "open"; + } + params.changes.push( + `Secured ${params.path}.allowFrom for Tencent QQBot 2.0 native approvals; wildcard/empty approval access was replaced with ${explicitAllowFrom.length > 0 ? "the existing explicit IDs" : "a non-matching marker"} while preserving open DM access separately.`, + ); + return; + } + const hasUnsupportedPolicy = + !legacy || + legacy.enabled === false || + hasConfiguredFilter(legacy.agentFilter) || + hasConfiguredFilter(legacy.sessionFilter) || + legacy.target !== undefined; + let nextAllowFrom: string[]; + let reason: string; + if (hasUnsupportedPolicy || explicitApprovers.includes("*")) { + nextAllowFrom = [APPROVALS_DISABLED_SENTINEL]; + reason = + "the Tencent 2.0 plugin cannot represent the previous approval policy, so native approval actions were locked"; + } else if (explicitApprovers.length > 0) { + const restrictiveAllowFrom = new Set(existingAllowFrom.filter((id) => id !== "*")); + nextAllowFrom = + dmPolicy === "allowlist" && existingAllowFrom.length === 0 + ? [] + : existingAllowFrom.length > 0 && !existingAllowFrom.includes("*") + ? explicitApprovers.filter((id) => restrictiveAllowFrom.has(id)) + : explicitApprovers; + if (nextAllowFrom.length === 0) { + nextAllowFrom = [APPROVALS_DISABLED_SENTINEL]; + reason = + "the approval and chat allowlists did not overlap, so native approval actions were locked"; + } else { + reason = "approval access was intersected with the existing chat allowlist"; + } + } else if (existingAllowFrom.length > 0 && !existingAllowFrom.includes("*")) { + // A configured execApprovals object fell back directly to channel allowFrom; + // commands.allowFrom applied only to the unconfigured same-chat path above. + nextAllowFrom = existingAllowFrom; + reason = "the existing restrictive chat allowlist remains the approval allowlist"; + } else { + nextAllowFrom = [APPROVALS_DISABLED_SENTINEL]; + reason = + "the previous same-chat or wildcard policy has no safe Tencent 2.0 representation, so native approval actions were locked"; + } + // Tencent uses allowFrom for both chat and approval actions. Keep an already + // open DM surface open when approval-only policy must become restrictive. + if (preserveOpenDm && !nextAllowFrom.includes("*")) { + params.entry.dmPolicy = "open"; + } + params.entry.allowFrom = nextAllowFrom; + delete params.entry.execApprovals; + params.changes.push( + `Moved ${params.path}.execApprovals → ${params.path}.allowFrom; ${reason}. Review chat access before re-enabling broader approval access.`, + ); +} + +function migrateAllowFrom(params: { + entry: Record; + path: string; + changes: string[]; +}): void { + const current = normalizeIds(params.entry.allowFrom); + const normalized = normalizeLegacyAllowFrom(params.entry.allowFrom); + if (current.every((id, index) => id === normalized[index])) { + return; + } + params.entry.allowFrom = normalized; + params.changes.push( + `Normalized ${params.path}.allowFrom QQBot-prefixed IDs for Tencent QQBot 2.0.`, + ); +} + +function hasLegacyStreamingTransport(entry: Record): boolean { + const streaming = getRecord(entry.streaming); + return Boolean( + streaming && (hasOwnKey(streaming, "nativeTransport") || hasOwnKey(streaming, "c2cStreamApi")), + ); +} + +function migrateStreamingTransport(params: { + entry: Record; + path: string; + changes: string[]; +}): void { + const streaming = getRecord(params.entry.streaming); + if (!streaming || !hasLegacyStreamingTransport(params.entry)) { + return; + } + const transport = + typeof streaming.nativeTransport === "boolean" + ? streaming.nativeTransport + : typeof streaming.c2cStreamApi === "boolean" + ? streaming.c2cStreamApi + : undefined; + delete streaming.nativeTransport; + delete streaming.c2cStreamApi; + if (transport !== undefined) { + // The bundled runtime evaluated nativeTransport independently of mode, so + // true still streamed with mode=off. Preserve the effective wire behavior. + streaming.mode = transport ? "partial" : "off"; + } + params.changes.push( + `Removed unsupported ${params.path}.streaming native transport keys for Tencent QQBot 2.0${transport === undefined ? "" : ` and set mode=${String(streaming.mode)}`}.`, + ); +} + +function mapTencentToolPolicy(value: unknown): "full" | "restricted" | "none" { + const policy = getRecord(value); + const allow = Array.isArray(policy?.allow) ? policy.allow.map(String) : undefined; + const deny = Array.isArray(policy?.deny) ? policy.deny.map(String) : undefined; + const allowsAll = !allow || allow.length === 0 || allow.includes("*"); + if (allowsAll && (!deny || deny.length === 0)) { + // The old runtime expands alsoAllow without an explicit allowlist to an + // implicit wildcard, so this group layer did not restrict the tool set. + return "full"; + } + if (deny?.length === 1 && deny[0] === "*") { + return "none"; + } + if ( + allowsAll && + deny?.length === 3 && + ["exec", "read", "write"].every((tool) => deny.includes(tool)) + ) { + return "restricted"; + } + return "none"; +} + +function mostRestrictiveTencentToolPolicy( + first: unknown, + second: "full" | "restricted" | "none", +): "full" | "restricted" | "none" { + const rank = { none: 0, restricted: 1, full: 2 } as const; + const normalizedFirst = + first === "full" || first === "restricted" || first === "none" ? first : "none"; + return rank[normalizedFirst] <= rank[second] ? normalizedFirst : second; +} + +function migrateGroupTools(params: { + entry: Record; + path: string; + changes: string[]; +}): void { + const groups = getRecord(params.entry.groups); + if (!groups) { + return; + } + for (const [groupId, groupValue] of Object.entries(groups)) { + const group = getRecord(groupValue); + if (!group || (!hasOwnKey(group, "tools") && !hasOwnKey(group, "toolsBySender"))) { + continue; + } + const groupPath = `${params.path}.groups.${groupId}`; + const migratedPolicy = hasOwnKey(group, "toolsBySender") + ? "none" + : mapTencentToolPolicy(group.tools); + group.toolPolicy = + group.toolPolicy === undefined + ? migratedPolicy + : mostRestrictiveTencentToolPolicy(group.toolPolicy, migratedPolicy); + params.changes.push( + `Moved ${groupPath}.tools policy → ${groupPath}.toolPolicy=${String(group.toolPolicy)} for Tencent QQBot 2.0, preserving the most restrictive configured policy.`, + ); + delete group.tools; + if (hasOwnKey(group, "toolsBySender")) { + delete group.toolsBySender; + params.changes.push( + `Removed ${groupPath}.toolsBySender; Tencent QQBot 2.0 cannot represent sender-specific tool policy, so the group policy was not broadened.`, + ); + } + } +} + +function hasLegacyGroupCommandLevel(entry: Record): boolean { + const groups = getRecord(entry.groups); + return Boolean( + groups && + Object.values(groups).some((groupValue) => { + const group = getRecord(groupValue); + return Boolean(group && hasOwnKey(group, "commandLevel")); + }), + ); +} + +function migrateGroupCommandLevels(params: { + entry: Record; + path: string; + changes: string[]; + inheritedEntry?: Record; +}): void { + const groups = getRecord(params.entry.groups); + if (!groups) { + const inheritedGroups = getRecord(params.inheritedEntry?.groups); + const inheritsRestrictiveCommandLevel = Boolean( + inheritedGroups && + Object.values(inheritedGroups).some((groupValue) => { + const group = getRecord(groupValue); + return Boolean(group && hasOwnKey(group, "commandLevel") && group.commandLevel !== "all"); + }), + ); + if ( + inheritsRestrictiveCommandLevel && + params.entry.groupPolicy !== undefined && + params.entry.groupPolicy !== "disabled" + ) { + // accounts.default inherits the root groups map but can override the + // root groupPolicy. Carry the fail-closed lock into that override. + params.entry.groupPolicy = "disabled"; + params.changes.push( + `Set ${params.path}.groupPolicy=disabled because this default account overrides the root lock while inheriting a safety/strict group command policy that Tencent QQBot 2.0 cannot represent.`, + ); + } + return; + } + let requiresLock = false; + for (const [groupId, groupValue] of Object.entries(groups)) { + const group = getRecord(groupValue); + if (!group || !hasOwnKey(group, "commandLevel")) { + continue; + } + const commandLevel = group.commandLevel; + if (commandLevel !== "all") { + requiresLock = true; + } + delete group.commandLevel; + params.changes.push( + `Removed unsupported ${params.path}.groups.${groupId}.commandLevel=${String(commandLevel)} for Tencent QQBot 2.0.`, + ); + } + if (!requiresLock) { + return; + } + // Tencent has no per-group command restriction. Disable this account's group + // surface so a former safety/strict policy cannot silently become all-access. + params.entry.groupPolicy = "disabled"; + params.changes.push( + `Set ${params.path}.groupPolicy=disabled because Tencent QQBot 2.0 cannot represent a previous safety/strict group command policy. Review the account before re-enabling group access.`, + ); +} + +const QQBOT_EXTERNALIZATION_RULES: LegacyConfigRule[] = [ + { + path: [], + message: + 'Environment-only QQBot credentials need a safe Tencent QQBot 2.0 config shell. Run "openclaw doctor --fix".', + match: (_value, root) => shouldCreateEnvironmentOnlyQQBotConfig(root), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot defaultAccount/accounts.default must migrate to Tencent QQBot 2.0 account selection. Run "openclaw doctor --fix".', + match: (value) => { + const qqbot = getRecord(value); + return Boolean( + qqbot && + (hasOwnKey(qqbot, "defaultAccount") || getRecord(getRecord(qqbot.accounts)?.default)), + ); + }, + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot clientSecretFile must migrate to a file-backed SecretRef for Tencent QQBot 2.0. Run "openclaw doctor --fix".', + match: (value) => hasQQBotEntryMatching(value, (entry) => hasOwnKey(entry, "clientSecretFile")), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot wildcard/empty allowFrom must be separated from Tencent QQBot 2.0 native approval access. Run "openclaw doctor --fix".', + match: (value) => + hasQQBotEntryMatching(value, (entry, inheritedEntry) => { + if (hasOwnKey(entry, "execApprovals")) { + return false; + } + const allowFrom = normalizeLegacyAllowFrom( + hasOwnKey(entry, "allowFrom") ? entry.allowFrom : inheritedEntry?.allowFrom, + ); + return allowFrom.length === 0 || allowFrom.includes("*"); + }), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot chat allowFrom must be reconciled with the previous commands.allowFrom approval operators for Tencent QQBot 2.0. Run "openclaw doctor --fix".', + match: (value, root) => { + const commandsAllowFrom = resolveLegacyQQBotCommandsAllowFrom(root); + if (commandsAllowFrom === undefined) { + return false; + } + const commandApprovers = new Set(commandsAllowFrom.filter((id) => id !== "*")); + return hasQQBotEntryMatching(value, (entry, inheritedEntry) => { + if ( + hasOwnKey(entry, "execApprovals") || + (!hasOwnKey(entry, "allowFrom") && inheritedEntry?.execApprovals !== undefined) + ) { + return false; + } + const allowFrom = normalizeLegacyAllowFrom( + hasOwnKey(entry, "allowFrom") ? entry.allowFrom : inheritedEntry?.allowFrom, + ).filter((id) => id !== "*" && id !== APPROVALS_DISABLED_SENTINEL); + return allowFrom.some((id) => !commandApprovers.has(id)); + }); + }, + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot groups.*.commandLevel must migrate before Tencent QQBot 2.0 can safely handle group commands. Run "openclaw doctor --fix".', + match: (value) => hasQQBotEntryMatching(value, hasLegacyGroupCommandLevel), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot streaming.nativeTransport/c2cStreamApi must migrate to Tencent QQBot 2.0 streaming.mode. Run "openclaw doctor --fix".', + match: (value) => hasQQBotEntryMatching(value, hasLegacyStreamingTransport), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot allowFrom IDs must migrate to Tencent QQBot 2.0 canonical uppercase OpenIDs. Run "openclaw doctor --fix".', + match: (value) => + hasQQBotEntryMatching(value, (entry) => { + const current = normalizeIds(entry.allowFrom); + const normalized = normalizeLegacyAllowFrom(entry.allowFrom); + return ( + current.length !== normalized.length || + current.some((id, index) => id !== normalized[index]) + ); + }), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot execApprovals must migrate to Tencent QQBot 2.0 allowFrom semantics. Run "openclaw doctor --fix".', + match: (value) => hasQQBotEntryMatching(value, (entry) => hasOwnKey(entry, "execApprovals")), + }, + { + path: ["channels", "qqbot"], + message: + 'QQBot group tools policies must migrate to Tencent QQBot 2.0 toolPolicy. Run "openclaw doctor --fix".', + match: (value) => + hasQQBotEntryMatching(value, (entry) => { + const groups = getRecord(entry.groups); + return Boolean( + groups && + Object.values(groups).some((groupValue) => { + const group = getRecord(groupValue); + return Boolean( + group && (hasOwnKey(group, "tools") || hasOwnKey(group, "toolsBySender")), + ); + }), + ); + }), + }, +]; + +export const LEGACY_CONFIG_MIGRATIONS_QQBOT: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "qqbot.tencent-2.0-compatibility", + describe: "Migrate bundled QQBot config to Tencent QQBot 2.0 canonical fields", + legacyRules: QQBOT_EXTERNALIZATION_RULES, + apply: (raw, changes) => { + let channels = getRecord(raw.channels); + let qqbot = getRecord(channels?.qqbot); + if (!qqbot && shouldCreateEnvironmentOnlyQQBotConfig(raw)) { + channels ??= {}; + raw.channels = channels; + qqbot = { + enabled: true, + dmPolicy: "open", + allowFrom: [APPROVALS_DISABLED_SENTINEL], + }; + channels.qqbot = qqbot; + changes.push( + "Created channels.qqbot for environment-only Tencent QQBot 2.0 credentials with native approvals locked; no credential value was copied into config.", + ); + } + if (!qqbot) { + return; + } + migrateDefaultAccount(qqbot, changes); + const commandsAllowFrom = resolveLegacyQQBotCommandsAllowFrom(raw); + for (const item of listQQBotConfigEntries(qqbot)) { + migrateClientSecretFile({ raw, changes, ...item }); + migrateExecApprovals({ changes, commandsAllowFrom, ...item }); + migrateAllowFrom({ changes, ...item }); + migrateStreamingTransport({ changes, ...item }); + migrateGroupTools({ changes, ...item }); + migrateGroupCommandLevels({ changes, ...item }); + } + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.ts b/src/commands/doctor/shared/legacy-config-migrations.ts index f3168a76c415..564e9362a233 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.ts @@ -1,12 +1,14 @@ // Top-level legacy config migration registry and rule inventory used by doctor. import { LEGACY_CONFIG_MIGRATIONS_AUDIO } from "./legacy-config-migrations.audio.js"; import { LEGACY_CONFIG_MIGRATIONS_CHANNELS } from "./legacy-config-migrations.channels.js"; +import { LEGACY_CONFIG_MIGRATIONS_QQBOT } from "./legacy-config-migrations.qqbot.js"; import { LEGACY_CONFIG_MIGRATIONS_QUEUE } from "./legacy-config-migrations.queue.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME } from "./legacy-config-migrations.runtime.js"; import { LEGACY_CONFIG_MIGRATIONS_WEB_SEARCH } from "./legacy-config-migrations.web-search.js"; const LEGACY_CONFIG_MIGRATION_SPECS = [ ...LEGACY_CONFIG_MIGRATIONS_CHANNELS, + ...LEGACY_CONFIG_MIGRATIONS_QQBOT, ...LEGACY_CONFIG_MIGRATIONS_AUDIO, ...LEGACY_CONFIG_MIGRATIONS_QUEUE, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME, diff --git a/src/commands/doctor/shared/open-policy-allowfrom.test.ts b/src/commands/doctor/shared/open-policy-allowfrom.test.ts index e08457d85ec1..77429094c59a 100644 --- a/src/commands/doctor/shared/open-policy-allowfrom.test.ts +++ b/src/commands/doctor/shared/open-policy-allowfrom.test.ts @@ -175,6 +175,29 @@ describe("doctor open-policy allowFrom repair", () => { expect(result.config.channels?.discord?.accounts?.work?.allowFrom).toEqual(["*"]); }); + it("does not widen QQBot chat access while allowFrom protects native approvals", () => { + const config = { + channels: { + qqbot: { + dmPolicy: "open", + allowFrom: ["openclaw:approval-disabled"], + accounts: { + work: { + dmPolicy: "open", + allowFrom: ["OPERATOR"], + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const first = maybeRepairOpenPolicyAllowFrom(config); + const second = maybeRepairOpenPolicyAllowFrom(first.config); + + expect(first).toEqual({ config, changes: [] }); + expect(second).toEqual({ config, changes: [] }); + }); + it("formats open-policy wildcard warnings", () => { const warnings = collectOpenPolicyAllowFromWarnings({ changes: ['- channels.signal.allowFrom: set to ["*"] (required by dmPolicy="open")'], diff --git a/src/commands/doctor/shared/open-policy-allowfrom.ts b/src/commands/doctor/shared/open-policy-allowfrom.ts index 52ef67e9606f..d7218000e6ec 100644 --- a/src/commands/doctor/shared/open-policy-allowfrom.ts +++ b/src/commands/doctor/shared/open-policy-allowfrom.ts @@ -2,7 +2,8 @@ import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js"; import { ensureOpenDmPolicyAllowFromWildcard } from "../../../channels/plugins/dm-access.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { resolveAllowFromMode, type AllowFromMode } from "./allow-from-mode.js"; +import { getDoctorChannelCapabilities } from "../channel-capabilities.js"; +import type { AllowFromMode } from "./allow-from-mode.js"; import { asObjectRecord } from "./object.js"; /** Format doctor warnings for open DM policies missing allowFrom wildcards. */ @@ -51,7 +52,11 @@ export function maybeRepairOpenPolicyAllowFrom(cfg: OpenClawConfig): { continue; } - const allowFromMode = resolveAllowFromMode(channelName); + const capabilities = getDoctorChannelCapabilities(channelName); + if (capabilities.openDmRequiresAllowFromWildcard === false) { + continue; + } + const allowFromMode = capabilities.dmAllowFromMode; ensureWildcard(channelConfig, `channels.${channelName}`, allowFromMode); const accounts = asObjectRecord(channelConfig.accounts); diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index e4472af4ddbf..14a0e7526e94 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -233,10 +233,10 @@ describe("ensureOnboardingPluginInstalled", () => { await ensureOnboardingPluginInstalled({ cfg: {}, entry: { - pluginId: "qqbot", + pluginId: "openclaw-qqbot", label: "QQ Bot", install: { - npmSpec: "@openclaw/qqbot@beta", + npmSpec: "@tencent-connect/openclaw-qqbot@2.0.1", }, }, prompter: { @@ -250,7 +250,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(captured?.message).toBe("安装 QQ Bot 插件?"); expect(captured?.options).toEqual([ - { value: "npm", label: "从 npm 下载(@openclaw/qqbot@beta)" }, + { value: "npm", label: "从 npm 下载(@tencent-connect/openclaw-qqbot@2.0.1)" }, { value: "skip", label: "暂时跳过" }, ]); } finally { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index bc31f976a109..d640a14e6888 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -25,15 +25,15 @@ const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ 'erv)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","order":75,"channelEnvVars":["LINE_CHANNEL_ACCESS_TOKEN","LINE_CHANNEL_SECRET"],"label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","order":70,"channelEnvVars":["MATRIX_ACCESS_TOKEN","MATRIX_DEVICE_ID","MATRIX_DEVICE_NAME","MATRIX_HOMESERVER","MATRIX_OPS_ACCESS_TOKEN","MATRIX_OPS_DEVICE_ID","MATRIX_OPS_DEVICE_NAME","MATRIX_OPS_HOMESERVER","MATRIX_PASSWORD","MATRIX_USER_ID"],"label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"mentionPatterns":{"label":"Matrix Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Matrix Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Matrix Mention Pattern Allowlist","help":"Matrix room IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Matrix Mention Pattern Denylist","help":"Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers."},"allowBots":{"label":"Matrix Allow Bot Messages","help":"Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \\"mentions\\" to require a visible room mention."},"botLoopProtection":{"label":"Matrix Bot Loop Protection","help":"Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch."},"botLoopProtection.enabled":{"label":"Matrix Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when configured bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Matrix Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Matrix Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Matrix Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"dangerouslyAllowNameMatching":{"label":"Matrix Display Name Matching","help":"Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable."},"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":', '{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Matrix Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."}}},{"pluginId":"mattermost","channelId":"mattermost","order":65,"channelEnvVars":["MATTERMOST_BOT_TOKEN","MATTERMOST_URL"],"label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"implicitMentions":{"label":"Mattermost Implicit Mentions","help":"Control which Mattermost reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Mattermost Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Mattermost Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Mattermost Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Mattermost Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."}}},{"pluginId":"msteams","channelId":"msteams","aliases":["teams"],"order":60,"channelEnvVars":["MSTEAMS_APP_ID","MSTEAMS_APP_PASSWORD","MSTEAMS_TENANT_ID"],"label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum"', ':["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"dangerouslyAllowNameMatching":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"cloud":{"type":"string","enum":["Public","USGov","USGovDoD","China"]},"serviceUrl":{"type":"string","format":"uri"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"graphMediaFallback":{"type":"boolean"},"requireMention":{"type":"boolean"},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"sharePointSiteId":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"cloud":{"label":"MS Teams Cloud","help":"Teams SDK cloud environment for auth, token validation, and token services: \\"Public\\", \\"USGov\\", \\"USGovDoD\\", or \\"China\\" (default: Public)."},"serviceUrl":{"label":"MS Teams Service URL","help":"Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC."},"graphMediaFallback":{"label":"MS Teams Graph Media Fallback","help":"Query Microsoft Graph for unresolved channel or group-chat HTML media. Adds one lookup per matching message when enabled (default: false)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.maxLineChars":{"label":"MS Teams Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","aliases":["nc","nc-talk"],"order":65,"channelEnvVars":["NEXTCLOUD_TALK_API_PASSWORD","NEXTCLOUD_TALK_BOT_SECRET"],"label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum', - '":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","order":55,"channelEnvVars":["NOSTR_PRIVATE_KEY"],"label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","order":999,"configurable":false,"label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","channelEnvVars":["QQBOT_APP_ID","QQBOT_CLIENT_SECRET"],"label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"raft","channelId":"raft","order":72,"channelEnvVars":["RAFT_PROFILE"],"label":"Raft","description":"Raft CLI wake bridge for human and agent collaboration.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"profile":{"type":"string","minLength":1},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"profile":{"type":"string","minLength":1}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId', - '":"reef","channelId":"reef","label":"Reef","description":"Guarded end-to-end encrypted claw messaging.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"default":true,"type":"boolean"},"configWrites":{"type":"boolean"},"relayUrl":{"default":"https://reefwire.ai","type":"string","format":"uri","pattern":"^[hH][tT][tT][pP][sS]?:\\\\/\\\\/[^\\\\\\\\/?#@]+\\\\/?$"},"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{0,62}$"},"email":{"type":"string","format":"email","pattern":"^(?!\\\\.)(?!.*\\\\.\\\\.)([A-Za-z0-9_\'+\\\\-\\\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$"},"guard":{"type":"object","properties":{"provider":{"type":"string","enum":["anthropic","openai"]},"pinnedModel":{"type":"string","minLength":1},"apiKeyEnv":{"type":"string","pattern":"^[A-Z_][A-Z0-9_]*$"},"policyVersion":{"type":"string","minLength":1},"timeoutMs":{"type":"integer","minimum":100,"maximum":120000}},"required":["provider","pinnedModel","apiKeyEnv","policyVersion","timeoutMs"],"additionalProperties":false},"stateDir":{"type":"string","minLength":1},"requestPolicy":{"default":"code-only","type":"string","enum":["code-only","friends-of-friends","open"]},"friends":{}},"required":["enabled","relayUrl","requestPolicy"],"additionalProperties":false}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device with additional setup for the local REST bridge.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"transport":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"managed-native"},"configPath":{"type":"string"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","minimum":1,"maximum":65535},"cliPath":{"type":"string"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreStories":{"type":"boolean"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"external-native"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"container"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false}]},"ignoreAttachments":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"transport":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"managed-native"},"configPath":{"type":"string"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","minimum":1,"maximum":65535},"cliPath":{"type":"string"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreStories":{"type":"boolean"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"external-native"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"container"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false}]},"ignoreAttachments":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","presentation":"phone-number"},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"reactionAllowlist":{"presentation":"phone-number"},"accounts.*.account":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"accounts.*.reactionAllowlist.*":{"presentation":"phone-number"},"transport":{"label":"Signal Transport","help":"Account-owned native process or external endpoint configuration. Named accounts do not inherit this value."},"transport.kind":{"label":"Signal Transport Kind","help":"Use managed-native to let OpenClaw start signal-cli, external-native for an existing native daemon, or container for signal-cli-rest-api."},"transport.configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."},"transport.url":{"label":"Signal Transport URL","help":"Base URL for an external-native or container transport, or the connection endpoint for a managed-native daemon when it differs from the bind address."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","i', - 'tems":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"default":"bot","type":"string","enum":["bot","user"]},"mode":{"default":"socket","type":"string","enum":["socket","http","relay"]},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"type":"string","enum":["bot","user"]},"mode":{"type":"string","enum":["socket","http","relay"]},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"', - 'store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy","postAs","mode","webhookPath","userTokenReadOnly"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"postAs":{"label":"Slack Identity","help":"Select \\"bot\\" (default) for the classic Slack app/bot identity or \\"user\\" to post as the authorizing human through a user token while the app carries event transport."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"implicitMentions":{"label":"Slack Implicit Mentions","help":"Control which Slack reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Slack Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Slack Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Slack Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allo', - 'wBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"relay":{"label":"Slack Relay Mode","help":"Relay-delivered Slack events. Use with mode=\\"relay\\" when openclaw-slack-router owns the Slack Socket Mode connection."},"relay.url":{"label":"Slack Relay URL","help":"Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws."},"relay.authToken":{"label":"Slack Relay Auth Token","help":"Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router."},"relay.gatewayId":{"label":"Slack Relay Gateway ID","help":"Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this Slack account."},"presenceEvents":{"label":"Slack Presence Events","help":"Poll observed human participants and wake the routed agent on away-to-active transitions. Default: \\"off\\"."},"presenceEvents.mode":{"label":"Slack Presence Event Mode","help":"\\"off\\" disables polling; \\"auto\\" covers DMs, MPIMs, and recent threads with up to 8 observed people; \\"on\\" also covers larger threads and top-level channels."},"channels.*.presenceEvents.mode":{"label":"Slack Channel Presence Event Mode","help":"Override presence events for one Slack channel. Use \\"on\\" to include large threads or top-level channel sessions."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS/MMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS/MMS channel configuration for inbound webhooks and outbound replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format; outbound attachments also require MMS capability.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly; outbound MMS also requires this same path to be reachable over HTTPS."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"', - 'additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","si', - 'lent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"progress\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"progress\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable normal Telegram block replies. This takes precedence over editable preview delivery."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.2 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sess', - 'ions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and direct-message routing safety."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"direct":{"label":"WhatsApp Direct Chat Overrides","help":"Per-conversation overrides keyed by WhatsApp DM id. Applied after a DM is already admitted by dmPolicy; \\"*\\" supplies a default without admitting anyone."},"pluginHooks":{"label":"WhatsApp Plugin Hooks","help":"Opt in to broadcasting inbound WhatsApp events to plugins. Payloads carry personal content, so only enable it for plugins you trust."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disab', - 'led."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + '":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","order":55,"channelEnvVars":["NOSTR_PRIVATE_KEY"],"label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","order":999,"configurable":false,"label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"raft","channelId":"raft","order":72,"channelEnvVars":["RAFT_PROFILE"],"label":"Raft","description":"Raft CLI wake bridge for human and agent collaboration.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"profile":{"type":"string","minLength":1},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"profile":{"type":"string","minLength":1}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"reef","channelId":"reef","label":"Reef","description":"Guarded end-to-end encrypted claw messaging.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"default":true,"type":"boolean"},"configWrites":{"type":"boolean"},"relayUrl":{"default":"https://reefwire.ai","type":"string","format":"uri","pattern":"^[hH][tT][tT][pP][sS]?:\\\\/\\\\/[^\\\\\\\\/?#@]+\\\\/?$"},"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{0,62}$"},"email":{"type":"string","format":"email","pattern":"^(?!\\\\.)(?!.*\\\\.\\\\.)([A-Za-z0-9_\'+\\\\-\\\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$"},"guard":{"type":"object","properties":{"provider":{"type":"string","enum":["anthropic","openai"]},"pinnedModel":{"type":"string","minLength":1},"apiKeyEnv":{"type":"string","pattern":"^[A-Z_][A-Z0-9_]*$"},"policyVersion":{"type":"string","minLength":1},"timeoutMs":{"type":"integer","minimum":100,"maximum":120000}},"required":["provider","pinnedModel","apiKeyEnv","policyVersion","timeoutMs"],"additionalProperties":false},"stateDir":{"type":"string","minLength":1},"requestPolicy":{"default":"code-only","type":"string","enum":["code-only","friends-of-friends","open"]},"friends":{}},"required":["enabled","relayUrl","requestPolicy"],"additionalProperties":false}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device with additional setup for the local REST bridge.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"transport":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"managed-native"},"configPath":{"type":"string"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","minimum":1,"maximum":65535},"cliPath":{"type":"string"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreStories":{"type":"boolean"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"external-native"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"container"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false}]},"ignoreAttachments":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"t', + 'ype":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"transport":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"managed-native"},"configPath":{"type":"string"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","minimum":1,"maximum":65535},"cliPath":{"type":"string"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreStories":{"type":"boolean"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"external-native"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"container"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false}]},"ignoreAttachments":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","presentation":"phone-number"},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"reactionAllowlist":{"presentation":"phone-number"},"accounts.*.account":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"accounts.*.reactionAllowlist.*":{"presentation":"phone-number"},"transport":{"label":"Signal Transport","help":"Account-owned native process or external endpoint configuration. Named accounts do not inherit this value."},"transport.kind":{"label":"Signal Transport Kind","help":"Use managed-native to let OpenClaw start signal-cli, external-native for an existing native daemon, or container for signal-cli-rest-api."},"transport.configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."},"transport.url":{"label":"Signal Transport URL","help":"Base URL for an external-native or container transport, or the connection endpoint for a managed-native daemon when it differs from the bind address."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"default":"bot","type":"string","enum":["bot","user"]},"mode":{"default":"socket","type":"string","enum":["socket","http","relay"]},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"a', + 'llowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"type":"string","enum":["bot","user"]},"mode":{"type":"string","enum":["socket","http","relay"]},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"', + 'boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy","postAs","mode","webhookPath","userTokenReadOnly"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"postAs":{"label":"Slack Identity","help":"Select \\"bot\\" (default) for the classic Slack app/bot identity or \\"user\\" to post as the authorizing human through a user token while the app carries event transport."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"implicitMentions":{"label":"Slack Implicit Mentions","help":"Control which Slack reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Slack Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Slack Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Slack Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"relay":{"label":"Slack Relay Mode","help":"Relay-delivered Slack events. Use with mode=\\"relay\\" when openclaw-slack-router owns the Slack Socket Mode connection."},"relay.url":{"label":"Slack Relay URL","help":"Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws."},"relay.authToken":{"label":"Slack Relay Auth Token","help":"Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router."},"relay.gatewayId":{"label":"Slack Relay Gateway ID","help":"Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this Slack account."},"presenceEvents":{"label":"Slack Presence Events","help":"Poll observed human participants and wake the routed agent on away-to-active transitions. Default: \\"off\\"."},"presenceEvents.mode":{"label":"Slack Presence Event Mode","help":"\\"off\\" disables polling; \\"auto\\" covers DMs, MPIMs, and recent threads with up to 8 observed people; \\"on\\" also covers larger threads and top-level channels."},"channels.*.presenceEvents.mode":{"label":"Slack Channel Presence Event Mode","help":"Override presence events for one Slack channel. Use \\"on\\" to include large threads or top-level channel sessions."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS/MMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"p', + 'rovider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS/MMS channel configuration for inbound webhooks and outbound replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format; outbound attachments also require MMS capability.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly; outbound MMS also requires this same path to be reachable over HTTPS."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"t', + 'ype":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode i', + 's deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"progress\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"progress\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable normal Telegram block replies. This takes precedence over editable preview delivery."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"status\\" is the safe default; \\"raw\\" opts into command text."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.2 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},', + '"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and direct-message routing safety."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"direct":{"label":"WhatsApp Direct Chat Overrides","help":"Per-conversation overrides keyed by WhatsApp DM id. Applied after a DM is already admitted by dmPolicy; \\"*\\" supplies a default without admitting anyone."},"pluginHooks":{"label":"WhatsApp Plugin Hooks","help":"Opt in to broadcasting inbound WhatsApp events to plugins. Payloads carry personal content, so only enable it for plugins you trust."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"store"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additi', + 'onalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/channel-config-metadata.ts b/src/config/channel-config-metadata.ts index 53903be0eebf..330182bda6a4 100644 --- a/src/config/channel-config-metadata.ts +++ b/src/config/channel-config-metadata.ts @@ -5,6 +5,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; +import { widenOfficialExternalChannelSecretSchema } from "./official-external-channel-secret-schema.js"; import type { ChannelUiMetadata, PluginUiMetadata } from "./schema.js"; import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; @@ -196,18 +197,22 @@ export function collectChannelSchemaMetadataWithOwnership( // advertises the same channel id. continue; } + const coreOwnedSchema = + record.origin === "bundled" || channelConfig.schema === undefined + ? channelConfig.schema + : normalizeCoreOwnedChannelSchema(channelConfig.schema); + const configSchema = widenOfficialExternalChannelSecretSchema({ + channelId, + schema: coreOwnedSchema, + }); byChannelId.set(channelId, { id: channelId, label: channelConfig.label ?? rootLabel ?? current?.label, description: channelConfig.description ?? rootDescription ?? current?.description, - // Installed plugin schemas can lag core; bundled schemas share its release and identity. - configSchema: - record.origin === "bundled" || channelConfig.schema === undefined - ? channelConfig.schema - : normalizeCoreOwnedChannelSchema(channelConfig.schema), + configSchema, configUiHints: channelConfig.uiHints as ChannelUiMetadata["configUiHints"], - schemaPluginId: channelConfig.schema === undefined ? undefined : record.id, - schemaPluginOrigin: channelConfig.schema === undefined ? undefined : record.origin, + schemaPluginId: configSchema === undefined ? undefined : record.id, + schemaPluginOrigin: configSchema === undefined ? undefined : record.origin, originRank, }); } diff --git a/src/config/official-external-channel-secret-schema.test.ts b/src/config/official-external-channel-secret-schema.test.ts new file mode 100644 index 000000000000..dd1446f639c6 --- /dev/null +++ b/src/config/official-external-channel-secret-schema.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; +import { collectChannelSchemaMetadataWithOwnership } from "./channel-config-metadata.js"; +import { widenOfficialExternalChannelSecretSchema } from "./official-external-channel-secret-schema.js"; + +describe("official external channel secret schema", () => { + it("widens Tencent QQBot root and account clientSecret fields to SecretRefs", () => { + const schema = widenOfficialExternalChannelSecretSchema({ + channelId: "qqbot", + schema: { + type: "object", + properties: { + clientSecret: { type: "string" }, + accounts: { + type: "object", + additionalProperties: { + type: "object", + properties: { clientSecret: { type: "string" } }, + }, + }, + }, + }, + }); + + const root = schema?.properties as Record> | undefined; + if (!root?.clientSecret || !root.accounts) { + throw new Error("expected root QQBot secret schema properties"); + } + expect(root.clientSecret.anyOf).toHaveLength(2); + const accounts = root.accounts.additionalProperties as + | { + properties?: Record; + } + | undefined; + if (!accounts?.properties?.clientSecret) { + throw new Error("expected account QQBot secret schema properties"); + } + expect(accounts.properties.clientSecret.anyOf).toHaveLength(2); + }); + + it("does not widen channels without a catalog secret contract", () => { + const schema = { type: "object", properties: { token: { type: "string" } } }; + + expect(widenOfficialExternalChannelSecretSchema({ channelId: "unknown", schema })).toBe(schema); + }); + + it("widens the installed Tencent manifest schema selected for channel validation", () => { + const registry = { + plugins: [ + { + id: "openclaw-qqbot", + origin: "global", + channels: ["qqbot"], + channelConfigs: { + qqbot: { + schema: { + type: "object", + additionalProperties: true, + properties: { clientSecret: { type: "string" } }, + }, + }, + }, + }, + ], + } as unknown as PluginManifestRegistry; + + const [metadata] = collectChannelSchemaMetadataWithOwnership(registry); + const properties = metadata?.configSchema?.properties as + | Record + | undefined; + if (!properties?.clientSecret) { + throw new Error("expected installed QQBot secret schema properties"); + } + expect(properties.clientSecret.anyOf).toHaveLength(2); + expect(metadata?.configSchema?.allOf).toHaveLength(1); + }); + + it("fails closed on QQBot configs that have not run the Tencent 2.0 migration", () => { + const schema = widenOfficialExternalChannelSecretSchema({ + channelId: "qqbot", + schema: { type: "object", additionalProperties: true }, + }); + if (!schema) { + throw new Error("expected QQBot host schema"); + } + const validate = (value: unknown) => + validateJsonSchemaValue({ + cacheKey: `qqbot-host-schema-${JSON.stringify(value)}`, + schema, + value, + }).ok; + + expect(validate({})).toBe(false); + expect(validate({ allowFrom: ["*"] })).toBe(false); + expect(validate({ allowFrom: ["user123"] })).toBe(false); + expect(validate({ defaultAccount: "ops", allowFrom: ["OWNER"] })).toBe(false); + expect( + validate({ + allowFrom: ["openclaw:approval-disabled"], + accounts: { default: { allowFrom: ["OWNER"] } }, + }), + ).toBe(false); + expect( + validate({ + allowFrom: ["openclaw:approval-disabled"], + accounts: { ops: { allowFrom: ["OWNER"] } }, + }), + ).toBe(true); + }); +}); diff --git a/src/config/official-external-channel-secret-schema.ts b/src/config/official-external-channel-secret-schema.ts new file mode 100644 index 000000000000..d7ce09b039a8 --- /dev/null +++ b/src/config/official-external-channel-secret-schema.ts @@ -0,0 +1,68 @@ +/** Widens official external channel schemas for host-resolved SecretRef fields. */ +import { + getOfficialExternalChannelHostSchemaAllOf, + getOfficialExternalChannelSecretContract, +} from "../plugins/official-external-plugin-catalog.js"; +import { cloneSchema } from "./schema.shared.js"; +import { SecretRefSchema } from "./zod-schema.core.js"; + +type JsonSchemaObject = Record & { + properties?: Record; + additionalProperties?: boolean | JsonSchemaObject; + anyOf?: JsonSchemaObject[]; + allOf?: JsonSchemaObject[]; +}; + +const SECRET_REF_SCHEMA = SecretRefSchema.toJSONSchema({ + io: "input", + target: "draft-07", + unrepresentable: "any", +}) as JsonSchemaObject; + +function asSchemaObject(value: unknown): JsonSchemaObject | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonSchemaObject) + : undefined; +} + +function widenProperties( + properties: Record | undefined, + fields: readonly string[], +): void { + if (!properties) { + return; + } + for (const field of fields) { + const current = asSchemaObject(properties[field]); + if (current) { + properties[field] = { anyOf: [current, cloneSchema(SECRET_REF_SCHEMA)] }; + } + } +} + +/** Keeps external plugin schemas honest while allowing host-resolved secret inputs. */ +export function widenOfficialExternalChannelSecretSchema(params: { + channelId: string; + schema: Record | undefined; +}): Record | undefined { + const contract = getOfficialExternalChannelSecretContract(params.channelId); + const hostSchemaAllOf = getOfficialExternalChannelHostSchemaAllOf(params.channelId); + if ((!contract && hostSchemaAllOf.length === 0) || !params.schema) { + return params.schema; + } + const next = cloneSchema(params.schema) as JsonSchemaObject; + if (contract) { + const fields = contract.fields.map((field) => field.field); + widenProperties(next.properties, fields); + const accounts = asSchemaObject(next.properties?.accounts); + const accountSchema = asSchemaObject(accounts?.additionalProperties); + widenProperties(accountSchema?.properties, fields); + } + if (hostSchemaAllOf.length > 0) { + next.allOf = [ + ...(Array.isArray(next.allOf) ? next.allOf : []), + ...hostSchemaAllOf.map((clause) => cloneSchema(clause) as JsonSchemaObject), + ]; + } + return next; +} diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts index 754f0caab628..8bccd0552ee1 100644 --- a/src/config/plugin-auto-enable.channels.test.ts +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -423,6 +423,35 @@ describe("applyPluginAutoEnable channels", () => { expect(result.changes.join("\n")).toContain("Modern Chat configured, enabled automatically."); }); + it("does not disable a renamed external owner through its removed bundled channel id", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + plugins: { + entries: { + "openclaw-qqbot": { enabled: true }, + }, + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { + id: "openclaw-qqbot", + channels: ["qqbot"], + channelConfigs: { + qqbot: { + schema: { type: "object" }, + preferOver: ["qqbot"], + }, + }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.qqbot).toBeUndefined(); + }); + it("falls back to the bundled channel when the preferred external plugin is disabled", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index e9875ffc0be4..dfd712ab4f14 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -824,6 +824,14 @@ function disableImplicitPreferredOverPlugin(params: { if (isPluginExplicitlySelected(params.originalConfig, params.pluginId)) { return params.config; } + // A built-in channel id can remain in the static channel catalog after its + // bundled plugin has been externalized. Do not synthesize a disabled entry + // for that owner unless it is still present in the runtime manifest set. + // Otherwise registry alias normalization can fold the stale channel id back + // onto the external owner and override its explicit enabled entry. + if (!params.manifestRegistry.plugins.some((plugin) => plugin.id === params.pluginId)) { + return params.config; + } if ( !normalizeChatChannelId(params.pluginId) && !isKnownPluginId(params.pluginId, params.manifestRegistry) diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index e9eeda4f76e5..89053cb34569 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -465,6 +465,56 @@ describe("setupChannels workspace shadow exclusion", () => { }); }); + it("normalizes official external compatibility output from interactive setup", async () => { + const setupWizard = { + channel: "qqbot", + getStatus: vi.fn(async () => ({ + channel: "qqbot", + configured: false, + statusLines: [], + })), + configure: vi.fn(async () => ({ + cfg: { + channels: { + qqbot: { + appId: "app-id", + clientSecret: "secret", + allowFrom: ["*"], + }, + }, + }, + })), + }; + const activePlugin = makeSetupPlugin({ id: "qqbot", label: "QQ Bot", setupWizard }); + listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); + resolveChannelSetupEntries.mockReturnValue( + makeChannelSetupEntries({ + entries: [{ id: "qqbot", meta: makeMeta("qqbot", "QQ Bot") }], + }), + ); + const select = vi.fn().mockResolvedValueOnce("qqbot").mockResolvedValueOnce("__done__"); + + const next = await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(next.channels?.qqbot).toMatchObject({ + dmPolicy: "open", + allowFrom: ["openclaw:approval-disabled"], + }); + }); + it("allowlists ClickClack when it is explicitly selected for setup", async () => { const setupWizard = { channel: "clickclack", diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index a4f353774ecb..fa0f1d7f79e9 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -13,6 +13,7 @@ import type { SetupChannelsOptions, } from "../channels/plugins/setup-wizard-types.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { normalizeExternalChannelSetupConfig } from "../commands/channel-setup/config-compatibility.js"; import { resolveChannelSetupEntries, shouldShowChannelInSetup, @@ -429,7 +430,7 @@ export async function setupChannels( const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { const previousCfg = next; - next = result.cfg; + next = normalizeExternalChannelSetupConfig({ cfg: result.cfg, channel }); if (result.completion === "paused") { // Persist partial setup state, but do not run configured-account hooks, // routing, or DM policy prompts until setup actually completes. diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index ca463d2f02de..6a04bf6c4511 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -302,7 +302,6 @@ describe("tsdown config", () => { expect(neverBundle("@vitest/expect")).toBe(true); expect(neverBundle("jimp")).toBe(true); expect(neverBundle("matrix-js-sdk/lib/client.js")).toBe(true); - expect(neverBundle("qrcode-terminal/lib/main.js")).toBe(true); expect(neverBundle("sharp")).toBe(true); expect(neverBundle("vitest")).toBe(true); expect(neverBundle("not-a-runtime-dependency")).toBe(false); @@ -317,7 +316,6 @@ describe("tsdown config", () => { "@vitest/expect", "jimp", "matrix-js-sdk", - "qrcode-terminal", "sharp", "vitest", ]) { @@ -329,7 +327,6 @@ describe("tsdown config", () => { } const externalize = external; expect(externalize("jimp", undefined, false)).toBe(true); - expect(externalize("qrcode-terminal/lib/main.js", undefined, false)).toBe(true); expect(externalize("sharp", undefined, false)).toBe(true); }); diff --git a/src/plugin-sdk/gateway-runtime.ts b/src/plugin-sdk/gateway-runtime.ts index 65ff2a430056..ae2b2f957251 100644 --- a/src/plugin-sdk/gateway-runtime.ts +++ b/src/plugin-sdk/gateway-runtime.ts @@ -34,6 +34,9 @@ export { resolveGatewayAuth } from "../gateway/auth.js"; export { GatewayClient } from "../gateway/client.js"; export { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; +// Compatibility for @tencent-connect/openclaw-qqbot@2.0.1. Remove after the pinned +// package migrates its approval handler to the dedicated approval runtime SDK. +export { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js"; export { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/schema/error-codes.js"; diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 82c4c99de21a..e6a550e97afc 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -1192,6 +1192,47 @@ describe("resolveGatewayStartupPluginIdsFromRegistry", () => { }); }); + it("starts a renamed external channel after its bundled owner is removed", () => { + const registry = createManifestRegistryFixture(); + registry.plugins.push( + withManifestLoadPaths({ + id: "openclaw-qqbot", + channels: ["qqbot"], + channelConfigs: { + qqbot: { + schema: { type: "object" }, + preferOver: ["qqbot"], + }, + }, + origin: "global", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + }), + ); + const index = createInstalledPluginIndexFixture(registry); + const sourceConfig = { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + plugins: { entries: { "openclaw-qqbot": { enabled: true } } }, + } as OpenClawConfig; + const runtimeConfig = applyPluginAutoEnable({ + config: sourceConfig, + env: createPluginPlanningTestEnv(), + manifestRegistry: registry, + }).config; + + expect(runtimeConfig.plugins?.entries?.qqbot).toBeUndefined(); + expect( + resolveGatewayStartupPluginPlanFromRegistry({ + config: runtimeConfig, + activationSourceConfig: sourceConfig, + env: createPluginPlanningTestEnv(), + index, + manifestRegistry: registry, + }).pluginIds, + ).toContain("openclaw-qqbot"); + }); + it("loads configured worker-provider owners from the activation source", () => { const activationSourceConfig = { channels: {}, diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index d89fc8ddebe6..4742e82828a6 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -71,10 +71,6 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ minHostVersionBaseline: "2026.3.22", }, { pluginId: "openshell" }, - { - pluginId: "qqbot", - pluginLocalRuntimeDeps: ["@tencent-connect/qqbot-connector", "mpg123-decoder", "silk-wasm"], - }, { pluginId: "slack" }, { pluginId: "synology-chat", minHostVersionBaseline: "2026.3.22" }, { pluginId: "telegram" }, diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index 20cabf0c3d3f..d85e51374ec1 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -31,7 +31,6 @@ const UNGUARDED_RUNTIME_API_PLUGIN_IDS = [ "open-prose", "qa-channel", "qa-lab", - "qqbot", "reef", "tlon", "tokenjuice", diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 99fdb9cb6ea2..921637cd6c06 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -780,6 +780,7 @@ describe("plugin-sdk subpath exports", () => { ], }); expectSourceMentions("runtime", ["createLoggerBackedRuntime"]); + expectSourceMentions("gateway-runtime", ["createOperatorApprovalsGatewayClient"]); expectSourceMentions("conversation-runtime", [ "recordInboundSession", "recordInboundSessionMetaSafe", diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 1a9e32474dd5..03c264993a89 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -429,14 +429,14 @@ describe("copyBundledPluginMetadata", () => { it("removes build-excluded bundled plugin metadata", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-excluded-meta-"); createPlugin(repoRoot, { - id: "qqbot", - packageName: "@openclaw/qqbot", + id: "whatsapp", + packageName: "@openclaw/whatsapp", packageOpenClaw: { extensions: ["./index.ts"], setupEntry: "./setup-entry.ts", }, }); - const staleDistDir = path.join(repoRoot, "dist", "extensions", "qqbot"); + const staleDistDir = path.join(repoRoot, "dist", "extensions", "whatsapp"); fs.mkdirSync(staleDistDir, { recursive: true }); fs.writeFileSync(path.join(staleDistDir, "index.js"), "export default {}\n", "utf8"); diff --git a/src/plugins/externalized-bundled-plugins.ts b/src/plugins/externalized-bundled-plugins.ts index 3dfc396e5b3f..91a993302a79 100644 --- a/src/plugins/externalized-bundled-plugins.ts +++ b/src/plugins/externalized-bundled-plugins.ts @@ -10,6 +10,8 @@ export type ExternalizedBundledPluginBridge = { preferredSource?: ExternalizedBundledPluginPreferredSource; /** npm spec OpenClaw can install when migrating the bundled plugin out. */ npmSpec?: string; + /** Catalog integrity pin for npmSpec; only valid for that exact spec. */ + expectedIntegrity?: string; /** ClawHub spec OpenClaw can install when migrating the bundled plugin out. */ clawhubSpec?: string; /** Optional ClawHub base URL for non-default registries. */ diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index 909b36e78ccf..4dd9a009cf2f 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -731,6 +731,37 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { ]); }); + it("normalizes the open-DM wildcard doctor capability from persisted metadata", () => { + const rootDir = makeTempDir(); + writePlugin(rootDir, "installed", "installed-"); + const index = createIndex(rootDir); + const registry = loadPluginManifestRegistryForInstalledIndex({ + index: { + ...index, + plugins: [ + { + ...expectDefined(index.plugins[0], "index.plugins[0] test invariant"), + packageChannel: { + id: "installed", + doctorCapabilities: { + openDmRequiresAllowFromWildcard: false, + }, + }, + }, + ], + }, + env: { + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }, + includeDisabled: true, + }); + + expect( + registry.plugins[0]?.packageChannel?.doctorCapabilities?.openDmRequiresAllowFromWildcard, + ).toBe(false); + }); + it("round-trips bundle metadata through the persisted index before reconstruction", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index a9cb83986404..b207a73700b6 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -240,6 +240,7 @@ function normalizePackageChannelDoctorCapabilities( normalized.groupModel = groupModel; } for (const key of [ + "openDmRequiresAllowFromWildcard", "groupAllowFromFallbackToAllowFrom", "warnOnEmptyGroupSenderAllowlist", ] as const) { diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 819a6741b057..df0faf6d7d9c 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -7,6 +7,7 @@ import officialExternalPluginCatalog from "../../scripts/lib/official-external-p import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createSqliteHostedOfficialExternalPluginCatalogSnapshotStore } from "./official-external-plugin-catalog-snapshot-store.js"; import { + getOfficialExternalChannelSecretContract, type HostedOfficialExternalPluginCatalogSnapshot, type HostedOfficialExternalPluginCatalogSnapshotStore, type OfficialExternalPluginCatalogEntry, @@ -1918,6 +1919,8 @@ describe("official external plugin catalog", () => { const wecomByChannel = expectCatalogEntry("wecom"); const wecomByPlugin = expectCatalogEntry("wecom-openclaw-plugin"); const yuanbaoByChannel = expectCatalogEntry("yuanbao"); + const qqbotByChannel = expectCatalogEntry("qqbot"); + const qqbotByPlugin = expectCatalogEntry("openclaw-qqbot"); expect(resolveOfficialExternalPluginId(wecomByChannel)).toBe("wecom-openclaw-plugin"); expect(resolveOfficialExternalPluginId(wecomByPlugin)).toBe("wecom-openclaw-plugin"); @@ -1928,6 +1931,27 @@ describe("official external plugin catalog", () => { expect(resolveOfficialExternalPluginInstall(yuanbaoByChannel)?.npmSpec).toBe( "openclaw-plugin-yuanbao@2.15.0", ); + expect(resolveOfficialExternalPluginId(qqbotByChannel)).toBe("openclaw-qqbot"); + expect(qqbotByPlugin).toBe(qqbotByChannel); + expect( + getOfficialExternalPluginCatalogManifest(qqbotByChannel)?.channel?.doctorCapabilities, + ).toEqual({ openDmRequiresAllowFromWildcard: false }); + expect(resolveOfficialExternalPluginInstall(qqbotByChannel)).toEqual({ + npmSpec: "@tencent-connect/openclaw-qqbot@2.0.1", + defaultChoice: "npm", + expectedIntegrity: + "sha512-2010PaCummeQaxerLtaGfQ/5HChiXaW/KpTERid7V/1zyTs46S2ACi0hgZQ1SB7tH0t1InWr8tzVBJV/pLss3Q==", + }); + expect(getOfficialExternalChannelSecretContract("qqbot")).toEqual({ + channelId: "qqbot", + fields: [ + { + field: "clientSecret", + activationField: "appId", + activationEnv: "QQBOT_APP_ID", + }, + ], + }); }); it("keeps official launch package specs on the production package names", () => { diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 2c4844919f91..d95f5691eb11 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -72,6 +72,17 @@ export type OfficialExternalWebSearchProvider = { autoDetectOrder?: number; }; +type OfficialExternalChannelSecretField = { + field: string; + activationField?: string; + activationEnv?: string; +}; + +type OfficialExternalChannelSecretContract = { + channelId: string; + fields: readonly OfficialExternalChannelSecretField[]; +}; + type OfficialExternalCatalogChannel = PluginPackageChannel & { /** Older hosted catalogs used a flat env list before configuredState became canonical. */ envVars?: readonly string[]; @@ -86,6 +97,20 @@ type OfficialExternalPluginCatalogManifest = { }; catalog?: PluginManifestCatalog; channel?: OfficialExternalCatalogChannel; + /** Host fallback for external channels that do not yet publish a secret-contract artifact. */ + channelSecrets?: { + fields?: readonly { + field?: string; + activationField?: string; + activationEnv?: string; + }[]; + }; + /** Host validation overlays for compatibility-sensitive external channel cutovers. */ + channelHostConfig?: { + docsSource?: "external" | "official"; + compatibilityMigration?: string; + schemaAllOf?: readonly Record[]; + }; providers?: readonly OfficialExternalProviderCatalogProvider[]; /** * Mirrors the plugin manifest's providerEndpoints so endpoint classification @@ -1463,6 +1488,17 @@ export function resolveOfficialExternalPluginLegacyIds( ); } +/** Returns the host-owned setup migration selected for an external channel cutover. */ +export function resolveOfficialExternalChannelCompatibilityMigration( + channelId: string, +): string | undefined { + const entry = getOfficialExternalPluginCatalogEntry(channelId); + return normalizeOptionalString( + getOfficialExternalPluginCatalogManifest(entry ?? {})?.channelHostConfig + ?.compatibilityMigration, + ); +} + function resolveOfficialExternalPluginLookupIds( entry: OfficialExternalPluginCatalogEntry, ): string[] { @@ -1704,6 +1740,71 @@ export function listOfficialExternalChannelEnvVars(): Array<{ }); } +const CHANNEL_SECRET_FIELD_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/; +const CHANNEL_SECRET_ENV_PATTERN = /^[A-Z][A-Z0-9_]*$/; + +/** Returns a validated host fallback secret contract for one external channel. */ +export function getOfficialExternalChannelSecretContract( + channelId: string, +): OfficialExternalChannelSecretContract | undefined { + const normalizedChannelId = normalizeOptionalString(channelId)?.toLowerCase(); + if (!normalizedChannelId) { + return undefined; + } + const entry = listOfficialExternalChannelCatalogEntries().find((candidate) => { + const id = normalizeOptionalString( + getOfficialExternalPluginCatalogManifest(candidate)?.channel?.id, + )?.toLowerCase(); + return id === normalizedChannelId; + }); + const fields = getOfficialExternalPluginCatalogManifest(entry ?? {})?.channelSecrets?.fields; + if (!fields) { + return undefined; + } + const normalizedFields = fields.flatMap((field) => { + const fieldName = normalizeOptionalString(field.field); + const activationField = normalizeOptionalString(field.activationField); + const activationEnv = normalizeOptionalString(field.activationEnv); + if ( + !fieldName || + !CHANNEL_SECRET_FIELD_PATTERN.test(fieldName) || + (activationField !== undefined && !CHANNEL_SECRET_FIELD_PATTERN.test(activationField)) || + (activationEnv !== undefined && !CHANNEL_SECRET_ENV_PATTERN.test(activationEnv)) + ) { + return []; + } + return [ + { + field: fieldName, + ...(activationField ? { activationField } : {}), + ...(activationEnv ? { activationEnv } : {}), + }, + ]; + }); + return normalizedFields.length > 0 + ? { channelId: normalizedChannelId, fields: normalizedFields } + : undefined; +} + +/** Returns trusted host validation clauses for one official external channel. */ +export function getOfficialExternalChannelHostSchemaAllOf( + channelId: string, +): readonly Record[] { + const normalizedChannelId = normalizeOptionalString(channelId)?.toLowerCase(); + if (!normalizedChannelId) { + return []; + } + const entry = listOfficialExternalChannelCatalogEntries().find((candidate) => { + const id = normalizeOptionalString( + getOfficialExternalPluginCatalogManifest(candidate)?.channel?.id, + )?.toLowerCase(); + return id === normalizedChannelId; + }); + const clauses = getOfficialExternalPluginCatalogManifest(entry ?? {})?.channelHostConfig + ?.schemaAllOf; + return Array.isArray(clauses) ? clauses.filter(isRecord) : []; +} + export function listOfficialExternalProviderCatalogEntries(): OfficialExternalPluginCatalogEntry[] { return listOfficialExternalPluginCatalogEntries().filter( (entry) => (getOfficialExternalPluginCatalogManifest(entry)?.providers?.length ?? 0) > 0, diff --git a/src/plugins/official-external-plugin-targets.test.ts b/src/plugins/official-external-plugin-targets.test.ts index 47d42c62aacb..4878bf251ec1 100644 --- a/src/plugins/official-external-plugin-targets.test.ts +++ b/src/plugins/official-external-plugin-targets.test.ts @@ -11,6 +11,15 @@ describe("official external channel targets", () => { ).toBe(true); }); + it("detects QQBot credentials from the external channel catalog", () => { + expect( + hasOfficialExternalChannelTarget({ + config: {}, + env: { QQBOT_APP_ID: "app-id" }, + }), + ).toBe(true); + }); + it("treats any generated all-of variable as a potential repair target", () => { expect( hasOfficialExternalChannelTarget({ diff --git a/src/plugins/package-manifest.ts b/src/plugins/package-manifest.ts index a1de446677fc..da57f9bf1f77 100644 --- a/src/plugins/package-manifest.ts +++ b/src/plugins/package-manifest.ts @@ -55,6 +55,8 @@ export type PluginPackageChannel = { export type PluginPackageChannelDoctorCapabilities = { dmAllowFromMode?: "topOnly" | "topOrNested" | "nestedOnly"; + /** Whether dmPolicy="open" requires an explicit "*" in allowFrom. Defaults to true. */ + openDmRequiresAllowFromWildcard?: boolean; groupModel?: "sender" | "route" | "hybrid"; groupAllowFromFallbackToAllowFrom?: boolean; warnOnEmptyGroupSenderAllowlist?: boolean; diff --git a/src/plugins/update-channel.ts b/src/plugins/update-channel.ts index f0904936185d..2788e2cbb912 100644 --- a/src/plugins/update-channel.ts +++ b/src/plugins/update-channel.ts @@ -187,6 +187,11 @@ export async function syncPluginsForUpdateChannel(params: { }) : null; const effectiveNpmSpec = channelNpmSpecs?.installSpec ?? npmSpec; + // The catalog integrity pin covers only the bridge's exact npm spec; an + // update-channel override resolves a different version and must not + // inherit it. + const bridgeNpmIntegrity = + effectiveNpmSpec === npmSpec ? bridge.expectedIntegrity?.trim() : undefined; let installSource = preferredSource; let installSpec = preferredSource === "clawhub" ? clawhubSpec : effectiveNpmSpec; let result: @@ -221,6 +226,7 @@ export async function syncPluginsForUpdateChannel(params: { config: params.config, mode: "update", expectedPluginId: targetPluginId, + ...(bridgeNpmIntegrity ? { expectedIntegrity: bridgeNpmIntegrity } : {}), trustedSourceLinkedOfficialInstall, logger, }); @@ -231,6 +237,7 @@ export async function syncPluginsForUpdateChannel(params: { config: params.config, mode: "update", expectedPluginId: targetPluginId, + ...(bridgeNpmIntegrity ? { expectedIntegrity: bridgeNpmIntegrity } : {}), trustedSourceLinkedOfficialInstall, logger, }); diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 022c5048350a..445dca357dd5 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -4937,6 +4937,57 @@ describe("syncPluginsForUpdateChannel", () => { }); }); + it("installs an externalized bundled plugin under its renamed package id", async () => { + resolveBundledPluginSourcesMock.mockReturnValue(new Map()); + installPluginFromNpmSpecMock.mockResolvedValue( + createSuccessfulNpmUpdateResult({ + pluginId: "openclaw-qqbot", + targetDir: "/tmp/openclaw-plugins/openclaw-qqbot", + version: "2.0.1", + }), + ); + + const result = await syncPluginsForUpdateChannel({ + channel: "stable", + externalizedBundledPluginBridges: [ + { + bundledPluginId: "qqbot", + pluginId: "openclaw-qqbot", + npmSpec: "@tencent-connect/openclaw-qqbot@2.0.1", + expectedIntegrity: "sha512-qqbot-catalog-pin", + channelIds: ["qqbot"], + }, + ], + config: { + channels: { qqbot: { enabled: true } }, + plugins: { + entries: { qqbot: { enabled: true } }, + load: { paths: [appBundledPluginRoot("qqbot")] }, + installs: { + qqbot: { + source: "path", + sourcePath: appBundledPluginRoot("qqbot"), + installPath: appBundledPluginRoot("qqbot"), + }, + }, + }, + }, + }); + + expect(npmInstallCall()?.expectedPluginId).toBe("openclaw-qqbot"); + expect(npmInstallCall()?.expectedIntegrity).toBe("sha512-qqbot-catalog-pin"); + expect(result.summary.switchedToNpm).toEqual(["openclaw-qqbot"]); + expect(result.config.plugins?.entries?.qqbot).toBeUndefined(); + expect(result.config.plugins?.entries?.["openclaw-qqbot"]).toEqual({ enabled: true }); + expect(result.config.plugins?.installs?.qqbot).toBeUndefined(); + expectRecordFields(result.config.plugins?.installs?.["openclaw-qqbot"], { + source: "npm", + spec: "@tencent-connect/openclaw-qqbot@2.0.1", + installPath: "/tmp/openclaw-plugins/openclaw-qqbot", + version: "2.0.1", + }); + }); + it("marks official externalized bundled npm installs as trusted", async () => { resolveBundledPluginSourcesMock.mockReturnValue(new Map()); installPluginFromNpmSpecMock.mockResolvedValue( diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts index bcb7931741c7..99d59df3e8b8 100644 --- a/src/secrets/channel-contract-api.external.test.ts +++ b/src/secrets/channel-contract-api.external.test.ts @@ -213,4 +213,53 @@ describe("external channel secret contract api", () => { expect(api).toBeUndefined(); }); + + it("falls back to official host secret metadata when an external plugin has no artifact", () => { + loadPluginMetadataSnapshotMock.mockReturnValue({ plugins: [] }); + + const api = loadChannelSecretContractApi({ + channelId: "qqbot", + config: { channels: { qqbot: { appId: "app" } } }, + env: {}, + }); + + expect(api?.secretTargetRegistryEntries?.map((entry) => entry.id)).toEqual([ + "channels.qqbot.accounts.*.clientSecret", + "channels.qqbot.clientSecret", + ]); + expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + }); + + it("falls back to official host secret metadata when plugin metadata is unavailable", () => { + loadPluginMetadataSnapshotMock.mockImplementation(() => { + throw new Error("metadata unavailable"); + }); + + const api = loadChannelSecretContractApi({ + channelId: "qqbot", + config: { channels: { qqbot: { appId: "app" } } }, + env: {}, + }); + + expect(api?.secretTargetRegistryEntries?.map((entry) => entry.id)).toEqual([ + "channels.qqbot.accounts.*.clientSecret", + "channels.qqbot.clientSecret", + ]); + }); + + it("does not hide installed plugin contract loading failures behind the official fallback", () => { + const record = writeExternalChannelPlugin({ pluginId: "qqbot", channelId: "qqbot" }); + loadPluginMetadataSnapshotMock.mockReturnValue({ plugins: [record] }); + shouldRejectHardlinkedPluginFilesMock.mockImplementation(() => { + throw new Error("contract policy failed"); + }); + + expect(() => + loadChannelSecretContractApi({ + channelId: "qqbot", + config: { channels: { qqbot: { appId: "app" } } }, + env: {}, + }), + ).toThrow("contract policy failed"); + }); }); diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index f7e819906796..c97a2d00a17b 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -19,6 +19,7 @@ import { } from "../plugins/plugin-module-loader-cache.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; +import { loadOfficialExternalChannelSecretContractApi } from "./official-external-channel-secret-contract.js"; import type { ResolverContext, SecretDefaults } from "./runtime-shared.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; @@ -203,18 +204,29 @@ export function loadChannelSecretContractApi(params: { // External contracts are considered only after bundled artifacts so core channels keep their // shipped metadata stable even when similarly named plugins are installed. const env = params.env ?? process.env; - for (const record of listChannelSecretContractRecords({ - channelId: params.channelId, - config: params.config, - env, - loadablePluginOrigins: params.loadablePluginOrigins, - })) { + const officialFallback = loadOfficialExternalChannelSecretContractApi(params.channelId); + let records: PluginManifestRecord[]; + try { + records = listChannelSecretContractRecords({ + channelId: params.channelId, + config: params.config, + env, + loadablePluginOrigins: params.loadablePluginOrigins, + }); + } catch (error) { + // Catalog contracts are process-stable fallbacks when plugin metadata is unavailable. + if (officialFallback) { + return officialFallback; + } + throw error; + } + for (const record of records) { const contract = loadExternalChannelSecretContractFromRecord(record, env); if (contract) { return contract; } } - return undefined; + return officialFallback; } /** Loads a channel secret contract directly from a manifest record. */ diff --git a/src/secrets/official-external-channel-secret-contract.test.ts b/src/secrets/official-external-channel-secret-contract.test.ts new file mode 100644 index 000000000000..a6d13f61f98d --- /dev/null +++ b/src/secrets/official-external-channel-secret-contract.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { loadOfficialExternalChannelSecretContractApi } from "./official-external-channel-secret-contract.js"; +import { createResolverContext } from "./runtime-shared.js"; + +describe("official external channel secret contracts", () => { + it("collects active QQBot root and account SecretRefs for Tencent 2.0.1", () => { + const config = { + channels: { + qqbot: { + appId: "root-app", + clientSecret: { source: "env" as const, provider: "default", id: "QQBOT_ROOT_SECRET" }, + accounts: { + named: { + appId: "named-app", + clientSecret: { + source: "env" as const, + provider: "default", + id: "QQBOT_NAMED_SECRET", + }, + }, + }, + }, + }, + }; + const context = createResolverContext({ sourceConfig: config, env: {} }); + const api = loadOfficialExternalChannelSecretContractApi("qqbot"); + + api?.collectRuntimeConfigAssignments({ config, defaults: undefined, context }); + + expect(context.assignments.map((assignment) => assignment.path)).toEqual([ + "channels.qqbot.clientSecret", + "channels.qqbot.accounts.named.clientSecret", + ]); + context.assignments[0]?.apply("resolved-root-secret"); + context.assignments[1]?.apply("resolved-named-secret"); + expect(config.channels.qqbot.clientSecret).toBe("resolved-root-secret"); + expect(config.channels.qqbot.accounts.named.clientSecret).toBe("resolved-named-secret"); + }); + + it("uses QQBOT_APP_ID only for the default account and skips inactive credentials", () => { + const config = { + channels: { + qqbot: { + clientSecret: { source: "env" as const, provider: "default", id: "QQBOT_ROOT_SECRET" }, + accounts: { + disabled: { + enabled: false, + appId: "disabled-app", + clientSecret: { + source: "env" as const, + provider: "default", + id: "QQBOT_DISABLED_SECRET", + }, + }, + missingAppId: { + clientSecret: { + source: "env" as const, + provider: "default", + id: "QQBOT_MISSING_APP_SECRET", + }, + }, + }, + }, + }, + }; + const context = createResolverContext({ + sourceConfig: config, + env: { QQBOT_APP_ID: "env-app" }, + }); + const api = loadOfficialExternalChannelSecretContractApi("qqbot"); + + api?.collectRuntimeConfigAssignments({ config, defaults: undefined, context }); + + expect(context.assignments.map((assignment) => assignment.path)).toEqual([ + "channels.qqbot.clientSecret", + ]); + expect(config.channels.qqbot).toHaveProperty("appId", "env-app"); + expect(context.warnings.map((warning) => warning.path)).toEqual([ + "channels.qqbot.accounts.disabled.clientSecret", + "channels.qqbot.accounts.missingAppId.clientSecret", + ]); + }); +}); diff --git a/src/secrets/official-external-channel-secret-contract.ts b/src/secrets/official-external-channel-secret-contract.ts new file mode 100644 index 000000000000..11587fce1a95 --- /dev/null +++ b/src/secrets/official-external-channel-secret-contract.ts @@ -0,0 +1,149 @@ +/** Host fallback secret contracts for external channels without contract artifacts. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + getOfficialExternalChannelSecretContract, + getOfficialExternalPluginCatalogManifest, + listOfficialExternalChannelCatalogEntries, +} from "../plugins/official-external-plugin-catalog.js"; +import { + createChannelSecretTargetRegistryEntries, + getChannelRecord, +} from "./channel-secret-basic-runtime.js"; +import { + collectSecretInputAssignment, + isChannelAccountEffectivelyEnabled, + isEnabledFlag, + type ResolverContext, + type SecretDefaults, +} from "./runtime-shared.js"; +import { isRecord } from "./shared.js"; +import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; + +type OfficialExternalChannelSecretContractApi = { + collectRuntimeConfigAssignments: (params: { + config: OpenClawConfig; + defaults: SecretDefaults | undefined; + context: ResolverContext; + }) => void; + secretTargetRegistryEntries: readonly SecretTargetRegistryEntry[]; +}; + +function hasActivationValue(params: { + record: Record; + activationField?: string; + activationEnv?: string; + env: NodeJS.ProcessEnv; + allowEnv: boolean; +}): boolean { + if (!params.activationField) { + return true; + } + if (normalizeOptionalString(params.record[params.activationField])) { + return true; + } + return Boolean( + params.allowEnv && + params.activationEnv && + normalizeOptionalString(params.env[params.activationEnv]), + ); +} + +export function loadOfficialExternalChannelSecretContractApi( + channelId: string, +): OfficialExternalChannelSecretContractApi | undefined { + const contract = getOfficialExternalChannelSecretContract(channelId); + if (!contract) { + return undefined; + } + const fieldNames = contract.fields.map((field) => field.field); + return { + secretTargetRegistryEntries: createChannelSecretTargetRegistryEntries({ + channelKey: contract.channelId, + channel: fieldNames, + account: fieldNames, + }), + collectRuntimeConfigAssignments({ config, defaults, context }) { + const channel = getChannelRecord(config, contract.channelId); + if (!channel) { + return; + } + for (const field of contract.fields) { + const activationEnvValue = field.activationEnv + ? normalizeOptionalString(context.env[field.activationEnv]) + : undefined; + if ( + isEnabledFlag(channel) && + field.activationField && + !normalizeOptionalString(channel[field.activationField]) && + activationEnvValue + ) { + // External discovery may enumerate accounts before its resolver reads + // env fallbacks. Materialize only into the ephemeral runtime config. + channel[field.activationField] = activationEnvValue; + } + collectSecretInputAssignment({ + value: channel[field.field], + path: `channels.${contract.channelId}.${field.field}`, + expected: "string", + defaults, + context, + active: + isEnabledFlag(channel) && + hasActivationValue({ + record: channel, + activationField: field.activationField, + activationEnv: field.activationEnv, + env: context.env, + allowEnv: true, + }), + inactiveReason: `external channel is disabled or ${field.activationField ?? "its credential surface"} is not configured.`, + apply: (value) => { + channel[field.field] = value; + }, + }); + const accounts = isRecord(channel.accounts) ? channel.accounts : undefined; + if (!accounts) { + continue; + } + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = isRecord(accountValue) ? accountValue : undefined; + if (!account || !Object.hasOwn(account, field.field)) { + continue; + } + collectSecretInputAssignment({ + value: account[field.field], + path: `channels.${contract.channelId}.accounts.${accountId}.${field.field}`, + expected: "string", + defaults, + context, + active: + isChannelAccountEffectivelyEnabled(channel, account) && + hasActivationValue({ + record: account, + activationField: field.activationField, + activationEnv: field.activationEnv, + env: context.env, + allowEnv: false, + }), + inactiveReason: `external channel account is disabled or ${field.activationField ?? "its credential surface"} is not configured.`, + apply: (value) => { + account[field.field] = value; + }, + }); + } + } + }, + }; +} + +export function listOfficialExternalChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { + return listOfficialExternalChannelCatalogEntries().flatMap((entry) => { + const channelId = normalizeOptionalString( + getOfficialExternalPluginCatalogManifest(entry)?.channel?.id, + ); + return channelId + ? (loadOfficialExternalChannelSecretContractApi(channelId)?.secretTargetRegistryEntries ?? []) + : []; + }); +} diff --git a/src/secrets/target-registry-data.current-snapshot.test.ts b/src/secrets/target-registry-data.current-snapshot.test.ts index 175aa55868c7..ca9afec9e74b 100644 --- a/src/secrets/target-registry-data.current-snapshot.test.ts +++ b/src/secrets/target-registry-data.current-snapshot.test.ts @@ -87,4 +87,13 @@ describe("getSecretTargetRegistry metadata reuse", () => { expect(ids).toContain("plugins.entries.snapshot-plugin.config.credentials.token"); expect(metadataMocks.listBundledPluginMetadata).not.toHaveBeenCalled(); }); + + it("keeps official external channel secret targets without installed plugin metadata", async () => { + const { getSecretTargetRegistry } = await import("./target-registry-data.js"); + + const ids = getSecretTargetRegistry().map((entry) => entry.id); + + expect(ids).toContain("channels.qqbot.clientSecret"); + expect(ids).toContain("channels.qqbot.accounts.*.clientSecret"); + }); }); diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 75534d275493..9d7648ffb519 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -2,6 +2,7 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js"; +import { listOfficialExternalChannelSecretTargetRegistryEntries } from "./official-external-channel-secret-contract.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret @@ -459,12 +460,22 @@ function loadSecretTargetRegistryFromPluginMetadata(params: { // manifest-scoped — web-provider contract + sensitive hint, or declared // secretInput paths — so a non-bundled origin cannot widen target paths // beyond its own declared contracts. - return [ + const entries = [ ...CORE_SECRET_TARGET_REGISTRY, ...listPluginWebProviderSecretTargetRegistryEntries(plugins), ...listPluginConfigSecretTargetRegistryEntries(plugins), ...listChannelSecretTargetRegistryEntries(channelPlugins), + ...listOfficialExternalChannelSecretTargetRegistryEntries(), ]; + const seen = new Set(); + return entries.filter((entry) => { + const key = `${entry.configFile}:${entry.pathPattern}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); } /** Returns only core-owned secret target registry entries. */ diff --git a/src/utils/message-channel.test.ts b/src/utils/message-channel.test.ts index 62603daa02c7..5ec6cacb3fb5 100644 --- a/src/utils/message-channel.test.ts +++ b/src/utils/message-channel.test.ts @@ -119,10 +119,11 @@ describe("message-channel", () => { try { const channelModule = await import("./message-channel.js"); const promptModule = await import("../channels/plugins/native-approval-prompt.js"); - for (const channel of ["webchat", "discord", "imessage", "telegram", "whatsapp"]) { + for (const channel of ["webchat", "discord", "imessage", "qqbot", "telegram", "whatsapp"]) { expect(channelModule.isNativeApprovalChannel(channel), channel).toBe(true); } expect(promptModule.isKnownNativeApprovalPromptChannel("whatsapp")).toBe(true); + expect(promptModule.isKnownNativeApprovalPromptChannel("qqbot")).toBe(true); for (const channel of ["feishu", "msteams", "line", "heartbeat", "", "TELEGRAM"]) { expect(channelModule.isNativeApprovalChannel(channel), channel).toBe(false); } diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index 459cdb03a722..164668e2f428 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -348,6 +348,32 @@ describe("buildOfficialChannelCatalog", () => { "sha512-3GD+mf3EjTSUTOAREjTHAyp/deXdpgqB+q+xE0b19Qtat4ADhUV1mHDwFkVCRqTCBY5ATFKtKcipoDejqFj/+w==", }, }); + expect( + summarizeCatalogEntry( + findCatalogEntry(entries, (entry) => entry.name === "@tencent-connect/openclaw-qqbot"), + ), + ).toMatchObject({ + name: "@tencent-connect/openclaw-qqbot", + source: "external", + plugin: { + id: "openclaw-qqbot", + label: "QQ Bot", + }, + contracts: { + tools: ["qqbot_platform_api", "qqbot_remind"], + }, + channel: { + id: "qqbot", + docsPath: "/channels/qqbot", + approvalFlags: ["native"], + }, + install: { + npmSpec: "@tencent-connect/openclaw-qqbot@2.0.1", + defaultChoice: "npm", + expectedIntegrity: + "sha512-2010PaCummeQaxerLtaGfQ/5HChiXaW/KpTERid7V/1zyTs46S2ACi0hgZQ1SB7tH0t1InWr8tzVBJV/pLss3Q==", + }, + }); expect(entries.some((entry) => entry.openclaw?.channel?.id === "local-only")).toBe(false); }); @@ -487,6 +513,7 @@ describe("buildOfficialChannelCatalog", () => { }); expect(entries.find((entry) => entry.id === "wecom")?.docsPath).toBe("/channels/wecom"); expect(entries.find((entry) => entry.id === "yuanbao")?.docsPath).toBe("/channels/yuanbao"); + expect(entries.find((entry) => entry.id === "qqbot")?.source).toBe("official"); }); it("uses the canonical channel docs route when a manifest omits docsPath", () => { @@ -668,7 +695,7 @@ describe("buildOfficialChannelCatalog", () => { ); }); - it("keeps third-party official external catalog npm sources exactly pinned", () => { + it("keeps third-party official external catalog npm sources pinned unless they track latest", () => { const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-policy-"); const entries = buildOfficialChannelCatalog({ repoRoot }).entries.filter( (entry) => entry.source === "external" && !entry.name?.startsWith("@openclaw/"), diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 3e443705ad4d..c770d4050d9d 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -99,27 +99,7 @@ describe("plugin npm runtime build planning", () => { } }); - it("includes top-level public runtime surfaces and root-build-excluded plugins", () => { - const qqbotPlan = resolvePluginNpmRuntimeBuildPlan({ - repoRoot, - packageDir: path.join(repoRoot, "extensions", "qqbot"), - }); - const qqbotRuntimePlan = expectPluginNpmRuntimeBuildPlan(qqbotPlan); - expect(qqbotRuntimePlan.entry).toEqual({ - api: path.join(repoRoot, "extensions", "qqbot", "api.ts"), - "channel-entry-api": path.join(repoRoot, "extensions", "qqbot", "channel-entry-api.ts"), - "channel-plugin-api": path.join(repoRoot, "extensions", "qqbot", "channel-plugin-api.ts"), - "doctor-contract-api": path.join(repoRoot, "extensions", "qqbot", "doctor-contract-api.ts"), - index: path.join(repoRoot, "extensions", "qqbot", "index.ts"), - "runtime-api": path.join(repoRoot, "extensions", "qqbot", "runtime-api.ts"), - "secret-contract-api": path.join(repoRoot, "extensions", "qqbot", "secret-contract-api.ts"), - "setup-entry": path.join(repoRoot, "extensions", "qqbot", "setup-entry.ts"), - "setup-plugin-api": path.join(repoRoot, "extensions", "qqbot", "setup-plugin-api.ts"), - "tools-api": path.join(repoRoot, "extensions", "qqbot", "tools-api.ts"), - }); - expect(qqbotRuntimePlan.runtimeExtensions).toEqual(["./dist/index.js"]); - expect(qqbotRuntimePlan.runtimeSetupEntry).toBe("./dist/setup-entry.js"); - + it("includes top-level public runtime surfaces", () => { const diffsPlan = resolvePluginNpmRuntimeBuildPlan({ repoRoot, packageDir: path.join(repoRoot, "extensions", "diffs"), diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index 4f2fe6c0b574..e40f25cbbc22 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -183,7 +183,7 @@ describe("bundled plugin build entries", () => { expectSomePrefixMatch(Object.keys(entries), `extensions/${pluginId}/`); expectNoPrefixMatches(artifacts, `dist/extensions/${pluginId}/`); } - for (const pluginId of ["qqbot", "whatsapp"]) { + for (const pluginId of ["whatsapp"]) { expectNoPrefixMatches(Object.keys(entries), `extensions/${pluginId}/`); expectNoPrefixMatches(artifacts, `dist/extensions/${pluginId}/`); } @@ -298,12 +298,11 @@ describe("bundled plugin build entries", () => { const selectedEntries = listBundledPluginBuildEntries({ env: { ...baselineEnv, - [DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "whatsapp,qqbot", + [DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV]: "whatsapp", }, }); expect(selectedEntries).toEqual(baselineEntries); - expectNoPrefixMatches(Object.keys(selectedEntries), "extensions/qqbot/"); expectNoPrefixMatches(Object.keys(selectedEntries), "extensions/whatsapp/"); }); diff --git a/test/scripts/check-session-accessor-boundary.test.ts b/test/scripts/check-session-accessor-boundary.test.ts index c27e229c511c..95656a617066 100644 --- a/test/scripts/check-session-accessor-boundary.test.ts +++ b/test/scripts/check-session-accessor-boundary.test.ts @@ -137,7 +137,6 @@ describe("session accessor boundary guard", () => { "extensions/mattermost/src/mattermost/model-picker.ts", "extensions/matrix/src/matrix/monitor/handler.ts", "extensions/matrix/src/session-route.ts", - "extensions/qqbot/src/engine/group/activation.ts", "extensions/slack/src/monitor/slash.ts", "extensions/telegram/src/bot-core.ts", "extensions/telegram/src/bot-handlers.runtime.ts", diff --git a/test/scripts/plugin-gateway-gauntlet.test.ts b/test/scripts/plugin-gateway-gauntlet.test.ts index dd6c95a6e0b2..043059808e98 100644 --- a/test/scripts/plugin-gateway-gauntlet.test.ts +++ b/test/scripts/plugin-gateway-gauntlet.test.ts @@ -358,7 +358,6 @@ describe("plugin gateway gauntlet helpers", () => { it("skips source-only plugin dirs that are excluded from the built runtime", async () => { await writeManifest("qa-lab", "openclaw.plugin.json", JSON.stringify({ id: "qa-lab" })); - await writeManifest("qqbot", "openclaw.plugin.json", JSON.stringify({ id: "qqbot" })); await writeManifest("telegram", "openclaw.plugin.json", JSON.stringify({ id: "telegram" })); const matrix = discoverBundledPluginManifests(repoRoot); diff --git a/test/scripts/root-dependency-ownership-audit.test.ts b/test/scripts/root-dependency-ownership-audit.test.ts index 5bd1701cff2a..ae732765e253 100644 --- a/test/scripts/root-dependency-ownership-audit.test.ts +++ b/test/scripts/root-dependency-ownership-audit.test.ts @@ -129,19 +129,19 @@ describe("collectRootDependencyOwnershipCheckErrors", () => { ); writeRepoFile( repoRoot, - "extensions/qqbot/package.json", + "extensions/demo-channel/package.json", JSON.stringify({ dependencies: { "vendor-sdk": "^1.0.0" } }), ); writeRepoFile( repoRoot, - "extensions/qqbot/src/setup.ts", + "extensions/demo-channel/src/setup.ts", 'const sdk = await import("vendor-sdk");\n', ); const records = collectRootDependencyOwnershipAudit({ repoRoot, scanRoots: ["extensions"] }); expect(collectRootDependencyOwnershipCheckErrors(records)).toEqual([ - "root dependency 'vendor-sdk' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: qqbot:dependencies; sample imports: extensions/qqbot/src/setup.ts", + "root dependency 'vendor-sdk' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: demo-channel:dependencies; sample imports: extensions/demo-channel/src/setup.ts", ]); }); @@ -209,11 +209,11 @@ describe("collectRootDependencyOwnershipCheckErrors", () => { collectRootDependencyOwnershipCheckErrors([ { category: "extension_only_localizable", - declaredInExtensions: ["qqbot:dependencies"], - depName: "@tencent-connect/qqbot-connector", + declaredInExtensions: ["demo-channel:dependencies"], + depName: "vendor-sdk", recommendation: "remove from root package.json and rely on owning extension manifests plus doctor --fix", - sampleFiles: ["extensions/qqbot/src/bridge/setup/finalize.ts"], + sampleFiles: ["extensions/demo-channel/src/setup.ts"], }, { category: "unreferenced", @@ -224,7 +224,7 @@ describe("collectRootDependencyOwnershipCheckErrors", () => { }, ]), ).toEqual([ - "root dependency '@tencent-connect/qqbot-connector' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: qqbot:dependencies; sample imports: extensions/qqbot/src/bridge/setup/finalize.ts", + "root dependency 'vendor-sdk' is extension-owned (remove from root package.json and rely on owning extension manifests plus doctor --fix); extension declarations: demo-channel:dependencies; sample imports: extensions/demo-channel/src/setup.ts", ]); }); diff --git a/test/scripts/ts-guard-utils.test.ts b/test/scripts/ts-guard-utils.test.ts index 9e8ddbd4b915..62672ae6a949 100644 --- a/test/scripts/ts-guard-utils.test.ts +++ b/test/scripts/ts-guard-utils.test.ts @@ -32,7 +32,7 @@ describe("resolveRepoRoot", () => { it("resolves correctly from a deeply nested extension path", () => { const fakeUrl = pathToFileURL( - path.resolve("extensions", "qqbot", "src", "utils", "hypothetical.mjs"), + path.resolve("extensions", "telegram", "src", "utils", "hypothetical.mjs"), ).href; const root = resolveRepoRoot(fakeUrl); @@ -44,7 +44,7 @@ describe("resolveRepoRoot", () => { const fromLib = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "lib", "a.mjs")).href); const fromScripts = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "b.mjs")).href); const fromExtension = resolveRepoRoot( - pathToFileURL(path.resolve("extensions", "qqbot", "c.mjs")).href, + pathToFileURL(path.resolve("extensions", "telegram", "c.mjs")).href, ); expect(fromLib).toBe(fromScripts); diff --git a/test/scripts/verify-plugin-npm-published-runtime.test.ts b/test/scripts/verify-plugin-npm-published-runtime.test.ts index 0ffaeb69816d..26abf45fe010 100644 --- a/test/scripts/verify-plugin-npm-published-runtime.test.ts +++ b/test/scripts/verify-plugin-npm-published-runtime.test.ts @@ -276,7 +276,7 @@ describe("collectPluginNpmPublishedRuntimeErrors", () => { expect( collectPluginNpmPublishedRuntimeErrors({ packageJson: { - name: "@openclaw/qqbot", + name: "@openclaw/example-channel", version: "2026.5.3", openclaw: { extensions: ["./index.ts"], diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 6ee42242dd94..506f23d25698 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -822,7 +822,6 @@ describe("scoped vitest configs", () => { "googlechat/**/*.test.ts", "nextcloud-talk/**/*.test.ts", "nostr/**/*.test.ts", - "qqbot/**/*.test.ts", "synology-chat/**/*.test.ts", "tlon/**/*.test.ts", "twitch/**/*.test.ts", diff --git a/test/vitest/vitest.extension-messaging-paths.mjs b/test/vitest/vitest.extension-messaging-paths.mjs index eb61dccc474c..251d85dd2ff3 100644 --- a/test/vitest/vitest.extension-messaging-paths.mjs +++ b/test/vitest/vitest.extension-messaging-paths.mjs @@ -5,7 +5,6 @@ const messagingExtensionIds = [ "googlechat", "nextcloud-talk", "nostr", - "qqbot", "synology-chat", "tlon", "twitch", diff --git a/tsdown.config.ts b/tsdown.config.ts index 8e03a849e518..a4bfdae6f66b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -221,7 +221,6 @@ const explicitNeverBundleDependencies = [ "jimp", "matrix-js-sdk", "prism-media", - "qrcode-terminal", "sharp", "typescript", "vitest",