mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(qqbot): install plugin from Tencent package (#107295)
* refactor(qqbot): remove bundled extension source Mechanical deletion half of the #107295 squashed rebase; the catalog repoint and host integration land in the follow-up commit. Co-authored-by: sliverp <870080352@qq.com> * refactor(qqbot): install plugin from Tencent package Squashed rebase of #107295 onto current main. Repoints the official external channel catalog at @tencent-connect/openclaw-qqbot@2.0.1 and adapts onboarding, doctor migrations, secrets, build guards, and tests. Documents the known limitation that the external package does not support structured SecretRef clientSecret values; operators move those to QQBOT_CLIENT_SECRET or clientSecretFile before upgrading. Co-authored-by: sliverp <870080352@qq.com> * fix(doctor): reuse shared hasOwnKey record helper The rebased QQBot migration carried its own hasOwnKey export, colliding with the one main now ships in legacy-config-record-shared.ts. Co-authored-by: sliverp <870080352@qq.com> * fix(plugins): carry catalog integrity through the update bridge The externalized-bundled-plugin bridge dropped the official catalog's expectedIntegrity pin, so bundled-user updates installed the external npm package without integrity verification. The bridge now carries the pin for the catalog's exact npm spec and both bridge install calls pass it through; update-channel spec overrides intentionally skip the pin since it only covers the pinned version. Co-authored-by: sliverp <870080352@qq.com> * chore(plugin-sdk): refresh per-entrypoint API baselines The QQBot compat export and bundled-type removal shift 26 entrypoint closure hashes in the new split baseline layout. Co-authored-by: sliverp <870080352@qq.com> * refactor(qqbot): drop helper reintroduced during rebase Main's coercion consolidation added this file after the deletion commit's base; its only consumers were the removed qqbot sources. Co-authored-by: sliverp <870080352@qq.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -148,7 +148,6 @@
|
||||
"channel: qqbot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qqbot/**"
|
||||
- "docs/channels/qqbot.md"
|
||||
"channel: raft":
|
||||
- changed-files:
|
||||
|
||||
@@ -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/*)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"0cff92037a9d807ad0301e14dd319645f9e27336e2d07aa4613bce129f86a827","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
{"contentHash":"c017cdb95283e766f3455b2e5a1c828a7227db87ec82e5f346d94682292cbbf7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"02efab07a69f7899421c36ab7cee6dd2bd84ce2d689d10549129dc4dc45a1f15","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
{"contentHash":"5da887b8d2f6cb04bb968b1d78ebc6c18f3d9a6ccfd17cf7dd3f6ea6f9cfe3ca","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"2534a0f3e5bc7c6f7dd08cdc732f705660dbd5a71d992b81406ec0926054ed56","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
{"contentHash":"f04ae840aa03ecffd6a5c617a206aeb468ab7e2663f146dee15e82a859ab94ea","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"475a9e86021703425ca3a33e602011d8e26ab7525f082b4daa35bf9ef40effb1","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
{"contentHash":"20a70ea7ba78d5c618d82338e5901b73389313867458f67dbd95b3f6f91f4cbc","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"16f5001d40fb5196f43834fff175a4e20cd7fe16c3020828dea46bf3fe7a9312","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
{"contentHash":"736070b02d2196ce75af349e9e71edcf464c8d43a01bdfd70c1b61abfac3e85f","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"2df969326d93c12f73523987617ebc453577a3610c374eb20529aa78b58759a3","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"}
|
||||
{"contentHash":"b70eb302db749674e237eb2d740dacca2c20f883cd7f0581299cf010d3a71863","entrypoint":"config-mutation","importSpecifier":"openclaw/plugin-sdk/config-mutation"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"96df262c825f0096a8050df72f6391bfa8c399555d148491dfbdbd6c402fd63b","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
|
||||
{"contentHash":"84cac1377fdac6d6665de5b8264a95c17b79823063420b21eb31596c5af7611e","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"e0c691c494cf0d17c251a21fecc4c856bce1c76ea647ed8ba62287d000145671","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
{"contentHash":"511e572af05c931e92680e1a154c5705a3ef54b822db8990605403d9d5697e3f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"80d1dfc109596a642c6e54cc9a0755f6d9824067cfa069742ba7c94bb790b6b6","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
{"contentHash":"9ea9ae4e9fd1c35de0578f6c68722dd9c037430c8b5bb526d0f6266fdf7edb2c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"e513691f5c73165100a0a9b820f2c5da0611dc8e901525542fd6539dd3d9c255","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
{"contentHash":"46ded5c5593969f06e7d8fef58d6a1c0d20f7deff515a386d3d03d7423d8f0c1","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"7f10ab95ec2d34e84789f8f298ddc1abbd1c5bb2fe804b84af469c7707ec8824","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
{"contentHash":"f62643baa25a9c3102e2173269ea301024a844e32dbfb7c39d122eca47a562dc","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"77a500390fb4b5d465334b23fabb2f77c1ca9178a6e095a2340a4a950c7fece0","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
{"contentHash":"ecf8ac0427d503d3c2f5c174cee58d8aa47192e66bdda123f34546d02e5146b2","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"6697592146ce483645a562acf9a105b57541733ca631ccba638164256ac501a9","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
{"contentHash":"9ee8b93b462bd6869a15ad42fa97aab32bd0a5e012b61f2e75355b898f85dc5b","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"1b51ace8b1e469c12e17dfe749484bbfa624781917e346a431bfe76eb1c286c3","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"}
|
||||
{"contentHash":"7233a8bb04605b4022cb18569b93f062d4f7986ae5ab5dd09e6c655984b56542","entrypoint":"provider-auth","importSpecifier":"openclaw/plugin-sdk/provider-auth"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"contentHash":"519c2334419a6eb156e96ae99fca2a77821f60be3d8714a17062493591df50e4","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
{"contentHash":"5bb57a7c8f5f158c973540af418c5ea7df019622ad890e9f8741737e550c4109","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"contentHash":"1ada23abeeeec02cbd00d3274fb591b85269bd3098d911a390b363b1ba5eca5f","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
{"contentHash":"8f23b9a7a62f9587e1a7da7adfcc9c2b17f59c3f86e8c8da342797172d16f504","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
|
||||
|
||||
+5
-18
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
@@ -1,2 +0,0 @@
|
||||
// Narrow bridge entrypoint for qqbot registerFull composition.
|
||||
export { registerQQBotFull } from "./src/bridge/channel-entry.js";
|
||||
@@ -1,2 +0,0 @@
|
||||
// Qqbot API module exposes the plugin public contract.
|
||||
export { qqbotPlugin } from "./src/channel.js";
|
||||
@@ -1,2 +0,0 @@
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
|
||||
export { stateMigrations } from "./src/state-migrations.js";
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <appId:secret>",
|
||||
"description": "QQBot app id and client secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "tokenFile",
|
||||
"kind": "string",
|
||||
"sensitive": true,
|
||||
"cli": {
|
||||
"flags": "--token-file <path>",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,6 +0,0 @@
|
||||
// Qqbot API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
· 头像:
|
||||

|
||||
```
|
||||
|
||||
不要无关输出原始头像 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 图片语法 `` 展示
|
||||
@@ -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}`。
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: qqbot-media
|
||||
description: QQBot rich media send and receive support. Use <qqmedia> 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 富媒体收发
|
||||
|
||||
## 用法
|
||||
|
||||
```
|
||||
<qqmedia>{实际路径或URL}</qqmedia>
|
||||
```
|
||||
|
||||
系统根据文件扩展名自动识别类型并路由:
|
||||
|
||||
- `.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**:`<qqmedia>{实际路径或URL}</qqmedia>`
|
||||
2. **使用你实际看到的文件路径**:刚创建文件时,用创建结果显示的路径;只有当沙箱 workspace-write 创建结果实际显示 `/workspace/...` 时,才按原样使用该路径,例如 `<qqmedia>/workspace/report.pdf</qqmedia>`。
|
||||
3. **附件路径直接使用上下文给出的路径**:如果路径来自会话【附件】上下文,不要改写成 `/workspace/...`。
|
||||
4. **URL 可以直接发送**:例如 `<qqmedia>https://example.com/image.png</qqmedia>`。
|
||||
5. **本地路径仍受安全根限制**:只能发送当前会话授权的 agent workspace、scoped media roots、OpenClaw 媒体目录或 QQBot 媒体目录内的文件;不要使用 `..` 逃出工作区。
|
||||
6. **不要扫描或主动发送上下文之外的本地文件**:只使用用户提供、工具刚生成,或当前会话上下文明确给出的路径。
|
||||
7. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB
|
||||
8. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
|
||||
9. 发送语音时不要重复语音中已朗读的文字
|
||||
10. 多个媒体用多个标签
|
||||
11. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
|
||||
@@ -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"试试?`
|
||||
- 删除成功:`✅ 已取消"{名称}"`
|
||||
@@ -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"}
|
||||
@@ -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"}}}
|
||||
@@ -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"}
|
||||
@@ -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:\n<qqimg>https://example.com/chart.png</qqimg>\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:\n<qqimg>https://example.com/chart.png</qqimg>\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"}}}
|
||||
@@ -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"}}}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
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}\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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
// Built-in version delegates SSRF validation to readRemoteMediaBuffer's ssrfPolicy.
|
||||
},
|
||||
|
||||
async resolveSecret(value): Promise<string | undefined> {
|
||||
if (typeof value === "string") {
|
||||
return value || undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async downloadFile(url: string, destDir: string, filename?: string): Promise<string> {
|
||||
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<FetchMediaResult> {
|
||||
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<ApprovalResolveResult> {
|
||||
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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
commandAuthorized: ctx.isAuthorizedSender,
|
||||
groupCommandLevel,
|
||||
queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT },
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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:<kind>:<id>` (case-insensitive
|
||||
* prefix). We split that string once and map `<kind>` 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<FromKind, QQBotFromParseResult["msgType"]> = {
|
||||
c2c: "c2c",
|
||||
dm: "dm",
|
||||
group: "group",
|
||||
channel: "guild",
|
||||
};
|
||||
|
||||
const TARGET_TYPE_MAP: Record<FromKind, QQBotFromParseResult["targetType"]> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<FrameworkSlashReply> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
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<ResolvedQQBotAccount>({
|
||||
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 <appId:secret>", description: "QQBot app id and client secret" },
|
||||
},
|
||||
tokenFile: {
|
||||
kind: "string",
|
||||
sensitive: true,
|
||||
cli: { flags: "--token-file <path>", description: "QQBot client secret file" },
|
||||
},
|
||||
useEnv: {
|
||||
kind: "boolean",
|
||||
cli: { flags: "--use-env", description: "Use QQBOT environment credentials" },
|
||||
},
|
||||
},
|
||||
legacyAdapter: qqbotSetupAdapterShared,
|
||||
});
|
||||
@@ -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<string, unknown>);
|
||||
}
|
||||
|
||||
/** Resolve the default QQBot account ID. */
|
||||
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||
return resolveDefaultAccountId(cfg as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
accountId,
|
||||
input,
|
||||
) as OpenClawConfig;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, unknown>) =>
|
||||
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)}`),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
await runtime.config.replaceConfigFile({
|
||||
nextConfig: cfg,
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PluginRuntime>({
|
||||
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 };
|
||||
@@ -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<T>(map: Map<string, T[]>): Map<string, SdkHistoryEntry[]> {
|
||||
return map as unknown as Map<string, SdkHistoryEntry[]>;
|
||||
}
|
||||
|
||||
export function createSdkHistoryAdapter(): HistoryPort {
|
||||
return {
|
||||
recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
|
||||
historyMap: Map<string, T[]>;
|
||||
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<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
commandsAllowFrom?: Array<string | number> | null;
|
||||
}): Promise<boolean> {
|
||||
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<string | number> | 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;
|
||||
}
|
||||
@@ -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<typeof finalizeQQBotSetup>[0];
|
||||
|
||||
function createParams(beforePersistentEffect: () => Promise<void>): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
|
||||
type SetupRuntime = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["runtime"];
|
||||
type SetupOptions = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[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<void> {
|
||||
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<void>;
|
||||
}): Promise<OpenClawConfig> {
|
||||
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<OpenClawConfig> {
|
||||
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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return { ...actual, fetchWithSsrFGuard: fetchWithSsrFGuardMock };
|
||||
});
|
||||
|
||||
vi.mock("../../engine/messaging/sender.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../engine/messaging/sender.js")>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<OpenClawPluginApi["config"]>,
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
|
||||
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" });
|
||||
}
|
||||
@@ -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<void>>(() => new Promise<void>(() => {})),
|
||||
writeConfigMock: vi.fn<(runtime: unknown, cfg: unknown) => Promise<void>>(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<typeof import("./bridge/narrowing.js")>()),
|
||||
writeOpenClawConfigThroughRuntime: (runtime: unknown, cfg: unknown) =>
|
||||
writeConfigMock(runtime, cfg),
|
||||
}));
|
||||
|
||||
import { qqbotPlugin } from "./channel.js";
|
||||
|
||||
function makeAccount(overrides: Partial<ResolvedQQBotAccount>): ResolvedQQBotAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
appId: "",
|
||||
clientSecret: "",
|
||||
enabled: true,
|
||||
markdownSupport: true,
|
||||
secretSource: "none",
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function startAccount(account: ResolvedQQBotAccount, cfg: Record<string, unknown>) {
|
||||
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<void>(() => {}));
|
||||
});
|
||||
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void>(() => {}));
|
||||
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<NonNullable<typeof startAccount>>[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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type QQBotLogoutAccount = NonNullable<NonNullable<typeof qqbotPlugin.gateway>["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<ReturnType<QQBotLogoutAccount>>;
|
||||
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<string, unknown>
|
||||
>;
|
||||
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<string, unknown>;
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = "<thinking>internal reasoning</thinking>final answer";
|
||||
expect(sanitize({ text: input1, payload: { text: input1 } })).toBe("final answer");
|
||||
|
||||
const input2 = "<think>step by step</think>result";
|
||||
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<Buffer>;
|
||||
};
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
type SentMediaParams = {
|
||||
to?: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaAccess?: {
|
||||
localRoots?: readonly string[];
|
||||
workspaceDir?: string;
|
||||
readFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
function latestMockArg(mock: ReturnType<typeof vi.fn>, 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: "<qqmedia>/tmp/openclaw-sandbox/report.docx</qqmedia>",
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ResolvedQQBotAccount> = {
|
||||
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,
|
||||
};
|
||||
@@ -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<ReturnType<typeof sendQQBotText>>) {
|
||||
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<ResolvedQQBotAccount> = {
|
||||
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<string, unknown>,
|
||||
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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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:<id>" in channel.allowFrom matches the inbound event.senderId "<id>".
|
||||
* 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<boolean> {
|
||||
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<string, unknown> = {},
|
||||
): Promise<boolean> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
const accountConfig = expectedPath.reduce<unknown>((value, key) => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return (value as Record<string, unknown>)[key];
|
||||
}, next) as Record<string, unknown> | 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({});
|
||||
});
|
||||
});
|
||||
@@ -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<unknown> {
|
||||
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<string> {
|
||||
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<typeof import("./engine/api/routes.js")>();
|
||||
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<typeof import("./engine/api/media.js")>();
|
||||
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 = <T>(sendFn: (token: string) => Promise<T>) =>
|
||||
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:\n<qqimg>https://example.com/chart.png</qqimg>\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<Record<string, number>> = {
|
||||
"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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
expect(qqbot.voiceDirectUploadFormats).toBeUndefined();
|
||||
expect(qqbot.audioFormatPolicy).toStrictEqual({
|
||||
transcodeEnabled: false,
|
||||
uploadDirectFormats: [".mp3"],
|
||||
});
|
||||
const accounts = qqbot.accounts as Record<string, Record<string, unknown>>;
|
||||
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"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
const streaming = params.entry.streaming;
|
||||
const path = `${params.pathPrefix}.streaming`;
|
||||
if (typeof streaming === "boolean") {
|
||||
const next: Record<string, unknown> = 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.<id>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.groups.<id>.tools instead. Run "openclaw doctor --fix".',
|
||||
match: groupToolPolicyMigration.hasLegacy,
|
||||
},
|
||||
{
|
||||
path: ["channels", "qqbot", "accounts"],
|
||||
message:
|
||||
'channels.qqbot.accounts.<id>.groups.<groupId>.toolPolicy is legacy and was ignored by QQBot group tool enforcement; use channels.qqbot.accounts.<id>.groups.<groupId>.tools instead. Run "openclaw doctor --fix".',
|
||||
match: (value) =>
|
||||
hasLegacyAccountStreamingAliases(value, (account) =>
|
||||
groupToolPolicyMigration.hasLegacy(asObjectRecord(account)?.groups),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeQqbotEntry(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
// Qqbot plugin module implements resolve policy behavior.
|
||||
import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js";
|
||||
|
||||
export interface EffectivePolicyInput {
|
||||
allowFrom?: Array<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
dmPolicy?: QQBotDmPolicy | null;
|
||||
groupPolicy?: QQBotGroupPolicy | null;
|
||||
}
|
||||
|
||||
function hasRealRestriction(list: Array<string | number> | 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 };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user