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:
Sliverp
2026-08-12 06:10:27 +08:00
committed by GitHub
parent d035dba6c7
commit 84c7d45f15
379 changed files with 2996 additions and 47557 deletions
@@ -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
-1
View File
@@ -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/*)
+1 -1
View File
@@ -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 }}
-5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
{"contentHash":"e0c691c494cf0d17c251a21fecc4c856bce1c76ea647ed8ba62287d000145671","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"511e572af05c931e92680e1a154c5705a3ef54b822db8990605403d9d5697e3f","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-11
View File
@@ -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.
-57
View File
@@ -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";
-2
View File
@@ -1,2 +0,0 @@
// Narrow bridge entrypoint for qqbot registerFull composition.
export { registerQQBotFull } from "./src/bridge/channel-entry.js";
-2
View File
@@ -1,2 +0,0 @@
// Qqbot API module exposes the plugin public contract.
export { qqbotPlugin } from "./src/channel.js";
-2
View File
@@ -1,2 +0,0 @@
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
export { stateMigrations } from "./src/state-migrations.js";
-45
View File
@@ -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,
});
-178
View File
@@ -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" }
}
}
}
-106
View File
@@ -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
}
}
}
-10
View File
@@ -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";
-6
View File
@@ -1,6 +0,0 @@
// Qqbot API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
-14
View File
@@ -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",
},
});
-3
View File
@@ -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 | 否 | 请求体 JSONPOST/PUT/PATCH 使用) |
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
| `confirmed` | boolean | 否 | `DELETE` 必须传 `true`,表示用户已确认精确删除目标 |
| `bulkConfirmed` | boolean | 否 | 批量 `DELETE`(如删除全部公告)必须额外传 `true` |
> 基础 URL`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。
## 🛡️ 安全边界
- 只在用户明确要求管理 QQ 频道、子频道、公告、论坛帖子或日程时调用写入接口。
- `POST``PUT``PATCH``DELETE` 会修改真实 QQ 资源。调用前先复述目标频道/子频道/帖子/日程和预期改动;删除、批量删除、公告覆盖等不可逆或大范围操作必须等用户确认后再执行。
- 删除前优先用 `GET`/列表接口查出候选项,让用户选择具体 ID;不要根据模糊名称猜测删除目标。
- `DELETE` 请求必须传 `confirmed: true`,否则工具会拒绝执行。`announces/all` 这样的批量操作还必须传 `bulkConfirmed: true`,只有在用户明确说要删除全部公告并再次确认后才可使用。
- 成员资料、头像 URL、频道图标等属于用户/群组资料。默认只总结必要字段;只有用户要求查看头像/图标或视觉比对时才内联展示图片,不要无关转发头像 URL。
---
## ⭐ 接口速查
### 频道(Guild
| 操作 | 方法 | 路径 | 参数说明 |
| ----------------- | ----- | ----------------------------------- | ------------------------------------------ |
| 获取频道列表 | `GET` | `/users/@me/guilds` | query: `before`, `after`, `limit`(最大100) |
| 获取频道 API 权限 | `GET` | `/guilds/{guild_id}/api_permission` | — |
### 子频道(Channel
| 操作 | 方法 | 路径 | 参数说明 |
| -------------- | ------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — |
| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — |
| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` |
| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) |
| 删除子频道 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
**子频道类型(type**`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛
### 成员(Member
| 操作 | 方法 | 路径 | 参数说明 |
| ------------------ | ----- | -------------------------------------------- | --------------------------------------------- |
| 获取成员列表 | `GET` | `/guilds/{guild_id}/members` | query: `after`(首次填0), `limit`(1-400) |
| 获取成员详情 | `GET` | `/guilds/{guild_id}/members/{user_id}` | — |
| 获取身份组成员列表 | `GET` | `/guilds/{guild_id}/roles/{role_id}/members` | query: `start_index`(首次填0), `limit`(1-400) |
| 获取在线成员数 | `GET` | `/channels/{channel_id}/online_nums` | — |
### 公告(Announces
| 操作 | 方法 | 路径 | 参数说明 |
| -------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) |
| 删除公告 | — | 见受确认保护的删除流程 | 破坏性操作;批量删除需二次确认 |
### 论坛(Forum)— 仅私域机器人
| 操作 | 方法 | 路径 | 参数说明 |
| ------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — |
| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — |
| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) |
| 删除帖子 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` |
### 日程(Schedule
| 操作 | 方法 | 路径 | 参数说明 |
| -------- | ------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
| 删除日程 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
**提醒类型(remind_type**`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前
> `*` 表示必填参数
---
## 💡 调用示例
### 获取频道列表
```json
{
"method": "GET",
"path": "/users/@me/guilds",
"query": { "limit": "100" }
}
```
### 获取子频道列表
```json
{
"method": "GET",
"path": "/guilds/123456/channels"
}
```
### 创建子频道
```json
{
"method": "POST",
"path": "/guilds/123456/channels",
"body": {
"name": "新频道",
"type": 0,
"position": 1,
"sub_type": 0
}
}
```
### 获取成员列表(分页)
```json
{
"method": "GET",
"path": "/guilds/123456/members",
"query": { "after": "0", "limit": "100" }
}
```
### 发表论坛帖子
```json
{
"method": "PUT",
"path": "/channels/789012/threads",
"body": {
"title": "公告标题",
"content": "# 标题\n\n公告内容",
"format": 3
}
}
```
### 创建日程
```json
{
"method": "POST",
"path": "/channels/456789/schedules",
"body": {
"schedule": {
"name": "周会",
"start_timestamp": "1770733800000",
"end_timestamp": "1770737400000",
"remind_type": "2"
}
}
}
```
### 创建推荐子频道公告
```json
{
"method": "POST",
"path": "/guilds/123456/announces",
"body": {
"announces_type": 0,
"recommend_channels": [{ "channel_id": "789012", "introduce": "欢迎来到攻略频道" }]
}
}
```
### 受确认保护的删除流程
删除类 QQ API 不作为普通速查示例暴露。若用户明确要求删除资源,先读取并复述目标对象,确认后再调用 `qqbot_channel_api``method` 设为 `"DELETE"``confirmed` 设为 `true``path` 使用已确认对象对应的资源路径。
| 删除对象 | 已确认后使用的 `path` | 额外要求 |
| -------- | ------------------------------------------------ | ---------------------------------------- |
| 子频道 | `/channels/{channel_id}` | 确认子频道 ID 和名称 |
| 单条公告 | `/guilds/{guild_id}/announces/{message_id}` | 确认公告 ID |
| 全部公告 | `/guilds/{guild_id}/announces/all` | 用户再次确认后再传 `bulkConfirmed: true` |
| 帖子 | `/channels/{channel_id}/threads/{thread_id}` | 确认帖子 ID、标题/作者 |
| 日程 | `/channels/{channel_id}/schedules/{schedule_id}` | 确认日程 ID、名称/时间 |
---
## 🔄 常用操作流程
### 获取频道和子频道信息
```
1. GET /users/@me/guilds → 获取频道列表,拿到 guild_id
2. GET /guilds/{guild_id}/channels → 获取子频道列表,拿到 channel_id
3. GET /channels/{channel_id} → 获取子频道详情
```
### 论坛发帖 + 评论
```
1. GET /guilds/{guild_id}/channels → 找到论坛子频道(type=10007
2. PUT /channels/{channel_id}/threads → 发表帖子
3. GET /channels/{channel_id}/threads → 获取帖子列表
4. GET /channels/{channel_id}/threads/{thread_id} → 获取帖子详情(含 author_id)
5. POST /channels/{channel_id}/threads/{thread_id}/comment → 发表评论
```
### 成员管理
```
1. GET /users/@me/guilds → 获取 guild_id
2. GET /guilds/{guild_id}/members?after=0&limit=100 → 获取成员列表
翻页:用上次最后一个 user.id 作为 after,直到返回空数组
3. GET /guilds/{guild_id}/members/{user_id} → 获取指定成员详情
```
### 展示成员头像
成员详情返回的 `user.avatar` 是头像 URL。默认只展示昵称、ID、加入时间等必要字段;当用户明确要求查看头像/图标或头像是当前任务的必要依据时,再用 Markdown 图片语法内联展示:
```
成员信息:
· 昵称:{nick}
· 头像:
![头像]({user.avatar})
```
不要无关输出原始头像 URL 或把头像作为普通链接转发。频道的 `icon` 字段同理:仅在用户明确需要查看时展示。
---
## 🚨 错误码处理
| 错误码 | 说明 | 解决方案 |
| ---------- | ---------------- | ------------------------------------------------------------------------------------- |
| **401** | Token 鉴权失败 | 检查 AppID 和 ClientSecret 配置 |
| **11241** | 频道 API 无权限 | 前往 QQ 开放平台申请权限,或调用 `GET /guilds/{guild_id}/api_permission` 查看可用权限 |
| **11242** | 仅私域机器人可用 | 需在 QQ 开放平台将机器人切换为私域模式 |
| **11243** | 需要管理频道权限 | 确保机器人拥有管理权限 |
| **11281** | 日程频率限制 | 单管理员/天限 10 次,单频道/天限 100 次 |
| **304023** | 推荐子频道超限 | 推荐子频道最多 3 条 |
---
## ⚠️ 注意事项
1. **路径中的占位符**(如 `{guild_id}``{channel_id}`)必须替换为实际值
2. **query 参数的值必须为字符串类型**,如 `{ "limit": "100" }` 而非 `{ "limit": 100 }`
3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重
4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替
5. **日程**的时间戳为毫秒级字符串
6. **删除操作不可逆**,必须先确认精确目标并传 `confirmed: true`;批量删除需二次确认并传 `bulkConfirmed: true`
7. **论坛操作**仅私域机器人可用
8. **子频道分组**type=4)的 `position` 必须 >= 2
9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `![描述](URL)` 展示
@@ -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; // 是否待审核
}
```
### APIPermissionAPI 权限)
```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` | JSONRichText |
### 日程提醒类型(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 | 否 | 应用 AppIDtype=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![fake](u)";
view.agentId = "agent```fake";
const payload = (await qqbotApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {},
request: {
id: "approval-1",
request: { command: rawCommand, commandPreview: "short preview" },
createdAtMs: Date.now(),
expiresAtMs: view.expiresAtMs,
},
approvalKind: "exec",
nowMs: Date.now(),
view,
})) as QQBotPendingPayload;
expect(commandText).not.toContain(secret);
expect(commandText).toContain("\\u{200B}");
expect(commandText).toContain("\\u{A}");
expect(commandText).toContain("你好😀");
expect(payload.text.replace(/[↩\n]/g, "")).toContain(commandText);
expect(payload.text).not.toContain(secret);
expect(payload.text).not.toContain("short preview");
expect(payload.text).not.toContain("/tmp\n![fake]");
expect(payload.text).toContain("📁 目录:\n```\n/tmp\\u{A}![fake](u)\n```");
expect(payload.text).toContain("🤖 Agent:\n````\nagent```fake\n````");
expect(payload.keyboard.content.rows[0]?.buttons.map((button) => button.action.data)).toEqual([
"approve:v2:exec:approval-1:allow-once",
"approve:v2:exec:approval-1:deny",
]);
});
it("renders a plugin approval's actual remaining lifetime", async () => {
const nowMs = 1_000_000;
const view = createPluginView(nowMs + 600_000);
const payload = (await qqbotApprovalNativeRuntime.presentation.buildPendingPayload({
cfg: {} as never,
accountId: "default",
context: {},
request: {
id: view.approvalId,
request: {
title: "stale raw title",
description: "stale raw description",
severity: "info",
},
createdAtMs: nowMs,
expiresAtMs: view.expiresAtMs,
},
approvalKind: "plugin",
nowMs,
view,
})) as QQBotPendingPayload;
expect(payload.text).toContain("🔴 审批请求");
expect(payload.text).toContain("📋 Install plugin");
expect(payload.text).toContain("📝 Approve the requested plugin");
expect(payload.text).not.toContain("stale raw");
expect(payload.text).toContain("⏱️ 超时: 600 秒");
expect(payload.keyboard.content.rows[0]?.buttons.map((button) => button.action.data)).toEqual([
"approve:v2:plugin:plugin%3Aapproval-1:allow-once",
"approve:v2:plugin:plugin%3Aapproval-1:deny",
]);
});
});
@@ -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);
});
});
-146
View File
@@ -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,
});
-174
View File
@@ -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;
}
-106
View File
@@ -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();
});
});
-183
View File
@@ -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)}`),
};
}
-31
View File
@@ -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),
}
);
}
-32
View File
@@ -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;
}
-20
View File
@@ -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 };
-187
View File
@@ -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();
});
});
-187
View File
@@ -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);
});
});
-32
View File
@@ -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,
};
-505
View File
@@ -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,
}),
},
};
-113
View File
@@ -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);
});
});
-102
View File
@@ -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);
-587
View File
@@ -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({});
});
});
-475
View File
@@ -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"] } },
});
});
});
-177
View File
@@ -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,
});
}
-8
View File
@@ -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