Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
11
extensions/qqbot/README.md
Normal file
11
extensions/qqbot/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# OpenClaw QQ Bot
|
||||
|
||||
Official OpenClaw channel plugin for QQ Bot group and direct-message workflows.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugin add @openclaw/qqbot
|
||||
```
|
||||
|
||||
Configure QQ Bot credentials in OpenClaw, then connect the bot to the groups or direct-message contexts where agents should operate.
|
||||
57
extensions/qqbot/api.ts
Normal file
57
extensions/qqbot/api.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// 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
extensions/qqbot/channel-entry-api.ts
Normal file
2
extensions/qqbot/channel-entry-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Narrow bridge entrypoint for qqbot registerFull composition.
|
||||
export { registerQQBotFull } from "./src/bridge/channel-entry.js";
|
||||
2
extensions/qqbot/channel-plugin-api.ts
Normal file
2
extensions/qqbot/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Qqbot API module exposes the plugin public contract.
|
||||
export { qqbotPlugin } from "./src/channel.js";
|
||||
2
extensions/qqbot/doctor-contract-api.ts
Normal file
2
extensions/qqbot/doctor-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
|
||||
export { stateMigrations } from "./src/state-migrations.js";
|
||||
45
extensions/qqbot/index.ts
Normal file
45
extensions/qqbot/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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,
|
||||
});
|
||||
125
extensions/qqbot/npm-shrinkwrap.json
generated
Normal file
125
extensions/qqbot/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"name": "@openclaw/qqbot",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/qqbot",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@tencent-connect/qqbot-connector": "1.1.0",
|
||||
"mpg123-decoder": "1.0.3",
|
||||
"silk-wasm": "3.7.1",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@eshaz/web-worker": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@eshaz/web-worker/-/web-worker-1.2.2.tgz",
|
||||
"integrity": "sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tencent-connect/qqbot-connector": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-connector/-/qqbot-connector-1.1.0.tgz",
|
||||
"integrity": "sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"qrcode-terminal": "^0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@wasm-audio-decoders/common": {
|
||||
"version": "9.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/common/-/common-9.0.7.tgz",
|
||||
"integrity": "sha512-WRaUuWSKV7pkttBygml/a6dIEpatq2nnZGFIoPTc5yPLkxL6Wk4YaslPM98OPQvWacvNZ+Py9xROGDtrFBDzag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eshaz/web-worker": "1.2.2",
|
||||
"simple-yenc": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mpg123-decoder": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mpg123-decoder/-/mpg123-decoder-1.0.3.tgz",
|
||||
"integrity": "sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@wasm-audio-decoders/common": "9.0.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/eshaz"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-terminal": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
|
||||
"bin": {
|
||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
||||
}
|
||||
},
|
||||
"node_modules/silk-wasm": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/silk-wasm/-/silk-wasm-3.7.1.tgz",
|
||||
"integrity": "sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-yenc": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-yenc/-/simple-yenc-1.0.4.tgz",
|
||||
"integrity": "sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/eshaz"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
extensions/qqbot/openclaw.plugin.json
Normal file
193
extensions/qqbot/openclaw.plugin.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"id": "qqbot",
|
||||
"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"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"qqbot": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"]
|
||||
},
|
||||
"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"]
|
||||
},
|
||||
"provider": { "type": "string" },
|
||||
"id": { "type": "string" }
|
||||
},
|
||||
"required": ["source", "provider", "id"]
|
||||
},
|
||||
"secretInput": {
|
||||
"anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }]
|
||||
},
|
||||
"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" }
|
||||
},
|
||||
"systemPrompt": { "type": "string" },
|
||||
"markdownSupport": { "type": "boolean" },
|
||||
"voiceDirectUploadFormats": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" },
|
||||
"urlDirectUpload": { "type": "boolean" },
|
||||
"upgradeUrl": { "type": "string" },
|
||||
"upgradeMode": {
|
||||
"type": "string",
|
||||
"enum": ["doc", "hot-reload"]
|
||||
},
|
||||
"streaming": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["off", "partial"],
|
||||
"default": "partial"
|
||||
},
|
||||
"c2cStreamApi": {
|
||||
"type": "boolean",
|
||||
"description": "Use QQ C2C official stream_messages API (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" }
|
||||
},
|
||||
"systemPrompt": { "type": "string" },
|
||||
"markdownSupport": { "type": "boolean" },
|
||||
"voiceDirectUploadFormats": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" },
|
||||
"stt": { "$ref": "#/$defs/stt" },
|
||||
"urlDirectUpload": { "type": "boolean" },
|
||||
"upgradeUrl": { "type": "string" },
|
||||
"upgradeMode": {
|
||||
"type": "string",
|
||||
"enum": ["doc", "hot-reload"]
|
||||
},
|
||||
"streaming": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["off", "partial"],
|
||||
"default": "partial"
|
||||
},
|
||||
"c2cStreamApi": {
|
||||
"type": "boolean",
|
||||
"description": "Use QQ C2C official stream_messages API (single-message typing-style updates)."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"accounts": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/account"
|
||||
}
|
||||
},
|
||||
"defaultAccount": { "type": "string" },
|
||||
"groups": { "$ref": "#/$defs/groups" }
|
||||
}
|
||||
}
|
||||
}
|
||||
63
extensions/qqbot/package.json
Normal file
63
extensions/qqbot/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@openclaw/qqbot",
|
||||
"version": "2026.6.11",
|
||||
"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.1.0",
|
||||
"mpg123-decoder": "1.0.3",
|
||||
"silk-wasm": "3.7.1",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@types/ws": "8.18.1",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "qqbot",
|
||||
"label": "QQ Bot",
|
||||
"selectionLabel": "QQ Bot (Official API)",
|
||||
"detailLabel": "QQ Bot",
|
||||
"docsPath": "/channels/qqbot",
|
||||
"docsLabel": "qqbot",
|
||||
"blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.",
|
||||
"systemImage": "bubble.left.and.bubble.right"
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/qqbot",
|
||||
"localPath": "extensions/qqbot",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
10
extensions/qqbot/runtime-api.ts
Normal file
10
extensions/qqbot/runtime-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// 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
extensions/qqbot/secret-contract-api.ts
Normal file
6
extensions/qqbot/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Qqbot API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
14
extensions/qqbot/setup-entry.ts
Normal file
14
extensions/qqbot/setup-entry.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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
extensions/qqbot/setup-plugin-api.ts
Normal file
3
extensions/qqbot/setup-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// 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";
|
||||
275
extensions/qqbot/skills/qqbot-channel/SKILL.md
Normal file
275
extensions/qqbot/skills/qqbot-channel/SKILL.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
name: qqbot-channel
|
||||
description: QQ channel management skill. Use qqbot_channel_api for explicit QQ channel-management requests; confirm write, delete, and bulk actions before calling authenticated QQ Open Platform endpoints.
|
||||
metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qqbot"] } } }
|
||||
---
|
||||
|
||||
# QQ 频道 API 请求指导
|
||||
|
||||
`qqbot_channel_api` 是一个 QQ 开放平台 HTTP 代理工具,**自动填充鉴权 Token**。你只需要指定 HTTP 方法、API 路径、请求体和查询参数。
|
||||
|
||||
## 📚 详细参考文档
|
||||
|
||||
每个接口的完整参数说明、返回值结构和枚举值定义:
|
||||
|
||||
- `references/api_references.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 工具参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------------- | ------- | ---- | ---------------------------------------------------------------------------- |
|
||||
| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
|
||||
| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 |
|
||||
| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) |
|
||||
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
|
||||
| `confirmed` | boolean | 否 | `DELETE` 必须传 `true`,表示用户已确认精确删除目标 |
|
||||
| `bulkConfirmed` | boolean | 否 | 批量 `DELETE`(如删除全部公告)必须额外传 `true` |
|
||||
|
||||
> 基础 URL:`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。
|
||||
|
||||
## 🛡️ 安全边界
|
||||
|
||||
- 只在用户明确要求管理 QQ 频道、子频道、公告、论坛帖子或日程时调用写入接口。
|
||||
- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前先复述目标频道/子频道/帖子/日程和预期改动;删除、批量删除、公告覆盖等不可逆或大范围操作必须等用户确认后再执行。
|
||||
- 删除前优先用 `GET`/列表接口查出候选项,让用户选择具体 ID;不要根据模糊名称猜测删除目标。
|
||||
- `DELETE` 请求必须传 `confirmed: true`,否则工具会拒绝执行。`announces/all` 这样的批量操作还必须传 `bulkConfirmed: true`,只有在用户明确说要删除全部公告并再次确认后才可使用。
|
||||
- 成员资料、头像 URL、频道图标等属于用户/群组资料。默认只总结必要字段;只有用户要求查看头像/图标或视觉比对时才内联展示图片,不要无关转发头像 URL。
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 接口速查
|
||||
|
||||
### 频道(Guild)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| ----------------- | ----- | ----------------------------------- | ------------------------------------------ |
|
||||
| 获取频道列表 | `GET` | `/users/@me/guilds` | query: `before`, `after`, `limit`(最大100) |
|
||||
| 获取频道 API 权限 | `GET` | `/guilds/{guild_id}/api_permission` | — |
|
||||
|
||||
### 子频道(Channel)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------------- | ------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — |
|
||||
| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — |
|
||||
| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` |
|
||||
| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) |
|
||||
| 删除子频道 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
|
||||
|
||||
**子频道类型(type)**:`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛
|
||||
|
||||
### 成员(Member)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| ------------------ | ----- | -------------------------------------------- | --------------------------------------------- |
|
||||
| 获取成员列表 | `GET` | `/guilds/{guild_id}/members` | query: `after`(首次填0), `limit`(1-400) |
|
||||
| 获取成员详情 | `GET` | `/guilds/{guild_id}/members/{user_id}` | — |
|
||||
| 获取身份组成员列表 | `GET` | `/guilds/{guild_id}/roles/{role_id}/members` | query: `start_index`(首次填0), `limit`(1-400) |
|
||||
| 获取在线成员数 | `GET` | `/channels/{channel_id}/online_nums` | — |
|
||||
|
||||
### 公告(Announces)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) |
|
||||
| 删除公告 | — | 见受确认保护的删除流程 | 破坏性操作;批量删除需二次确认 |
|
||||
|
||||
### 论坛(Forum)— 仅私域机器人
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| ------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — |
|
||||
| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — |
|
||||
| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON,默认3) |
|
||||
| 删除帖子 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
|
||||
| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` |
|
||||
|
||||
### 日程(Schedule)
|
||||
|
||||
| 操作 | 方法 | 路径 | 参数说明 |
|
||||
| -------- | ------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
|
||||
| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
|
||||
| 删除日程 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
|
||||
|
||||
**提醒类型(remind_type)**:`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前
|
||||
|
||||
> `*` 表示必填参数
|
||||
|
||||
---
|
||||
|
||||
## 💡 调用示例
|
||||
|
||||
### 获取频道列表
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/users/@me/guilds",
|
||||
"query": { "limit": "100" }
|
||||
}
|
||||
```
|
||||
|
||||
### 获取子频道列表
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/guilds/123456/channels"
|
||||
}
|
||||
```
|
||||
|
||||
### 创建子频道
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/guilds/123456/channels",
|
||||
"body": {
|
||||
"name": "新频道",
|
||||
"type": 0,
|
||||
"position": 1,
|
||||
"sub_type": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 获取成员列表(分页)
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/guilds/123456/members",
|
||||
"query": { "after": "0", "limit": "100" }
|
||||
}
|
||||
```
|
||||
|
||||
### 发表论坛帖子
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/channels/789012/threads",
|
||||
"body": {
|
||||
"title": "公告标题",
|
||||
"content": "# 标题\n\n公告内容",
|
||||
"format": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建日程
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/channels/456789/schedules",
|
||||
"body": {
|
||||
"schedule": {
|
||||
"name": "周会",
|
||||
"start_timestamp": "1770733800000",
|
||||
"end_timestamp": "1770737400000",
|
||||
"remind_type": "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建推荐子频道公告
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/guilds/123456/announces",
|
||||
"body": {
|
||||
"announces_type": 0,
|
||||
"recommend_channels": [{ "channel_id": "789012", "introduce": "欢迎来到攻略频道" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 受确认保护的删除流程
|
||||
|
||||
删除类 QQ API 不作为普通速查示例暴露。若用户明确要求删除资源,先读取并复述目标对象,确认后再调用 `qqbot_channel_api`:`method` 设为 `"DELETE"`,`confirmed` 设为 `true`,`path` 使用已确认对象对应的资源路径。
|
||||
|
||||
| 删除对象 | 已确认后使用的 `path` | 额外要求 |
|
||||
| -------- | ------------------------------------------------ | ---------------------------------------- |
|
||||
| 子频道 | `/channels/{channel_id}` | 确认子频道 ID 和名称 |
|
||||
| 单条公告 | `/guilds/{guild_id}/announces/{message_id}` | 确认公告 ID |
|
||||
| 全部公告 | `/guilds/{guild_id}/announces/all` | 用户再次确认后再传 `bulkConfirmed: true` |
|
||||
| 帖子 | `/channels/{channel_id}/threads/{thread_id}` | 确认帖子 ID、标题/作者 |
|
||||
| 日程 | `/channels/{channel_id}/schedules/{schedule_id}` | 确认日程 ID、名称/时间 |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 常用操作流程
|
||||
|
||||
### 获取频道和子频道信息
|
||||
|
||||
```
|
||||
1. GET /users/@me/guilds → 获取频道列表,拿到 guild_id
|
||||
2. GET /guilds/{guild_id}/channels → 获取子频道列表,拿到 channel_id
|
||||
3. GET /channels/{channel_id} → 获取子频道详情
|
||||
```
|
||||
|
||||
### 论坛发帖 + 评论
|
||||
|
||||
```
|
||||
1. GET /guilds/{guild_id}/channels → 找到论坛子频道(type=10007)
|
||||
2. PUT /channels/{channel_id}/threads → 发表帖子
|
||||
3. GET /channels/{channel_id}/threads → 获取帖子列表
|
||||
4. GET /channels/{channel_id}/threads/{thread_id} → 获取帖子详情(含 author_id)
|
||||
5. POST /channels/{channel_id}/threads/{thread_id}/comment → 发表评论
|
||||
```
|
||||
|
||||
### 成员管理
|
||||
|
||||
```
|
||||
1. GET /users/@me/guilds → 获取 guild_id
|
||||
2. GET /guilds/{guild_id}/members?after=0&limit=100 → 获取成员列表
|
||||
翻页:用上次最后一个 user.id 作为 after,直到返回空数组
|
||||
3. GET /guilds/{guild_id}/members/{user_id} → 获取指定成员详情
|
||||
```
|
||||
|
||||
### 展示成员头像
|
||||
|
||||
成员详情返回的 `user.avatar` 是头像 URL。默认只展示昵称、ID、加入时间等必要字段;当用户明确要求查看头像/图标或头像是当前任务的必要依据时,再用 Markdown 图片语法内联展示:
|
||||
|
||||
```
|
||||
成员信息:
|
||||
· 昵称:{nick}
|
||||
· 头像:
|
||||

|
||||
```
|
||||
|
||||
不要无关输出原始头像 URL 或把头像作为普通链接转发。频道的 `icon` 字段同理:仅在用户明确需要查看时展示。
|
||||
|
||||
---
|
||||
|
||||
## 🚨 错误码处理
|
||||
|
||||
| 错误码 | 说明 | 解决方案 |
|
||||
| ---------- | ---------------- | ------------------------------------------------------------------------------------- |
|
||||
| **401** | Token 鉴权失败 | 检查 AppID 和 ClientSecret 配置 |
|
||||
| **11241** | 频道 API 无权限 | 前往 QQ 开放平台申请权限,或调用 `GET /guilds/{guild_id}/api_permission` 查看可用权限 |
|
||||
| **11242** | 仅私域机器人可用 | 需在 QQ 开放平台将机器人切换为私域模式 |
|
||||
| **11243** | 需要管理频道权限 | 确保机器人拥有管理权限 |
|
||||
| **11281** | 日程频率限制 | 单管理员/天限 10 次,单频道/天限 100 次 |
|
||||
| **304023** | 推荐子频道超限 | 推荐子频道最多 3 条 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **路径中的占位符**(如 `{guild_id}`、`{channel_id}`)必须替换为实际值
|
||||
2. **query 参数的值必须为字符串类型**,如 `{ "limit": "100" }` 而非 `{ "limit": 100 }`
|
||||
3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重
|
||||
4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替
|
||||
5. **日程**的时间戳为毫秒级字符串
|
||||
6. **删除操作不可逆**,必须先确认精确目标并传 `confirmed: true`;批量删除需二次确认并传 `bulkConfirmed: true`
|
||||
7. **论坛操作**仅私域机器人可用
|
||||
8. **子频道分组**(type=4)的 `position` 必须 >= 2
|
||||
9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次
|
||||
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `` 展示
|
||||
@@ -0,0 +1,529 @@
|
||||
# QQ 频道 API 完整参考
|
||||
|
||||
本文档包含 QQ 开放平台频道相关所有接口的详细参数说明、返回值结构和枚举值定义。
|
||||
|
||||
通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。
|
||||
|
||||
## 调用安全规则
|
||||
|
||||
- `POST`、`PUT`、`PATCH` 和 `DELETE` 会修改真实 QQ 资源。调用前确认用户明确授权了该操作。
|
||||
- 删除接口不可逆。删除前先用读取接口确认目标 ID、名称和范围,并把将要删除的对象复述给用户;`qqbot_channel_api` 要求 `confirmed: true` 才会执行 `DELETE`。
|
||||
- 批量删除 sentinel 必须二次确认并额外传 `bulkConfirmed: true`;不要把模糊表达自动扩展成“删除全部”。
|
||||
- 删除端点不作为普通 agent 速查路径列出。需要删除时,先用读取接口确认对象,再通过受确认保护的删除流程执行。
|
||||
- 成员资料和头像 URL 只用于当前请求;除非用户明确要求查看头像/图标,不要内联展示或转发这些图片 URL。
|
||||
|
||||
---
|
||||
|
||||
## 📌 通用说明
|
||||
|
||||
### 基础 URL
|
||||
|
||||
`https://api.sgroup.qq.com`
|
||||
|
||||
### 鉴权(自动处理)
|
||||
|
||||
工具自动填充以下请求头,无需手动设置:
|
||||
|
||||
```
|
||||
Authorization: QQBot {access_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 错误返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "错误描述",
|
||||
"code": 错误码
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 返回值类型定义
|
||||
|
||||
### Guild(频道)
|
||||
|
||||
```typescript
|
||||
interface Guild {
|
||||
id: string; // 频道 ID
|
||||
name: string; // 频道名称
|
||||
icon: string; // 频道头像 URL
|
||||
owner_id: string; // 频道拥有者 ID
|
||||
owner: boolean; // 机器人是否为频道拥有者
|
||||
joined_at: string; // 机器人加入时间(ISO 8601)
|
||||
member_count: number; // 频道成员数
|
||||
max_members: number; // 频道最大成员数
|
||||
description: string; // 频道描述
|
||||
}
|
||||
```
|
||||
|
||||
### Channel(子频道)
|
||||
|
||||
```typescript
|
||||
interface Channel {
|
||||
id: string; // 子频道 ID
|
||||
guild_id: string; // 所属频道 ID
|
||||
name: string; // 子频道名称
|
||||
type: number; // 子频道类型(见枚举)
|
||||
position: number; // 排序位置
|
||||
parent_id: string; // 所属分组 ID
|
||||
owner_id: string; // 创建者 ID
|
||||
sub_type: number; // 子类型(见枚举)
|
||||
private_type?: number; // 私密类型(见枚举)
|
||||
speak_permission?: number; // 发言权限(见枚举)
|
||||
application_id?: string; // 应用子频道 AppID
|
||||
}
|
||||
```
|
||||
|
||||
### User(用户)
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string; // 用户 ID
|
||||
username: string; // 用户名
|
||||
avatar: string; // 头像 URL
|
||||
bot: boolean; // 是否为机器人
|
||||
union_openid?: string; // 特殊关联应用的 openid
|
||||
union_user_account?: string; // 特殊关联应用的用户信息
|
||||
}
|
||||
```
|
||||
|
||||
### Member(成员)
|
||||
|
||||
```typescript
|
||||
interface Member {
|
||||
user: User; // 用户基本信息
|
||||
nick: string; // 在频道中的昵称
|
||||
roles: string[]; // 身份组 ID 列表
|
||||
joined_at: string; // 加入频道时间(ISO 8601)
|
||||
deaf?: boolean; // 是否被禁言
|
||||
mute?: boolean; // 是否被闭麦
|
||||
pending?: boolean; // 是否待审核
|
||||
}
|
||||
```
|
||||
|
||||
### APIPermission(API 权限)
|
||||
|
||||
```typescript
|
||||
interface APIPermission {
|
||||
path: string; // 接口路径
|
||||
method: string; // 请求方法
|
||||
desc: string; // 接口描述
|
||||
auth_status: number; // 授权状态:0=未授权, 1=已授权
|
||||
}
|
||||
```
|
||||
|
||||
### AnnouncesResult(公告结果)
|
||||
|
||||
```typescript
|
||||
interface AnnouncesResult {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
message_id: string;
|
||||
announces_type: number;
|
||||
recommend_channels: RecommendChannel[];
|
||||
}
|
||||
|
||||
interface RecommendChannel {
|
||||
channel_id: string; // 推荐的子频道 ID
|
||||
introduce: string; // 推荐语
|
||||
}
|
||||
```
|
||||
|
||||
### ThreadDetail(帖子详情)
|
||||
|
||||
```typescript
|
||||
interface ThreadDetail {
|
||||
thread: {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
thread_info: {
|
||||
thread_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
date_time: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ThreadListResult(帖子列表)
|
||||
|
||||
```typescript
|
||||
interface ThreadListResult {
|
||||
threads: Array<{
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
thread_info: {
|
||||
thread_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
date_time: string;
|
||||
};
|
||||
}>;
|
||||
is_finish: number; // 1=已到底, 0=还有更多
|
||||
}
|
||||
```
|
||||
|
||||
### Schedule(日程)
|
||||
|
||||
```typescript
|
||||
interface Schedule {
|
||||
id?: string;
|
||||
name: string;
|
||||
start_timestamp: string; // 毫秒级时间戳
|
||||
end_timestamp: string;
|
||||
jump_channel_id?: string;
|
||||
remind_type?: string;
|
||||
creator?: {
|
||||
user: { id: string; username: string; bot: boolean };
|
||||
nick: string;
|
||||
joined_at: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 枚举值定义
|
||||
|
||||
### 子频道类型(Channel type)
|
||||
|
||||
| 值 | 名称 | 说明 |
|
||||
| ------- | ---------- | -------------------------------- |
|
||||
| `0` | 文字子频道 | 普通文字聊天 |
|
||||
| `2` | 语音子频道 | 语音聊天 |
|
||||
| `4` | 子频道分组 | 组织子频道的分组(position ≥ 2) |
|
||||
| `10005` | 直播子频道 | 直播功能 |
|
||||
| `10006` | 应用子频道 | 需 application_id |
|
||||
| `10007` | 论坛子频道 | 论坛功能 |
|
||||
|
||||
### 子频道子类型(Channel sub_type)
|
||||
|
||||
| 值 | 名称 |
|
||||
| --- | ---- |
|
||||
| `0` | 闲聊 |
|
||||
| `1` | 公告 |
|
||||
| `2` | 攻略 |
|
||||
| `3` | 开黑 |
|
||||
|
||||
### 子频道私密类型(Channel private_type)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | -------------------- |
|
||||
| `0` | 公开子频道 |
|
||||
| `1` | 管理员和指定成员可见 |
|
||||
| `2` | 仅管理员可见 |
|
||||
|
||||
### 子频道发言权限(Channel speak_permission)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | ------------------------------------------ |
|
||||
| `0` | 无效(仅创建公告子频道时有效,此时为只读) |
|
||||
| `1` | 所有人可发言 |
|
||||
| `2` | 仅管理员和指定成员可发言 |
|
||||
|
||||
### 公告类型(announces_type)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | -------- |
|
||||
| `0` | 成员公告 |
|
||||
| `1` | 欢迎公告 |
|
||||
|
||||
### 帖子格式(format)
|
||||
|
||||
| 值 | 格式 |
|
||||
| --- | -------------------- |
|
||||
| `1` | 纯文本 |
|
||||
| `2` | HTML |
|
||||
| `3` | Markdown(**默认**) |
|
||||
| `4` | JSON(RichText) |
|
||||
|
||||
### 日程提醒类型(remind_type)
|
||||
|
||||
| 值 | 说明 |
|
||||
| ----- | -------------- |
|
||||
| `"0"` | 不提醒 |
|
||||
| `"1"` | 开始时提醒 |
|
||||
| `"2"` | 开始前 5 分钟 |
|
||||
| `"3"` | 开始前 15 分钟 |
|
||||
| `"4"` | 开始前 30 分钟 |
|
||||
| `"5"` | 开始前 60 分钟 |
|
||||
|
||||
### API 权限授权状态(auth_status)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | ------ |
|
||||
| `0` | 未授权 |
|
||||
| `1` | 已授权 |
|
||||
|
||||
---
|
||||
|
||||
## 📖 各接口详细说明
|
||||
|
||||
### GET /users/@me/guilds — 获取频道列表
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------ | ---- | ---------------------------------------------------- |
|
||||
| `before` | string | 否 | 读此 guild id 之前的数据 |
|
||||
| `after` | string | 否 | 读此 guild id 之后的数据(与 before 同时设置时无效) |
|
||||
| `limit` | string | 否 | 每次拉取条数,默认 100,最大 100 |
|
||||
|
||||
**返回**: `Guild[]`
|
||||
|
||||
**调用示例**:
|
||||
|
||||
```json
|
||||
{ "method": "GET", "path": "/users/@me/guilds", "query": { "limit": "100" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/api_permission — 获取频道 API 权限
|
||||
|
||||
**返回**: `{ apis: APIPermission[] }`
|
||||
|
||||
**调用示例**:
|
||||
|
||||
```json
|
||||
{ "method": "GET", "path": "/guilds/123456/api_permission" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/channels — 获取子频道列表
|
||||
|
||||
**返回**: `Channel[]`
|
||||
|
||||
**调用示例**:
|
||||
|
||||
```json
|
||||
{ "method": "GET", "path": "/guilds/123456/channels" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /channels/{channel_id} — 获取子频道详情
|
||||
|
||||
**返回**: `Channel`
|
||||
|
||||
---
|
||||
|
||||
### POST /guilds/{guild_id}/channels — 创建子频道
|
||||
|
||||
> ⚠️ 仅私域机器人可用,需管理频道权限
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------ | -------- | ---- | ------------------------------------- |
|
||||
| `name` | string | 是 | 子频道名称 |
|
||||
| `type` | number | 是 | 子频道类型 |
|
||||
| `position` | number | 是 | 排序位置(type=4 时 ≥ 2) |
|
||||
| `sub_type` | number | 否 | 子类型 |
|
||||
| `parent_id` | string | 否 | 所属分组 ID |
|
||||
| `private_type` | number | 否 | 私密类型 |
|
||||
| `private_user_ids` | string[] | 否 | 私密成员列表(private_type=1 时有效) |
|
||||
| `speak_permission` | number | 否 | 发言权限 |
|
||||
| `application_id` | string | 否 | 应用 AppID(type=10006 时需要) |
|
||||
|
||||
**返回**: `Channel`
|
||||
|
||||
---
|
||||
|
||||
### PATCH /channels/{channel_id} — 修改子频道
|
||||
|
||||
> ⚠️ 仅私域机器人可用
|
||||
|
||||
**请求体**(至少一个):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ------------------ | ------ | -------- |
|
||||
| `name` | string | 名称 |
|
||||
| `position` | number | 排序位置 |
|
||||
| `parent_id` | string | 分组 ID |
|
||||
| `private_type` | number | 私密类型 |
|
||||
| `speak_permission` | number | 发言权限 |
|
||||
|
||||
**返回**: `Channel`
|
||||
|
||||
---
|
||||
|
||||
### 删除子频道(破坏性操作)
|
||||
|
||||
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体子频道 ID、子频道名称和用户删除意图,并传 `confirmed: true`;不要按模糊名称猜测删除目标。确认后使用子频道资源路径 `/channels/{channel_id}`。
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/members — 获取成员列表
|
||||
|
||||
> 仅私域机器人可用
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ------- | ------ | ---------------------------------- |
|
||||
| `after` | string | 上次最后一个 user.id,首次填 `"0"` |
|
||||
| `limit` | string | 分页大小 1-400,默认 1 |
|
||||
|
||||
**返回**: `Member[]`
|
||||
|
||||
> 翻页:用最后一个 `user.id` 作为 `after`,直到返回空数组。可能返回重复成员,需按 `user.id` 去重。
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/members/{user_id} — 获取成员详情
|
||||
|
||||
**返回**: `Member`
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/roles/{role_id}/members — 获取身份组成员列表
|
||||
|
||||
> 仅私域机器人可用
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ------------- | ------ | ---------------------- |
|
||||
| `start_index` | string | 分页标识,首次填 `"0"` |
|
||||
| `limit` | string | 分页大小 1-400,默认 1 |
|
||||
|
||||
**返回**: `{ data: Member[], next: string }`
|
||||
|
||||
> 翻页:用 `next` 作为 `start_index`,直到 `data` 为空。
|
||||
|
||||
---
|
||||
|
||||
### GET /channels/{channel_id}/online_nums — 获取在线成员数
|
||||
|
||||
**返回**: `{ online_nums: number }`
|
||||
|
||||
---
|
||||
|
||||
### POST /guilds/{guild_id}/announces — 创建频道公告
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------------------- | ------ | ---- | --------------------------------------------------- |
|
||||
| `message_id` | string | 否 | 消息 ID(有值时创建消息公告,此时 channel_id 必填) |
|
||||
| `channel_id` | string | 否 | 子频道 ID |
|
||||
| `announces_type` | number | 否 | 0=成员公告,1=欢迎公告 |
|
||||
| `recommend_channels` | array | 否 | 推荐子频道列表(最多 3 条,message_id 为空时生效) |
|
||||
|
||||
> 两种公告类型会互相顶替
|
||||
|
||||
**返回**: `AnnouncesResult`
|
||||
|
||||
---
|
||||
|
||||
### 删除公告(破坏性操作)
|
||||
|
||||
> 调用前必须确认具体公告 ID 并传 `confirmed: true`,确认后使用公告资源路径 `/guilds/{guild_id}/announces/{message_id}`。批量删除全部公告只能在用户明确要求并再次确认后使用 `/guilds/{guild_id}/announces/all`,并且必须额外传 `bulkConfirmed: true`。
|
||||
|
||||
---
|
||||
|
||||
### GET /channels/{channel_id}/threads — 获取帖子列表
|
||||
|
||||
> 仅私域机器人可用,channel_id 须为论坛子频道(type=10007)
|
||||
|
||||
**返回**: `ThreadListResult`
|
||||
|
||||
---
|
||||
|
||||
### GET /channels/{channel_id}/threads/{thread_id} — 获取帖子详情
|
||||
|
||||
> 仅私域机器人可用
|
||||
|
||||
**返回**: `ThreadDetail`
|
||||
|
||||
---
|
||||
|
||||
### PUT /channels/{channel_id}/threads — 发表帖子
|
||||
|
||||
> 仅私域机器人可用
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | ------------------------------------------ |
|
||||
| `title` | string | 是 | 帖子标题 |
|
||||
| `content` | string | 是 | 帖子内容 |
|
||||
| `format` | number | 否 | 1=文本, 2=HTML, 3=Markdown(默认), 4=JSON |
|
||||
|
||||
**返回**: `{ task_id: string, create_time: string }`
|
||||
|
||||
---
|
||||
|
||||
### 删除帖子(破坏性操作)
|
||||
|
||||
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体帖子 ID、帖子标题/作者和用户删除意图,并传 `confirmed: true`。确认后使用帖子资源路径 `/channels/{channel_id}/threads/{thread_id}`。
|
||||
|
||||
---
|
||||
|
||||
### POST /channels/{channel_id}/threads/{thread_id}/comment — 发表评论
|
||||
|
||||
> 仅私域机器人可用
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------------------- | ------ | ---- | ------------ |
|
||||
| `thread_author` | string | 是 | 帖子作者 ID |
|
||||
| `content` | string | 是 | 评论内容 |
|
||||
| `thread_create_time` | string | 否 | 帖子创建时间 |
|
||||
| `image` | string | 否 | 图片链接 |
|
||||
|
||||
**返回**: `{ task_id: string, create_time: number }`
|
||||
|
||||
---
|
||||
|
||||
### POST /channels/{channel_id}/schedules — 创建日程
|
||||
|
||||
> 需要管理频道权限。单管理员/天限 10 次,单频道/天限 100 次。
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"schedule": {
|
||||
"name": "日程名称",
|
||||
"start_timestamp": "毫秒时间戳",
|
||||
"end_timestamp": "毫秒时间戳",
|
||||
"jump_channel_id": "0",
|
||||
"remind_type": "0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------------------------- | ------ | ---- | ------------------------- |
|
||||
| `schedule.name` | string | 是 | 日程名称 |
|
||||
| `schedule.start_timestamp` | string | 是 | 开始时间(毫秒) |
|
||||
| `schedule.end_timestamp` | string | 是 | 结束时间(毫秒) |
|
||||
| `schedule.jump_channel_id` | string | 否 | 跳转子频道 ID,默认 `"0"` |
|
||||
| `schedule.remind_type` | string | 否 | 提醒类型,默认 `"0"` |
|
||||
|
||||
**返回**: `Schedule`
|
||||
|
||||
---
|
||||
|
||||
### PATCH /channels/{channel_id}/schedules/{schedule_id} — 修改日程
|
||||
|
||||
> 需要管理频道权限
|
||||
|
||||
**请求体**:同创建日程
|
||||
|
||||
**返回**: `Schedule`
|
||||
|
||||
---
|
||||
|
||||
### 删除日程(破坏性操作)
|
||||
|
||||
> ⚠️ 不可逆!需要管理频道权限。调用前必须确认具体日程 ID、日程名称/时间和用户删除意图,并传 `confirmed: true`。确认后使用日程资源路径 `/channels/{channel_id}/schedules/{schedule_id}`。
|
||||
43
extensions/qqbot/skills/qqbot-media/SKILL.md
Normal file
43
extensions/qqbot/skills/qqbot-media/SKILL.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
|
||||
154
extensions/qqbot/skills/qqbot-remind/SKILL.md
Normal file
154
extensions/qqbot/skills/qqbot-remind/SKILL.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
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分钟后提醒你喝水~`
|
||||
|
||||
---
|
||||
|
||||
## 备用方案(直接使用 `cron` 工具)
|
||||
|
||||
> 仅当 `qqbot_remind` 工具不可用但 `cron` 工具可用时使用以下方式。
|
||||
|
||||
### 核心规则
|
||||
|
||||
> **payload.kind 必须是 `"agentTurn"`,绝对不能用 `"systemEvent"`!**
|
||||
> `systemEvent` 只在 AI 会话内部注入文本,用户收不到 QQ 消息。
|
||||
|
||||
**不可更改字段**:
|
||||
|
||||
| 字段 | 固定值 | 原因 |
|
||||
| -------------------- | ------------- | ---------------------------- |
|
||||
| `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
|
||||
| `delivery.mode` | `"announce"` | 主动投递模式 |
|
||||
| `delivery.channel` | `"qqbot"` | QQ 通道标识 |
|
||||
| `delivery.to` | 目标地址 | 从当前会话上下文获取 |
|
||||
| `delivery.accountId` | 当前账户 ID | 多账号场景下不可省略 |
|
||||
| `sessionTarget` | `"isolated"` | 隔离会话避免污染 |
|
||||
|
||||
> `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。
|
||||
> 计算方式:`当前时间戳ms + 延迟毫秒`。
|
||||
|
||||
### 一次性提醒(schedule.kind = "at")
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"job": {
|
||||
"name": "{任务名}",
|
||||
"schedule": { "kind": "at", "atMs": "{当前时间戳ms + N*60000}" },
|
||||
"sessionTarget": "isolated",
|
||||
"wakeMode": "now",
|
||||
"deleteAfterRun": true,
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "announce",
|
||||
"channel": "qqbot",
|
||||
"to": "qqbot:c2c:{openid}",
|
||||
"accountId": "{accountId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 周期提醒(schedule.kind = "cron")
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"job": {
|
||||
"name": "{任务名}",
|
||||
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "{用户确认的 IANA 时区}" },
|
||||
"sessionTarget": "isolated",
|
||||
"wakeMode": "now",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀"
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "announce",
|
||||
"channel": "qqbot",
|
||||
"to": "qqbot:c2c:{openid}",
|
||||
"accountId": "{accountId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 周期任务**不加** `deleteAfterRun`。群聊 `delivery.to` 格式为 `"qqbot:group:{group_openid}"`。
|
||||
|
||||
---
|
||||
|
||||
## 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"试试?`
|
||||
- 删除成功:`✅ 已取消"{名称}"`
|
||||
213
extensions/qqbot/src/bridge/approval/capability.ts
Normal file
213
extensions/qqbot/src/bridge/approval/capability.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
204
extensions/qqbot/src/bridge/approval/handler-runtime.ts
Normal file
204
extensions/qqbot/src/bridge/approval/handler-runtime.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 isExecRequest(request: ApprovalRequest): request is ExecApprovalRequest {
|
||||
return "expiresAtMs" in request;
|
||||
}
|
||||
|
||||
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: ({ request, view }) => {
|
||||
const req = request as ApprovalRequest;
|
||||
const text = isExecRequest(req) ? buildExecApprovalText(req) : buildPluginApprovalText(req);
|
||||
const keyboard = buildApprovalKeyboard(
|
||||
req.id,
|
||||
view.actions.map((action) => action.decision),
|
||||
);
|
||||
getBridgeLogger().debug?.(
|
||||
`[qqbot:approval-runtime] buildPendingPayload requestId=${req.id} kind=${
|
||||
isExecRequest(req) ? "exec" : "plugin"
|
||||
}`,
|
||||
);
|
||||
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;
|
||||
140
extensions/qqbot/src/bridge/bootstrap.ts
Normal file
140
extensions/qqbot/src/bridge/bootstrap.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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 { createLazyRuntimeModule } 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";
|
||||
import { getBridgeLogger } from "./logger.js";
|
||||
|
||||
const loadMediaRuntimeModule = createLazyRuntimeModule(
|
||||
() => import("openclaw/plugin-sdk/media-runtime"),
|
||||
);
|
||||
|
||||
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 loadMediaRuntimeModule();
|
||||
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 loadMediaRuntimeModule();
|
||||
const result = await readRemoteMediaBuffer({
|
||||
url: options.url,
|
||||
filePathHint: options.filePathHint,
|
||||
maxBytes: options.maxBytes,
|
||||
maxRedirects: options.maxRedirects,
|
||||
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(approvalId: string, decision: string): Promise<boolean> {
|
||||
try {
|
||||
const { getRuntimeConfig } = await import("openclaw/plugin-sdk/runtime-config-snapshot");
|
||||
const { resolveApprovalOverGateway } =
|
||||
await import("openclaw/plugin-sdk/approval-gateway-runtime");
|
||||
const cfg = getRuntimeConfig();
|
||||
await resolveApprovalOverGateway({
|
||||
cfg,
|
||||
approvalId,
|
||||
decision: decision as "allow-once" | "allow-always" | "deny",
|
||||
clientDisplayName: "QQBot Approval Handler",
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
getBridgeLogger().error(`[qqbot] resolveApproval failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
18
extensions/qqbot/src/bridge/channel-entry.ts
Normal file
18
extensions/qqbot/src/bridge/channel-entry.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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: false,
|
||||
accounts: {
|
||||
default: {
|
||||
allowFrom: ["TRUSTED_OPENID"],
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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).toBe(true);
|
||||
expect(qqbot?.accounts?.default?.streaming).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
87
extensions/qqbot/src/bridge/commands/from-parser.test.ts
Normal file
87
extensions/qqbot/src/bridge/commands/from-parser.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
60
extensions/qqbot/src/bridge/commands/from-parser.ts
Normal file
60
extensions/qqbot/src/bridge/commands/from-parser.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
76
extensions/qqbot/src/bridge/commands/result-dispatcher.ts
Normal file
76
extensions/qqbot/src/bridge/commands/result-dispatcher.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
133
extensions/qqbot/src/bridge/config-shared.ts
Normal file
133
extensions/qqbot/src/bridge/config-shared.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Qqbot helper module supports config shared behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
|
||||
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 { normalizeLowercaseStringOrEmpty } from "../engine/utils/string-normalize.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,
|
||||
} 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);
|
||||
}
|
||||
|
||||
function formatQQBotAllowFrom(params: {
|
||||
allowFrom: Array<string | number> | undefined | null;
|
||||
}): string[] {
|
||||
return engineFormatAllowFrom(params.allowFrom);
|
||||
}
|
||||
|
||||
export const qqbotConfigAdapter = {
|
||||
listAccountIds: (cfg: OpenClawConfig) => listQQBotAccountIds(cfg),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }),
|
||||
defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultQQBotAccountId(cfg),
|
||||
setAccountEnabled: ({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
}) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "qqbot",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "qqbot",
|
||||
accountId,
|
||||
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
|
||||
}),
|
||||
isConfigured: isQQBotConfigured,
|
||||
describeAccount: describeQQBotAccount,
|
||||
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) =>
|
||||
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }).config?.allowFrom,
|
||||
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> | undefined | null }) =>
|
||||
formatQQBotAllowFrom({ allowFrom }),
|
||||
};
|
||||
|
||||
export 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 }),
|
||||
};
|
||||
177
extensions/qqbot/src/bridge/config.ts
Normal file
177
extensions/qqbot/src/bridge/config.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Qqbot helper module supports config behavior.
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { coerceSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
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;
|
||||
|
||||
interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||
accounts?: Record<string, QQBotAccountConfig>;
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
/**
|
||||
* Legacy top-level account uses `channels.qqbot` as the base, but per-account
|
||||
* fields (allowFrom, streaming, …) often live under `accounts.default`.
|
||||
* Merge that slice so runtime sees `config.streaming` etc.
|
||||
*/
|
||||
const accountConfig: QQBotAccountConfig =
|
||||
base.accountId === DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
...qqbot,
|
||||
...qqbot?.accounts?.[DEFAULT_ACCOUNT_ID],
|
||||
}
|
||||
: (qqbot?.accounts?.[base.accountId] ?? {});
|
||||
|
||||
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 {
|
||||
clientSecret = fs.readFileSync(accountConfig.clientSecretFile, "utf8").trim();
|
||||
secretSource = "file";
|
||||
} catch {
|
||||
secretSource = "none";
|
||||
}
|
||||
} else if (process.env.QQBOT_CLIENT_SECRET && base.accountId === DEFAULT_ACCOUNT_ID) {
|
||||
clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
||||
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;
|
||||
}
|
||||
178
extensions/qqbot/src/bridge/gateway.ts
Normal file
178
extensions/qqbot/src/bridge/gateway.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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, getQQBotRuntimeForEngine } 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;
|
||||
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 runtime = getQQBotRuntimeForEngine();
|
||||
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,
|
||||
onReady: ctx.onReady,
|
||||
onResumed: ctx.onResumed,
|
||||
onError: ctx.onError,
|
||||
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
extensions/qqbot/src/bridge/logger.ts
Normal file
31
extensions/qqbot/src/bridge/logger.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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
extensions/qqbot/src/bridge/narrowing.ts
Normal file
32
extensions/qqbot/src/bridge/narrowing.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
146
extensions/qqbot/src/bridge/plugin-version.test.ts
Normal file
146
extensions/qqbot/src/bridge/plugin-version.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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 { QQBOT_PLUGIN_VERSION_UNKNOWN, 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(QQBOT_PLUGIN_VERSION_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(QQBOT_PLUGIN_VERSION_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(QQBOT_PLUGIN_VERSION_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");
|
||||
});
|
||||
});
|
||||
102
extensions/qqbot/src/bridge/plugin-version.ts
Normal file
102
extensions/qqbot/src/bridge/plugin-version.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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. */
|
||||
export 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;
|
||||
}
|
||||
29
extensions/qqbot/src/bridge/runtime.ts
Normal file
29
extensions/qqbot/src/bridge/runtime.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Qqbot plugin module implements runtime behavior.
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { GatewayPluginRuntime } from "../engine/gateway/types.js";
|
||||
import { setOpenClawVersion } from "../engine/messaging/sender.js";
|
||||
|
||||
// Single plugin runtime per process — concurrent multi-tenant qqbot runtimes are not supported.
|
||||
const {
|
||||
setRuntime: _setRuntime,
|
||||
clearRuntime: resetQQBotRuntimeForTest,
|
||||
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, resetQQBotRuntimeForTest, setQQBotRuntime };
|
||||
|
||||
/** Type-narrowed getter for engine/ modules that need GatewayPluginRuntime. */
|
||||
export function getQQBotRuntimeForEngine(): GatewayPluginRuntime {
|
||||
return getQQBotRuntime() as GatewayPluginRuntime;
|
||||
}
|
||||
187
extensions/qqbot/src/bridge/sdk-adapter.ts
Normal file
187
extensions/qqbot/src/bridge/sdk-adapter.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// 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;
|
||||
}
|
||||
145
extensions/qqbot/src/bridge/setup/finalize.ts
Normal file
145
extensions/qqbot/src/bridge/setup/finalize.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Qqbot plugin module implements finalize behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
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"];
|
||||
|
||||
function isQQBotAccountConfigured(cfg: OpenClawConfig, accountId: string): boolean {
|
||||
const account = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true });
|
||||
return Boolean(account.appId && account.clientSecret);
|
||||
}
|
||||
|
||||
async function linkViaQrCode(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
prompter: SetupPrompter;
|
||||
runtime: SetupRuntime;
|
||||
}): Promise<OpenClawConfig> {
|
||||
try {
|
||||
const { qrConnect } = await import("@tencent-connect/qqbot-connector");
|
||||
|
||||
const accounts: { appId: string; appSecret: string }[] = await qrConnect({
|
||||
source: "openclaw",
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
await params.prompter.note("未获取到任何 QQ Bot 账号信息。", "QQ Bot");
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
let next = params.cfg;
|
||||
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const { appId, appSecret } = accounts[i];
|
||||
// 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) {
|
||||
params.runtime.log(`✔ QQ Bot 绑定成功!(AppID: ${accounts[0].appId})`);
|
||||
} else {
|
||||
const idList = accounts.map((a) => a.appId).join(", ");
|
||||
params.runtime.log(`✔ ${accounts.length} 个 QQ Bot 绑定成功!(AppID: ${idList})`);
|
||||
}
|
||||
|
||||
return next;
|
||||
} catch (error) {
|
||||
params.runtime.error(`QQ Bot 绑定失败: ${String(error)}`);
|
||||
await params.prompter.note(
|
||||
[
|
||||
"绑定失败,您可以稍后手动配置。",
|
||||
`文档: ${formatDocsLink("/channels/qqbot", "qqbot")}`,
|
||||
].join("\n"),
|
||||
"QQ Bot",
|
||||
);
|
||||
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;
|
||||
}): 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,
|
||||
});
|
||||
} 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 };
|
||||
}
|
||||
35
extensions/qqbot/src/bridge/setup/surface.ts
Normal file
35
extensions/qqbot/src/bridge/setup/surface.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// 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 }) =>
|
||||
await finalizeQQBotSetup({ cfg, accountId, forceAllowFrom, prompter, runtime }),
|
||||
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
60
extensions/qqbot/src/bridge/tools/channel.ts
Normal file
60
extensions/qqbot/src/bridge/tools/channel.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Qqbot plugin module implements channel behavior.
|
||||
import type { OpenClawPluginApi } 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.
|
||||
*/
|
||||
export function registerChannelTool(api: OpenClawPluginApi): void {
|
||||
const cfg = api.config;
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountIds = listQQBotAccountIds(cfg);
|
||||
if (accountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccountId = accountIds[0];
|
||||
const account = resolveQQBotAccount(cfg, firstAccountId);
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
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 });
|
||||
},
|
||||
},
|
||||
{ name: "qqbot_channel_api" },
|
||||
);
|
||||
}
|
||||
15
extensions/qqbot/src/bridge/tools/index.ts
Normal file
15
extensions/qqbot/src/bridge/tools/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
132
extensions/qqbot/src/bridge/tools/remind.test.ts
Normal file
132
extensions/qqbot/src/bridge/tools/remind.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// Qqbot tests cover remind plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RemindCronAction } from "../../engine/tools/remind-logic.js";
|
||||
|
||||
const { callGatewayToolMock } = vi.hoisted(() => ({
|
||||
callGatewayToolMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({
|
||||
callGatewayTool: callGatewayToolMock,
|
||||
}));
|
||||
|
||||
import { createRemindTool } from "./remind.js";
|
||||
|
||||
type CronAddToolPayload = {
|
||||
job?: {
|
||||
sessionTarget?: string;
|
||||
payload?: {
|
||||
kind?: string;
|
||||
message?: 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 = createRemindTool({
|
||||
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?.job?.sessionTarget).toBe("isolated");
|
||||
expect(addPayload?.job?.payload?.kind).toBe("agentTurn");
|
||||
expect(addPayload?.job?.payload?.message).toContain("drink water");
|
||||
expect(addPayload?.job?.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 = createRemindTool({});
|
||||
|
||||
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" },
|
||||
);
|
||||
});
|
||||
|
||||
it("supports injected cron scheduler dependencies for engine-level tests", async () => {
|
||||
const callCron = vi.fn(async (_params: unknown) => ({ id: "job-1" }));
|
||||
const tool = createRemindTool(
|
||||
{
|
||||
deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
|
||||
},
|
||||
{ callCron },
|
||||
);
|
||||
|
||||
await tool.execute("tool-call-1", {
|
||||
action: "add",
|
||||
content: "drink water",
|
||||
time: "5m",
|
||||
});
|
||||
|
||||
const cronParams = callCron.mock.calls.at(0)?.[0] as RemindCronAction | undefined;
|
||||
expect(cronParams?.action).toBe("add");
|
||||
if (cronParams?.action !== "add") {
|
||||
throw new Error("Expected add reminder cron params");
|
||||
}
|
||||
expect(cronParams.job.delivery).toEqual({
|
||||
mode: "announce",
|
||||
channel: "qqbot",
|
||||
to: "qqbot:c2c:user-openid",
|
||||
accountId: "bot2",
|
||||
});
|
||||
expect(callGatewayToolMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules when sender ownership is missing", async () => {
|
||||
const callCron = vi.fn(async (_params: unknown) => ({ id: "job-1" }));
|
||||
const tool = createRemindTool(
|
||||
{
|
||||
deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
|
||||
},
|
||||
{ callCron },
|
||||
);
|
||||
|
||||
await tool.execute("tool-call-1", {
|
||||
action: "add",
|
||||
content: "drink water",
|
||||
time: "5m",
|
||||
});
|
||||
|
||||
expect(callCron).toHaveBeenCalledTimes(1);
|
||||
expect(callGatewayToolMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
79
extensions/qqbot/src/bridge/tools/remind.ts
Normal file
79
extensions/qqbot/src/bridge/tools/remind.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// 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 },
|
||||
{ job: params.job },
|
||||
);
|
||||
}
|
||||
return unexpectedCronParams(params);
|
||||
},
|
||||
};
|
||||
|
||||
export 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" });
|
||||
}
|
||||
229
extensions/qqbot/src/channel.message-adapter.test.ts
Normal file
229
extensions/qqbot/src/channel.message-adapter.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// 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("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");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
34
extensions/qqbot/src/channel.setup.ts
Normal file
34
extensions/qqbot/src/channel.setup.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Qqbot plugin module implements channel.setup behavior.
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import "./bridge/bootstrap.js";
|
||||
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } 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,
|
||||
},
|
||||
setup: {
|
||||
...qqbotSetupAdapterShared,
|
||||
},
|
||||
};
|
||||
438
extensions/qqbot/src/channel.ts
Normal file
438
extensions/qqbot/src/channel.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
// Qqbot plugin module implements channel behavior.
|
||||
import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime";
|
||||
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 { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-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, qqbotSetupAdapterShared } 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,
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
/**
|
||||
* Treat an account as configured when either the live config has
|
||||
* credentials OR a recoverable credential backup exists. This mirrors
|
||||
* the standalone plugin and lets the gateway survive a hot upgrade
|
||||
* that wiped openclaw.json mid-flight.
|
||||
*/
|
||||
isConfigured: (account: ResolvedQQBotAccount | undefined) => {
|
||||
if (qqbotConfigAdapter.isConfigured(account)) {
|
||||
return true;
|
||||
}
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
const backup = loadCredentialBackup(account.accountId);
|
||||
return Boolean(backup?.appId && backup?.clientSecret);
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
...qqbotSetupAdapterShared,
|
||||
},
|
||||
approvalCapability: getQQBotApprovalCapability(),
|
||||
groups: {
|
||||
resolveToolPolicy: resolveQQBotGroupToolPolicy,
|
||||
},
|
||||
message: qqbotMessageAdapter,
|
||||
messaging: {
|
||||
targetPrefixes: ["qqbot"],
|
||||
/** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
|
||||
normalizeTarget: coreNormalizeTarget,
|
||||
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 credentials from the per-account backup if the live
|
||||
// config is missing appId/secret (e.g. a hot-upgrade wiped
|
||||
// openclaw.json). We only restore when both fields are empty so a
|
||||
// user's intentional clear isn't silently undone.
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
const backup = loadCredentialBackup(account.accountId);
|
||||
if (backup?.appId && backup?.clientSecret) {
|
||||
try {
|
||||
const nextCfg = applyQQBotAccountConfig(cfg, account.accountId, {
|
||||
appId: backup.appId,
|
||||
clientSecret: backup.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({
|
||||
...ctx.getStatus(),
|
||||
running: true,
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
});
|
||||
// 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({
|
||||
...ctx.getStatus(),
|
||||
running: true,
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
});
|
||||
persistAccountCredentialSnapshot(account);
|
||||
},
|
||||
onError: (error) => {
|
||||
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
|
||||
ctx.setStatus({
|
||||
...ctx.getStatus(),
|
||||
lastError: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
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(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,
|
||||
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,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
113
extensions/qqbot/src/command-auth.test.ts
Normal file
113
extensions/qqbot/src/command-auth.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
103
extensions/qqbot/src/config-schema.ts
Normal file
103
extensions/qqbot/src/config-schema.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Qqbot helper module supports config schema behavior.
|
||||
import {
|
||||
AllowFromListSchema,
|
||||
ToolPolicySchema,
|
||||
buildChannelConfigSchema,
|
||||
} 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();
|
||||
|
||||
/** When `true`, same as `mode: "partial"` and `c2cStreamApi: true` for C2C. Object form kept for legacy configs. */
|
||||
const QQBotStreamingSchema = z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z
|
||||
.object({
|
||||
/** "partial" (default) enables block streaming; "off" disables it. */
|
||||
mode: z.enum(["off", "partial"]).default("partial"),
|
||||
/** @deprecated Prefer `streaming: true`. */
|
||||
c2cStreamApi: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.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 = z.enum(["open", "allowlist", "disabled"]).optional();
|
||||
const QQBotGroupCommandLevelSchema = z.enum(["all", "safety", "strict"]).optional();
|
||||
|
||||
const QQBotGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
commandLevel: QQBotGroupCommandLevelSchema,
|
||||
ignoreOtherMentions: z.boolean().optional(),
|
||||
historyLimit: z.number().optional(),
|
||||
name: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
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,
|
||||
systemPrompt: z.string().optional(),
|
||||
markdownSupport: z.boolean().optional(),
|
||||
voiceDirectUploadFormats: z.array(z.string()).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();
|
||||
|
||||
export const QQBotConfigSchema = QQBotAccountSchema.extend({
|
||||
stt: QQBotSttSchema,
|
||||
accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
}).passthrough();
|
||||
export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema);
|
||||
450
extensions/qqbot/src/config.test.ts
Normal file
450
extensions/qqbot/src/config.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
// Qqbot tests cover config plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
type JsonSchemaObject,
|
||||
validateJsonSchemaValue,
|
||||
} from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qqbotSetupAdapterShared } from "./bridge/config-shared.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
resolveDefaultQQBotAccountId,
|
||||
resolveQQBotAccount,
|
||||
} from "./bridge/config.js";
|
||||
import { qqbotSetupPlugin } from "./channel.setup.js";
|
||||
import { QQBotConfigSchema } from "./config-schema.js";
|
||||
import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js";
|
||||
|
||||
function requireQQBotSetup() {
|
||||
if (!qqbotSetupPlugin.setup) {
|
||||
throw new Error("QQBot setup missing");
|
||||
}
|
||||
return qqbotSetupPlugin.setup;
|
||||
}
|
||||
|
||||
describe("qqbot config", () => {
|
||||
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("accepts SecretRef-backed credentials in the runtime schema", () => {
|
||||
const parsed = QQBotConfigSchema.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 = QQBotConfigSchema.safeParse({
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
stt: {
|
||||
provider: "openai",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts canonical group tools config", () => {
|
||||
const parsed = QQBotConfigSchema.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 = QQBotConfigSchema.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("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 consistently across setup paths", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
const lightweightSetup = requireQQBotSetup();
|
||||
|
||||
const input = { token: "broken", name: "Bad" };
|
||||
|
||||
expect(
|
||||
runtimeSetup.validateInput?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --token must be in appId:clientSecret format");
|
||||
expect(
|
||||
lightweightSetup.validateInput?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --token must be in appId:clientSecret format");
|
||||
expect(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toStrictEqual({});
|
||||
expect(
|
||||
lightweightSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("preserves the --use-env add flow across setup paths", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
const lightweightSetup = requireQQBotSetup();
|
||||
|
||||
const input = { useEnv: true, name: "Env Bot" };
|
||||
|
||||
expect(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toStrictEqual({
|
||||
channels: {
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
allowFrom: ["*"],
|
||||
name: "Env Bot",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
lightweightSetup.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 runtime setup accountId is omitted", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
|
||||
expect(
|
||||
runtimeSetup.resolveAccountId?.({
|
||||
cfg: makeQqbotDefaultAccountConfig(),
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).toBe("bot2");
|
||||
});
|
||||
|
||||
it("rejects --use-env for named accounts across setup paths", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
const lightweightSetup = requireQQBotSetup();
|
||||
|
||||
const input = { useEnv: true, name: "Env Bot" };
|
||||
|
||||
expect(
|
||||
runtimeSetup.validateInput?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --use-env only supports the default account");
|
||||
expect(
|
||||
lightweightSetup.validateInput?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --use-env only supports the default account");
|
||||
expect(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toStrictEqual({});
|
||||
expect(
|
||||
lightweightSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
98
extensions/qqbot/src/doctor-contract.test.ts
Normal file
98
extensions/qqbot/src/doctor-contract.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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";
|
||||
|
||||
describe("qqbot doctor contract", () => {
|
||||
it("detects legacy root and account group toolPolicy config", () => {
|
||||
expect(
|
||||
legacyConfigRules[0]?.match?.(
|
||||
{
|
||||
G1: { toolPolicy: "none" },
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
legacyConfigRules[1]?.match?.(
|
||||
{
|
||||
bot2: {
|
||||
groups: {
|
||||
G1: { toolPolicy: "none" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
164
extensions/qqbot/src/doctor-contract.ts
Normal file
164
extensions/qqbot/src/doctor-contract.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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 } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
const RESTRICTED_GROUP_TOOLS: GroupToolPolicyConfig = {
|
||||
deny: ["exec", "read", "write"],
|
||||
};
|
||||
|
||||
function hasLegacyGroupToolPolicy(value: unknown): boolean {
|
||||
const groups = asObjectRecord(value);
|
||||
if (!groups) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(groups).some((group) => asObjectRecord(group)?.toolPolicy !== undefined);
|
||||
}
|
||||
|
||||
function hasLegacyAccountGroupToolPolicy(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) =>
|
||||
hasLegacyGroupToolPolicy(asObjectRecord(account)?.groups),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function migrateGroups(params: {
|
||||
groups: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { groups: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextGroups = { ...params.groups };
|
||||
for (const [groupId, rawGroup] of Object.entries(params.groups)) {
|
||||
const group = asObjectRecord(rawGroup);
|
||||
if (!group || group.toolPolicy === undefined) {
|
||||
continue;
|
||||
}
|
||||
const { toolPolicy, ...rest } = group;
|
||||
const nextGroup = { ...rest };
|
||||
const policy = migrateToolPolicy(toolPolicy);
|
||||
const path = `${params.pathPrefix}.${groupId}`;
|
||||
if (nextGroup.tools !== undefined) {
|
||||
params.changes.push(`Removed ${path}.toolPolicy (${path}.tools already exists).`);
|
||||
} else if (policy) {
|
||||
nextGroup.tools = policy;
|
||||
params.changes.push(
|
||||
`Moved ${path}.toolPolicy=${describeToolPolicy(toolPolicy)} to ${path}.tools.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed unsupported ${path}.toolPolicy=${describeToolPolicy(toolPolicy)}.`,
|
||||
);
|
||||
}
|
||||
nextGroups[groupId] = nextGroup;
|
||||
changed = true;
|
||||
}
|
||||
return { groups: nextGroups, changed };
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
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: hasLegacyGroupToolPolicy,
|
||||
},
|
||||
{
|
||||
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: hasLegacyAccountGroupToolPolicy,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.qqbot);
|
||||
if (!rawEntry) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updated = rawEntry;
|
||||
let changed = false;
|
||||
|
||||
const groups = asObjectRecord(updated.groups);
|
||||
if (groups) {
|
||||
const migrated = migrateGroups({
|
||||
groups,
|
||||
pathPrefix: "channels.qqbot.groups",
|
||||
changes,
|
||||
});
|
||||
if (migrated.changed) {
|
||||
updated = { ...updated, groups: migrated.groups };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = asObjectRecord(updated.accounts);
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts = { ...accounts };
|
||||
for (const [accountId, rawAccount] of Object.entries(accounts)) {
|
||||
const account = asObjectRecord(rawAccount);
|
||||
const accountGroups = asObjectRecord(account?.groups);
|
||||
if (!account || !accountGroups) {
|
||||
continue;
|
||||
}
|
||||
const migrated = migrateGroups({
|
||||
groups: accountGroups,
|
||||
pathPrefix: `channels.qqbot.accounts.${accountId}.groups`,
|
||||
changes,
|
||||
});
|
||||
if (migrated.changed) {
|
||||
nextAccounts[accountId] = { ...account, groups: migrated.groups };
|
||||
accountsChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updated = { ...updated, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
qqbot: updated as unknown as NonNullable<OpenClawConfig["channels"]>["qqbot"],
|
||||
} as OpenClawConfig["channels"],
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
8
extensions/qqbot/src/doctor.ts
Normal file
8
extensions/qqbot/src/doctor.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 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,
|
||||
};
|
||||
3
extensions/qqbot/src/engine/access/index.ts
Normal file
3
extensions/qqbot/src/engine/access/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Qqbot plugin entrypoint registers its OpenClaw integration.
|
||||
export { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js";
|
||||
export { type QQBotDmPolicy, type QQBotGroupPolicy } from "./types.js";
|
||||
62
extensions/qqbot/src/engine/access/resolve-policy.test.ts
Normal file
62
extensions/qqbot/src/engine/access/resolve-policy.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
31
extensions/qqbot/src/engine/access/resolve-policy.ts
Normal file
31
extensions/qqbot/src/engine/access/resolve-policy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 };
|
||||
}
|
||||
61
extensions/qqbot/src/engine/access/sender-match.test.ts
Normal file
61
extensions/qqbot/src/engine/access/sender-match.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Qqbot tests cover sender match plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createQQBotSenderMatcher,
|
||||
normalizeQQBotAllowFrom,
|
||||
normalizeQQBotSenderId,
|
||||
} from "./sender-match.js";
|
||||
|
||||
describe("normalizeQQBotSenderId", () => {
|
||||
it("uppercases and strips qqbot: prefix", () => {
|
||||
expect(normalizeQQBotSenderId("qqbot:abc123")).toBe("ABC123");
|
||||
expect(normalizeQQBotSenderId("QQBot:abc123")).toBe("ABC123");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizeQQBotSenderId(" USER1 ")).toBe("USER1");
|
||||
});
|
||||
|
||||
it("returns empty string for non-string input", () => {
|
||||
expect(normalizeQQBotSenderId(undefined as unknown as string)).toBe("");
|
||||
expect(normalizeQQBotSenderId(null as unknown as string)).toBe("");
|
||||
expect(normalizeQQBotSenderId({} as unknown as string)).toBe("");
|
||||
});
|
||||
|
||||
it("accepts numeric input", () => {
|
||||
expect(normalizeQQBotSenderId(42)).toBe("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeQQBotAllowFrom", () => {
|
||||
it("normalizes all entries and drops empty ones", () => {
|
||||
expect(normalizeQQBotAllowFrom(["qqbot:user1", "USER2", "", " "])).toEqual(["USER1", "USER2"]);
|
||||
});
|
||||
|
||||
it("returns empty array for undefined/null", () => {
|
||||
expect(normalizeQQBotAllowFrom(undefined)).toStrictEqual([]);
|
||||
expect(normalizeQQBotAllowFrom(null)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createQQBotSenderMatcher", () => {
|
||||
it("matches wildcard regardless of sender", () => {
|
||||
expect(createQQBotSenderMatcher("USER1")(["*"])).toBe(true);
|
||||
expect(createQQBotSenderMatcher("")(["*"])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches case-insensitive with qqbot: prefix", () => {
|
||||
const match = createQQBotSenderMatcher("qqbot:USER1");
|
||||
expect(match(["qqbot:user1"])).toBe(true);
|
||||
expect(match(["USER1"])).toBe(true);
|
||||
expect(match(["USER2"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false on empty allowlist", () => {
|
||||
expect(createQQBotSenderMatcher("USER1")([])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty sender against non-wildcard list", () => {
|
||||
expect(createQQBotSenderMatcher("")(["USER1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
55
extensions/qqbot/src/engine/access/sender-match.ts
Normal file
55
extensions/qqbot/src/engine/access/sender-match.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* QQBot sender normalization and allowlist matching.
|
||||
*
|
||||
* Keeps QQ-specific quirks (the `qqbot:` prefix, uppercase-insensitive
|
||||
* comparison) localized to this module so the policy engine itself can
|
||||
* stay channel-agnostic.
|
||||
*/
|
||||
|
||||
/** Normalize a single entry (openid): strip `qqbot:` prefix, uppercase, trim. */
|
||||
export function normalizeQQBotSenderId(raw: unknown): string {
|
||||
if (typeof raw !== "string" && typeof raw !== "number") {
|
||||
return "";
|
||||
}
|
||||
return String(raw)
|
||||
.trim()
|
||||
.replace(/^qqbot:/i, "")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/** Normalize an entire allowFrom list, dropping empty entries. */
|
||||
export function normalizeQQBotAllowFrom(list: Array<string | number> | undefined | null): string[] {
|
||||
if (!list || list.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const entry of list) {
|
||||
const normalized = normalizeQQBotSenderId(entry);
|
||||
if (normalized) {
|
||||
out.push(normalized);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a matcher closure suitable for passing to the policy engine's
|
||||
* `isSenderAllowed` callback. The caller supplies the sender once, and
|
||||
* the returned function can be invoked against different allowlists
|
||||
* (DM allowlist vs group allowlist) without repeating normalization.
|
||||
*/
|
||||
export function createQQBotSenderMatcher(senderId: string): (allowFrom: string[]) => boolean {
|
||||
const normalizedSender = normalizeQQBotSenderId(senderId);
|
||||
return (allowFrom: string[]) => {
|
||||
if (allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
if (!normalizedSender) {
|
||||
return false;
|
||||
}
|
||||
return allowFrom.some((entry) => normalizeQQBotSenderId(entry) === normalizedSender);
|
||||
};
|
||||
}
|
||||
3
extensions/qqbot/src/engine/access/types.ts
Normal file
3
extensions/qqbot/src/engine/access/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Qqbot type declarations define plugin contracts.
|
||||
export type QQBotDmPolicy = "open" | "allowlist" | "disabled";
|
||||
export type QQBotGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
27
extensions/qqbot/src/engine/adapter/audio.port.ts
Normal file
27
extensions/qqbot/src/engine/adapter/audio.port.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Audio port — abstracts inbound + outbound audio conversion operations.
|
||||
*
|
||||
* The engine defines this interface; the bridge layer provides an
|
||||
* implementation backed by `engine/utils/audio.js` functions.
|
||||
*/
|
||||
|
||||
/** Inbound audio conversion (SILK→WAV, voice detection, duration formatting). */
|
||||
export interface AudioConvertPort {
|
||||
convertSilkToWav(
|
||||
silkPath: string,
|
||||
outputDir: string,
|
||||
): Promise<{ wavPath: string; duration: number } | null>;
|
||||
isVoiceAttachment(att: { content_type: string; filename?: string }): boolean;
|
||||
formatDuration(seconds: number): string;
|
||||
}
|
||||
|
||||
/** Outbound audio conversion (WAV→SILK, audio detection, transcoding). */
|
||||
export interface OutboundAudioPort {
|
||||
audioFileToSilkBase64(
|
||||
audioPath: string,
|
||||
directUploadFormats?: string[],
|
||||
): Promise<string | undefined>;
|
||||
isAudioFile(pathOrUrl: string, mimeType?: string): boolean;
|
||||
shouldTranscodeVoice(filePath: string): boolean;
|
||||
waitForFile(filePath: string, maxWaitMs?: number): Promise<number>;
|
||||
}
|
||||
22
extensions/qqbot/src/engine/adapter/commands.port.ts
Normal file
22
extensions/qqbot/src/engine/adapter/commands.port.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Commands port — abstracts slash-command dependencies injected by the
|
||||
* bridge layer (version resolvers, approve runtime getter).
|
||||
*
|
||||
* Eliminates global `register*` singletons in `slash-commands-impl.ts`.
|
||||
*/
|
||||
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
|
||||
/** Runtime getter shape for the `/bot-approve` command. */
|
||||
export type ApproveRuntimeGetter = () => {
|
||||
config: Pick<PluginRuntime["config"], "current" | "replaceConfigFile">;
|
||||
};
|
||||
|
||||
export interface CommandsPort {
|
||||
/** Resolve the framework runtime version string. */
|
||||
resolveVersion: () => string;
|
||||
/** Plugin version string (e.g. "1.2.3"). */
|
||||
pluginVersion: string;
|
||||
/** Runtime getter for `/bot-approve` config management. */
|
||||
approveRuntimeGetter?: ApproveRuntimeGetter;
|
||||
}
|
||||
52
extensions/qqbot/src/engine/adapter/history.port.ts
Normal file
52
extensions/qqbot/src/engine/adapter/history.port.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* History port — abstracts the group history cache operations.
|
||||
*
|
||||
* The engine defines this interface; the bridge layer provides an
|
||||
* implementation backed by SDK `reply-history` functions. The engine's
|
||||
* built-in implementation in `group/history.ts` is used as the default
|
||||
* when no adapter is injected (standalone build).
|
||||
*/
|
||||
|
||||
/** Minimal history entry shape expected by the port. */
|
||||
export interface HistoryEntryLike {
|
||||
sender: string;
|
||||
body: string;
|
||||
timestamp?: number;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface HistoryPort {
|
||||
/**
|
||||
* Record a non-@ message into the pending history buffer.
|
||||
* No-op when `limit <= 0` or `entry` is missing.
|
||||
*/
|
||||
recordPendingHistoryEntry<T extends HistoryEntryLike>(params: {
|
||||
historyMap: Map<string, T[]>;
|
||||
historyKey: string;
|
||||
entry?: T | null;
|
||||
limit: number;
|
||||
}): T[];
|
||||
|
||||
/**
|
||||
* Build the full user-message string prefixed with buffered history.
|
||||
* Returns `currentMessage` unchanged when no history exists.
|
||||
*/
|
||||
buildPendingHistoryContext(params: {
|
||||
historyMap: Map<string, HistoryEntryLike[]>;
|
||||
historyKey: string;
|
||||
limit: number;
|
||||
currentMessage: string;
|
||||
formatEntry: (entry: HistoryEntryLike) => string;
|
||||
lineBreak?: string;
|
||||
}): string;
|
||||
|
||||
/**
|
||||
* Clear a group's pending history buffer.
|
||||
* No-op when `limit <= 0`.
|
||||
*/
|
||||
clearPendingHistory(params: {
|
||||
historyMap: Map<string, HistoryEntryLike[]>;
|
||||
historyKey: string;
|
||||
limit: number;
|
||||
}): void;
|
||||
}
|
||||
77
extensions/qqbot/src/engine/adapter/index.ts
Normal file
77
extensions/qqbot/src/engine/adapter/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Qqbot plugin entrypoint registers its OpenClaw integration.
|
||||
import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { EffectivePolicyInput } from "../access/resolve-policy.js";
|
||||
import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js";
|
||||
|
||||
export type QQBotInboundAccess = ResolvedChannelMessageIngress;
|
||||
|
||||
export interface AccessPort {
|
||||
resolveInboundAccess(
|
||||
input: EffectivePolicyInput & {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
},
|
||||
): QQBotInboundAccess | Promise<QQBotInboundAccess>;
|
||||
|
||||
resolveSlashCommandAuthorization(input: {
|
||||
cfg: unknown;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
commandsAllowFrom?: Array<string | number>;
|
||||
}): boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface EngineAdapters {
|
||||
history: import("./history.port.js").HistoryPort;
|
||||
mentionGate: import("./mention-gate.port.js").MentionGatePort;
|
||||
access: AccessPort;
|
||||
audioConvert: import("./audio.port.js").AudioConvertPort;
|
||||
outboundAudio: import("./audio.port.js").OutboundAudioPort;
|
||||
commands: import("./commands.port.js").CommandsPort;
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise<void>;
|
||||
resolveSecret(value: string | SecretInputRef | undefined): Promise<string | undefined>;
|
||||
downloadFile(url: string, destDir: string, filename?: string): Promise<string>;
|
||||
fetchMedia(options: FetchMediaOptions): Promise<FetchMediaResult>;
|
||||
getTempDir(): string;
|
||||
hasConfiguredSecret(value: unknown): boolean;
|
||||
normalizeSecretInputString(value: unknown): string | undefined;
|
||||
resolveSecretInputString(params: { value: unknown; path: string }): string | undefined;
|
||||
resolveApproval?(approvalId: string, decision: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
let platformAdapter: PlatformAdapter | null = null;
|
||||
let platformAdapterFactory: (() => PlatformAdapter) | null = null;
|
||||
|
||||
export function registerPlatformAdapter(adapter: PlatformAdapter): void {
|
||||
platformAdapter = adapter;
|
||||
}
|
||||
|
||||
export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void {
|
||||
platformAdapterFactory = factory;
|
||||
}
|
||||
|
||||
export function getPlatformAdapter(): PlatformAdapter {
|
||||
if (!platformAdapter && platformAdapterFactory) {
|
||||
platformAdapter = platformAdapterFactory();
|
||||
}
|
||||
if (!platformAdapter) {
|
||||
throw new Error(
|
||||
"PlatformAdapter not registered. Call registerPlatformAdapter() during bootstrap.",
|
||||
);
|
||||
}
|
||||
return platformAdapter;
|
||||
}
|
||||
|
||||
export function hasPlatformAdapter(): boolean {
|
||||
return platformAdapter !== null || platformAdapterFactory !== null;
|
||||
}
|
||||
50
extensions/qqbot/src/engine/adapter/mention-gate.port.ts
Normal file
50
extensions/qqbot/src/engine/adapter/mention-gate.port.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Mention gate port — abstracts the SDK's `resolveInboundMentionDecision`
|
||||
* + `resolveControlCommandGate` into a single interface.
|
||||
*
|
||||
* The engine's `resolveGroupMessageGate` (Layer 1: ignoreOtherMentions)
|
||||
* is QQ-specific and stays in `group/message-gating.ts`. Layer 2+3
|
||||
* (command gating + mention gating + command bypass) delegate to this port.
|
||||
*/
|
||||
|
||||
/** Implicit mention kind aligned with SDK's `InboundImplicitMentionKind`. */
|
||||
type ImplicitMentionKind = "reply_to_bot" | "quoted_bot" | "bot_thread_participant" | "native";
|
||||
|
||||
/** Facts about the current message's mention state. */
|
||||
export interface MentionFacts {
|
||||
canDetectMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
hasAnyMention?: boolean;
|
||||
implicitMentionKinds?: readonly ImplicitMentionKind[];
|
||||
}
|
||||
|
||||
/** Policy configuration for the mention gate. */
|
||||
export interface MentionPolicy {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
}
|
||||
|
||||
/** Result of the mention gate evaluation. */
|
||||
export interface MentionGateDecision {
|
||||
effectiveWasMentioned: boolean;
|
||||
shouldSkip: boolean;
|
||||
shouldBypassMention: boolean;
|
||||
implicitMention: boolean;
|
||||
}
|
||||
|
||||
export interface MentionGatePort {
|
||||
/**
|
||||
* Evaluate whether the message should be skipped based on mention
|
||||
* policy, command bypass, and implicit mention rules.
|
||||
*
|
||||
* Equivalent to SDK's `resolveInboundMentionDecision` with the
|
||||
* command-bypass logic folded in.
|
||||
*/
|
||||
resolveInboundMentionDecision(params: {
|
||||
facts: MentionFacts;
|
||||
policy: MentionPolicy;
|
||||
}): MentionGateDecision;
|
||||
}
|
||||
38
extensions/qqbot/src/engine/adapter/types.ts
Normal file
38
extensions/qqbot/src/engine/adapter/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shared types used by the PlatformAdapter interface.
|
||||
*/
|
||||
|
||||
/** Reference to a secret stored in the platform's secret management system. */
|
||||
export interface SecretInputRef {
|
||||
source: "env" | "file" | "config";
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Options for fetching remote media through the platform adapter. */
|
||||
export interface FetchMediaOptions {
|
||||
url: string;
|
||||
/** Hint for the local filename when saving. */
|
||||
filePathHint?: string;
|
||||
/** Maximum bytes to download. */
|
||||
maxBytes?: number;
|
||||
/** Maximum redirects to follow. */
|
||||
maxRedirects?: number;
|
||||
/** SSRF policy configuration. */
|
||||
ssrfPolicy?: SsrfPolicyConfig;
|
||||
/** Extra fetch() RequestInit options. */
|
||||
requestInit?: RequestInit;
|
||||
}
|
||||
|
||||
/** Result of a remote media fetch operation. */
|
||||
export interface FetchMediaResult {
|
||||
buffer: Buffer;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** SSRF policy configuration — platform-agnostic subset. */
|
||||
export interface SsrfPolicyConfig {
|
||||
/** Hostnames that are always allowed (supports `*.example.com` wildcards). */
|
||||
hostnameAllowlist?: string[];
|
||||
/** Whether to allow RFC 2544 benchmark ranges (198.18.0.0/15). */
|
||||
allowRfc2544BenchmarkRange?: boolean;
|
||||
}
|
||||
123
extensions/qqbot/src/engine/api/api-client.test.ts
Normal file
123
extensions/qqbot/src/engine/api/api-client.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Qqbot tests cover api-client plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../../../test-support/streaming-error-response.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError } from "../types.js";
|
||||
import { ApiClient } from "./api-client.js";
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ApiClient", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
it("bounds error bodies without using response.text()", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const tracked = cancelTrackedResponse(`${"qqbot api unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: tracked.response,
|
||||
release,
|
||||
});
|
||||
|
||||
const client = new ApiClient({ baseUrl: "https://qqbot.test" });
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await client.request("token-1", "GET", "/v2/users/@me");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(String(error)).toContain("API Error [/v2/users/@me] HTTP 503");
|
||||
expect(String(error)).toContain("qqbot api unavailable");
|
||||
expect(String(error)).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
|
||||
url: "https://qqbot.test/v2/users/@me",
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: "QQBot token-1",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "QQBotPlugin/unknown",
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
},
|
||||
auditContext: "qqbot-api",
|
||||
policy: {
|
||||
hostnameAllowlist: ["qqbot.test"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds successful response bodies without using response.text()", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: streamed.response,
|
||||
release,
|
||||
});
|
||||
|
||||
const client = new ApiClient({ baseUrl: "https://qqbot.test" });
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await client.request("token-1", "GET", "/v2/users/@me");
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(String(error)).toContain("QQBot API response: text response exceeds 16777216 bytes");
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
243
extensions/qqbot/src/engine/api/api-client.ts
Normal file
243
extensions/qqbot/src/engine/api/api-client.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Core HTTP client for the QQ Open Platform REST API.
|
||||
*
|
||||
* Key improvements over the old `src/api.ts#apiRequest`:
|
||||
* - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA)
|
||||
* is injected via the constructor, eliminating module-level globals.
|
||||
* - Throws structured `ApiError` with httpStatus, bizCode, and path fields.
|
||||
* - Detects HTML error pages from CDN/gateway and returns user-friendly messages.
|
||||
* - `redactBodyKeys` replaces the hardcoded `file_data` redaction.
|
||||
*/
|
||||
|
||||
import {
|
||||
readProviderTextResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.sgroup.qq.com";
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const FILE_UPLOAD_TIMEOUT_MS = 120_000;
|
||||
const QQBOT_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
function resolveQqbotApiSsrfPolicy(url: string): SsrFPolicy {
|
||||
return {
|
||||
hostnameAllowlist: [new URL(url).hostname],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
/** Request timeout override in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Body keys to redact in debug logs (e.g. `['file_data']`). */
|
||||
redactBodyKeys?: string[];
|
||||
/**
|
||||
* Mark the request as a file-upload call.
|
||||
*
|
||||
* Triggers the longer `fileUploadTimeoutMs` (default 120s) instead of the
|
||||
* standard `defaultTimeoutMs` (default 30s). Prefer this flag over
|
||||
* inspecting the request path; it keeps the timeout policy independent of
|
||||
* route naming conventions.
|
||||
*/
|
||||
uploadRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful HTTP client for the QQ Open Platform.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' });
|
||||
* const data = await client.request<{ url: string }>(token, 'GET', '/gateway');
|
||||
* ```
|
||||
*/
|
||||
export class ApiClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly fileUploadTimeoutMs: number;
|
||||
private readonly logger?: EngineLogger;
|
||||
private readonly resolveUserAgent: () => string;
|
||||
|
||||
constructor(config: ApiClientConfig = {}) {
|
||||
this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
|
||||
this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS;
|
||||
this.logger = config.logger;
|
||||
const ua = config.userAgent ?? "QQBotPlugin/unknown";
|
||||
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an authenticated JSON request to the QQ Open Platform.
|
||||
*
|
||||
* @param accessToken - Bearer token (`QQBot {token}`).
|
||||
* @param method - HTTP method.
|
||||
* @param path - API path (appended to baseUrl).
|
||||
* @param body - Optional JSON body.
|
||||
* @param options - Optional request overrides.
|
||||
* @returns Parsed JSON response.
|
||||
* @throws {ApiError} On HTTP or parse errors.
|
||||
*/
|
||||
async request<T = unknown>(
|
||||
accessToken: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `QQBot ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": this.resolveUserAgent(),
|
||||
};
|
||||
|
||||
const isFileUpload =
|
||||
options?.uploadRequest === true ||
|
||||
// Back-compat: legacy callers that predate the explicit `uploadRequest`
|
||||
// flag still get the long timeout when hitting file endpoints. New
|
||||
// code should always pass `uploadRequest: true` explicitly.
|
||||
path.includes("/files") ||
|
||||
path.includes("/upload_prepare") ||
|
||||
path.includes("/upload_part_finish");
|
||||
const timeout =
|
||||
options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const fetchInit: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
fetchInit.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
// Debug logging with optional body redaction.
|
||||
this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
|
||||
if (body && this.logger?.debug) {
|
||||
const logBody = { ...(body as Record<string, unknown>) };
|
||||
for (const key of options?.redactBodyKeys ?? ["file_data"]) {
|
||||
if (typeof logBody[key] === "string") {
|
||||
logBody[key] = `<redacted ${logBody[key].length} chars>`;
|
||||
}
|
||||
}
|
||||
this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: fetchInit,
|
||||
auditContext: "qqbot-api",
|
||||
policy: resolveQqbotApiSsrfPolicy(url),
|
||||
});
|
||||
res = guarded.response;
|
||||
release = guarded.release;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`);
|
||||
throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path);
|
||||
}
|
||||
this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`);
|
||||
throw new ApiError(`Network error [${path}]: ${formatErrorMessage(err)}`, 0, path);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
try {
|
||||
// Log response status and trace ID.
|
||||
const traceId = res.headers.get("x-tps-trace-id") ?? "";
|
||||
this.logger?.info?.(
|
||||
`[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`,
|
||||
);
|
||||
|
||||
const readBody = async (limitBytes?: number): Promise<string> => {
|
||||
try {
|
||||
return limitBytes === undefined
|
||||
? await readProviderTextResponse(res, "QQBot API response")
|
||||
: await readResponseTextLimited(res, limitBytes);
|
||||
} catch (err) {
|
||||
throw new ApiError(
|
||||
`Failed to read response [${path}]: ${formatErrorMessage(err)}`,
|
||||
res.status,
|
||||
path,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const rawBody = res.ok ? await readBody() : await readBody(QQBOT_API_ERROR_BODY_LIMIT_BYTES);
|
||||
this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`);
|
||||
|
||||
// Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages).
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const isHtmlResponse =
|
||||
contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
|
||||
|
||||
if (!res.ok) {
|
||||
if (isHtmlResponse) {
|
||||
const statusHint =
|
||||
res.status === 502 || res.status === 503 || res.status === 504
|
||||
? "调用发生异常,请稍候重试"
|
||||
: res.status === 429
|
||||
? "请求过于频繁,已被限流"
|
||||
: `开放平台返回 HTTP ${res.status}`;
|
||||
throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
|
||||
}
|
||||
|
||||
// JSON error response.
|
||||
try {
|
||||
const error = JSON.parse(rawBody) as {
|
||||
message?: string;
|
||||
code?: number;
|
||||
err_code?: number;
|
||||
};
|
||||
const bizCode = error.code ?? error.err_code;
|
||||
throw new ApiError(
|
||||
`API Error [${path}]: ${error.message ?? rawBody}`,
|
||||
res.status,
|
||||
path,
|
||||
bizCode,
|
||||
error.message,
|
||||
);
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof ApiError) {
|
||||
throw parseErr;
|
||||
}
|
||||
throw new ApiError(
|
||||
`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`,
|
||||
res.status,
|
||||
path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Successful response but not JSON (extreme edge case).
|
||||
if (isHtmlResponse) {
|
||||
throw new ApiError(
|
||||
`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`,
|
||||
res.status,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as T;
|
||||
} catch {
|
||||
throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
|
||||
}
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
465
extensions/qqbot/src/engine/api/media-chunked.test.ts
Normal file
465
extensions/qqbot/src/engine/api/media-chunked.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
// Qqbot tests cover media chunked plugin behavior.
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeSource } from "../messaging/media-source.js";
|
||||
import {
|
||||
ApiError,
|
||||
MediaFileType,
|
||||
type UploadMediaResponse,
|
||||
type UploadPrepareResponse,
|
||||
} from "../types.js";
|
||||
import type { ApiClient } from "./api-client.js";
|
||||
import {
|
||||
ChunkedMediaApi,
|
||||
UploadDailyLimitExceededError,
|
||||
} from "./media-chunked.js";
|
||||
import type { UploadCacheAdapter } from "./media.js";
|
||||
import { UPLOAD_PREPARE_FALLBACK_CODE } from "./retry.js";
|
||||
import type { TokenManager } from "./token.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
// ============ Test doubles ============
|
||||
|
||||
/** Build a minimal ApiClient stub whose `request` is fully mockable. */
|
||||
function mockApiClient(): ApiClient & { request: ReturnType<typeof vi.fn<ApiClient["request"]>> } {
|
||||
return {
|
||||
request: vi.fn<ApiClient["request"]>(),
|
||||
} as unknown as ApiClient & { request: ReturnType<typeof vi.fn<ApiClient["request"]>> };
|
||||
}
|
||||
|
||||
/** Minimal TokenManager stub returning a static token. */
|
||||
function mockTokenManager(token = "test-token"): TokenManager {
|
||||
return {
|
||||
getAccessToken: vi.fn().mockResolvedValue(token),
|
||||
} as unknown as TokenManager;
|
||||
}
|
||||
|
||||
/** In-memory upload-cache adapter. */
|
||||
function inMemoryCache(): UploadCacheAdapter & {
|
||||
getSpy: ReturnType<typeof vi.fn>;
|
||||
setSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const store = new Map<string, string>();
|
||||
const getSpy = vi.fn(
|
||||
(hash: string, scope: string, targetId: string, fileType: number) =>
|
||||
store.get(`${hash}:${scope}:${targetId}:${fileType}`) ?? null,
|
||||
);
|
||||
const setSpy = vi.fn(
|
||||
(hash: string, scope: string, targetId: string, fileType: number, fileInfo: string) => {
|
||||
store.set(`${hash}:${scope}:${targetId}:${fileType}`, fileInfo);
|
||||
},
|
||||
);
|
||||
return {
|
||||
computeHash: (data: string | Uint8Array) => crypto.createHash("md5").update(data).digest("hex"),
|
||||
get: getSpy,
|
||||
set: setSpy,
|
||||
getSpy,
|
||||
setSpy,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a canned upload_prepare response with `parts` presigned URLs. */
|
||||
function makePrepareResponse(uploadId: string, parts: number): UploadPrepareResponse {
|
||||
return {
|
||||
upload_id: uploadId,
|
||||
block_size: 8,
|
||||
parts: Array.from({ length: parts }, (_, i) => ({
|
||||
index: i + 1,
|
||||
presigned_url: `https://cos.example.com/part-${i + 1}`,
|
||||
})),
|
||||
concurrency: 2,
|
||||
retry_timeout: 60,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fixture: a 20-byte buffer that spans 3 parts at block_size=8. */
|
||||
const FIXTURE_BUFFER = Buffer.from("0123456789abcdefghij"); // 20 bytes
|
||||
|
||||
// ============ fetch stub for COS PUT ============
|
||||
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
function stubFetchOk(): ReturnType<typeof vi.fn> {
|
||||
fetchWithSsrFGuardMock.mockImplementation(async () => ({
|
||||
response: new Response("", {
|
||||
status: 200,
|
||||
headers: {
|
||||
ETag: '"etag-value"',
|
||||
"x-cos-request-id": "req-id",
|
||||
},
|
||||
}),
|
||||
release: vi.fn(),
|
||||
}));
|
||||
return fetchWithSsrFGuardMock;
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Tests ============
|
||||
|
||||
describe("media-chunked: UploadDailyLimitExceededError", () => {
|
||||
it("captures filePath / fileSize / message", () => {
|
||||
const err = new UploadDailyLimitExceededError("/tmp/x.mp4", 123, "quota exceeded");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe("UploadDailyLimitExceededError");
|
||||
expect(err.filePath).toBe("/tmp/x.mp4");
|
||||
expect(err.fileSize).toBe(123);
|
||||
expect(err.message).toBe("quota exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects url / base64 sources up-front", async () => {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
const api = new ChunkedMediaApi(client, tm);
|
||||
|
||||
await expect(
|
||||
api.uploadChunked({
|
||||
scope: "c2c",
|
||||
targetId: "u1",
|
||||
fileType: MediaFileType.IMAGE,
|
||||
source: { kind: "url", url: "https://x" },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
}),
|
||||
).rejects.toThrow(/unsupported source kind 'url'/);
|
||||
|
||||
await expect(
|
||||
api.uploadChunked({
|
||||
scope: "c2c",
|
||||
targetId: "u1",
|
||||
fileType: MediaFileType.IMAGE,
|
||||
source: { kind: "base64", data: "AA==" },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
}),
|
||||
).rejects.toThrow(/unsupported source kind 'base64'/);
|
||||
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes the cache fast path and skips upload_prepare on hit", async () => {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
const cache = inMemoryCache();
|
||||
|
||||
// Seed cache with the md5 that uploadChunked will compute.
|
||||
const md5 = crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex");
|
||||
cache.set(md5, "c2c", "u1", MediaFileType.IMAGE, "cached-file-info", "uuid", 999);
|
||||
|
||||
const api = new ChunkedMediaApi(client, tm, { uploadCache: cache });
|
||||
|
||||
const result = await api.uploadChunked({
|
||||
scope: "c2c",
|
||||
targetId: "u1",
|
||||
fileType: MediaFileType.IMAGE,
|
||||
source: { kind: "buffer", buffer: FIXTURE_BUFFER },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
});
|
||||
|
||||
expect(result.file_info).toBe("cached-file-info");
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(cache.getSpy).toHaveBeenCalledWith(md5, "c2c", "u1", MediaFileType.IMAGE);
|
||||
});
|
||||
|
||||
it("runs prepare → COS PUT → part_finish → complete for a buffer source", async () => {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
const cache = inMemoryCache();
|
||||
const fetchSpy = stubFetchOk();
|
||||
|
||||
const prepareResp = makePrepareResponse("uid-1", 3);
|
||||
const completeResp: UploadMediaResponse = {
|
||||
file_uuid: "uuid-final",
|
||||
file_info: "final-file-info",
|
||||
ttl: 3600,
|
||||
};
|
||||
|
||||
// First request: upload_prepare; three follow-ups: upload_part_finish ×3
|
||||
// plus one complete. Because concurrency=2 the order of part_finish is
|
||||
// not strictly deterministic, so match on path + payload key.
|
||||
client.request.mockImplementation(
|
||||
async (_token: string, _method: string, pathLocal: string, body: unknown) => {
|
||||
const uploadBody = body as Record<string, unknown>;
|
||||
if (pathLocal.endsWith("/upload_prepare")) {
|
||||
expect(uploadBody.file_type).toBe(MediaFileType.FILE);
|
||||
expect(typeof uploadBody.md5).toBe("string");
|
||||
expect(typeof uploadBody.sha1).toBe("string");
|
||||
expect(typeof uploadBody.md5_10m).toBe("string");
|
||||
expect(uploadBody.file_size).toBe(FIXTURE_BUFFER.length);
|
||||
return prepareResp;
|
||||
}
|
||||
if (pathLocal.endsWith("/upload_part_finish")) {
|
||||
expect(uploadBody.upload_id).toBe("uid-1");
|
||||
expect(typeof uploadBody.part_index).toBe("number");
|
||||
return {};
|
||||
}
|
||||
if (pathLocal.endsWith("/files")) {
|
||||
expect(uploadBody.upload_id).toBe("uid-1");
|
||||
return completeResp;
|
||||
}
|
||||
throw new Error(`unexpected path ${pathLocal}`);
|
||||
},
|
||||
);
|
||||
|
||||
const api = new ChunkedMediaApi(client, tm, { uploadCache: cache });
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const result = await api.uploadChunked({
|
||||
scope: "group",
|
||||
targetId: "g1",
|
||||
fileType: MediaFileType.FILE,
|
||||
source: { kind: "buffer", buffer: FIXTURE_BUFFER, fileName: "blob.bin" },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
onProgress,
|
||||
});
|
||||
|
||||
expect(result).toEqual(completeResp);
|
||||
|
||||
// One prepare + 3 part_finish + 1 complete = 5 client requests.
|
||||
expect(client.request).toHaveBeenCalledTimes(5);
|
||||
|
||||
// 3 COS PUTs, one per part, each to the presigned URL.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
const putUrls = fetchSpy.mock.calls.map((c) => (c[0] as { url: string }).url);
|
||||
expect(new Set(putUrls)).toEqual(
|
||||
new Set([
|
||||
"https://cos.example.com/part-1",
|
||||
"https://cos.example.com/part-2",
|
||||
"https://cos.example.com/part-3",
|
||||
]),
|
||||
);
|
||||
|
||||
// FILE uploads carry filename metadata in upload_prepare, so the content-only
|
||||
// cache is bypassed to avoid reusing file_info with a stale name.
|
||||
expect(cache.getSpy).not.toHaveBeenCalled();
|
||||
expect(cache.setSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Progress callback hit 3 times with monotonically-increasing counts.
|
||||
expect(onProgress).toHaveBeenCalledTimes(3);
|
||||
const last = onProgress.mock.calls.at(2)?.[0];
|
||||
expect(last.completedParts).toBe(3);
|
||||
expect(last.totalParts).toBe(3);
|
||||
expect(last.uploadedBytes).toBe(FIXTURE_BUFFER.length);
|
||||
expect(last.totalBytes).toBe(FIXTURE_BUFFER.length);
|
||||
});
|
||||
|
||||
it("bounds COS PUT error bodies without using response.text()", async () => {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
const logger = { info: vi.fn(), error: vi.fn(), warn: vi.fn() };
|
||||
client.request.mockImplementation(async (_token, _method, pathLocal) => {
|
||||
if (pathLocal.endsWith("/upload_prepare")) {
|
||||
return makePrepareResponse("uid-bounded", 1);
|
||||
}
|
||||
throw new Error(`unexpected path ${pathLocal}`);
|
||||
});
|
||||
|
||||
const releases = [vi.fn(async () => {}), vi.fn(async () => {}), vi.fn(async () => {})];
|
||||
const trackedResponses = releases.map((release) => {
|
||||
const tracked = cancelTrackedResponse(`${"cos gateway unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
"x-cos-request-id": "req-bounded",
|
||||
},
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
return {
|
||||
response: tracked.response,
|
||||
wasCanceled: tracked.wasCanceled,
|
||||
release,
|
||||
textSpy,
|
||||
};
|
||||
});
|
||||
const pendingResponses = [...trackedResponses];
|
||||
|
||||
fetchWithSsrFGuardMock.mockImplementation(async () => {
|
||||
const next = pendingResponses.shift();
|
||||
if (!next) {
|
||||
throw new Error("unexpected extra COS PUT attempt");
|
||||
}
|
||||
return {
|
||||
response: next.response,
|
||||
release: next.release,
|
||||
};
|
||||
});
|
||||
|
||||
const api = new ChunkedMediaApi(client, tm, { logger });
|
||||
let error: unknown;
|
||||
try {
|
||||
await api.uploadChunked({
|
||||
scope: "group",
|
||||
targetId: "g1",
|
||||
fileType: MediaFileType.FILE,
|
||||
source: { kind: "buffer", buffer: Buffer.from("01234567"), fileName: "blob.bin" },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(String(error)).toContain("COS PUT failed: 503 Service Unavailable");
|
||||
expect(String(error)).toContain("cos gateway unavailable");
|
||||
expect(String(error)).not.toContain("tail");
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(3);
|
||||
for (const tracked of trackedResponses) {
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(tracked.textSpy).not.toHaveBeenCalled();
|
||||
expect(tracked.release).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
expect(JSON.stringify(logger.error.mock.calls)).toContain("cos gateway unavailable");
|
||||
expect(JSON.stringify(logger.error.mock.calls)).not.toContain("tail");
|
||||
});
|
||||
|
||||
it("maps UPLOAD_PREPARE_FALLBACK_CODE to UploadDailyLimitExceededError", async () => {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
client.request.mockRejectedValueOnce(
|
||||
new ApiError(
|
||||
"daily limit exceeded",
|
||||
200,
|
||||
"/v2/users/u1/upload_prepare",
|
||||
UPLOAD_PREPARE_FALLBACK_CODE,
|
||||
"quota",
|
||||
),
|
||||
);
|
||||
|
||||
const api = new ChunkedMediaApi(client, tm);
|
||||
await expect(
|
||||
api.uploadChunked({
|
||||
scope: "c2c",
|
||||
targetId: "u1",
|
||||
fileType: MediaFileType.FILE,
|
||||
source: { kind: "buffer", buffer: FIXTURE_BUFFER, fileName: "big.bin" },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UploadDailyLimitExceededError);
|
||||
});
|
||||
|
||||
it("streams hashes from a localPath source", async () => {
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chunked-"));
|
||||
const filePath = path.join(tmp, "fixture.bin");
|
||||
await fs.promises.writeFile(filePath, FIXTURE_BUFFER);
|
||||
try {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
stubFetchOk();
|
||||
|
||||
client.request.mockImplementation(async (_t, _m, p) => {
|
||||
if (p.endsWith("/upload_prepare")) {
|
||||
return makePrepareResponse("uid-2", 3);
|
||||
}
|
||||
if (p.endsWith("/upload_part_finish")) {
|
||||
return {};
|
||||
}
|
||||
if (p.endsWith("/files")) {
|
||||
return { file_uuid: "u", file_info: "fi", ttl: 10 } satisfies UploadMediaResponse;
|
||||
}
|
||||
throw new Error(`unexpected ${p}`);
|
||||
});
|
||||
|
||||
const api = new ChunkedMediaApi(client, tm);
|
||||
const result = await api.uploadChunked({
|
||||
scope: "c2c",
|
||||
targetId: "u1",
|
||||
fileType: MediaFileType.VIDEO,
|
||||
source: { kind: "localPath", path: filePath, size: FIXTURE_BUFFER.length },
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
});
|
||||
|
||||
expect(result.file_info).toBe("fi");
|
||||
|
||||
// Verify prepare received the md5 of the on-disk bytes.
|
||||
const prepareCall = client.request.mock.calls.find((c) => c[2].endsWith("/upload_prepare"))!;
|
||||
const prepareBody = prepareCall[3] as { md5: string; file_name: string };
|
||||
expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex"));
|
||||
expect(prepareBody.file_name).toBe("fixture.bin");
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the verified localPath handle if the path is replaced before chunked upload", async () => {
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chunked-verified-"));
|
||||
const filePath = path.join(tmp, "fixture.bin");
|
||||
await fs.promises.writeFile(filePath, FIXTURE_BUFFER);
|
||||
const source = await normalizeSource({ localPath: filePath }, { maxSize: 1_000_000 });
|
||||
await fs.promises.rm(filePath);
|
||||
await fs.promises.writeFile(filePath, Buffer.from("replacement bytes"));
|
||||
try {
|
||||
const client = mockApiClient();
|
||||
const tm = mockTokenManager();
|
||||
stubFetchOk();
|
||||
|
||||
client.request.mockImplementation(async (_t, _m, p) => {
|
||||
if (p.endsWith("/upload_prepare")) {
|
||||
return makePrepareResponse("uid-verified", 3);
|
||||
}
|
||||
if (p.endsWith("/upload_part_finish")) {
|
||||
return {};
|
||||
}
|
||||
if (p.endsWith("/files")) {
|
||||
return { file_uuid: "u", file_info: "fi", ttl: 10 } satisfies UploadMediaResponse;
|
||||
}
|
||||
throw new Error(`unexpected ${p}`);
|
||||
});
|
||||
|
||||
const api = new ChunkedMediaApi(client, tm);
|
||||
await api.uploadChunked({
|
||||
scope: "c2c",
|
||||
targetId: "u1",
|
||||
fileType: MediaFileType.VIDEO,
|
||||
source,
|
||||
creds: { appId: "a", clientSecret: "s" },
|
||||
});
|
||||
|
||||
const prepareCall = client.request.mock.calls.find((c) => c[2].endsWith("/upload_prepare"))!;
|
||||
const prepareBody = prepareCall[3] as { md5: string };
|
||||
expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex"));
|
||||
} finally {
|
||||
if (source.kind === "localPath") {
|
||||
await source.opened?.close().catch(() => undefined);
|
||||
}
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
634
extensions/qqbot/src/engine/api/media-chunked.ts
Normal file
634
extensions/qqbot/src/engine/api/media-chunked.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Chunked media upload for the QQ Open Platform.
|
||||
*
|
||||
* ## Flow (mirrors the upload sequence diagram)
|
||||
*
|
||||
* 1. `upload_prepare` — submit file metadata + (md5 / sha1 / md5_10m) hashes,
|
||||
* receive `{ upload_id, block_size, parts[], concurrency?, retry_timeout? }`.
|
||||
* 2. For every part (parallelized under a bounded concurrency):
|
||||
* a. Read the part bytes (stream from disk or slice in-memory buffer).
|
||||
* b. PUT the bytes to the pre-signed COS URL.
|
||||
* c. POST `upload_part_finish { upload_id, part_index, block_size, md5 }`,
|
||||
* retrying under {@link PART_FINISH_RETRY_POLICY} + the persistent
|
||||
* retry loop for {@link PART_FINISH_RETRYABLE_CODES}.
|
||||
* 3. POST `complete_upload { upload_id }` — returns `{ file_uuid, file_info,
|
||||
* ttl }` identical to the one-shot path.
|
||||
* 4. If `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
|
||||
* (`40093002` — daily upload quota exceeded), throw
|
||||
* {@link UploadDailyLimitExceededError} so the upper layer can surface a
|
||||
* user-facing message. The dispatcher is responsible for the fallback
|
||||
* (there is no server path that will accept the file at this point).
|
||||
*
|
||||
* ## Why a class
|
||||
*
|
||||
* Mirrors {@link MediaApi}: injects {@link ApiClient}, {@link TokenManager},
|
||||
* the upload cache adapter, an optional filename sanitizer, and a logger.
|
||||
* Keeping the client singleton plumbing consistent means only one place
|
||||
* manages UA / baseUrl / file-upload timeouts.
|
||||
*
|
||||
* ## Upload cache integration
|
||||
*
|
||||
* Chunked uploads participate in the same `file_info` cache as
|
||||
* {@link MediaApi.uploadMedia}. The cache key is derived from the full-file
|
||||
* md5 (already computed for `upload_prepare`) so repeat sends of the same
|
||||
* large file hit the cache before we even talk to `upload_prepare`.
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { MediaSource, OpenedLocalFile } from "../messaging/media-source.js";
|
||||
import { openLocalFile } from "../messaging/media-source.js";
|
||||
import {
|
||||
ApiError,
|
||||
MediaFileType,
|
||||
type ChatScope,
|
||||
type EngineLogger,
|
||||
type UploadMediaResponse,
|
||||
type UploadPart,
|
||||
type UploadPrepareHashes,
|
||||
type UploadPrepareResponse,
|
||||
} from "../types.js";
|
||||
import { formatFileSize } from "../utils/file-utils.js";
|
||||
import type { ApiClient } from "./api-client.js";
|
||||
import type { SanitizeFileNameFn, UploadCacheAdapter } from "./media.js";
|
||||
import {
|
||||
buildPartFinishPersistentPolicy,
|
||||
COMPLETE_UPLOAD_RETRY_POLICY,
|
||||
PART_FINISH_RETRY_POLICY,
|
||||
UPLOAD_PREPARE_FALLBACK_CODE,
|
||||
withRetry,
|
||||
} from "./retry.js";
|
||||
import { uploadCompletePath, uploadPartFinishPath, uploadPreparePath } from "./routes.js";
|
||||
import type { TokenManager } from "./token.js";
|
||||
|
||||
// ============ Public types ============
|
||||
|
||||
/**
|
||||
* Raised when `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
|
||||
* (40093002). Carries enough context for the outbound layer to render a
|
||||
* user-facing fallback message (file name, size, and the originating
|
||||
* local path when available).
|
||||
*/
|
||||
export class UploadDailyLimitExceededError extends Error {
|
||||
override readonly name = "UploadDailyLimitExceededError";
|
||||
|
||||
constructor(
|
||||
/** Original local file path, or `"<buffer>"` when uploading an in-memory buffer. */
|
||||
public readonly filePath: string,
|
||||
/** File size in bytes. */
|
||||
public readonly fileSize: number,
|
||||
/** Original error message from the server. */
|
||||
originalMessage: string,
|
||||
) {
|
||||
super(originalMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/** Chunked-upload progress callback payload. */
|
||||
interface ChunkedUploadProgress {
|
||||
completedParts: number;
|
||||
totalParts: number;
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
/** Per-call options for {@link ChunkedMediaApi.uploadChunked}. */
|
||||
interface UploadChunkedOptions {
|
||||
scope: ChatScope;
|
||||
targetId: string;
|
||||
fileType: MediaFileType;
|
||||
source: MediaSource;
|
||||
creds: { appId: string; clientSecret: string };
|
||||
/**
|
||||
* Optional filename override. When omitted, derived from `source.path`
|
||||
* (localPath) / `source.fileName` (buffer) / `"file"` (fallback).
|
||||
*/
|
||||
fileName?: string;
|
||||
/** Progress callback invoked after every successful part. */
|
||||
onProgress?: (progress: ChunkedUploadProgress) => void;
|
||||
/** Log prefix — defaults to `"[qqbot:chunked-upload]"`. */
|
||||
logPrefix?: string;
|
||||
}
|
||||
|
||||
/** Configuration for the {@link ChunkedMediaApi} constructor. */
|
||||
interface ChunkedMediaApiConfig {
|
||||
logger?: EngineLogger;
|
||||
/** Upload cache adapter (optional; omit to disable caching). */
|
||||
uploadCache?: UploadCacheAdapter;
|
||||
/** File name sanitizer — defaults to identity. */
|
||||
sanitizeFileName?: SanitizeFileNameFn;
|
||||
}
|
||||
|
||||
// ============ Tuning constants ============
|
||||
|
||||
/** Default concurrency when the server does not specify one. */
|
||||
const DEFAULT_CONCURRENT_PARTS = 1;
|
||||
|
||||
/** Hard cap on per-upload concurrency regardless of what the server returns. */
|
||||
const MAX_CONCURRENT_PARTS = 10;
|
||||
|
||||
/**
|
||||
* Upper bound on the persistent-retry window for `upload_part_finish`.
|
||||
*
|
||||
* The server may suggest `retry_timeout` via `upload_prepare` — we honor
|
||||
* it but clamp to 10 minutes so a runaway server can't hold the caller
|
||||
* hostage.
|
||||
*/
|
||||
const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Per-part PUT timeout (5 minutes). Matches the low-bandwidth tolerance. */
|
||||
const PART_UPLOAD_TIMEOUT_MS = 300_000;
|
||||
const PART_UPLOAD_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Boundary used by `md5_10m` — first 10,002,432 bytes.
|
||||
*
|
||||
* Files smaller than this return the whole-file md5 for `md5_10m` (per the
|
||||
* server contract).
|
||||
*/
|
||||
const MD5_10M_SIZE = 10_002_432;
|
||||
|
||||
// ============ Class ============
|
||||
|
||||
/**
|
||||
* Chunked upload module. Stateless across calls — see
|
||||
* {@link ChunkedMediaApi.uploadChunked} for the main entry.
|
||||
*/
|
||||
export class ChunkedMediaApi {
|
||||
private readonly client: ApiClient;
|
||||
private readonly tokenManager: TokenManager;
|
||||
private readonly logger?: EngineLogger;
|
||||
private readonly cache?: UploadCacheAdapter;
|
||||
private readonly sanitize: SanitizeFileNameFn;
|
||||
|
||||
constructor(client: ApiClient, tokenManager: TokenManager, config: ChunkedMediaApiConfig = {}) {
|
||||
this.client = client;
|
||||
this.tokenManager = tokenManager;
|
||||
this.logger = config.logger;
|
||||
this.cache = config.uploadCache;
|
||||
this.sanitize = config.sanitizeFileName ?? ((n) => n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a {@link MediaSource} via the chunked endpoint. Only `localPath`
|
||||
* and `buffer` sources are accepted — `url` / `base64` must fall through
|
||||
* to {@link MediaApi.uploadMedia}.
|
||||
*
|
||||
* @throws {UploadDailyLimitExceededError} when `upload_prepare` returns
|
||||
* {@link UPLOAD_PREPARE_FALLBACK_CODE}.
|
||||
*/
|
||||
async uploadChunked(opts: UploadChunkedOptions): Promise<UploadMediaResponse> {
|
||||
const prefix = opts.logPrefix ?? "[qqbot:chunked-upload]";
|
||||
|
||||
// 1. Resolve input: size + verified local file descriptor (or buffer).
|
||||
const input = await resolveSource(opts.source, opts.fileName);
|
||||
|
||||
try {
|
||||
const displayName = input.fileName;
|
||||
const fileSize = input.size;
|
||||
const pathLabel = input.kind === "localPath" ? input.path : "<buffer>";
|
||||
|
||||
this.logger?.info?.(
|
||||
`${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`,
|
||||
);
|
||||
|
||||
// 2. Compute md5 / sha1 / md5_10m. Identical for buffer and localPath,
|
||||
// but the localPath descriptor streams so it never has to materialize the
|
||||
// whole file twice or reopen a path after validation.
|
||||
const hashes = await computeHashes(input);
|
||||
this.logger?.debug?.(
|
||||
`${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`,
|
||||
);
|
||||
|
||||
// 3. Upload-cache fast path: the md5 hash is already a strong content
|
||||
// identifier, so we can short-circuit before even calling upload_prepare.
|
||||
const canUseUploadCache = opts.fileType !== MediaFileType.FILE;
|
||||
if (this.cache && canUseUploadCache) {
|
||||
const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType);
|
||||
if (cached) {
|
||||
this.logger?.info?.(
|
||||
`${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`,
|
||||
);
|
||||
return { file_uuid: "", file_info: cached, ttl: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. upload_prepare.
|
||||
const fileNameForPrepare =
|
||||
opts.fileType === MediaFileType.FILE ? this.sanitize(displayName) : displayName;
|
||||
const prepareResp = await this.callUploadPrepare(
|
||||
opts,
|
||||
fileNameForPrepare,
|
||||
fileSize,
|
||||
hashes,
|
||||
pathLabel,
|
||||
);
|
||||
|
||||
const { upload_id, parts } = prepareResp;
|
||||
const block_size = prepareResp.block_size;
|
||||
const maxConcurrent = Math.min(
|
||||
prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS,
|
||||
MAX_CONCURRENT_PARTS,
|
||||
);
|
||||
const retryTimeoutMs = prepareResp.retry_timeout
|
||||
? Math.min(prepareResp.retry_timeout * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
|
||||
: undefined;
|
||||
|
||||
this.logger?.info?.(
|
||||
`${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`,
|
||||
);
|
||||
|
||||
// 5. Upload every part. Concurrency is per-upload, not global.
|
||||
let completedParts = 0;
|
||||
let uploadedBytes = 0;
|
||||
|
||||
const uploadPart = async (part: UploadPart): Promise<void> => {
|
||||
const partIndex = part.index; // 1-based.
|
||||
const offset = (partIndex - 1) * block_size;
|
||||
const length = Math.min(block_size, fileSize - offset);
|
||||
|
||||
const partBuffer = await readPart(input, offset, length);
|
||||
const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
|
||||
|
||||
this.logger?.debug?.(
|
||||
`${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`,
|
||||
);
|
||||
|
||||
// 5a. PUT to pre-signed COS URL.
|
||||
await putToPresignedUrl(
|
||||
part.presigned_url,
|
||||
partBuffer,
|
||||
partIndex,
|
||||
parts.length,
|
||||
this.logger,
|
||||
prefix,
|
||||
);
|
||||
|
||||
// 5b. upload_part_finish — fetch a fresh token each time to defend
|
||||
// against long uploads exceeding the token TTL.
|
||||
await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
|
||||
|
||||
completedParts++;
|
||||
uploadedBytes += length;
|
||||
this.logger?.info?.(
|
||||
`${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`,
|
||||
);
|
||||
|
||||
opts.onProgress?.({
|
||||
completedParts,
|
||||
totalParts: parts.length,
|
||||
uploadedBytes,
|
||||
totalBytes: fileSize,
|
||||
});
|
||||
};
|
||||
|
||||
await runWithConcurrency(
|
||||
parts.map((part) => () => uploadPart(part)),
|
||||
maxConcurrent,
|
||||
);
|
||||
|
||||
this.logger?.info?.(`${prefix} all parts uploaded, completing...`);
|
||||
|
||||
// 6. complete_upload.
|
||||
const result = await this.callCompleteUpload(opts, upload_id);
|
||||
this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`);
|
||||
|
||||
// 7. Populate the shared upload cache so subsequent sends skip re-uploading.
|
||||
if (this.cache && canUseUploadCache && result.file_info && result.ttl > 0) {
|
||||
this.cache.set(
|
||||
hashes.md5,
|
||||
opts.scope,
|
||||
opts.targetId,
|
||||
opts.fileType,
|
||||
result.file_info,
|
||||
result.file_uuid,
|
||||
result.ttl,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (input.kind === "localPath" && input.closeWhenDone) {
|
||||
await input.opened.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Internal call wrappers --------
|
||||
|
||||
private async callUploadPrepare(
|
||||
opts: UploadChunkedOptions,
|
||||
fileName: string,
|
||||
fileSize: number,
|
||||
hashes: UploadPrepareHashes,
|
||||
pathLabel: string,
|
||||
): Promise<UploadPrepareResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
||||
const path = uploadPreparePath(opts.scope, opts.targetId);
|
||||
try {
|
||||
return await this.client.request<UploadPrepareResponse>(
|
||||
token,
|
||||
"POST",
|
||||
path,
|
||||
{
|
||||
file_type: opts.fileType,
|
||||
file_name: fileName,
|
||||
file_size: fileSize,
|
||||
md5: hashes.md5,
|
||||
sha1: hashes.sha1,
|
||||
md5_10m: hashes.md5_10m,
|
||||
},
|
||||
{ uploadRequest: true },
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
||||
throw new UploadDailyLimitExceededError(pathLabel, fileSize, err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async callUploadPartFinish(
|
||||
opts: UploadChunkedOptions,
|
||||
uploadId: string,
|
||||
partIndex: number,
|
||||
blockSize: number,
|
||||
md5: string,
|
||||
retryTimeoutMs?: number,
|
||||
): Promise<void> {
|
||||
const persistentPolicy = buildPartFinishPersistentPolicy(retryTimeoutMs);
|
||||
const path = uploadPartFinishPath(opts.scope, opts.targetId);
|
||||
await withRetry(
|
||||
async () => {
|
||||
// Refresh the token on every attempt — the token may be expired by
|
||||
// the time we reach the tail of a long upload.
|
||||
const token = await this.tokenManager.getAccessToken(
|
||||
opts.creds.appId,
|
||||
opts.creds.clientSecret,
|
||||
);
|
||||
return this.client.request(
|
||||
token,
|
||||
"POST",
|
||||
path,
|
||||
{
|
||||
upload_id: uploadId,
|
||||
part_index: partIndex,
|
||||
block_size: blockSize,
|
||||
md5,
|
||||
},
|
||||
{ uploadRequest: true },
|
||||
);
|
||||
},
|
||||
PART_FINISH_RETRY_POLICY,
|
||||
persistentPolicy,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
private async callCompleteUpload(
|
||||
opts: UploadChunkedOptions,
|
||||
uploadId: string,
|
||||
): Promise<UploadMediaResponse> {
|
||||
const path = uploadCompletePath(opts.scope, opts.targetId);
|
||||
return withRetry(
|
||||
async () => {
|
||||
const token = await this.tokenManager.getAccessToken(
|
||||
opts.creds.appId,
|
||||
opts.creds.clientSecret,
|
||||
);
|
||||
return this.client.request<UploadMediaResponse>(
|
||||
token,
|
||||
"POST",
|
||||
path,
|
||||
{ upload_id: uploadId },
|
||||
{ uploadRequest: true },
|
||||
);
|
||||
},
|
||||
COMPLETE_UPLOAD_RETRY_POLICY,
|
||||
undefined,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Source resolution ============
|
||||
|
||||
/**
|
||||
* Normalized chunked-upload input: everything the uploader needs to read
|
||||
* the bytes plus the metadata required by `upload_prepare`.
|
||||
*/
|
||||
type ChunkedInput =
|
||||
| {
|
||||
kind: "localPath";
|
||||
path: string;
|
||||
size: number;
|
||||
fileName: string;
|
||||
opened: OpenedLocalFile;
|
||||
closeWhenDone: boolean;
|
||||
}
|
||||
| { kind: "buffer"; buffer: Buffer; size: number; fileName: string };
|
||||
|
||||
async function resolveSource(
|
||||
source: MediaSource,
|
||||
fileNameOverride?: string,
|
||||
): Promise<ChunkedInput> {
|
||||
if (source.kind === "localPath") {
|
||||
const inferredName = source.path.split(/[/\\]/).pop() || "file";
|
||||
const opened =
|
||||
source.opened ?? (await openLocalFile(source.path, { maxSize: Number.MAX_SAFE_INTEGER }));
|
||||
return {
|
||||
kind: "localPath",
|
||||
path: source.path,
|
||||
size: opened.size,
|
||||
fileName: fileNameOverride ?? inferredName,
|
||||
opened,
|
||||
closeWhenDone: source.opened === undefined,
|
||||
};
|
||||
}
|
||||
if (source.kind === "buffer") {
|
||||
return {
|
||||
kind: "buffer",
|
||||
buffer: source.buffer,
|
||||
size: source.buffer.length,
|
||||
fileName: fileNameOverride ?? source.fileName ?? "file",
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`ChunkedMediaApi: unsupported source kind '${source.kind}'. ` +
|
||||
"Chunked upload only supports 'localPath' and 'buffer'; route 'url'/'base64' through the one-shot uploader.",
|
||||
);
|
||||
}
|
||||
|
||||
async function readPart(input: ChunkedInput, offset: number, length: number): Promise<Buffer> {
|
||||
if (input.kind === "buffer") {
|
||||
return input.buffer.subarray(offset, offset + length);
|
||||
}
|
||||
const buf = Buffer.alloc(length);
|
||||
const { bytesRead } = await input.opened.handle.read(buf, 0, length, offset);
|
||||
return bytesRead < length ? buf.subarray(0, bytesRead) : buf;
|
||||
}
|
||||
|
||||
// ============ Hash computation ============
|
||||
|
||||
/**
|
||||
* Stream the source once to compute md5 + sha1 + md5_10m.
|
||||
*
|
||||
* For buffer inputs the three hashes are computed in a single pass over
|
||||
* the existing memory. For localPath inputs the verified descriptor drives
|
||||
* the hashers so memory use stays constant.
|
||||
*/
|
||||
async function computeHashes(input: ChunkedInput): Promise<UploadPrepareHashes> {
|
||||
if (input.kind === "buffer") {
|
||||
const md5 = crypto.createHash("md5").update(input.buffer).digest("hex");
|
||||
const sha1 = crypto.createHash("sha1").update(input.buffer).digest("hex");
|
||||
const md5_10m =
|
||||
input.size > MD5_10M_SIZE
|
||||
? crypto.createHash("md5").update(input.buffer.subarray(0, MD5_10M_SIZE)).digest("hex")
|
||||
: md5;
|
||||
return { md5, sha1, md5_10m };
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const md5 = crypto.createHash("md5");
|
||||
const sha1 = crypto.createHash("sha1");
|
||||
const md5_10m = crypto.createHash("md5");
|
||||
let consumed = 0;
|
||||
const needsMd5_10m = input.size > MD5_10M_SIZE;
|
||||
|
||||
const stream = createReadStreamFromHandle(input.opened.handle);
|
||||
stream.on("data", (chunk: Buffer | string) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
md5.update(buf);
|
||||
sha1.update(buf);
|
||||
if (needsMd5_10m) {
|
||||
const remaining = MD5_10M_SIZE - consumed;
|
||||
if (remaining > 0) {
|
||||
md5_10m.update(remaining >= buf.length ? buf : buf.subarray(0, remaining));
|
||||
}
|
||||
}
|
||||
consumed += buf.length;
|
||||
});
|
||||
stream.on("end", () => {
|
||||
const md5Hex = md5.digest("hex");
|
||||
const sha1Hex = sha1.digest("hex");
|
||||
resolve({
|
||||
md5: md5Hex,
|
||||
sha1: sha1Hex,
|
||||
md5_10m: needsMd5_10m ? md5_10m.digest("hex") : md5Hex,
|
||||
});
|
||||
});
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function createReadStreamFromHandle(handle: FileHandle): NodeJS.ReadableStream {
|
||||
return handle.createReadStream({ autoClose: false, start: 0 });
|
||||
}
|
||||
|
||||
// ============ COS PUT ============
|
||||
|
||||
/** Per-part retry budget for the COS PUT call (exponential backoff). */
|
||||
const PART_UPLOAD_MAX_RETRIES = 2;
|
||||
|
||||
async function putToPresignedUrl(
|
||||
presignedUrl: string,
|
||||
data: Buffer,
|
||||
partIndex: number,
|
||||
totalParts: number,
|
||||
logger: EngineLogger | undefined,
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// Convert to a standard ArrayBuffer before wrapping in Blob so type
|
||||
// definitions (incl. bun-types) accept the argument.
|
||||
const ab = data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength,
|
||||
) as ArrayBuffer;
|
||||
|
||||
const startTime = Date.now();
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: presignedUrl,
|
||||
auditContext: "qqbot-media-part-upload",
|
||||
init: {
|
||||
method: "PUT",
|
||||
body: new Blob([ab]),
|
||||
headers: { "Content-Length": String(data.length) },
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const requestId = response.headers.get("x-cos-request-id") ?? "-";
|
||||
const etag = response.headers.get("ETag") ?? "-";
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await readResponseTextLimited(
|
||||
response,
|
||||
PART_UPLOAD_ERROR_BODY_LIMIT_BYTES,
|
||||
).catch(() => "");
|
||||
logger?.error?.(
|
||||
`${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`,
|
||||
);
|
||||
throw new Error(
|
||||
`COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`,
|
||||
);
|
||||
}
|
||||
|
||||
logger?.debug?.(
|
||||
`${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`,
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (lastError.name === "AbortError") {
|
||||
lastError = new Error(
|
||||
`Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT_MS}ms`,
|
||||
);
|
||||
}
|
||||
if (attempt < PART_UPLOAD_MAX_RETRIES) {
|
||||
const delay = 1000 * 2 ** attempt;
|
||||
(logger?.warn ?? logger?.error)?.(
|
||||
`${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${lastError.message.slice(0, 120)}), retrying in ${delay}ms`,
|
||||
);
|
||||
await sleep(delay);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error(`Part ${partIndex}/${totalParts} upload failed`);
|
||||
}
|
||||
|
||||
// ============ Concurrency ============
|
||||
|
||||
/**
|
||||
* Batch-mode concurrency limiter. Deliberately simple: dispatch N tasks at
|
||||
* a time and wait for the whole batch to settle before the next batch.
|
||||
*
|
||||
* A pool / queue implementation would recover some throughput when tasks
|
||||
* have heavy variance, but part uploads are size-uniform (last part can be
|
||||
* short) so the extra complexity is not worth it.
|
||||
*/
|
||||
async function runWithConcurrency(
|
||||
tasks: Array<() => Promise<void>>,
|
||||
maxConcurrent: number,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < tasks.length; i += maxConcurrent) {
|
||||
const batch = tasks.slice(i, i + maxConcurrent);
|
||||
await Promise.all(batch.map((task) => task()));
|
||||
}
|
||||
}
|
||||
449
extensions/qqbot/src/engine/api/media.test.ts
Normal file
449
extensions/qqbot/src/engine/api/media.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
// Qqbot tests cover media plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MediaFileType, type UploadMediaResponse } from "../types.js";
|
||||
import { MAX_UPLOAD_SIZE } from "../utils/file-utils.js";
|
||||
import { ApiClient } from "./api-client.js";
|
||||
import { MediaApi } from "./media.js";
|
||||
import { TokenManager } from "./token.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
const readResponseWithLimitMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/response-limit-runtime", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/response-limit-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readResponseWithLimit: readResponseWithLimitMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
};
|
||||
});
|
||||
|
||||
const UPLOAD_RESPONSE: UploadMediaResponse = {
|
||||
file_uuid: "uuid-1",
|
||||
file_info: "file-info-1",
|
||||
ttl: 3600,
|
||||
};
|
||||
|
||||
const MEDIA_BYTES = Buffer.from("downloaded-media");
|
||||
const MEDIA_BASE64 = MEDIA_BYTES.toString("base64");
|
||||
|
||||
function mockGuardedResponse(
|
||||
body: BodyInit = MEDIA_BYTES,
|
||||
init?: ResponseInit,
|
||||
): {
|
||||
release: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(body, init),
|
||||
release,
|
||||
});
|
||||
return { release };
|
||||
}
|
||||
|
||||
function mockApiClient(): ApiClient {
|
||||
const client = new ApiClient();
|
||||
vi.spyOn(client, "request").mockResolvedValue(UPLOAD_RESPONSE);
|
||||
return client;
|
||||
}
|
||||
|
||||
function mockTokenManager(): TokenManager {
|
||||
const tokenManager = new TokenManager();
|
||||
vi.spyOn(tokenManager, "getAccessToken").mockResolvedValue("token-1");
|
||||
return tokenManager;
|
||||
}
|
||||
|
||||
function expectGuardedDownload(url: string): void {
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
|
||||
url,
|
||||
maxRedirects: 0,
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
const signal = fetchWithSsrFGuardMock.mock.calls.at(-1)?.[0]?.signal;
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
|
||||
describe("MediaApi.uploadMedia direct URL uploads", () => {
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
readResponseWithLimitMock.mockReset();
|
||||
readResponseWithLimitMock.mockResolvedValue(MEDIA_BYTES);
|
||||
mockGuardedResponse();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ fileType: MediaFileType.IMAGE, url: "https://cdn.example.com/assets/photo.png" },
|
||||
{ fileType: MediaFileType.VIDEO, url: "http://cdn.example.com/assets/video.mp4" },
|
||||
{ fileType: MediaFileType.FILE, url: "http://cdn.example.com/assets/report.pdf" },
|
||||
])(
|
||||
"downloads public HTTP(S) $fileType URLs through the pinned SSRF guard",
|
||||
async ({ fileType, url }) => {
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
const result = await api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
fileType,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url },
|
||||
);
|
||||
|
||||
expect(result).toBe(UPLOAD_RESPONSE);
|
||||
expectGuardedDownload(url);
|
||||
expect(readResponseWithLimitMock).toHaveBeenCalledWith(
|
||||
expect.any(Response),
|
||||
MAX_UPLOAD_SIZE,
|
||||
{ chunkTimeoutMs: 10_000 },
|
||||
);
|
||||
expect(tokenManager["getAccessToken"]).toHaveBeenCalledWith("app-id", "client-secret");
|
||||
expect(client["request"]).toHaveBeenCalledWith(
|
||||
"token-1",
|
||||
"POST",
|
||||
expect.any(String),
|
||||
{
|
||||
file_type: fileType,
|
||||
srv_send_msg: false,
|
||||
file_data: MEDIA_BASE64,
|
||||
},
|
||||
{
|
||||
redactBodyKeys: ["file_data"],
|
||||
uploadRequest: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("releases the pinned SSRF dispatcher after downloading media", async () => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
const { release } = mockGuardedResponse();
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://cdn.example.com/assets/photo.png" },
|
||||
);
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds stalled guarded fetch setup before reading URL bodies", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
fetchWithSsrFGuardMock.mockImplementationOnce(() => new Promise(() => {}));
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
const uploadPromise = api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://slow-dns.example.com/assets/photo.png" },
|
||||
);
|
||||
const rejection = expect(uploadPromise).rejects.toThrow(
|
||||
"Direct-upload media URL fetch timed out",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await rejection;
|
||||
expect(readResponseWithLimitMock).not.toHaveBeenCalled();
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects URL bodies that keep trickling under the idle timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
const { release } = mockGuardedResponse();
|
||||
readResponseWithLimitMock.mockReset();
|
||||
readResponseWithLimitMock.mockImplementationOnce(() => new Promise<Buffer>(() => {}));
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
const uploadPromise = api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://cdn.example.com/assets/slow.bin" },
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5 && readResponseWithLimitMock.mock.calls.length === 0; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(readResponseWithLimitMock).toHaveBeenCalledOnce();
|
||||
|
||||
const rejection = expect(uploadPromise).rejects.toThrow(
|
||||
"Direct-upload media URL body timed out",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(8 * 60_000);
|
||||
await rejection;
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes downloaded URL media through the base64 upload cache", async () => {
|
||||
const cache = {
|
||||
computeHash: vi.fn(() => "hash-1"),
|
||||
get: vi.fn(() => "cached-file-info"),
|
||||
set: vi.fn(),
|
||||
};
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager, { uploadCache: cache });
|
||||
|
||||
const result = await api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://cdn.example.com/assets/photo.png" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ file_uuid: "", file_info: "cached-file-info", ttl: 0 });
|
||||
expect(cache.computeHash).toHaveBeenCalledWith(MEDIA_BASE64);
|
||||
expect(cache.get).toHaveBeenCalledWith("hash-1", "c2c", "user-openid", MediaFileType.IMAGE);
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse cached FILE uploads when the requested filename differs", async () => {
|
||||
const cache = {
|
||||
computeHash: vi.fn(() => "hash-1"),
|
||||
get: vi.fn(() => "cached-file-info"),
|
||||
set: vi.fn(),
|
||||
};
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager, {
|
||||
uploadCache: cache,
|
||||
sanitizeFileName: (name) => `safe-${name}`,
|
||||
});
|
||||
|
||||
await api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.FILE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://cdn.example.com/report.pdf", fileName: "report.pdf" },
|
||||
);
|
||||
|
||||
expect(cache.computeHash).not.toHaveBeenCalled();
|
||||
expect(cache.get).not.toHaveBeenCalled();
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
expect(client["request"]).toHaveBeenCalledWith(
|
||||
"token-1",
|
||||
"POST",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
file_data: MEDIA_BASE64,
|
||||
file_name: "safe-report.pdf",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid direct-upload URLs before downloading media or calling the QQ API", async () => {
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await expect(
|
||||
api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "not a url" },
|
||||
),
|
||||
).rejects.toThrow("Direct-upload media URL must be a valid URL");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-HTTP direct-upload URLs before downloading media or calling the QQ API", async () => {
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await expect(
|
||||
api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "ftp://media.qq.com/assets/photo.png" },
|
||||
),
|
||||
).rejects.toThrow("Direct-upload media URL must use HTTP or HTTPS");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["127.0.0.1", "169.254.169.254", "10.0.0.1", "192.168.1.1"])(
|
||||
"does not upload direct URLs rejected by the SSRF guard: %s",
|
||||
async (host) => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await expect(
|
||||
api.uploadMedia(
|
||||
"group",
|
||||
"group-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: `https://${host}/latest/meta-data/` },
|
||||
),
|
||||
).rejects.toThrow("Blocked hostname");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not forward URLs when the guarded download fails", async () => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
fetchWithSsrFGuardMock.mockRejectedValueOnce(
|
||||
new Error("Blocked: resolves to private/internal/special-use IP address"),
|
||||
);
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await expect(
|
||||
api.uploadMedia(
|
||||
"group",
|
||||
"group-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://attacker.example/latest/meta-data/" },
|
||||
),
|
||||
).rejects.toThrow("resolves to private");
|
||||
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects literal RFC 2544 special-use URL hosts through the guarded download", async () => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await expect(
|
||||
api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://198.18.0.42/assets/photo.png" },
|
||||
),
|
||||
).rejects.toThrow("Blocked hostname");
|
||||
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps public literal IP URLs on the default SSRF policy", async () => {
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "http://93.184.216.34/assets/photo.png" },
|
||||
);
|
||||
|
||||
expectGuardedDownload("http://93.184.216.34/assets/photo.png");
|
||||
});
|
||||
|
||||
it("does not pass URL or fake-IP DNS policy to the QQ upload body", async () => {
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://cdn.example.com/assets/photo.png" },
|
||||
);
|
||||
|
||||
expectGuardedDownload("https://cdn.example.com/assets/photo.png");
|
||||
expect(client["request"]).toHaveBeenCalledWith(
|
||||
"token-1",
|
||||
"POST",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
file_data: MEDIA_BASE64,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(client["request"]).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({ url: expect.any(String) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects HTTP errors from guarded direct-upload downloads before calling the QQ API", async () => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
mockGuardedResponse("not found", { status: 404 });
|
||||
const client = mockApiClient();
|
||||
const tokenManager = mockTokenManager();
|
||||
const api = new MediaApi(client, tokenManager);
|
||||
|
||||
await expect(
|
||||
api.uploadMedia(
|
||||
"c2c",
|
||||
"user-openid",
|
||||
MediaFileType.IMAGE,
|
||||
{ appId: "app-id", clientSecret: "client-secret" },
|
||||
{ url: "https://cdn.example.com/missing.png" },
|
||||
),
|
||||
).rejects.toThrow("Direct-upload media URL returned HTTP 404");
|
||||
|
||||
expect(tokenManager["getAccessToken"]).not.toHaveBeenCalled();
|
||||
expect(client["request"]).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
342
extensions/qqbot/src/engine/api/media.ts
Normal file
342
extensions/qqbot/src/engine/api/media.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Media upload API for the QQ Open Platform (small-file direct upload).
|
||||
*
|
||||
* Key improvements:
|
||||
* - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`.
|
||||
* - Upload cache integration via composition (passed in constructor).
|
||||
* - Uses `withRetry` from the shared retry engine.
|
||||
*
|
||||
* Chunked upload for files above `LARGE_FILE_THRESHOLD` is tracked by
|
||||
* {@link ./media-chunked.ts}; this module currently handles only the
|
||||
* one-shot path.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard, isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
MediaFileType,
|
||||
type ChatScope,
|
||||
type UploadMediaResponse,
|
||||
type MessageResponse,
|
||||
type EngineLogger,
|
||||
} from "../types.js";
|
||||
import { MAX_UPLOAD_SIZE } from "../utils/file-utils.js";
|
||||
import { ApiClient } from "./api-client.js";
|
||||
import { withRetry, UPLOAD_RETRY_POLICY } from "./retry.js";
|
||||
import { mediaUploadPath, messagePath, getNextMsgSeq } from "./routes.js";
|
||||
import { TokenManager } from "./token.js";
|
||||
|
||||
/** Upload cache interface — the caller provides the implementation. */
|
||||
export interface UploadCacheAdapter {
|
||||
computeHash: (data: string) => string;
|
||||
get: (hash: string, scope: string, targetId: string, fileType: number) => string | null;
|
||||
set: (
|
||||
hash: string,
|
||||
scope: string,
|
||||
targetId: string,
|
||||
fileType: number,
|
||||
fileInfo: string,
|
||||
fileUuid: string,
|
||||
ttl: number,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** File name sanitizer — injected to avoid importing platform-specific utils. */
|
||||
export type SanitizeFileNameFn = (name: string) => string;
|
||||
|
||||
interface MediaApiConfig {
|
||||
logger?: EngineLogger;
|
||||
/** Upload cache adapter (optional, omit to disable caching). */
|
||||
uploadCache?: UploadCacheAdapter;
|
||||
/** File name sanitizer. */
|
||||
sanitizeFileName?: SanitizeFileNameFn;
|
||||
}
|
||||
|
||||
const DIRECT_UPLOAD_DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const DIRECT_UPLOAD_READ_IDLE_TIMEOUT_MS = 10_000;
|
||||
const DIRECT_UPLOAD_BODY_GRACE_TIMEOUT_MS = 30_000;
|
||||
const DIRECT_UPLOAD_MIN_DOWNLOAD_BYTES_PER_SECOND = 256 * 1024;
|
||||
const DIRECT_UPLOAD_MAX_BODY_TIMEOUT_MS = 8 * 60_000;
|
||||
|
||||
function assertDirectUploadDownloadHostAllowed(hostname: string): void {
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
throw new Error("Blocked hostname or private/internal/special-use IP address");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDirectUploadDownload(url: string) {
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new Error("Direct-upload media URL fetch timed out");
|
||||
let timedOut = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, DIRECT_UPLOAD_DOWNLOAD_TIMEOUT_MS);
|
||||
unrefTimer(timeout);
|
||||
});
|
||||
const guardedFetch = fetchWithSsrFGuard({
|
||||
url,
|
||||
maxRedirects: 0,
|
||||
signal: controller.signal,
|
||||
});
|
||||
void guardedFetch.then(
|
||||
(result) => {
|
||||
if (timedOut) {
|
||||
void result.release().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
try {
|
||||
return await Promise.race([guardedFetch, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unrefTimer(timeout: ReturnType<typeof setTimeout>): void {
|
||||
if (typeof timeout === "object" && "unref" in timeout) {
|
||||
(timeout as { unref: () => void }).unref();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDirectUploadBodyTimeoutMs(maxBytes: number): number {
|
||||
const transferTimeoutMs = Math.ceil(
|
||||
(maxBytes / DIRECT_UPLOAD_MIN_DOWNLOAD_BYTES_PER_SECOND) * 1000,
|
||||
);
|
||||
return Math.min(
|
||||
DIRECT_UPLOAD_BODY_GRACE_TIMEOUT_MS + transferTimeoutMs,
|
||||
DIRECT_UPLOAD_MAX_BODY_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
async function readDirectUploadResponse(response: Response, maxBytes: number): Promise<Buffer> {
|
||||
const timeoutMs = resolveDirectUploadBodyTimeoutMs(maxBytes);
|
||||
const timeoutError = new Error(`Direct-upload media URL body timed out after ${timeoutMs}ms`);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
void response.body?.cancel(timeoutError).catch(() => undefined);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
unrefTimer(timeout);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
readResponseWithLimit(response, maxBytes, {
|
||||
chunkTimeoutMs: DIRECT_UPLOAD_READ_IDLE_TIMEOUT_MS,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDirectUploadUrl(
|
||||
url: string,
|
||||
opts: { maxBytes?: number } = {},
|
||||
): Promise<Buffer> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error("Direct-upload media URL must be a valid URL");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Direct-upload media URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
assertDirectUploadDownloadHostAllowed(parsed.hostname);
|
||||
const { response, release } = await fetchDirectUploadDownload(parsed.toString());
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Direct-upload media URL returned HTTP ${response.status}`);
|
||||
}
|
||||
return await readDirectUploadResponse(response, opts.maxBytes ?? MAX_UPLOAD_SIZE);
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small-file media upload module.
|
||||
*
|
||||
* Handles base64 and URL-based uploads with optional caching and retry.
|
||||
*/
|
||||
export class MediaApi {
|
||||
private readonly client: ApiClient;
|
||||
private readonly tokenManager: TokenManager;
|
||||
private readonly logger?: EngineLogger;
|
||||
private readonly cache?: UploadCacheAdapter;
|
||||
private readonly sanitize: SanitizeFileNameFn;
|
||||
|
||||
constructor(client: ApiClient, tokenManager: TokenManager, config: MediaApiConfig = {}) {
|
||||
this.client = client;
|
||||
this.tokenManager = tokenManager;
|
||||
this.logger = config.logger;
|
||||
this.cache = config.uploadCache;
|
||||
this.sanitize = config.sanitizeFileName ?? ((n) => n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media via base64, URL, buffer, or local file path to a C2C or Group target.
|
||||
*
|
||||
* The `localPath` and `buffer` branches are equivalent to `fileData` for the
|
||||
* current one-shot implementation — the file is read and base64-encoded
|
||||
* synchronously. They exist as first-class inputs so that a future chunked
|
||||
* upload implementation can consume them without interface churn.
|
||||
*
|
||||
* @param scope - `'c2c'` or `'group'`.
|
||||
* @param targetId - User openid or group openid.
|
||||
* @param fileType - Media file type code.
|
||||
* @param creds - Authentication credentials.
|
||||
* @param opts - Upload options. Exactly one of `url`/`fileData`/`buffer`/`localPath`
|
||||
* must be supplied.
|
||||
* @returns Upload result containing `file_info` for subsequent message sends.
|
||||
*/
|
||||
async uploadMedia(
|
||||
scope: ChatScope,
|
||||
targetId: string,
|
||||
fileType: MediaFileType,
|
||||
creds: { appId: string; clientSecret: string },
|
||||
opts: {
|
||||
url?: string;
|
||||
fileData?: string;
|
||||
/**
|
||||
* Raw bytes in memory. Currently re-encoded to base64 internally;
|
||||
* reserved as a dedicated input for the future chunked uploader.
|
||||
*/
|
||||
buffer?: Buffer;
|
||||
/**
|
||||
* On-disk path. Currently read + base64-encoded internally; reserved
|
||||
* for streaming ingestion by the future chunked uploader.
|
||||
*/
|
||||
localPath?: string;
|
||||
srvSendMsg?: boolean;
|
||||
fileName?: string;
|
||||
},
|
||||
): Promise<UploadMediaResponse> {
|
||||
const sources = [opts.url, opts.fileData, opts.buffer, opts.localPath].filter(
|
||||
(v) => v !== undefined,
|
||||
);
|
||||
if (sources.length === 0) {
|
||||
throw new Error(`uploadMedia: one of url/fileData/buffer/localPath is required`);
|
||||
}
|
||||
if (sources.length > 1) {
|
||||
throw new Error(
|
||||
`uploadMedia: url/fileData/buffer/localPath are mutually exclusive (got ${sources.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
// One-shot path: materialize buffer/localPath into fileData.
|
||||
// Future chunked-upload work will branch here on size and route
|
||||
// buffer/localPath through streaming ingestion instead of base64 encoding.
|
||||
let fileData = opts.fileData;
|
||||
if (opts.buffer) {
|
||||
fileData = opts.buffer.toString("base64");
|
||||
} else if (opts.localPath) {
|
||||
const buf = await fs.promises.readFile(opts.localPath);
|
||||
fileData = buf.toString("base64");
|
||||
} else if (opts.url !== undefined) {
|
||||
const buf = await downloadDirectUploadUrl(opts.url);
|
||||
fileData = buf.toString("base64");
|
||||
}
|
||||
|
||||
// Check cache for base64 uploads.
|
||||
const uploadCache =
|
||||
fileData !== undefined && !(fileType === MediaFileType.FILE && opts.fileName)
|
||||
? this.cache
|
||||
: undefined;
|
||||
if (fileData !== undefined && uploadCache) {
|
||||
const hash = uploadCache.computeHash(fileData);
|
||||
const cached = uploadCache.get(hash, scope, targetId, fileType);
|
||||
if (cached) {
|
||||
return { file_uuid: "", file_info: cached, ttl: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
file_type: fileType,
|
||||
srv_send_msg: opts.srvSendMsg ?? false,
|
||||
};
|
||||
if (fileData !== undefined) {
|
||||
body.file_data = fileData;
|
||||
}
|
||||
if (fileType === MediaFileType.FILE && opts.fileName) {
|
||||
body.file_name = this.sanitize(opts.fileName);
|
||||
}
|
||||
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const path = mediaUploadPath(scope, targetId);
|
||||
|
||||
const result = await withRetry(
|
||||
() =>
|
||||
this.client.request<UploadMediaResponse>(token, "POST", path, body, {
|
||||
redactBodyKeys: ["file_data"],
|
||||
uploadRequest: true,
|
||||
}),
|
||||
UPLOAD_RETRY_POLICY,
|
||||
undefined,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Cache the result for future dedup.
|
||||
if (fileData !== undefined && uploadCache && result.file_info && result.ttl > 0) {
|
||||
const hash = uploadCache.computeHash(fileData);
|
||||
uploadCache.set(
|
||||
hash,
|
||||
scope,
|
||||
targetId,
|
||||
fileType,
|
||||
result.file_info,
|
||||
result.file_uuid,
|
||||
result.ttl,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a media message (upload result → message) to a C2C or Group target.
|
||||
*
|
||||
* @param scope - `'c2c'` or `'group'`.
|
||||
* @param targetId - User openid or group openid.
|
||||
* @param fileInfo - `file_info` from a prior upload.
|
||||
* @param creds - Authentication credentials.
|
||||
* @param opts - Message options.
|
||||
*/
|
||||
async sendMediaMessage(
|
||||
scope: ChatScope,
|
||||
targetId: string,
|
||||
fileInfo: string,
|
||||
creds: { appId: string; clientSecret: string },
|
||||
opts?: {
|
||||
msgId?: string;
|
||||
content?: string;
|
||||
},
|
||||
): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
||||
const path = messagePath(scope, targetId);
|
||||
|
||||
return this.client.request<MessageResponse>(token, "POST", path, {
|
||||
msg_type: 7,
|
||||
media: { file_info: fileInfo },
|
||||
msg_seq: msgSeq,
|
||||
...(opts?.content ? { content: opts.content } : {}),
|
||||
...(opts?.msgId ? { msg_id: opts.msgId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
300
extensions/qqbot/src/engine/api/messages.ts
Normal file
300
extensions/qqbot/src/engine/api/messages.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Message sending API for the QQ Open Platform.
|
||||
*
|
||||
* Key design improvements:
|
||||
* - Unified `sendMessage(scope, ...)` replaces `sendC2CMessage` + `sendGroupMessage`.
|
||||
* - `onMessageSent` hook is scoped to the instance, not a module-level global.
|
||||
* - Markdown support flag is per-instance, not a global Map.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChatScope,
|
||||
MessageResponse,
|
||||
OutboundMeta,
|
||||
EngineLogger,
|
||||
InlineKeyboard,
|
||||
StreamMessageRequest,
|
||||
} from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
import { ApiClient } from "./api-client.js";
|
||||
import {
|
||||
messagePath,
|
||||
channelMessagePath,
|
||||
dmMessagePath,
|
||||
gatewayPath,
|
||||
interactionPath,
|
||||
getNextMsgSeq,
|
||||
streamMessagePath,
|
||||
} from "./routes.js";
|
||||
import { TokenManager } from "./token.js";
|
||||
|
||||
interface MessageApiConfig {
|
||||
/** Whether the QQ Bot has markdown permission. */
|
||||
markdownSupport: boolean;
|
||||
/** Logger for diagnostics. */
|
||||
logger?: EngineLogger;
|
||||
}
|
||||
|
||||
type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
|
||||
|
||||
/**
|
||||
* Message sending module.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const api = new MessageApi(client, tokenMgr, { markdownSupport: true });
|
||||
* await api.sendMessage('c2c', openid, 'Hello!', { appId, clientSecret, msgId });
|
||||
* ```
|
||||
*/
|
||||
export class MessageApi {
|
||||
private readonly client: ApiClient;
|
||||
private readonly tokenManager: TokenManager;
|
||||
private readonly markdownSupport: boolean;
|
||||
private readonly logger?: EngineLogger;
|
||||
private messageSentHook: OnMessageSentCallback | null = null;
|
||||
|
||||
constructor(client: ApiClient, tokenManager: TokenManager, config: MessageApiConfig) {
|
||||
this.client = client;
|
||||
this.tokenManager = tokenManager;
|
||||
this.markdownSupport = config.markdownSupport;
|
||||
this.logger = config.logger;
|
||||
}
|
||||
|
||||
/** Register a callback invoked when a sent message returns a ref_idx. */
|
||||
onMessageSent(callback: OnMessageSentCallback): void {
|
||||
this.messageSentHook = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the registered hook about a sent message.
|
||||
* Use this for media sends that bypass `sendAndNotify`.
|
||||
*/
|
||||
notifyMessageSent(refIdx: string, meta: OutboundMeta): void {
|
||||
if (this.messageSentHook) {
|
||||
try {
|
||||
this.messageSentHook(refIdx, meta);
|
||||
} catch (err) {
|
||||
this.logger?.error?.(
|
||||
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Unified message sending ----
|
||||
|
||||
/**
|
||||
* Send a text message to a C2C or Group target.
|
||||
*
|
||||
* Automatically constructs the correct path, body format (markdown vs plain),
|
||||
* and message sequence number.
|
||||
*/
|
||||
async sendMessage(
|
||||
scope: ChatScope,
|
||||
targetId: string,
|
||||
content: string,
|
||||
creds: Credentials,
|
||||
opts?: {
|
||||
msgId?: string;
|
||||
messageReference?: string;
|
||||
inlineKeyboard?: InlineKeyboard;
|
||||
forcePlainText?: boolean;
|
||||
},
|
||||
): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
||||
const body = this.buildMessageBody(
|
||||
content,
|
||||
opts?.msgId,
|
||||
msgSeq,
|
||||
opts?.messageReference,
|
||||
opts?.inlineKeyboard,
|
||||
opts?.forcePlainText,
|
||||
);
|
||||
const path = messagePath(scope, targetId);
|
||||
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
||||
}
|
||||
|
||||
/** Send a proactive (no msgId) message to a C2C or Group target. */
|
||||
async sendProactiveMessage(
|
||||
scope: ChatScope,
|
||||
targetId: string,
|
||||
content: string,
|
||||
creds: Credentials,
|
||||
opts?: { forcePlainText?: boolean },
|
||||
): Promise<MessageResponse> {
|
||||
if (!content?.trim()) {
|
||||
throw new Error("Proactive message content must not be empty");
|
||||
}
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const body = this.buildProactiveBody(content, opts?.forcePlainText);
|
||||
const path = messagePath(scope, targetId);
|
||||
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
||||
}
|
||||
|
||||
// ---- Channel / DM ----
|
||||
|
||||
/** Send a channel message. */
|
||||
async sendChannelMessage(opts: {
|
||||
channelId: string;
|
||||
content: string;
|
||||
creds: Credentials;
|
||||
msgId?: string;
|
||||
}): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
||||
return this.client.request<MessageResponse>(token, "POST", channelMessagePath(opts.channelId), {
|
||||
content: opts.content,
|
||||
...(opts.msgId ? { msg_id: opts.msgId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a DM (guild direct message). */
|
||||
async sendDmMessage(opts: {
|
||||
guildId: string;
|
||||
content: string;
|
||||
creds: Credentials;
|
||||
msgId?: string;
|
||||
}): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
||||
return this.client.request<MessageResponse>(token, "POST", dmMessagePath(opts.guildId), {
|
||||
content: opts.content,
|
||||
...(opts.msgId ? { msg_id: opts.msgId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- C2C Input Notify ----
|
||||
|
||||
/** Send a typing indicator to a C2C user. */
|
||||
async sendInputNotify(opts: {
|
||||
openid: string;
|
||||
creds: Credentials;
|
||||
msgId?: string;
|
||||
inputSecond?: number;
|
||||
}): Promise<{ refIdx?: string }> {
|
||||
const inputSecond = opts.inputSecond ?? 60;
|
||||
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
||||
const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
||||
const response = await this.client.request<{ ext_info?: { ref_idx?: string } }>(
|
||||
token,
|
||||
"POST",
|
||||
messagePath("c2c", opts.openid),
|
||||
{
|
||||
msg_type: 6,
|
||||
input_notify: { input_type: 1, input_second: inputSecond },
|
||||
msg_seq: msgSeq,
|
||||
...(opts.msgId ? { msg_id: opts.msgId } : {}),
|
||||
},
|
||||
);
|
||||
return { refIdx: response.ext_info?.ref_idx };
|
||||
}
|
||||
|
||||
// ---- Interaction ----
|
||||
|
||||
/** Acknowledge an INTERACTION_CREATE event. */
|
||||
async acknowledgeInteraction(
|
||||
interactionId: string,
|
||||
creds: Credentials,
|
||||
code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
|
||||
): Promise<void> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
await this.client.request(token, "PUT", interactionPath(interactionId), { code });
|
||||
}
|
||||
|
||||
// ---- Gateway ----
|
||||
|
||||
/** Get the WebSocket gateway URL. */
|
||||
async getGatewayUrl(creds: Credentials): Promise<string> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const data = await this.client.request<{ url: string }>(token, "GET", gatewayPath());
|
||||
return data.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a C2C stream message chunk (`/v2/users/{openid}/stream_messages`).
|
||||
* Only supported for one-to-one chats.
|
||||
*/
|
||||
async sendC2CStreamMessage(
|
||||
creds: Credentials,
|
||||
openid: string,
|
||||
req: StreamMessageRequest,
|
||||
): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const path = streamMessagePath(openid);
|
||||
const body: Record<string, unknown> = {
|
||||
input_mode: req.input_mode,
|
||||
input_state: req.input_state,
|
||||
content_type: req.content_type,
|
||||
content_raw: req.content_raw,
|
||||
event_id: req.event_id,
|
||||
msg_id: req.msg_id,
|
||||
msg_seq: req.msg_seq,
|
||||
index: req.index,
|
||||
};
|
||||
if (req.stream_msg_id) {
|
||||
body.stream_msg_id = req.stream_msg_id;
|
||||
}
|
||||
return this.client.request<MessageResponse>(token, "POST", path, body);
|
||||
}
|
||||
|
||||
// ---- Internal ----
|
||||
|
||||
private async sendAndNotify(
|
||||
_appId: string,
|
||||
accessToken: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
meta: OutboundMeta,
|
||||
): Promise<MessageResponse> {
|
||||
const result = await this.client.request<MessageResponse>(accessToken, method, path, body);
|
||||
if (result.ext_info?.ref_idx && this.messageSentHook) {
|
||||
try {
|
||||
this.messageSentHook(result.ext_info.ref_idx, meta);
|
||||
} catch (err) {
|
||||
this.logger?.error?.(
|
||||
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildMessageBody(
|
||||
content: string,
|
||||
msgId: string | undefined,
|
||||
msgSeq: number,
|
||||
messageReference?: string,
|
||||
inlineKeyboard?: InlineKeyboard,
|
||||
forcePlainText = false,
|
||||
): Record<string, unknown> {
|
||||
const useMarkdown = this.markdownSupport && !forcePlainText;
|
||||
const body: Record<string, unknown> = useMarkdown
|
||||
? { markdown: { content }, msg_type: 2, msg_seq: msgSeq }
|
||||
: { content, msg_type: 0, msg_seq: msgSeq };
|
||||
|
||||
if (msgId) {
|
||||
body.msg_id = msgId;
|
||||
}
|
||||
if (messageReference && !useMarkdown) {
|
||||
body.message_reference = { message_id: messageReference };
|
||||
}
|
||||
if (inlineKeyboard) {
|
||||
body.keyboard = inlineKeyboard;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private buildProactiveBody(content: string, forcePlainText = false): Record<string, unknown> {
|
||||
return this.markdownSupport && !forcePlainText
|
||||
? { markdown: { content }, msg_type: 2 }
|
||||
: { content, msg_type: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
/** Credentials needed to authenticate API requests. */
|
||||
export interface Credentials {
|
||||
appId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
214
extensions/qqbot/src/engine/api/retry.ts
Normal file
214
extensions/qqbot/src/engine/api/retry.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Generic retry engine for QQ Bot API requests.
|
||||
*
|
||||
* Replaces the three separate retry implementations in the old `api.ts`:
|
||||
* - `apiRequestWithRetry` (upload retry with exponential backoff)
|
||||
* - `partFinishWithRetry` (part-finish retry + persistent retry on specific biz codes)
|
||||
* - `completeUploadWithRetry` (unconditional retry for complete-upload)
|
||||
*
|
||||
* All three patterns are expressed as a single `withRetry` function
|
||||
* parameterized by `RetryPolicy` and optional `PersistentRetryPolicy`.
|
||||
*/
|
||||
|
||||
import { sleep } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
|
||||
/** Standard retry policy with exponential or fixed backoff. */
|
||||
interface RetryPolicy {
|
||||
/** Maximum retry attempts (excluding the initial attempt). */
|
||||
maxRetries: number;
|
||||
/** Base delay in milliseconds. */
|
||||
baseDelayMs: number;
|
||||
/** Backoff strategy. */
|
||||
backoff: "exponential" | "fixed";
|
||||
/**
|
||||
* Predicate to decide whether an error is retryable.
|
||||
* Return `false` to immediately rethrow.
|
||||
* Defaults to always-retry when omitted.
|
||||
*/
|
||||
shouldRetry?: (error: Error, attempt: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent retry policy for specific business error codes.
|
||||
*
|
||||
* When `shouldPersistRetry` returns true, the engine switches from
|
||||
* the standard retry loop into a tight fixed-interval loop bounded
|
||||
* only by the total timeout.
|
||||
*/
|
||||
interface PersistentRetryPolicy {
|
||||
/** Total timeout in milliseconds for the persistent retry loop. */
|
||||
timeoutMs: number;
|
||||
/** Fixed interval between retries in milliseconds. */
|
||||
intervalMs: number;
|
||||
/** Predicate to decide whether an error triggers persistent retry. */
|
||||
shouldPersistRetry: (error: Error) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async operation with configurable retry semantics.
|
||||
*
|
||||
* @param fn - The async operation to retry.
|
||||
* @param policy - Standard retry configuration.
|
||||
* @param persistentPolicy - Optional persistent retry for specific error codes.
|
||||
* @param logger - Optional logger for retry diagnostics.
|
||||
* @returns The result of the first successful invocation.
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
policy: RetryPolicy,
|
||||
persistentPolicy?: PersistentRetryPolicy,
|
||||
logger?: EngineLogger,
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
|
||||
|
||||
// Check for persistent-retry trigger before standard retry logic.
|
||||
if (persistentPolicy?.shouldPersistRetry(lastError)) {
|
||||
(logger?.warn ?? logger?.error)?.(
|
||||
`[qqbot:retry] Hit persistent-retry trigger, entering persistent loop (timeout=${persistentPolicy.timeoutMs / 1000}s)`,
|
||||
);
|
||||
return await persistentRetryLoop(fn, persistentPolicy, logger);
|
||||
}
|
||||
|
||||
// Check whether this error is retryable under the standard policy.
|
||||
if (policy.shouldRetry?.(lastError, attempt) === false) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Schedule the next retry with the configured backoff.
|
||||
if (attempt < policy.maxRetries) {
|
||||
const delay =
|
||||
policy.backoff === "exponential" ? policy.baseDelayMs * 2 ** attempt : policy.baseDelayMs;
|
||||
|
||||
logger?.debug?.(
|
||||
`[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`,
|
||||
);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent retry loop: fixed-interval retries bounded by a total timeout.
|
||||
*
|
||||
* Used for `upload_part_finish` when the server returns specific business
|
||||
* error codes indicating the backend is still processing.
|
||||
*/
|
||||
async function persistentRetryLoop<T>(
|
||||
fn: () => Promise<T>,
|
||||
policy: PersistentRetryPolicy,
|
||||
logger?: EngineLogger,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + policy.timeoutMs;
|
||||
let attempt = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const result = await fn();
|
||||
logger?.debug?.(`[qqbot:retry] Persistent retry succeeded after ${attempt} retries`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(formatErrorMessage(err));
|
||||
|
||||
// If the error is no longer retryable, abort immediately.
|
||||
if (!policy.shouldPersistRetry(lastError)) {
|
||||
logger?.error?.(`[qqbot:retry] Persistent retry: error is no longer retryable, aborting`);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const actualDelay = Math.min(policy.intervalMs, remaining);
|
||||
(logger?.warn ?? logger?.error)?.(
|
||||
`[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`,
|
||||
);
|
||||
await sleep(actualDelay);
|
||||
}
|
||||
}
|
||||
|
||||
logger?.error?.(
|
||||
`[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1000}s (${attempt} attempts)`,
|
||||
);
|
||||
throw lastError ?? new Error(`Persistent retry timed out (${policy.timeoutMs / 1000}s)`);
|
||||
}
|
||||
|
||||
// ============ Pre-built Retry Policies ============
|
||||
|
||||
/** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */
|
||||
export const UPLOAD_RETRY_POLICY: RetryPolicy = {
|
||||
maxRetries: 2,
|
||||
baseDelayMs: 1000,
|
||||
backoff: "exponential",
|
||||
shouldRetry: (error) => {
|
||||
const msg = error.message;
|
||||
return !(
|
||||
msg.includes("400") ||
|
||||
msg.includes("401") ||
|
||||
msg.includes("Invalid") ||
|
||||
msg.includes("timeout") ||
|
||||
msg.includes("Timeout")
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Complete-upload retry: unconditional retry with exponential backoff. */
|
||||
export const COMPLETE_UPLOAD_RETRY_POLICY: RetryPolicy = {
|
||||
maxRetries: 2,
|
||||
baseDelayMs: 2000,
|
||||
backoff: "exponential",
|
||||
// Always retry — complete-upload failures are often transient server-side.
|
||||
};
|
||||
|
||||
/** Part-finish standard retry policy. */
|
||||
export const PART_FINISH_RETRY_POLICY: RetryPolicy = {
|
||||
maxRetries: 2,
|
||||
baseDelayMs: 1000,
|
||||
backoff: "exponential",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a persistent retry policy for part-finish with a specific timeout.
|
||||
*
|
||||
* @param retryTimeoutMs - Total timeout (defaults to 2 minutes).
|
||||
* @param retryableCodes - Business error codes that trigger persistent retry.
|
||||
*/
|
||||
export function buildPartFinishPersistentPolicy(
|
||||
retryTimeoutMs?: number,
|
||||
retryableCodes: Set<number> = PART_FINISH_RETRYABLE_CODES,
|
||||
): PersistentRetryPolicy {
|
||||
return {
|
||||
timeoutMs: retryTimeoutMs ?? 2 * 60 * 1000,
|
||||
intervalMs: 1000,
|
||||
shouldPersistRetry: (error) => {
|
||||
if (retryableCodes.size === 0) {
|
||||
return false;
|
||||
}
|
||||
// Check for ApiError with matching bizCode.
|
||||
if ("bizCode" in error && typeof (error as { bizCode?: number }).bizCode === "number") {
|
||||
return retryableCodes.has((error as { bizCode: number }).bizCode);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Business error codes that trigger persistent part-finish retry. */
|
||||
const PART_FINISH_RETRYABLE_CODES: Set<number> = new Set([40093001]);
|
||||
|
||||
/** upload_prepare error code indicating daily limit exceeded. */
|
||||
export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
|
||||
95
extensions/qqbot/src/engine/api/routes.ts
Normal file
95
extensions/qqbot/src/engine/api/routes.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Centralized API route templates for the QQ Open Platform.
|
||||
*
|
||||
* Eliminates C2C/Group path duplication by parameterizing on `ChatScope`.
|
||||
* Inspired by `bot-node-sdk/src/openapi/v1/resource.ts`.
|
||||
*/
|
||||
|
||||
import type { ChatScope } from "../types.js";
|
||||
|
||||
/**
|
||||
* Build the message-send path for C2C or Group.
|
||||
*
|
||||
* - C2C: `/v2/users/{id}/messages`
|
||||
* - Group: `/v2/groups/{id}/messages`
|
||||
*/
|
||||
export function messagePath(scope: ChatScope, targetId: string): string {
|
||||
return scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`;
|
||||
}
|
||||
|
||||
/** Channel message path. */
|
||||
export function channelMessagePath(channelId: string): string {
|
||||
return `/channels/${channelId}/messages`;
|
||||
}
|
||||
|
||||
/** DM (direct message inside a guild) path. */
|
||||
export function dmMessagePath(guildId: string): string {
|
||||
return `/dms/${guildId}/messages`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the media upload (small-file) path for C2C or Group.
|
||||
*
|
||||
* - C2C: `/v2/users/{id}/files`
|
||||
* - Group: `/v2/groups/{id}/files`
|
||||
*/
|
||||
export function mediaUploadPath(scope: ChatScope, targetId: string): string {
|
||||
return scope === "c2c" ? `/v2/users/${targetId}/files` : `/v2/groups/${targetId}/files`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the upload_prepare path for C2C or Group.
|
||||
*
|
||||
* - C2C: `/v2/users/{id}/upload_prepare`
|
||||
* - Group: `/v2/groups/{id}/upload_prepare`
|
||||
*/
|
||||
export function uploadPreparePath(scope: ChatScope, targetId: string): string {
|
||||
return scope === "c2c"
|
||||
? `/v2/users/${targetId}/upload_prepare`
|
||||
: `/v2/groups/${targetId}/upload_prepare`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the upload_part_finish path for C2C or Group.
|
||||
*/
|
||||
export function uploadPartFinishPath(scope: ChatScope, targetId: string): string {
|
||||
return scope === "c2c"
|
||||
? `/v2/users/${targetId}/upload_part_finish`
|
||||
: `/v2/groups/${targetId}/upload_part_finish`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete-upload (files) path for C2C or Group.
|
||||
* (Same as mediaUploadPath — the complete endpoint reuses the files path.)
|
||||
*/
|
||||
export function uploadCompletePath(scope: ChatScope, targetId: string): string {
|
||||
return mediaUploadPath(scope, targetId);
|
||||
}
|
||||
|
||||
/** Stream message path (C2C only). */
|
||||
export function streamMessagePath(openid: string): string {
|
||||
return `/v2/users/${openid}/stream_messages`;
|
||||
}
|
||||
|
||||
/** Gateway URL path. */
|
||||
export function gatewayPath(): string {
|
||||
return "/gateway";
|
||||
}
|
||||
|
||||
/** Interaction acknowledgement path. */
|
||||
export function interactionPath(interactionId: string): string {
|
||||
return `/interactions/${interactionId}`;
|
||||
}
|
||||
|
||||
// ============ Shared Helpers ============
|
||||
|
||||
/**
|
||||
* Generate a message sequence number in the 0..65535 range.
|
||||
*
|
||||
* Used by both `messages.ts` and `media.ts` to avoid duplicate definitions.
|
||||
*/
|
||||
export function getNextMsgSeq(_msgId: string): number {
|
||||
const timePart = Date.now() % 100_000_000;
|
||||
const random = Math.floor(Math.random() * 65536);
|
||||
return (timePart ^ random) % 65536;
|
||||
}
|
||||
180
extensions/qqbot/src/engine/api/token.test.ts
Normal file
180
extensions/qqbot/src/engine/api/token.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Qqbot tests cover token plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TokenManager } from "./token.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
};
|
||||
});
|
||||
|
||||
function mockGuardedTokenResponse(body: BodyInit, init?: ResponseInit): ReturnType<typeof vi.fn> {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(body, init),
|
||||
release,
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
release: ReturnType<typeof vi.fn>;
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
const release = vi.fn(async () => {});
|
||||
const response = new Response(stream, init);
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
|
||||
return {
|
||||
release,
|
||||
response,
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
describe("QQBot token manager", () => {
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("wraps malformed access token JSON", async () => {
|
||||
const release = mockGuardedTokenResponse("{not json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(new TokenManager().getAccessToken("app-id", "secret")).rejects.toThrow(
|
||||
"QQBot access_token response was malformed JSON",
|
||||
);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
|
||||
url: "https://bots.qq.com/app/getAppAccessToken",
|
||||
auditContext: "qqbot-token",
|
||||
capture: false,
|
||||
policy: {
|
||||
hostnameAllowlist: ["bots.qq.com"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "QQBotPlugin/unknown",
|
||||
},
|
||||
body: JSON.stringify({ appId: "app-id", clientSecret: "secret" }),
|
||||
},
|
||||
});
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("bounds access token responses without using response.text()", async () => {
|
||||
const logger = { debug: vi.fn(), info: vi.fn(), error: vi.fn() };
|
||||
const tracked = cancelTrackedResponse(`${"qqbot token unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(new TokenManager({ logger }).getAccessToken("app-id", "secret")).rejects.toThrow(
|
||||
"QQBot access_token response was malformed JSON",
|
||||
);
|
||||
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(tracked.release).toHaveBeenCalledTimes(1);
|
||||
expect(logger.debug.mock.calls.join("\n")).toContain("qqbot token unavailable");
|
||||
expect(logger.debug.mock.calls.join("\n")).not.toContain("tail");
|
||||
});
|
||||
|
||||
it("passes the RFC2544 SSRF allowance to the token fetch (regression for #88984)", async () => {
|
||||
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(new TokenManager().getAccessToken("app-id", "secret")).resolves.toBe("token-1");
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://bots.qq.com/app/getAppAccessToken",
|
||||
auditContext: "qqbot-token",
|
||||
policy: {
|
||||
hostnameAllowlist: ["bots.qq.com"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not cache access tokens forever when expires_in is unsafe", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":1e309}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const manager = new TokenManager();
|
||||
await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1");
|
||||
|
||||
const status = manager.getStatus("app-id");
|
||||
expect(status.status).toBe("valid");
|
||||
expect(status.expiresAt).toBe(Date.now() + 7200 * 1000);
|
||||
});
|
||||
|
||||
it("does not extend explicit non-positive token lifetimes", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":0}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const manager = new TokenManager();
|
||||
await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1");
|
||||
|
||||
expect(manager.getStatus("app-id")).toEqual({
|
||||
status: "expired",
|
||||
expiresAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache fetched tokens when the process clock is outside the Date range", async () => {
|
||||
const logger = { debug: vi.fn(), info: vi.fn(), error: vi.fn() };
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const manager = new TokenManager({ logger });
|
||||
try {
|
||||
await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1");
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(manager.getStatus("app-id")).toEqual({ status: "none", expiresAt: null });
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
"[qqbot:token:app-id] Not cached: invalid process clock",
|
||||
);
|
||||
});
|
||||
});
|
||||
331
extensions/qqbot/src/engine/api/token.ts
Normal file
331
extensions/qqbot/src/engine/api/token.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Token management for the QQ Open Platform.
|
||||
*
|
||||
* All state (cache, singleflight promises, background refresh controllers)
|
||||
* is encapsulated in the `TokenManager` class instance — no module-level
|
||||
* globals, fully supporting multi-account concurrent operation.
|
||||
*/
|
||||
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictPositiveInteger,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
resolveTimestampMsToIsoString,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
|
||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200;
|
||||
const QQBOT_TOKEN_RESPONSE_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Host-scoped SSRF policy for the QQ Bot token endpoint.
|
||||
*
|
||||
* `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this
|
||||
* relaxation only ever applies to that single host. Fake-IP proxy stacks
|
||||
* (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into
|
||||
* the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF
|
||||
* guard blocks. We mirror the existing media-path pattern
|
||||
* (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation
|
||||
* stays narrowly host-scoped instead of weakening the global default.
|
||||
*
|
||||
* See https://github.com/openclaw/openclaw/issues/88984.
|
||||
*/
|
||||
const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = {
|
||||
hostnameAllowlist: ["bots.qq.com"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
interface BackgroundRefreshOptions {
|
||||
refreshAheadMs?: number;
|
||||
randomOffsetMs?: number;
|
||||
minRefreshIntervalMs?: number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
function resolveTokenExpiresInSeconds(value: unknown): number {
|
||||
const parsed = parseStrictPositiveInteger(value);
|
||||
if (parsed !== undefined) {
|
||||
return parsed;
|
||||
}
|
||||
if (value == null || (typeof value === "number" && !Number.isFinite(value))) {
|
||||
return DEFAULT_TOKEN_EXPIRES_IN_SECONDS;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-appId token manager with caching, singleflight, and background refresh.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' });
|
||||
* const token = await tm.getAccessToken('appId', 'secret');
|
||||
* ```
|
||||
*/
|
||||
export class TokenManager {
|
||||
private readonly cache = new Map<string, CachedToken>();
|
||||
private readonly fetchPromises = new Map<string, Promise<string>>();
|
||||
private readonly refreshControllers = new Map<string, AbortController>();
|
||||
private readonly logger?: EngineLogger;
|
||||
private readonly resolveUserAgent: () => string;
|
||||
|
||||
constructor(config?: { logger?: EngineLogger; userAgent?: string | (() => string) }) {
|
||||
this.logger = config?.logger;
|
||||
const ua = config?.userAgent ?? "QQBotPlugin/unknown";
|
||||
this.resolveUserAgent = typeof ua === "function" ? ua : () => ua;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain an access token with caching and singleflight semantics.
|
||||
*
|
||||
* When multiple callers request a token for the same appId concurrently,
|
||||
* only one actual HTTP request is made — the others await the same promise.
|
||||
*/
|
||||
async getAccessToken(appId: string, clientSecret: string): Promise<string> {
|
||||
const normalizedId = appId.trim();
|
||||
const cached = this.cache.get(normalizedId);
|
||||
|
||||
// Refresh slightly before expiry without making short-lived tokens unusable.
|
||||
const refreshAheadMs = cached
|
||||
? Math.min(5 * 60 * 1000, (cached.expiresAt - Date.now()) / 3)
|
||||
: 0;
|
||||
|
||||
if (cached && Date.now() < cached.expiresAt - refreshAheadMs) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
// Singleflight: reuse an in-progress fetch.
|
||||
let pending = this.fetchPromises.get(normalizedId);
|
||||
if (pending) {
|
||||
this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`);
|
||||
return pending;
|
||||
}
|
||||
|
||||
pending = (async () => {
|
||||
try {
|
||||
return await this.doFetchToken(normalizedId, clientSecret);
|
||||
} finally {
|
||||
this.fetchPromises.delete(normalizedId);
|
||||
}
|
||||
})();
|
||||
|
||||
this.fetchPromises.set(normalizedId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Clear the cached token for one appId, or all. */
|
||||
clearCache(appId?: string): void {
|
||||
if (appId) {
|
||||
this.cache.delete(appId.trim());
|
||||
this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
this.logger?.debug?.(`[token] All caches cleared`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Return token status for diagnostics. */
|
||||
getStatus(appId: string): {
|
||||
status: "valid" | "expired" | "refreshing" | "none";
|
||||
expiresAt: number | null;
|
||||
} {
|
||||
if (this.fetchPromises.has(appId)) {
|
||||
return { status: "refreshing", expiresAt: this.cache.get(appId)?.expiresAt ?? null };
|
||||
}
|
||||
const cached = this.cache.get(appId);
|
||||
if (!cached) {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
const remaining = cached.expiresAt - Date.now();
|
||||
const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
|
||||
return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
/** Start a background token refresh loop for one appId. */
|
||||
startBackgroundRefresh(
|
||||
appId: string,
|
||||
clientSecret: string,
|
||||
options?: BackgroundRefreshOptions,
|
||||
): void {
|
||||
if (this.refreshControllers.has(appId)) {
|
||||
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
refreshAheadMs = 5 * 60 * 1000,
|
||||
randomOffsetMs = 30 * 1000,
|
||||
minRefreshIntervalMs = 60 * 1000,
|
||||
retryDelayMs = 5 * 1000,
|
||||
} = options ?? {};
|
||||
|
||||
const controller = new AbortController();
|
||||
this.refreshControllers.set(appId, controller);
|
||||
const { signal } = controller;
|
||||
|
||||
const loop = async () => {
|
||||
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`);
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
await this.getAccessToken(appId, clientSecret);
|
||||
const cached = this.cache.get(appId);
|
||||
|
||||
if (cached) {
|
||||
const expiresIn = cached.expiresAt - Date.now();
|
||||
const randomOffset = Math.random() * randomOffsetMs;
|
||||
const refreshIn = Math.max(
|
||||
expiresIn - refreshAheadMs - randomOffset,
|
||||
minRefreshIntervalMs,
|
||||
);
|
||||
this.logger?.debug?.(
|
||||
`[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`,
|
||||
);
|
||||
await this.abortableSleep(refreshIn, signal);
|
||||
} else {
|
||||
await this.abortableSleep(minRefreshIntervalMs, signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
this.logger?.error?.(
|
||||
`[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
await this.abortableSleep(retryDelayMs, signal);
|
||||
}
|
||||
}
|
||||
|
||||
this.refreshControllers.delete(appId);
|
||||
this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`);
|
||||
};
|
||||
|
||||
loop().catch((err: unknown) => {
|
||||
this.refreshControllers.delete(appId);
|
||||
this.logger?.error?.(
|
||||
`[qqbot:token:${appId}] Background refresh crashed: ${formatErrorMessage(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop background refresh for one appId, or all. */
|
||||
stopBackgroundRefresh(appId?: string): void {
|
||||
if (appId) {
|
||||
const ctrl = this.refreshControllers.get(appId);
|
||||
if (ctrl) {
|
||||
ctrl.abort();
|
||||
this.refreshControllers.delete(appId);
|
||||
}
|
||||
} else {
|
||||
for (const ctrl of this.refreshControllers.values()) {
|
||||
ctrl.abort();
|
||||
}
|
||||
this.refreshControllers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Internal ----
|
||||
|
||||
private async doFetchToken(appId: string, clientSecret: string): Promise<string> {
|
||||
this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`);
|
||||
|
||||
let response: Response;
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url: TOKEN_URL,
|
||||
auditContext: "qqbot-token",
|
||||
capture: false,
|
||||
policy: QQBOT_TOKEN_SSRF_POLICY,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": this.resolveUserAgent(),
|
||||
},
|
||||
body: JSON.stringify({ appId, clientSecret }),
|
||||
},
|
||||
});
|
||||
response = guarded.response;
|
||||
release = guarded.release;
|
||||
} catch (err) {
|
||||
this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`);
|
||||
throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const traceId = response.headers.get("x-tps-trace-id") ?? "";
|
||||
this.logger?.debug?.(
|
||||
`[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`,
|
||||
);
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await readResponseTextLimited(response, QQBOT_TOKEN_RESPONSE_LIMIT_BYTES);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
|
||||
this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`);
|
||||
|
||||
let data: { access_token?: string; expires_in?: unknown };
|
||||
try {
|
||||
data = JSON.parse(rawBody);
|
||||
} catch {
|
||||
throw new Error("QQBot access_token response was malformed JSON");
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const nowMs = asDateTimestampMs(Date.now());
|
||||
if (nowMs === undefined) {
|
||||
this.logger?.debug?.(`[qqbot:token:${appId}] Not cached: invalid process clock`);
|
||||
return data.access_token;
|
||||
}
|
||||
const expiresAt =
|
||||
resolveExpiresAtMsFromDurationSeconds(resolveTokenExpiresInSeconds(data.expires_in), {
|
||||
nowMs,
|
||||
}) ?? nowMs;
|
||||
this.cache.set(appId, { token: data.access_token, expiresAt, appId });
|
||||
this.logger?.debug?.(
|
||||
`[qqbot:token:${appId}] Cached, expires at: ${resolveTimestampMsToIsoString(expiresAt)}`,
|
||||
);
|
||||
|
||||
return data.access_token;
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
}
|
||||
|
||||
private abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
23
extensions/qqbot/src/engine/approval/index.test.ts
Normal file
23
extensions/qqbot/src/engine/approval/index.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Qqbot tests cover index plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApprovalKeyboard } from "./index.js";
|
||||
|
||||
describe("buildApprovalKeyboard", () => {
|
||||
it("omits allow-always when the decision is unavailable", () => {
|
||||
const keyboard = buildApprovalKeyboard("approval-123", ["allow-once", "deny"]);
|
||||
const buttons = keyboard.content.rows[0]?.buttons ?? [];
|
||||
|
||||
expect(buttons.map((button) => button.id)).toEqual(["allow", "deny"]);
|
||||
expect(buttons.map((button) => button.action.data)).toEqual([
|
||||
"approve:approval-123:allow-once",
|
||||
"approve:approval-123:deny",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps all buttons when all decisions are allowed", () => {
|
||||
const keyboard = buildApprovalKeyboard("approval-123", ["allow-once", "allow-always", "deny"]);
|
||||
const buttons = keyboard.content.rows[0]?.buttons ?? [];
|
||||
|
||||
expect(buttons.map((button) => button.id)).toEqual(["allow", "always", "deny"]);
|
||||
});
|
||||
});
|
||||
224
extensions/qqbot/src/engine/approval/index.ts
Normal file
224
extensions/qqbot/src/engine/approval/index.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Approval helpers — pure functions, zero framework dependencies.
|
||||
*
|
||||
* - Build approval message text + inline keyboard
|
||||
* - Resolve delivery target from session metadata
|
||||
* - Parse INTERACTION_CREATE button data
|
||||
*/
|
||||
|
||||
import type { ChatScope, InlineKeyboard, KeyboardButton } from "../types.js";
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface ExecApprovalRequest {
|
||||
id: string;
|
||||
expiresAtMs: number;
|
||||
request: {
|
||||
commandPreview?: string;
|
||||
command?: string;
|
||||
cwd?: string;
|
||||
agentId?: string;
|
||||
turnSourceAccountId?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceTo?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PluginApprovalRequest {
|
||||
id: string;
|
||||
request: {
|
||||
timeoutMs?: number;
|
||||
severity?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
toolName?: string;
|
||||
pluginId?: string;
|
||||
agentId?: string;
|
||||
turnSourceAccountId?: string;
|
||||
sessionKey?: string;
|
||||
turnSourceTo?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type ApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
interface ApprovalTarget {
|
||||
type: ChatScope;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ParsedApprovalAction {
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
}
|
||||
|
||||
// ============ Text Builders ============
|
||||
|
||||
export function buildExecApprovalText(request: ExecApprovalRequest): string {
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
|
||||
const lines: string[] = ["\u{1f510} \u547d\u4ee4\u6267\u884c\u5ba1\u6279", ""];
|
||||
const cmd = request.request.commandPreview ?? request.request.command ?? "";
|
||||
if (cmd) {
|
||||
lines.push(`\`\`\`\n${cmd.slice(0, 300)}\n\`\`\``);
|
||||
}
|
||||
if (request.request.cwd) {
|
||||
lines.push(`\u{1f4c1} \u76ee\u5f55: ${request.request.cwd}`);
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
|
||||
}
|
||||
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${expiresIn} \u79d2`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildPluginApprovalText(request: PluginApprovalRequest): string {
|
||||
const timeoutSec = Math.round((request.request.timeoutMs ?? 120_000) / 1000);
|
||||
const severityIcon =
|
||||
request.request.severity === "critical"
|
||||
? "\u{1f534}"
|
||||
: request.request.severity === "info"
|
||||
? "\u{1f535}"
|
||||
: "\u{1f7e1}";
|
||||
|
||||
const lines: string[] = [`${severityIcon} \u5ba1\u6279\u8bf7\u6c42`, ""];
|
||||
lines.push(`\u{1f4cb} ${request.request.title}`);
|
||||
if (request.request.description) {
|
||||
lines.push(`\u{1f4dd} ${request.request.description}`);
|
||||
}
|
||||
if (request.request.toolName) {
|
||||
lines.push(`\u{1f527} \u5de5\u5177: ${request.request.toolName}`);
|
||||
}
|
||||
if (request.request.pluginId) {
|
||||
lines.push(`\u{1f50c} \u63d2\u4ef6: ${request.request.pluginId}`);
|
||||
}
|
||||
if (request.request.agentId) {
|
||||
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
|
||||
}
|
||||
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${timeoutSec} \u79d2`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ============ Keyboard Builder ============
|
||||
|
||||
/**
|
||||
* Build the three-button inline keyboard for approval messages.
|
||||
*
|
||||
* type=1 (Callback): click triggers INTERACTION_CREATE, button_data = data field.
|
||||
* group_id "approval": clicking one button grays out the others (mutual exclusion).
|
||||
* click_limit=1: each user can only click once.
|
||||
* permission.type=2: all users can interact.
|
||||
*/
|
||||
export function buildApprovalKeyboard(
|
||||
approvalId: string,
|
||||
allowedDecisions: readonly ApprovalDecision[] = ["allow-once", "allow-always", "deny"],
|
||||
): InlineKeyboard {
|
||||
const makeBtn = (
|
||||
id: string,
|
||||
label: string,
|
||||
visitedLabel: string,
|
||||
data: string,
|
||||
style: 0 | 1,
|
||||
): KeyboardButton => ({
|
||||
id,
|
||||
render_data: { label, visited_label: visitedLabel, style },
|
||||
action: {
|
||||
type: 1,
|
||||
data,
|
||||
permission: { type: 2 },
|
||||
click_limit: 1,
|
||||
},
|
||||
group_id: "approval",
|
||||
});
|
||||
|
||||
const buttons: KeyboardButton[] = [];
|
||||
if (allowedDecisions.includes("allow-once")) {
|
||||
buttons.push(
|
||||
makeBtn(
|
||||
"allow",
|
||||
"\u2705 \u5141\u8bb8\u4e00\u6b21",
|
||||
"\u5df2\u5141\u8bb8",
|
||||
`approve:${approvalId}:allow-once`,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (allowedDecisions.includes("allow-always")) {
|
||||
buttons.push(
|
||||
makeBtn(
|
||||
"always",
|
||||
"\u2b50 \u59cb\u7ec8\u5141\u8bb8",
|
||||
"\u5df2\u59cb\u7ec8\u5141\u8bb8",
|
||||
`approve:${approvalId}:allow-always`,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (allowedDecisions.includes("deny")) {
|
||||
buttons.push(
|
||||
makeBtn("deny", "\u274c \u62d2\u7edd", "\u5df2\u62d2\u7edd", `approve:${approvalId}:deny`, 0),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: {
|
||||
rows: [
|
||||
{
|
||||
buttons,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Target Resolver ============
|
||||
|
||||
/**
|
||||
* Extract the delivery target from a sessionKey or turnSourceTo string.
|
||||
*
|
||||
* Expected formats:
|
||||
* agent:main:qqbot:direct:OPENID -> { type: "c2c", id: "OPENID" }
|
||||
* agent:main:qqbot:c2c:OPENID -> { type: "c2c", id: "OPENID" }
|
||||
* agent:main:qqbot:group:GROUPID -> { type: "group", id: "GROUPID" }
|
||||
*
|
||||
* Returns null if neither field matches the expected pattern.
|
||||
*/
|
||||
export function resolveApprovalTarget(
|
||||
sessionKey: string | null | undefined,
|
||||
turnSourceTo: string | null | undefined,
|
||||
): ApprovalTarget | null {
|
||||
const sk = sessionKey ?? turnSourceTo;
|
||||
if (!sk) {
|
||||
return null;
|
||||
}
|
||||
const m = sk.match(/qqbot:(c2c|direct|group):([A-F0-9]+)/i);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
const type: ChatScope = m[1].toLowerCase() === "group" ? "group" : "c2c";
|
||||
return { type, id: m[2] };
|
||||
}
|
||||
|
||||
// ============ Interaction Parser ============
|
||||
|
||||
/**
|
||||
* Parse the button_data string from an INTERACTION_CREATE event.
|
||||
*
|
||||
* Expected format: `approve:<approvalId>:<decision>`
|
||||
* where approvalId may be prefixed with "exec:" or "plugin:".
|
||||
*
|
||||
* Returns null if the data does not match the approval button format.
|
||||
*/
|
||||
export function parseApprovalButtonData(buttonData: string): ParsedApprovalAction | null {
|
||||
const m = buttonData.match(
|
||||
/^approve:((?:(?:exec|plugin):)?[0-9a-f-]+):(allow-once|allow-always|deny)$/i,
|
||||
);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId: m[1],
|
||||
decision: m[2] as ApprovalDecision,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Qqbot tests cover log helpers plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const platformMock = await vi.hoisted(async () => {
|
||||
const fsLocal = await import("node:fs");
|
||||
const pathLocal = await import("node:path");
|
||||
return {
|
||||
fs: fsLocal,
|
||||
homeDir: "",
|
||||
path: pathLocal,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../utils/platform.js", () => ({
|
||||
getHomeDir: () => platformMock.homeDir,
|
||||
getQQBotDataDir: (...subPaths: string[]) => {
|
||||
const dir = platformMock.path.join(platformMock.homeDir, ".openclaw", "qqbot", ...subPaths);
|
||||
platformMock.fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
},
|
||||
isWindows: () => false,
|
||||
}));
|
||||
|
||||
import { buildBotLogsResult } from "./log-helpers.js";
|
||||
|
||||
describe("buildBotLogsResult", () => {
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-logs-"));
|
||||
platformMock.homeDir = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("suffixes same-second log exports instead of overwriting", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z"));
|
||||
const logDir = path.join(tempHome, ".openclaw", "logs");
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(logDir, "gateway.log"), "line 1\nline 2\n", "utf8");
|
||||
|
||||
const first = buildBotLogsResult();
|
||||
const second = buildBotLogsResult();
|
||||
|
||||
expect(typeof first).toBe("object");
|
||||
expect(typeof second).toBe("object");
|
||||
if (!first || !second || typeof first === "string" || typeof second === "string") {
|
||||
throw new Error("expected file upload results");
|
||||
}
|
||||
expect(path.basename(first.filePath)).toBe("bot-logs-2026-05-05T10-11-12.txt");
|
||||
expect(path.basename(second.filePath)).toBe("bot-logs-2026-05-05T10-11-12-2.txt");
|
||||
expect(fs.readFileSync(first.filePath, "utf8")).toContain("line 1");
|
||||
expect(fs.readFileSync(second.filePath, "utf8")).toContain("line 2");
|
||||
});
|
||||
});
|
||||
343
extensions/qqbot/src/engine/commands/builtin/log-helpers.ts
Normal file
343
extensions/qqbot/src/engine/commands/builtin/log-helpers.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
// Qqbot helper module supports log helpers behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getHomeDir, getQQBotDataDir, isWindows } from "../../utils/platform.js";
|
||||
import type { SlashCommandResult } from "../slash-commands.js";
|
||||
|
||||
/** Read user-configured log file paths from local config files. */
|
||||
function getConfiguredLogFiles(): string[] {
|
||||
const homeDir = getHomeDir();
|
||||
const files: string[] = [];
|
||||
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
|
||||
try {
|
||||
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
|
||||
const cfg = loadJsonFile<{ logging?: { file?: unknown } }>(cfgPath);
|
||||
const logFile = cfg?.logging?.file;
|
||||
if (logFile && typeof logFile === "string") {
|
||||
files.push(path.resolve(logFile));
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Collect directories that may contain runtime logs across common install layouts. */
|
||||
function collectCandidateLogDirs(): string[] {
|
||||
const homeDir = getHomeDir();
|
||||
const dirs = new Set<string>();
|
||||
|
||||
const pushDir = (p?: string) => {
|
||||
if (!p) {
|
||||
return;
|
||||
}
|
||||
const normalized = path.resolve(p);
|
||||
dirs.add(normalized);
|
||||
};
|
||||
|
||||
const pushStateDir = (stateDir?: string) => {
|
||||
if (!stateDir) {
|
||||
return;
|
||||
}
|
||||
pushDir(stateDir);
|
||||
pushDir(path.join(stateDir, "logs"));
|
||||
};
|
||||
|
||||
for (const logFile of getConfiguredLogFiles()) {
|
||||
pushDir(path.dirname(logFile));
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
|
||||
pushStateDir(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
|
||||
pushDir(path.join(homeDir, name));
|
||||
pushDir(path.join(homeDir, name, "logs"));
|
||||
}
|
||||
|
||||
const searchRoots = new Set<string>([homeDir, process.cwd(), path.dirname(process.cwd())]);
|
||||
if (process.env.APPDATA) {
|
||||
searchRoots.add(process.env.APPDATA);
|
||||
}
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
searchRoots.add(process.env.LOCALAPPDATA);
|
||||
}
|
||||
|
||||
for (const root of searchRoots) {
|
||||
try {
|
||||
const entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const base = path.join(root, entry.name);
|
||||
pushDir(base);
|
||||
pushDir(path.join(base, "logs"));
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or inaccessible directories.
|
||||
}
|
||||
}
|
||||
|
||||
if (!isWindows()) {
|
||||
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
|
||||
pushDir(path.join("/var/log", name));
|
||||
}
|
||||
}
|
||||
|
||||
const tmpRoots = new Set<string>();
|
||||
if (isWindows()) {
|
||||
tmpRoots.add("C:\\tmp");
|
||||
if (process.env.TEMP) {
|
||||
tmpRoots.add(process.env.TEMP);
|
||||
}
|
||||
if (process.env.TMP) {
|
||||
tmpRoots.add(process.env.TMP);
|
||||
}
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
|
||||
}
|
||||
} else {
|
||||
tmpRoots.add("/tmp");
|
||||
}
|
||||
for (const tmpRoot of tmpRoots) {
|
||||
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
|
||||
pushDir(path.join(tmpRoot, name));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(dirs);
|
||||
}
|
||||
|
||||
type LogCandidate = {
|
||||
filePath: string;
|
||||
sourceDir: string;
|
||||
mtimeMs: number;
|
||||
};
|
||||
|
||||
function addCollisionSuffix(filePath: string, suffix: number): string {
|
||||
const ext = path.extname(filePath);
|
||||
const baseName = path.basename(filePath, ext);
|
||||
return path.join(path.dirname(filePath), `${baseName}-${suffix}${ext}`);
|
||||
}
|
||||
|
||||
function writeNewTextFileSync(filePath: string, contents: string): string {
|
||||
for (let suffix = 1; suffix <= 100; suffix++) {
|
||||
const candidate = suffix === 1 ? filePath : addCollisionSuffix(filePath, suffix);
|
||||
try {
|
||||
fs.writeFileSync(candidate, contents, { encoding: "utf8", flag: "wx" });
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find an unused log export filename near ${filePath}`);
|
||||
}
|
||||
|
||||
function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
|
||||
const candidates: LogCandidate[] = [];
|
||||
const dedupe = new Set<string>();
|
||||
|
||||
const pushFile = (filePath: string, sourceDir: string) => {
|
||||
const normalized = path.resolve(filePath);
|
||||
if (dedupe.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(normalized);
|
||||
if (!stat.isFile()) {
|
||||
return;
|
||||
}
|
||||
dedupe.add(normalized);
|
||||
candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
// Ignore missing or inaccessible files.
|
||||
}
|
||||
};
|
||||
|
||||
for (const logFile of getConfiguredLogFiles()) {
|
||||
pushFile(logFile, path.dirname(logFile));
|
||||
}
|
||||
|
||||
for (const dir of logDirs) {
|
||||
pushFile(path.join(dir, "gateway.log"), dir);
|
||||
pushFile(path.join(dir, "gateway.err.log"), dir);
|
||||
pushFile(path.join(dir, "openclaw.log"), dir);
|
||||
pushFile(path.join(dir, "clawdbot.log"), dir);
|
||||
pushFile(path.join(dir, "moltbot.log"), dir);
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (!/\.(log|txt)$/i.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
pushFile(path.join(dir, entry.name), dir);
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or inaccessible directories.
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the last N lines of a file without loading the entire file into memory.
|
||||
*/
|
||||
function tailFileLines(
|
||||
filePath: string,
|
||||
maxLines: number,
|
||||
): { tail: string[]; totalFileLines: number } {
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const stat = fs.fstatSync(fd);
|
||||
const fileSize = stat.size;
|
||||
if (fileSize === 0) {
|
||||
return { tail: [], totalFileLines: 0 };
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 64 * 1024;
|
||||
const chunks: Buffer[] = [];
|
||||
let bytesRead = 0;
|
||||
let position = fileSize;
|
||||
let newlineCount = 0;
|
||||
|
||||
while (position > 0 && newlineCount <= maxLines) {
|
||||
const readSize = Math.min(CHUNK_SIZE, position);
|
||||
position -= readSize;
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, position);
|
||||
chunks.unshift(buf);
|
||||
bytesRead += readSize;
|
||||
|
||||
for (let i = 0; i < readSize; i++) {
|
||||
if (buf[i] === 0x0a) {
|
||||
newlineCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tailContent = Buffer.concat(chunks).toString("utf8");
|
||||
const allLines = tailContent.split("\n");
|
||||
|
||||
const tail = allLines.slice(-maxLines);
|
||||
|
||||
let totalFileLines: number;
|
||||
if (bytesRead >= fileSize) {
|
||||
totalFileLines = allLines.length;
|
||||
} else {
|
||||
const avgBytesPerLine = bytesRead / Math.max(allLines.length, 1);
|
||||
totalFileLines = Math.round(fileSize / avgBytesPerLine);
|
||||
}
|
||||
|
||||
return { tail, totalFileLines };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the /bot-logs result: collect recent log files, write them to a temp file.
|
||||
*/
|
||||
export function buildBotLogsResult(): SlashCommandResult {
|
||||
const logDirs = collectCandidateLogDirs();
|
||||
const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
|
||||
|
||||
if (recentFiles.length === 0) {
|
||||
const existingDirs = logDirs.filter((d) => {
|
||||
try {
|
||||
return fs.existsSync(d);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const searched =
|
||||
existingDirs.length > 0
|
||||
? existingDirs.map((d) => ` • ${d}`).join("\n")
|
||||
: logDirs
|
||||
.slice(0, 6)
|
||||
.map((d) => ` • ${d}`)
|
||||
.join("\n") + (logDirs.length > 6 ? `\n …以及另外 ${logDirs.length - 6} 个路径` : "");
|
||||
return [
|
||||
`⚠️ 未找到日志文件`,
|
||||
``,
|
||||
`已搜索以下${existingDirs.length > 0 ? "存在的" : ""}路径:`,
|
||||
searched,
|
||||
``,
|
||||
`💡 如果日志存放在自定义路径,请在配置中添加:`,
|
||||
` "logging": { "file": "/path/to/your/logfile.log" }`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let totalIncluded = 0;
|
||||
let totalOriginal = 0;
|
||||
let truncatedCount = 0;
|
||||
const MAX_LINES_PER_FILE = 1000;
|
||||
for (const logFile of recentFiles) {
|
||||
try {
|
||||
const { tail, totalFileLines } = tailFileLines(logFile.filePath, MAX_LINES_PER_FILE);
|
||||
if (tail.length > 0) {
|
||||
const fileName = path.basename(logFile.filePath);
|
||||
lines.push(
|
||||
`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`,
|
||||
);
|
||||
lines.push(`from: ${logFile.sourceDir}`);
|
||||
lines.push(...tail);
|
||||
totalIncluded += tail.length;
|
||||
totalOriginal += totalFileLines;
|
||||
if (totalFileLines > MAX_LINES_PER_FILE) {
|
||||
truncatedCount++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
lines.push(`[Failed to read ${path.basename(logFile.filePath)}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return `⚠️ 找到了日志文件,但无法读取。请检查文件权限。`;
|
||||
}
|
||||
|
||||
const tmpDir = getQQBotDataDir("downloads");
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const tmpFile = writeNewTextFileSync(
|
||||
path.join(tmpDir, `bot-logs-${timestamp}.txt`),
|
||||
lines.join("\n"),
|
||||
);
|
||||
|
||||
const fileCount = recentFiles.length;
|
||||
const topSources = uniqueStrings(recentFiles.map((item) => item.sourceDir)).slice(0, 3);
|
||||
let summaryText = `共 ${fileCount} 个日志文件,包含 ${totalIncluded} 行内容`;
|
||||
if (truncatedCount > 0) {
|
||||
summaryText += `(其中 ${truncatedCount} 个文件已截断为最后 ${MAX_LINES_PER_FILE} 行,总计原始 ${totalOriginal} 行)`;
|
||||
}
|
||||
return {
|
||||
text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
|
||||
filePath: tmpFile,
|
||||
};
|
||||
}
|
||||
20
extensions/qqbot/src/engine/commands/builtin/register-all.ts
Normal file
20
extensions/qqbot/src/engine/commands/builtin/register-all.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Qqbot plugin module implements register all behavior.
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import { registerApproveCommands } from "./register-approve.js";
|
||||
import { registerBasicBotCommands } from "./register-basic.js";
|
||||
import { registerClearStorageCommands } from "./register-clear-storage.js";
|
||||
import { registerGroupAllwaysCommand } from "./register-group-allways.js";
|
||||
import { registerLogCommands } from "./register-logs.js";
|
||||
import { registerStreamingCommands } from "./register-streaming.js";
|
||||
|
||||
/**
|
||||
* Register all built-in slash commands on the shared registry instance.
|
||||
*/
|
||||
export function registerBuiltinSlashCommands(registry: SlashCommandRegistry): void {
|
||||
registerBasicBotCommands(registry);
|
||||
registerLogCommands(registry);
|
||||
registerClearStorageCommands(registry);
|
||||
registerStreamingCommands(registry);
|
||||
registerApproveCommands(registry);
|
||||
registerGroupAllwaysCommand(registry);
|
||||
}
|
||||
202
extensions/qqbot/src/engine/commands/builtin/register-approve.ts
Normal file
202
extensions/qqbot/src/engine/commands/builtin/register-approve.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Qqbot plugin module implements register approve behavior.
|
||||
import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js";
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import { getApproveRuntimeGetter } from "./state.js";
|
||||
|
||||
export function registerApproveCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-approve",
|
||||
description: "管理命令执行审批配置",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-approve 查看操作指引`,
|
||||
`/bot-approve on 开启审批(白名单模式,推荐)`,
|
||||
`/bot-approve off 关闭审批,命令直接执行`,
|
||||
`/bot-approve always 始终审批,每次执行都需审批`,
|
||||
`/bot-approve reset 恢复框架默认值`,
|
||||
`/bot-approve status 查看当前审批配置`,
|
||||
].join("\n"),
|
||||
handler: async (ctx) => {
|
||||
const arg = ctx.args.trim().toLowerCase();
|
||||
|
||||
let runtime: ReturnType<NonNullable<ApproveRuntimeGetter>>;
|
||||
try {
|
||||
const getter = getApproveRuntimeGetter();
|
||||
if (!getter) {
|
||||
throw new Error("runtime not available");
|
||||
}
|
||||
runtime = getter();
|
||||
} catch {
|
||||
return [
|
||||
`🔐 命令执行审批配置`,
|
||||
``,
|
||||
`❌ 当前环境不支持在线配置修改,请通过 CLI 手动配置:`,
|
||||
``,
|
||||
`\`\`\`shell`,
|
||||
`# 开启审批(白名单模式)`,
|
||||
`openclaw config set tools.exec.security allowlist`,
|
||||
`openclaw config set tools.exec.ask on-miss`,
|
||||
``,
|
||||
`# 关闭审批`,
|
||||
`openclaw config set tools.exec.security full`,
|
||||
`openclaw config set tools.exec.ask off`,
|
||||
`\`\`\``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const configApi = runtime.config;
|
||||
|
||||
const loadExecConfig = () => {
|
||||
const cfg = configApi.current();
|
||||
const tools = ((cfg as Record<string, unknown>).tools ?? {}) as Record<string, unknown>;
|
||||
const exec = (tools.exec ?? {}) as Record<string, unknown>;
|
||||
const security = typeof exec.security === "string" ? exec.security : "deny";
|
||||
const ask = typeof exec.ask === "string" ? exec.ask : "on-miss";
|
||||
return { security, ask };
|
||||
};
|
||||
|
||||
const writeExecConfig = async (security: string, ask: string) => {
|
||||
const cfg = structuredClone(configApi.current() as Record<string, unknown>);
|
||||
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
|
||||
const exec = (tools.exec ?? {}) as Record<string, unknown>;
|
||||
exec.security = security;
|
||||
exec.ask = ask;
|
||||
tools.exec = exec;
|
||||
cfg.tools = tools;
|
||||
await configApi.replaceConfigFile({ nextConfig: cfg, afterWrite: { mode: "auto" } });
|
||||
};
|
||||
|
||||
const formatStatus = (security: string, ask: string) => {
|
||||
const secIcon = security === "full" ? "🟢" : security === "allowlist" ? "🟡" : "🔴";
|
||||
const askIcon = ask === "off" ? "🟢" : ask === "always" ? "🔴" : "🟡";
|
||||
return [
|
||||
`🔐 当前审批配置`,
|
||||
``,
|
||||
`${secIcon} 安全模式 (security): **${security}**`,
|
||||
`${askIcon} 审批模式 (ask): **${ask}**`,
|
||||
``,
|
||||
security === "deny"
|
||||
? `⚠️ 当前为 deny 模式,所有命令执行被拒绝`
|
||||
: security === "full" && ask === "off"
|
||||
? `✅ 所有命令无需审批直接执行`
|
||||
: security === "allowlist" && ask === "on-miss"
|
||||
? `🛡️ 白名单命令直接执行,其余需审批`
|
||||
: ask === "always"
|
||||
? `🔒 每次命令执行都需要人工审批`
|
||||
: `ℹ️ security=${security}, ask=${ask}`,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
if (!arg) {
|
||||
return [
|
||||
`🔐 命令执行审批配置`,
|
||||
``,
|
||||
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批(白名单模式)`,
|
||||
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
|
||||
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
|
||||
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
|
||||
`<qqbot-cmd-input text="/bot-approve status" show="/bot-approve status"/> 查看当前配置`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (arg === "status") {
|
||||
const { security, ask } = loadExecConfig();
|
||||
return [
|
||||
formatStatus(security, ask),
|
||||
``,
|
||||
`<qqbot-cmd-input text="/bot-approve on" show="/bot-approve on"/> 开启审批`,
|
||||
`<qqbot-cmd-input text="/bot-approve off" show="/bot-approve off"/> 关闭审批`,
|
||||
`<qqbot-cmd-input text="/bot-approve always" show="/bot-approve always"/> 严格模式`,
|
||||
`<qqbot-cmd-input text="/bot-approve reset" show="/bot-approve reset"/> 恢复默认`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (arg === "on") {
|
||||
try {
|
||||
await writeExecConfig("allowlist", "on-miss");
|
||||
return [
|
||||
`✅ 审批已开启`,
|
||||
``,
|
||||
`• security = allowlist(白名单模式)`,
|
||||
`• ask = on-miss(未命中白名单时需审批)`,
|
||||
``,
|
||||
`已批准的命令自动加入白名单,下次直接执行。`,
|
||||
].join("\n");
|
||||
} catch (err: unknown) {
|
||||
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (arg === "off") {
|
||||
try {
|
||||
await writeExecConfig("full", "off");
|
||||
return [
|
||||
`✅ 审批已关闭`,
|
||||
``,
|
||||
`• security = full(允许所有命令)`,
|
||||
`• ask = off(不需要审批)`,
|
||||
``,
|
||||
`⚠️ 所有命令将直接执行,不会弹出审批确认。`,
|
||||
].join("\n");
|
||||
} catch (err: unknown) {
|
||||
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (arg === "always" || arg === "strict") {
|
||||
try {
|
||||
await writeExecConfig("allowlist", "always");
|
||||
return [
|
||||
`✅ 已切换为严格审批模式`,
|
||||
``,
|
||||
`• security = allowlist`,
|
||||
`• ask = always(每次执行都需审批)`,
|
||||
``,
|
||||
`每个命令都会弹出审批按钮,需手动确认。`,
|
||||
].join("\n");
|
||||
} catch (err: unknown) {
|
||||
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (arg === "reset") {
|
||||
try {
|
||||
const cfg = structuredClone(configApi.current() as Record<string, unknown>);
|
||||
const tools = (cfg.tools ?? {}) as Record<string, unknown>;
|
||||
const exec = (tools.exec ?? {}) as Record<string, unknown>;
|
||||
delete exec.security;
|
||||
delete exec.ask;
|
||||
if (Object.keys(exec).length === 0) {
|
||||
delete tools.exec;
|
||||
} else {
|
||||
tools.exec = exec;
|
||||
}
|
||||
if (Object.keys(tools).length === 0) {
|
||||
delete cfg.tools;
|
||||
} else {
|
||||
cfg.tools = tools;
|
||||
}
|
||||
await configApi.replaceConfigFile({ nextConfig: cfg, afterWrite: { mode: "auto" } });
|
||||
return [
|
||||
`✅ 审批配置已重置`,
|
||||
``,
|
||||
`已移除 tools.exec.security 和 tools.exec.ask`,
|
||||
`框架将使用默认值(security=deny, ask=on-miss)`,
|
||||
``,
|
||||
`如需开启命令执行,请使用 /bot-approve on`,
|
||||
].join("\n");
|
||||
} catch (err: unknown) {
|
||||
return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
`❌ 未知参数: ${arg}`,
|
||||
``,
|
||||
`可用选项: on | off | always | reset | status`,
|
||||
`输入 /bot-approve ? 查看详细用法`,
|
||||
].join("\n");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Qqbot plugin module implements register basic behavior.
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import { getPluginVersionString, resolveRuntimeServiceVersion } from "./state.js";
|
||||
|
||||
const QQBOT_PLUGIN_GITHUB_URL = "https://github.com/openclaw/openclaw/tree/main/extensions/qqbot";
|
||||
const QQBOT_UPGRADE_GUIDE_URL = "https://q.qq.com/qqbot/openclaw/upgrade.html";
|
||||
|
||||
export function registerBasicBotCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-help",
|
||||
description: "查看所有内置命令",
|
||||
usage: [
|
||||
`/bot-help`,
|
||||
``,
|
||||
`查看所有可用的 QQBot 内置命令及其简要说明。`,
|
||||
`在命令后追加 ? 可查看详细用法。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
const isGroup = ctx.type === "group";
|
||||
const lines = [`### QQBot 内置命令`, ``];
|
||||
for (const [name, cmd] of registry.getAllCommands()) {
|
||||
if (isGroup && cmd.c2cOnly) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
||||
}
|
||||
lines.push(``, `> 插件版本 v${getPluginVersionString()}`);
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-me",
|
||||
description: "查看当前发送者的账号ID",
|
||||
c2cOnly: true,
|
||||
usage: [`/bot-me`, ``, `显示当前发送者的账号ID`].join("\n"),
|
||||
handler: (ctx) => {
|
||||
return `你的账号ID:\`${ctx.senderId}\``;
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-ping",
|
||||
description: "测试 OpenClaw 与 QQ 之间的网络延迟",
|
||||
usage: [
|
||||
`/bot-ping`,
|
||||
``,
|
||||
`测试当前 OpenClaw 宿主机与 QQ 服务器之间的网络延迟。`,
|
||||
`返回网络传输耗时和插件处理耗时。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
const now = Date.now();
|
||||
const eventTime = new Date(ctx.eventTimestamp).getTime();
|
||||
if (Number.isNaN(eventTime)) {
|
||||
return `✅ pong!`;
|
||||
}
|
||||
const totalMs = now - eventTime;
|
||||
const qqToPlugin = ctx.receivedAt - eventTime;
|
||||
const pluginProcess = now - ctx.receivedAt;
|
||||
const lines = [
|
||||
`✅ pong!`,
|
||||
``,
|
||||
`⏱ 延迟:${totalMs}ms`,
|
||||
` ├ 网络传输:${qqToPlugin}ms`,
|
||||
` └ 插件处理:${pluginProcess}ms`,
|
||||
];
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-version",
|
||||
description: "查看 QQBot 插件版本和 OpenClaw 框架版本",
|
||||
c2cOnly: true,
|
||||
usage: [`/bot-version`, ``, `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`].join("\n"),
|
||||
handler: async () => {
|
||||
const frameworkVersion = resolveRuntimeServiceVersion();
|
||||
const ver = getPluginVersionString();
|
||||
const lines = [
|
||||
`🦞 OpenClaw 框架版本:${frameworkVersion}`,
|
||||
`🤖 QQBot 插件版本:v${ver}`,
|
||||
`🌟 官方 GitHub 仓库:[点击前往](${QQBOT_PLUGIN_GITHUB_URL})`,
|
||||
];
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "bot-upgrade",
|
||||
description: "查看 QQBot 升级指引",
|
||||
c2cOnly: true,
|
||||
usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"),
|
||||
handler: () =>
|
||||
[`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Qqbot plugin module implements register clear storage behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getQQBotMediaPath } from "../../utils/platform.js";
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
|
||||
function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] {
|
||||
const files: { filePath: string; size: number }[] = [];
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return files;
|
||||
}
|
||||
const walk = (dir: string) => {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
files.push({ filePath: fullPath, size: stat.size });
|
||||
} catch {
|
||||
// Skip inaccessible files.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dirPath);
|
||||
files.sort((a, b) => b.size - a.size);
|
||||
return files;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function removeEmptyDirs(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return;
|
||||
}
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
removeEmptyDirs(path.join(dirPath, entry.name));
|
||||
}
|
||||
}
|
||||
try {
|
||||
const remaining = fs.readdirSync(dirPath);
|
||||
if (remaining.length === 0) {
|
||||
fs.rmdirSync(dirPath);
|
||||
}
|
||||
} catch {
|
||||
// Directory may be in use, skip.
|
||||
}
|
||||
}
|
||||
|
||||
const CLEAR_STORAGE_MAX_DISPLAY = 10;
|
||||
|
||||
/**
|
||||
* Resolve the canonical QQBot downloads directory.
|
||||
*
|
||||
* All inbound attachments and outbound fallback downloads are stored directly
|
||||
* under `~/.openclaw/media/qqbot/downloads/` without appId subdivision.
|
||||
* The clear-storage command therefore cleans the entire downloads root.
|
||||
*/
|
||||
function resolveQqbotDownloadsDir(): string {
|
||||
return getQQBotMediaPath("downloads");
|
||||
}
|
||||
|
||||
export function registerClearStorageCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-clear-storage",
|
||||
description: "清理通过 QQBot 对话产生的下载文件,释放主机磁盘空间",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-clear-storage`,
|
||||
``,
|
||||
`扫描 QQBot 下载目录下的所有文件并列出明细。`,
|
||||
`确认后执行删除,释放主机磁盘空间。`,
|
||||
``,
|
||||
`/bot-clear-storage --force 确认执行清理`,
|
||||
``,
|
||||
`⚠️ 仅在私聊中可用。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
const isForce = ctx.args.trim() === "--force";
|
||||
const targetDir = resolveQqbotDownloadsDir();
|
||||
const displayDir = `~/.openclaw/media/qqbot/downloads`;
|
||||
|
||||
if (!isForce) {
|
||||
const files = scanDirectoryFiles(targetDir);
|
||||
|
||||
if (files.length === 0) {
|
||||
return [`✅ 当前没有需要清理的文件`, ``, `目录 \`${displayDir}\` 为空或不存在。`].join(
|
||||
"\n",
|
||||
);
|
||||
}
|
||||
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
const lines: string[] = [
|
||||
`即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}。`,
|
||||
``,
|
||||
`目录文件概况:`,
|
||||
];
|
||||
|
||||
const displayFiles = files.slice(0, CLEAR_STORAGE_MAX_DISPLAY);
|
||||
for (const f of displayFiles) {
|
||||
const relativePath = path.relative(targetDir, f.filePath).replace(/\\/g, "/");
|
||||
lines.push(`${relativePath} (${formatBytes(f.size)})`, ``, ``);
|
||||
}
|
||||
if (files.length > CLEAR_STORAGE_MAX_DISPLAY) {
|
||||
lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)})]`, ``);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
`确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中 AI 无法再找回相关文件。`,
|
||||
`‼️ 点击指令确认删除`,
|
||||
`<qqbot-cmd-enter text="/bot-clear-storage --force" />`,
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const files = scanDirectoryFiles(targetDir);
|
||||
|
||||
if (files.length === 0) {
|
||||
return `✅ 目录已为空,无需清理`;
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
let deletedSize = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const f of files) {
|
||||
try {
|
||||
fs.unlinkSync(f.filePath);
|
||||
deletedCount++;
|
||||
deletedSize += f.size;
|
||||
} catch {
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
removeEmptyDirs(targetDir);
|
||||
} catch {
|
||||
// Non-critical, silently ignore.
|
||||
}
|
||||
|
||||
if (failedCount === 0) {
|
||||
return [
|
||||
`✅ 清理成功`,
|
||||
``,
|
||||
`已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
`⚠️ 部分清理完成`,
|
||||
``,
|
||||
`已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}),${failedCount} 个文件删除失败。`,
|
||||
].join("\n");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Qqbot tests cover group-allways command plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QueuedMessage } from "../../gateway/message-queue.js";
|
||||
import type { GatewayAccount } from "../../gateway/types.js";
|
||||
import { sendText } from "../../messaging/sender.js";
|
||||
import { trySlashCommand } from "../slash-command-handler.js";
|
||||
import { installCommandRuntime } from "../slash-command-test-support.js";
|
||||
|
||||
vi.mock("../../messaging/outbound.js", () => ({
|
||||
sendDocument: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../messaging/sender.js", () => ({
|
||||
accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })),
|
||||
buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })),
|
||||
sendText: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
type WrittenQQBotConfigWithAllways = {
|
||||
defaultRequireMention?: unknown;
|
||||
accounts?: Record<string, { defaultRequireMention?: unknown }>;
|
||||
};
|
||||
|
||||
type RunCommandParams = {
|
||||
account?: GatewayAccount;
|
||||
arg?: string;
|
||||
config?: OpenClawConfig;
|
||||
};
|
||||
|
||||
const queueSnapshot = {
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
};
|
||||
|
||||
function createGroupAllwaysMessage(arg = ""): QueuedMessage {
|
||||
return {
|
||||
type: "c2c",
|
||||
senderId: "TRUSTED_OPENID",
|
||||
content: `/bot-group-allways ${arg}`.trim(),
|
||||
messageId: "msg-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createAccount(accountId = "default", overrides?: Record<string, unknown>): GatewayAccount {
|
||||
return {
|
||||
accountId,
|
||||
appId: "app",
|
||||
clientSecret: "",
|
||||
markdownSupport: true,
|
||||
config: {
|
||||
allowFrom: ["*"],
|
||||
...(accountId === "default" ? { defaultRequireMention: true } : {}),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(qqbot: NonNullable<OpenClawConfig["channels"]>["qqbot"]): OpenClawConfig {
|
||||
return {
|
||||
commands: {
|
||||
allowFrom: { qqbot: ["TRUSTED_OPENID"] },
|
||||
},
|
||||
channels: { qqbot },
|
||||
};
|
||||
}
|
||||
|
||||
function getAllwaysConfig(
|
||||
write: OpenClawConfig | undefined,
|
||||
): WrittenQQBotConfigWithAllways | undefined {
|
||||
return write?.channels?.qqbot as WrittenQQBotConfigWithAllways | undefined;
|
||||
}
|
||||
|
||||
async function runGroupAllwaysCommand({
|
||||
account = createAccount(),
|
||||
arg = "",
|
||||
config = createConfig({ allowFrom: ["*"], defaultRequireMention: true }),
|
||||
}: RunCommandParams = {}) {
|
||||
const writes: OpenClawConfig[] = [];
|
||||
installCommandRuntime(config, writes);
|
||||
|
||||
const result = await trySlashCommand(createGroupAllwaysMessage(arg), {
|
||||
account,
|
||||
cfg: config,
|
||||
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
|
||||
getQueueSnapshot: () => queueSnapshot,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
writes,
|
||||
reply: vi.mocked(sendText).mock.calls.at(0)?.[1] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("bot-group-allways command", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(sendText).mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
defaultRequireMention: true,
|
||||
expectedReply: "仅被 @ 时回复",
|
||||
},
|
||||
{
|
||||
defaultRequireMention: false,
|
||||
expectedReply: "自主判断何时发言",
|
||||
},
|
||||
])("shows current status for defaultRequireMention=$defaultRequireMention", async (testCase) => {
|
||||
const config = createConfig({
|
||||
allowFrom: ["*"],
|
||||
defaultRequireMention: testCase.defaultRequireMention,
|
||||
});
|
||||
|
||||
const { result, reply, writes } = await runGroupAllwaysCommand({
|
||||
account: createAccount("default", {
|
||||
defaultRequireMention: testCase.defaultRequireMention,
|
||||
}),
|
||||
config,
|
||||
});
|
||||
|
||||
expect(result).toBe("handled");
|
||||
expect(writes).toHaveLength(0);
|
||||
expect(reply).toContain(testCase.expectedReply);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
arg: "on",
|
||||
currentDefaultRequireMention: true,
|
||||
expectedDefaultRequireMention: false,
|
||||
expectedReply: "**on**",
|
||||
},
|
||||
{
|
||||
arg: "off",
|
||||
currentDefaultRequireMention: false,
|
||||
expectedDefaultRequireMention: true,
|
||||
expectedReply: "**off**",
|
||||
},
|
||||
])("writes defaultRequireMention for default account when toggled $arg", async (testCase) => {
|
||||
const config = createConfig({
|
||||
allowFrom: ["*"],
|
||||
defaultRequireMention: testCase.currentDefaultRequireMention,
|
||||
});
|
||||
|
||||
const { result, reply, writes } = await runGroupAllwaysCommand({
|
||||
account: createAccount("default", {
|
||||
defaultRequireMention: testCase.currentDefaultRequireMention,
|
||||
}),
|
||||
arg: testCase.arg,
|
||||
config,
|
||||
});
|
||||
|
||||
expect(result).toBe("handled");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(getAllwaysConfig(writes[0])?.defaultRequireMention).toBe(
|
||||
testCase.expectedDefaultRequireMention,
|
||||
);
|
||||
expect(reply).toContain(testCase.expectedReply);
|
||||
});
|
||||
|
||||
it("writes to accounts.{accountId}.defaultRequireMention for named accounts", async () => {
|
||||
const { result, writes } = await runGroupAllwaysCommand({
|
||||
account: createAccount("bot-a"),
|
||||
arg: "on",
|
||||
config: createConfig({
|
||||
allowFrom: ["*"],
|
||||
accounts: {
|
||||
"bot-a": {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("handled");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(getAllwaysConfig(writes[0])?.accounts?.["bot-a"]?.defaultRequireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no-op when toggling to same state", async () => {
|
||||
const { result, reply, writes } = await runGroupAllwaysCommand({
|
||||
arg: "off",
|
||||
config: createConfig({
|
||||
allowFrom: ["*"],
|
||||
defaultRequireMention: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("handled");
|
||||
expect(writes).toHaveLength(0);
|
||||
expect(reply).toContain("无需操作");
|
||||
});
|
||||
|
||||
it("returns error for invalid argument", async () => {
|
||||
const { result, reply, writes } = await runGroupAllwaysCommand({
|
||||
arg: "invalid",
|
||||
config: createConfig({
|
||||
allowFrom: ["*"],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("handled");
|
||||
expect(writes).toHaveLength(0);
|
||||
expect(reply).toContain("参数错误");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// 导入运行时配置缓存清除函数,确保配置更新后 getRuntimeConfig() 能读取到最新值
|
||||
import { clearRuntimeConfigSnapshot } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
// Qqbot plugin module implements register group allways behavior.
|
||||
import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js";
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import {
|
||||
getApproveRuntimeGetter,
|
||||
getPluginVersionString,
|
||||
resolveRuntimeServiceVersion,
|
||||
} from "./state.js";
|
||||
|
||||
export function registerGroupAllwaysCommand(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-group-allways",
|
||||
description: "修改群消息默认响应模式",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-group-allways on AI 自主判断何时发言(无需 @)`,
|
||||
`/bot-group-allways off 仅在被 @ 时回复`,
|
||||
`/bot-group-allways 查看当前设置`,
|
||||
``,
|
||||
`设为 on 后,AI 会自主判断每条消息是否需要回复(无需 @)。`,
|
||||
`仍可通过 groups.{groupId}.requireMention 对单个群覆盖。`,
|
||||
``,
|
||||
`优先级:具体群配置 > 通配符 "*" > defaultRequireMention(本指令)> 默认 true`,
|
||||
].join("\n"),
|
||||
handler: async (ctx) => {
|
||||
const arg = ctx.args.trim().toLowerCase();
|
||||
|
||||
// 读取当前 defaultRequireMention 状态
|
||||
const currentVal = ctx.accountConfig?.defaultRequireMention;
|
||||
const currentRequireMention = currentVal ?? true; // 未设置时硬编码默认为 true
|
||||
|
||||
// 无参数:查看当前状态
|
||||
if (!arg) {
|
||||
return [
|
||||
`🤖 群自主发言状态:${currentRequireMention ? "❌ 仅被 @ 时回复" : "✅ 自主判断何时发言"}`,
|
||||
`使用 <qqbot-cmd-input text="/bot-group-allways on" show="/bot-group-allways on"/> 设为自主发言`,
|
||||
`使用 <qqbot-cmd-input text="/bot-group-allways off" show="/bot-group-allways off"/> 设为仅被 @ 时回复`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (arg !== "on" && arg !== "off") {
|
||||
return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-group-allways on`;
|
||||
}
|
||||
|
||||
const newRequireMention = arg === "off"; // on=自主发言(requireMention=false), off=仅被@时回复(requireMention=true)
|
||||
|
||||
// 如果状态没变,直接返回
|
||||
if (newRequireMention === currentRequireMention) {
|
||||
return `🤖 群自主发言已经是"${arg}"状态,无需操作`;
|
||||
}
|
||||
|
||||
// 获取运行时配置 API
|
||||
let runtime: ReturnType<NonNullable<ApproveRuntimeGetter>>;
|
||||
try {
|
||||
const getter = getApproveRuntimeGetter();
|
||||
if (!getter) {
|
||||
throw new Error("runtime not available");
|
||||
}
|
||||
runtime = getter();
|
||||
} catch {
|
||||
const fwVer = resolveRuntimeServiceVersion();
|
||||
const ver = getPluginVersionString();
|
||||
return [
|
||||
`❌ 当前版本不支持该指令`,
|
||||
``,
|
||||
`🦞框架版本:${fwVer}`,
|
||||
`🤖QQBot 插件版本:v${ver}`,
|
||||
``,
|
||||
`可通过以下命令手动设置:`,
|
||||
``,
|
||||
`\`\`\`shell`,
|
||||
`# 设为 AI 自主判断何时发言(defaultRequireMention=false)`,
|
||||
`openclaw config set channels.qqbot.defaultRequireMention false`,
|
||||
`# 或设为仅被 @ 时回复(defaultRequireMention=true)`,
|
||||
`openclaw config set channels.qqbot.defaultRequireMention true`,
|
||||
`\`\`\``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
try {
|
||||
const configApi = runtime.config;
|
||||
const currentCfg = structuredClone(configApi.current() as Record<string, unknown>);
|
||||
const qqbot = ((currentCfg.channels ?? {}) as Record<string, unknown>).qqbot as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (!qqbot) {
|
||||
return `❌ 配置文件中未找到 qqbot 通道配置`;
|
||||
}
|
||||
|
||||
const accountId = ctx.accountId;
|
||||
const isNamedAccount =
|
||||
accountId !== "default" &&
|
||||
Boolean(
|
||||
(qqbot.accounts as Record<string, Record<string, unknown>> | undefined)?.[accountId],
|
||||
);
|
||||
|
||||
if (isNamedAccount) {
|
||||
// 命名账户:更新 accounts.{accountId}.defaultRequireMention
|
||||
const accounts = (qqbot.accounts as Record<string, Record<string, unknown>>) ?? {};
|
||||
const nextAccounts = { ...accounts };
|
||||
const acct = { ...nextAccounts[accountId] };
|
||||
acct.defaultRequireMention = newRequireMention;
|
||||
nextAccounts[accountId] = acct;
|
||||
qqbot.accounts = nextAccounts;
|
||||
} else {
|
||||
// 默认账户:更新 qqbot.defaultRequireMention
|
||||
qqbot.defaultRequireMention = newRequireMention;
|
||||
}
|
||||
|
||||
await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } });
|
||||
|
||||
// 清除运行时配置缓存,确保 getRuntimeConfig() 下次调用时重新加载最新配置
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
return [
|
||||
`✅ 群自主发言已设置为 ${newRequireMention ? "**off**(仅被 @ 时回复)" : "**on**(AI 自主判断何时发言)"}`,
|
||||
``,
|
||||
newRequireMention
|
||||
? `仅在被 @ 机器人才会回复。`
|
||||
: `AI 将自主判断群消息是否需要回复,无需被 @ 即可发言。`,
|
||||
].join("\n");
|
||||
} catch (err: unknown) {
|
||||
return `❌ 配置写入失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Qqbot plugin module implements register logs behavior.
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import { buildBotLogsResult } from "./log-helpers.js";
|
||||
|
||||
export function registerLogCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-logs",
|
||||
description: "导出本地日志文件",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-logs`,
|
||||
``,
|
||||
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
|
||||
`每个文件只保留最后 1000 行,并作为附件返回。`,
|
||||
].join("\n"),
|
||||
handler: () => {
|
||||
return buildBotLogsResult();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Qqbot plugin module implements register streaming behavior.
|
||||
import type { ApproveRuntimeGetter } from "../../adapter/commands.port.js";
|
||||
import type { SlashCommandRegistry } from "../slash-commands.js";
|
||||
import {
|
||||
getApproveRuntimeGetter,
|
||||
getPluginVersionString,
|
||||
resolveRuntimeServiceVersion,
|
||||
} from "./state.js";
|
||||
|
||||
function isStreamingConfigEnabled(streaming: unknown): boolean {
|
||||
if (streaming === true) {
|
||||
return true;
|
||||
}
|
||||
if (streaming === false || streaming === undefined || streaming === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof streaming === "object") {
|
||||
const o = streaming as Record<string, unknown>;
|
||||
if (o.c2cStreamApi === true) {
|
||||
return true;
|
||||
}
|
||||
if (o.mode === "off") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerStreamingCommands(registry: SlashCommandRegistry): void {
|
||||
registry.register({
|
||||
name: "bot-streaming",
|
||||
description: "一键开关流式消息",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
usage: [
|
||||
`/bot-streaming on 开启流式消息`,
|
||||
`/bot-streaming off 关闭流式消息`,
|
||||
`/bot-streaming 查看当前流式消息状态`,
|
||||
``,
|
||||
`开启后,AI 的回复会以流式形式逐步显示(打字机效果)。`,
|
||||
`注意:仅 C2C(私聊)支持流式消息。`,
|
||||
].join("\n"),
|
||||
handler: async (ctx) => {
|
||||
const arg = ctx.args.trim().toLowerCase();
|
||||
const currentOn = isStreamingConfigEnabled(ctx.accountConfig?.streaming);
|
||||
|
||||
if (!arg) {
|
||||
return [
|
||||
`📡 流式消息状态:${currentOn ? "✅ 已开启" : "❌ 已关闭"}`,
|
||||
``,
|
||||
`使用 <qqbot-cmd-input text="/bot-streaming on" show="/bot-streaming on"/> 开启`,
|
||||
`使用 <qqbot-cmd-input text="/bot-streaming off" show="/bot-streaming off"/> 关闭`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
if (arg !== "on" && arg !== "off") {
|
||||
return `❌ 参数错误,请使用 on 或 off\n\n示例:/bot-streaming on`;
|
||||
}
|
||||
|
||||
const wantOn = arg === "on";
|
||||
if (wantOn === currentOn) {
|
||||
return `📡 流式消息已经是${wantOn ? "开启" : "关闭"}状态,无需操作`;
|
||||
}
|
||||
|
||||
let runtime: ReturnType<NonNullable<ApproveRuntimeGetter>>;
|
||||
try {
|
||||
const getter = getApproveRuntimeGetter();
|
||||
if (!getter) {
|
||||
throw new Error("runtime not available");
|
||||
}
|
||||
runtime = getter();
|
||||
} catch {
|
||||
const fwVer = resolveRuntimeServiceVersion();
|
||||
const ver = getPluginVersionString();
|
||||
return [
|
||||
`❌ 当前版本不支持该指令`,
|
||||
``,
|
||||
`🦞框架版本:${fwVer}`,
|
||||
`🤖QQBot 插件版本:v${ver}`,
|
||||
``,
|
||||
`可通过以下命令手动开启流式消息:`,
|
||||
``,
|
||||
`\`\`\`shell`,
|
||||
`# 1. 开启流式消息`,
|
||||
`openclaw config set channels.qqbot.streaming true`,
|
||||
``,
|
||||
`# 2. 重启网关使配置生效`,
|
||||
`openclaw gateway restart`,
|
||||
`\`\`\``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
try {
|
||||
const configApi = runtime.config;
|
||||
const currentCfg = structuredClone(configApi.current() as Record<string, unknown>);
|
||||
const qqbot = ((currentCfg.channels ?? {}) as Record<string, unknown>).qqbot as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (!qqbot) {
|
||||
return `❌ 配置文件中未找到 qqbot 通道配置`;
|
||||
}
|
||||
|
||||
const accountId = ctx.accountId;
|
||||
const newVal: unknown = wantOn;
|
||||
|
||||
if (accountId !== "default") {
|
||||
const prevAccounts =
|
||||
(qqbot.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
|
||||
const nextAccounts = { ...prevAccounts };
|
||||
const acct = { ...nextAccounts[accountId] };
|
||||
acct.streaming = newVal;
|
||||
nextAccounts[accountId] = acct;
|
||||
qqbot.accounts = nextAccounts;
|
||||
} else {
|
||||
qqbot.streaming = newVal;
|
||||
const accs = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
if (accs?.default && typeof accs.default === "object") {
|
||||
const nextAccs = { ...accs };
|
||||
const def = { ...accs.default, streaming: newVal };
|
||||
nextAccs.default = def;
|
||||
qqbot.accounts = nextAccs;
|
||||
}
|
||||
}
|
||||
|
||||
await configApi.replaceConfigFile({ nextConfig: currentCfg, afterWrite: { mode: "auto" } });
|
||||
|
||||
return [
|
||||
`✅ 流式消息已${wantOn ? "开启" : "关闭"}`,
|
||||
``,
|
||||
wantOn ? `AI 的回复将以流式形式逐步显示(仅私聊生效)。` : `AI 的回复将恢复为完整发送。`,
|
||||
].join("\n");
|
||||
} catch (err: unknown) {
|
||||
return `❌ 配置写入失败: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
32
extensions/qqbot/src/engine/commands/builtin/state.ts
Normal file
32
extensions/qqbot/src/engine/commands/builtin/state.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Qqbot plugin module implements state behavior.
|
||||
import type { ApproveRuntimeGetter, CommandsPort } from "../../adapter/commands.port.js";
|
||||
|
||||
let resolveVersionGetter: () => string = () => "unknown";
|
||||
let approveRuntimeGetter: ApproveRuntimeGetter | null = null;
|
||||
let PLUGIN_VERSION = "unknown";
|
||||
|
||||
/**
|
||||
* Initialize command dependencies from the EngineAdapters.commands port.
|
||||
* Called once by the bridge layer during startup.
|
||||
*/
|
||||
export function initSlashCommandDeps(port: CommandsPort): void {
|
||||
resolveVersionGetter = port.resolveVersion;
|
||||
PLUGIN_VERSION = port.pluginVersion;
|
||||
approveRuntimeGetter = port.approveRuntimeGetter ?? null;
|
||||
}
|
||||
|
||||
export function resolveRuntimeServiceVersion(): string {
|
||||
return resolveVersionGetter();
|
||||
}
|
||||
|
||||
export function getPluginVersionString(): string {
|
||||
return PLUGIN_VERSION;
|
||||
}
|
||||
|
||||
export function getFrameworkVersionString(): string {
|
||||
return resolveVersionGetter();
|
||||
}
|
||||
|
||||
export function getApproveRuntimeGetter(): ApproveRuntimeGetter | null {
|
||||
return approveRuntimeGetter;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Qqbot tests cover group command visibility classification.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyCoreCommandForGroup, parseSlashCommandName } from "./command-visibility.js";
|
||||
|
||||
describe("QQBot command visibility", () => {
|
||||
it("parses slash command names case-insensitively", () => {
|
||||
expect(parseSlashCommandName(" /NEW now ")).toBe("new");
|
||||
expect(parseSlashCommandName("/CONFIG: show")).toBe("config");
|
||||
expect(parseSlashCommandName("/config:show")).toBe("config");
|
||||
expect(parseSlashCommandName("/config@bot show")).toBe("config");
|
||||
expect(parseSlashCommandName("hello")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps safe collaboration commands visible in groups", () => {
|
||||
for (const command of ["/help", "/btw side question", "/stop"]) {
|
||||
expect(classifyCoreCommandForGroup(command).visibility).toBe("group");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps group-session controls callable but hidden from group menus", () => {
|
||||
for (const command of ["/new", "/reset", "/name", "/compact"]) {
|
||||
expect(classifyCoreCommandForGroup(command).visibility).toBe("hidden");
|
||||
}
|
||||
expect(classifyCoreCommandForGroup("/name", "safety").visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("marks sensitive core commands as private-only in groups", () => {
|
||||
for (const command of [
|
||||
"/config",
|
||||
"/bash",
|
||||
"/export-session",
|
||||
"/diagnostics",
|
||||
"/tts",
|
||||
"/steer",
|
||||
"/tell",
|
||||
"/model",
|
||||
"/models",
|
||||
"/status",
|
||||
"/verbose",
|
||||
"/v",
|
||||
"/config: show",
|
||||
"/model@bot sonnet",
|
||||
]) {
|
||||
expect(classifyCoreCommandForGroup(command, "safety").visibility).toBe("private");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps omitted command level compatible with all mode", () => {
|
||||
for (const command of ["/config", "/bash", "/new", "/status"]) {
|
||||
expect(classifyCoreCommandForGroup(command).visibility).not.toBe("private");
|
||||
}
|
||||
});
|
||||
|
||||
it("allows every recognized core command in all mode", () => {
|
||||
for (const command of ["/config", "/bash", "/new", "/name", "/status"]) {
|
||||
expect(classifyCoreCommandForGroup(command, "all").visibility).not.toBe("private");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps urgent stop callable in strict mode", () => {
|
||||
expect(classifyCoreCommandForGroup("/stop", "strict").visibility).toBe("group");
|
||||
});
|
||||
|
||||
it("limits other core commands in strict mode", () => {
|
||||
expect(classifyCoreCommandForGroup("/new", "strict").visibility).toBe("hidden");
|
||||
expect(classifyCoreCommandForGroup("/reset", "strict").visibility).toBe("hidden");
|
||||
expect(classifyCoreCommandForGroup("/name", "strict").visibility).toBe("private");
|
||||
expect(classifyCoreCommandForGroup("/status", "strict").visibility).toBe("private");
|
||||
expect(classifyCoreCommandForGroup("/config", "strict").visibility).toBe("private");
|
||||
});
|
||||
|
||||
it("keeps strict mode fail-closed for unclassified slash commands", () => {
|
||||
expect(classifyCoreCommandForGroup("/bot-dynamic", "strict").visibility).toBe("private");
|
||||
expect(classifyCoreCommandForGroup("/unknown", "strict").visibility).toBe("private");
|
||||
});
|
||||
|
||||
it("does not make plugin and unknown slash commands private in all mode", () => {
|
||||
expect(classifyCoreCommandForGroup("/bot-help").visibility).not.toBe("private");
|
||||
expect(classifyCoreCommandForGroup("/unknown").visibility).not.toBe("private");
|
||||
});
|
||||
|
||||
it("leaves plugin and unknown slash commands to their existing dispatch path in safety mode", () => {
|
||||
expect(classifyCoreCommandForGroup("/bot-help", "safety").visibility).toBe("unknown");
|
||||
expect(classifyCoreCommandForGroup("/unknown", "safety").visibility).toBe("unknown");
|
||||
});
|
||||
});
|
||||
119
extensions/qqbot/src/engine/commands/command-visibility.ts
Normal file
119
extensions/qqbot/src/engine/commands/command-visibility.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
// Qqbot plugin module classifies slash-command visibility for QQ group chats.
|
||||
import type { QQBotGroupCommandLevel } from "../config/group.js";
|
||||
|
||||
export type GroupCommandVisibility = "group" | "hidden" | "private" | "unknown";
|
||||
|
||||
export const PRIVATE_CHAT_ONLY_TEXT = "该命令仅限私聊使用,请在私聊中发送。";
|
||||
|
||||
const GROUP_VISIBLE_CORE_COMMANDS = new Set(["help", "btw", "side", "stop"]);
|
||||
|
||||
const STRICT_CORE_COMMANDS = new Set(["new", "reset"]);
|
||||
|
||||
const GROUP_HIDDEN_CORE_COMMANDS = new Set([
|
||||
"goal",
|
||||
"usage",
|
||||
"activation",
|
||||
"send",
|
||||
"reset",
|
||||
"new",
|
||||
"name",
|
||||
"compact",
|
||||
"think",
|
||||
"thinking",
|
||||
"t",
|
||||
"fast",
|
||||
"reasoning",
|
||||
"reason",
|
||||
"queue",
|
||||
]);
|
||||
|
||||
const PRIVATE_ONLY_CORE_COMMANDS = new Set([
|
||||
"commands",
|
||||
"tools",
|
||||
"skill",
|
||||
"diagnostics",
|
||||
"crestodian",
|
||||
"tasks",
|
||||
"allowlist",
|
||||
"approve",
|
||||
"context",
|
||||
"export-session",
|
||||
"export",
|
||||
"export-trajectory",
|
||||
"trajectory",
|
||||
"tts",
|
||||
"whoami",
|
||||
"id",
|
||||
"session",
|
||||
"subagents",
|
||||
"acp",
|
||||
"focus",
|
||||
"unfocus",
|
||||
"agents",
|
||||
"steer",
|
||||
"tell",
|
||||
"config",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"plugin",
|
||||
"debug",
|
||||
"status",
|
||||
"restart",
|
||||
"trace",
|
||||
"verbose",
|
||||
"v",
|
||||
"elevated",
|
||||
"elev",
|
||||
"exec",
|
||||
"model",
|
||||
"models",
|
||||
"bash",
|
||||
]);
|
||||
|
||||
export function parseSlashCommandName(content: string | undefined | null): string | undefined {
|
||||
const trimmed = (content ?? "").trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return undefined;
|
||||
}
|
||||
const firstToken = trimmed.slice(1).split(/\s+/, 1)[0]?.trim().toLowerCase() ?? "";
|
||||
const commandName = firstToken.split(/[@::]/u, 1)[0] ?? "";
|
||||
return commandName || undefined;
|
||||
}
|
||||
|
||||
export function classifyCoreCommandForGroup(
|
||||
content: string | undefined | null,
|
||||
commandLevel: QQBotGroupCommandLevel = "all",
|
||||
): {
|
||||
commandName?: string;
|
||||
visibility: GroupCommandVisibility;
|
||||
} {
|
||||
const commandName = parseSlashCommandName(content);
|
||||
if (!commandName) {
|
||||
return { visibility: "unknown" };
|
||||
}
|
||||
if (commandLevel === "all") {
|
||||
return {
|
||||
commandName,
|
||||
visibility: GROUP_VISIBLE_CORE_COMMANDS.has(commandName) ? "group" : "hidden",
|
||||
};
|
||||
}
|
||||
if (commandLevel === "strict") {
|
||||
if (commandName === "stop") {
|
||||
return { commandName, visibility: "group" };
|
||||
}
|
||||
if (STRICT_CORE_COMMANDS.has(commandName)) {
|
||||
return { commandName, visibility: "hidden" };
|
||||
}
|
||||
return { commandName, visibility: "private" };
|
||||
}
|
||||
if (GROUP_VISIBLE_CORE_COMMANDS.has(commandName)) {
|
||||
return { commandName, visibility: "group" };
|
||||
}
|
||||
if (GROUP_HIDDEN_CORE_COMMANDS.has(commandName)) {
|
||||
return { commandName, visibility: "hidden" };
|
||||
}
|
||||
if (PRIVATE_ONLY_CORE_COMMANDS.has(commandName)) {
|
||||
return { commandName, visibility: "private" };
|
||||
}
|
||||
return { commandName, visibility: "unknown" };
|
||||
}
|
||||
88
extensions/qqbot/src/engine/commands/slash-command-auth.ts
Normal file
88
extensions/qqbot/src/engine/commands/slash-command-auth.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Pre-dispatch authorization for requireAuth slash commands.
|
||||
*
|
||||
* Unlike the inbound message ingress command projection (which permits
|
||||
* open-policy chat senders), this function requires the sender to appear in an
|
||||
* **explicit non-wildcard** allowFrom list.
|
||||
*
|
||||
* Rationale: sensitive operations (log export, file deletion, approval
|
||||
* config changes) must be gated behind a deliberate operator decision.
|
||||
* A wide-open DM policy means "anyone can chat", not "anyone can run
|
||||
* admin commands".
|
||||
*/
|
||||
|
||||
import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../access/index.js";
|
||||
|
||||
type SlashCommandAuthEntry = string | number;
|
||||
|
||||
function isSlashCommandAuthEntry(value: unknown): value is SlashCommandAuthEntry {
|
||||
return typeof value === "string" || typeof value === "number";
|
||||
}
|
||||
|
||||
function readSlashCommandAuthList(value: unknown): SlashCommandAuthEntry[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value.filter(isSlashCommandAuthEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the command-specific QQBot allowlist from the root OpenClaw config.
|
||||
*
|
||||
* `commands.allowFrom.qqbot` takes precedence over the global
|
||||
* `commands.allowFrom["*"]`, matching the framework command authorization
|
||||
* contract used by registered plugin commands.
|
||||
*/
|
||||
export function resolveQQBotCommandsAllowFrom(cfg: unknown): SlashCommandAuthEntry[] | undefined {
|
||||
if (!cfg || typeof cfg !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const commands = (cfg as { commands?: unknown }).commands;
|
||||
if (!commands || typeof commands !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const allowFrom = (commands as { allowFrom?: unknown }).allowFrom;
|
||||
if (!allowFrom || typeof allowFrom !== "object" || Array.isArray(allowFrom)) {
|
||||
return undefined;
|
||||
}
|
||||
const byProvider = allowFrom as Record<string, unknown>;
|
||||
return readSlashCommandAuthList(byProvider.qqbot) ?? readSlashCommandAuthList(byProvider["*"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether `senderId` is authorized to execute `requireAuth`
|
||||
* slash commands for the given account configuration.
|
||||
*
|
||||
* Authorization rules:
|
||||
* - `commands.allowFrom.qqbot` / `commands.allowFrom["*"]` configured →
|
||||
* use that command-specific list instead of channel allowFrom
|
||||
* - `allowFrom` not configured / empty / only `["*"]` → **false**
|
||||
* (wildcard means "open to everyone", not explicit authorization)
|
||||
* - `allowFrom` contains at least one concrete entry AND sender
|
||||
* matches a concrete entry → **true**
|
||||
* - Group messages use `groupAllowFrom` when present, falling back
|
||||
* to `allowFrom`.
|
||||
*/
|
||||
export function resolveSlashCommandAuth(params: {
|
||||
senderId: string;
|
||||
isGroup: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
commandsAllowFrom?: Array<string | number>;
|
||||
}): boolean {
|
||||
const rawList =
|
||||
params.commandsAllowFrom ??
|
||||
(params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
|
||||
? params.groupAllowFrom
|
||||
: params.allowFrom);
|
||||
|
||||
const normalized = normalizeQQBotAllowFrom(rawList);
|
||||
|
||||
// Require and match only explicit (non-wildcard) entries.
|
||||
const explicitEntries = normalized.filter((entry) => entry !== "*");
|
||||
if (explicitEntries.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return createQQBotSenderMatcher(params.senderId)(explicitEntries);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Qqbot tests cover slash command handler plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QueuedMessage } from "../gateway/message-queue.js";
|
||||
import type { GatewayAccount } from "../gateway/types.js";
|
||||
import { sendText } from "../messaging/sender.js";
|
||||
import { trySlashCommand } from "./slash-command-handler.js";
|
||||
import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js";
|
||||
|
||||
vi.mock("../messaging/outbound.js", () => ({
|
||||
sendDocument: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../messaging/sender.js", () => ({
|
||||
accountToCreds: vi.fn(() => ({ appId: "app", clientSecret: "" })),
|
||||
buildDeliveryTarget: vi.fn(() => ({ targetType: "c2c", targetId: "TRUSTED_OPENID" })),
|
||||
sendText: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
function createStreamingMessage(): QueuedMessage {
|
||||
return {
|
||||
type: "c2c",
|
||||
senderId: "TRUSTED_OPENID",
|
||||
content: "/bot-streaming on",
|
||||
messageId: "msg-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupStopMessage(): QueuedMessage {
|
||||
return {
|
||||
type: "group",
|
||||
senderId: "TRUSTED_OPENID",
|
||||
content: "/stop",
|
||||
messageId: "msg-stop",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
groupOpenid: "GROUP_OPENID",
|
||||
};
|
||||
}
|
||||
|
||||
function createDmStopMessage(): QueuedMessage {
|
||||
return {
|
||||
type: "c2c",
|
||||
senderId: "TRUSTED_OPENID",
|
||||
content: "/stop",
|
||||
messageId: "msg-stop-dm",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createAccount(): GatewayAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
appId: "app",
|
||||
clientSecret: "",
|
||||
markdownSupport: true,
|
||||
config: {
|
||||
allowFrom: ["*"],
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authorizeGroupCommands(account: GatewayAccount): void {
|
||||
account.config.groupAllowFrom = ["TRUSTED_OPENID"];
|
||||
}
|
||||
|
||||
describe("trySlashCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(sendText).mockClear();
|
||||
});
|
||||
|
||||
it("honors commands.allowFrom for pre-dispatch bot-streaming in open DM configs", async () => {
|
||||
const writes: OpenClawConfig[] = [];
|
||||
const config: OpenClawConfig = {
|
||||
commands: {
|
||||
allowFrom: {
|
||||
qqbot: ["TRUSTED_OPENID"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
qqbot: {
|
||||
allowFrom: ["*"],
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
installCommandRuntime(config, writes);
|
||||
|
||||
const result = await trySlashCommand(createStreamingMessage(), {
|
||||
account: createAccount(),
|
||||
cfg: config,
|
||||
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
|
||||
getQueueSnapshot: () => ({
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const qqbot = getWrittenQQBotConfig(writes[0]);
|
||||
expect(result).toBe("handled");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(qqbot?.streaming).toBe(true);
|
||||
expect(vi.mocked(sendText).mock.calls.at(0)?.[1]).toContain("已开启");
|
||||
});
|
||||
|
||||
it("keeps group /stop urgent when command level is strict", async () => {
|
||||
const account = createAccount();
|
||||
authorizeGroupCommands(account);
|
||||
account.config.groups = {
|
||||
GROUP_OPENID: { commandLevel: "strict" },
|
||||
};
|
||||
|
||||
const result = await trySlashCommand(createGroupStopMessage(), {
|
||||
account,
|
||||
cfg: {},
|
||||
getMessagePeerId: () => "group:GROUP_OPENID",
|
||||
getQueueSnapshot: () => ({
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("urgent");
|
||||
});
|
||||
|
||||
it("keeps group /stop urgent outside strict command level", async () => {
|
||||
const account = createAccount();
|
||||
authorizeGroupCommands(account);
|
||||
|
||||
const result = await trySlashCommand(createGroupStopMessage(), {
|
||||
account,
|
||||
cfg: {},
|
||||
getMessagePeerId: () => "group:GROUP_OPENID",
|
||||
getQueueSnapshot: () => ({
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("urgent");
|
||||
});
|
||||
|
||||
it("does not let unauthorized group /stop bypass the queue", async () => {
|
||||
const result = await trySlashCommand(createGroupStopMessage(), {
|
||||
account: createAccount(),
|
||||
cfg: {},
|
||||
getMessagePeerId: () => "group:GROUP_OPENID",
|
||||
getQueueSnapshot: () => ({
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("enqueue");
|
||||
});
|
||||
|
||||
it("keeps open DM /stop urgent", async () => {
|
||||
const result = await trySlashCommand(createDmStopMessage(), {
|
||||
account: createAccount(),
|
||||
cfg: {},
|
||||
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
|
||||
getQueueSnapshot: () => ({
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toBe("urgent");
|
||||
});
|
||||
});
|
||||
180
extensions/qqbot/src/engine/commands/slash-command-handler.ts
Normal file
180
extensions/qqbot/src/engine/commands/slash-command-handler.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Slash command handler — intercept slash commands before message queue.
|
||||
*
|
||||
* Extracted from gateway.ts to keep the gateway connection logic thin.
|
||||
* Handles urgent commands, normal slash commands, and file delivery.
|
||||
*/
|
||||
|
||||
import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js";
|
||||
import type { QueuedMessage } from "../gateway/message-queue.js";
|
||||
import type { GatewayAccount, EngineLogger } from "../gateway/types.js";
|
||||
import { sendDocument } from "../messaging/outbound.js";
|
||||
import {
|
||||
sendText as senderSendText,
|
||||
buildDeliveryTarget,
|
||||
accountToCreds,
|
||||
} from "../messaging/sender.js";
|
||||
import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js";
|
||||
import { matchSlashCommand } from "./slash-commands-impl.js";
|
||||
import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface SlashCommandHandlerContext {
|
||||
account: GatewayAccount;
|
||||
cfg?: unknown;
|
||||
log?: EngineLogger;
|
||||
getMessagePeerId: (msg: QueuedMessage) => string;
|
||||
getQueueSnapshot: (peerId: string) => QueueSnapshot;
|
||||
resolveCommandAuthorized?: (params: {
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
commandsAllowFrom?: Array<string | number>;
|
||||
}) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
// ============ Constants ============
|
||||
|
||||
const URGENT_COMMANDS = ["/stop"];
|
||||
|
||||
// ============ trySlashCommandOrEnqueue ============
|
||||
|
||||
/**
|
||||
* Check if the message is a slash command and handle it.
|
||||
*
|
||||
* @returns `true` if handled (command executed or enqueued as urgent),
|
||||
* `false` if the message should be queued for normal processing.
|
||||
*/
|
||||
export async function trySlashCommand(
|
||||
msg: QueuedMessage,
|
||||
ctx: SlashCommandHandlerContext,
|
||||
): Promise<"handled" | "urgent" | "enqueue"> {
|
||||
const { account, log } = ctx;
|
||||
const content = (msg.content ?? "").trim();
|
||||
|
||||
if (!content.startsWith("/")) {
|
||||
return "enqueue";
|
||||
}
|
||||
|
||||
const isGroup = msg.type === "group" || msg.type === "guild";
|
||||
const groupCommandLevel = isGroup
|
||||
? resolveGroupCommandLevelFromAccountConfig(
|
||||
account.config,
|
||||
msg.groupOpenid ?? msg.channelId ?? null,
|
||||
)
|
||||
: undefined;
|
||||
const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg);
|
||||
const commandAuthorized = ctx.resolveCommandAuthorized
|
||||
? await ctx.resolveCommandAuthorized({
|
||||
isGroup,
|
||||
senderId: msg.senderId,
|
||||
conversationId: msg.groupOpenid ?? msg.channelId ?? msg.senderId,
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
commandsAllowFrom,
|
||||
})
|
||||
: resolveSlashCommandAuth({
|
||||
senderId: msg.senderId,
|
||||
isGroup,
|
||||
allowFrom: account.config?.allowFrom,
|
||||
groupAllowFrom: account.config?.groupAllowFrom,
|
||||
commandsAllowFrom,
|
||||
});
|
||||
|
||||
// Urgent command detection — bypass queue and execute immediately.
|
||||
const contentLower = content.toLowerCase();
|
||||
const isUrgentCommand = URGENT_COMMANDS.some(
|
||||
(cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
|
||||
);
|
||||
if (isUrgentCommand) {
|
||||
if (isGroup && !commandAuthorized) {
|
||||
return "enqueue";
|
||||
}
|
||||
log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
|
||||
return "urgent";
|
||||
}
|
||||
|
||||
// Normal slash command — try to match and execute.
|
||||
const receivedAt = Date.now();
|
||||
const peerId = ctx.getMessagePeerId(msg);
|
||||
const cmdCtx: SlashCommandContext = {
|
||||
type: msg.type,
|
||||
senderId: msg.senderId,
|
||||
senderName: msg.senderName,
|
||||
messageId: msg.messageId,
|
||||
eventTimestamp: msg.timestamp,
|
||||
receivedAt,
|
||||
rawContent: content,
|
||||
args: "",
|
||||
channelId: msg.channelId,
|
||||
groupOpenid: msg.groupOpenid,
|
||||
accountId: account.accountId,
|
||||
appId: account.appId,
|
||||
accountConfig: account.config,
|
||||
commandAuthorized,
|
||||
groupCommandLevel,
|
||||
queueSnapshot: ctx.getQueueSnapshot(peerId),
|
||||
};
|
||||
|
||||
try {
|
||||
const reply = await matchSlashCommand(cmdCtx);
|
||||
if (reply === null) {
|
||||
return "enqueue";
|
||||
}
|
||||
|
||||
log?.debug?.(`Slash command matched: ${content}`);
|
||||
|
||||
const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
|
||||
const replyText = isFileResult ? (reply as { text: string }).text : reply;
|
||||
const replyFile = isFileResult ? (reply as { filePath: string }).filePath : null;
|
||||
|
||||
// Send text reply.
|
||||
if (msg.type === "c2c" || msg.type === "group" || msg.type === "dm" || msg.type === "guild") {
|
||||
const slashTarget = buildDeliveryTarget(msg);
|
||||
const slashCreds = accountToCreds(account);
|
||||
await senderSendText(slashTarget, replyText, slashCreds, { msgId: msg.messageId });
|
||||
}
|
||||
|
||||
// Send file attachment if present.
|
||||
if (replyFile) {
|
||||
try {
|
||||
const targetType =
|
||||
msg.type === "group"
|
||||
? "group"
|
||||
: msg.type === "dm"
|
||||
? "dm"
|
||||
: msg.type === "c2c"
|
||||
? "c2c"
|
||||
: "channel";
|
||||
const targetId =
|
||||
msg.type === "group"
|
||||
? msg.groupOpenid || msg.senderId
|
||||
: msg.type === "dm"
|
||||
? msg.guildId || msg.senderId
|
||||
: msg.type === "c2c"
|
||||
? msg.senderId
|
||||
: msg.channelId || msg.senderId;
|
||||
await sendDocument(
|
||||
{
|
||||
targetType,
|
||||
targetId,
|
||||
account,
|
||||
replyToId: msg.messageId,
|
||||
},
|
||||
replyFile,
|
||||
{ allowQQBotDataDownloads: true },
|
||||
);
|
||||
} catch (fileErr) {
|
||||
log?.error(`Failed to send slash command file: ${String(fileErr)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return "handled";
|
||||
} catch (err) {
|
||||
log?.error(`Slash command error: ${String(err)}`);
|
||||
return "enqueue";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Qqbot plugin module implements slash command test support behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { CommandsPort } from "../adapter/commands.port.js";
|
||||
import { initCommands } from "./slash-commands-impl.js";
|
||||
|
||||
type RuntimeConfigApi = ReturnType<NonNullable<CommandsPort["approveRuntimeGetter"]>>["config"];
|
||||
type ReplaceConfigFile = RuntimeConfigApi["replaceConfigFile"];
|
||||
type ReplaceConfigFileResult = Awaited<ReturnType<ReplaceConfigFile>>;
|
||||
|
||||
export type WrittenQQBotConfig = {
|
||||
streaming?: unknown;
|
||||
accounts?: { default?: { streaming?: unknown } };
|
||||
};
|
||||
|
||||
export function installCommandRuntime(
|
||||
currentConfig: OpenClawConfig,
|
||||
writes: OpenClawConfig[],
|
||||
): void {
|
||||
const replaceConfigFile: ReplaceConfigFile = async (params) => {
|
||||
writes.push(params.nextConfig);
|
||||
return undefined as unknown as ReplaceConfigFileResult;
|
||||
};
|
||||
|
||||
initCommands({
|
||||
resolveVersion: () => "test",
|
||||
pluginVersion: "0.0.0-test",
|
||||
approveRuntimeGetter: () => ({
|
||||
config: {
|
||||
current: () => currentConfig,
|
||||
replaceConfigFile,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function getWrittenQQBotConfig(
|
||||
write: OpenClawConfig | undefined,
|
||||
): WrittenQQBotConfig | undefined {
|
||||
return write?.channels?.qqbot as WrittenQQBotConfig | undefined;
|
||||
}
|
||||
277
extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts
Normal file
277
extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
// Qqbot tests cover slash commands impl plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js";
|
||||
import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js";
|
||||
import { getFrameworkCommands, matchSlashCommand } from "./slash-commands-impl.js";
|
||||
import { SlashCommandRegistry, type SlashCommandContext } from "./slash-commands.js";
|
||||
|
||||
function createStreamingContext(overrides: Partial<SlashCommandContext> = {}): SlashCommandContext {
|
||||
return {
|
||||
type: "c2c",
|
||||
senderId: "UNTRUSTED_OPENID",
|
||||
messageId: "msg-1",
|
||||
eventTimestamp: "2026-01-01T00:00:00.000Z",
|
||||
receivedAt: 1,
|
||||
rawContent: "/bot-streaming on",
|
||||
args: "",
|
||||
accountId: "default",
|
||||
appId: "app",
|
||||
accountConfig: { allowFrom: ["*"], streaming: false },
|
||||
commandAuthorized: false,
|
||||
queueSnapshot: {
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 1,
|
||||
senderPending: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("QQBot framework slash commands", () => {
|
||||
it("exposes private-only admin commands with private-chat metadata", () => {
|
||||
const commands = getFrameworkCommands();
|
||||
const names = commands.map((command) => command.name);
|
||||
|
||||
expect(names).toContain("bot-approve");
|
||||
expect(names).toContain("bot-clear-storage");
|
||||
expect(names).toContain("bot-logs");
|
||||
expect(names).toContain("bot-streaming");
|
||||
for (const commandName of ["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]) {
|
||||
const command = commands.find((entry) => entry.name === commandName);
|
||||
expect(command?.c2cOnly).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves private-only auth metadata for framework registration", () => {
|
||||
const registry = new SlashCommandRegistry();
|
||||
registry.register({
|
||||
name: "private-admin",
|
||||
description: "private admin command",
|
||||
requireAuth: true,
|
||||
c2cOnly: true,
|
||||
handler: () => "ok",
|
||||
});
|
||||
registry.register({
|
||||
name: "shared-admin",
|
||||
description: "shared admin command",
|
||||
requireAuth: true,
|
||||
handler: () => "ok",
|
||||
});
|
||||
|
||||
const commands = registry.getFrameworkCommands();
|
||||
|
||||
expect(commands.map((command) => command.name)).toEqual(["private-admin", "shared-admin"]);
|
||||
const privateAdmin = commands.find((command) => command.name === "private-admin");
|
||||
const sharedAdmin = commands.find((command) => command.name === "shared-admin");
|
||||
expect(privateAdmin?.c2cOnly).toBe(true);
|
||||
expect(sharedAdmin?.c2cOnly).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes bot-streaming through the auth-gated framework registry", () => {
|
||||
expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-streaming");
|
||||
});
|
||||
|
||||
it("rejects private-only plugin commands in groups with the shared private-chat message", async () => {
|
||||
const result = await matchSlashCommand(
|
||||
createStreamingContext({
|
||||
type: "group",
|
||||
rawContent: "/bot-me",
|
||||
groupOpenid: "group-1",
|
||||
commandAuthorized: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
|
||||
});
|
||||
|
||||
it("keeps private-only plugin commands private even when command level is all", async () => {
|
||||
const result = await matchSlashCommand(
|
||||
createStreamingContext({
|
||||
type: "group",
|
||||
rawContent: "/bot-me",
|
||||
groupOpenid: "group-1",
|
||||
commandAuthorized: true,
|
||||
groupCommandLevel: "all",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
|
||||
});
|
||||
|
||||
it("rejects plugin commands in groups when command level is strict", async () => {
|
||||
const result = await matchSlashCommand(
|
||||
createStreamingContext({
|
||||
type: "group",
|
||||
rawContent: "/bot-ping",
|
||||
groupOpenid: "group-1",
|
||||
commandAuthorized: true,
|
||||
groupCommandLevel: "strict",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
|
||||
});
|
||||
|
||||
it("keeps requireAuth commands gated in default all group mode", async () => {
|
||||
const registry = new SlashCommandRegistry();
|
||||
registry.register({
|
||||
name: "shared-admin",
|
||||
description: "shared admin command",
|
||||
requireAuth: true,
|
||||
handler: () => "ok",
|
||||
});
|
||||
|
||||
const result = await registry.matchSlashCommand(
|
||||
createStreamingContext({
|
||||
type: "group",
|
||||
rawContent: "/shared-admin",
|
||||
groupOpenid: "group-1",
|
||||
commandAuthorized: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toContain("权限不足");
|
||||
});
|
||||
|
||||
it("does not write streaming config when the sender is not command-authorized", async () => {
|
||||
const writes: OpenClawConfig[] = [];
|
||||
installCommandRuntime(
|
||||
{
|
||||
channels: {
|
||||
qqbot: {
|
||||
allowFrom: ["*"],
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
writes,
|
||||
);
|
||||
|
||||
const result = await matchSlashCommand(createStreamingContext());
|
||||
|
||||
expect(result).toContain("权限不足");
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not write streaming config when allowFrom mixes wildcard with another sender", async () => {
|
||||
const writes: OpenClawConfig[] = [];
|
||||
const allowFrom = ["*", "TRUSTED_OPENID"];
|
||||
installCommandRuntime(
|
||||
{
|
||||
channels: {
|
||||
qqbot: {
|
||||
allowFrom,
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
writes,
|
||||
);
|
||||
|
||||
const commandAuthorized = resolveSlashCommandAuth({
|
||||
senderId: "UNTRUSTED_OPENID",
|
||||
isGroup: false,
|
||||
allowFrom,
|
||||
});
|
||||
const result = await matchSlashCommand(
|
||||
createStreamingContext({
|
||||
accountConfig: { allowFrom, streaming: false },
|
||||
commandAuthorized,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(commandAuthorized).toBe(false);
|
||||
expect(result).toContain("权限不足");
|
||||
expect(writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("writes streaming config when commands.allowFrom grants the sender in open DM configs", async () => {
|
||||
const writes: OpenClawConfig[] = [];
|
||||
installCommandRuntime(
|
||||
{
|
||||
commands: {
|
||||
allowFrom: {
|
||||
qqbot: ["TRUSTED_OPENID"],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
qqbot: {
|
||||
allowFrom: ["*"],
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
writes,
|
||||
);
|
||||
|
||||
const commandAuthorized = resolveSlashCommandAuth({
|
||||
senderId: "TRUSTED_OPENID",
|
||||
isGroup: false,
|
||||
allowFrom: ["*"],
|
||||
commandsAllowFrom: resolveQQBotCommandsAllowFrom({
|
||||
commands: {
|
||||
allowFrom: {
|
||||
qqbot: ["TRUSTED_OPENID"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await matchSlashCommand(
|
||||
createStreamingContext({
|
||||
senderId: "TRUSTED_OPENID",
|
||||
accountConfig: { allowFrom: ["*"], streaming: false },
|
||||
commandAuthorized,
|
||||
}),
|
||||
);
|
||||
|
||||
const qqbot = getWrittenQQBotConfig(writes[0]);
|
||||
expect(commandAuthorized).toBe(true);
|
||||
expect(result).toContain("已开启");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(qqbot?.streaming).toBe(true);
|
||||
});
|
||||
|
||||
it("writes streaming config when the sender is command-authorized", async () => {
|
||||
const writes: OpenClawConfig[] = [];
|
||||
const allowFrom = ["*", "TRUSTED_OPENID"];
|
||||
installCommandRuntime(
|
||||
{
|
||||
channels: {
|
||||
qqbot: {
|
||||
allowFrom,
|
||||
streaming: false,
|
||||
accounts: {
|
||||
default: {
|
||||
allowFrom,
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writes,
|
||||
);
|
||||
|
||||
const commandAuthorized = resolveSlashCommandAuth({
|
||||
senderId: "TRUSTED_OPENID",
|
||||
isGroup: false,
|
||||
allowFrom,
|
||||
});
|
||||
const result = await matchSlashCommand(
|
||||
createStreamingContext({
|
||||
senderId: "TRUSTED_OPENID",
|
||||
accountConfig: { allowFrom, streaming: false },
|
||||
commandAuthorized,
|
||||
}),
|
||||
);
|
||||
|
||||
const qqbot = getWrittenQQBotConfig(writes[0]);
|
||||
expect(commandAuthorized).toBe(true);
|
||||
expect(result).toContain("已开启");
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(qqbot?.streaming).toBe(true);
|
||||
expect(qqbot?.accounts?.default?.streaming).toBe(true);
|
||||
});
|
||||
});
|
||||
61
extensions/qqbot/src/engine/commands/slash-commands-impl.ts
Normal file
61
extensions/qqbot/src/engine/commands/slash-commands-impl.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* QQBot plugin-level slash command handler.
|
||||
*
|
||||
* Type definitions and the command registry/dispatcher are in
|
||||
* `./slash-commands.ts`. Built-in command bodies live under `./builtin/`.
|
||||
*/
|
||||
|
||||
import type { CommandsPort } from "../adapter/commands.port.js";
|
||||
import { debugLog } from "../utils/log.js";
|
||||
import { registerBuiltinSlashCommands } from "./builtin/register-all.js";
|
||||
import {
|
||||
getFrameworkVersionString,
|
||||
getPluginVersionString,
|
||||
initSlashCommandDeps,
|
||||
} from "./builtin/state.js";
|
||||
import {
|
||||
SlashCommandRegistry,
|
||||
type SlashCommandContext,
|
||||
type SlashCommandResult,
|
||||
type QQBotFrameworkCommand,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
const registry = new SlashCommandRegistry();
|
||||
registerBuiltinSlashCommands(registry);
|
||||
|
||||
/**
|
||||
* Initialize command dependencies from the EngineAdapters.commands port.
|
||||
* Called once by the bridge layer during startup.
|
||||
*/
|
||||
export function initCommands(port: CommandsPort): void {
|
||||
initSlashCommandDeps(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return commands that may be registered with the framework via
|
||||
* api.registerCommand() in registerFull().
|
||||
*/
|
||||
export function getFrameworkCommands(): QQBotFrameworkCommand[] {
|
||||
return registry.getFrameworkCommands();
|
||||
}
|
||||
|
||||
// Slash command entry point — delegates to core/ registry.
|
||||
|
||||
/**
|
||||
* Try to match and execute a plugin-level slash command.
|
||||
*
|
||||
* @returns A reply when matched, or null when the message should continue through normal routing.
|
||||
*/
|
||||
export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
|
||||
return registry.matchSlashCommand(ctx, { info: debugLog });
|
||||
}
|
||||
|
||||
/** Return the plugin version for external callers. */
|
||||
export function getPluginVersion(): string {
|
||||
return getPluginVersionString();
|
||||
}
|
||||
|
||||
/** Return the framework version for external callers. */
|
||||
export function getFrameworkVersion(): string {
|
||||
return getFrameworkVersionString();
|
||||
}
|
||||
207
extensions/qqbot/src/engine/commands/slash-commands.ts
Normal file
207
extensions/qqbot/src/engine/commands/slash-commands.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Slash command registration and dispatch framework.
|
||||
*
|
||||
* This module provides the type definitions, command registry, and
|
||||
* `matchSlashCommand` dispatcher that both plugin versions share.
|
||||
*
|
||||
* Concrete command implementations (e.g. `/bot-ping`, `/bot-logs`) are
|
||||
* registered by the upper-layer bootstrap code, NOT defined here.
|
||||
*
|
||||
* Zero external dependencies.
|
||||
*/
|
||||
|
||||
import type { QQBotGroupCommandLevel } from "../config/group.js";
|
||||
import { PRIVATE_CHAT_ONLY_TEXT } from "./command-visibility.js";
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
/** Slash command context (message metadata plus runtime state). */
|
||||
export interface SlashCommandContext {
|
||||
/** Message type. */
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
/** Sender ID. */
|
||||
senderId: string;
|
||||
/** Sender display name. */
|
||||
senderName?: string;
|
||||
/** Message ID used for passive replies. */
|
||||
messageId: string;
|
||||
/** Event timestamp from QQ as an ISO string. */
|
||||
eventTimestamp: string;
|
||||
/** Local receipt timestamp in milliseconds. */
|
||||
receivedAt: number;
|
||||
/** Raw message content. */
|
||||
rawContent: string;
|
||||
/** Command arguments after stripping the command name. */
|
||||
args: string;
|
||||
/** Channel ID for guild messages. */
|
||||
channelId?: string;
|
||||
/** Group openid for group messages. */
|
||||
groupOpenid?: string;
|
||||
/** Account ID. */
|
||||
accountId: string;
|
||||
/** Bot App ID. */
|
||||
appId: string;
|
||||
/** Account config available to the command handler. */
|
||||
accountConfig?: Record<string, unknown>;
|
||||
/** Whether the sender is authorized per the allowFrom config. */
|
||||
commandAuthorized: boolean;
|
||||
/** Effective per-group command level for group invocations. */
|
||||
groupCommandLevel?: QQBotGroupCommandLevel;
|
||||
/** Queue snapshot for the current sender. */
|
||||
queueSnapshot: QueueSnapshot;
|
||||
}
|
||||
|
||||
/** Queue status snapshot. */
|
||||
export interface QueueSnapshot {
|
||||
totalPending: number;
|
||||
activeUsers: number;
|
||||
maxConcurrentUsers: number;
|
||||
senderPending: number;
|
||||
}
|
||||
|
||||
/** Slash command result: text, a text+file result, or null to skip handling. */
|
||||
export type SlashCommandResult = string | SlashCommandFileResult | null;
|
||||
|
||||
/** Slash command result that sends text first and then a local file. */
|
||||
interface SlashCommandFileResult {
|
||||
text: string;
|
||||
/** Local file path to send. */
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/** Slash command definition. */
|
||||
interface SlashCommand {
|
||||
/** Command name without the leading slash. */
|
||||
name: string;
|
||||
/** Short description. */
|
||||
description: string;
|
||||
/** Detailed usage text shown by `/command ?`. */
|
||||
usage?: string;
|
||||
/** When true, the command requires the sender to pass the allowFrom authorization check. */
|
||||
requireAuth?: boolean;
|
||||
/** When true, the command is only available in c2c (private) chat. Group invocations are rejected automatically. */
|
||||
c2cOnly?: boolean;
|
||||
/** Command handler. */
|
||||
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
|
||||
}
|
||||
|
||||
/** Framework command definition for commands that require authorization. */
|
||||
export interface QQBotFrameworkCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
usage?: string;
|
||||
c2cOnly?: boolean;
|
||||
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
|
||||
}
|
||||
|
||||
// ============ Command Registry ============
|
||||
|
||||
/** Lowercase and trim a string. */
|
||||
function lc(s: string): string {
|
||||
return (s ?? "").toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Slash command registry.
|
||||
*
|
||||
* Maintains two maps:
|
||||
* - `commands` — QQBot message-flow commands
|
||||
* - `frameworkCommands` — auth-gated commands that are safe on the framework surface
|
||||
*/
|
||||
export class SlashCommandRegistry {
|
||||
private readonly commands = new Map<string, SlashCommand>();
|
||||
private readonly frameworkCommands = new Map<string, SlashCommand>();
|
||||
|
||||
/** Register one command. */
|
||||
register(cmd: SlashCommand): void {
|
||||
const key = lc(cmd.name);
|
||||
// Always register in the pre-dispatch map so QQ message-flow slash
|
||||
// commands can match and execute directly (with requireAuth gating).
|
||||
this.commands.set(key, cmd);
|
||||
// Auth-gated commands are exposed to the framework command surface.
|
||||
// Private-chat-only metadata is preserved so the bridge can enforce the
|
||||
// same routing restriction before dispatching handlers.
|
||||
if (cmd.requireAuth) {
|
||||
this.frameworkCommands.set(key, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/** Return all commands that may be registered on the framework surface. */
|
||||
getFrameworkCommands(): QQBotFrameworkCommand[] {
|
||||
return Array.from(this.frameworkCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
usage: cmd.usage,
|
||||
c2cOnly: cmd.c2cOnly,
|
||||
handler: cmd.handler,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Return all registered commands (both maps) for help listing. */
|
||||
getAllCommands(): Map<string, SlashCommand> {
|
||||
const all = new Map<string, SlashCommand>();
|
||||
for (const [k, v] of this.commands) {
|
||||
all.set(k, v);
|
||||
}
|
||||
for (const [k, v] of this.frameworkCommands) {
|
||||
all.set(k, v);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match and execute a pre-dispatch slash command.
|
||||
*
|
||||
* @returns A reply when matched, or null when the message should continue
|
||||
* through normal routing.
|
||||
*/
|
||||
async matchSlashCommand(
|
||||
ctx: SlashCommandContext,
|
||||
log?: { info?: (msg: string) => void },
|
||||
): Promise<SlashCommandResult> {
|
||||
const content = ctx.rawContent.trim();
|
||||
if (!content.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spaceIdx = content.indexOf(" ");
|
||||
const cmdName = lc(spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx));
|
||||
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
|
||||
|
||||
const cmd = this.commands.get(cmdName);
|
||||
if (!cmd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isGroup = ctx.type === "group" || ctx.type === "guild";
|
||||
const groupCommandLevel = ctx.groupCommandLevel ?? "all";
|
||||
if (isGroup && groupCommandLevel === "strict") {
|
||||
return PRIVATE_CHAT_ONLY_TEXT;
|
||||
}
|
||||
|
||||
// Reject c2cOnly commands when invoked outside private chat.
|
||||
if (cmd.c2cOnly && ctx.type !== "c2c") {
|
||||
return PRIVATE_CHAT_ONLY_TEXT;
|
||||
}
|
||||
|
||||
// Gate sensitive commands behind the allowFrom authorization check.
|
||||
if (cmd.requireAuth && !ctx.commandAuthorized) {
|
||||
log?.info?.(
|
||||
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
|
||||
);
|
||||
const configHint = isGroup ? "groupAllowFrom" : "allowFrom";
|
||||
return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}。`;
|
||||
}
|
||||
|
||||
// `/command ?` returns usage help.
|
||||
if (args === "?") {
|
||||
if (cmd.usage) {
|
||||
return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
|
||||
}
|
||||
return `/${cmd.name} - ${cmd.description}`;
|
||||
}
|
||||
|
||||
ctx.args = args;
|
||||
return await cmd.handler(ctx);
|
||||
}
|
||||
}
|
||||
163
extensions/qqbot/src/engine/config/credential-backup.test.ts
Normal file
163
extensions/qqbot/src/engine/config/credential-backup.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// Qqbot tests cover credential backup plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installQQBotRuntimeForStateTests,
|
||||
resetQQBotStateTestRuntime,
|
||||
} from "../../test-support/runtime.js";
|
||||
|
||||
type CredentialBackup = {
|
||||
accountId: string;
|
||||
appId: string;
|
||||
clientSecret: string;
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
function createTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
createdDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function useMockHome(homeDir: string): Promise<void> {
|
||||
vi.doMock("node:os", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:os")>();
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual, homedir: () => homeDir },
|
||||
homedir: () => homeDir,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useStateDir(stateDir: string): void {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
installQQBotRuntimeForStateTests(stateDir);
|
||||
}
|
||||
|
||||
function writeJson(filePath: string, value: unknown): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function legacyCredentialBackupFile(accountId: string): string {
|
||||
return path.join(
|
||||
process.env.OPENCLAW_STATE_DIR!,
|
||||
"qqbot",
|
||||
"data",
|
||||
`credential-backup-${accountId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function legacySingleCredentialBackupFile(): string {
|
||||
return path.join(process.env.OPENCLAW_STATE_DIR!, "qqbot", "data", "credential-backup.json");
|
||||
}
|
||||
|
||||
function readCredentialRows(stateDir: string): CredentialBackup[] {
|
||||
const store = createPluginStateSyncKeyedStoreForTests<CredentialBackup>("qqbot", {
|
||||
namespace: "credential-backups",
|
||||
maxEntries: 1000,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
|
||||
});
|
||||
return store.entries().map((entry) => entry.value);
|
||||
}
|
||||
|
||||
describe("engine/config/credential-backup", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const stateDir = createTempDir("qqbot-state-");
|
||||
const homeDir = createTempDir("qqbot-home-");
|
||||
vi.stubEnv("HOME", homeDir);
|
||||
await useMockHome(homeDir);
|
||||
useStateDir(stateDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetQQBotStateTestRuntime();
|
||||
resetPluginStateStoreForTests();
|
||||
vi.doUnmock("node:os");
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of createdDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips a credential snapshot through SQLite without writing JSON", async () => {
|
||||
const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js");
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR!;
|
||||
|
||||
saveCredentialBackup("default", "app-1", "secret-1");
|
||||
|
||||
const loaded = loadCredentialBackup("default");
|
||||
expect(loaded).toMatchObject({
|
||||
accountId: "default",
|
||||
appId: "app-1",
|
||||
clientSecret: "secret-1",
|
||||
});
|
||||
expect(fs.existsSync(legacyCredentialBackupFile("default"))).toBe(false);
|
||||
expect(readCredentialRows(stateDir)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps same account IDs isolated across state directories", async () => {
|
||||
const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js");
|
||||
const stateDirA = process.env.OPENCLAW_STATE_DIR!;
|
||||
saveCredentialBackup("default", "app-a", "secret-a");
|
||||
|
||||
const stateDirB = createTempDir("qqbot-state-b-");
|
||||
useStateDir(stateDirB);
|
||||
expect(loadCredentialBackup("default")).toBeNull();
|
||||
saveCredentialBackup("default", "app-b", "secret-b");
|
||||
|
||||
useStateDir(stateDirA);
|
||||
expect(loadCredentialBackup("default")?.appId).toBe("app-a");
|
||||
|
||||
useStateDir(stateDirB);
|
||||
expect(loadCredentialBackup("default")?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
it("does not import state-dir legacy JSON backups during runtime reads", async () => {
|
||||
const { loadCredentialBackup } = await import("./credential-backup.js");
|
||||
const legacyFile = legacyCredentialBackupFile("default");
|
||||
writeJson(legacyFile, {
|
||||
accountId: "default",
|
||||
appId: "app-old",
|
||||
clientSecret: "secret-old",
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(loadCredentialBackup("default")).toBeNull();
|
||||
expect(fs.existsSync(legacyFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not import legacy single-file backups during runtime reads", async () => {
|
||||
const { loadCredentialBackup } = await import("./credential-backup.js");
|
||||
const legacyFile = legacySingleCredentialBackupFile();
|
||||
writeJson(legacyFile, {
|
||||
accountId: "other-acct",
|
||||
appId: "app-old",
|
||||
clientSecret: "secret-old",
|
||||
savedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(loadCredentialBackup("default")).toBeNull();
|
||||
expect(fs.existsSync(legacyFile)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores empty appId/clientSecret on save", async () => {
|
||||
const { loadCredentialBackup, saveCredentialBackup } = await import("./credential-backup.js");
|
||||
saveCredentialBackup("default", "", "secret");
|
||||
saveCredentialBackup("default", "app", "");
|
||||
|
||||
expect(loadCredentialBackup("default")).toBeNull();
|
||||
expect(readCredentialRows(process.env.OPENCLAW_STATE_DIR!)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
88
extensions/qqbot/src/engine/config/credential-backup.ts
Normal file
88
extensions/qqbot/src/engine/config/credential-backup.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Credential backup & recovery.
|
||||
* 凭证暂存与恢复。
|
||||
*
|
||||
* Solves the "hot-upgrade interrupted, appId/secret vanished from
|
||||
* openclaw.json" failure mode.
|
||||
*
|
||||
* Mechanics:
|
||||
* - After each successful gateway start we snapshot the currently
|
||||
* resolved `appId` / `clientSecret` to a per-account SQLite KV entry.
|
||||
* - During plugin startup, if the live config has an empty appId or
|
||||
* secret, the gateway consults the backup and restores the values
|
||||
* via the config mutation API.
|
||||
* - Legacy JSON backups are imported by `openclaw doctor --fix`, not by
|
||||
* runtime startup.
|
||||
*
|
||||
* Safety notes:
|
||||
* - Only restore when credentials are **actually empty** — never
|
||||
* overwrite a user's intentional config change.
|
||||
* - Per-account key only; not keyed by appId because recovery happens
|
||||
* precisely when appId is unknown.
|
||||
*/
|
||||
|
||||
import { buildQQBotStateKey, openQQBotSyncKeyedStore } from "../utils/sqlite-state.js";
|
||||
|
||||
interface CredentialBackup {
|
||||
accountId: string;
|
||||
appId: string;
|
||||
clientSecret: string;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export const CREDENTIAL_BACKUPS_NAMESPACE = "credential-backups";
|
||||
export const MAX_CREDENTIAL_BACKUPS = 1000;
|
||||
|
||||
function createCredentialBackupStore() {
|
||||
return openQQBotSyncKeyedStore<CredentialBackup>({
|
||||
namespace: CREDENTIAL_BACKUPS_NAMESPACE,
|
||||
maxEntries: MAX_CREDENTIAL_BACKUPS,
|
||||
});
|
||||
}
|
||||
|
||||
export function credentialBackupKey(accountId: string): string {
|
||||
return buildQQBotStateKey("credential-backup", accountId);
|
||||
}
|
||||
|
||||
function isUsableBackup(data: CredentialBackup | null | undefined): data is CredentialBackup {
|
||||
return Boolean(data?.accountId && data.appId && data.clientSecret);
|
||||
}
|
||||
|
||||
/** Persist a credential snapshot (called once gateway reaches READY). */
|
||||
export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
|
||||
if (!appId || !clientSecret) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data: CredentialBackup = {
|
||||
accountId,
|
||||
appId,
|
||||
clientSecret,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
createCredentialBackupStore().register(credentialBackupKey(accountId), data);
|
||||
} catch {
|
||||
/* best-effort — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a credential snapshot for `accountId`.
|
||||
*
|
||||
* Reads SQLite only. Legacy JSON backup import is owned by doctor/setup
|
||||
* migration so runtime startup stays canonical-state-only.
|
||||
*/
|
||||
export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
|
||||
try {
|
||||
if (accountId) {
|
||||
const store = createCredentialBackupStore();
|
||||
const data = store.lookup(credentialBackupKey(accountId));
|
||||
if (isUsableBackup(data)) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* corrupt file — ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
76
extensions/qqbot/src/engine/config/credentials.ts
Normal file
76
extensions/qqbot/src/engine/config/credentials.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* QQBot credential management (pure logic layer).
|
||||
* QQBot 凭证管理(纯逻辑层)。
|
||||
*
|
||||
* Credential clearing and field-level cleanup for logout and setup
|
||||
* flows. All functions operate on plain objects (Record<string, unknown>)
|
||||
* and stay framework-agnostic.
|
||||
*/
|
||||
|
||||
import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./resolve.js";
|
||||
|
||||
// ---- Logout: clear all credential fields for an account ----
|
||||
|
||||
interface ClearCredentialsResult {
|
||||
nextCfg: Record<string, unknown>;
|
||||
cleared: boolean;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove clientSecret / clientSecretFile from a QQBot account config.
|
||||
*
|
||||
* Returns a shallow-cloned config with credentials removed, plus flags
|
||||
* indicating whether anything actually changed.
|
||||
*/
|
||||
export function clearAccountCredentials(
|
||||
cfg: Record<string, unknown>,
|
||||
accountId: string,
|
||||
): ClearCredentialsResult {
|
||||
const nextCfg = { ...cfg };
|
||||
const channels = asRecord(cfg.channels);
|
||||
const nextQQBot = channels?.qqbot ? { ...asRecord(channels.qqbot) } : undefined;
|
||||
let cleared = false;
|
||||
let changed = false;
|
||||
|
||||
if (nextQQBot) {
|
||||
const qqbot = nextQQBot as Record<string, unknown>;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (qqbot.clientSecret) {
|
||||
delete qqbot.clientSecret;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
if (qqbot.clientSecretFile) {
|
||||
delete qqbot.clientSecretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const accounts = qqbot.accounts as Record<string, Record<string, unknown>> | undefined;
|
||||
if (accounts && accountId in accounts) {
|
||||
const entry = accounts[accountId] as Record<string, unknown> | undefined;
|
||||
if (entry && "clientSecret" in entry) {
|
||||
delete entry.clientSecret;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
if (entry && "clientSecretFile" in entry) {
|
||||
delete entry.clientSecretFile;
|
||||
cleared = true;
|
||||
changed = true;
|
||||
}
|
||||
if (entry && Object.keys(entry).length === 0) {
|
||||
delete accounts[accountId];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed && nextQQBot) {
|
||||
nextCfg.channels = { ...channels, qqbot: nextQQBot };
|
||||
}
|
||||
|
||||
return { nextCfg, cleared, changed };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user