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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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
View 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";

View File

@@ -0,0 +1,2 @@
// Narrow bridge entrypoint for qqbot registerFull composition.
export { registerQQBotFull } from "./src/bridge/channel-entry.js";

View File

@@ -0,0 +1,2 @@
// Qqbot API module exposes the plugin public contract.
export { qqbotPlugin } from "./src/channel.js";

View 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
View 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
View 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"
}
}
}
}

View 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" }
}
}
}

View 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
}
}
}

View 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";

View File

@@ -0,0 +1,6 @@
// Qqbot API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View 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",
},
});

View 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";

View 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 | 否 | 请求体 JSONPOST/PUT/PATCH 使用) |
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
| `confirmed` | boolean | 否 | `DELETE` 必须传 `true`,表示用户已确认精确删除目标 |
| `bulkConfirmed` | boolean | 否 | 批量 `DELETE`(如删除全部公告)必须额外传 `true` |
> 基础 URL`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。
## 🛡️ 安全边界
- 只在用户明确要求管理 QQ 频道、子频道、公告、论坛帖子或日程时调用写入接口。
- `POST``PUT``PATCH``DELETE` 会修改真实 QQ 资源。调用前先复述目标频道/子频道/帖子/日程和预期改动;删除、批量删除、公告覆盖等不可逆或大范围操作必须等用户确认后再执行。
- 删除前优先用 `GET`/列表接口查出候选项,让用户选择具体 ID不要根据模糊名称猜测删除目标。
- `DELETE` 请求必须传 `confirmed: true`,否则工具会拒绝执行。`announces/all` 这样的批量操作还必须传 `bulkConfirmed: true`,只有在用户明确说要删除全部公告并再次确认后才可使用。
- 成员资料、头像 URL、频道图标等属于用户/群组资料。默认只总结必要字段;只有用户要求查看头像/图标或视觉比对时才内联展示图片,不要无关转发头像 URL。
---
## ⭐ 接口速查
### 频道Guild
| 操作 | 方法 | 路径 | 参数说明 |
| ----------------- | ----- | ----------------------------------- | ------------------------------------------ |
| 获取频道列表 | `GET` | `/users/@me/guilds` | query: `before`, `after`, `limit`(最大100) |
| 获取频道 API 权限 | `GET` | `/guilds/{guild_id}/api_permission` | — |
### 子频道Channel
| 操作 | 方法 | 路径 | 参数说明 |
| -------------- | ------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| 获取子频道列表 | `GET` | `/guilds/{guild_id}/channels` | — |
| 获取子频道详情 | `GET` | `/channels/{channel_id}` | — |
| 创建子频道 | `POST` | `/guilds/{guild_id}/channels` | body: `name`\*, `type`\*, `position`\*, `sub_type`, `parent_id`, `private_type`, `private_user_ids`, `speak_permission`, `application_id` |
| 修改子频道 | `PATCH` | `/channels/{channel_id}` | body: `name`, `position`, `parent_id`, `private_type`, `speak_permission`(至少一个) |
| 删除子频道 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
**子频道类型type**`0`=文字, `2`=语音, `4`=分组(position≥2), `10005`=直播, `10006`=应用, `10007`=论坛
### 成员Member
| 操作 | 方法 | 路径 | 参数说明 |
| ------------------ | ----- | -------------------------------------------- | --------------------------------------------- |
| 获取成员列表 | `GET` | `/guilds/{guild_id}/members` | query: `after`(首次填0), `limit`(1-400) |
| 获取成员详情 | `GET` | `/guilds/{guild_id}/members/{user_id}` | — |
| 获取身份组成员列表 | `GET` | `/guilds/{guild_id}/roles/{role_id}/members` | query: `start_index`(首次填0), `limit`(1-400) |
| 获取在线成员数 | `GET` | `/channels/{channel_id}/online_nums` | — |
### 公告Announces
| 操作 | 方法 | 路径 | 参数说明 |
| -------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------ |
| 创建公告 | `POST` | `/guilds/{guild_id}/announces` | body: `message_id`, `channel_id`, `announces_type`(0=成员,1=欢迎), `recommend_channels`(最多3条) |
| 删除公告 | — | 见受确认保护的删除流程 | 破坏性操作;批量删除需二次确认 |
### 论坛Forum— 仅私域机器人
| 操作 | 方法 | 路径 | 参数说明 |
| ------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
| 获取帖子列表 | `GET` | `/channels/{channel_id}/threads` | — |
| 获取帖子详情 | `GET` | `/channels/{channel_id}/threads/{thread_id}` | — |
| 发表帖子 | `PUT` | `/channels/{channel_id}/threads` | body: `title`\*, `content`\*, `format`(1=文本,2=HTML,3=Markdown,4=JSON默认3) |
| 删除帖子 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
| 发表评论 | `POST` | `/channels/{channel_id}/threads/{thread_id}/comment` | body: `thread_author`\*, `content`\*, `thread_create_time`, `image` |
### 日程Schedule
| 操作 | 方法 | 路径 | 参数说明 |
| -------- | ------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| 创建日程 | `POST` | `/channels/{channel_id}/schedules` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
| 修改日程 | `PATCH` | `/channels/{channel_id}/schedules/{schedule_id}` | body: `{ schedule: { name*, start_timestamp*, end_timestamp*, jump_channel_id, remind_type } }` |
| 删除日程 | — | 见受确认保护的删除流程 | 破坏性操作;不要在未确认时调用 |
**提醒类型remind_type**`"0"`=不提醒, `"1"`=开始时, `"2"`=5分钟前, `"3"`=15分钟前, `"4"`=30分钟前, `"5"`=60分钟前
> `*` 表示必填参数
---
## 💡 调用示例
### 获取频道列表
```json
{
"method": "GET",
"path": "/users/@me/guilds",
"query": { "limit": "100" }
}
```
### 获取子频道列表
```json
{
"method": "GET",
"path": "/guilds/123456/channels"
}
```
### 创建子频道
```json
{
"method": "POST",
"path": "/guilds/123456/channels",
"body": {
"name": "新频道",
"type": 0,
"position": 1,
"sub_type": 0
}
}
```
### 获取成员列表(分页)
```json
{
"method": "GET",
"path": "/guilds/123456/members",
"query": { "after": "0", "limit": "100" }
}
```
### 发表论坛帖子
```json
{
"method": "PUT",
"path": "/channels/789012/threads",
"body": {
"title": "公告标题",
"content": "# 标题\n\n公告内容",
"format": 3
}
}
```
### 创建日程
```json
{
"method": "POST",
"path": "/channels/456789/schedules",
"body": {
"schedule": {
"name": "周会",
"start_timestamp": "1770733800000",
"end_timestamp": "1770737400000",
"remind_type": "2"
}
}
}
```
### 创建推荐子频道公告
```json
{
"method": "POST",
"path": "/guilds/123456/announces",
"body": {
"announces_type": 0,
"recommend_channels": [{ "channel_id": "789012", "introduce": "欢迎来到攻略频道" }]
}
}
```
### 受确认保护的删除流程
删除类 QQ API 不作为普通速查示例暴露。若用户明确要求删除资源,先读取并复述目标对象,确认后再调用 `qqbot_channel_api``method` 设为 `"DELETE"``confirmed` 设为 `true``path` 使用已确认对象对应的资源路径。
| 删除对象 | 已确认后使用的 `path` | 额外要求 |
| -------- | ------------------------------------------------ | ---------------------------------------- |
| 子频道 | `/channels/{channel_id}` | 确认子频道 ID 和名称 |
| 单条公告 | `/guilds/{guild_id}/announces/{message_id}` | 确认公告 ID |
| 全部公告 | `/guilds/{guild_id}/announces/all` | 用户再次确认后再传 `bulkConfirmed: true` |
| 帖子 | `/channels/{channel_id}/threads/{thread_id}` | 确认帖子 ID、标题/作者 |
| 日程 | `/channels/{channel_id}/schedules/{schedule_id}` | 确认日程 ID、名称/时间 |
---
## 🔄 常用操作流程
### 获取频道和子频道信息
```
1. GET /users/@me/guilds → 获取频道列表,拿到 guild_id
2. GET /guilds/{guild_id}/channels → 获取子频道列表,拿到 channel_id
3. GET /channels/{channel_id} → 获取子频道详情
```
### 论坛发帖 + 评论
```
1. GET /guilds/{guild_id}/channels → 找到论坛子频道type=10007
2. PUT /channels/{channel_id}/threads → 发表帖子
3. GET /channels/{channel_id}/threads → 获取帖子列表
4. GET /channels/{channel_id}/threads/{thread_id} → 获取帖子详情(含 author_id
5. POST /channels/{channel_id}/threads/{thread_id}/comment → 发表评论
```
### 成员管理
```
1. GET /users/@me/guilds → 获取 guild_id
2. GET /guilds/{guild_id}/members?after=0&limit=100 → 获取成员列表
翻页:用上次最后一个 user.id 作为 after直到返回空数组
3. GET /guilds/{guild_id}/members/{user_id} → 获取指定成员详情
```
### 展示成员头像
成员详情返回的 `user.avatar` 是头像 URL。默认只展示昵称、ID、加入时间等必要字段当用户明确要求查看头像/图标或头像是当前任务的必要依据时,再用 Markdown 图片语法内联展示:
```
成员信息:
· 昵称:{nick}
· 头像:
![头像]({user.avatar})
```
不要无关输出原始头像 URL 或把头像作为普通链接转发。频道的 `icon` 字段同理:仅在用户明确需要查看时展示。
---
## 🚨 错误码处理
| 错误码 | 说明 | 解决方案 |
| ---------- | ---------------- | ------------------------------------------------------------------------------------- |
| **401** | Token 鉴权失败 | 检查 AppID 和 ClientSecret 配置 |
| **11241** | 频道 API 无权限 | 前往 QQ 开放平台申请权限,或调用 `GET /guilds/{guild_id}/api_permission` 查看可用权限 |
| **11242** | 仅私域机器人可用 | 需在 QQ 开放平台将机器人切换为私域模式 |
| **11243** | 需要管理频道权限 | 确保机器人拥有管理权限 |
| **11281** | 日程频率限制 | 单管理员/天限 10 次,单频道/天限 100 次 |
| **304023** | 推荐子频道超限 | 推荐子频道最多 3 条 |
---
## ⚠️ 注意事项
1. **路径中的占位符**(如 `{guild_id}``{channel_id}`)必须替换为实际值
2. **query 参数的值必须为字符串类型**,如 `{ "limit": "100" }` 而非 `{ "limit": 100 }`
3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重
4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替
5. **日程**的时间戳为毫秒级字符串
6. **删除操作不可逆**,必须先确认精确目标并传 `confirmed: true`;批量删除需二次确认并传 `bulkConfirmed: true`
7. **论坛操作**仅私域机器人可用
8. **子频道分组**type=4`position` 必须 >= 2
9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 属于资料信息;默认总结必要字段,只在用户明确需要查看图片时用 Markdown 图片语法 `![描述](URL)` 展示

View File

@@ -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; // 是否待审核
}
```
### APIPermissionAPI 权限)
```typescript
interface APIPermission {
path: string; // 接口路径
method: string; // 请求方法
desc: string; // 接口描述
auth_status: number; // 授权状态0=未授权, 1=已授权
}
```
### AnnouncesResult公告结果
```typescript
interface AnnouncesResult {
guild_id: string;
channel_id: string;
message_id: string;
announces_type: number;
recommend_channels: RecommendChannel[];
}
interface RecommendChannel {
channel_id: string; // 推荐的子频道 ID
introduce: string; // 推荐语
}
```
### ThreadDetail帖子详情
```typescript
interface ThreadDetail {
thread: {
guild_id: string;
channel_id: string;
author_id: string;
thread_info: {
thread_id: string;
title: string;
content: string;
date_time: string;
};
};
}
```
### ThreadListResult帖子列表
```typescript
interface ThreadListResult {
threads: Array<{
guild_id: string;
channel_id: string;
author_id: string;
thread_info: {
thread_id: string;
title: string;
content: string;
date_time: string;
};
}>;
is_finish: number; // 1=已到底, 0=还有更多
}
```
### Schedule日程
```typescript
interface Schedule {
id?: string;
name: string;
start_timestamp: string; // 毫秒级时间戳
end_timestamp: string;
jump_channel_id?: string;
remind_type?: string;
creator?: {
user: { id: string; username: string; bot: boolean };
nick: string;
joined_at: string;
};
}
```
---
## 📋 枚举值定义
### 子频道类型Channel type
| 值 | 名称 | 说明 |
| ------- | ---------- | -------------------------------- |
| `0` | 文字子频道 | 普通文字聊天 |
| `2` | 语音子频道 | 语音聊天 |
| `4` | 子频道分组 | 组织子频道的分组position ≥ 2 |
| `10005` | 直播子频道 | 直播功能 |
| `10006` | 应用子频道 | 需 application_id |
| `10007` | 论坛子频道 | 论坛功能 |
### 子频道子类型Channel sub_type
| 值 | 名称 |
| --- | ---- |
| `0` | 闲聊 |
| `1` | 公告 |
| `2` | 攻略 |
| `3` | 开黑 |
### 子频道私密类型Channel private_type
| 值 | 说明 |
| --- | -------------------- |
| `0` | 公开子频道 |
| `1` | 管理员和指定成员可见 |
| `2` | 仅管理员可见 |
### 子频道发言权限Channel speak_permission
| 值 | 说明 |
| --- | ------------------------------------------ |
| `0` | 无效(仅创建公告子频道时有效,此时为只读) |
| `1` | 所有人可发言 |
| `2` | 仅管理员和指定成员可发言 |
### 公告类型announces_type
| 值 | 说明 |
| --- | -------- |
| `0` | 成员公告 |
| `1` | 欢迎公告 |
### 帖子格式format
| 值 | 格式 |
| --- | -------------------- |
| `1` | 纯文本 |
| `2` | HTML |
| `3` | Markdown**默认** |
| `4` | JSONRichText |
### 日程提醒类型remind_type
| 值 | 说明 |
| ----- | -------------- |
| `"0"` | 不提醒 |
| `"1"` | 开始时提醒 |
| `"2"` | 开始前 5 分钟 |
| `"3"` | 开始前 15 分钟 |
| `"4"` | 开始前 30 分钟 |
| `"5"` | 开始前 60 分钟 |
### API 权限授权状态auth_status
| 值 | 说明 |
| --- | ------ |
| `0` | 未授权 |
| `1` | 已授权 |
---
## 📖 各接口详细说明
### GET /users/@me/guilds — 获取频道列表
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
| -------- | ------ | ---- | ---------------------------------------------------- |
| `before` | string | 否 | 读此 guild id 之前的数据 |
| `after` | string | 否 | 读此 guild id 之后的数据(与 before 同时设置时无效) |
| `limit` | string | 否 | 每次拉取条数,默认 100最大 100 |
**返回**: `Guild[]`
**调用示例**:
```json
{ "method": "GET", "path": "/users/@me/guilds", "query": { "limit": "100" } }
```
---
### GET /guilds/{guild_id}/api_permission — 获取频道 API 权限
**返回**: `{ apis: APIPermission[] }`
**调用示例**:
```json
{ "method": "GET", "path": "/guilds/123456/api_permission" }
```
---
### GET /guilds/{guild_id}/channels — 获取子频道列表
**返回**: `Channel[]`
**调用示例**:
```json
{ "method": "GET", "path": "/guilds/123456/channels" }
```
---
### GET /channels/{channel_id} — 获取子频道详情
**返回**: `Channel`
---
### POST /guilds/{guild_id}/channels — 创建子频道
> ⚠️ 仅私域机器人可用,需管理频道权限
**请求体**:
| 参数 | 类型 | 必填 | 说明 |
| ------------------ | -------- | ---- | ------------------------------------- |
| `name` | string | 是 | 子频道名称 |
| `type` | number | 是 | 子频道类型 |
| `position` | number | 是 | 排序位置type=4 时 ≥ 2 |
| `sub_type` | number | 否 | 子类型 |
| `parent_id` | string | 否 | 所属分组 ID |
| `private_type` | number | 否 | 私密类型 |
| `private_user_ids` | string[] | 否 | 私密成员列表private_type=1 时有效) |
| `speak_permission` | number | 否 | 发言权限 |
| `application_id` | string | 否 | 应用 AppIDtype=10006 时需要) |
**返回**: `Channel`
---
### PATCH /channels/{channel_id} — 修改子频道
> ⚠️ 仅私域机器人可用
**请求体**(至少一个):
| 参数 | 类型 | 说明 |
| ------------------ | ------ | -------- |
| `name` | string | 名称 |
| `position` | number | 排序位置 |
| `parent_id` | string | 分组 ID |
| `private_type` | number | 私密类型 |
| `speak_permission` | number | 发言权限 |
**返回**: `Channel`
---
### 删除子频道(破坏性操作)
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体子频道 ID、子频道名称和用户删除意图并传 `confirmed: true`;不要按模糊名称猜测删除目标。确认后使用子频道资源路径 `/channels/{channel_id}`。
---
### GET /guilds/{guild_id}/members — 获取成员列表
> 仅私域机器人可用
**查询参数**:
| 参数 | 类型 | 说明 |
| ------- | ------ | ---------------------------------- |
| `after` | string | 上次最后一个 user.id首次填 `"0"` |
| `limit` | string | 分页大小 1-400默认 1 |
**返回**: `Member[]`
> 翻页:用最后一个 `user.id` 作为 `after`,直到返回空数组。可能返回重复成员,需按 `user.id` 去重。
---
### GET /guilds/{guild_id}/members/{user_id} — 获取成员详情
**返回**: `Member`
---
### GET /guilds/{guild_id}/roles/{role_id}/members — 获取身份组成员列表
> 仅私域机器人可用
**查询参数**:
| 参数 | 类型 | 说明 |
| ------------- | ------ | ---------------------- |
| `start_index` | string | 分页标识,首次填 `"0"` |
| `limit` | string | 分页大小 1-400默认 1 |
**返回**: `{ data: Member[], next: string }`
> 翻页:用 `next` 作为 `start_index`,直到 `data` 为空。
---
### GET /channels/{channel_id}/online_nums — 获取在线成员数
**返回**: `{ online_nums: number }`
---
### POST /guilds/{guild_id}/announces — 创建频道公告
**请求体**:
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | --------------------------------------------------- |
| `message_id` | string | 否 | 消息 ID有值时创建消息公告此时 channel_id 必填) |
| `channel_id` | string | 否 | 子频道 ID |
| `announces_type` | number | 否 | 0=成员公告1=欢迎公告 |
| `recommend_channels` | array | 否 | 推荐子频道列表(最多 3 条message_id 为空时生效) |
> 两种公告类型会互相顶替
**返回**: `AnnouncesResult`
---
### 删除公告(破坏性操作)
> 调用前必须确认具体公告 ID 并传 `confirmed: true`,确认后使用公告资源路径 `/guilds/{guild_id}/announces/{message_id}`。批量删除全部公告只能在用户明确要求并再次确认后使用 `/guilds/{guild_id}/announces/all`,并且必须额外传 `bulkConfirmed: true`。
---
### GET /channels/{channel_id}/threads — 获取帖子列表
> 仅私域机器人可用channel_id 须为论坛子频道type=10007
**返回**: `ThreadListResult`
---
### GET /channels/{channel_id}/threads/{thread_id} — 获取帖子详情
> 仅私域机器人可用
**返回**: `ThreadDetail`
---
### PUT /channels/{channel_id}/threads — 发表帖子
> 仅私域机器人可用
**请求体**:
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ------------------------------------------ |
| `title` | string | 是 | 帖子标题 |
| `content` | string | 是 | 帖子内容 |
| `format` | number | 否 | 1=文本, 2=HTML, 3=Markdown默认, 4=JSON |
**返回**: `{ task_id: string, create_time: string }`
---
### 删除帖子(破坏性操作)
> ⚠️ 不可逆!仅私域机器人可用。调用前必须确认具体帖子 ID、帖子标题/作者和用户删除意图,并传 `confirmed: true`。确认后使用帖子资源路径 `/channels/{channel_id}/threads/{thread_id}`。
---
### POST /channels/{channel_id}/threads/{thread_id}/comment — 发表评论
> 仅私域机器人可用
**请求体**:
| 参数 | 类型 | 必填 | 说明 |
| -------------------- | ------ | ---- | ------------ |
| `thread_author` | string | 是 | 帖子作者 ID |
| `content` | string | 是 | 评论内容 |
| `thread_create_time` | string | 否 | 帖子创建时间 |
| `image` | string | 否 | 图片链接 |
**返回**: `{ task_id: string, create_time: number }`
---
### POST /channels/{channel_id}/schedules — 创建日程
> 需要管理频道权限。单管理员/天限 10 次,单频道/天限 100 次。
**请求体**:
```json
{
"schedule": {
"name": "日程名称",
"start_timestamp": "毫秒时间戳",
"end_timestamp": "毫秒时间戳",
"jump_channel_id": "0",
"remind_type": "0"
}
}
```
| 参数 | 类型 | 必填 | 说明 |
| -------------------------- | ------ | ---- | ------------------------- |
| `schedule.name` | string | 是 | 日程名称 |
| `schedule.start_timestamp` | string | 是 | 开始时间(毫秒) |
| `schedule.end_timestamp` | string | 是 | 结束时间(毫秒) |
| `schedule.jump_channel_id` | string | 否 | 跳转子频道 ID默认 `"0"` |
| `schedule.remind_type` | string | 否 | 提醒类型,默认 `"0"` |
**返回**: `Schedule`
---
### PATCH /channels/{channel_id}/schedules/{schedule_id} — 修改日程
> 需要管理频道权限
**请求体**:同创建日程
**返回**: `Schedule`
---
### 删除日程(破坏性操作)
> ⚠️ 不可逆!需要管理频道权限。调用前必须确认具体日程 ID、日程名称/时间和用户删除意图,并传 `confirmed: true`。确认后使用日程资源路径 `/channels/{channel_id}/schedules/{schedule_id}`。

View 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. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)

View 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"试试?`
- 删除成功:`✅ 已取消"{名称}"`

View 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;
}

View 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;

View 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();

View 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);
}

View File

@@ -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);
});
});

View File

@@ -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 },
};
}

View File

@@ -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);
});
});

View File

@@ -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,
});
},
});
}
}

View 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",
});
});
});

View 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,
};
}

View 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 };
}

View 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 }),
};

View 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;
}

View 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)}`),
};
}

View 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),
}
);
}

View 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" },
});
}

View 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");
});
});

View 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;
}

View 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;
}

View 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;
}

View 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 };
}

View 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),
};

View 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" },
);
}

View 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);
}

View 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();
});
});

View 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" });
}

View 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);
});
});

View 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,
},
};

View 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,
}),
},
};

View 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);
});
});

View 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);

View 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({});
});
});

View 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"] } },
});
});
});

View 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,
};
}

View 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,
};

View 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";

View 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",
});
});
});
});

View 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 };
}

View 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);
});
});

View 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);
};
}

View File

@@ -0,0 +1,3 @@
// Qqbot type declarations define plugin contracts.
export type QQBotDmPolicy = "open" | "allowlist" | "disabled";
export type QQBotGroupPolicy = "open" | "allowlist" | "disabled";

View 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>;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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);
});
});

View 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?.();
}
}
}

View 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 });
}
});
});

View 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()));
}
}

View 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();
});
});

View 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 } : {}),
});
}
}

View 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;
}

View 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;

View 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;
}

View 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",
);
});
});

View 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 });
});
}
}

View 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"]);
});
});

View 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,
};
}

View File

@@ -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");
});
});

View 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,
};
}

View 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);
}

View 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");
},
});
}

View File

@@ -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"),
});
}

View File

@@ -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");
},
});
}

View File

@@ -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("参数错误");
});
});

View File

@@ -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)}`;
}
},
});
}

View File

@@ -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();
},
});
}

View File

@@ -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)}`;
}
},
});
}

View 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;
}

View File

@@ -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("/configshow")).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");
});
});

View 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" };
}

View 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);
}

View File

@@ -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");
});
});

View 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";
}
}

View File

@@ -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;
}

View 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);
});
});

View 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();
}

View 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);
}
}

View 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);
});
});

View 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;
}

View 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