mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 12:10:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
9
openclaw/extensions/qqbot/api.ts
Normal file
9
openclaw/extensions/qqbot/api.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { qqbotPlugin } from "./src/channel.js";
|
||||
export { qqbotSetupPlugin } from "./src/channel.setup.js";
|
||||
export { getFrameworkCommands } from "./src/slash-commands.js";
|
||||
export { registerChannelTool } from "./src/tools/channel.js";
|
||||
export { registerRemindTool } from "./src/tools/remind.js";
|
||||
export * from "./src/types.js";
|
||||
export * from "./src/config.js";
|
||||
export * from "./src/outbound.js";
|
||||
export * from "./src/proactive.js";
|
||||
209
openclaw/extensions/qqbot/index.ts
Normal file
209
openclaw/extensions/qqbot/index.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import {
|
||||
defineBundledChannelEntry,
|
||||
loadBundledEntryExportSync,
|
||||
type OpenClawPluginApi,
|
||||
type PluginCommandContext,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
type QQBotAccount = {
|
||||
accountId: string;
|
||||
appId: string;
|
||||
config: unknown;
|
||||
};
|
||||
|
||||
type MediaTargetContext = {
|
||||
targetType: "c2c" | "group" | "channel" | "dm";
|
||||
targetId: string;
|
||||
account: QQBotAccount;
|
||||
logPrefix: string;
|
||||
};
|
||||
type SendDocumentOptions = {
|
||||
allowQQBotDataDownloads?: boolean;
|
||||
};
|
||||
|
||||
type QQBotFrameworkCommandResult =
|
||||
| string
|
||||
| {
|
||||
text: string;
|
||||
filePath?: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type QQBotFrameworkCommand = {
|
||||
name: string;
|
||||
description: string;
|
||||
handler: (ctx: Record<string, unknown>) => Promise<QQBotFrameworkCommandResult>;
|
||||
};
|
||||
|
||||
function resolveQQBotAccount(config: unknown, accountId?: string): QQBotAccount {
|
||||
const resolve = loadBundledEntryExportSync<(config: unknown, accountId?: string) => QQBotAccount>(
|
||||
import.meta.url,
|
||||
{
|
||||
specifier: "./api.js",
|
||||
exportName: "resolveQQBotAccount",
|
||||
},
|
||||
);
|
||||
return resolve(config, accountId);
|
||||
}
|
||||
|
||||
function sendDocument(
|
||||
context: MediaTargetContext,
|
||||
filePath: string,
|
||||
options?: SendDocumentOptions,
|
||||
) {
|
||||
const send = loadBundledEntryExportSync<
|
||||
(
|
||||
context: MediaTargetContext,
|
||||
filePath: string,
|
||||
options?: SendDocumentOptions,
|
||||
) => Promise<unknown>
|
||||
>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "sendDocument",
|
||||
});
|
||||
return send(context, filePath, options);
|
||||
}
|
||||
|
||||
function getFrameworkCommands(): QQBotFrameworkCommand[] {
|
||||
const getCommands = loadBundledEntryExportSync<() => QQBotFrameworkCommand[]>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "getFrameworkCommands",
|
||||
});
|
||||
return getCommands();
|
||||
}
|
||||
|
||||
function registerChannelTool(api: OpenClawPluginApi): void {
|
||||
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "registerChannelTool",
|
||||
});
|
||||
register(api);
|
||||
}
|
||||
|
||||
function registerRemindTool(api: OpenClawPluginApi): void {
|
||||
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
|
||||
specifier: "./api.js",
|
||||
exportName: "registerRemindTool",
|
||||
});
|
||||
register(api);
|
||||
}
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "qqbot",
|
||||
name: "QQ Bot",
|
||||
description: "QQ Bot channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "qqbotPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setQQBotRuntime",
|
||||
},
|
||||
registerFull(api: OpenClawPluginApi) {
|
||||
registerChannelTool(api);
|
||||
registerRemindTool(api);
|
||||
|
||||
// Register all requireAuth:true slash commands with the framework so that
|
||||
// resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence
|
||||
// and qqbot: prefix normalization before any handler runs.
|
||||
for (const cmd of getFrameworkCommands()) {
|
||||
api.registerCommand({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
requireAuth: true,
|
||||
acceptsArgs: true,
|
||||
handler: async (ctx: PluginCommandContext) => {
|
||||
// Derive the QQBot message type from ctx.from so that handlers that
|
||||
// inspect SlashCommandContext.type get the correct value.
|
||||
// ctx.from format: "qqbot:<type>:<id>" e.g. "qqbot:c2c:<senderId>"
|
||||
const fromStripped = (ctx.from ?? "").replace(/^qqbot:/i, "");
|
||||
const rawMsgType = fromStripped.split(":")[0] ?? "c2c";
|
||||
const msgType: "c2c" | "guild" | "dm" | "group" =
|
||||
rawMsgType === "group"
|
||||
? "group"
|
||||
: rawMsgType === "channel"
|
||||
? "guild"
|
||||
: rawMsgType === "dm"
|
||||
? "dm"
|
||||
: "c2c";
|
||||
|
||||
// Parse target for file sends (same from string).
|
||||
const colonIdx = fromStripped.indexOf(":");
|
||||
const targetId = colonIdx !== -1 ? fromStripped.slice(colonIdx + 1) : fromStripped;
|
||||
const targetType: "c2c" | "group" | "channel" | "dm" =
|
||||
rawMsgType === "group"
|
||||
? "group"
|
||||
: rawMsgType === "channel"
|
||||
? "channel"
|
||||
: rawMsgType === "dm"
|
||||
? "dm"
|
||||
: "c2c";
|
||||
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
|
||||
|
||||
// Build a minimal SlashCommandContext from the framework PluginCommandContext.
|
||||
// commandAuthorized is always true here because the framework has already
|
||||
// verified the sender via resolveCommandAuthorization().
|
||||
const slashCtx = {
|
||||
type: msgType,
|
||||
senderId: ctx.senderId ?? "",
|
||||
messageId: "",
|
||||
eventTimestamp: new Date().toISOString(),
|
||||
receivedAt: Date.now(),
|
||||
rawContent: `/${cmd.name}${ctx.args ? ` ${ctx.args}` : ""}`,
|
||||
args: ctx.args ?? "",
|
||||
accountId: account.accountId,
|
||||
// appId is not available from PluginCommandContext directly; handlers
|
||||
// that need it should call resolveQQBotAccount(ctx.config, ctx.accountId).
|
||||
appId: account.appId,
|
||||
accountConfig: account.config,
|
||||
commandAuthorized: true,
|
||||
queueSnapshot: {
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 10,
|
||||
senderPending: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await cmd.handler(slashCtx);
|
||||
|
||||
// Plain-text result.
|
||||
if (typeof result === "string") {
|
||||
return { text: result };
|
||||
}
|
||||
|
||||
// File result: send the file attachment via QQ API, return text summary.
|
||||
if (result && typeof result === "object" && "filePath" in result) {
|
||||
try {
|
||||
const mediaCtx: MediaTargetContext = {
|
||||
targetType,
|
||||
targetId,
|
||||
account,
|
||||
logPrefix: `[qqbot:${account.accountId}]`,
|
||||
};
|
||||
await sendDocument(mediaCtx, String(result.filePath), {
|
||||
allowQQBotDataDownloads: true,
|
||||
});
|
||||
} catch {
|
||||
// File send failed; the text summary is still returned below.
|
||||
}
|
||||
return { text: result.text };
|
||||
}
|
||||
|
||||
return {
|
||||
text:
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"text" in result &&
|
||||
typeof result.text === "string"
|
||||
? result.text
|
||||
: "⚠️ 命令返回了意外结果。",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
177
openclaw/extensions/qqbot/openclaw.plugin.json
Normal file
177
openclaw/extensions/qqbot/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
{
|
||||
"id": "qqbot",
|
||||
"channels": ["qqbot"],
|
||||
"channelEnvVars": {
|
||||
"qqbot": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"]
|
||||
},
|
||||
"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" }
|
||||
}
|
||||
},
|
||||
"speechQueryParams": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"provider": { "type": "string" },
|
||||
"baseUrl": { "type": "string" },
|
||||
"apiKey": { "type": "string" },
|
||||
"model": { "type": "string" },
|
||||
"voice": { "type": "string" },
|
||||
"authStyle": {
|
||||
"type": "string",
|
||||
"enum": ["bearer", "api-key"]
|
||||
},
|
||||
"queryParams": { "$ref": "#/$defs/speechQueryParams" },
|
||||
"speed": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"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" }]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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" },
|
||||
"tts": { "$ref": "#/$defs/tts" },
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"accounts": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/account"
|
||||
}
|
||||
},
|
||||
"defaultAccount": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
60
openclaw/extensions/qqbot/package.json
Normal file
60
openclaw/extensions/qqbot/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "@openclaw/qqbot",
|
||||
"version": "2026.4.20",
|
||||
"private": false,
|
||||
"description": "OpenClaw QQ Bot channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"mpg123-decoder": "^1.0.3",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"@types/ws": "^8.18.1",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.20"
|
||||
},
|
||||
"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.4.20"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.20"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
9
openclaw/extensions/qqbot/runtime-api.ts
Normal file
9
openclaw/extensions/qqbot/runtime-api.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export type { ChannelPlugin, OpenClawPluginApi, PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export type {
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { ResolvedQQBotAccount, QQBotAccountConfig } from "./src/types.js";
|
||||
export { getQQBotRuntime, setQQBotRuntime } from "./src/runtime.js";
|
||||
9
openclaw/extensions/qqbot/setup-entry.ts
Normal file
9
openclaw/extensions/qqbot/setup-entry.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "qqbotSetupPlugin",
|
||||
},
|
||||
});
|
||||
262
openclaw/extensions/qqbot/skills/qqbot-channel/SKILL.md
Normal file
262
openclaw/extensions/qqbot/skills/qqbot-channel/SKILL.md
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
---
|
||||
name: qqbot-channel
|
||||
description: QQ 频道管理技能。查询频道列表、子频道、成员、发帖、公告、日程等操作。使用 qqbot_channel_api 工具代理 QQ 开放平台 HTTP 接口,自动处理 Token 鉴权。当用户需要查看频道、管理子频道、查询成员、发布帖子/公告/日程时使用。
|
||||
metadata: { "openclaw": { "emoji": "📡", "requires": { "config": ["channels.qqbot"] } } }
|
||||
---
|
||||
|
||||
# QQ 频道 API 请求指导
|
||||
|
||||
`qqbot_channel_api` 是一个 QQ 开放平台 HTTP 代理工具,**自动填充鉴权 Token**。你只需要指定 HTTP 方法、API 路径、请求体和查询参数。
|
||||
|
||||
## 📚 详细参考文档
|
||||
|
||||
每个接口的完整参数说明、返回值结构和枚举值定义:
|
||||
|
||||
- `references/api_references.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 工具参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------ | ---- | ---------------------------------------------------------------------------- |
|
||||
| `method` | string | 是 | HTTP 方法:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
|
||||
| `path` | string | 是 | API 路径(不含域名),如 `/guilds/{guild_id}/channels`,需替换占位符为实际值 |
|
||||
| `body` | object | 否 | 请求体 JSON(POST/PUT/PATCH 使用) |
|
||||
| `query` | object | 否 | URL 查询参数键值对,值为字符串类型 |
|
||||
|
||||
> 基础 URL:`https://api.sgroup.qq.com`,鉴权头 `Authorization: QQBot {token}` 由工具自动填充。
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 接口速查
|
||||
|
||||
### 频道(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`(至少一个) |
|
||||
| 删除子频道 | `DELETE` | `/channels/{channel_id}` | ⚠️ 不可逆 |
|
||||
|
||||
**子频道类型(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条) |
|
||||
| 删除公告 | `DELETE` | `/guilds/{guild_id}/announces/{message_id}` | message_id 设 `all` 删除所有 |
|
||||
|
||||
### 论坛(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) |
|
||||
| 删除帖子 | `DELETE` | `/channels/{channel_id}/threads/{thread_id}` | ⚠️ 不可逆 |
|
||||
| 发表评论 | `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 } }` |
|
||||
| 删除日程 | `DELETE` | `/channels/{channel_id}/schedules/{schedule_id}` | ⚠️ 不可逆 |
|
||||
|
||||
**提醒类型(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": "欢迎来到攻略频道" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 删除所有公告
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/guilds/123456/announces/all"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 常用操作流程
|
||||
|
||||
### 获取频道和子频道信息
|
||||
|
||||
```
|
||||
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,**必须使用 Markdown 图片语法展示**,让用户直接看到头像图片,而非纯文本链接:
|
||||
|
||||
```
|
||||
成员信息:
|
||||
· 昵称:{nick}
|
||||
· 头像:
|
||||

|
||||
```
|
||||
|
||||
> **禁止**将头像 URL 作为纯文本或超链接展示(如 `查看头像`),必须用 `` 语法内联显示。频道的 `icon` 字段同理。
|
||||
|
||||
---
|
||||
|
||||
## 🚨 错误码处理
|
||||
|
||||
| 错误码 | 说明 | 解决方案 |
|
||||
| ---------- | ---------------- | ------------------------------------------------------------------------------------- |
|
||||
| **401** | Token 鉴权失败 | 检查 AppID 和 ClientSecret 配置 |
|
||||
| **11241** | 频道 API 无权限 | 前往 QQ 开放平台申请权限,或调用 `GET /guilds/{guild_id}/api_permission` 查看可用权限 |
|
||||
| **11242** | 仅私域机器人可用 | 需在 QQ 开放平台将机器人切换为私域模式 |
|
||||
| **11243** | 需要管理频道权限 | 确保机器人拥有管理权限 |
|
||||
| **11281** | 日程频率限制 | 单管理员/天限 10 次,单频道/天限 100 次 |
|
||||
| **304023** | 推荐子频道超限 | 推荐子频道最多 3 条 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **路径中的占位符**(如 `{guild_id}`、`{channel_id}`)必须替换为实际值
|
||||
2. **query 参数的值必须为字符串类型**,如 `{ "limit": "100" }` 而非 `{ "limit": 100 }`
|
||||
3. **成员列表翻页**时可能返回重复成员,需按 `user.id` 去重
|
||||
4. **公告**的两种类型(消息公告和推荐子频道公告)会互相顶替
|
||||
5. **日程**的时间戳为毫秒级字符串
|
||||
6. **删除操作不可逆**,请谨慎使用
|
||||
7. **论坛操作**仅私域机器人可用
|
||||
8. **子频道分组**(type=4)的 `position` 必须 >= 2
|
||||
9. **日程操作**有频率限制:单个管理员每天 10 次,单个频道每天 100 次
|
||||
10. **头像/图标展示**:成员 `user.avatar` 和频道 `icon` 等图片 URL 必须使用 Markdown 图片语法 `` 展示,禁止作为纯文本或超链接展示
|
||||
|
|
@ -0,0 +1,521 @@
|
|||
# QQ 频道 API 完整参考
|
||||
|
||||
本文档包含 QQ 开放平台频道相关所有接口的详细参数说明、返回值结构和枚举值定义。
|
||||
|
||||
通过 `qqbot_channel_api` 工具代理请求,工具自动处理鉴权。
|
||||
|
||||
---
|
||||
|
||||
## 📌 通用说明
|
||||
|
||||
### 基础 URL
|
||||
|
||||
`https://api.sgroup.qq.com`
|
||||
|
||||
### 鉴权(自动处理)
|
||||
|
||||
工具自动填充以下请求头,无需手动设置:
|
||||
|
||||
```
|
||||
Authorization: QQBot {access_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 错误返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "错误描述",
|
||||
"code": 错误码
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 返回值类型定义
|
||||
|
||||
### Guild(频道)
|
||||
|
||||
```typescript
|
||||
interface Guild {
|
||||
id: string; // 频道 ID
|
||||
name: string; // 频道名称
|
||||
icon: string; // 频道头像 URL
|
||||
owner_id: string; // 频道拥有者 ID
|
||||
owner: boolean; // 机器人是否为频道拥有者
|
||||
joined_at: string; // 机器人加入时间(ISO 8601)
|
||||
member_count: number; // 频道成员数
|
||||
max_members: number; // 频道最大成员数
|
||||
description: string; // 频道描述
|
||||
}
|
||||
```
|
||||
|
||||
### Channel(子频道)
|
||||
|
||||
```typescript
|
||||
interface Channel {
|
||||
id: string; // 子频道 ID
|
||||
guild_id: string; // 所属频道 ID
|
||||
name: string; // 子频道名称
|
||||
type: number; // 子频道类型(见枚举)
|
||||
position: number; // 排序位置
|
||||
parent_id: string; // 所属分组 ID
|
||||
owner_id: string; // 创建者 ID
|
||||
sub_type: number; // 子类型(见枚举)
|
||||
private_type?: number; // 私密类型(见枚举)
|
||||
speak_permission?: number; // 发言权限(见枚举)
|
||||
application_id?: string; // 应用子频道 AppID
|
||||
}
|
||||
```
|
||||
|
||||
### User(用户)
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string; // 用户 ID
|
||||
username: string; // 用户名
|
||||
avatar: string; // 头像 URL
|
||||
bot: boolean; // 是否为机器人
|
||||
union_openid?: string; // 特殊关联应用的 openid
|
||||
union_user_account?: string; // 特殊关联应用的用户信息
|
||||
}
|
||||
```
|
||||
|
||||
### Member(成员)
|
||||
|
||||
```typescript
|
||||
interface Member {
|
||||
user: User; // 用户基本信息
|
||||
nick: string; // 在频道中的昵称
|
||||
roles: string[]; // 身份组 ID 列表
|
||||
joined_at: string; // 加入频道时间(ISO 8601)
|
||||
deaf?: boolean; // 是否被禁言
|
||||
mute?: boolean; // 是否被闭麦
|
||||
pending?: boolean; // 是否待审核
|
||||
}
|
||||
```
|
||||
|
||||
### APIPermission(API 权限)
|
||||
|
||||
```typescript
|
||||
interface APIPermission {
|
||||
path: string; // 接口路径
|
||||
method: string; // 请求方法
|
||||
desc: string; // 接口描述
|
||||
auth_status: number; // 授权状态:0=未授权, 1=已授权
|
||||
}
|
||||
```
|
||||
|
||||
### AnnouncesResult(公告结果)
|
||||
|
||||
```typescript
|
||||
interface AnnouncesResult {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
message_id: string;
|
||||
announces_type: number;
|
||||
recommend_channels: RecommendChannel[];
|
||||
}
|
||||
|
||||
interface RecommendChannel {
|
||||
channel_id: string; // 推荐的子频道 ID
|
||||
introduce: string; // 推荐语
|
||||
}
|
||||
```
|
||||
|
||||
### ThreadDetail(帖子详情)
|
||||
|
||||
```typescript
|
||||
interface ThreadDetail {
|
||||
thread: {
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
thread_info: {
|
||||
thread_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
date_time: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### ThreadListResult(帖子列表)
|
||||
|
||||
```typescript
|
||||
interface ThreadListResult {
|
||||
threads: Array<{
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
thread_info: {
|
||||
thread_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
date_time: string;
|
||||
};
|
||||
}>;
|
||||
is_finish: number; // 1=已到底, 0=还有更多
|
||||
}
|
||||
```
|
||||
|
||||
### Schedule(日程)
|
||||
|
||||
```typescript
|
||||
interface Schedule {
|
||||
id?: string;
|
||||
name: string;
|
||||
start_timestamp: string; // 毫秒级时间戳
|
||||
end_timestamp: string;
|
||||
jump_channel_id?: string;
|
||||
remind_type?: string;
|
||||
creator?: {
|
||||
user: { id: string; username: string; bot: boolean };
|
||||
nick: string;
|
||||
joined_at: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 枚举值定义
|
||||
|
||||
### 子频道类型(Channel type)
|
||||
|
||||
| 值 | 名称 | 说明 |
|
||||
| ------- | ---------- | -------------------------------- |
|
||||
| `0` | 文字子频道 | 普通文字聊天 |
|
||||
| `2` | 语音子频道 | 语音聊天 |
|
||||
| `4` | 子频道分组 | 组织子频道的分组(position ≥ 2) |
|
||||
| `10005` | 直播子频道 | 直播功能 |
|
||||
| `10006` | 应用子频道 | 需 application_id |
|
||||
| `10007` | 论坛子频道 | 论坛功能 |
|
||||
|
||||
### 子频道子类型(Channel sub_type)
|
||||
|
||||
| 值 | 名称 |
|
||||
| --- | ---- |
|
||||
| `0` | 闲聊 |
|
||||
| `1` | 公告 |
|
||||
| `2` | 攻略 |
|
||||
| `3` | 开黑 |
|
||||
|
||||
### 子频道私密类型(Channel private_type)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | -------------------- |
|
||||
| `0` | 公开子频道 |
|
||||
| `1` | 管理员和指定成员可见 |
|
||||
| `2` | 仅管理员可见 |
|
||||
|
||||
### 子频道发言权限(Channel speak_permission)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | ------------------------------------------ |
|
||||
| `0` | 无效(仅创建公告子频道时有效,此时为只读) |
|
||||
| `1` | 所有人可发言 |
|
||||
| `2` | 仅管理员和指定成员可发言 |
|
||||
|
||||
### 公告类型(announces_type)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | -------- |
|
||||
| `0` | 成员公告 |
|
||||
| `1` | 欢迎公告 |
|
||||
|
||||
### 帖子格式(format)
|
||||
|
||||
| 值 | 格式 |
|
||||
| --- | -------------------- |
|
||||
| `1` | 纯文本 |
|
||||
| `2` | HTML |
|
||||
| `3` | Markdown(**默认**) |
|
||||
| `4` | JSON(RichText) |
|
||||
|
||||
### 日程提醒类型(remind_type)
|
||||
|
||||
| 值 | 说明 |
|
||||
| ----- | -------------- |
|
||||
| `"0"` | 不提醒 |
|
||||
| `"1"` | 开始时提醒 |
|
||||
| `"2"` | 开始前 5 分钟 |
|
||||
| `"3"` | 开始前 15 分钟 |
|
||||
| `"4"` | 开始前 30 分钟 |
|
||||
| `"5"` | 开始前 60 分钟 |
|
||||
|
||||
### API 权限授权状态(auth_status)
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | ------ |
|
||||
| `0` | 未授权 |
|
||||
| `1` | 已授权 |
|
||||
|
||||
---
|
||||
|
||||
## 📖 各接口详细说明
|
||||
|
||||
### GET /users/@me/guilds — 获取频道列表
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| -------- | ------ | ---- | ---------------------------------------------------- |
|
||||
| `before` | string | 否 | 读此 guild id 之前的数据 |
|
||||
| `after` | string | 否 | 读此 guild id 之后的数据(与 before 同时设置时无效) |
|
||||
| `limit` | string | 否 | 每次拉取条数,默认 100,最大 100 |
|
||||
|
||||
**返回**: `Guild[]`
|
||||
|
||||
**调用示例**:
|
||||
|
||||
```json
|
||||
{ "method": "GET", "path": "/users/@me/guilds", "query": { "limit": "100" } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/api_permission — 获取频道 API 权限
|
||||
|
||||
**返回**: `{ apis: APIPermission[] }`
|
||||
|
||||
**调用示例**:
|
||||
|
||||
```json
|
||||
{ "method": "GET", "path": "/guilds/123456/api_permission" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /guilds/{guild_id}/channels — 获取子频道列表
|
||||
|
||||
**返回**: `Channel[]`
|
||||
|
||||
**调用示例**:
|
||||
|
||||
```json
|
||||
{ "method": "GET", "path": "/guilds/123456/channels" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /channels/{channel_id} — 获取子频道详情
|
||||
|
||||
**返回**: `Channel`
|
||||
|
||||
---
|
||||
|
||||
### POST /guilds/{guild_id}/channels — 创建子频道
|
||||
|
||||
> ⚠️ 仅私域机器人可用,需管理频道权限
|
||||
|
||||
**请求体**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------------------ | -------- | ---- | ------------------------------------- |
|
||||
| `name` | string | 是 | 子频道名称 |
|
||||
| `type` | number | 是 | 子频道类型 |
|
||||
| `position` | number | 是 | 排序位置(type=4 时 ≥ 2) |
|
||||
| `sub_type` | number | 否 | 子类型 |
|
||||
| `parent_id` | string | 否 | 所属分组 ID |
|
||||
| `private_type` | number | 否 | 私密类型 |
|
||||
| `private_user_ids` | string[] | 否 | 私密成员列表(private_type=1 时有效) |
|
||||
| `speak_permission` | number | 否 | 发言权限 |
|
||||
| `application_id` | string | 否 | 应用 AppID(type=10006 时需要) |
|
||||
|
||||
**返回**: `Channel`
|
||||
|
||||
---
|
||||
|
||||
### PATCH /channels/{channel_id} — 修改子频道
|
||||
|
||||
> ⚠️ 仅私域机器人可用
|
||||
|
||||
**请求体**(至少一个):
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ------------------ | ------ | -------- |
|
||||
| `name` | string | 名称 |
|
||||
| `position` | number | 排序位置 |
|
||||
| `parent_id` | string | 分组 ID |
|
||||
| `private_type` | number | 私密类型 |
|
||||
| `speak_permission` | number | 发言权限 |
|
||||
|
||||
**返回**: `Channel`
|
||||
|
||||
---
|
||||
|
||||
### DELETE /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`
|
||||
|
||||
---
|
||||
|
||||
### DELETE /guilds/{guild_id}/announces/{message_id} — 删除公告
|
||||
|
||||
> `message_id` 设为 `all` 删除所有公告
|
||||
|
||||
---
|
||||
|
||||
### 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 }`
|
||||
|
||||
---
|
||||
|
||||
### DELETE /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`
|
||||
|
||||
---
|
||||
|
||||
### DELETE /channels/{channel_id}/schedules/{schedule_id} — 删除日程
|
||||
|
||||
> ⚠️ 不可逆!需要管理频道权限
|
||||
36
openclaw/extensions/qqbot/skills/qqbot-media/SKILL.md
Normal file
36
openclaw/extensions/qqbot/skills/qqbot-media/SKILL.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: qqbot-media
|
||||
description: QQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。
|
||||
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` 等 → 视频
|
||||
- 其他扩展名 → 文件
|
||||
- 无扩展名的 URL → 默认按图片处理
|
||||
|
||||
## 接收媒体
|
||||
|
||||
- 用户发来的**图片**自动下载到本地,路径在上下文【附件】中,可直接用 `<qqmedia>路径</qqmedia>` 回发
|
||||
- 用户发来的**语音**路径在上下文中;若有 STT 能力则优先转写
|
||||
|
||||
## 规则
|
||||
|
||||
1. **路径必须是绝对路径**(以 `/` 或 `http` 开头)
|
||||
2. **标签必须用开闭标签包裹路径**:`<qqmedia>路径</qqmedia>`
|
||||
3. **文件大小上限 10MB**
|
||||
4. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
|
||||
5. 发送语音时不要重复语音中已朗读的文字
|
||||
6. 多个媒体用多个标签
|
||||
7. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
|
||||
149
openclaw/extensions/qqbot/skills/qqbot-remind/SKILL.md
Normal file
149
openclaw/extensions/qqbot/skills/qqbot-remind/SKILL.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
name: qqbot-remind
|
||||
description: QQBot 定时提醒。支持一次性和周期性提醒的创建、查询、取消。当通过 QQ 通道通信且涉及提醒/定时任务时使用。
|
||||
metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqbot"] } } }
|
||||
---
|
||||
|
||||
# QQ Bot 定时提醒
|
||||
|
||||
## ⚠️ 强制规则
|
||||
|
||||
**当用户提到「提醒」「闹钟」「定时」「X分钟/小时后」「每天X点」「叫我」等任何涉及延时或定时的请求时,你必须调用工具,绝对不能只用自然语言回复说"好的,我会提醒你"!**
|
||||
|
||||
你没有内存或后台线程,口头承诺"到时候提醒"是无效的——只有调用工具才能真正注册定时任务。
|
||||
|
||||
---
|
||||
|
||||
## 推荐流程(优先使用 `qqbot_remind` 工具)
|
||||
|
||||
**第一步**:调用 `qqbot_remind` 工具,传入简单参数:
|
||||
|
||||
| 参数 | 说明 | 示例 |
|
||||
| --------- | -------------------------------------------- | ---------------------------------- |
|
||||
| `action` | 操作类型 | `"add"` / `"list"` / `"remove"` |
|
||||
| `content` | 提醒内容 | `"喝水"` |
|
||||
| `to` | 目标地址(可选,系统自动获取,通常无需填写) | — |
|
||||
| `time` | 时间(相对时间或 cron 表达式) | `"5m"` / `"1h30m"` / `"0 8 * * *"` |
|
||||
| `jobId` | 任务 ID(仅 remove) | `"xxx"` |
|
||||
|
||||
**第二步**:`qqbot_remind` 会返回 `cronParams`,你必须**立即**将 `cronParams` 的值作为参数调用 `cron` 工具。
|
||||
|
||||
**第三步**:根据 `cron` 工具的返回结果,回复用户。
|
||||
|
||||
### 示例
|
||||
|
||||
用户说:"5分钟后提醒我喝水"
|
||||
|
||||
1. 调用 `qqbot_remind`:`{ "action": "add", "content": "喝水", "time": "5m" }`
|
||||
2. 收到返回的 `cronParams` → 立即调用 `cron` 工具,参数为该 `cronParams`
|
||||
3. 回复用户:`⏰ 好的,5分钟后提醒你喝水~`
|
||||
|
||||
---
|
||||
|
||||
## 备用方案(直接使用 `cron` 工具)
|
||||
|
||||
> 仅当 `qqbot_remind` 工具不可用时使用以下方式。
|
||||
|
||||
### 核心规则
|
||||
|
||||
> **payload.kind 必须是 `"agentTurn"`,绝对不能用 `"systemEvent"`!**
|
||||
> `systemEvent` 只在 AI 会话内部注入文本,用户收不到 QQ 消息。
|
||||
|
||||
**5 个不可更改字段**:
|
||||
|
||||
| 字段 | 固定值 | 原因 |
|
||||
| ----------------- | ------------- | ---------------------------- |
|
||||
| `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 |
|
||||
| `payload.deliver` | `true` | 否则不投递 |
|
||||
| `payload.channel` | `"qqbot"` | QQ 通道标识 |
|
||||
| `payload.to` | 用户 openid | 从 `To` 字段获取 |
|
||||
| `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点缀",
|
||||
"deliver": true,
|
||||
"channel": "qqbot",
|
||||
"to": "{openid}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 周期提醒(schedule.kind = "cron")
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "add",
|
||||
"job": {
|
||||
"name": "{任务名}",
|
||||
"schedule": { "kind": "cron", "expr": "0 8 * * *", "tz": "Asia/Shanghai" },
|
||||
"sessionTarget": "isolated",
|
||||
"wakeMode": "now",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀",
|
||||
"deliver": true,
|
||||
"channel": "qqbot",
|
||||
"to": "{openid}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"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 * * * *"` |
|
||||
|
||||
> 周期提醒必须加 `"tz": "Asia/Shanghai"`。
|
||||
|
||||
---
|
||||
|
||||
## 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"试试?`
|
||||
- 删除成功:`✅ 已取消"{名称}"`
|
||||
1051
openclaw/extensions/qqbot/src/api.ts
Normal file
1051
openclaw/extensions/qqbot/src/api.ts
Normal file
File diff suppressed because it is too large
Load diff
204
openclaw/extensions/qqbot/src/channel-config-shared.ts
Normal file
204
openclaw/extensions/qqbot/src/channel-config-shared.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
applyQQBotAccountConfig,
|
||||
listQQBotAccountIds,
|
||||
resolveDefaultQQBotAccountId,
|
||||
resolveQQBotAccount,
|
||||
} from "./config.js";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeStringifiedOptionalString(
|
||||
value: string | number | null | undefined,
|
||||
): string | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
export const qqbotMeta = {
|
||||
id: "qqbot",
|
||||
label: "QQ Bot",
|
||||
selectionLabel: "QQ Bot",
|
||||
docsPath: "/channels/qqbot",
|
||||
blurb: "Connect to QQ via official QQ Bot API",
|
||||
order: 50,
|
||||
} as const;
|
||||
|
||||
function parseQQBotInlineToken(token: string): { appId: string; clientSecret: string } | null {
|
||||
const colonIdx = token.indexOf(":");
|
||||
if (colonIdx <= 0 || colonIdx === token.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appId = token.slice(0, colonIdx).trim();
|
||||
const clientSecret = token.slice(colonIdx + 1).trim();
|
||||
if (!appId || !clientSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { appId, clientSecret };
|
||||
}
|
||||
|
||||
export function validateQQBotSetupInput(params: {
|
||||
accountId: string;
|
||||
input: ChannelSetupInput;
|
||||
}): string | null {
|
||||
const { accountId, input } = params;
|
||||
|
||||
if (!input.token && !input.tokenFile && !input.useEnv) {
|
||||
return "QQBot requires --token (format: appId:clientSecret) or --use-env";
|
||||
}
|
||||
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "QQBot --use-env only supports the default account";
|
||||
}
|
||||
|
||||
if (input.token && !parseQQBotInlineToken(input.token)) {
|
||||
return "QQBot --token must be in appId:clientSecret format";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyQQBotSetupAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
input: ChannelSetupInput;
|
||||
}): OpenClawConfig {
|
||||
if (params.input.useEnv && params.accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
let appId = "";
|
||||
let clientSecret = "";
|
||||
|
||||
if (params.input.token) {
|
||||
const parsed = parseQQBotInlineToken(params.input.token);
|
||||
if (!parsed) {
|
||||
return params.cfg;
|
||||
}
|
||||
appId = parsed.appId;
|
||||
clientSecret = parsed.clientSecret;
|
||||
}
|
||||
|
||||
if (!appId && !params.input.tokenFile && !params.input.useEnv) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
return applyQQBotAccountConfig(params.cfg, params.accountId, {
|
||||
appId,
|
||||
clientSecret,
|
||||
clientSecretFile: params.input.tokenFile,
|
||||
name: params.input.name,
|
||||
});
|
||||
}
|
||||
|
||||
export function isQQBotConfigured(account: ResolvedQQBotAccount | undefined): boolean {
|
||||
return Boolean(
|
||||
account?.appId &&
|
||||
(Boolean(account?.clientSecret) ||
|
||||
hasConfiguredSecretInput(account?.config?.clientSecret) ||
|
||||
Boolean(account?.config?.clientSecretFile?.trim())),
|
||||
);
|
||||
}
|
||||
|
||||
export function describeQQBotAccount(account: ResolvedQQBotAccount | undefined) {
|
||||
return {
|
||||
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
name: account?.name,
|
||||
enabled: account?.enabled ?? false,
|
||||
configured: isQQBotConfigured(account),
|
||||
tokenSource: account?.secretSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatQQBotAllowFrom(params: {
|
||||
allowFrom: Array<string | number> | undefined | null;
|
||||
}): string[] {
|
||||
return (params.allowFrom ?? [])
|
||||
.map((entry) => normalizeStringifiedOptionalString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.map((entry) => entry.replace(/^qqbot:/i, ""))
|
||||
.map((entry) => entry.toUpperCase());
|
||||
}
|
||||
|
||||
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 }),
|
||||
};
|
||||
32
openclaw/extensions/qqbot/src/channel.setup.ts
Normal file
32
openclaw/extensions/qqbot/src/channel.setup.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js";
|
||||
import { qqbotChannelConfigSchema } from "./config-schema.js";
|
||||
import { qqbotSetupWizard } from "./setup-surface.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,
|
||||
},
|
||||
};
|
||||
255
openclaw/extensions/qqbot/src/channel.ts
Normal file
255
openclaw/extensions/qqbot/src/channel.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { initApiConfig } from "./api.js";
|
||||
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js";
|
||||
import { qqbotChannelConfigSchema } from "./config-schema.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "./config.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
import { qqbotSetupWizard } from "./setup-surface.js";
|
||||
// Re-export text helpers so existing consumers of channel.ts are unaffected.
|
||||
// The canonical definition lives in text-utils.ts to avoid a circular
|
||||
// dependency: channel.ts → (dynamic) gateway.ts → outbound-deliver.ts → channel.ts.
|
||||
export { chunkText, TEXT_CHUNK_LIMIT } from "./text-utils.js";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
|
||||
type QQBotOutboundModule = typeof import("./outbound.js");
|
||||
|
||||
// Shared promise so concurrent multi-account startups serialize the dynamic
|
||||
// import of the gateway module, avoiding an ESM circular-dependency race.
|
||||
let _gatewayModulePromise: Promise<typeof import("./gateway.js")> | undefined;
|
||||
let _outboundModulePromise: Promise<QQBotOutboundModule> | undefined;
|
||||
|
||||
function loadGatewayModule(): Promise<typeof import("./gateway.js")> {
|
||||
_gatewayModulePromise ??= import("./gateway.js");
|
||||
return _gatewayModulePromise;
|
||||
}
|
||||
|
||||
function loadOutboundModule(): Promise<QQBotOutboundModule> {
|
||||
_outboundModulePromise ??= import("./outbound.js");
|
||||
return _outboundModulePromise;
|
||||
}
|
||||
|
||||
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
id: "qqbot",
|
||||
setupWizard: qqbotSetupWizard,
|
||||
meta: {
|
||||
...qqbotMeta,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: false,
|
||||
threads: false,
|
||||
/**
|
||||
* blockStreaming=true means the channel supports block streaming.
|
||||
* The framework collects streamed blocks and sends them through deliver().
|
||||
*/
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.qqbot"] },
|
||||
configSchema: qqbotChannelConfigSchema,
|
||||
|
||||
config: {
|
||||
...qqbotConfigAdapter,
|
||||
},
|
||||
setup: {
|
||||
...qqbotSetupAdapterShared,
|
||||
},
|
||||
messaging: {
|
||||
/** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
|
||||
normalizeTarget: (target: string): string | undefined => {
|
||||
const id = target.replace(/^qqbot:/i, "");
|
||||
if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) {
|
||||
return `qqbot:${id}`;
|
||||
}
|
||||
const openIdHexPattern = /^[0-9a-fA-F]{32}$/;
|
||||
if (openIdHexPattern.test(id)) {
|
||||
return `qqbot:c2c:${id}`;
|
||||
}
|
||||
const openIdUuidPattern =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (openIdUuidPattern.test(id)) {
|
||||
return `qqbot:c2c:${id}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
targetResolver: {
|
||||
/** Return true when the id looks like a QQ Bot target. */
|
||||
looksLikeId: (id: string): boolean => {
|
||||
if (/^qqbot:(c2c|group|channel):/i.test(id)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(c2c|group|channel):/i.test(id)) {
|
||||
return true;
|
||||
}
|
||||
if (/^[0-9a-fA-F]{32}$/.test(id)) {
|
||||
return true;
|
||||
}
|
||||
const openIdPattern =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
return openIdPattern.test(id);
|
||||
},
|
||||
hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 5000,
|
||||
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
const { sendText } = await loadOutboundModule();
|
||||
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
||||
const result = await sendText({ to, text, accountId, replyToId, account });
|
||||
return {
|
||||
channel: "qqbot" as const,
|
||||
messageId: result.messageId ?? "",
|
||||
meta: result.error ? { error: result.error } : undefined,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
const { sendMedia } = await loadOutboundModule();
|
||||
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
||||
const result = await sendMedia({
|
||||
to,
|
||||
text: text ?? "",
|
||||
mediaUrl: mediaUrl ?? "",
|
||||
accountId,
|
||||
replyToId,
|
||||
account,
|
||||
});
|
||||
return {
|
||||
channel: "qqbot" as const,
|
||||
messageId: result.messageId ?? "",
|
||||
meta: result.error ? { error: result.error } : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const { account } = ctx;
|
||||
const { abortSignal, log, cfg } = ctx;
|
||||
// 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,
|
||||
onReady: () => {
|
||||
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
|
||||
ctx.setStatus({
|
||||
...ctx.getStatus(),
|
||||
running: true,
|
||||
connected: true,
|
||||
lastConnectedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
|
||||
ctx.setStatus({
|
||||
...ctx.getStatus(),
|
||||
lastError: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
logoutAccount: async ({ accountId, cfg }) => {
|
||||
const nextCfg = { ...cfg } as OpenClawConfig;
|
||||
const nextQQBot = cfg.channels?.qqbot ? { ...cfg.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 = { ...nextCfg.channels, qqbot: nextQQBot };
|
||||
const runtime = getQQBotRuntime();
|
||||
const configApi = runtime.config as {
|
||||
writeConfigFile: (cfg: OpenClawConfig) => Promise<void>;
|
||||
};
|
||||
await configApi.writeConfigFile(nextCfg);
|
||||
}
|
||||
|
||||
const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, 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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
62
openclaw/extensions/qqbot/src/command-auth.test.ts
Normal file
62
openclaw/extensions/qqbot/src/command-auth.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* 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: commands.allowFrom.qqbot precedence over channel allowFrom is enforced
|
||||
* by the framework's resolveCommandAuthorization(). QQBot routes requireAuth:true
|
||||
* commands through the framework (api.registerCommand), so that behavior is
|
||||
* covered by the framework's own tests rather than duplicated here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qqbotPlugin } from "./channel.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 formatAllowFrom = qqbotPlugin.config.formatAllowFrom!;
|
||||
|
||||
/** Mirrors the fixed gateway.ts inbound commandAuthorized computation. */
|
||||
function resolveInboundCommandAuthorized(rawAllowFrom: string[], senderId: string): boolean {
|
||||
const normalizedAllowFrom = formatAllowFrom({
|
||||
cfg: {} as never,
|
||||
accountId: null,
|
||||
allowFrom: rawAllowFrom,
|
||||
});
|
||||
const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase();
|
||||
const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*");
|
||||
return allowAll || normalizedAllowFrom.includes(normalizedSenderId);
|
||||
}
|
||||
|
||||
it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", () => {
|
||||
expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes when qqbot: prefix is mixed case", () => {
|
||||
expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a sender not in the qqbot:-prefixed allowFrom list", () => {
|
||||
expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).toBe(false);
|
||||
});
|
||||
|
||||
it("authorizes any sender when allowFrom is empty (open)", () => {
|
||||
expect(resolveInboundCommandAuthorized([], "ANYONE")).toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes any sender when allowFrom contains wildcard *", () => {
|
||||
expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).toBe(true);
|
||||
});
|
||||
});
|
||||
4
openclaw/extensions/qqbot/src/config-record-shared.ts
Normal file
4
openclaw/extensions/qqbot/src/config-record-shared.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { asOptionalObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const asRecord = asOptionalObjectRecord;
|
||||
export const readString = readStringField;
|
||||
81
openclaw/extensions/qqbot/src/config-schema.ts
Normal file
81
openclaw/extensions/qqbot/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
AllowFromListSchema,
|
||||
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 QQBotSpeechQueryParamsSchema = z.record(z.string(), z.string()).optional();
|
||||
|
||||
const QQBotTtsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
provider: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
voice: z.string().optional(),
|
||||
authStyle: z.enum(["bearer", "api-key"]).optional(),
|
||||
queryParams: QQBotSpeechQueryParamsSchema,
|
||||
speed: z.number().optional(),
|
||||
})
|
||||
.strict()
|
||||
.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();
|
||||
|
||||
const QQBotStreamingSchema = z
|
||||
.union([
|
||||
z.boolean(),
|
||||
z
|
||||
.object({
|
||||
/** "partial" (default) enables block streaming; "off" disables it. */
|
||||
mode: z.enum(["off", "partial"]).default("partial"),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.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,
|
||||
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,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const QQBotConfigSchema = QQBotAccountSchema.extend({
|
||||
tts: QQBotTtsSchema,
|
||||
stt: QQBotSttSchema,
|
||||
accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
}).passthrough();
|
||||
export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema);
|
||||
293
openclaw/extensions/qqbot/src/config.test.ts
Normal file
293
openclaw/extensions/qqbot/src/config.test.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./channel-config-shared.js";
|
||||
import { QQBotConfigSchema } from "./config-schema.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js";
|
||||
|
||||
describe("qqbot config", () => {
|
||||
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",
|
||||
tts: {
|
||||
provider: "openai",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves top-level media and upgrade config on the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: "secret-value",
|
||||
audioFormatPolicy: {
|
||||
sttDirectFormats: [".wav"],
|
||||
uploadDirectFormats: [".mp3"],
|
||||
transcodeEnabled: false,
|
||||
},
|
||||
urlDirectUpload: false,
|
||||
upgradeUrl: "https://docs.openclaw.ai/channels/qqbot",
|
||||
upgradeMode: "hot-reload",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID);
|
||||
|
||||
expect(resolved.clientSecret).toBe("secret-value");
|
||||
expect(resolved.config.audioFormatPolicy).toEqual({
|
||||
sttDirectFormats: [".wav"],
|
||||
uploadDirectFormats: [".mp3"],
|
||||
transcodeEnabled: false,
|
||||
});
|
||||
expect(resolved.config.urlDirectUpload).toBe(false);
|
||||
expect(resolved.config.upgradeUrl).toBe("https://docs.openclaw.ai/channels/qqbot");
|
||||
expect(resolved.config.upgradeMode).toBe("hot-reload");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
clientSecret: "secret-value",
|
||||
name: "Bot Two",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveQQBotAccount(cfg);
|
||||
|
||||
expect(resolved.accountId).toBe("bot2");
|
||||
expect(resolved.appId).toBe("654321");
|
||||
expect(resolved.clientSecret).toBe("secret-value");
|
||||
expect(resolved.name).toBe("Bot Two");
|
||||
});
|
||||
|
||||
it("rejects unresolved SecretRefs on runtime resolution", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "QQBOT_CLIENT_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow(
|
||||
'channels.qqbot.clientSecret: unresolved SecretRef "env:default:QQBOT_CLIENT_SECRET"',
|
||||
);
|
||||
});
|
||||
|
||||
it("allows unresolved SecretRefs for setup/status flows", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "QQBOT_CLIENT_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID, {
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
|
||||
expect(resolved.clientSecret).toBe("");
|
||||
expect(resolved.secretSource).toBe("config");
|
||||
expect(qqbotConfigAdapter.isConfigured(resolved)).toBe(true);
|
||||
expect(qqbotConfigAdapter.describeAccount(resolved).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 next = qqbotSetupAdapterShared.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).toMatchObject({
|
||||
enabled: true,
|
||||
appId: "102905186",
|
||||
clientSecret: "Oi2Mg1Mh2Ni3:Pl7TpBXuHe1OmAYwKi7W",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed --token in shared setup config", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
|
||||
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(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves the --use-env add flow in shared setup config", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
|
||||
const input = { useEnv: true, name: "Env Bot" };
|
||||
|
||||
expect(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
allowFrom: ["*"],
|
||||
name: "Env Bot",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when runtime setup accountId is omitted", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
|
||||
expect(
|
||||
runtimeSetup.resolveAccountId?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: { appId: "123456" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).toBe("bot2");
|
||||
});
|
||||
|
||||
it("rejects --use-env for named accounts in shared setup config", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
|
||||
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(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
221
openclaw/extensions/qqbot/src/config.ts
Normal file
221
openclaw/extensions/qqbot/src/config.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
||||
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||
accounts?: Record<string, QQBotAccountConfig>;
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
function normalizeConfiguredDefaultAccountId(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeQQBotAccountConfig(account: QQBotAccountConfig | undefined): QQBotAccountConfig {
|
||||
if (!account) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...account,
|
||||
...(account.audioFormatPolicy ? { audioFormatPolicy: { ...account.audioFormatPolicy } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAppId(raw: unknown): string {
|
||||
if (typeof raw === "string") {
|
||||
return raw.trim();
|
||||
}
|
||||
if (typeof raw === "number") {
|
||||
return String(raw);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** List all configured QQBot account IDs. */
|
||||
export function listQQBotAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
|
||||
if (qqbot?.appId || process.env.QQBOT_APP_ID) {
|
||||
ids.add(DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
if (qqbot?.accounts) {
|
||||
for (const accountId of Object.keys(qqbot.accounts)) {
|
||||
if (qqbot.accounts[accountId]?.appId) {
|
||||
ids.add(accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
/** Resolve the default QQBot account ID. */
|
||||
export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
const configuredDefaultAccountId = normalizeConfiguredDefaultAccountId(qqbot?.defaultAccount);
|
||||
if (
|
||||
configuredDefaultAccountId &&
|
||||
(configuredDefaultAccountId === DEFAULT_ACCOUNT_ID ||
|
||||
Boolean(qqbot?.accounts?.[configuredDefaultAccountId]?.appId))
|
||||
) {
|
||||
return configuredDefaultAccountId;
|
||||
}
|
||||
if (qqbot?.appId || process.env.QQBOT_APP_ID) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
if (qqbot?.accounts) {
|
||||
const ids = Object.keys(qqbot.accounts);
|
||||
if (ids.length > 0) {
|
||||
return ids[0];
|
||||
}
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
/** Resolve QQBot account config for runtime or setup flows. */
|
||||
export function resolveQQBotAccount(
|
||||
cfg: OpenClawConfig,
|
||||
accountId?: string | null,
|
||||
opts?: { allowUnresolvedSecretRef?: boolean },
|
||||
): ResolvedQQBotAccount {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultQQBotAccountId(cfg);
|
||||
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
||||
|
||||
let accountConfig: QQBotAccountConfig = {};
|
||||
let appId = "";
|
||||
let clientSecret = "";
|
||||
let secretSource: "config" | "file" | "env" | "none" = "none";
|
||||
|
||||
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
// Default account reads from top-level config and keeps the full field surface.
|
||||
accountConfig = normalizeQQBotAccountConfig(qqbot);
|
||||
appId = normalizeAppId(qqbot?.appId);
|
||||
} else {
|
||||
// Named accounts read from channels.qqbot.accounts.
|
||||
const account = qqbot?.accounts?.[resolvedAccountId];
|
||||
accountConfig = normalizeQQBotAccountConfig(account);
|
||||
appId = normalizeAppId(account?.appId);
|
||||
}
|
||||
|
||||
const clientSecretPath =
|
||||
resolvedAccountId === DEFAULT_ACCOUNT_ID
|
||||
? "channels.qqbot.clientSecret"
|
||||
: `channels.qqbot.accounts.${resolvedAccountId}.clientSecret`;
|
||||
|
||||
// Resolve clientSecret from config, file, or environment.
|
||||
if (hasConfiguredSecretInput(accountConfig.clientSecret)) {
|
||||
clientSecret = opts?.allowUnresolvedSecretRef
|
||||
? (normalizeSecretInputString(accountConfig.clientSecret) ?? "")
|
||||
: (normalizeResolvedSecretInputString({
|
||||
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 && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
||||
secretSource = "env";
|
||||
}
|
||||
|
||||
// AppId can also fall back to an environment variable.
|
||||
if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
appId = normalizeAppId(process.env.QQBOT_APP_ID);
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: resolvedAccountId,
|
||||
name: accountConfig.name,
|
||||
enabled: accountConfig.enabled !== false,
|
||||
appId,
|
||||
clientSecret,
|
||||
secretSource,
|
||||
systemPrompt: accountConfig.systemPrompt,
|
||||
markdownSupport: accountConfig.markdownSupport !== false,
|
||||
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 {
|
||||
const next = { ...cfg };
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
// Default allowFrom to ["*"] when not yet configured.
|
||||
const existingConfig = (next.channels?.qqbot as QQBotChannelConfig) || {};
|
||||
const allowFrom = existingConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> | undefined),
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret, clientSecretFile: undefined }
|
||||
: input.clientSecretFile
|
||||
? { clientSecretFile: input.clientSecretFile, clientSecret: undefined }
|
||||
: {}),
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Default allowFrom to ["*"] when not yet configured.
|
||||
const existingAccountConfig =
|
||||
(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
|
||||
const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> | undefined),
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts,
|
||||
[accountId]: {
|
||||
...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret, clientSecretFile: undefined }
|
||||
: input.clientSecretFile
|
||||
? { clientSecretFile: input.clientSecretFile, clientSecret: undefined }
|
||||
: {}),
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
1530
openclaw/extensions/qqbot/src/gateway.ts
Normal file
1530
openclaw/extensions/qqbot/src/gateway.ts
Normal file
File diff suppressed because it is too large
Load diff
372
openclaw/extensions/qqbot/src/inbound-attachments.ts
Normal file
372
openclaw/extensions/qqbot/src/inbound-attachments.ts
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { transcribeAudio, resolveSTTConfig } from "./stt.js";
|
||||
import { convertSilkToWav, isVoiceAttachment, formatDuration } from "./utils/audio-convert.js";
|
||||
import { downloadFile } from "./utils/file-utils.js";
|
||||
import { getQQBotMediaDir } from "./utils/platform.js";
|
||||
|
||||
export interface RawAttachment {
|
||||
content_type: string;
|
||||
url: string;
|
||||
filename?: string;
|
||||
voice_wav_url?: string;
|
||||
asr_refer_text?: string;
|
||||
}
|
||||
|
||||
export type TranscriptSource = "stt" | "asr" | "fallback";
|
||||
|
||||
/** Normalized attachment output consumed by the gateway. */
|
||||
export interface ProcessedAttachments {
|
||||
attachmentInfo: string;
|
||||
imageUrls: string[];
|
||||
imageMediaTypes: string[];
|
||||
voiceAttachmentPaths: string[];
|
||||
voiceAttachmentUrls: string[];
|
||||
voiceAsrReferTexts: string[];
|
||||
voiceTranscripts: string[];
|
||||
voiceTranscriptSources: TranscriptSource[];
|
||||
attachmentLocalPaths: Array<string | null>;
|
||||
}
|
||||
|
||||
interface ProcessContext {
|
||||
accountId: string;
|
||||
cfg: unknown;
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: ProcessedAttachments = {
|
||||
attachmentInfo: "",
|
||||
imageUrls: [],
|
||||
imageMediaTypes: [],
|
||||
voiceAttachmentPaths: [],
|
||||
voiceAttachmentUrls: [],
|
||||
voiceAsrReferTexts: [],
|
||||
voiceTranscripts: [],
|
||||
voiceTranscriptSources: [],
|
||||
attachmentLocalPaths: [],
|
||||
};
|
||||
|
||||
/** Download, convert, transcribe, and classify inbound attachments. */
|
||||
export async function processAttachments(
|
||||
attachments: RawAttachment[] | undefined,
|
||||
ctx: ProcessContext,
|
||||
): Promise<ProcessedAttachments> {
|
||||
if (!attachments?.length) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const { accountId, cfg, log } = ctx;
|
||||
const downloadDir = getQQBotMediaDir("downloads");
|
||||
const prefix = `[qqbot:${accountId}]`;
|
||||
|
||||
const imageUrls: string[] = [];
|
||||
const imageMediaTypes: string[] = [];
|
||||
const voiceAttachmentPaths: string[] = [];
|
||||
const voiceAttachmentUrls: string[] = [];
|
||||
const voiceAsrReferTexts: string[] = [];
|
||||
const voiceTranscripts: string[] = [];
|
||||
const voiceTranscriptSources: TranscriptSource[] = [];
|
||||
const attachmentLocalPaths: Array<string | null> = [];
|
||||
const otherAttachments: string[] = [];
|
||||
|
||||
// Phase 1: download all attachments in parallel.
|
||||
const downloadTasks = attachments.map(async (att) => {
|
||||
const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
|
||||
const isVoice = isVoiceAttachment(att);
|
||||
const wavUrl =
|
||||
isVoice && att.voice_wav_url
|
||||
? att.voice_wav_url.startsWith("//")
|
||||
? `https:${att.voice_wav_url}`
|
||||
: att.voice_wav_url
|
||||
: "";
|
||||
|
||||
let localPath: string | null = null;
|
||||
let audioPath: string | null = null;
|
||||
|
||||
if (isVoice && wavUrl) {
|
||||
const wavLocalPath = await downloadFile(wavUrl, downloadDir);
|
||||
if (wavLocalPath) {
|
||||
localPath = wavLocalPath;
|
||||
audioPath = wavLocalPath;
|
||||
log?.info(
|
||||
`${prefix} Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`,
|
||||
);
|
||||
} else {
|
||||
log?.error(`${prefix} Failed to download voice_wav_url, falling back to original URL`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!localPath) {
|
||||
localPath = await downloadFile(attUrl, downloadDir, att.filename);
|
||||
}
|
||||
|
||||
return { att, attUrl, isVoice, localPath, audioPath };
|
||||
});
|
||||
|
||||
const downloadResults = await Promise.all(downloadTasks);
|
||||
|
||||
// Phase 2: convert/transcribe voice attachments and classify everything else.
|
||||
const processTasks = downloadResults.map(
|
||||
async ({ att, attUrl, isVoice, localPath, audioPath }) => {
|
||||
const asrReferText = normalizeOptionalString(att.asr_refer_text) ?? "";
|
||||
const wavUrl =
|
||||
isVoice && att.voice_wav_url
|
||||
? att.voice_wav_url.startsWith("//")
|
||||
? `https:${att.voice_wav_url}`
|
||||
: att.voice_wav_url
|
||||
: "";
|
||||
const voiceSourceUrl = wavUrl || attUrl;
|
||||
|
||||
const meta = {
|
||||
voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : undefined,
|
||||
asrReferText: isVoice && asrReferText ? asrReferText : undefined,
|
||||
};
|
||||
|
||||
if (localPath) {
|
||||
if (att.content_type?.startsWith("image/")) {
|
||||
log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
|
||||
return { localPath, type: "image" as const, contentType: att.content_type, meta };
|
||||
} else if (isVoice) {
|
||||
log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
|
||||
return processVoiceAttachment(
|
||||
localPath,
|
||||
audioPath,
|
||||
att,
|
||||
asrReferText,
|
||||
cfg,
|
||||
downloadDir,
|
||||
log,
|
||||
prefix,
|
||||
);
|
||||
} else {
|
||||
log?.info(`${prefix} Downloaded attachment to: ${localPath}`);
|
||||
return { localPath, type: "other" as const, filename: att.filename, meta };
|
||||
}
|
||||
} else {
|
||||
log?.error(`${prefix} Failed to download: ${attUrl}`);
|
||||
if (att.content_type?.startsWith("image/")) {
|
||||
return {
|
||||
localPath: null,
|
||||
type: "image-fallback" as const,
|
||||
attUrl,
|
||||
contentType: att.content_type,
|
||||
meta,
|
||||
};
|
||||
} else if (isVoice && asrReferText) {
|
||||
log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`);
|
||||
return {
|
||||
localPath: null,
|
||||
type: "voice-fallback" as const,
|
||||
transcript: asrReferText,
|
||||
meta,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
localPath: null,
|
||||
type: "other-fallback" as const,
|
||||
filename: att.filename ?? att.content_type,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const processResults = await Promise.all(processTasks);
|
||||
|
||||
// Phase 3: collect results in the original attachment order.
|
||||
for (const result of processResults) {
|
||||
if (result.meta.voiceUrl) {
|
||||
voiceAttachmentUrls.push(result.meta.voiceUrl);
|
||||
}
|
||||
if (result.meta.asrReferText) {
|
||||
voiceAsrReferTexts.push(result.meta.asrReferText);
|
||||
}
|
||||
|
||||
if (result.type === "image" && result.localPath) {
|
||||
imageUrls.push(result.localPath);
|
||||
imageMediaTypes.push(result.contentType);
|
||||
attachmentLocalPaths.push(result.localPath);
|
||||
} else if (result.type === "voice" && result.localPath) {
|
||||
voiceAttachmentPaths.push(result.localPath);
|
||||
voiceTranscripts.push(result.transcript);
|
||||
voiceTranscriptSources.push(result.transcriptSource);
|
||||
attachmentLocalPaths.push(result.localPath);
|
||||
} else if (result.type === "other" && result.localPath) {
|
||||
otherAttachments.push(`[Attachment: ${result.localPath}]`);
|
||||
attachmentLocalPaths.push(result.localPath);
|
||||
} else if (result.type === "image-fallback") {
|
||||
imageUrls.push(result.attUrl);
|
||||
imageMediaTypes.push(result.contentType);
|
||||
attachmentLocalPaths.push(null);
|
||||
} else if (result.type === "voice-fallback") {
|
||||
voiceTranscripts.push(result.transcript);
|
||||
voiceTranscriptSources.push("asr");
|
||||
attachmentLocalPaths.push(null);
|
||||
} else if (result.type === "other-fallback") {
|
||||
otherAttachments.push(`[Attachment: ${result.filename}] (download failed)`);
|
||||
attachmentLocalPaths.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
const attachmentInfo = otherAttachments.length > 0 ? "\n" + otherAttachments.join("\n") : "";
|
||||
|
||||
return {
|
||||
attachmentInfo,
|
||||
imageUrls,
|
||||
imageMediaTypes,
|
||||
voiceAttachmentPaths,
|
||||
voiceAttachmentUrls,
|
||||
voiceAsrReferTexts,
|
||||
voiceTranscripts,
|
||||
voiceTranscriptSources,
|
||||
attachmentLocalPaths,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format voice transcripts into user-visible text. */
|
||||
export function formatVoiceText(transcripts: string[]): string {
|
||||
if (transcripts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return transcripts.length === 1
|
||||
? `[Voice message] ${transcripts[0]}`
|
||||
: transcripts.map((t, i) => `[Voice ${i + 1}] ${t}`).join("\n");
|
||||
}
|
||||
|
||||
// Internal helpers.
|
||||
|
||||
type VoiceResult =
|
||||
| {
|
||||
localPath: string;
|
||||
type: "voice";
|
||||
transcript: string;
|
||||
transcriptSource: TranscriptSource;
|
||||
meta: { voiceUrl?: string; asrReferText?: string };
|
||||
}
|
||||
| {
|
||||
localPath: string;
|
||||
type: "voice";
|
||||
transcript: string;
|
||||
transcriptSource: TranscriptSource;
|
||||
meta: { voiceUrl?: string; asrReferText?: string };
|
||||
};
|
||||
|
||||
async function processVoiceAttachment(
|
||||
localPath: string,
|
||||
audioPath: string | null,
|
||||
att: RawAttachment,
|
||||
asrReferText: string,
|
||||
cfg: unknown,
|
||||
downloadDir: string,
|
||||
log: ProcessContext["log"],
|
||||
prefix: string,
|
||||
): Promise<VoiceResult> {
|
||||
const wavUrl = att.voice_wav_url
|
||||
? att.voice_wav_url.startsWith("//")
|
||||
? `https:${att.voice_wav_url}`
|
||||
: att.voice_wav_url
|
||||
: "";
|
||||
const attUrl = att.url?.startsWith("//") ? `https:${att.url}` : att.url;
|
||||
const voiceSourceUrl = wavUrl || attUrl;
|
||||
const meta = {
|
||||
voiceUrl: voiceSourceUrl || undefined,
|
||||
asrReferText: asrReferText || undefined,
|
||||
};
|
||||
|
||||
const sttCfg = resolveSTTConfig(cfg as Record<string, unknown>);
|
||||
if (!sttCfg) {
|
||||
if (asrReferText) {
|
||||
log?.info(
|
||||
`${prefix} Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`,
|
||||
);
|
||||
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
||||
}
|
||||
log?.info(
|
||||
`${prefix} Voice attachment: ${att.filename} (STT not configured, skipping transcription)`,
|
||||
);
|
||||
return {
|
||||
localPath,
|
||||
type: "voice",
|
||||
transcript: "[Voice message - transcription unavailable because STT is not configured]",
|
||||
transcriptSource: "fallback",
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert SILK input to WAV before STT when necessary.
|
||||
if (!audioPath) {
|
||||
log?.info(`${prefix} Voice attachment: ${att.filename}, converting SILK→WAV...`);
|
||||
try {
|
||||
const wavResult = await convertSilkToWav(localPath, downloadDir);
|
||||
if (wavResult) {
|
||||
audioPath = wavResult.wavPath;
|
||||
log?.info(
|
||||
`${prefix} Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`,
|
||||
);
|
||||
} else {
|
||||
audioPath = localPath;
|
||||
}
|
||||
} catch (convertErr) {
|
||||
log?.error(
|
||||
`${prefix} Voice conversion failed: ${
|
||||
convertErr instanceof Error ? convertErr.message : JSON.stringify(convertErr)
|
||||
}`,
|
||||
);
|
||||
if (asrReferText) {
|
||||
return {
|
||||
localPath,
|
||||
type: "voice",
|
||||
transcript: asrReferText,
|
||||
transcriptSource: "asr",
|
||||
meta,
|
||||
};
|
||||
}
|
||||
return {
|
||||
localPath,
|
||||
type: "voice",
|
||||
transcript: "[Voice message - format conversion failed]",
|
||||
transcriptSource: "fallback",
|
||||
meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Run speech-to-text on the prepared audio file.
|
||||
try {
|
||||
const transcript = await transcribeAudio(audioPath, cfg as Record<string, unknown>);
|
||||
if (transcript) {
|
||||
log?.info(`${prefix} STT transcript: ${transcript.slice(0, 100)}...`);
|
||||
return { localPath, type: "voice", transcript, transcriptSource: "stt", meta };
|
||||
}
|
||||
if (asrReferText) {
|
||||
log?.info(`${prefix} STT returned empty result, using asr_refer_text fallback`);
|
||||
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
||||
}
|
||||
log?.info(`${prefix} STT returned empty result`);
|
||||
return {
|
||||
localPath,
|
||||
type: "voice",
|
||||
transcript: "[Voice message - transcription returned an empty result]",
|
||||
transcriptSource: "fallback",
|
||||
meta,
|
||||
};
|
||||
} catch (sttErr) {
|
||||
log?.error(
|
||||
`${prefix} STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`,
|
||||
);
|
||||
if (asrReferText) {
|
||||
return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta };
|
||||
}
|
||||
return {
|
||||
localPath,
|
||||
type: "voice",
|
||||
transcript: "[Voice message - transcription failed]",
|
||||
transcriptSource: "fallback",
|
||||
meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
280
openclaw/extensions/qqbot/src/known-users.ts
Normal file
280
openclaw/extensions/qqbot/src/known-users.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { debugLog, debugError } from "./utils/debug-log.js";
|
||||
|
||||
/** Persisted record for a user who has interacted with the bot. */
|
||||
export interface KnownUser {
|
||||
openid: string;
|
||||
type: "c2c" | "group";
|
||||
nickname?: string;
|
||||
groupOpenid?: string;
|
||||
accountId: string;
|
||||
firstSeenAt: number;
|
||||
lastSeenAt: number;
|
||||
interactionCount: number;
|
||||
}
|
||||
|
||||
import { getQQBotDataDir } from "./utils/platform.js";
|
||||
|
||||
const KNOWN_USERS_DIR = getQQBotDataDir("data");
|
||||
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
|
||||
|
||||
let usersCache: Map<string, KnownUser> | null = null;
|
||||
|
||||
const SAVE_THROTTLE_MS = 5000;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isDirty = false;
|
||||
|
||||
/** Ensure the data directory exists. */
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(KNOWN_USERS_DIR)) {
|
||||
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Load persisted users into the in-memory cache. */
|
||||
function loadUsersFromFile(): Map<string, KnownUser> {
|
||||
if (usersCache !== null) {
|
||||
return usersCache;
|
||||
}
|
||||
|
||||
usersCache = new Map();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||
const users = JSON.parse(data) as KnownUser[];
|
||||
|
||||
for (const user of users) {
|
||||
const key = makeUserKey(user);
|
||||
usersCache.set(key, user);
|
||||
}
|
||||
|
||||
debugLog(`[known-users] Loaded ${usersCache.size} users`);
|
||||
}
|
||||
} catch (err) {
|
||||
debugError(`[known-users] Failed to load users: ${String(err)}`);
|
||||
usersCache = new Map();
|
||||
}
|
||||
|
||||
return usersCache;
|
||||
}
|
||||
|
||||
/** Schedule a throttled write to disk. */
|
||||
function saveUsersToFile(): void {
|
||||
if (!isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
doSaveUsersToFile();
|
||||
}, SAVE_THROTTLE_MS);
|
||||
}
|
||||
|
||||
/** Perform the actual write to disk. */
|
||||
function doSaveUsersToFile(): void {
|
||||
if (!usersCache || !isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const users = Array.from(usersCache.values());
|
||||
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
|
||||
isDirty = false;
|
||||
} catch (err) {
|
||||
debugError(`[known-users] Failed to save users: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Flush pending writes immediately, typically during shutdown. */
|
||||
export function flushKnownUsers(): void {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
doSaveUsersToFile();
|
||||
}
|
||||
|
||||
/** Build a stable composite key for one user record. */
|
||||
function makeUserKey(user: Partial<KnownUser>): string {
|
||||
const base = `${user.accountId}:${user.type}:${user.openid}`;
|
||||
if (user.type === "group" && user.groupOpenid) {
|
||||
return `${base}:${user.groupOpenid}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Record a known user whenever a message is received. */
|
||||
export function recordKnownUser(user: {
|
||||
openid: string;
|
||||
type: "c2c" | "group";
|
||||
nickname?: string;
|
||||
groupOpenid?: string;
|
||||
accountId: string;
|
||||
}): void {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey(user);
|
||||
const now = Date.now();
|
||||
|
||||
const existing = cache.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.lastSeenAt = now;
|
||||
existing.interactionCount++;
|
||||
if (user.nickname && user.nickname !== existing.nickname) {
|
||||
existing.nickname = user.nickname;
|
||||
}
|
||||
} else {
|
||||
const newUser: KnownUser = {
|
||||
openid: user.openid,
|
||||
type: user.type,
|
||||
nickname: user.nickname,
|
||||
groupOpenid: user.groupOpenid,
|
||||
accountId: user.accountId,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
interactionCount: 1,
|
||||
};
|
||||
cache.set(key, newUser);
|
||||
debugLog(`[known-users] New user: ${user.openid} (${user.type})`);
|
||||
}
|
||||
|
||||
isDirty = true;
|
||||
saveUsersToFile();
|
||||
}
|
||||
|
||||
/** Look up one known user. */
|
||||
export function getKnownUser(
|
||||
accountId: string,
|
||||
openid: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
groupOpenid?: string,
|
||||
): KnownUser | undefined {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
/** List known users with optional filtering and sorting. */
|
||||
export function listKnownUsers(options?: {
|
||||
accountId?: string;
|
||||
type?: "c2c" | "group";
|
||||
activeWithin?: number;
|
||||
limit?: number;
|
||||
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
|
||||
sortOrder?: "asc" | "desc";
|
||||
}): KnownUser[] {
|
||||
const cache = loadUsersFromFile();
|
||||
let users = Array.from(cache.values());
|
||||
|
||||
if (options?.accountId) {
|
||||
users = users.filter((u) => u.accountId === options.accountId);
|
||||
}
|
||||
if (options?.type) {
|
||||
users = users.filter((u) => u.type === options.type);
|
||||
}
|
||||
if (options?.activeWithin) {
|
||||
const cutoff = Date.now() - options.activeWithin;
|
||||
users = users.filter((u) => u.lastSeenAt >= cutoff);
|
||||
}
|
||||
|
||||
const sortBy = options?.sortBy ?? "lastSeenAt";
|
||||
const sortOrder = options?.sortOrder ?? "desc";
|
||||
users.sort((a, b) => {
|
||||
const aVal = a[sortBy] ?? 0;
|
||||
const bVal = b[sortBy] ?? 0;
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
if (options?.limit && options.limit > 0) {
|
||||
users = users.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/** Return summary stats for known users. */
|
||||
export function getKnownUsersStats(accountId?: string): {
|
||||
totalUsers: number;
|
||||
c2cUsers: number;
|
||||
groupUsers: number;
|
||||
activeIn24h: number;
|
||||
activeIn7d: number;
|
||||
} {
|
||||
let users = listKnownUsers({ accountId });
|
||||
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
totalUsers: users.length,
|
||||
c2cUsers: users.filter((u) => u.type === "c2c").length,
|
||||
groupUsers: users.filter((u) => u.type === "group").length,
|
||||
activeIn24h: users.filter((u) => now - u.lastSeenAt < day).length,
|
||||
activeIn7d: users.filter((u) => now - u.lastSeenAt < 7 * day).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove one user record. */
|
||||
export function removeKnownUser(
|
||||
accountId: string,
|
||||
openid: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
groupOpenid?: string,
|
||||
): boolean {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||
|
||||
if (cache.has(key)) {
|
||||
cache.delete(key);
|
||||
isDirty = true;
|
||||
saveUsersToFile();
|
||||
debugLog(`[known-users] Removed user ${openid}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Clear all user records, optionally scoped to one account. */
|
||||
export function clearKnownUsers(accountId?: string): number {
|
||||
const cache = loadUsersFromFile();
|
||||
let count = 0;
|
||||
|
||||
if (accountId) {
|
||||
for (const [key, user] of cache.entries()) {
|
||||
if (user.accountId === accountId) {
|
||||
cache.delete(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
count = cache.size;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
isDirty = true;
|
||||
doSaveUsersToFile();
|
||||
debugLog(`[known-users] Cleared ${count} users`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Return all groups in which a user has interacted. */
|
||||
export function getUserGroups(accountId: string, openid: string): string[] {
|
||||
const users = listKnownUsers({ accountId, type: "group" });
|
||||
return users.filter((u) => u.openid === openid && u.groupOpenid).map((u) => u.groupOpenid!);
|
||||
}
|
||||
|
||||
/** Return all recorded members for one group. */
|
||||
export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] {
|
||||
return listKnownUsers({ accountId, type: "group" }).filter((u) => u.groupOpenid === groupOpenid);
|
||||
}
|
||||
56
openclaw/extensions/qqbot/src/manifest-schema.test.ts
Normal file
56
openclaw/extensions/qqbot/src/manifest-schema.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
const manifestConfigSchemaCacheKey = "qqbot.manifest.config-schema";
|
||||
|
||||
describe("qqbot manifest schema", () => {
|
||||
it("accepts top-level speech overrides", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: manifestConfigSchemaCacheKey,
|
||||
value: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "tts-key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
authStyle: "api-key",
|
||||
queryParams: {
|
||||
format: "wav",
|
||||
},
|
||||
speed: 1.1,
|
||||
},
|
||||
stt: {
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "stt-key",
|
||||
model: "whisper-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts defaultAccount", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: manifestConfigSchemaCacheKey,
|
||||
value: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
203
openclaw/extensions/qqbot/src/message-queue.ts
Normal file
203
openclaw/extensions/qqbot/src/message-queue.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import type { QueueSnapshot } from "./slash-commands.js";
|
||||
|
||||
// Message queue limits.
|
||||
const MESSAGE_QUEUE_SIZE = 1000;
|
||||
const PER_USER_QUEUE_SIZE = 20;
|
||||
const MAX_CONCURRENT_USERS = 10;
|
||||
|
||||
/**
|
||||
* Queue item used for asynchronous message handling without blocking heartbeats.
|
||||
*/
|
||||
export interface QueuedMessage {
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
content: string;
|
||||
messageId: string;
|
||||
timestamp: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
groupOpenid?: string;
|
||||
attachments?: Array<{
|
||||
content_type: string;
|
||||
url: string;
|
||||
filename?: string;
|
||||
voice_wav_url?: string;
|
||||
asr_refer_text?: string;
|
||||
}>;
|
||||
/** refIdx of the quoted message. */
|
||||
refMsgIdx?: string;
|
||||
/** refIdx assigned to this message for future quoting. */
|
||||
msgIdx?: string;
|
||||
}
|
||||
|
||||
export interface MessageQueueContext {
|
||||
accountId: string;
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
/** Abort-state probe supplied by the caller. */
|
||||
isAborted: () => boolean;
|
||||
}
|
||||
|
||||
export interface MessageQueue {
|
||||
enqueue: (msg: QueuedMessage) => void;
|
||||
startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise<void>) => void;
|
||||
getSnapshot: (senderPeerId: string) => QueueSnapshot;
|
||||
getMessagePeerId: (msg: QueuedMessage) => string;
|
||||
/** Clear a user's queued messages and return how many were dropped. */
|
||||
clearUserQueue: (peerId: string) => number;
|
||||
/** Execute one message immediately, bypassing the queue for urgent commands. */
|
||||
executeImmediate: (msg: QueuedMessage) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-user concurrent queue.
|
||||
* Messages are serialized per user and processed in parallel across users.
|
||||
*/
|
||||
export function createMessageQueue(ctx: MessageQueueContext): MessageQueue {
|
||||
const { accountId, log } = ctx;
|
||||
|
||||
const userQueues = new Map<string, QueuedMessage[]>();
|
||||
const activeUsers = new Set<string>();
|
||||
let messagesProcessed = 0;
|
||||
let handleMessageFnRef: ((msg: QueuedMessage) => Promise<void>) | null = null;
|
||||
let totalEnqueued = 0;
|
||||
|
||||
const getMessagePeerId = (msg: QueuedMessage): string => {
|
||||
if (msg.type === "guild") {
|
||||
return `guild:${msg.channelId ?? "unknown"}`;
|
||||
}
|
||||
if (msg.type === "group") {
|
||||
return `group:${msg.groupOpenid ?? "unknown"}`;
|
||||
}
|
||||
return `dm:${msg.senderId}`;
|
||||
};
|
||||
|
||||
const drainUserQueue = async (peerId: string): Promise<void> => {
|
||||
if (activeUsers.has(peerId)) {
|
||||
return;
|
||||
}
|
||||
if (activeUsers.size >= MAX_CONCURRENT_USERS) {
|
||||
log?.info(
|
||||
`[qqbot:${accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = userQueues.get(peerId);
|
||||
if (!queue || queue.length === 0) {
|
||||
userQueues.delete(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
activeUsers.add(peerId);
|
||||
|
||||
try {
|
||||
while (queue.length > 0 && !ctx.isAborted()) {
|
||||
const msg = queue.shift()!;
|
||||
totalEnqueued = Math.max(0, totalEnqueued - 1);
|
||||
try {
|
||||
if (handleMessageFnRef) {
|
||||
await handleMessageFnRef(msg);
|
||||
messagesProcessed++;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
activeUsers.delete(peerId);
|
||||
userQueues.delete(peerId);
|
||||
for (const [waitingPeerId, waitingQueue] of userQueues) {
|
||||
if (activeUsers.size >= MAX_CONCURRENT_USERS) {
|
||||
break;
|
||||
}
|
||||
if (waitingQueue.length > 0 && !activeUsers.has(waitingPeerId)) {
|
||||
void drainUserQueue(waitingPeerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const enqueue = (msg: QueuedMessage): void => {
|
||||
const peerId = getMessagePeerId(msg);
|
||||
let queue = userQueues.get(peerId);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
userQueues.set(peerId, queue);
|
||||
}
|
||||
|
||||
if (queue.length >= PER_USER_QUEUE_SIZE) {
|
||||
const dropped = queue.shift();
|
||||
log?.error(
|
||||
`[qqbot:${accountId}] Per-user queue full for ${peerId}, dropping oldest message ${dropped?.messageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
totalEnqueued++;
|
||||
if (totalEnqueued > MESSAGE_QUEUE_SIZE) {
|
||||
log?.error(
|
||||
`[qqbot:${accountId}] Global queue limit reached (${totalEnqueued}), message from ${peerId} may be delayed`,
|
||||
);
|
||||
}
|
||||
|
||||
queue.push(msg);
|
||||
log?.debug?.(
|
||||
`[qqbot:${accountId}] Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`,
|
||||
);
|
||||
|
||||
void drainUserQueue(peerId);
|
||||
};
|
||||
|
||||
const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
|
||||
handleMessageFnRef = handleMessageFn;
|
||||
log?.info(
|
||||
`[qqbot:${accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`,
|
||||
);
|
||||
};
|
||||
|
||||
const getSnapshot = (senderPeerId: string): QueueSnapshot => {
|
||||
let totalPending = 0;
|
||||
for (const [, q] of userQueues) {
|
||||
totalPending += q.length;
|
||||
}
|
||||
const senderQueue = userQueues.get(senderPeerId);
|
||||
return {
|
||||
totalPending,
|
||||
activeUsers: activeUsers.size,
|
||||
maxConcurrentUsers: MAX_CONCURRENT_USERS,
|
||||
senderPending: senderQueue ? senderQueue.length : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const clearUserQueue = (peerId: string): number => {
|
||||
const queue = userQueues.get(peerId);
|
||||
if (!queue || queue.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const droppedCount = queue.length;
|
||||
queue.length = 0;
|
||||
totalEnqueued = Math.max(0, totalEnqueued - droppedCount);
|
||||
return droppedCount;
|
||||
};
|
||||
|
||||
const executeImmediate = (msg: QueuedMessage): void => {
|
||||
if (handleMessageFnRef) {
|
||||
handleMessageFnRef(msg).catch((err) => {
|
||||
log?.error(`[qqbot:${accountId}] Immediate execution error: ${err}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue,
|
||||
startProcessor,
|
||||
getSnapshot,
|
||||
getMessagePeerId,
|
||||
clearUserQueue,
|
||||
executeImmediate,
|
||||
};
|
||||
}
|
||||
228
openclaw/extensions/qqbot/src/outbound-deliver.test.ts
Normal file
228
openclaw/extensions/qqbot/src/outbound-deliver.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
sendC2CMessage: vi.fn(),
|
||||
sendDmMessage: vi.fn(),
|
||||
sendGroupMessage: vi.fn(),
|
||||
sendChannelMessage: vi.fn(),
|
||||
sendC2CImageMessage: vi.fn(),
|
||||
sendGroupImageMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const outboundMocks = vi.hoisted(() => ({
|
||||
sendPhoto: vi.fn(async () => ({})),
|
||||
sendVoice: vi.fn(async () => ({})),
|
||||
sendVideoMsg: vi.fn(async () => ({})),
|
||||
sendDocument: vi.fn(async () => ({})),
|
||||
sendMedia: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
chunkMarkdownText: vi.fn((text: string) => [text]),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendC2CMessage: apiMocks.sendC2CMessage,
|
||||
sendDmMessage: apiMocks.sendDmMessage,
|
||||
sendGroupMessage: apiMocks.sendGroupMessage,
|
||||
sendChannelMessage: apiMocks.sendChannelMessage,
|
||||
sendC2CImageMessage: apiMocks.sendC2CImageMessage,
|
||||
sendGroupImageMessage: apiMocks.sendGroupImageMessage,
|
||||
}));
|
||||
|
||||
vi.mock("./outbound.js", () => ({
|
||||
sendPhoto: outboundMocks.sendPhoto,
|
||||
sendVoice: outboundMocks.sendVoice,
|
||||
sendVideoMsg: outboundMocks.sendVideoMsg,
|
||||
sendDocument: outboundMocks.sendDocument,
|
||||
sendMedia: outboundMocks.sendMedia,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getQQBotRuntime: () => ({
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: runtimeMocks.chunkMarkdownText,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const imageSizeMocks = vi.hoisted(() => ({
|
||||
getImageSize: vi.fn(),
|
||||
formatQQBotMarkdownImage: vi.fn(),
|
||||
hasQQBotImageSize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/image-size.js", () => ({
|
||||
getImageSize: (...args: unknown[]) => imageSizeMocks.getImageSize(...args),
|
||||
formatQQBotMarkdownImage: (...args: unknown[]) =>
|
||||
imageSizeMocks.formatQQBotMarkdownImage(...args),
|
||||
hasQQBotImageSize: (...args: unknown[]) => imageSizeMocks.hasQQBotImageSize(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
parseAndSendMediaTags,
|
||||
sendPlainReply,
|
||||
type ConsumeQuoteRefFn,
|
||||
type DeliverAccountContext,
|
||||
type DeliverEventContext,
|
||||
type SendWithRetryFn,
|
||||
} from "./outbound-deliver.js";
|
||||
|
||||
function buildEvent(): DeliverEventContext {
|
||||
return {
|
||||
type: "c2c",
|
||||
senderId: "user-1",
|
||||
messageId: "msg-1",
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountContext(markdownSupport: boolean): DeliverAccountContext {
|
||||
return {
|
||||
qualifiedTarget: "qqbot:c2c:user-1",
|
||||
account: {
|
||||
accountId: "default",
|
||||
appId: "app-id",
|
||||
clientSecret: "secret",
|
||||
markdownSupport,
|
||||
config: {},
|
||||
} as DeliverAccountContext["account"],
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const sendWithRetry: SendWithRetryFn = async (sendFn) => await sendFn("token");
|
||||
const consumeQuoteRef: ConsumeQuoteRefFn = () => undefined;
|
||||
|
||||
describe("qqbot outbound deliver", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.chunkMarkdownText.mockImplementation((text: string) => [text]);
|
||||
imageSizeMocks.getImageSize.mockResolvedValue(null);
|
||||
imageSizeMocks.formatQQBotMarkdownImage.mockImplementation((url: string) => ``);
|
||||
imageSizeMocks.hasQQBotImageSize.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("sends plain replies through the shared text chunk sender", async () => {
|
||||
await sendPlainReply(
|
||||
{},
|
||||
"hello plain world",
|
||||
buildEvent(),
|
||||
buildAccountContext(false),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenCalledWith(
|
||||
"app-id",
|
||||
"token",
|
||||
"user-1",
|
||||
"hello plain world",
|
||||
"msg-1",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends markdown replies through the shared text chunk sender", async () => {
|
||||
await sendPlainReply(
|
||||
{},
|
||||
"hello markdown world",
|
||||
buildEvent(),
|
||||
buildAccountContext(true),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenCalledWith(
|
||||
"app-id",
|
||||
"token",
|
||||
"user-1",
|
||||
"hello markdown world",
|
||||
"msg-1",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes media-tag text segments through the shared chunk sender", async () => {
|
||||
await parseAndSendMediaTags(
|
||||
"before<qqimg>https://example.com/a.png</qqimg>after",
|
||||
buildEvent(),
|
||||
buildAccountContext(false),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
);
|
||||
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"app-id",
|
||||
"token",
|
||||
"user-1",
|
||||
"before",
|
||||
"msg-1",
|
||||
undefined,
|
||||
);
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"app-id",
|
||||
"token",
|
||||
"user-1",
|
||||
"after",
|
||||
"msg-1",
|
||||
undefined,
|
||||
);
|
||||
expect(outboundMocks.sendPhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("private-network image URL degradation", () => {
|
||||
it("sends markdown reply with fallback dimensions when getImageSize returns null", async () => {
|
||||
imageSizeMocks.getImageSize.mockResolvedValue(null);
|
||||
|
||||
await sendPlainReply(
|
||||
{},
|
||||
"Look at this: ",
|
||||
buildEvent(),
|
||||
buildAccountContext(true),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
[],
|
||||
);
|
||||
|
||||
// getImageSize was called with the private-network URL
|
||||
expect(imageSizeMocks.getImageSize).toHaveBeenCalledWith("https://10.0.0.1/internal.png");
|
||||
// formatQQBotMarkdownImage was called with null size (triggers default dimensions)
|
||||
expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith(
|
||||
"https://10.0.0.1/internal.png",
|
||||
null,
|
||||
);
|
||||
// Message was still sent (not crashed)
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends markdown reply with fallback when getImageSize throws", async () => {
|
||||
imageSizeMocks.getImageSize.mockRejectedValue(new Error("SSRF blocked"));
|
||||
|
||||
await sendPlainReply(
|
||||
{},
|
||||
"Check ",
|
||||
buildEvent(),
|
||||
buildAccountContext(true),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
[],
|
||||
);
|
||||
|
||||
// formatQQBotMarkdownImage still called with null (catch path in outbound-deliver)
|
||||
expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith(
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
null,
|
||||
);
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
841
openclaw/extensions/qqbot/src/outbound-deliver.ts
Normal file
841
openclaw/extensions/qqbot/src/outbound-deliver.ts
Normal file
|
|
@ -0,0 +1,841 @@
|
|||
/**
|
||||
* Outbound delivery helpers.
|
||||
*
|
||||
* The gateway deliver callback uses two pipelines:
|
||||
* 1. `parseAndSendMediaTags` handles `<qqimg/qqvoice/qqvideo/qqfile/qqmedia>` tags in order.
|
||||
* 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media.
|
||||
*/
|
||||
|
||||
import {
|
||||
sendC2CMessage,
|
||||
sendDmMessage,
|
||||
sendGroupMessage,
|
||||
sendChannelMessage,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
} from "./api.js";
|
||||
import {
|
||||
sendPhoto,
|
||||
sendVoice,
|
||||
sendVideoMsg,
|
||||
sendDocument,
|
||||
sendMedia as sendMediaAuto,
|
||||
type MediaTargetContext,
|
||||
} from "./outbound.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
import { chunkText, TEXT_CHUNK_LIMIT } from "./text-utils.js";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
|
||||
import { normalizeMediaTags } from "./utils/media-tags.js";
|
||||
import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js";
|
||||
import { filterInternalMarkers } from "./utils/text-parsing.js";
|
||||
|
||||
// Type definitions.
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export interface DeliverEventContext {
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
senderId: string;
|
||||
messageId: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
groupOpenid?: string;
|
||||
msgIdx?: string;
|
||||
}
|
||||
|
||||
export interface DeliverAccountContext {
|
||||
account: ResolvedQQBotAccount;
|
||||
qualifiedTarget: string;
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrapper that retries when the access token expires. */
|
||||
export type SendWithRetryFn = <T>(sendFn: (token: string) => Promise<T>) => Promise<T>;
|
||||
|
||||
/** Consume a quote ref exactly once. */
|
||||
export type ConsumeQuoteRefFn = () => string | undefined;
|
||||
|
||||
function resolveQQBotMediaTargetContext(
|
||||
event: DeliverEventContext,
|
||||
account: ResolvedQQBotAccount,
|
||||
prefix: string,
|
||||
): MediaTargetContext {
|
||||
return {
|
||||
targetType:
|
||||
event.type === "c2c"
|
||||
? "c2c"
|
||||
: event.type === "group"
|
||||
? "group"
|
||||
: event.type === "dm"
|
||||
? "dm"
|
||||
: "channel",
|
||||
targetId:
|
||||
event.type === "c2c"
|
||||
? event.senderId
|
||||
: event.type === "group"
|
||||
? event.groupOpenid!
|
||||
: event.type === "dm"
|
||||
? event.guildId!
|
||||
: event.channelId!,
|
||||
account,
|
||||
replyToId: event.messageId,
|
||||
logPrefix: prefix,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendQQBotAutoMediaBatch(params: {
|
||||
qualifiedTarget: string;
|
||||
account: ResolvedQQBotAccount;
|
||||
replyToId: string;
|
||||
mediaUrls: string[];
|
||||
log?: DeliverAccountContext["log"];
|
||||
onResultError: (mediaUrl: string, error: string) => string;
|
||||
onThrownError: (mediaUrl: string, error: string) => string;
|
||||
onSuccess?: (mediaUrl: string) => string | undefined;
|
||||
}): Promise<void> {
|
||||
for (const mediaUrl of params.mediaUrls) {
|
||||
try {
|
||||
const result = await sendMediaAuto({
|
||||
to: params.qualifiedTarget,
|
||||
text: "",
|
||||
mediaUrl,
|
||||
accountId: params.account.accountId,
|
||||
replyToId: params.replyToId,
|
||||
account: params.account,
|
||||
});
|
||||
if (result.error) {
|
||||
params.log?.error(params.onResultError(mediaUrl, result.error));
|
||||
continue;
|
||||
}
|
||||
const successMessage = params.onSuccess?.(mediaUrl);
|
||||
if (successMessage) {
|
||||
params.log?.info(successMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
params.log?.error(params.onThrownError(mediaUrl, String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Media-tag parsing and delivery.
|
||||
|
||||
/**
|
||||
* Parse media tags from the reply text and send them in order.
|
||||
*
|
||||
* @returns `true` when media tags were found and handled; `false` when the caller
|
||||
* should continue through the plain-text pipeline.
|
||||
*/
|
||||
export async function parseAndSendMediaTags(
|
||||
replyText: string,
|
||||
event: DeliverEventContext,
|
||||
actx: DeliverAccountContext,
|
||||
sendWithRetry: SendWithRetryFn,
|
||||
consumeQuoteRef: ConsumeQuoteRefFn,
|
||||
): Promise<{ handled: boolean; normalizedText: string }> {
|
||||
const { account, log } = actx;
|
||||
const prefix = `[qqbot:${account.accountId}]`;
|
||||
|
||||
// Normalize common malformed tags produced by smaller models.
|
||||
const text = normalizeMediaTags(replyText);
|
||||
|
||||
const mediaTagRegex =
|
||||
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
|
||||
|
||||
if (mediaTagMatches.length === 0) {
|
||||
return { handled: false, normalizedText: text };
|
||||
}
|
||||
|
||||
const tagCounts = mediaTagMatches.reduce(
|
||||
(acc, m) => {
|
||||
const t = normalizeLowercaseStringOrEmpty(m[1]);
|
||||
acc[t] = (acc[t] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
log?.info(
|
||||
`${prefix} Detected media tags: ${Object.entries(tagCounts)
|
||||
.map(([k, v]) => `${v} <${k}>`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
|
||||
// Build a sequential send queue.
|
||||
type QueueItem = {
|
||||
type: "text" | "image" | "voice" | "video" | "file" | "media";
|
||||
content: string;
|
||||
};
|
||||
const sendQueue: QueueItem[] = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
const regex2 =
|
||||
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
||||
let match;
|
||||
|
||||
while ((match = regex2.exec(text)) !== null) {
|
||||
const textBefore = text
|
||||
.slice(lastIndex, match.index)
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
if (textBefore) {
|
||||
sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
|
||||
}
|
||||
|
||||
const tagName = normalizeLowercaseStringOrEmpty(match[1]);
|
||||
let mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log, prefix);
|
||||
|
||||
if (mediaPath) {
|
||||
const typeMap: Record<string, QueueItem["type"]> = {
|
||||
qqmedia: "media",
|
||||
qqvoice: "voice",
|
||||
qqvideo: "video",
|
||||
qqfile: "file",
|
||||
};
|
||||
const itemType = typeMap[tagName] ?? "image";
|
||||
sendQueue.push({ type: itemType, content: mediaPath });
|
||||
log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const textAfter = text
|
||||
.slice(lastIndex)
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
if (textAfter) {
|
||||
sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
|
||||
}
|
||||
|
||||
log?.info(`${prefix} Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
|
||||
|
||||
// Send queue items in order.
|
||||
const mediaTarget = resolveQQBotMediaTargetContext(event, account, prefix);
|
||||
|
||||
for (const item of sendQueue) {
|
||||
if (item.type === "text") {
|
||||
await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef);
|
||||
} else if (item.type === "image") {
|
||||
await sendQQBotPhotoWithLogging({
|
||||
target: mediaTarget,
|
||||
imageUrl: item.content,
|
||||
log,
|
||||
onError: (error) => `${prefix} sendPhoto error: ${error}`,
|
||||
});
|
||||
} else if (item.type === "voice") {
|
||||
await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix);
|
||||
} else if (item.type === "video") {
|
||||
await sendQQBotResultWithLogging({
|
||||
run: async () => await sendVideoMsg(mediaTarget, item.content),
|
||||
log,
|
||||
onError: (error) => `${prefix} sendVideoMsg error: ${error}`,
|
||||
});
|
||||
} else if (item.type === "file") {
|
||||
await sendQQBotResultWithLogging({
|
||||
run: async () => await sendDocument(mediaTarget, item.content),
|
||||
log,
|
||||
onError: (error) => `${prefix} sendDocument error: ${error}`,
|
||||
});
|
||||
} else if (item.type === "media") {
|
||||
await sendQQBotResultWithLogging({
|
||||
run: async () =>
|
||||
await sendMediaAuto({
|
||||
to: actx.qualifiedTarget,
|
||||
text: "",
|
||||
mediaUrl: item.content,
|
||||
accountId: account.accountId,
|
||||
replyToId: event.messageId,
|
||||
account,
|
||||
}),
|
||||
log,
|
||||
onError: (error) => `${prefix} sendMedia(auto) error: ${error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { handled: true, normalizedText: text };
|
||||
}
|
||||
|
||||
// Unstructured reply delivery for plain text and images.
|
||||
|
||||
export interface PlainReplyPayload {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply that does not contain structured media tags.
|
||||
* Handles markdown image embeds, Base64 media, plain-text chunking, and local media routing.
|
||||
*/
|
||||
export async function sendPlainReply(
|
||||
payload: PlainReplyPayload,
|
||||
replyText: string,
|
||||
event: DeliverEventContext,
|
||||
actx: DeliverAccountContext,
|
||||
sendWithRetry: SendWithRetryFn,
|
||||
consumeQuoteRef: ConsumeQuoteRefFn,
|
||||
toolMediaUrls: string[],
|
||||
): Promise<void> {
|
||||
const { account, qualifiedTarget, log } = actx;
|
||||
const prefix = `[qqbot:${account.accountId}]`;
|
||||
|
||||
const collectedImageUrls: string[] = [];
|
||||
const localMediaToSend: string[] = [];
|
||||
|
||||
const collectImageUrl = (url: string | undefined | null): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
|
||||
const isDataUrl = url.startsWith("data:image/");
|
||||
if (isHttpUrl || isDataUrl) {
|
||||
if (!collectedImageUrls.includes(url)) {
|
||||
collectedImageUrls.push(url);
|
||||
log?.info(
|
||||
`${prefix} Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (isLocalFilePath(url)) {
|
||||
if (!localMediaToSend.includes(url)) {
|
||||
localMediaToSend.push(url);
|
||||
log?.info(`${prefix} Collected local media for auto-routing: ${url}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (payload.mediaUrls?.length) {
|
||||
for (const url of payload.mediaUrls) {
|
||||
collectImageUrl(url);
|
||||
}
|
||||
}
|
||||
if (payload.mediaUrl) {
|
||||
collectImageUrl(payload.mediaUrl);
|
||||
}
|
||||
|
||||
// Extract markdown images.
|
||||
const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
|
||||
const mdMatches = [...replyText.matchAll(mdImageRegex)];
|
||||
for (const m of mdMatches) {
|
||||
const url = m[2]?.trim();
|
||||
if (url && !collectedImageUrls.includes(url)) {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
collectedImageUrls.push(url);
|
||||
log?.info(`${prefix} Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
|
||||
} else if (isLocalFilePath(url)) {
|
||||
if (!localMediaToSend.includes(url)) {
|
||||
localMediaToSend.push(url);
|
||||
log?.info(`${prefix} Collected local media from markdown for auto-routing: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract bare image URLs.
|
||||
const bareUrlRegex =
|
||||
/(?<![(["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
|
||||
const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
|
||||
for (const m of bareUrlMatches) {
|
||||
const url = m[1];
|
||||
if (url && !collectedImageUrls.includes(url)) {
|
||||
collectedImageUrls.push(url);
|
||||
log?.info(`${prefix} Extracted bare image URL: ${url.slice(0, 80)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
const useMarkdown = account.markdownSupport;
|
||||
log?.info(`${prefix} Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`);
|
||||
|
||||
let textWithoutImages = filterInternalMarkers(replyText);
|
||||
|
||||
// Strip markdown image tags that are neither HTTP URLs nor collected local paths
|
||||
// to prevent leaking unresolvable paths (e.g. relative paths) to the user.
|
||||
for (const m of mdMatches) {
|
||||
const url = m[2]?.trim();
|
||||
if (url && !url.startsWith("http://") && !url.startsWith("https://") && !isLocalFilePath(url)) {
|
||||
textWithoutImages = textWithoutImages.replace(m[0], "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (useMarkdown) {
|
||||
await sendMarkdownReply(
|
||||
textWithoutImages,
|
||||
collectedImageUrls,
|
||||
mdMatches,
|
||||
bareUrlMatches,
|
||||
event,
|
||||
actx,
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
);
|
||||
} else {
|
||||
await sendPlainTextReply(
|
||||
textWithoutImages,
|
||||
collectedImageUrls,
|
||||
mdMatches,
|
||||
bareUrlMatches,
|
||||
event,
|
||||
actx,
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
);
|
||||
}
|
||||
|
||||
// Send local media collected from payload.mediaUrl or markdown local paths.
|
||||
if (localMediaToSend.length > 0) {
|
||||
log?.info(
|
||||
`${prefix} Sending ${localMediaToSend.length} local media via sendMedia auto-routing`,
|
||||
);
|
||||
await sendQQBotAutoMediaBatch({
|
||||
qualifiedTarget,
|
||||
account,
|
||||
replyToId: event.messageId,
|
||||
mediaUrls: localMediaToSend,
|
||||
log,
|
||||
onSuccess: (mediaPath) => `${prefix} Sent local media: ${mediaPath}`,
|
||||
onResultError: (mediaPath, error) =>
|
||||
`${prefix} sendMedia(auto) error for ${mediaPath}: ${error}`,
|
||||
onThrownError: (mediaPath, error) =>
|
||||
`${prefix} sendMedia(auto) failed for ${mediaPath}: ${error}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Forward media gathered during the tool phase.
|
||||
if (toolMediaUrls.length > 0) {
|
||||
log?.info(
|
||||
`${prefix} Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`,
|
||||
);
|
||||
await sendQQBotAutoMediaBatch({
|
||||
qualifiedTarget,
|
||||
account,
|
||||
replyToId: event.messageId,
|
||||
mediaUrls: toolMediaUrls,
|
||||
log,
|
||||
onSuccess: (mediaUrl) => `${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`,
|
||||
onResultError: (_mediaUrl, error) => `${prefix} Tool media forward error: ${error}`,
|
||||
onThrownError: (_mediaUrl, error) => `${prefix} Tool media forward failed: ${error}`,
|
||||
});
|
||||
toolMediaUrls.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helpers.
|
||||
|
||||
/** Decode a media path by stripping `MEDIA:`, expanding `~`, and unescaping. */
|
||||
function decodeMediaPath(raw: string, log: DeliverAccountContext["log"], prefix: string): string {
|
||||
let mediaPath = raw;
|
||||
if (mediaPath.startsWith("MEDIA:")) {
|
||||
mediaPath = mediaPath.slice("MEDIA:".length);
|
||||
}
|
||||
mediaPath = normalizePath(mediaPath);
|
||||
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
||||
|
||||
// Skip octal escape decoding for Windows local paths (e.g. C:\Users\1\file.txt)
|
||||
// where backslash-digit sequences like \1, \2 ... \7 are directory separators,
|
||||
// not octal escape sequences.
|
||||
const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
|
||||
try {
|
||||
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
||||
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
||||
|
||||
if (!isWinLocal && (hasOctal || hasNonASCII)) {
|
||||
log?.debug?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`);
|
||||
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
||||
return String.fromCharCode(parseInt(octal, 8));
|
||||
});
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < decoded.length; i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code <= 0xff) {
|
||||
bytes.push(code);
|
||||
} else {
|
||||
const charBytes = Buffer.from(decoded[i], "utf8");
|
||||
bytes.push(...charBytes);
|
||||
}
|
||||
}
|
||||
const buffer = Buffer.from(bytes);
|
||||
const utf8Decoded = buffer.toString("utf8");
|
||||
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
|
||||
mediaPath = utf8Decoded;
|
||||
log?.debug?.(`${prefix} Successfully decoded path: ${mediaPath}`);
|
||||
}
|
||||
}
|
||||
} catch (decodeErr) {
|
||||
log?.error(`${prefix} Path decode error: ${String(decodeErr)}`);
|
||||
}
|
||||
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
/** Shared helper for sending chunked text replies. */
|
||||
async function sendQQBotTextChunk(params: {
|
||||
account: ResolvedQQBotAccount;
|
||||
event: DeliverEventContext;
|
||||
token: string;
|
||||
text: string;
|
||||
consumeQuoteRef: ConsumeQuoteRefFn;
|
||||
allowDm: boolean;
|
||||
}): Promise<unknown> {
|
||||
const { account, event, token, text, consumeQuoteRef, allowDm } = params;
|
||||
const ref = consumeQuoteRef();
|
||||
if (event.type === "c2c") {
|
||||
return await sendC2CMessage(account.appId, token, event.senderId, text, event.messageId, ref);
|
||||
}
|
||||
if (event.type === "group" && event.groupOpenid) {
|
||||
return await sendGroupMessage(account.appId, token, event.groupOpenid, text, event.messageId);
|
||||
}
|
||||
if (allowDm && event.type === "dm" && event.guildId) {
|
||||
return await sendDmMessage(token, event.guildId, text, event.messageId);
|
||||
}
|
||||
if (event.channelId) {
|
||||
return await sendChannelMessage(token, event.channelId, text, event.messageId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function sendTextChunks(
|
||||
text: string,
|
||||
event: DeliverEventContext,
|
||||
actx: DeliverAccountContext,
|
||||
sendWithRetry: SendWithRetryFn,
|
||||
consumeQuoteRef: ConsumeQuoteRefFn,
|
||||
): Promise<void> {
|
||||
const { account, log } = actx;
|
||||
const prefix = `[qqbot:${account.accountId}]`;
|
||||
const chunks = getQQBotRuntime().channel.text.chunkMarkdownText(text, TEXT_CHUNK_LIMIT);
|
||||
await sendQQBotTextChunksWithRetry({
|
||||
account,
|
||||
event,
|
||||
chunks,
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
allowDm: true,
|
||||
log,
|
||||
onSuccess: (chunk) =>
|
||||
`${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`,
|
||||
onError: (err) => `${prefix} Failed to send text chunk: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendQQBotTextChunksWithRetry(params: {
|
||||
account: ResolvedQQBotAccount;
|
||||
event: DeliverEventContext;
|
||||
chunks: string[];
|
||||
sendWithRetry: SendWithRetryFn;
|
||||
consumeQuoteRef: ConsumeQuoteRefFn;
|
||||
allowDm: boolean;
|
||||
log?: DeliverAccountContext["log"];
|
||||
onSuccess: (chunk: string) => string;
|
||||
onError: (err: unknown) => string;
|
||||
}): Promise<void> {
|
||||
const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params;
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendWithRetry((token) =>
|
||||
sendQQBotTextChunk({
|
||||
account,
|
||||
event,
|
||||
token,
|
||||
text: chunk,
|
||||
consumeQuoteRef,
|
||||
allowDm,
|
||||
}),
|
||||
);
|
||||
log?.info(params.onSuccess(chunk));
|
||||
} catch (err) {
|
||||
log?.error(params.onError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendQQBotResultWithLogging(params: {
|
||||
run: () => Promise<{ error?: string }>;
|
||||
log?: DeliverAccountContext["log"];
|
||||
onSuccess?: () => string | undefined;
|
||||
onError: (error: string) => string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const result = await params.run();
|
||||
if (result.error) {
|
||||
params.log?.error(params.onError(result.error));
|
||||
return;
|
||||
}
|
||||
const successMessage = params.onSuccess?.();
|
||||
if (successMessage) {
|
||||
params.log?.info(successMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
params.log?.error(params.onError(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendQQBotPhotoWithLogging(params: {
|
||||
target: MediaTargetContext;
|
||||
imageUrl: string;
|
||||
log?: DeliverAccountContext["log"];
|
||||
onSuccess?: (imageUrl: string) => string | undefined;
|
||||
onError: (error: string) => string;
|
||||
}): Promise<void> {
|
||||
await sendQQBotResultWithLogging({
|
||||
run: async () => await sendPhoto(params.target, params.imageUrl),
|
||||
log: params.log,
|
||||
onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : undefined,
|
||||
onError: params.onError,
|
||||
});
|
||||
}
|
||||
|
||||
/** Send voice with a 45s timeout guard. */
|
||||
async function sendVoiceWithTimeout(
|
||||
target: MediaTargetContext,
|
||||
voicePath: string,
|
||||
account: ResolvedQQBotAccount,
|
||||
log: DeliverAccountContext["log"],
|
||||
prefix: string,
|
||||
): Promise<void> {
|
||||
const uploadFormats =
|
||||
account.config?.audioFormatPolicy?.uploadDirectFormats ??
|
||||
account.config?.voiceDirectUploadFormats;
|
||||
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
||||
const voiceTimeout = 45000;
|
||||
const ac = new AbortController();
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => {
|
||||
if (ac.signal.aborted) {
|
||||
log?.info(`${prefix} sendVoice completed after timeout, suppressing late delivery`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: "Voice send completed after timeout (suppressed)",
|
||||
} as typeof r;
|
||||
}
|
||||
return r;
|
||||
}),
|
||||
new Promise<{ channel: string; error: string }>((resolve) =>
|
||||
setTimeout(() => {
|
||||
ac.abort();
|
||||
resolve({ channel: "qqbot", error: "Voice send timed out and was skipped" });
|
||||
}, voiceTimeout),
|
||||
),
|
||||
]);
|
||||
if (result.error) {
|
||||
log?.error(`${prefix} sendVoice error: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`${prefix} sendVoice unexpected error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send in markdown mode. */
|
||||
async function sendMarkdownReply(
|
||||
textWithoutImages: string,
|
||||
imageUrls: string[],
|
||||
mdMatches: RegExpMatchArray[],
|
||||
bareUrlMatches: RegExpMatchArray[],
|
||||
event: DeliverEventContext,
|
||||
actx: DeliverAccountContext,
|
||||
sendWithRetry: SendWithRetryFn,
|
||||
consumeQuoteRef: ConsumeQuoteRefFn,
|
||||
): Promise<void> {
|
||||
const { account, log } = actx;
|
||||
const prefix = `[qqbot:${account.accountId}]`;
|
||||
|
||||
// Split images into public URLs vs. Base64 payloads.
|
||||
const httpImageUrls: string[] = [];
|
||||
const base64ImageUrls: string[] = [];
|
||||
for (const url of imageUrls) {
|
||||
if (url.startsWith("data:image/")) {
|
||||
base64ImageUrls.push(url);
|
||||
} else if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
httpImageUrls.push(url);
|
||||
}
|
||||
}
|
||||
log?.info(
|
||||
`${prefix} Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`,
|
||||
);
|
||||
|
||||
// Send Base64 images.
|
||||
if (base64ImageUrls.length > 0) {
|
||||
log?.info(`${prefix} Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
|
||||
for (const imageUrl of base64ImageUrls) {
|
||||
try {
|
||||
await sendWithRetry(async (token) => {
|
||||
if (event.type === "c2c") {
|
||||
await sendC2CImageMessage(
|
||||
account.appId,
|
||||
token,
|
||||
event.senderId,
|
||||
imageUrl,
|
||||
event.messageId,
|
||||
);
|
||||
} else if (event.type === "group" && event.groupOpenid) {
|
||||
await sendGroupImageMessage(
|
||||
account.appId,
|
||||
token,
|
||||
event.groupOpenid,
|
||||
imageUrl,
|
||||
event.messageId,
|
||||
);
|
||||
} else if (event.type === "dm" && event.guildId) {
|
||||
log?.info(`${prefix} DM does not support rich media image, skipping Base64 image`);
|
||||
} else if (event.channelId) {
|
||||
log?.info(`${prefix} Channel does not support rich media, skipping Base64 image`);
|
||||
}
|
||||
});
|
||||
log?.info(
|
||||
`${prefix} Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`,
|
||||
);
|
||||
} catch (imgErr) {
|
||||
log?.error(`${prefix} Failed to send Base64 image via Rich Media API: ${String(imgErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle public image URLs.
|
||||
const existingMdUrls = new Set(mdMatches.map((m) => m[2]));
|
||||
const imagesToAppend: string[] = [];
|
||||
|
||||
for (const url of httpImageUrls) {
|
||||
if (!existingMdUrls.has(url)) {
|
||||
try {
|
||||
const size = await getImageSize(url);
|
||||
imagesToAppend.push(formatQQBotMarkdownImage(url, size));
|
||||
log?.info(
|
||||
`${prefix} Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`,
|
||||
);
|
||||
} catch (err) {
|
||||
log?.info(`${prefix} Failed to get image size, using default: ${String(err)}`);
|
||||
imagesToAppend.push(formatQQBotMarkdownImage(url, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill dimensions for existing markdown images.
|
||||
let result = textWithoutImages;
|
||||
for (const m of mdMatches) {
|
||||
const fullMatch = m[0];
|
||||
const imgUrl = m[2];
|
||||
const isHttpUrl = imgUrl.startsWith("http://") || imgUrl.startsWith("https://");
|
||||
if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
|
||||
try {
|
||||
const size = await getImageSize(imgUrl);
|
||||
result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
|
||||
log?.info(
|
||||
`${prefix} Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`,
|
||||
);
|
||||
} catch (err) {
|
||||
log?.info(
|
||||
`${prefix} Failed to get image size for existing md, using default: ${String(err)}`,
|
||||
);
|
||||
result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove bare image URLs from the text body.
|
||||
for (const m of bareUrlMatches) {
|
||||
result = result.replace(m[0], "").trim();
|
||||
}
|
||||
|
||||
// Append markdown images.
|
||||
if (imagesToAppend.length > 0) {
|
||||
result = result.trim();
|
||||
result = result ? result + "\n\n" + imagesToAppend.join("\n") : imagesToAppend.join("\n");
|
||||
}
|
||||
|
||||
// Send markdown text.
|
||||
if (result.trim()) {
|
||||
const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT);
|
||||
await sendQQBotTextChunksWithRetry({
|
||||
account,
|
||||
event,
|
||||
chunks: mdChunks,
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
allowDm: true,
|
||||
log,
|
||||
onSuccess: (chunk) =>
|
||||
`${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`,
|
||||
onError: (err) => `${prefix} Failed to send markdown message chunk: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Send in plain-text mode. */
|
||||
async function sendPlainTextReply(
|
||||
textWithoutImages: string,
|
||||
imageUrls: string[],
|
||||
mdMatches: RegExpMatchArray[],
|
||||
bareUrlMatches: RegExpMatchArray[],
|
||||
event: DeliverEventContext,
|
||||
actx: DeliverAccountContext,
|
||||
sendWithRetry: SendWithRetryFn,
|
||||
consumeQuoteRef: ConsumeQuoteRefFn,
|
||||
): Promise<void> {
|
||||
const { account, log } = actx;
|
||||
const prefix = `[qqbot:${account.accountId}]`;
|
||||
|
||||
const imgMediaTarget = resolveQQBotMediaTargetContext(event, account, prefix);
|
||||
|
||||
let result = textWithoutImages;
|
||||
for (const m of mdMatches) {
|
||||
result = result.replace(m[0], "").trim();
|
||||
}
|
||||
for (const m of bareUrlMatches) {
|
||||
result = result.replace(m[0], "").trim();
|
||||
}
|
||||
|
||||
// QQ group messages reject some dotted bare URLs, so filter them first.
|
||||
if (result && event.type !== "c2c") {
|
||||
result = result.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||
}
|
||||
|
||||
try {
|
||||
for (const imageUrl of imageUrls) {
|
||||
await sendQQBotPhotoWithLogging({
|
||||
target: imgMediaTarget,
|
||||
imageUrl,
|
||||
log,
|
||||
onSuccess: (nextImageUrl) =>
|
||||
`${prefix} Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`,
|
||||
onError: (error) => `${prefix} Failed to send image: ${error}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.trim()) {
|
||||
const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT);
|
||||
await sendQQBotTextChunksWithRetry({
|
||||
account,
|
||||
event,
|
||||
chunks: plainChunks,
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
allowDm: false,
|
||||
log,
|
||||
onSuccess: (chunk) =>
|
||||
`${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`,
|
||||
onError: (err) => `${prefix} Send failed: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(`${prefix} Send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
398
openclaw/extensions/qqbot/src/outbound.security.test.ts
Normal file
398
openclaw/extensions/qqbot/src/outbound.security.test.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { getQQBotDataDir, getQQBotMediaDir } from "./utils/platform.js";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getAccessToken: vi.fn(async () => "token"),
|
||||
sendC2CFileMessage: vi.fn(async () => ({ id: "msg-c2c-file", timestamp: "ts" })),
|
||||
sendC2CImageMessage: vi.fn(async () => ({ id: "msg-c2c-image", timestamp: "ts" })),
|
||||
sendC2CMessage: vi.fn(async () => ({ id: "msg-c2c-text", timestamp: "ts" })),
|
||||
sendC2CVideoMessage: vi.fn(async () => ({ id: "msg-c2c-video", timestamp: "ts" })),
|
||||
sendC2CVoiceMessage: vi.fn(async () => ({ id: "msg-c2c-voice", timestamp: "ts" })),
|
||||
sendChannelMessage: vi.fn(async () => ({ id: "msg-channel", timestamp: "ts" })),
|
||||
sendDmMessage: vi.fn(async () => ({ id: "msg-dm", timestamp: "ts" })),
|
||||
sendGroupFileMessage: vi.fn(async () => ({ id: "msg-group-file", timestamp: "ts" })),
|
||||
sendGroupImageMessage: vi.fn(async () => ({ id: "msg-group-image", timestamp: "ts" })),
|
||||
sendGroupMessage: vi.fn(async () => ({ id: "msg-group-text", timestamp: "ts" })),
|
||||
sendGroupVideoMessage: vi.fn(async () => ({ id: "msg-group-video", timestamp: "ts" })),
|
||||
sendGroupVoiceMessage: vi.fn(async () => ({ id: "msg-group-voice", timestamp: "ts" })),
|
||||
sendProactiveC2CMessage: vi.fn(async () => ({ id: "msg-proactive-c2c", timestamp: "ts" })),
|
||||
sendProactiveGroupMessage: vi.fn(async () => ({ id: "msg-proactive-group", timestamp: "ts" })),
|
||||
}));
|
||||
|
||||
const audioConvertMocks = vi.hoisted(() => ({
|
||||
audioFileToSilkBase64: vi.fn(async () => "c2lsaw=="),
|
||||
isAudioFile: vi.fn((filePath: string, mimeType?: string) => {
|
||||
if (mimeType === "voice" || mimeType?.startsWith("audio/")) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
filePath.endsWith(".mp3") ||
|
||||
filePath.endsWith(".wav") ||
|
||||
filePath.endsWith(".amr") ||
|
||||
filePath.endsWith(".ogg")
|
||||
);
|
||||
}),
|
||||
shouldTranscodeVoice: vi.fn(() => false),
|
||||
waitForFile: vi.fn(async (_filePath: string) => 1024),
|
||||
}));
|
||||
|
||||
const fileUtilsMocks = vi.hoisted(() => ({
|
||||
checkFileSize: vi.fn(() => ({ ok: true })),
|
||||
downloadFile: vi.fn(),
|
||||
fileExistsAsync: vi.fn(async () => true),
|
||||
formatFileSize: vi.fn((size: number) => `${size}`),
|
||||
readFileAsync: vi.fn(async () => Buffer.from("file-data")),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => apiMocks);
|
||||
|
||||
vi.mock("./utils/audio-convert.js", () => ({
|
||||
audioFileToSilkBase64: audioConvertMocks.audioFileToSilkBase64,
|
||||
isAudioFile: audioConvertMocks.isAudioFile,
|
||||
shouldTranscodeVoice: audioConvertMocks.shouldTranscodeVoice,
|
||||
waitForFile: audioConvertMocks.waitForFile,
|
||||
}));
|
||||
|
||||
vi.mock("./utils/file-utils.js", () => ({
|
||||
checkFileSize: fileUtilsMocks.checkFileSize,
|
||||
downloadFile: fileUtilsMocks.downloadFile,
|
||||
fileExistsAsync: fileUtilsMocks.fileExistsAsync,
|
||||
formatFileSize: fileUtilsMocks.formatFileSize,
|
||||
readFileAsync: fileUtilsMocks.readFileAsync,
|
||||
}));
|
||||
|
||||
vi.mock("./utils/debug-log.js", () => ({
|
||||
debugError: vi.fn(),
|
||||
debugLog: vi.fn(),
|
||||
debugWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
sendDocument,
|
||||
sendMedia,
|
||||
sendPhoto,
|
||||
sendVideoMsg,
|
||||
sendVoice,
|
||||
type MediaOutboundContext,
|
||||
type MediaTargetContext,
|
||||
type OutboundResult,
|
||||
} from "./outbound.js";
|
||||
|
||||
const createdRoots: string[] = [];
|
||||
|
||||
const account: ResolvedQQBotAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
appId: "app-id",
|
||||
clientSecret: "secret",
|
||||
secretSource: "config",
|
||||
markdownSupport: true,
|
||||
config: {},
|
||||
};
|
||||
|
||||
function buildTarget(): MediaTargetContext {
|
||||
return {
|
||||
targetType: "c2c",
|
||||
targetId: "user-1",
|
||||
account,
|
||||
replyToId: "msg-1",
|
||||
logPrefix: "[qqbot:test]",
|
||||
};
|
||||
}
|
||||
|
||||
function buildMediaContext(mediaUrl: string): MediaOutboundContext {
|
||||
return {
|
||||
to: "qqbot:c2c:user-1",
|
||||
text: "",
|
||||
account,
|
||||
mediaUrl,
|
||||
replyToId: "msg-1",
|
||||
};
|
||||
}
|
||||
|
||||
function createOutsideFile(ext: string): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-security-"));
|
||||
createdRoots.push(root);
|
||||
const filePath = path.join(root, `payload${ext}`);
|
||||
fs.writeFileSync(filePath, "payload", "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createAllowedCommandDownloadPath(ext: string): string {
|
||||
const root = fs.mkdtempSync(path.join(getQQBotDataDir("downloads"), "command-download-"));
|
||||
createdRoots.push(root);
|
||||
const filePath = path.join(root, `download${ext}`);
|
||||
fs.writeFileSync(filePath, "payload", "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createAllowedMediaPath(
|
||||
ext: string,
|
||||
options: { createFile?: boolean; content?: string } = {},
|
||||
): string {
|
||||
const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-security-"));
|
||||
createdRoots.push(root);
|
||||
const filePath = path.join(root, `allowed${ext}`);
|
||||
if (options.createFile !== false) {
|
||||
fs.writeFileSync(filePath, options.content ?? "payload", "utf8");
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function createDelayedMissingMediaPath(ext: string): string {
|
||||
const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-delayed-security-"));
|
||||
createdRoots.push(root);
|
||||
return path.join(root, "pending", `delayed${ext}`);
|
||||
}
|
||||
|
||||
function createMissingSymlinkEscapePath(ext: string): string | null {
|
||||
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-symlink-outside-"));
|
||||
createdRoots.push(outsideRoot);
|
||||
|
||||
const inMediaRoot = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-symlink-"));
|
||||
createdRoots.push(inMediaRoot);
|
||||
|
||||
const linkPath = path.join(inMediaRoot, "link");
|
||||
try {
|
||||
fs.symlinkSync(outsideRoot, linkPath, "dir");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(linkPath, `delayed${ext}`);
|
||||
}
|
||||
|
||||
function writeFileWithParents(filePath: string, content: string = "payload"): number {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, "utf8");
|
||||
return fs.statSync(filePath).size;
|
||||
}
|
||||
|
||||
function installMissingSegmentSymlinkRace(
|
||||
delayedVoicePath: string,
|
||||
outsideRootPrefix: string,
|
||||
): boolean {
|
||||
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), outsideRootPrefix));
|
||||
createdRoots.push(outsideRoot);
|
||||
|
||||
const symlinkProbe = path.join(path.dirname(path.dirname(delayedVoicePath)), "probe-link");
|
||||
try {
|
||||
fs.symlinkSync(outsideRoot, symlinkProbe, "dir");
|
||||
fs.unlinkSync(symlinkProbe);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => {
|
||||
const symlinkParent = path.dirname(candidatePath);
|
||||
fs.symlinkSync(outsideRoot, symlinkParent, "dir");
|
||||
const outsideFile = path.join(outsideRoot, path.basename(candidatePath));
|
||||
return writeFileWithParents(outsideFile);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function expectBlocked(result: OutboundResult, expectedError: string): void {
|
||||
expect(result.channel).toBe("qqbot");
|
||||
expect(result.error).toBe(expectedError);
|
||||
expect(apiMocks.getAccessToken).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
const nonDotRelativeTraversalPath = "src/../../../../etc/passwd";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const root of createdRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("qqbot outbound local media path security", () => {
|
||||
it("allows local image paths inside QQ Bot media storage", async () => {
|
||||
const allowedPath = createAllowedMediaPath(".png");
|
||||
const result = await sendPhoto(buildTarget(), allowedPath);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks local image paths outside QQ Bot media storage", async () => {
|
||||
const outsidePath = createOutsideFile(".png");
|
||||
const result = await sendPhoto(buildTarget(), outsidePath);
|
||||
|
||||
expectBlocked(result, "Image path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("blocks local voice paths outside QQ Bot media storage", async () => {
|
||||
const outsidePath = createOutsideFile(".mp3");
|
||||
const result = await sendVoice(buildTarget(), outsidePath, undefined, false);
|
||||
|
||||
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("allows delayed local voice paths inside QQ Bot media storage", async () => {
|
||||
const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false });
|
||||
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) =>
|
||||
writeFileWithParents(candidatePath),
|
||||
);
|
||||
const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks delayed voice paths when a missing segment is replaced by a symlink after precheck", async () => {
|
||||
const delayedVoicePath = createDelayedMissingMediaPath(".mp3");
|
||||
if (!installMissingSegmentSymlinkRace(delayedVoicePath, "qqbot-outbound-race-outside-")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true);
|
||||
|
||||
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("returns a blocked result when missing-path canonicalization cannot resolve root", async () => {
|
||||
const originalExistsSync = fs.existsSync.bind(fs);
|
||||
const originalRealpathSync = fs.realpathSync.bind(fs);
|
||||
|
||||
const existsSpy = vi.spyOn(fs, "existsSync");
|
||||
existsSpy.mockImplementation((candidate: fs.PathLike) => {
|
||||
const candidateText = typeof candidate === "string" ? candidate : candidate.toString();
|
||||
const root = path.parse(candidateText).root;
|
||||
if (candidateText === root) {
|
||||
return false;
|
||||
}
|
||||
return originalExistsSync(candidate);
|
||||
});
|
||||
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
realpathSpy.mockImplementation(((candidate: fs.PathLike) => {
|
||||
const candidateText = typeof candidate === "string" ? candidate : candidate.toString();
|
||||
const root = path.parse(candidateText).root;
|
||||
if (candidateText === root) {
|
||||
throw new Error("missing-root");
|
||||
}
|
||||
return originalRealpathSync(candidate);
|
||||
}) as typeof fs.realpathSync);
|
||||
|
||||
try {
|
||||
const result = await sendVoice(
|
||||
buildTarget(),
|
||||
"/qqbot-missing-root/sub/path.mp3",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
|
||||
} finally {
|
||||
existsSpy.mockRestore();
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks delayed voice paths that escape via symlinked parent directories", async () => {
|
||||
const delayedVoicePath = createMissingSymlinkEscapePath(".mp3");
|
||||
if (!delayedVoicePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true);
|
||||
|
||||
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("blocks local video paths outside QQ Bot media storage", async () => {
|
||||
const outsidePath = createOutsideFile(".mp4");
|
||||
const result = await sendVideoMsg(buildTarget(), outsidePath);
|
||||
|
||||
expectBlocked(result, "Video path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("blocks local document paths outside QQ Bot media storage", async () => {
|
||||
const outsidePath = createOutsideFile(".txt");
|
||||
const result = await sendDocument(buildTarget(), outsidePath);
|
||||
|
||||
expectBlocked(result, "File path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("blocks QQ Bot command-download paths for sendDocument by default", async () => {
|
||||
const commandDownloadPath = createAllowedCommandDownloadPath(".txt");
|
||||
const result = await sendDocument(buildTarget(), commandDownloadPath);
|
||||
|
||||
expectBlocked(result, "File path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("allows QQ Bot command-download paths for sendDocument when explicitly enabled", async () => {
|
||||
const commandDownloadPath = createAllowedCommandDownloadPath(".txt");
|
||||
const result = await sendDocument(buildTarget(), commandDownloadPath, {
|
||||
allowQQBotDataDownloads: true,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(2);
|
||||
expect(apiMocks.sendC2CFileMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks non-dot relative traversal paths for document sends", async () => {
|
||||
const result = await sendDocument(buildTarget(), nonDotRelativeTraversalPath);
|
||||
|
||||
expectBlocked(result, "File path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("blocks sendMedia local paths outside QQ Bot media storage", async () => {
|
||||
const outsidePath = createOutsideFile(".txt");
|
||||
const result = await sendMedia(buildMediaContext(outsidePath));
|
||||
|
||||
expectBlocked(result, "Media path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("allows delayed local audio paths in sendMedia inside QQ Bot media storage", async () => {
|
||||
const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false });
|
||||
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) =>
|
||||
writeFileWithParents(candidatePath),
|
||||
);
|
||||
const result = await sendMedia(buildMediaContext(delayedVoicePath));
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks sendMedia delayed audio paths when a missing segment is replaced by a symlink", async () => {
|
||||
const delayedVoicePath = createDelayedMissingMediaPath(".mp3");
|
||||
if (!installMissingSegmentSymlinkRace(delayedVoicePath, "qqbot-outbound-race-sendmedia-")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendMedia(buildMediaContext(delayedVoicePath));
|
||||
|
||||
expectBlocked(
|
||||
result,
|
||||
"voice: Voice path must be inside QQ Bot media storage | fallback file: File path must be inside QQ Bot media storage",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks sendMedia delayed audio paths that escape via symlinked parents", async () => {
|
||||
const delayedVoicePath = createMissingSymlinkEscapePath(".mp3");
|
||||
if (!delayedVoicePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendMedia(buildMediaContext(delayedVoicePath));
|
||||
|
||||
expectBlocked(result, "Media path must be inside QQ Bot media storage");
|
||||
});
|
||||
|
||||
it("blocks non-dot relative traversal paths in sendMedia", async () => {
|
||||
const result = await sendMedia(buildMediaContext(nonDotRelativeTraversalPath));
|
||||
|
||||
expectBlocked(result, "Media path must be inside QQ Bot media storage");
|
||||
});
|
||||
});
|
||||
1660
openclaw/extensions/qqbot/src/outbound.ts
Normal file
1660
openclaw/extensions/qqbot/src/outbound.ts
Normal file
File diff suppressed because it is too large
Load diff
64
openclaw/extensions/qqbot/src/proactive.test.ts
Normal file
64
openclaw/extensions/qqbot/src/proactive.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendProactive } from "./proactive.js";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getAccessToken: vi.fn(),
|
||||
sendProactiveC2CMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
getAccessToken: apiMocks.getAccessToken,
|
||||
sendProactiveC2CMessage: apiMocks.sendProactiveC2CMessage,
|
||||
sendProactiveGroupMessage: vi.fn(),
|
||||
sendChannelMessage: vi.fn(),
|
||||
sendC2CImageMessage: vi.fn(),
|
||||
sendGroupImageMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("qqbot proactive sends", () => {
|
||||
beforeEach(() => {
|
||||
apiMocks.getAccessToken.mockReset();
|
||||
apiMocks.sendProactiveC2CMessage.mockReset();
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", async () => {
|
||||
apiMocks.getAccessToken.mockResolvedValue("access-token");
|
||||
apiMocks.sendProactiveC2CMessage.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
timestamp: 123,
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
clientSecret: "secret-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await sendProactive(
|
||||
{
|
||||
to: "openid-1",
|
||||
text: "hello",
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(apiMocks.getAccessToken).toHaveBeenCalledWith("654321", "secret-value");
|
||||
expect(apiMocks.sendProactiveC2CMessage).toHaveBeenCalledWith(
|
||||
"654321",
|
||||
"access-token",
|
||||
"openid-1",
|
||||
"hello",
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBe("msg-1");
|
||||
});
|
||||
});
|
||||
330
openclaw/extensions/qqbot/src/proactive.ts
Normal file
330
openclaw/extensions/qqbot/src/proactive.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* QQ Bot proactive messaging helpers.
|
||||
*
|
||||
* This module sends proactive messages and manages known-user queries.
|
||||
* Known-user storage is delegated to `./known-users.ts`.
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
getAccessToken,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
sendProactiveC2CMessage,
|
||||
sendProactiveGroupMessage,
|
||||
} from "./api.js";
|
||||
import { resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js";
|
||||
import {
|
||||
clearKnownUsers as clearKnownUsersImpl,
|
||||
getKnownUser as getKnownUserImpl,
|
||||
listKnownUsers as listKnownUsersImpl,
|
||||
removeKnownUser as removeKnownUserImpl,
|
||||
} from "./known-users.js";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import { debugError, debugLog } from "./utils/debug-log.js";
|
||||
|
||||
// Re-export known-user types and functions from the canonical module.
|
||||
export {
|
||||
clearKnownUsers as clearKnownUsersFromStore,
|
||||
flushKnownUsers,
|
||||
getKnownUser as getKnownUserFromStore,
|
||||
listKnownUsers as listKnownUsersFromStore,
|
||||
recordKnownUser,
|
||||
removeKnownUser as removeKnownUserFromStore,
|
||||
} from "./known-users.js";
|
||||
export type { KnownUser } from "./known-users.js";
|
||||
|
||||
/** Options for proactive message sending. */
|
||||
export interface ProactiveSendOptions {
|
||||
to: string;
|
||||
text: string;
|
||||
type?: "c2c" | "group" | "channel";
|
||||
imageUrl?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/** Result returned from proactive sends. */
|
||||
export interface ProactiveSendResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
timestamp?: number | string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Filters for listing known users. */
|
||||
export interface ListKnownUsersOptions {
|
||||
type?: "c2c" | "group" | "channel";
|
||||
accountId?: string;
|
||||
sortByLastInteraction?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Look up a known user entry (adapter for the old proactive API shape). */
|
||||
export function getKnownUser(
|
||||
type: string,
|
||||
openid: string,
|
||||
accountId: string,
|
||||
): ReturnType<typeof getKnownUserImpl> {
|
||||
return getKnownUserImpl(accountId, openid, type as "c2c" | "group");
|
||||
}
|
||||
|
||||
/** List known users with optional filtering and sorting (adapter). */
|
||||
export function listKnownUsers(
|
||||
options?: ListKnownUsersOptions,
|
||||
): ReturnType<typeof listKnownUsersImpl> {
|
||||
const type = options?.type;
|
||||
return listKnownUsersImpl({
|
||||
type: type === "channel" ? undefined : type,
|
||||
accountId: options?.accountId,
|
||||
limit: options?.limit,
|
||||
sortBy: options?.sortByLastInteraction !== false ? "lastSeenAt" : undefined,
|
||||
sortOrder: "desc",
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove one known user entry (adapter). */
|
||||
export function removeKnownUser(type: string, openid: string, accountId: string): boolean {
|
||||
return removeKnownUserImpl(accountId, openid, type as "c2c" | "group");
|
||||
}
|
||||
|
||||
/** Clear all known users, optionally scoped to a single account (adapter). */
|
||||
export function clearKnownUsers(accountId?: string): number {
|
||||
return clearKnownUsersImpl(accountId);
|
||||
}
|
||||
|
||||
/** Resolve account config and send a proactive message. */
|
||||
export async function sendProactive(
|
||||
options: ProactiveSendOptions,
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<ProactiveSendResult> {
|
||||
const {
|
||||
to,
|
||||
text,
|
||||
type = "c2c",
|
||||
imageUrl,
|
||||
accountId = resolveDefaultQQBotAccountId(cfg),
|
||||
} = options;
|
||||
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return {
|
||||
success: false,
|
||||
error: "QQBot not configured (missing appId or clientSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
if (imageUrl) {
|
||||
try {
|
||||
if (type === "c2c") {
|
||||
await sendC2CImageMessage(account.appId, accessToken, to, imageUrl, undefined, undefined);
|
||||
} else if (type === "group") {
|
||||
await sendGroupImageMessage(
|
||||
account.appId,
|
||||
accessToken,
|
||||
to,
|
||||
imageUrl,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
debugLog(`[qqbot:proactive] Sent image to ${type}:${to}`);
|
||||
} catch (err) {
|
||||
debugError(`[qqbot:proactive] Failed to send image: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let result: { id: string; timestamp: number | string };
|
||||
|
||||
if (type === "c2c") {
|
||||
result = await sendProactiveC2CMessage(account.appId, accessToken, to, text);
|
||||
} else if (type === "group") {
|
||||
result = await sendProactiveGroupMessage(account.appId, accessToken, to, text);
|
||||
} else if (type === "channel") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Channel proactive messages are not supported. Please use group or c2c.",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown message type: ${String(type)}`,
|
||||
};
|
||||
}
|
||||
|
||||
debugLog(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
debugError(`[qqbot:proactive] Failed to send message: ${message}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Send one proactive message to each recipient. */
|
||||
export async function sendBulkProactiveMessage(
|
||||
recipients: string[],
|
||||
text: string,
|
||||
type: "c2c" | "group",
|
||||
cfg: OpenClawConfig,
|
||||
accountId = resolveDefaultQQBotAccountId(cfg),
|
||||
): Promise<Array<{ to: string; result: ProactiveSendResult }>> {
|
||||
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||
|
||||
for (const to of recipients) {
|
||||
const result = await sendProactive({ to, text, type, accountId }, cfg);
|
||||
results.push({ to, result });
|
||||
|
||||
// Add a small delay to reduce rate-limit pressure.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to all known users.
|
||||
*
|
||||
* @param text Message content.
|
||||
* @param cfg OpenClaw config.
|
||||
* @param options Optional filters.
|
||||
* @returns Aggregate send statistics.
|
||||
*/
|
||||
export async function broadcastMessage(
|
||||
text: string,
|
||||
cfg: OpenClawConfig,
|
||||
options?: {
|
||||
type?: "c2c" | "group";
|
||||
accountId?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ to: string; result: ProactiveSendResult }>;
|
||||
}> {
|
||||
const users = listKnownUsers({
|
||||
type: options?.type,
|
||||
accountId: options?.accountId,
|
||||
limit: options?.limit,
|
||||
sortByLastInteraction: true,
|
||||
});
|
||||
|
||||
// Channel recipients do not support proactive sends.
|
||||
const validUsers = users.filter((u) => u.type === "c2c" || u.type === "group");
|
||||
|
||||
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const user of validUsers) {
|
||||
const targetId = user.type === "group" ? (user.groupOpenid ?? user.openid) : user.openid;
|
||||
const result = await sendProactive(
|
||||
{
|
||||
to: targetId,
|
||||
text,
|
||||
type: user.type,
|
||||
accountId: user.accountId,
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
results.push({ to: targetId, result });
|
||||
|
||||
if (result.success) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
|
||||
// Add a small delay to reduce rate-limit pressure.
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return {
|
||||
total: validUsers.length,
|
||||
success,
|
||||
failed,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers.
|
||||
|
||||
/**
|
||||
* Send a proactive message using a resolved account without a full config object.
|
||||
*
|
||||
* @param account Resolved account configuration.
|
||||
* @param to Target openid.
|
||||
* @param text Message content.
|
||||
* @param type Message type.
|
||||
*/
|
||||
export async function sendProactiveMessageDirect(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
text: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
): Promise<ProactiveSendResult> {
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return {
|
||||
success: false,
|
||||
error: "QQBot not configured (missing appId or clientSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
let result: { id: string; timestamp: number | string };
|
||||
|
||||
if (type === "c2c") {
|
||||
result = await sendProactiveC2CMessage(account.appId, accessToken, to, text);
|
||||
} else {
|
||||
result = await sendProactiveGroupMessage(account.appId, accessToken, to, text);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatErrorMessage(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return known-user counts for the selected account.
|
||||
*/
|
||||
export function getKnownUsersStats(accountId?: string): {
|
||||
total: number;
|
||||
c2c: number;
|
||||
group: number;
|
||||
channel: number;
|
||||
} {
|
||||
const users = listKnownUsers({ accountId });
|
||||
|
||||
return {
|
||||
total: users.length,
|
||||
c2c: users.filter((u) => u.type === "c2c").length,
|
||||
group: users.filter((u) => u.type === "group").length,
|
||||
channel: 0, // Channel users are not tracked in known-users storage.
|
||||
};
|
||||
}
|
||||
309
openclaw/extensions/qqbot/src/ref-index-store.ts
Normal file
309
openclaw/extensions/qqbot/src/ref-index-store.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { debugLog, debugError } from "./utils/debug-log.js";
|
||||
import { getQQBotDataDir } from "./utils/platform.js";
|
||||
|
||||
/** Summary stored for one quoted message. */
|
||||
export interface RefIndexEntry {
|
||||
content: string;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
timestamp: number;
|
||||
isBot?: boolean;
|
||||
attachments?: RefAttachmentSummary[];
|
||||
}
|
||||
|
||||
/** Attachment summary persisted alongside a ref index entry. */
|
||||
export interface RefAttachmentSummary {
|
||||
type: "image" | "voice" | "video" | "file" | "unknown";
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
transcript?: string;
|
||||
transcriptSource?: "stt" | "asr" | "tts" | "fallback";
|
||||
localPath?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const STORAGE_DIR = getQQBotDataDir("data");
|
||||
const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
|
||||
const MAX_ENTRIES = 50000;
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const COMPACT_THRESHOLD_RATIO = 2;
|
||||
|
||||
interface RefIndexLine {
|
||||
k: string;
|
||||
v: RefIndexEntry;
|
||||
t: number;
|
||||
}
|
||||
|
||||
let cache: Map<string, RefIndexEntry & { _createdAt: number }> | null = null;
|
||||
let totalLinesOnDisk = 0;
|
||||
|
||||
/** Lazily load the JSONL store into memory. */
|
||||
function loadFromFile(): Map<string, RefIndexEntry & { _createdAt: number }> {
|
||||
if (cache !== null) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
cache = new Map();
|
||||
totalLinesOnDisk = 0;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(REF_INDEX_FILE)) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(REF_INDEX_FILE, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
const now = Date.now();
|
||||
let expired = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
totalLinesOnDisk++;
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as RefIndexLine;
|
||||
if (!entry.k || !entry.v || !entry.t) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now - entry.t > TTL_MS) {
|
||||
expired++;
|
||||
continue;
|
||||
}
|
||||
|
||||
cache.set(entry.k, {
|
||||
...entry.v,
|
||||
_createdAt: entry.t,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`[ref-index-store] Loaded ${cache.size} entries from ${totalLinesOnDisk} lines (${expired} expired)`,
|
||||
);
|
||||
|
||||
if (shouldCompact()) {
|
||||
compactFile();
|
||||
}
|
||||
} catch (err) {
|
||||
debugError(`[ref-index-store] Failed to load: ${String(err)}`);
|
||||
cache = new Map();
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Append one record to the JSONL file. */
|
||||
function appendLine(line: RefIndexLine): void {
|
||||
try {
|
||||
ensureDir();
|
||||
fs.appendFileSync(REF_INDEX_FILE, JSON.stringify(line) + "\n", "utf-8");
|
||||
totalLinesOnDisk++;
|
||||
} catch (err) {
|
||||
debugError(`[ref-index-store] Failed to append: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function shouldCompact(): boolean {
|
||||
if (!cache) {
|
||||
return false;
|
||||
}
|
||||
return totalLinesOnDisk > cache.size * COMPACT_THRESHOLD_RATIO && totalLinesOnDisk > 1000;
|
||||
}
|
||||
|
||||
function compactFile(): void {
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = totalLinesOnDisk;
|
||||
try {
|
||||
ensureDir();
|
||||
const tmpPath = REF_INDEX_FILE + ".tmp";
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [key, entry] of cache) {
|
||||
const line: RefIndexLine = {
|
||||
k: key,
|
||||
v: {
|
||||
content: entry.content,
|
||||
senderId: entry.senderId,
|
||||
senderName: entry.senderName,
|
||||
timestamp: entry.timestamp,
|
||||
isBot: entry.isBot,
|
||||
attachments: entry.attachments,
|
||||
},
|
||||
t: entry._createdAt,
|
||||
};
|
||||
lines.push(JSON.stringify(line));
|
||||
}
|
||||
|
||||
fs.writeFileSync(tmpPath, lines.join("\n") + "\n", "utf-8");
|
||||
fs.renameSync(tmpPath, REF_INDEX_FILE);
|
||||
totalLinesOnDisk = cache.size;
|
||||
debugLog(`[ref-index-store] Compacted: ${before} lines → ${totalLinesOnDisk} lines`);
|
||||
} catch (err) {
|
||||
debugError(
|
||||
`[ref-index-store] Compact failed: ${err instanceof Error ? err.message : JSON.stringify(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function evictIfNeeded(): void {
|
||||
if (!cache || cache.size < MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of cache) {
|
||||
if (now - entry._createdAt > TTL_MS) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (cache.size >= MAX_ENTRIES) {
|
||||
const sorted = [...cache.entries()].toSorted((a, b) => a[1]._createdAt - b[1]._createdAt);
|
||||
const toRemove = sorted.slice(0, cache.size - MAX_ENTRIES + 1000);
|
||||
for (const [key] of toRemove) {
|
||||
cache.delete(key);
|
||||
}
|
||||
debugLog(`[ref-index-store] Evicted ${toRemove.length} oldest entries`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a refIdx mapping for one message. */
|
||||
export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
|
||||
const store = loadFromFile();
|
||||
evictIfNeeded();
|
||||
|
||||
const now = Date.now();
|
||||
store.set(refIdx, {
|
||||
content: entry.content,
|
||||
senderId: entry.senderId,
|
||||
senderName: entry.senderName,
|
||||
timestamp: entry.timestamp,
|
||||
isBot: entry.isBot,
|
||||
attachments: entry.attachments,
|
||||
_createdAt: now,
|
||||
});
|
||||
|
||||
appendLine({
|
||||
k: refIdx,
|
||||
v: {
|
||||
content: entry.content,
|
||||
senderId: entry.senderId,
|
||||
senderName: entry.senderName,
|
||||
timestamp: entry.timestamp,
|
||||
isBot: entry.isBot,
|
||||
attachments: entry.attachments,
|
||||
},
|
||||
t: now,
|
||||
});
|
||||
|
||||
if (shouldCompact()) {
|
||||
compactFile();
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up one quoted message by refIdx. */
|
||||
export function getRefIndex(refIdx: string): RefIndexEntry | null {
|
||||
const store = loadFromFile();
|
||||
const entry = store.get(refIdx);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - entry._createdAt > TTL_MS) {
|
||||
store.delete(refIdx);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
content: entry.content,
|
||||
senderId: entry.senderId,
|
||||
senderName: entry.senderName,
|
||||
timestamp: entry.timestamp,
|
||||
isBot: entry.isBot,
|
||||
attachments: entry.attachments,
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a ref-index entry into text suitable for model context. */
|
||||
export function formatRefEntryForAgent(entry: RefIndexEntry): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (entry.content.trim()) {
|
||||
parts.push(entry.content);
|
||||
}
|
||||
|
||||
if (entry.attachments?.length) {
|
||||
for (const att of entry.attachments) {
|
||||
const sourceHint = att.localPath ? ` (${att.localPath})` : att.url ? ` (${att.url})` : "";
|
||||
switch (att.type) {
|
||||
case "image":
|
||||
parts.push(`[image${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
||||
break;
|
||||
case "voice":
|
||||
if (att.transcript) {
|
||||
const sourceMap = {
|
||||
stt: "local STT",
|
||||
asr: "platform ASR",
|
||||
tts: "TTS source",
|
||||
fallback: "fallback text",
|
||||
};
|
||||
const sourceTag = att.transcriptSource
|
||||
? ` - ${sourceMap[att.transcriptSource] || att.transcriptSource}`
|
||||
: "";
|
||||
parts.push(`[voice message (content: "${att.transcript}"${sourceTag})${sourceHint}]`);
|
||||
} else {
|
||||
parts.push(`[voice message${sourceHint}]`);
|
||||
}
|
||||
break;
|
||||
case "video":
|
||||
parts.push(`[video${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
||||
break;
|
||||
case "file":
|
||||
parts.push(`[file${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
||||
break;
|
||||
default:
|
||||
parts.push(`[attachment${att.filename ? `: ${att.filename}` : ""}${sourceHint}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" ") || "[empty message]";
|
||||
}
|
||||
|
||||
/** Compact the store before process exit when needed. */
|
||||
export function flushRefIndex(): void {
|
||||
if (cache && shouldCompact()) {
|
||||
compactFile();
|
||||
}
|
||||
}
|
||||
|
||||
/** Return ref-index stats for diagnostics. */
|
||||
export function getRefIndexStats(): {
|
||||
size: number;
|
||||
maxEntries: number;
|
||||
totalLinesOnDisk: number;
|
||||
filePath: string;
|
||||
} {
|
||||
const store = loadFromFile();
|
||||
return {
|
||||
size: store.size,
|
||||
maxEntries: MAX_ENTRIES,
|
||||
totalLinesOnDisk,
|
||||
filePath: REF_INDEX_FILE,
|
||||
};
|
||||
}
|
||||
74
openclaw/extensions/qqbot/src/reply-dispatcher.test.ts
Normal file
74
openclaw/extensions/qqbot/src/reply-dispatcher.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
clearTokenCache: vi.fn(),
|
||||
getAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
sendC2CFileMessage: vi.fn(),
|
||||
sendC2CImageMessage: vi.fn(),
|
||||
sendC2CMessage: vi.fn(),
|
||||
sendC2CVideoMessage: vi.fn(),
|
||||
sendC2CVoiceMessage: vi.fn(),
|
||||
sendChannelMessage: vi.fn(),
|
||||
sendDmMessage: vi.fn(),
|
||||
sendGroupFileMessage: vi.fn(),
|
||||
sendGroupImageMessage: vi.fn(),
|
||||
sendGroupMessage: vi.fn(),
|
||||
sendGroupVideoMessage: vi.fn(),
|
||||
sendGroupVoiceMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => apiMocks);
|
||||
|
||||
import { handleStructuredPayload, type ReplyContext } from "./reply-dispatcher.js";
|
||||
|
||||
function buildCtx(): ReplyContext {
|
||||
return {
|
||||
target: {
|
||||
type: "c2c",
|
||||
senderId: "user-1",
|
||||
messageId: "msg-1",
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
appId: "app-id",
|
||||
clientSecret: "secret",
|
||||
config: {},
|
||||
} as ReplyContext["account"],
|
||||
cfg: {},
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("qqbot reply dispatcher", () => {
|
||||
it("allows inline data image URLs for structured image payloads", async () => {
|
||||
const ctx = buildCtx();
|
||||
const recordActivity = vi.fn();
|
||||
const dataUrl = "data:image/png;base64,Zm9v";
|
||||
|
||||
const handled = await handleStructuredPayload(
|
||||
ctx,
|
||||
`QQBOT_PAYLOAD:${JSON.stringify({
|
||||
type: "media",
|
||||
mediaType: "image",
|
||||
source: "url",
|
||||
path: dataUrl,
|
||||
})}`,
|
||||
recordActivity,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(recordActivity).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledWith(
|
||||
"app-id",
|
||||
"token",
|
||||
"user-1",
|
||||
dataUrl,
|
||||
"msg-1",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
695
openclaw/extensions/qqbot/src/reply-dispatcher.ts
Normal file
695
openclaw/extensions/qqbot/src/reply-dispatcher.ts
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
getAccessToken,
|
||||
sendC2CMessage,
|
||||
sendChannelMessage,
|
||||
sendDmMessage,
|
||||
sendGroupMessage,
|
||||
clearTokenCache,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
sendC2CVoiceMessage,
|
||||
sendGroupVoiceMessage,
|
||||
sendC2CVideoMessage,
|
||||
sendGroupVideoMessage,
|
||||
sendC2CFileMessage,
|
||||
sendGroupFileMessage,
|
||||
} from "./api.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
import {
|
||||
isGlobalTTSAvailable,
|
||||
resolveTTSConfig,
|
||||
textToSilk,
|
||||
audioFileToSilkBase64,
|
||||
formatDuration,
|
||||
} from "./utils/audio-convert.js";
|
||||
import { MAX_UPLOAD_SIZE, formatFileSize } from "./utils/file-utils.js";
|
||||
import {
|
||||
parseQQBotPayload,
|
||||
encodePayloadForCron,
|
||||
isCronReminderPayload,
|
||||
isMediaPayload,
|
||||
type MediaPayload,
|
||||
} from "./utils/payload.js";
|
||||
import {
|
||||
getQQBotDataDir,
|
||||
normalizePath,
|
||||
resolveQQBotPayloadLocalFilePath,
|
||||
sanitizeFileName,
|
||||
} from "./utils/platform.js";
|
||||
|
||||
export interface MessageTarget {
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
senderId: string;
|
||||
messageId: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
groupOpenid?: string;
|
||||
}
|
||||
|
||||
export interface ReplyContext {
|
||||
target: MessageTarget;
|
||||
account: ResolvedQQBotAccount;
|
||||
cfg: unknown;
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** Send a message and retry once if the token appears to have expired. */
|
||||
export async function sendWithTokenRetry<T>(
|
||||
appId: string,
|
||||
clientSecret: string,
|
||||
sendFn: (token: string) => Promise<T>,
|
||||
log?: ReplyContext["log"],
|
||||
accountId?: string,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const token = await getAccessToken(appId, clientSecret);
|
||||
return await sendFn(token);
|
||||
} catch (err) {
|
||||
const errMsg = String(err);
|
||||
if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
|
||||
log?.info(`[qqbot:${accountId}] Token may be expired, refreshing...`);
|
||||
clearTokenCache(appId);
|
||||
const newToken = await getAccessToken(appId, clientSecret);
|
||||
return await sendFn(newToken);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Route a text message to the correct QQ target type. */
|
||||
export async function sendTextToTarget(
|
||||
ctx: ReplyContext,
|
||||
text: string,
|
||||
refIdx?: string,
|
||||
): Promise<void> {
|
||||
const { target, account } = ctx;
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CMessage(account.appId, token, target.senderId, text, target.messageId, refIdx);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupMessage(account.appId, token, target.groupOpenid, text, target.messageId);
|
||||
} else if (target.channelId) {
|
||||
await sendChannelMessage(token, target.channelId, text, target.messageId);
|
||||
} else if (target.type === "dm" && target.guildId) {
|
||||
await sendDmMessage(token, target.guildId, text, target.messageId);
|
||||
}
|
||||
},
|
||||
ctx.log,
|
||||
account.accountId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Best-effort delivery for error text back to the user. */
|
||||
export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise<void> {
|
||||
try {
|
||||
await sendTextToTarget(ctx, errorText);
|
||||
} catch (sendErr) {
|
||||
ctx.log?.error(
|
||||
`[qqbot:${ctx.account.accountId}] Failed to send error message: ${String(sendErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a structured payload prefixed with `QQBOT_PAYLOAD:`.
|
||||
* Returns true when the reply was handled here, otherwise false.
|
||||
*/
|
||||
export async function handleStructuredPayload(
|
||||
ctx: ReplyContext,
|
||||
replyText: string,
|
||||
recordActivity: () => void,
|
||||
): Promise<boolean> {
|
||||
const { account, log } = ctx;
|
||||
const payloadResult = parseQQBotPayload(replyText);
|
||||
|
||||
if (!payloadResult.isPayload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payloadResult.error) {
|
||||
log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!payloadResult.payload) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsedPayload = payloadResult.payload;
|
||||
const unknownPayload = payloadResult.payload as unknown;
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Detected structured payload, type: ${parsedPayload.type}`,
|
||||
);
|
||||
|
||||
if (isCronReminderPayload(parsedPayload)) {
|
||||
log?.info(`[qqbot:${account.accountId}] Processing cron_reminder payload`);
|
||||
const cronMessage = encodePayloadForCron(parsedPayload);
|
||||
const confirmText = `⏰ Reminder scheduled. It will be sent at the configured time: "${parsedPayload.content}"`;
|
||||
try {
|
||||
await sendTextToTarget(ctx, confirmText);
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Cron reminder confirmation sent, cronMessage: ${cronMessage}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Failed to send cron confirmation: ${
|
||||
err instanceof Error ? err.message : JSON.stringify(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
recordActivity();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isMediaPayload(parsedPayload)) {
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Processing media payload, mediaType: ${parsedPayload.mediaType}`,
|
||||
);
|
||||
|
||||
if (parsedPayload.mediaType === "image") {
|
||||
await handleImagePayload(ctx, parsedPayload);
|
||||
} else if (parsedPayload.mediaType === "audio") {
|
||||
await handleAudioPayload(ctx, parsedPayload);
|
||||
} else if (parsedPayload.mediaType === "video") {
|
||||
await handleVideoPayload(ctx, parsedPayload);
|
||||
} else if (parsedPayload.mediaType === "file") {
|
||||
await handleFilePayload(ctx, parsedPayload);
|
||||
} else {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Unknown media type: ${JSON.stringify(parsedPayload.mediaType)}`,
|
||||
);
|
||||
}
|
||||
recordActivity();
|
||||
return true;
|
||||
}
|
||||
|
||||
const payloadType =
|
||||
typeof unknownPayload === "object" &&
|
||||
unknownPayload !== null &&
|
||||
"type" in unknownPayload &&
|
||||
typeof unknownPayload.type === "string"
|
||||
? unknownPayload.type
|
||||
: "unknown";
|
||||
log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${payloadType}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media payload handlers.
|
||||
|
||||
function validateStructuredPayloadLocalPath(
|
||||
ctx: ReplyContext,
|
||||
payloadPath: string,
|
||||
mediaType: "image" | "video" | "file",
|
||||
): string | null {
|
||||
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
|
||||
if (allowedPath) {
|
||||
return allowedPath;
|
||||
}
|
||||
|
||||
ctx.log?.error(
|
||||
`[qqbot:${ctx.account.accountId}] Blocked ${mediaType} payload local path outside QQ Bot media storage`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRemoteHttpUrl(p: string): boolean {
|
||||
return p.startsWith("http://") || p.startsWith("https://");
|
||||
}
|
||||
|
||||
function isInlineImageDataUrl(p: string): boolean {
|
||||
return /^data:image\/[^;]+;base64,/i.test(p);
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: string, maxLen = 200): string {
|
||||
return value
|
||||
.replace(/[\r\n\t]/g, " ")
|
||||
.replaceAll("\0", " ")
|
||||
.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
|
||||
if (!isHttpUrl) {
|
||||
return "<local-file>";
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(pathValue);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
|
||||
return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
|
||||
} catch {
|
||||
return "<invalid-url>";
|
||||
}
|
||||
}
|
||||
|
||||
async function readStructuredPayloadLocalFile(filePath: string): Promise<Buffer> {
|
||||
const openFlags =
|
||||
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
|
||||
const handle = await fs.promises.open(filePath, openFlags);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Path is not a regular file");
|
||||
}
|
||||
if (stat.size > MAX_UPLOAD_SIZE) {
|
||||
throw new Error(
|
||||
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(MAX_UPLOAD_SIZE)}`,
|
||||
);
|
||||
}
|
||||
return handle.readFile();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, log } = ctx;
|
||||
const normalizedPath = normalizePath(payload.path);
|
||||
let imageUrl: string | null;
|
||||
if (payload.source === "file") {
|
||||
imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
|
||||
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
|
||||
imageUrl = normalizedPath;
|
||||
} else {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!imageUrl) {
|
||||
return;
|
||||
}
|
||||
const originalImagePath = payload.source === "file" ? imageUrl : undefined;
|
||||
|
||||
if (payload.source === "file") {
|
||||
try {
|
||||
const fileBuffer = await readStructuredPayloadLocalFile(imageUrl);
|
||||
const base64Data = fileBuffer.toString("base64");
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl));
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
};
|
||||
const mimeType = mimeTypes[ext];
|
||||
if (!mimeType) {
|
||||
log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
|
||||
return;
|
||||
}
|
||||
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`,
|
||||
);
|
||||
} catch (readErr) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Failed to read local image: ${
|
||||
readErr instanceof Error ? readErr.message : JSON.stringify(readErr)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CImageMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
imageUrl,
|
||||
target.messageId,
|
||||
undefined,
|
||||
originalImagePath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupImageMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
imageUrl,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm" && target.guildId) {
|
||||
// By design: DM only supports text/markdown; use markdown image syntax with the
|
||||
// original path so the QQ client can attempt to render it.
|
||||
await sendDmMessage(token, target.guildId, ``, target.messageId);
|
||||
} else if (target.channelId) {
|
||||
// By design: channel messages only support text/markdown, same approach as DM above.
|
||||
await sendChannelMessage(
|
||||
token,
|
||||
target.channelId,
|
||||
``,
|
||||
target.messageId,
|
||||
);
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] Sent image via media payload`);
|
||||
|
||||
if (payload.caption) {
|
||||
await sendTextToTarget(ctx, payload.caption);
|
||||
}
|
||||
} catch (err) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Failed to send image: ${
|
||||
err instanceof Error ? err.message : JSON.stringify(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, cfg, log } = ctx;
|
||||
try {
|
||||
const ttsText = payload.caption || payload.path;
|
||||
if (!ttsText?.trim()) {
|
||||
log?.error(`[qqbot:${account.accountId}] Voice missing text`);
|
||||
return;
|
||||
}
|
||||
|
||||
let silkBase64: string | undefined;
|
||||
let silkPath: string | undefined;
|
||||
let duration: number | undefined;
|
||||
let providerLabel: string | undefined;
|
||||
|
||||
// Strategy 1: Plugin-specific TTS (OpenAI-compatible /audio/speech API).
|
||||
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
||||
if (ttsCfg) {
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] TTS (plugin): "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`,
|
||||
);
|
||||
const ttsDir = getQQBotDataDir("tts");
|
||||
const result = await textToSilk(ttsText, ttsCfg, ttsDir);
|
||||
silkBase64 = result.silkBase64;
|
||||
silkPath = result.silkPath;
|
||||
duration = result.duration;
|
||||
providerLabel = ttsCfg.model;
|
||||
} else {
|
||||
// Strategy 2: Fall back to global TTS provider registry (e.g. Edge TTS).
|
||||
if (!isGlobalTTSAvailable(cfg as OpenClawConfig)) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] TTS not configured (neither plugin channels.qqbot.tts nor global messages.tts)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log?.info(`[qqbot:${account.accountId}] TTS (global fallback): "${ttsText.slice(0, 50)}..."`);
|
||||
const globalResult = await getQQBotRuntime().tts.textToSpeech({
|
||||
text: ttsText,
|
||||
cfg: cfg as OpenClawConfig,
|
||||
channel: "qqbot",
|
||||
});
|
||||
if (!globalResult.success || !globalResult.audioPath) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Global TTS failed: ${globalResult.error ?? "unknown"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Global TTS returned: provider=${globalResult.provider}, format=${globalResult.outputFormat}, path=${globalResult.audioPath}`,
|
||||
);
|
||||
providerLabel = globalResult.provider ?? "global";
|
||||
|
||||
// Convert the global TTS audio file to SILK for QQ upload.
|
||||
const base64 = await audioFileToSilkBase64(globalResult.audioPath);
|
||||
if (!base64) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to convert global TTS audio to SILK`);
|
||||
return;
|
||||
}
|
||||
silkBase64 = base64;
|
||||
silkPath = globalResult.audioPath;
|
||||
duration = 0; // Duration unknown from global TTS; use 0 as fallback.
|
||||
}
|
||||
|
||||
if (!silkBase64) {
|
||||
log?.error(`[qqbot:${account.accountId}] TTS produced no audio output`);
|
||||
return;
|
||||
}
|
||||
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] TTS done (${providerLabel}): ${duration ? formatDuration(duration) : "N/A"}, file: ${silkPath ?? "N/A"}`,
|
||||
);
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVoiceMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
silkBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
ttsText,
|
||||
silkPath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVoiceMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
silkBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm" && target.guildId) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Voice not supported in DM, sending text fallback`,
|
||||
);
|
||||
await sendDmMessage(token, target.guildId, ttsText, target.messageId);
|
||||
} else if (target.channelId) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] Voice not supported in channel, sending text fallback`,
|
||||
);
|
||||
await sendChannelMessage(token, target.channelId, ttsText, target.messageId);
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] Voice message sent`);
|
||||
} catch (err) {
|
||||
log?.error(
|
||||
`[qqbot:${account.accountId}] TTS/voice send failed: ${
|
||||
err instanceof Error ? err.message : JSON.stringify(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, log } = ctx;
|
||||
try {
|
||||
const originalPath = payload.path ?? "";
|
||||
const normalizedPath = normalizePath(originalPath);
|
||||
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
||||
const videoPath = isHttpUrl
|
||||
? normalizedPath
|
||||
: validateStructuredPayloadLocalPath(ctx, originalPath, "video");
|
||||
if (!videoPath) {
|
||||
return;
|
||||
}
|
||||
if (!videoPath.trim()) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video missing path`);
|
||||
return;
|
||||
}
|
||||
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`,
|
||||
);
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (isHttpUrl) {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
videoPath,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
videoPath,
|
||||
undefined,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
||||
}
|
||||
} else {
|
||||
const fileBuffer = await readStructuredPayloadLocalFile(videoPath);
|
||||
const videoBase64 = fileBuffer.toString("base64");
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`,
|
||||
);
|
||||
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
undefined,
|
||||
videoBase64,
|
||||
target.messageId,
|
||||
undefined,
|
||||
videoPath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupVideoMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
undefined,
|
||||
videoBase64,
|
||||
target.messageId,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
||||
}
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] Video message sent`);
|
||||
|
||||
if (payload.caption) {
|
||||
await sendTextToTarget(ctx, payload.caption);
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err);
|
||||
log?.error(`[qqbot:${account.accountId}] Video send failed: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
|
||||
const { target, account, log } = ctx;
|
||||
try {
|
||||
const originalPath = payload.path ?? "";
|
||||
const normalizedPath = normalizePath(originalPath);
|
||||
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
|
||||
const filePath = isHttpUrl
|
||||
? normalizedPath
|
||||
: validateStructuredPayloadLocalPath(ctx, originalPath, "file");
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
if (!filePath.trim()) {
|
||||
log?.error(`[qqbot:${account.accountId}] File missing path`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = sanitizeFileName(path.basename(filePath));
|
||||
log?.info(
|
||||
`[qqbot:${account.accountId}] File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
|
||||
);
|
||||
|
||||
await sendWithTokenRetry(
|
||||
account.appId,
|
||||
account.clientSecret,
|
||||
async (token) => {
|
||||
if (isHttpUrl) {
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
undefined,
|
||||
filePath,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
undefined,
|
||||
filePath,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
||||
}
|
||||
} else {
|
||||
const fileBuffer = await readStructuredPayloadLocalFile(filePath);
|
||||
const fileBase64 = fileBuffer.toString("base64");
|
||||
if (target.type === "c2c") {
|
||||
await sendC2CFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.senderId,
|
||||
fileBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
fileName,
|
||||
filePath,
|
||||
);
|
||||
} else if (target.type === "group" && target.groupOpenid) {
|
||||
await sendGroupFileMessage(
|
||||
account.appId,
|
||||
token,
|
||||
target.groupOpenid,
|
||||
fileBase64,
|
||||
undefined,
|
||||
target.messageId,
|
||||
fileName,
|
||||
);
|
||||
} else if (target.type === "dm") {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
|
||||
} else if (target.channelId) {
|
||||
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
||||
}
|
||||
}
|
||||
},
|
||||
log,
|
||||
account.accountId,
|
||||
);
|
||||
log?.info(`[qqbot:${account.accountId}] File message sent`);
|
||||
} catch (err) {
|
||||
const errMsg =
|
||||
err instanceof Error ? err.message : typeof err === "string" ? err : JSON.stringify(err);
|
||||
log?.error(`[qqbot:${account.accountId}] File send failed: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
9
openclaw/extensions/qqbot/src/runtime.ts
Normal file
9
openclaw/extensions/qqbot/src/runtime.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setQQBotRuntime, getRuntime: getQQBotRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "qqbot",
|
||||
errorMessage: "QQBot runtime not initialized",
|
||||
});
|
||||
export { getQQBotRuntime, setQQBotRuntime };
|
||||
94
openclaw/extensions/qqbot/src/session-store.test.ts
Normal file
94
openclaw/extensions/qqbot/src/session-store.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionState } from "./session-store.js";
|
||||
|
||||
type SessionStoreModule = typeof import("./session-store.js");
|
||||
|
||||
async function loadSessionStore(testRoot: string): Promise<SessionStoreModule> {
|
||||
vi.resetModules();
|
||||
vi.doMock("./utils/platform.js", () => ({
|
||||
getQQBotDataDir: (...subPaths: string[]) => {
|
||||
const dir = path.join(testRoot, ...subPaths);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
},
|
||||
}));
|
||||
return import("./session-store.js");
|
||||
}
|
||||
|
||||
function buildSession(accountId: string, overrides: Partial<SessionState> = {}): SessionState {
|
||||
return {
|
||||
accountId,
|
||||
intentLevelIndex: 0,
|
||||
lastConnectedAt: 1_700_000_000_000,
|
||||
lastSeq: 42,
|
||||
savedAt: 1_700_000_000_000,
|
||||
sessionId: `session-${accountId}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("qqbot session store", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("./utils/platform.js");
|
||||
vi.restoreAllMocks();
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps distinct sessions when account ids collide under the legacy filename sanitizer", async () => {
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-session-store-"));
|
||||
tempRoots.push(testRoot);
|
||||
const store = await loadSessionStore(testRoot);
|
||||
|
||||
const colonAccount = "acct:one";
|
||||
const slashAccount = "acct/one";
|
||||
store.saveSession(buildSession(colonAccount, { lastSeq: 11, sessionId: "colon-session" }));
|
||||
store.saveSession(buildSession(slashAccount, { lastSeq: 22, sessionId: "slash-session" }));
|
||||
|
||||
expect(store.loadSession(colonAccount)).toMatchObject({
|
||||
accountId: colonAccount,
|
||||
lastSeq: 11,
|
||||
sessionId: "colon-session",
|
||||
});
|
||||
expect(store.loadSession(slashAccount)).toMatchObject({
|
||||
accountId: slashAccount,
|
||||
lastSeq: 22,
|
||||
sessionId: "slash-session",
|
||||
});
|
||||
|
||||
const sessionFiles = fs
|
||||
.readdirSync(path.join(testRoot, "sessions"))
|
||||
.filter((file) => file.startsWith("session-") && file.endsWith(".json"));
|
||||
expect(sessionFiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("loads a legacy sanitized session file for backward compatibility", async () => {
|
||||
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-session-store-"));
|
||||
tempRoots.push(testRoot);
|
||||
const store = await loadSessionStore(testRoot);
|
||||
|
||||
const accountId = "legacy/account:id";
|
||||
const legacyPath = path.join(
|
||||
testRoot,
|
||||
"sessions",
|
||||
`session-${accountId.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify(buildSession(accountId, { savedAt: Date.now(), sessionId: "legacy" })),
|
||||
);
|
||||
|
||||
expect(store.loadSession(accountId)).toMatchObject({
|
||||
accountId,
|
||||
sessionId: "legacy",
|
||||
});
|
||||
});
|
||||
});
|
||||
293
openclaw/extensions/qqbot/src/session-store.ts
Normal file
293
openclaw/extensions/qqbot/src/session-store.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { debugLog, debugError } from "./utils/debug-log.js";
|
||||
|
||||
/** Persisted gateway session state. */
|
||||
export interface SessionState {
|
||||
sessionId: string | null;
|
||||
lastSeq: number | null;
|
||||
lastConnectedAt: number;
|
||||
intentLevelIndex: number;
|
||||
accountId: string;
|
||||
savedAt: number;
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
import { getQQBotDataDir } from "./utils/platform.js";
|
||||
|
||||
const SESSION_DIR = getQQBotDataDir("sessions");
|
||||
|
||||
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
|
||||
const SAVE_THROTTLE_MS = 1000;
|
||||
const throttleState = new Map<
|
||||
string,
|
||||
{
|
||||
pendingState: SessionState | null;
|
||||
lastSaveTime: number;
|
||||
throttleTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
/** Ensure the session directory exists. */
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(SESSION_DIR)) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function encodeAccountIdForFileName(accountId: string): string {
|
||||
return Buffer.from(accountId, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function getLegacySessionPath(accountId: string): string {
|
||||
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return path.join(SESSION_DIR, `session-${safeId}.json`);
|
||||
}
|
||||
|
||||
/** Return the session file path for one account. */
|
||||
function getSessionPath(accountId: string): string {
|
||||
const encodedId = encodeAccountIdForFileName(accountId);
|
||||
return path.join(SESSION_DIR, `session-${encodedId}.json`);
|
||||
}
|
||||
|
||||
function getCandidateSessionPaths(accountId: string): string[] {
|
||||
const primaryPath = getSessionPath(accountId);
|
||||
const legacyPath = getLegacySessionPath(accountId);
|
||||
return primaryPath === legacyPath ? [primaryPath] : [primaryPath, legacyPath];
|
||||
}
|
||||
|
||||
/** Load a saved session, rejecting expired or mismatched appId entries. */
|
||||
export function loadSession(accountId: string, expectedAppId?: string): SessionState | null {
|
||||
try {
|
||||
let filePath: string | null = null;
|
||||
for (const candidatePath of getCandidateSessionPaths(accountId)) {
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
filePath = candidatePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
||||
debugLog(
|
||||
`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (expectedAppId && state.appId && state.appId !== expectedAppId) {
|
||||
debugLog(
|
||||
`[session-store] appId mismatch for ${accountId}: saved=${state.appId}, current=${expectedAppId}. Discarding stale session.`,
|
||||
);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
|
||||
debugLog(`[session-store] Invalid session data for ${accountId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, appId=${state.appId ?? "unknown"}, age=${Math.round((now - state.savedAt) / 1000)}s`,
|
||||
);
|
||||
return state;
|
||||
} catch (err) {
|
||||
debugError(`[session-store] Failed to load session for ${accountId}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save session state with throttling. */
|
||||
export function saveSession(state: SessionState): void {
|
||||
const { accountId } = state;
|
||||
|
||||
let throttle = throttleState.get(accountId);
|
||||
if (!throttle) {
|
||||
throttle = {
|
||||
pendingState: null,
|
||||
lastSaveTime: 0,
|
||||
throttleTimer: null,
|
||||
};
|
||||
throttleState.set(accountId, throttle);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastSave = now - throttle.lastSaveTime;
|
||||
|
||||
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
|
||||
doSaveSession(state);
|
||||
throttle.lastSaveTime = now;
|
||||
throttle.pendingState = null;
|
||||
|
||||
if (throttle.throttleTimer) {
|
||||
clearTimeout(throttle.throttleTimer);
|
||||
throttle.throttleTimer = null;
|
||||
}
|
||||
} else {
|
||||
throttle.pendingState = state;
|
||||
|
||||
if (!throttle.throttleTimer) {
|
||||
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
|
||||
throttle.throttleTimer = setTimeout(() => {
|
||||
const t = throttleState.get(accountId);
|
||||
if (t && t.pendingState) {
|
||||
doSaveSession(t.pendingState);
|
||||
t.lastSaveTime = Date.now();
|
||||
t.pendingState = null;
|
||||
}
|
||||
if (t) {
|
||||
t.throttleTimer = null;
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write one session file to disk immediately. */
|
||||
function doSaveSession(state: SessionState): void {
|
||||
const filePath = getSessionPath(state.accountId);
|
||||
const legacyPath = getLegacySessionPath(state.accountId);
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
|
||||
const stateToSave: SessionState = {
|
||||
...state,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
|
||||
if (legacyPath !== filePath && fs.existsSync(legacyPath)) {
|
||||
fs.unlinkSync(legacyPath);
|
||||
}
|
||||
debugLog(
|
||||
`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`,
|
||||
);
|
||||
} catch (err) {
|
||||
debugError(`[session-store] Failed to save session for ${state.accountId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a saved session and any pending throttle state. */
|
||||
export function clearSession(accountId: string): void {
|
||||
const throttle = throttleState.get(accountId);
|
||||
if (throttle) {
|
||||
if (throttle.throttleTimer) {
|
||||
clearTimeout(throttle.throttleTimer);
|
||||
}
|
||||
throttleState.delete(accountId);
|
||||
}
|
||||
|
||||
try {
|
||||
let cleared = false;
|
||||
for (const filePath of getCandidateSessionPaths(accountId)) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
cleared = true;
|
||||
}
|
||||
}
|
||||
if (cleared) {
|
||||
debugLog(`[session-store] Cleared session for ${accountId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
debugError(`[session-store] Failed to clear session for ${accountId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Update only lastSeq on the persisted session. */
|
||||
export function updateLastSeq(accountId: string, lastSeq: number): void {
|
||||
const existing = loadSession(accountId);
|
||||
if (existing && existing.sessionId) {
|
||||
saveSession({
|
||||
...existing,
|
||||
lastSeq,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Load all saved sessions from disk. */
|
||||
export function getAllSessions(): SessionState[] {
|
||||
const sessions = new Map<string, SessionState>();
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith("session-") && file.endsWith(".json")) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
if (typeof state.accountId !== "string" || !state.accountId) {
|
||||
continue;
|
||||
}
|
||||
const existing = sessions.get(state.accountId);
|
||||
if (!existing || (state.savedAt ?? 0) >= (existing.savedAt ?? 0)) {
|
||||
sessions.set(state.accountId, state);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed session files here.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing directories and similar filesystem errors.
|
||||
}
|
||||
|
||||
return [...sessions.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired session files from disk.
|
||||
*/
|
||||
export function cleanupExpiredSessions(): number {
|
||||
let cleaned = 0;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
const now = Date.now();
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith("session-") && file.endsWith(".json")) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
|
||||
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
||||
fs.unlinkSync(filePath);
|
||||
cleaned++;
|
||||
debugLog(`[session-store] Cleaned expired session: ${file}`);
|
||||
}
|
||||
} catch {
|
||||
// Remove corrupted session files while ignoring parse errors.
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
cleaned++;
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing directories and similar filesystem errors.
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
163
openclaw/extensions/qqbot/src/setup-surface.ts
Normal file
163
openclaw/extensions/qqbot/src/setup-surface.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
createStandardChannelSetupStatus,
|
||||
hasConfiguredSecretInput,
|
||||
setSetupChannelEnabled,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listQQBotAccountIds,
|
||||
resolveQQBotAccount,
|
||||
applyQQBotAccountConfig,
|
||||
} from "./config.js";
|
||||
|
||||
const channel = "qqbot" as const;
|
||||
|
||||
type QQBotEnvCredentialField = "appId" | "clientSecret";
|
||||
|
||||
/**
|
||||
* Clear only the credential fields owned by the setup prompt that switched to
|
||||
* env-backed resolution. This preserves mixed-source setups such as config
|
||||
* AppID + env AppSecret.
|
||||
*/
|
||||
function clearQQBotCredentialField(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
field: QQBotEnvCredentialField,
|
||||
): OpenClawConfig {
|
||||
const next = { ...cfg };
|
||||
const qqbot = { ...(next.channels?.qqbot as Record<string, unknown> | undefined) };
|
||||
|
||||
const clearField = (entry: Record<string, unknown>) => {
|
||||
if (field === "appId") {
|
||||
delete entry.appId;
|
||||
return;
|
||||
}
|
||||
delete entry.clientSecret;
|
||||
delete entry.clientSecretFile;
|
||||
};
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
clearField(qqbot);
|
||||
} else {
|
||||
const accounts = { ...(qqbot.accounts as Record<string, Record<string, unknown>> | undefined) };
|
||||
if (accounts[accountId]) {
|
||||
const entry = { ...accounts[accountId] };
|
||||
clearField(entry);
|
||||
accounts[accountId] = entry;
|
||||
qqbot.accounts = accounts;
|
||||
}
|
||||
}
|
||||
|
||||
next.channels = { ...next.channels, qqbot };
|
||||
return next;
|
||||
}
|
||||
|
||||
const QQBOT_SETUP_HELP_LINES = [
|
||||
"To create a QQ Bot, visit the QQ Open Platform:",
|
||||
` ${formatDocsLink("https://q.qq.com", "q.qq.com")}`,
|
||||
"",
|
||||
"1. Create an application and note the AppID.",
|
||||
"2. Go to development settings to find the AppSecret.",
|
||||
];
|
||||
|
||||
export const qqbotSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "QQ Bot",
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs AppID + AppSecret",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs AppID + AppSecret",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 6,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
(accountId ? [accountId] : listQQBotAccountIds(cfg)).some((resolvedAccountId) => {
|
||||
const account = resolveQQBotAccount(cfg, resolvedAccountId, {
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
return Boolean(
|
||||
account.appId &&
|
||||
(Boolean(account.clientSecret) ||
|
||||
hasConfiguredSecretInput(account.config.clientSecret) ||
|
||||
Boolean(account.config.clientSecretFile?.trim())),
|
||||
);
|
||||
}),
|
||||
}),
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "token",
|
||||
providerHint: channel,
|
||||
credentialLabel: "AppID",
|
||||
preferredEnvVar: "QQBOT_APP_ID",
|
||||
helpTitle: "QQ Bot AppID",
|
||||
helpLines: QQBOT_SETUP_HELP_LINES,
|
||||
envPrompt: "QQBOT_APP_ID detected. Use env var?",
|
||||
keepPrompt: "QQ Bot AppID already configured. Keep it?",
|
||||
inputPrompt: "Enter QQ Bot AppID",
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true });
|
||||
const hasConfiguredValue = Boolean(
|
||||
hasConfiguredSecretInput(resolved.config.clientSecret) ||
|
||||
normalizeOptionalString(resolved.config.clientSecretFile) ||
|
||||
resolved.clientSecret,
|
||||
);
|
||||
return {
|
||||
accountConfigured: Boolean(resolved.appId && hasConfiguredValue),
|
||||
hasConfiguredValue: Boolean(resolved.appId),
|
||||
resolvedValue: resolved.appId || undefined,
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? normalizeOptionalString(process.env.QQBOT_APP_ID)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
applyUseEnv: ({ cfg, accountId }) =>
|
||||
clearQQBotCredentialField(applyQQBotAccountConfig(cfg, accountId, {}), accountId, "appId"),
|
||||
applySet: ({ cfg, accountId, resolvedValue }) =>
|
||||
applyQQBotAccountConfig(cfg, accountId, { appId: resolvedValue }),
|
||||
},
|
||||
{
|
||||
inputKey: "password",
|
||||
providerHint: "qqbot-secret",
|
||||
credentialLabel: "AppSecret",
|
||||
preferredEnvVar: "QQBOT_CLIENT_SECRET",
|
||||
helpTitle: "QQ Bot AppSecret",
|
||||
helpLines: QQBOT_SETUP_HELP_LINES,
|
||||
envPrompt: "QQBOT_CLIENT_SECRET detected. Use env var?",
|
||||
keepPrompt: "QQ Bot AppSecret already configured. Keep it?",
|
||||
inputPrompt: "Enter QQ Bot AppSecret",
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true });
|
||||
const hasConfiguredValue = Boolean(
|
||||
hasConfiguredSecretInput(resolved.config.clientSecret) ||
|
||||
normalizeOptionalString(resolved.config.clientSecretFile) ||
|
||||
resolved.clientSecret,
|
||||
);
|
||||
return {
|
||||
accountConfigured: Boolean(resolved.appId && hasConfiguredValue),
|
||||
hasConfiguredValue,
|
||||
resolvedValue: resolved.clientSecret || undefined,
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? normalizeOptionalString(process.env.QQBOT_CLIENT_SECRET)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
applyUseEnv: ({ cfg, accountId }) =>
|
||||
clearQQBotCredentialField(
|
||||
applyQQBotAccountConfig(cfg, accountId, {}),
|
||||
accountId,
|
||||
"clientSecret",
|
||||
),
|
||||
applySet: ({ cfg, accountId, resolvedValue }) =>
|
||||
applyQQBotAccountConfig(cfg, accountId, { clientSecret: resolvedValue }),
|
||||
},
|
||||
],
|
||||
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
165
openclaw/extensions/qqbot/src/setup.test.ts
Normal file
165
openclaw/extensions/qqbot/src/setup.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./config.js";
|
||||
import { qqbotSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const qqbotSetupPlugin = {
|
||||
id: "qqbot",
|
||||
setupWizard: qqbotSetupWizard,
|
||||
meta: {
|
||||
...qqbotMeta,
|
||||
},
|
||||
config: {
|
||||
...qqbotConfigAdapter,
|
||||
},
|
||||
setup: {
|
||||
...qqbotSetupAdapterShared,
|
||||
},
|
||||
};
|
||||
|
||||
const getQQBotSetupStatus = createPluginSetupWizardStatus(qqbotSetupPlugin as never);
|
||||
|
||||
describe("qqbot setup", () => {
|
||||
it("treats SecretRef-backed default accounts as configured", () => {
|
||||
const configured = qqbotSetupWizard.status.resolveConfigured?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "QQBOT_CLIENT_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(true);
|
||||
});
|
||||
|
||||
it("treats named accounts with clientSecretFile as configured", () => {
|
||||
const configured = qqbotSetupWizard.status.resolveConfigured?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
clientSecretFile: "/tmp/qqbot-secret.txt",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(true);
|
||||
});
|
||||
|
||||
it("setup status honors the selected named account", async () => {
|
||||
const status = await getQQBotSetupStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "QQBOT_CLIENT_SECRET",
|
||||
},
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {
|
||||
qqbot: "bot2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["QQ Bot: needs AppID + AppSecret"]);
|
||||
});
|
||||
|
||||
it("marks unresolved SecretRef accounts as configured in setup-only plugin status", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "QQBOT_CLIENT_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const account = qqbotSetupPlugin.config.resolveAccount?.(cfg, DEFAULT_ACCOUNT_ID);
|
||||
|
||||
expect(account?.clientSecret).toBe("");
|
||||
expect(qqbotSetupPlugin.config.isConfigured?.(account)).toBe(true);
|
||||
expect(qqbotSetupPlugin.config.describeAccount?.(account)?.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the sibling credential when switching only AppSecret to env mode", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "123456",
|
||||
clientSecret: "secret-from-config",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const next = await qqbotSetupWizard.credentials[1].applyUseEnv!({
|
||||
cfg,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
|
||||
expect(next.channels?.qqbot).toMatchObject({
|
||||
appId: "123456",
|
||||
});
|
||||
expect("clientSecret" in (next.channels?.qqbot ?? {})).toBe(false);
|
||||
expect("clientSecretFile" in (next.channels?.qqbot ?? {})).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes account ids to lowercase", () => {
|
||||
const setup = qqbotSetupPlugin.setup;
|
||||
expect(setup).toBeDefined();
|
||||
|
||||
expect(
|
||||
setup.resolveAccountId?.({
|
||||
accountId: " Bot2 ",
|
||||
} as never),
|
||||
).toBe("bot2");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when setup accountId is omitted", () => {
|
||||
const setup = qqbotSetupPlugin.setup;
|
||||
expect(setup).toBeDefined();
|
||||
|
||||
expect(
|
||||
setup.resolveAccountId?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: { appId: "123456" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).toBe("bot2");
|
||||
});
|
||||
});
|
||||
180
openclaw/extensions/qqbot/src/slash-commands.test.ts
Normal file
180
openclaw/extensions/qqbot/src/slash-commands.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getFrameworkCommands,
|
||||
matchSlashCommand,
|
||||
type SlashCommandContext,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
/** Build a minimal SlashCommandContext for testing. */
|
||||
function buildCtx(overrides: Partial<SlashCommandContext> = {}): SlashCommandContext {
|
||||
return {
|
||||
type: "c2c",
|
||||
senderId: "test-user-001",
|
||||
messageId: "msg-001",
|
||||
eventTimestamp: new Date().toISOString(),
|
||||
receivedAt: Date.now(),
|
||||
rawContent: "/bot-ping",
|
||||
args: "",
|
||||
accountId: "default",
|
||||
appId: "000000",
|
||||
commandAuthorized: true,
|
||||
queueSnapshot: {
|
||||
totalPending: 0,
|
||||
activeUsers: 0,
|
||||
maxConcurrentUsers: 10,
|
||||
senderPending: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubEmptyLogFilesystem() {
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
vi.spyOn(fs, "readdirSync").mockReturnValue([] as never);
|
||||
vi.spyOn(fs, "statSync").mockImplementation(() => {
|
||||
throw new Error("missing");
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("slash command authorization", () => {
|
||||
// ---- /bot-logs (moved to framework registerCommand) ----
|
||||
// /bot-logs is registered with the framework via registerCommand() so that
|
||||
// resolveCommandAuthorization() enforces commands.allowFrom.qqbot precedence
|
||||
// and qqbot: prefix normalization. It is no longer in the pre-dispatch
|
||||
// slash-command registry, so matchSlashCommand returns null and lets the
|
||||
// normal inbound queue handle it.
|
||||
|
||||
it("passes /bot-logs through to the framework (returns null)", async () => {
|
||||
const ctx = buildCtx({ rawContent: "/bot-logs", commandAuthorized: false });
|
||||
expect(await matchSlashCommand(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes /bot-logs ? through to the framework (returns null)", async () => {
|
||||
const ctx = buildCtx({ rawContent: "/bot-logs ?", commandAuthorized: false });
|
||||
expect(await matchSlashCommand(ctx)).toBeNull();
|
||||
});
|
||||
|
||||
// ---- /bot-ping (no requireAuth) ----
|
||||
|
||||
it("allows /bot-ping for unauthorized sender", async () => {
|
||||
const ctx = buildCtx({
|
||||
rawContent: "/bot-ping",
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const result = await matchSlashCommand(ctx);
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("pong");
|
||||
});
|
||||
|
||||
it("allows /bot-ping for authorized sender", async () => {
|
||||
const ctx = buildCtx({
|
||||
rawContent: "/bot-ping",
|
||||
commandAuthorized: true,
|
||||
});
|
||||
const result = await matchSlashCommand(ctx);
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("pong");
|
||||
});
|
||||
|
||||
// ---- /bot-help (no requireAuth) ----
|
||||
|
||||
it("allows /bot-help for unauthorized sender", async () => {
|
||||
const ctx = buildCtx({
|
||||
rawContent: "/bot-help",
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const result = await matchSlashCommand(ctx);
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("QQBot");
|
||||
});
|
||||
|
||||
// ---- /bot-version (no requireAuth) ----
|
||||
|
||||
it("allows /bot-version for unauthorized sender", async () => {
|
||||
const ctx = buildCtx({
|
||||
rawContent: "/bot-version",
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const result = await matchSlashCommand(ctx);
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("OpenClaw");
|
||||
});
|
||||
|
||||
// ---- unknown commands ----
|
||||
|
||||
it("returns null for unknown slash commands", async () => {
|
||||
const ctx = buildCtx({
|
||||
rawContent: "/unknown-command",
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const result = await matchSlashCommand(ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-slash messages", async () => {
|
||||
const ctx = buildCtx({
|
||||
rawContent: "hello",
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const result = await matchSlashCommand(ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ---- usage query (?) for remaining pre-dispatch commands ----
|
||||
});
|
||||
|
||||
describe("/bot-logs framework command hardening", () => {
|
||||
function getBotLogsHandler() {
|
||||
const command = getFrameworkCommands().find((item) => item.name === "bot-logs");
|
||||
expect(command).toBeDefined();
|
||||
return command!.handler;
|
||||
}
|
||||
|
||||
it("rejects /bot-logs when allowFrom is wildcard", async () => {
|
||||
const handler = getBotLogsHandler();
|
||||
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["*"] } }));
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("权限不足");
|
||||
});
|
||||
|
||||
it("rejects /bot-logs when allowFrom mixes wildcard and explicit entries", async () => {
|
||||
const handler = getBotLogsHandler();
|
||||
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["*", "qqbot:user-1"] } }));
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("权限不足");
|
||||
});
|
||||
|
||||
it("rejects /bot-logs when allowFrom uses qqbot:* wildcard form", async () => {
|
||||
const handler = getBotLogsHandler();
|
||||
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:*"] } }));
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("权限不足");
|
||||
});
|
||||
|
||||
it("rejects /bot-logs when allowFrom uses qqbot: * wildcard form", async () => {
|
||||
const handler = getBotLogsHandler();
|
||||
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot: *"] } }));
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result as string).toContain("权限不足");
|
||||
});
|
||||
|
||||
it("allows /bot-logs when allowFrom contains numeric sender ids", async () => {
|
||||
stubEmptyLogFilesystem();
|
||||
const handler = getBotLogsHandler();
|
||||
const accountConfig = { allowFrom: [12345] } as unknown as SlashCommandContext["accountConfig"];
|
||||
const result = await handler(buildCtx({ accountConfig }));
|
||||
expect(result).toContain("未找到日志文件");
|
||||
});
|
||||
|
||||
it("allows /bot-logs execution when allowFrom is explicit", async () => {
|
||||
stubEmptyLogFilesystem();
|
||||
const handler = getBotLogsHandler();
|
||||
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:user-1"] } }));
|
||||
expect(result).toContain("未找到日志文件");
|
||||
});
|
||||
});
|
||||
667
openclaw/extensions/qqbot/src/slash-commands.ts
Normal file
667
openclaw/extensions/qqbot/src/slash-commands.ts
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
/**
|
||||
* QQBot plugin-level slash command handler.
|
||||
*
|
||||
* Design goals:
|
||||
* 1. Intercept plugin commands before messages enter the AI queue.
|
||||
* 2. Let unmatched "/" messages continue through the normal framework path.
|
||||
* 3. Keep command registration small and explicit.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { QQBotAccountConfig } from "./types.js";
|
||||
import { debugLog } from "./utils/debug-log.js";
|
||||
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
|
||||
const require = createRequire(import.meta.url);
|
||||
const PACKAGE_JSON_CANDIDATES = [
|
||||
"../package.json",
|
||||
"./package.json",
|
||||
"../../package.json",
|
||||
] as const;
|
||||
|
||||
function readPluginVersion(): string {
|
||||
for (const candidate of PACKAGE_JSON_CANDIDATES) {
|
||||
try {
|
||||
const version = (require(candidate) as { version?: unknown }).version;
|
||||
if (typeof version === "string" && version.trim().length > 0) {
|
||||
return version;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing candidate paths across source and bundled layouts.
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Read the package version from package.json.
|
||||
const PLUGIN_VERSION = readPluginVersion();
|
||||
|
||||
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";
|
||||
|
||||
// ============ 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?: QQBotAccountConfig;
|
||||
/** Whether the sender is authorized per the allowFrom config. */
|
||||
commandAuthorized: boolean;
|
||||
/** Queue snapshot for the current sender. */
|
||||
queueSnapshot: QueueSnapshot;
|
||||
}
|
||||
|
||||
/** Queue status snapshot. */
|
||||
export interface QueueSnapshot {
|
||||
/** Total pending messages across all sender queues. */
|
||||
totalPending: number;
|
||||
/** Number of senders currently being processed. */
|
||||
activeUsers: number;
|
||||
/** Maximum concurrent sender count. */
|
||||
maxConcurrentUsers: number;
|
||||
/** Pending messages for the current sender. */
|
||||
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. */
|
||||
export 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;
|
||||
/** 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;
|
||||
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
|
||||
}
|
||||
|
||||
function normalizeCommandAllowlistEntry(entry: unknown): string {
|
||||
if (
|
||||
typeof entry === "string" ||
|
||||
typeof entry === "number" ||
|
||||
typeof entry === "boolean" ||
|
||||
typeof entry === "bigint"
|
||||
) {
|
||||
return `${entry}`
|
||||
.trim()
|
||||
.replace(/^qqbot:\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasExplicitCommandAllowlist(accountConfig?: QQBotAccountConfig): boolean {
|
||||
const allowFrom = accountConfig?.allowFrom;
|
||||
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return allowFrom.every((entry) => {
|
||||
const normalized = normalizeCommandAllowlistEntry(entry);
|
||||
return normalized.length > 0 && normalized !== "*";
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Command registry ============
|
||||
|
||||
// Pre-dispatch commands (requireAuth: false) — handled immediately before queuing.
|
||||
const commands: Map<string, SlashCommand> = new Map();
|
||||
|
||||
// Framework commands (requireAuth: true) — registered via api.registerCommand() so that
|
||||
// resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence and
|
||||
// qqbot: prefix normalization before the handler runs.
|
||||
const frameworkCommands: Map<string, SlashCommand> = new Map();
|
||||
|
||||
function registerCommand(cmd: SlashCommand): void {
|
||||
if (cmd.requireAuth) {
|
||||
frameworkCommands.set(normalizeLowercaseStringOrEmpty(cmd.name), cmd);
|
||||
} else {
|
||||
commands.set(normalizeLowercaseStringOrEmpty(cmd.name), cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all commands that require authorization, for registration with the
|
||||
* framework via api.registerCommand() in registerFull().
|
||||
*/
|
||||
export function getFrameworkCommands(): QQBotFrameworkCommand[] {
|
||||
return Array.from(frameworkCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
usage: cmd.usage,
|
||||
handler: cmd.handler,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============ Built-in commands ============
|
||||
|
||||
/**
|
||||
* /bot-ping — test current network latency between OpenClaw and QQ.
|
||||
*/
|
||||
registerCommand({
|
||||
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 (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");
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* /bot-version — show the OpenClaw framework version.
|
||||
*/
|
||||
registerCommand({
|
||||
name: "bot-version",
|
||||
description: "查看 OpenClaw 框架版本",
|
||||
usage: [`/bot-version`, ``, `查看当前 OpenClaw 框架版本。`].join("\n"),
|
||||
handler: async () => {
|
||||
const frameworkVersion = resolveRuntimeServiceVersion();
|
||||
const lines = [`🦞 OpenClaw 版本:${frameworkVersion}`];
|
||||
lines.push(`🌟 官方 GitHub 仓库:[点击前往](${QQBOT_PLUGIN_GITHUB_URL})`);
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* /bot-upgrade — show the upgrade guide.
|
||||
*/
|
||||
registerCommand({
|
||||
name: "bot-upgrade",
|
||||
description: "查看 QQBot 升级指引",
|
||||
usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"),
|
||||
handler: () =>
|
||||
[`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"),
|
||||
});
|
||||
|
||||
/**
|
||||
* /bot-help — list all built-in QQBot commands.
|
||||
*/
|
||||
registerCommand({
|
||||
name: "bot-help",
|
||||
description: "查看所有内置命令",
|
||||
usage: [
|
||||
`/bot-help`,
|
||||
``,
|
||||
`查看所有可用的 QQBot 内置命令及其简要说明。`,
|
||||
`在命令后追加 ? 可查看详细用法。`,
|
||||
].join("\n"),
|
||||
handler: () => {
|
||||
const lines = [`### QQBot 内置命令`, ``];
|
||||
for (const [name, cmd] of commands) {
|
||||
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
||||
}
|
||||
for (const [name, cmd] of frameworkCommands) {
|
||||
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
},
|
||||
});
|
||||
|
||||
/** 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`);
|
||||
if (!fs.existsSync(cfgPath)) {
|
||||
continue;
|
||||
}
|
||||
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// Common Linux log directories under /var/log.
|
||||
if (!isWindows()) {
|
||||
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
|
||||
pushDir(path.join("/var/log", name));
|
||||
}
|
||||
}
|
||||
|
||||
// Temporary directories may also contain gateway logs.
|
||||
const tmpRoots = new Set<string>();
|
||||
if (isWindows()) {
|
||||
// Windows temp locations.
|
||||
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 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.
|
||||
}
|
||||
};
|
||||
|
||||
// Highest priority: explicit logging.file paths from config.
|
||||
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.
|
||||
* Uses a reverse-read strategy: reads fixed-size chunks from the end of the
|
||||
* file until the requested number of newline characters are found.
|
||||
*
|
||||
* Also estimates the total line count from the file size and the average bytes
|
||||
* per line observed in the tail portion (exact count is not feasible for
|
||||
* multi-GB files without a full scan).
|
||||
*/
|
||||
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, and return the summary text plus the temp file path.
|
||||
*
|
||||
* Authorization is enforced upstream by the framework (registerCommand with
|
||||
* requireAuth:true); this function contains no auth logic.
|
||||
*
|
||||
* Returns a SlashCommandFileResult on success (text + filePath), or a plain
|
||||
* string error message when no logs are found or files cannot be read.
|
||||
*/
|
||||
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 = path.join(tmpDir, `bot-logs-${timestamp}.txt`);
|
||||
fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
|
||||
|
||||
const fileCount = recentFiles.length;
|
||||
const topSources = Array.from(new Set(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,
|
||||
};
|
||||
}
|
||||
|
||||
registerCommand({
|
||||
name: "bot-logs",
|
||||
description: "导出本地日志文件",
|
||||
requireAuth: true,
|
||||
usage: [
|
||||
`/bot-logs`,
|
||||
``,
|
||||
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
|
||||
`每个文件只保留最后 1000 行,并作为附件返回。`,
|
||||
].join("\n"),
|
||||
handler: (ctx) => {
|
||||
// Defense in depth: require an explicit QQ allowlist entry for log export.
|
||||
// This keeps `/bot-logs` closed when setup leaves allowFrom in permissive mode.
|
||||
if (!hasExplicitCommandAllowlist(ctx.accountConfig)) {
|
||||
return `⛔ 权限不足:请先在 channels.qqbot.allowFrom(或对应账号 allowFrom)中配置明确的发送者列表后再使用 /bot-logs。`;
|
||||
}
|
||||
return buildBotLogsResult();
|
||||
},
|
||||
});
|
||||
|
||||
// Slash command entry point.
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const content = ctx.rawContent.trim();
|
||||
if (!content.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the command name and trailing arguments.
|
||||
const spaceIdx = content.indexOf(" ");
|
||||
const cmdName = normalizeLowercaseStringOrEmpty(
|
||||
spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx),
|
||||
);
|
||||
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
|
||||
|
||||
const cmd = commands.get(cmdName);
|
||||
if (!cmd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gate sensitive commands behind the allowFrom authorization check.
|
||||
if (cmd.requireAuth && !ctx.commandAuthorized) {
|
||||
debugLog(
|
||||
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
|
||||
);
|
||||
return `⛔ 权限不足:/${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;
|
||||
const result = await cmd.handler(ctx);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return the plugin version for external callers. */
|
||||
export function getPluginVersion(): string {
|
||||
return PLUGIN_VERSION;
|
||||
}
|
||||
94
openclaw/extensions/qqbot/src/stt.ts
Normal file
94
openclaw/extensions/qqbot/src/stt.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* OpenAI-compatible STT used at the plugin layer.
|
||||
*
|
||||
* This avoids pushing raw WAV PCM into the framework media-understanding pipeline.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { asRecord, readString } from "./config-record-shared.js";
|
||||
import { sanitizeFileName } from "./utils/platform.js";
|
||||
|
||||
export interface STTConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export function resolveSTTConfig(cfg: Record<string, unknown>): STTConfig | null {
|
||||
const channels = asRecord(cfg.channels);
|
||||
const qqbot = asRecord(channels?.qqbot);
|
||||
const channelStt = asRecord(qqbot?.stt);
|
||||
const models = asRecord(cfg.models);
|
||||
const providers = asRecord(models?.providers);
|
||||
|
||||
// Prefer plugin-specific STT config.
|
||||
if (channelStt && channelStt.enabled !== false) {
|
||||
const providerId = readString(channelStt, "provider") ?? "openai";
|
||||
const providerCfg = asRecord(providers?.[providerId]);
|
||||
const baseUrl = readString(channelStt, "baseUrl") ?? readString(providerCfg, "baseUrl");
|
||||
const apiKey = readString(channelStt, "apiKey") ?? readString(providerCfg, "apiKey");
|
||||
const model = readString(channelStt, "model") ?? "whisper-1";
|
||||
if (baseUrl && apiKey) {
|
||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to framework-level audio model config.
|
||||
const tools = asRecord(cfg.tools);
|
||||
const media = asRecord(tools?.media);
|
||||
const audio = asRecord(media?.audio);
|
||||
const audioModels = audio?.models;
|
||||
const audioModelEntry = Array.isArray(audioModels) ? asRecord(audioModels[0]) : undefined;
|
||||
if (audioModelEntry) {
|
||||
const providerId = readString(audioModelEntry, "provider") ?? "openai";
|
||||
const providerCfg = asRecord(providers?.[providerId]);
|
||||
const baseUrl = readString(audioModelEntry, "baseUrl") ?? readString(providerCfg, "baseUrl");
|
||||
const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey");
|
||||
const model = readString(audioModelEntry, "model") ?? "whisper-1";
|
||||
if (baseUrl && apiKey) {
|
||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, model };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function transcribeAudio(
|
||||
audioPath: string,
|
||||
cfg: Record<string, unknown>,
|
||||
): Promise<string | null> {
|
||||
const sttCfg = resolveSTTConfig(cfg);
|
||||
if (!sttCfg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(audioPath);
|
||||
const fileName = sanitizeFileName(path.basename(audioPath));
|
||||
const mime = fileName.endsWith(".wav")
|
||||
? "audio/wav"
|
||||
: fileName.endsWith(".mp3")
|
||||
? "audio/mpeg"
|
||||
: fileName.endsWith(".ogg")
|
||||
? "audio/ogg"
|
||||
: "application/octet-stream";
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([fileBuffer], { type: mime }), fileName);
|
||||
form.append("model", sttCfg.model);
|
||||
|
||||
const resp = await fetch(`${sttCfg.baseUrl}/audio/transcriptions`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => "");
|
||||
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const result = (await resp.json()) as { text?: string };
|
||||
return normalizeOptionalString(result.text) ?? null;
|
||||
}
|
||||
14
openclaw/extensions/qqbot/src/text-utils.ts
Normal file
14
openclaw/extensions/qqbot/src/text-utils.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { getQQBotRuntime } from "./runtime.js";
|
||||
|
||||
/** Maximum text length for a single QQ Bot message. */
|
||||
export const TEXT_CHUNK_LIMIT = 5000;
|
||||
|
||||
/**
|
||||
* Markdown-aware text chunking.
|
||||
*
|
||||
* Delegates to the SDK chunker so code fences and bracket balance stay intact.
|
||||
*/
|
||||
export function chunkText(text: string, limit: number): string[] {
|
||||
const runtime = getQQBotRuntime();
|
||||
return runtime.channel.text.chunkMarkdownText(text, limit);
|
||||
}
|
||||
250
openclaw/extensions/qqbot/src/tools/channel.ts
Normal file
250
openclaw/extensions/qqbot/src/tools/channel.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { getAccessToken } from "../api.js";
|
||||
import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js";
|
||||
import { debugError, debugLog } from "../utils/debug-log.js";
|
||||
|
||||
const API_BASE = "https://api.sgroup.qq.com";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
interface ChannelApiParams {
|
||||
method: string;
|
||||
path: string;
|
||||
body?: Record<string, unknown>;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
|
||||
const ChannelApiSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
method: {
|
||||
type: "string",
|
||||
description: "HTTP method. Allowed values: GET, POST, PUT, PATCH, DELETE.",
|
||||
enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"API path without the host. Replace placeholders with concrete values. " +
|
||||
"Examples: /users/@me/guilds, /guilds/{guild_id}/channels, /channels/{channel_id}.",
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
description:
|
||||
"JSON request body for POST/PUT/PATCH requests. GET/DELETE usually do not need it.",
|
||||
},
|
||||
query: {
|
||||
type: "object",
|
||||
description:
|
||||
"URL query parameters as key/value pairs appended to the path. " +
|
||||
'For example, { "limit": "100", "after": "0" } becomes ?limit=100&after=0.',
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["method", "path"],
|
||||
} as const;
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: Record<string, string>): string {
|
||||
let url = `${API_BASE}${path}`;
|
||||
if (query && Object.keys(query).length > 0) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
params.set(key, value);
|
||||
}
|
||||
}
|
||||
const qs = params.toString();
|
||||
if (qs) {
|
||||
url += `?${qs}`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function validatePath(path: string): string | null {
|
||||
if (!path.startsWith("/")) {
|
||||
return "path must start with /";
|
||||
}
|
||||
if (path.includes("..") || path.includes("//")) {
|
||||
return "path must not contain .. or //";
|
||||
}
|
||||
if (!/^\/[a-zA-Z0-9\-._~:@!$&'()*+,;=/%]+$/.test(path) && path !== "/") {
|
||||
return "path contains unsupported characters";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
debugLog("[qqbot-channel-api] No config available, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const accountIds = listQQBotAccountIds(cfg);
|
||||
if (accountIds.length === 0) {
|
||||
debugLog("[qqbot-channel-api] No QQBot accounts configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccountId = accountIds[0];
|
||||
const account = resolveQQBotAccount(cfg, firstAccountId);
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
debugLog("[qqbot-channel-api] Account not fully configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: "qqbot_channel_api",
|
||||
label: "QQBot Channel API",
|
||||
description:
|
||||
"Authenticated HTTP proxy for QQ Open Platform channel APIs. " +
|
||||
"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 p = params as ChannelApiParams;
|
||||
if (!p.method) {
|
||||
return json({ error: "method is required" });
|
||||
}
|
||||
if (!p.path) {
|
||||
return json({ error: "path is required" });
|
||||
}
|
||||
|
||||
const method = p.method.toUpperCase();
|
||||
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
return json({
|
||||
error: `Unsupported HTTP method: ${method}. Allowed values: GET, POST, PUT, PATCH, DELETE`,
|
||||
});
|
||||
}
|
||||
|
||||
const pathError = validatePath(p.path);
|
||||
if (pathError) {
|
||||
return json({ error: pathError });
|
||||
}
|
||||
|
||||
if ((method === "GET" || method === "DELETE") && p.body && Object.keys(p.body).length > 0) {
|
||||
debugLog(`[qqbot-channel-api] ${method} request with body, body will be ignored`);
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
const url = buildUrl(p.path, p.query);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `QQBot ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (p.body && ["POST", "PUT", "PATCH"].includes(method)) {
|
||||
fetchOptions.body = JSON.stringify(p.body);
|
||||
}
|
||||
|
||||
debugLog(`[qqbot-channel-api] >>> ${method} ${url} (timeout: ${DEFAULT_TIMEOUT_MS}ms)`);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, fetchOptions);
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
debugError(`[qqbot-channel-api] <<< Request timeout after ${DEFAULT_TIMEOUT_MS}ms`);
|
||||
return json({
|
||||
error: `Request timed out after ${DEFAULT_TIMEOUT_MS}ms`,
|
||||
path: p.path,
|
||||
});
|
||||
}
|
||||
debugError("[qqbot-channel-api] <<< Network error:", err);
|
||||
return json({
|
||||
error: `Network error: ${formatErrorMessage(err)}`,
|
||||
path: p.path,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
debugLog(`[qqbot-channel-api] <<< Status: ${res.status} ${res.statusText}`);
|
||||
|
||||
const rawBody = await res.text();
|
||||
if (!rawBody || rawBody.trim() === "") {
|
||||
if (res.ok) {
|
||||
return json({ success: true, status: res.status, path: p.path });
|
||||
}
|
||||
return json({
|
||||
error: `API returned ${res.status} ${res.statusText}`,
|
||||
status: res.status,
|
||||
path: p.path,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
parsed = rawBody;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errMsg =
|
||||
typeof parsed === "object" && parsed && "message" in parsed
|
||||
? String((parsed as { message?: unknown }).message)
|
||||
: `${res.status} ${res.statusText}`;
|
||||
debugError(`[qqbot-channel-api] Error [${method} ${p.path}]: ${errMsg}`);
|
||||
return json({
|
||||
error: errMsg,
|
||||
status: res.status,
|
||||
path: p.path,
|
||||
details: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
status: res.status,
|
||||
path: p.path,
|
||||
data: parsed,
|
||||
});
|
||||
} catch (err) {
|
||||
return json({
|
||||
error: formatErrorMessage(err),
|
||||
path: p.path,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "qqbot_channel_api" },
|
||||
);
|
||||
}
|
||||
260
openclaw/extensions/qqbot/src/tools/remind.ts
Normal file
260
openclaw/extensions/qqbot/src/tools/remind.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
interface RemindParams {
|
||||
action: "add" | "list" | "remove";
|
||||
content?: string;
|
||||
to?: string;
|
||||
time?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
const RemindSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
description:
|
||||
"Action type. add=create a reminder, list=show reminders, remove=delete a reminder.",
|
||||
enum: ["add", "list", "remove"],
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description:
|
||||
'Reminder content, for example "drink water" or "join the meeting". Required when action=add.',
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description:
|
||||
"Delivery target from the `[QQBot] to=` context value. " +
|
||||
"Direct-message format: qqbot:c2c:user_openid. Group format: qqbot:group:group_openid. Required when action=add.",
|
||||
},
|
||||
time: {
|
||||
type: "string",
|
||||
description:
|
||||
"Time description. Supported formats:\n" +
|
||||
'1. Relative time, for example "5m", "1h", "1h30m", or "2d"\n' +
|
||||
'2. Cron expression, for example "0 8 * * *" or "0 9 * * 1-5"\n' +
|
||||
"Values containing spaces are treated as cron expressions; everything else is treated as a one-shot relative delay.\n" +
|
||||
"Required when action=add.",
|
||||
},
|
||||
timezone: {
|
||||
type: "string",
|
||||
description: 'Timezone used for cron reminders. Defaults to "Asia/Shanghai".',
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Optional reminder job name. Defaults to the first 20 characters of content.",
|
||||
},
|
||||
jobId: {
|
||||
type: "string",
|
||||
description: "Job ID to remove. Required when action=remove; fetch it with list first.",
|
||||
},
|
||||
},
|
||||
required: ["action"],
|
||||
} as const;
|
||||
|
||||
function json(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRelativeTime(timeStr: string): number | null {
|
||||
const s = normalizeLowercaseStringOrEmpty(timeStr);
|
||||
if (/^\d+$/.test(s)) {
|
||||
return parseInt(s, 10) * 60_000;
|
||||
}
|
||||
|
||||
let totalMs = 0;
|
||||
let matched = false;
|
||||
const regex = /(\d+(?:\.\d+)?)\s*(d|h|m|s)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(s)) !== null) {
|
||||
matched = true;
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
switch (unit) {
|
||||
case "d":
|
||||
totalMs += value * 86_400_000;
|
||||
break;
|
||||
case "h":
|
||||
totalMs += value * 3_600_000;
|
||||
break;
|
||||
case "m":
|
||||
totalMs += value * 60_000;
|
||||
break;
|
||||
case "s":
|
||||
totalMs += value * 1_000;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return matched ? Math.round(totalMs) : null;
|
||||
}
|
||||
|
||||
function isCronExpression(timeStr: string): boolean {
|
||||
const parts = timeStr.trim().split(/\s+/);
|
||||
if (parts.length < 3 || parts.length > 6) {
|
||||
return false;
|
||||
}
|
||||
// Each cron field must start with a digit, *, or a cron-special character.
|
||||
return parts.every((p) => /^[0-9*?/,LW#-]/.test(p));
|
||||
}
|
||||
|
||||
function generateJobName(content: string): string {
|
||||
const trimmed = content.trim();
|
||||
const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}…` : trimmed;
|
||||
return `Reminder: ${short}`;
|
||||
}
|
||||
|
||||
function buildReminderPrompt(content: string): string {
|
||||
return (
|
||||
`You are a warm reminder assistant. Please remind the user about: ${content}. ` +
|
||||
`Requirements: (1) do not reply with HEARTBEAT_OK (2) do not explain who you are ` +
|
||||
`(3) output a direct and caring reminder message (4) you may add a short encouraging line ` +
|
||||
`(5) keep it within 2-3 sentences (6) use a small amount of emoji.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildOnceJob(params: RemindParams, delayMs: number) {
|
||||
const atMs = Date.now() + delayMs;
|
||||
const to = params.to!;
|
||||
const content = params.content!;
|
||||
const name = params.name || generateJobName(content);
|
||||
return {
|
||||
action: "add",
|
||||
job: {
|
||||
name,
|
||||
schedule: { kind: "at", atMs },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
deleteAfterRun: true,
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: buildReminderPrompt(content),
|
||||
deliver: true,
|
||||
channel: "qqbot",
|
||||
to,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCronJob(params: RemindParams) {
|
||||
const to = params.to!;
|
||||
const content = params.content!;
|
||||
const name = params.name || generateJobName(content);
|
||||
const tz = params.timezone || "Asia/Shanghai";
|
||||
return {
|
||||
action: "add",
|
||||
job: {
|
||||
name,
|
||||
schedule: { kind: "cron", expr: params.time!.trim(), tz },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: buildReminderPrompt(content),
|
||||
deliver: true,
|
||||
channel: "qqbot",
|
||||
to,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatDelay(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds}s`;
|
||||
}
|
||||
const totalMinutes = Math.round(ms / 60_000);
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (minutes === 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
return `${hours}h${minutes}m`;
|
||||
}
|
||||
|
||||
export function registerRemindTool(api: OpenClawPluginApi): void {
|
||||
api.registerTool(
|
||||
{
|
||||
name: "qqbot_remind",
|
||||
label: "QQBot Reminder",
|
||||
description:
|
||||
"Create, list, and remove QQ reminders. " +
|
||||
"Use simple parameters without manually building cron JSON.\n" +
|
||||
"Create: action=add, content=message, to=target, time=schedule\n" +
|
||||
"List: action=list\n" +
|
||||
"Remove: action=remove, jobId=job id from list\n" +
|
||||
'Time examples: "5m", "1h", "0 8 * * *"',
|
||||
parameters: RemindSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const p = params as RemindParams;
|
||||
|
||||
if (p.action === "list") {
|
||||
return json({
|
||||
_instruction: "Use the cron tool immediately with the following parameters.",
|
||||
cronParams: { action: "list" },
|
||||
});
|
||||
}
|
||||
|
||||
if (p.action === "remove") {
|
||||
if (!p.jobId) {
|
||||
return json({
|
||||
error: "jobId is required when action=remove. Use action=list first.",
|
||||
});
|
||||
}
|
||||
return json({
|
||||
_instruction: "Use the cron tool immediately with the following parameters.",
|
||||
cronParams: { action: "remove", jobId: p.jobId },
|
||||
});
|
||||
}
|
||||
|
||||
if (!p.content) {
|
||||
return json({ error: "content is required when action=add" });
|
||||
}
|
||||
if (!p.to) {
|
||||
return json({ error: "to is required when action=add" });
|
||||
}
|
||||
if (!p.time) {
|
||||
return json({ error: "time is required when action=add" });
|
||||
}
|
||||
|
||||
if (isCronExpression(p.time)) {
|
||||
return json({
|
||||
_instruction:
|
||||
"Use the cron tool immediately with the following parameters, then tell the user the reminder has been scheduled.",
|
||||
cronParams: buildCronJob(p),
|
||||
summary: `⏰ Recurring reminder: "${p.content}" (${p.time}, tz=${p.timezone || "Asia/Shanghai"})`,
|
||||
});
|
||||
}
|
||||
|
||||
const delayMs = parseRelativeTime(p.time);
|
||||
if (delayMs == null) {
|
||||
return json({
|
||||
error: `Could not parse time format: ${p.time}. Use values like 5m, 1h, 1h30m, or a cron expression.`,
|
||||
});
|
||||
}
|
||||
if (delayMs < 30_000) {
|
||||
return json({ error: "Reminder delay must be at least 30 seconds" });
|
||||
}
|
||||
|
||||
return json({
|
||||
_instruction:
|
||||
"Use the cron tool immediately with the following parameters, then tell the user the reminder has been scheduled.",
|
||||
cronParams: buildOnceJob(p, delayMs),
|
||||
summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${p.content}"`,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ name: "qqbot_remind" },
|
||||
);
|
||||
}
|
||||
159
openclaw/extensions/qqbot/src/types.ts
Normal file
159
openclaw/extensions/qqbot/src/types.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
|
||||
/** QQ Bot base config. */
|
||||
export interface QQBotConfig {
|
||||
appId: string;
|
||||
clientSecret?: SecretInput;
|
||||
clientSecretFile?: string;
|
||||
}
|
||||
|
||||
/** Resolved QQ Bot account config used at runtime. */
|
||||
export interface ResolvedQQBotAccount {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
appId: string;
|
||||
clientSecret: string;
|
||||
secretSource: "config" | "file" | "env" | "none";
|
||||
/** Additional system prompt text. */
|
||||
systemPrompt?: string;
|
||||
/** Whether markdown output is enabled. Defaults to true. */
|
||||
markdownSupport: boolean;
|
||||
config: QQBotAccountConfig;
|
||||
}
|
||||
|
||||
/** QQ Bot account config from user settings. */
|
||||
export interface QQBotAccountConfig {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
appId?: string;
|
||||
clientSecret?: SecretInput;
|
||||
clientSecretFile?: string;
|
||||
allowFrom?: string[];
|
||||
/** Optional system prompt prepended to user messages. */
|
||||
systemPrompt?: string;
|
||||
/** Whether markdown output is enabled. Defaults to true. */
|
||||
markdownSupport?: boolean;
|
||||
/**
|
||||
* @deprecated Use audioFormatPolicy.uploadDirectFormats instead.
|
||||
* Legacy list of formats that can upload directly without SILK conversion.
|
||||
*/
|
||||
voiceDirectUploadFormats?: string[];
|
||||
/**
|
||||
* Audio format policy covering inbound STT and outbound upload behavior.
|
||||
*/
|
||||
audioFormatPolicy?: AudioFormatPolicy;
|
||||
/**
|
||||
* Whether public URLs should be uploaded to QQ directly. Defaults to true.
|
||||
*/
|
||||
urlDirectUpload?: boolean;
|
||||
/**
|
||||
* Upgrade guide URL returned by `/bot-upgrade`.
|
||||
*/
|
||||
upgradeUrl?: string;
|
||||
/**
|
||||
* Upgrade command mode.
|
||||
* - "doc": show an upgrade guide link
|
||||
* - "hot-reload": run an in-place npm update flow
|
||||
*/
|
||||
upgradeMode?: "doc" | "hot-reload";
|
||||
/**
|
||||
* Block streaming configuration.
|
||||
* - mode "partial" (default): enable block streaming for incremental delivery.
|
||||
* - mode "off": buffer the full response before sending.
|
||||
*/
|
||||
streaming?: {
|
||||
mode?: "off" | "partial";
|
||||
};
|
||||
}
|
||||
|
||||
/** Audio format policy controlling which formats can skip transcoding. */
|
||||
export interface AudioFormatPolicy {
|
||||
/**
|
||||
* Formats supported directly by the STT provider.
|
||||
*/
|
||||
sttDirectFormats?: string[];
|
||||
/**
|
||||
* Formats QQ accepts directly for outbound uploads.
|
||||
*/
|
||||
uploadDirectFormats?: string[];
|
||||
/**
|
||||
* Whether outbound audio transcoding is enabled. Defaults to true.
|
||||
*/
|
||||
transcodeEnabled?: boolean;
|
||||
}
|
||||
|
||||
/** Rich-media attachment metadata. */
|
||||
export interface MessageAttachment {
|
||||
content_type: string;
|
||||
filename?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
size?: number;
|
||||
url: string;
|
||||
voice_wav_url?: string;
|
||||
asr_refer_text?: string;
|
||||
}
|
||||
|
||||
/** C2C message event payload. */
|
||||
export interface C2CMessageEvent {
|
||||
author: {
|
||||
id: string;
|
||||
union_openid: string;
|
||||
user_openid: string;
|
||||
};
|
||||
content: string;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
message_scene?: {
|
||||
source: string;
|
||||
/** ext can contain ref_msg_idx and msg_idx values. */
|
||||
ext?: string[];
|
||||
};
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/** Guild @-message event payload. */
|
||||
export interface GuildMessageEvent {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
guild_id: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
author: {
|
||||
id: string;
|
||||
username?: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
member?: {
|
||||
nick?: string;
|
||||
joined_at?: string;
|
||||
};
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/** Group @-message event payload. */
|
||||
export interface GroupMessageEvent {
|
||||
author: {
|
||||
id: string;
|
||||
member_openid: string;
|
||||
};
|
||||
content: string;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
group_id: string;
|
||||
group_openid: string;
|
||||
message_scene?: {
|
||||
source: string;
|
||||
ext?: string[];
|
||||
};
|
||||
attachments?: MessageAttachment[];
|
||||
}
|
||||
|
||||
/** WebSocket event payload. */
|
||||
export interface WSPayload {
|
||||
op: number;
|
||||
d?: unknown;
|
||||
s?: number;
|
||||
t?: string;
|
||||
}
|
||||
12
openclaw/extensions/qqbot/src/types/silk-wasm.d.ts
vendored
Normal file
12
openclaw/extensions/qqbot/src/types/silk-wasm.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
declare module "silk-wasm" {
|
||||
export type SilkCodecResult = {
|
||||
data: Uint8Array;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export function isSilk(input: Uint8Array): boolean;
|
||||
|
||||
export function decode(input: Uint8Array, sampleRate: number): Promise<SilkCodecResult>;
|
||||
|
||||
export function encode(input: Uint8Array, sampleRate: number): Promise<SilkCodecResult>;
|
||||
}
|
||||
66
openclaw/extensions/qqbot/src/typing-keepalive.ts
Normal file
66
openclaw/extensions/qqbot/src/typing-keepalive.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/** Periodically refresh C2C typing state while a response is still in progress. */
|
||||
|
||||
import { sendC2CInputNotify } from "./api.js";
|
||||
|
||||
// Refresh every 50s for the QQ API's 60s input-notify window.
|
||||
export const TYPING_INTERVAL_MS = 50_000;
|
||||
export const TYPING_INPUT_SECOND = 60;
|
||||
|
||||
export class TypingKeepAlive {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly getToken: () => Promise<string>,
|
||||
private readonly clearCache: () => void,
|
||||
private readonly openid: string,
|
||||
private readonly msgId: string | undefined,
|
||||
private readonly log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
},
|
||||
private readonly logPrefix = "[qqbot]",
|
||||
) {}
|
||||
|
||||
/** Start periodic keep-alive sends. */
|
||||
start(): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
this.timer = setInterval(() => {
|
||||
if (this.stopped) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
this.send().catch(() => {});
|
||||
}, TYPING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop periodic keep-alive sends. */
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async send(): Promise<void> {
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
await sendC2CInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND);
|
||||
this.log?.debug?.(`${this.logPrefix} Typing keep-alive sent to ${this.openid}`);
|
||||
} catch (err) {
|
||||
try {
|
||||
this.clearCache();
|
||||
const token = await this.getToken();
|
||||
await sendC2CInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND);
|
||||
} catch {
|
||||
this.log?.debug?.(
|
||||
`${this.logPrefix} Typing keep-alive failed for ${this.openid}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
909
openclaw/extensions/qqbot/src/utils/audio-convert.ts
Normal file
909
openclaw/extensions/qqbot/src/utils/audio-convert.ts
Normal file
|
|
@ -0,0 +1,909 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { asRecord, readString } from "../config-record-shared.js";
|
||||
import { debugLog, debugError, debugWarn } from "./debug-log.js";
|
||||
import { detectFfmpeg, isWindows } from "./platform.js";
|
||||
|
||||
type SilkWasm = typeof import("silk-wasm");
|
||||
let _silkWasmPromise: Promise<SilkWasm | null> | null = null;
|
||||
|
||||
function loadSilkWasm(): Promise<SilkWasm | null> {
|
||||
if (_silkWasmPromise) {
|
||||
return _silkWasmPromise;
|
||||
}
|
||||
_silkWasmPromise = import("silk-wasm").catch((err) => {
|
||||
debugWarn(
|
||||
`[audio-convert] silk-wasm not available; SILK encode/decode disabled (${formatErrorMessage(err)})`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
return _silkWasmPromise;
|
||||
}
|
||||
|
||||
/** Wrap PCM s16le bytes in a WAV container. */
|
||||
function pcmToWav(
|
||||
pcmData: Uint8Array,
|
||||
sampleRate: number,
|
||||
channels: number = 1,
|
||||
bitsPerSample: number = 16,
|
||||
): Buffer {
|
||||
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
||||
const blockAlign = channels * (bitsPerSample / 8);
|
||||
const dataSize = pcmData.length;
|
||||
const headerSize = 44;
|
||||
const fileSize = headerSize + dataSize;
|
||||
|
||||
const buffer = Buffer.alloc(fileSize);
|
||||
|
||||
// RIFF header
|
||||
buffer.write("RIFF", 0);
|
||||
buffer.writeUInt32LE(fileSize - 8, 4);
|
||||
buffer.write("WAVE", 8);
|
||||
|
||||
// fmt sub-chunk
|
||||
buffer.write("fmt ", 12);
|
||||
buffer.writeUInt32LE(16, 16); // sub-chunk size
|
||||
buffer.writeUInt16LE(1, 20); // PCM format
|
||||
buffer.writeUInt16LE(channels, 22);
|
||||
buffer.writeUInt32LE(sampleRate, 24);
|
||||
buffer.writeUInt32LE(byteRate, 28);
|
||||
buffer.writeUInt16LE(blockAlign, 32);
|
||||
buffer.writeUInt16LE(bitsPerSample, 34);
|
||||
|
||||
// data sub-chunk
|
||||
buffer.write("data", 36);
|
||||
buffer.writeUInt32LE(dataSize, 40);
|
||||
Buffer.from(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength).copy(buffer, headerSize);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Strip a leading AMR header from QQ voice payloads when present. */
|
||||
function stripAmrHeader(buf: Buffer): Buffer {
|
||||
const AMR_HEADER = Buffer.from("#!AMR\n");
|
||||
if (buf.length > 6 && buf.subarray(0, 6).equals(AMR_HEADER)) {
|
||||
return buf.subarray(6);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Convert SILK or AMR voice files into WAV. */
|
||||
export async function convertSilkToWav(
|
||||
inputPath: string,
|
||||
outputDir?: string,
|
||||
): Promise<{ wavPath: string; duration: number } | null> {
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileBuf = fs.readFileSync(inputPath);
|
||||
|
||||
const strippedBuf = stripAmrHeader(fileBuf);
|
||||
|
||||
const rawData = new Uint8Array(
|
||||
strippedBuf.buffer,
|
||||
strippedBuf.byteOffset,
|
||||
strippedBuf.byteLength,
|
||||
);
|
||||
|
||||
const silk = await loadSilkWasm();
|
||||
if (!silk || !silk.isSilk(rawData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// QQ voice commonly uses 24 kHz.
|
||||
const sampleRate = 24000;
|
||||
const result = await silk.decode(rawData, sampleRate);
|
||||
|
||||
const wavBuffer = pcmToWav(result.data, sampleRate);
|
||||
|
||||
const dir = outputDir || path.dirname(inputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const baseName = path.basename(inputPath, path.extname(inputPath));
|
||||
const wavPath = path.join(dir, `${baseName}.wav`);
|
||||
fs.writeFileSync(wavPath, wavBuffer);
|
||||
|
||||
return { wavPath, duration: result.duration };
|
||||
}
|
||||
|
||||
/** Return true when an attachment looks like a voice file. */
|
||||
export function isVoiceAttachment(att: { content_type?: string; filename?: string }): boolean {
|
||||
if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) {
|
||||
return true;
|
||||
}
|
||||
const ext = att.filename ? normalizeLowercaseStringOrEmpty(path.extname(att.filename)) : "";
|
||||
return [".amr", ".silk", ".slk", ".slac"].includes(ext);
|
||||
}
|
||||
|
||||
/** Format a duration as a user-readable string. */
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const seconds = Math.round(durationMs / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainSeconds = seconds % 60;
|
||||
return remainSeconds > 0 ? `${minutes}m ${remainSeconds}s` : `${minutes}m`;
|
||||
}
|
||||
|
||||
export function isAudioFile(filePath: string, mimeType?: string): boolean {
|
||||
// Prefer MIME when extension data is missing or misleading.
|
||||
if (mimeType) {
|
||||
if (mimeType === "voice" || mimeType.startsWith("audio/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
||||
return [
|
||||
".silk",
|
||||
".slk",
|
||||
".amr",
|
||||
".wav",
|
||||
".mp3",
|
||||
".ogg",
|
||||
".opus",
|
||||
".aac",
|
||||
".flac",
|
||||
".m4a",
|
||||
".wma",
|
||||
".pcm",
|
||||
].includes(ext);
|
||||
}
|
||||
|
||||
/** Voice MIME types the QQ platform accepts without transcoding. */
|
||||
const QQ_NATIVE_VOICE_MIMES = new Set([
|
||||
"audio/silk",
|
||||
"audio/amr",
|
||||
"audio/wav",
|
||||
"audio/wave",
|
||||
"audio/x-wav",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
]);
|
||||
|
||||
/** Voice extensions the QQ platform accepts without transcoding. */
|
||||
const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]);
|
||||
|
||||
/**
|
||||
* Return true when voice input must be transcoded before upload.
|
||||
*/
|
||||
export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean {
|
||||
// Prefer MIME when it is available.
|
||||
if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) {
|
||||
return false;
|
||||
}
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
||||
if (QQ_NATIVE_VOICE_EXTS.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
return isAudioFile(filePath, mimeType);
|
||||
}
|
||||
|
||||
// TTS helpers.
|
||||
|
||||
export interface TTSConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
authStyle?: "bearer" | "api-key";
|
||||
queryParams?: Record<string, string>;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
type QQBotTtsProviderConfig = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
authStyle?: string;
|
||||
queryParams?: Record<string, string>;
|
||||
};
|
||||
|
||||
type QQBotTtsBlock = QQBotTtsProviderConfig & {
|
||||
model?: string;
|
||||
voice?: string;
|
||||
speed?: number;
|
||||
};
|
||||
|
||||
function readNumber(record: Record<string, unknown> | undefined, key: string): number | undefined {
|
||||
const value = record?.[key];
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function readStringMap(value: unknown): Record<string, string> {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(record).flatMap(([key, entryValue]) =>
|
||||
typeof entryValue === "string" ? [[key, entryValue]] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTTSFromBlock(
|
||||
block: QQBotTtsBlock,
|
||||
providerCfg: QQBotTtsProviderConfig | undefined,
|
||||
): TTSConfig | null {
|
||||
const baseUrl = readString(block, "baseUrl") ?? readString(providerCfg, "baseUrl");
|
||||
const apiKey = readString(block, "apiKey") ?? readString(providerCfg, "apiKey");
|
||||
const model = readString(block, "model") ?? "tts-1";
|
||||
const voice = readString(block, "voice") ?? "alloy";
|
||||
if (!baseUrl || !apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authStyle =
|
||||
(readString(block, "authStyle") ?? readString(providerCfg, "authStyle")) === "api-key"
|
||||
? ("api-key" as const)
|
||||
: ("bearer" as const);
|
||||
const queryParams: Record<string, string> = {
|
||||
...readStringMap(providerCfg?.queryParams),
|
||||
...readStringMap(block.queryParams),
|
||||
};
|
||||
const speed = readNumber(block, "speed");
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
apiKey,
|
||||
model,
|
||||
voice,
|
||||
authStyle,
|
||||
...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
|
||||
...(speed !== undefined ? { speed } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTTSConfig(cfg: Record<string, unknown>): TTSConfig | null {
|
||||
const models = asRecord(cfg.models);
|
||||
const providers = asRecord(models?.providers);
|
||||
|
||||
// Prefer plugin-specific TTS config first.
|
||||
const channels = asRecord(cfg.channels);
|
||||
const qqbot = asRecord(channels?.qqbot);
|
||||
const channelTts = asRecord(qqbot?.tts);
|
||||
if (channelTts && channelTts.enabled !== false) {
|
||||
const providerId = readString(channelTts, "provider") ?? "openai";
|
||||
const providerCfg = asRecord(providers?.[providerId]);
|
||||
const result = resolveTTSFromBlock(channelTts, providerCfg);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to framework-level TTS config.
|
||||
const messages = asRecord(cfg.messages);
|
||||
const msgTts = asRecord(messages?.tts);
|
||||
const autoMode = readString(msgTts, "auto");
|
||||
if (msgTts && autoMode !== "off" && autoMode !== "disabled") {
|
||||
const providerId = readString(msgTts, "provider") ?? "openai";
|
||||
const providerBlock = asRecord(msgTts[providerId]) ?? {};
|
||||
const providerCfg = asRecord(providers?.[providerId]);
|
||||
const result = resolveTTSFromBlock(providerBlock, providerCfg);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether global TTS is potentially available by inspecting the
|
||||
* framework-level `messages.tts` config. This mirrors the resolution logic
|
||||
* in the core `resolveTtsConfig`: when `auto` is set it must not be `"off"`;
|
||||
* when only the legacy `enabled` boolean is present it must be truthy;
|
||||
* when neither is set TTS defaults to off.
|
||||
*
|
||||
* This does NOT guarantee a specific provider is registered/configured – it
|
||||
* only checks that TTS is not explicitly (or implicitly) disabled.
|
||||
*/
|
||||
export function isGlobalTTSAvailable(cfg: OpenClawConfig): boolean {
|
||||
const msgTts = cfg.messages?.tts;
|
||||
if (!msgTts) {
|
||||
return false;
|
||||
}
|
||||
// Framework canonical field takes precedence.
|
||||
if (msgTts.auto) {
|
||||
return msgTts.auto !== "off";
|
||||
}
|
||||
// Legacy compat: `enabled: true` → "always", absent/false → "off".
|
||||
return msgTts.enabled === true;
|
||||
}
|
||||
|
||||
/** Build the TTS endpoint URL and auth headers. */
|
||||
function buildTTSRequest(ttsCfg: TTSConfig): { url: string; headers: Record<string, string> } {
|
||||
let url = `${ttsCfg.baseUrl}/audio/speech`;
|
||||
if (ttsCfg.queryParams && Object.keys(ttsCfg.queryParams).length > 0) {
|
||||
const qs = new URLSearchParams(ttsCfg.queryParams).toString();
|
||||
url += `?${qs}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (ttsCfg.authStyle === "api-key") {
|
||||
headers["api-key"] = ttsCfg.apiKey;
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${ttsCfg.apiKey}`;
|
||||
}
|
||||
|
||||
return { url, headers };
|
||||
}
|
||||
|
||||
export async function textToSpeechPCM(
|
||||
text: string,
|
||||
ttsCfg: TTSConfig,
|
||||
): Promise<{ pcmBuffer: Buffer; sampleRate: number }> {
|
||||
const sampleRate = 24000;
|
||||
const { url, headers } = buildTTSRequest(ttsCfg);
|
||||
|
||||
debugLog(
|
||||
`[tts] Request: model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, url=${url}`,
|
||||
);
|
||||
debugLog(
|
||||
`[tts] Input text (${text.length} chars): "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`,
|
||||
);
|
||||
|
||||
// Prefer PCM first to avoid an extra decode pass.
|
||||
const formats: Array<{ format: string; needsDecode: boolean }> = [
|
||||
{ format: "pcm", needsDecode: false },
|
||||
{ format: "mp3", needsDecode: true },
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const { format, needsDecode } of formats) {
|
||||
const controller = new AbortController();
|
||||
const ttsTimeout = setTimeout(() => controller.abort(), 120000);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model: ttsCfg.model,
|
||||
input: text,
|
||||
voice: ttsCfg.voice,
|
||||
response_format: format,
|
||||
...(format === "pcm" ? { sample_rate: sampleRate } : {}),
|
||||
...(ttsCfg.speed !== undefined ? { speed: ttsCfg.speed } : {}),
|
||||
};
|
||||
|
||||
debugLog(`[tts] Trying format=${format}...`);
|
||||
const fetchStart = Date.now();
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(ttsTimeout));
|
||||
|
||||
const fetchMs = Date.now() - fetchStart;
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text().catch(() => "");
|
||||
debugLog(
|
||||
`[tts] HTTP ${resp.status} for format=${format} (${fetchMs}ms): ${detail.slice(0, 200)}`,
|
||||
);
|
||||
// Some providers reject PCM but accept MP3, so retry there.
|
||||
if (format === "pcm" && (resp.status === 400 || resp.status === 422)) {
|
||||
debugLog(`[tts] PCM format not supported, falling back to mp3`);
|
||||
lastError = new Error(`TTS PCM not supported: ${detail.slice(0, 200)}`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`TTS failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
const rawBuffer = Buffer.from(arrayBuffer);
|
||||
debugLog(
|
||||
`[tts] Response OK: format=${format}, size=${rawBuffer.length} bytes, latency=${fetchMs}ms`,
|
||||
);
|
||||
|
||||
if (!needsDecode) {
|
||||
debugLog(
|
||||
`[tts] Done: PCM direct, ${rawBuffer.length} bytes, total=${Date.now() - startTime}ms`,
|
||||
);
|
||||
return { pcmBuffer: rawBuffer, sampleRate };
|
||||
}
|
||||
|
||||
// MP3 responses must be decoded back into PCM.
|
||||
debugLog(`[tts] Decoding mp3 response (${rawBuffer.length} bytes) to PCM...`);
|
||||
const tmpDir = path.join(fs.mkdtempSync(path.join(require("node:os").tmpdir(), "tts-")));
|
||||
const tmpMp3 = path.join(tmpDir, "tts.mp3");
|
||||
fs.writeFileSync(tmpMp3, rawBuffer);
|
||||
|
||||
try {
|
||||
// Prefer ffmpeg when it is available.
|
||||
const ffmpegCmd = await checkFfmpeg();
|
||||
if (ffmpegCmd) {
|
||||
const pcmBuf = await ffmpegToPCM(ffmpegCmd, tmpMp3, sampleRate);
|
||||
debugLog(
|
||||
`[tts] Done: mp3→PCM (ffmpeg), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`,
|
||||
);
|
||||
return { pcmBuffer: pcmBuf, sampleRate };
|
||||
}
|
||||
const pcmBuf = await wasmDecodeMp3ToPCM(rawBuffer, sampleRate);
|
||||
if (pcmBuf) {
|
||||
debugLog(
|
||||
`[tts] Done: mp3→PCM (wasm), ${pcmBuf.length} bytes, total=${Date.now() - startTime}ms`,
|
||||
);
|
||||
return { pcmBuffer: pcmBuf, sampleRate };
|
||||
}
|
||||
throw new Error("No decoder available for mp3 (install ffmpeg for best compatibility)");
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmpMp3);
|
||||
fs.rmdirSync(tmpDir);
|
||||
} catch {}
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(ttsTimeout);
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
debugLog(`[tts] Error for format=${format}: ${lastError.message.slice(0, 200)}`);
|
||||
if (format === "pcm") {
|
||||
continue;
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`[tts] All formats exhausted after ${Date.now() - startTime}ms`);
|
||||
throw lastError ?? new Error("TTS failed: all formats exhausted");
|
||||
}
|
||||
|
||||
export async function pcmToSilk(
|
||||
pcmBuffer: Buffer,
|
||||
sampleRate: number,
|
||||
): Promise<{ silkBuffer: Buffer; duration: number }> {
|
||||
const silk = await loadSilkWasm();
|
||||
if (!silk) {
|
||||
throw new Error("silk-wasm is not available; cannot encode PCM to SILK");
|
||||
}
|
||||
const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
|
||||
const result = await silk.encode(pcmData, sampleRate);
|
||||
return {
|
||||
silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
|
||||
duration: result.duration,
|
||||
};
|
||||
}
|
||||
|
||||
export async function textToSilk(
|
||||
text: string,
|
||||
ttsCfg: TTSConfig,
|
||||
outputDir: string,
|
||||
): Promise<{ silkPath: string; silkBase64: string; duration: number }> {
|
||||
const { pcmBuffer, sampleRate } = await textToSpeechPCM(text, ttsCfg);
|
||||
const { silkBuffer, duration } = await pcmToSilk(pcmBuffer, sampleRate);
|
||||
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const silkPath = path.join(outputDir, `tts-${Date.now()}.silk`);
|
||||
fs.writeFileSync(silkPath, silkBuffer);
|
||||
|
||||
return { silkPath, silkBase64: silkBuffer.toString("base64"), duration };
|
||||
}
|
||||
|
||||
// Generic audio -> SILK conversion.
|
||||
|
||||
/** Upload formats accepted directly by the QQ Bot API. */
|
||||
const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"];
|
||||
|
||||
/**
|
||||
* Convert a local audio file into an uploadable Base64 payload.
|
||||
*/
|
||||
export async function audioFileToSilkBase64(
|
||||
filePath: string,
|
||||
directUploadFormats?: string[],
|
||||
): Promise<string | null> {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buf = fs.readFileSync(filePath);
|
||||
if (buf.length === 0) {
|
||||
debugError(`[audio-convert] file is empty: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
||||
|
||||
const uploadFormats = directUploadFormats
|
||||
? normalizeFormats(directUploadFormats)
|
||||
: QQ_NATIVE_UPLOAD_FORMATS;
|
||||
if (uploadFormats.includes(ext)) {
|
||||
debugLog(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
|
||||
return buf.toString("base64");
|
||||
}
|
||||
|
||||
// Some .slk/.slac files are already SILK and can be uploaded directly.
|
||||
if ([".slk", ".slac"].includes(ext)) {
|
||||
const stripped = stripAmrHeader(buf);
|
||||
const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
|
||||
const silk = await loadSilkWasm();
|
||||
if (silk?.isSilk(raw)) {
|
||||
debugLog(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
|
||||
return buf.toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
// Also detect SILK by header, not just by extension.
|
||||
const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
const strippedCheck = stripAmrHeader(buf);
|
||||
const strippedRaw = new Uint8Array(
|
||||
strippedCheck.buffer,
|
||||
strippedCheck.byteOffset,
|
||||
strippedCheck.byteLength,
|
||||
);
|
||||
const silkForCheck = await loadSilkWasm();
|
||||
if (silkForCheck?.isSilk(rawCheck) || silkForCheck?.isSilk(strippedRaw)) {
|
||||
debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
|
||||
return buf.toString("base64");
|
||||
}
|
||||
|
||||
const targetRate = 24000;
|
||||
|
||||
// Prefer ffmpeg for broad codec coverage.
|
||||
const ffmpegCmd = await checkFfmpeg();
|
||||
if (ffmpegCmd) {
|
||||
try {
|
||||
debugLog(
|
||||
`[audio-convert] ffmpeg (${ffmpegCmd}): converting ${ext} (${buf.length} bytes) → PCM s16le ${targetRate}Hz`,
|
||||
);
|
||||
const pcmBuf = await ffmpegToPCM(ffmpegCmd, filePath, targetRate);
|
||||
if (pcmBuf.length === 0) {
|
||||
debugError(`[audio-convert] ffmpeg produced empty PCM output`);
|
||||
return null;
|
||||
}
|
||||
const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
|
||||
debugLog(`[audio-convert] ffmpeg: ${ext} → SILK done (${silkBuffer.length} bytes)`);
|
||||
return silkBuffer.toString("base64");
|
||||
} catch (err) {
|
||||
debugError(`[audio-convert] ffmpeg conversion failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to WASM decoders when ffmpeg is unavailable.
|
||||
debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`);
|
||||
|
||||
if (ext === ".pcm") {
|
||||
const pcmBuf = Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
|
||||
return silkBuffer.toString("base64");
|
||||
}
|
||||
|
||||
if (ext === ".wav" || (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF")) {
|
||||
const wavInfo = parseWavFallback(buf);
|
||||
if (wavInfo) {
|
||||
const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
|
||||
return silkBuffer.toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
if (ext === ".mp3" || ext === ".mpeg") {
|
||||
const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
|
||||
if (pcmBuf) {
|
||||
const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
|
||||
debugLog(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
|
||||
return silkBuffer.toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
const installHint = isWindows()
|
||||
? "Install ffmpeg with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org"
|
||||
: process.platform === "darwin"
|
||||
? "Install ffmpeg with brew install ffmpeg"
|
||||
: "Install ffmpeg with sudo apt install ffmpeg or sudo yum install ffmpeg";
|
||||
debugError(`[audio-convert] unsupported format: ${ext} (no ffmpeg available). ${installHint}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a file exists and its size has stabilized.
|
||||
*/
|
||||
export async function waitForFile(
|
||||
filePath: string,
|
||||
timeoutMs: number = 30000,
|
||||
pollMs: number = 500,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
let lastSize = -1;
|
||||
let stableCount = 0;
|
||||
let fileExists = false;
|
||||
let fileAppearedAt = 0;
|
||||
let pollCount = 0;
|
||||
|
||||
const emptyGiveUpMs = 10000;
|
||||
const noFileGiveUpMs = 15000;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
pollCount++;
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!fileExists) {
|
||||
fileExists = true;
|
||||
fileAppearedAt = Date.now();
|
||||
debugLog(
|
||||
`[audio-convert] waitForFile: file appeared (${stat.size} bytes, after ${Date.now() - start}ms): ${path.basename(filePath)}`,
|
||||
);
|
||||
}
|
||||
if (stat.size > 0) {
|
||||
if (stat.size === lastSize) {
|
||||
stableCount++;
|
||||
if (stableCount >= 2) {
|
||||
debugLog(
|
||||
`[audio-convert] waitForFile: ready (${stat.size} bytes, waited ${Date.now() - start}ms, polls=${pollCount})`,
|
||||
);
|
||||
return stat.size;
|
||||
}
|
||||
} else {
|
||||
stableCount = 0;
|
||||
}
|
||||
lastSize = stat.size;
|
||||
} else {
|
||||
if (Date.now() - fileAppearedAt > emptyGiveUpMs) {
|
||||
debugError(
|
||||
`[audio-convert] waitForFile: file still empty after ${emptyGiveUpMs}ms, giving up: ${path.basename(filePath)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!fileExists && Date.now() - start > noFileGiveUpMs) {
|
||||
debugError(
|
||||
`[audio-convert] waitForFile: file never appeared after ${noFileGiveUpMs}ms, giving up: ${path.basename(filePath)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
}
|
||||
|
||||
try {
|
||||
const finalStat = fs.statSync(filePath);
|
||||
if (finalStat.size > 0) {
|
||||
debugWarn(
|
||||
`[audio-convert] waitForFile: timeout but file has data (${finalStat.size} bytes), using it`,
|
||||
);
|
||||
return finalStat.size;
|
||||
}
|
||||
debugError(
|
||||
`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file exists but empty (0 bytes): ${path.basename(filePath)}`,
|
||||
);
|
||||
} catch {
|
||||
debugError(
|
||||
`[audio-convert] waitForFile: timeout after ${timeoutMs}ms, file never appeared: ${path.basename(filePath)}`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Delegate ffmpeg detection to the platform helper. */
|
||||
async function checkFfmpeg(): Promise<string | null> {
|
||||
return detectFfmpeg();
|
||||
}
|
||||
|
||||
/** Convert arbitrary audio into mono 24 kHz PCM s16le with ffmpeg. */
|
||||
function ffmpegToPCM(
|
||||
ffmpegCmd: string,
|
||||
inputPath: string,
|
||||
sampleRate: number = 24000,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"-i",
|
||||
inputPath,
|
||||
"-f",
|
||||
"s16le",
|
||||
"-ar",
|
||||
String(sampleRate),
|
||||
"-ac",
|
||||
"1",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-v",
|
||||
"error",
|
||||
"pipe:1",
|
||||
];
|
||||
execFile(
|
||||
ffmpegCmd,
|
||||
args,
|
||||
{
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
encoding: "buffer",
|
||||
...(isWindows() ? { windowsHide: true } : {}),
|
||||
},
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
reject(new Error(`ffmpeg failed: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
resolve(stdout as unknown as Buffer);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type MpegDecoderConstructor = typeof import("mpg123-decoder").MPEGDecoder;
|
||||
|
||||
let mpegDecoderConstructorPromise: Promise<MpegDecoderConstructor> | null = null;
|
||||
|
||||
async function loadMpegDecoderConstructor(): Promise<MpegDecoderConstructor> {
|
||||
mpegDecoderConstructorPromise ??= import("mpg123-decoder").then(({ MPEGDecoder }) => MPEGDecoder);
|
||||
return mpegDecoderConstructorPromise;
|
||||
}
|
||||
|
||||
/** Decode MP3 into PCM through mpg123-decoder when ffmpeg is unavailable. */
|
||||
async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise<Buffer | null> {
|
||||
try {
|
||||
const MPEGDecoder = await loadMpegDecoderConstructor();
|
||||
debugLog(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`);
|
||||
const decoder = new MPEGDecoder();
|
||||
await decoder.ready;
|
||||
|
||||
const decoded = decoder.decode(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
|
||||
decoder.free();
|
||||
|
||||
if (decoded.samplesDecoded === 0 || decoded.channelData.length === 0) {
|
||||
debugError(
|
||||
`[audio-convert] WASM MP3 decode: no samples (samplesDecoded=${decoded.samplesDecoded})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
debugLog(
|
||||
`[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`,
|
||||
);
|
||||
|
||||
// Down-mix multi-channel float PCM into mono.
|
||||
let floatMono: Float32Array;
|
||||
if (decoded.channelData.length === 1) {
|
||||
floatMono = decoded.channelData[0];
|
||||
} else {
|
||||
floatMono = new Float32Array(decoded.samplesDecoded);
|
||||
const channels = decoded.channelData.length;
|
||||
for (let i = 0; i < decoded.samplesDecoded; i++) {
|
||||
let sum = 0;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
sum += decoded.channelData[ch][i];
|
||||
}
|
||||
floatMono[i] = sum / channels;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Float32 PCM into s16le.
|
||||
const s16 = new Uint8Array(floatMono.length * 2);
|
||||
const view = new DataView(s16.buffer);
|
||||
for (let i = 0; i < floatMono.length; i++) {
|
||||
const clamped = Math.max(-1, Math.min(1, floatMono[i]));
|
||||
const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
|
||||
view.setInt16(i * 2, Math.round(val), true);
|
||||
}
|
||||
|
||||
// Resample with simple linear interpolation.
|
||||
let pcm: Uint8Array = s16;
|
||||
if (decoded.sampleRate !== targetRate) {
|
||||
const inputSamples = s16.length / 2;
|
||||
const outputSamples = Math.round((inputSamples * targetRate) / decoded.sampleRate);
|
||||
const output = new Uint8Array(outputSamples * 2);
|
||||
const inView = new DataView(s16.buffer, s16.byteOffset, s16.byteLength);
|
||||
const outView = new DataView(output.buffer, output.byteOffset, output.byteLength);
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
const srcIdx = (i * decoded.sampleRate) / targetRate;
|
||||
const idx0 = Math.floor(srcIdx);
|
||||
const idx1 = Math.min(idx0 + 1, inputSamples - 1);
|
||||
const frac = srcIdx - idx0;
|
||||
const s0 = inView.getInt16(idx0 * 2, true);
|
||||
const s1 = inView.getInt16(idx1 * 2, true);
|
||||
const sample = Math.round(s0 + (s1 - s0) * frac);
|
||||
outView.setInt16(i * 2, Math.max(-32768, Math.min(32767, sample)), true);
|
||||
}
|
||||
pcm = output;
|
||||
}
|
||||
|
||||
return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
} catch (err) {
|
||||
debugError(`[audio-convert] WASM MP3 decode failed: ${formatErrorMessage(err)}`);
|
||||
if (err instanceof Error && err.stack) {
|
||||
debugError(`[audio-convert] stack: ${err.stack}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize file extensions to lowercased dotted form. */
|
||||
function normalizeFormats(formats: string[]): string[] {
|
||||
return formats.map((f) => {
|
||||
const lower = normalizeLowercaseStringOrEmpty(f);
|
||||
return lower.startsWith(".") ? lower : `.${lower}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse standard PCM WAV as a no-ffmpeg fallback. */
|
||||
function parseWavFallback(buf: Buffer): Buffer | null {
|
||||
if (buf.length < 44) {
|
||||
return null;
|
||||
}
|
||||
if (buf.toString("ascii", 0, 4) !== "RIFF") {
|
||||
return null;
|
||||
}
|
||||
if (buf.toString("ascii", 8, 12) !== "WAVE") {
|
||||
return null;
|
||||
}
|
||||
if (buf.toString("ascii", 12, 16) !== "fmt ") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioFormat = buf.readUInt16LE(20);
|
||||
if (audioFormat !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channels = buf.readUInt16LE(22);
|
||||
const sampleRate = buf.readUInt32LE(24);
|
||||
const bitsPerSample = buf.readUInt16LE(34);
|
||||
if (bitsPerSample !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the PCM data chunk.
|
||||
let offset = 36;
|
||||
while (offset < buf.length - 8) {
|
||||
const chunkId = buf.toString("ascii", offset, offset + 4);
|
||||
const chunkSize = buf.readUInt32LE(offset + 4);
|
||||
if (chunkId === "data") {
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = Math.min(dataStart + chunkSize, buf.length);
|
||||
let pcm = new Uint8Array(buf.buffer, buf.byteOffset + dataStart, dataEnd - dataStart);
|
||||
|
||||
// Downmix multi-channel audio to mono.
|
||||
if (channels > 1) {
|
||||
const samplesPerCh = pcm.length / (2 * channels);
|
||||
const mono = new Uint8Array(samplesPerCh * 2);
|
||||
const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
const outV = new DataView(mono.buffer, mono.byteOffset, mono.byteLength);
|
||||
for (let i = 0; i < samplesPerCh; i++) {
|
||||
let sum = 0;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
sum += inV.getInt16((i * channels + ch) * 2, true);
|
||||
}
|
||||
outV.setInt16(i * 2, Math.max(-32768, Math.min(32767, Math.round(sum / channels))), true);
|
||||
}
|
||||
pcm = mono;
|
||||
}
|
||||
|
||||
// Resample with simple linear interpolation.
|
||||
const targetRate = 24000;
|
||||
if (sampleRate !== targetRate) {
|
||||
const inSamples = pcm.length / 2;
|
||||
const outSamples = Math.round((inSamples * targetRate) / sampleRate);
|
||||
const out = new Uint8Array(outSamples * 2);
|
||||
const inV = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
const outV = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
for (let i = 0; i < outSamples; i++) {
|
||||
const src = (i * sampleRate) / targetRate;
|
||||
const i0 = Math.floor(src);
|
||||
const i1 = Math.min(i0 + 1, inSamples - 1);
|
||||
const f = src - i0;
|
||||
const s0 = inV.getInt16(i0 * 2, true);
|
||||
const s1 = inV.getInt16(i1 * 2, true);
|
||||
outV.setInt16(
|
||||
i * 2,
|
||||
Math.max(-32768, Math.min(32767, Math.round(s0 + (s1 - s0) * f))),
|
||||
true,
|
||||
);
|
||||
}
|
||||
pcm = out;
|
||||
}
|
||||
|
||||
return Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength);
|
||||
}
|
||||
offset += 8 + chunkSize;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
26
openclaw/extensions/qqbot/src/utils/debug-log.ts
Normal file
26
openclaw/extensions/qqbot/src/utils/debug-log.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Debug logging utility for QQBot plugin.
|
||||
*
|
||||
* Only outputs when QQBOT_DEBUG environment variable is set.
|
||||
* Prevents leaking user message content in production logs.
|
||||
*/
|
||||
|
||||
const isDebug = () => !!process.env.QQBOT_DEBUG;
|
||||
|
||||
export function debugLog(...args: unknown[]): void {
|
||||
if (isDebug()) {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function debugWarn(...args: unknown[]): void {
|
||||
if (isDebug()) {
|
||||
console.warn(...args);
|
||||
}
|
||||
}
|
||||
|
||||
export function debugError(...args: unknown[]): void {
|
||||
if (isDebug()) {
|
||||
console.error(...args);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||
70
openclaw/extensions/qqbot/src/utils/file-utils.test.ts
Normal file
70
openclaw/extensions/qqbot/src/utils/file-utils.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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";
|
||||
|
||||
const mediaRuntimeMocks = vi.hoisted(() => ({
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./file-utils-runtime.js", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
import { QQBOT_MEDIA_SSRF_POLICY, downloadFile } from "./file-utils.js";
|
||||
|
||||
describe("qqbot file-utils downloadFile", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockReset();
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "qqbot-file-utils-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("downloads through the guarded media runtime with the qqbot SSRF policy", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
contentType: "image/png",
|
||||
fileName: "remote.png",
|
||||
});
|
||||
|
||||
const savedPath = await downloadFile(
|
||||
"https://media.qq.com/assets/photo.png",
|
||||
tempDir,
|
||||
"photo.png",
|
||||
);
|
||||
|
||||
expect(savedPath).toBeTruthy();
|
||||
expect(savedPath).toMatch(/photo_\d+_[0-9a-f]{6}\.png$/);
|
||||
expect(await fs.promises.readFile(savedPath!, "utf8")).toBe("image-bytes");
|
||||
expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledWith({
|
||||
url: "https://media.qq.com/assets/photo.png",
|
||||
filePathHint: "photo.png",
|
||||
ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
expect(QQBOT_MEDIA_SSRF_POLICY).toEqual({
|
||||
hostnameAllowlist: [
|
||||
"*.qpic.cn",
|
||||
"*.qq.com",
|
||||
"*.weiyun.com",
|
||||
"*.qq.com.cn",
|
||||
"*.ugcimg.cn",
|
||||
"*.myqcloud.com",
|
||||
"*.tencentcos.cn",
|
||||
"*.tencentcos.com",
|
||||
],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-HTTPS URLs before attempting a fetch", async () => {
|
||||
const savedPath = await downloadFile("http://media.qq.com/assets/photo.png", tempDir);
|
||||
|
||||
expect(savedPath).toBeNull();
|
||||
expect(mediaRuntimeMocks.fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
188
openclaw/extensions/qqbot/src/utils/file-utils.ts
Normal file
188
openclaw/extensions/qqbot/src/utils/file-utils.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { fetchRemoteMedia } from "./file-utils-runtime.js";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
/** Maximum file size accepted by the QQ Bot API. */
|
||||
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
/** Threshold used to treat an upload as a large file. */
|
||||
export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
|
||||
|
||||
const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [
|
||||
// QQ富媒体
|
||||
"*.qpic.cn",
|
||||
"*.qq.com",
|
||||
"*.weiyun.com",
|
||||
"*.qq.com.cn",
|
||||
|
||||
// QQ机器人
|
||||
"*.ugcimg.cn",
|
||||
|
||||
// 腾讯云COS
|
||||
"*.myqcloud.com",
|
||||
"*.tencentcos.cn",
|
||||
"*.tencentcos.com",
|
||||
];
|
||||
|
||||
export const QQBOT_MEDIA_SSRF_POLICY: SsrFPolicy = {
|
||||
hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
/** Result of local file-size validation. */
|
||||
export interface FileSizeCheckResult {
|
||||
ok: boolean;
|
||||
size: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Validate that a file is within the allowed upload size. */
|
||||
export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > maxSize) {
|
||||
const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
|
||||
const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
|
||||
return {
|
||||
ok: false,
|
||||
size: stat.size,
|
||||
error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`,
|
||||
};
|
||||
}
|
||||
return { ok: true, size: stat.size };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
size: 0,
|
||||
error: `Failed to read file metadata: ${formatErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Read file contents asynchronously. */
|
||||
export async function readFileAsync(filePath: string): Promise<Buffer> {
|
||||
return fs.promises.readFile(filePath);
|
||||
}
|
||||
|
||||
/** Check file readability asynchronously. */
|
||||
export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get file size asynchronously. */
|
||||
export async function getFileSizeAsync(filePath: string): Promise<number> {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
/** Return true when a file should be treated as large. */
|
||||
export function isLargeFile(sizeBytes: number): boolean {
|
||||
return sizeBytes >= LARGE_FILE_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Format a byte count into a human-readable size string. */
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
/** Infer a MIME type from the file extension. */
|
||||
export function getMimeType(filePath: string): string {
|
||||
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".zip": "application/zip",
|
||||
".tar": "application/x-tar",
|
||||
".gz": "application/gzip",
|
||||
".txt": "text/plain",
|
||||
};
|
||||
return mimeTypes[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/** Download a remote file into a local directory. */
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destDir: string,
|
||||
originalFilename?: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsedUrl.protocol !== "https:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url: parsedUrl.toString(),
|
||||
filePathHint: originalFilename,
|
||||
ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
|
||||
let filename = normalizeOptionalString(originalFilename) ?? "";
|
||||
if (!filename) {
|
||||
filename =
|
||||
(normalizeOptionalString(fetched.fileName) ?? path.basename(parsedUrl.pathname)) ||
|
||||
"download";
|
||||
}
|
||||
|
||||
const ts = Date.now();
|
||||
const ext = path.extname(filename);
|
||||
const base = path.basename(filename, ext) || "file";
|
||||
const rand = crypto.randomBytes(3).toString("hex");
|
||||
const safeFilename = `${base}_${ts}_${rand}${ext}`;
|
||||
|
||||
const destPath = path.join(destDir, safeFilename);
|
||||
await fs.promises.writeFile(destPath, fetched.buffer);
|
||||
return destPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
164
openclaw/extensions/qqbot/src/utils/image-size.test.ts
Normal file
164
openclaw/extensions/qqbot/src/utils/image-size.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Buffer } from "buffer";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mediaRuntimeMocks = vi.hoisted(() => ({
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
import { getImageSizeFromUrl, parseImageSize } from "./image-size.js";
|
||||
|
||||
/** Build a minimal valid PNG header with the given dimensions. */
|
||||
function buildPngHeader(width: number, height: number): Buffer {
|
||||
const buf = Buffer.alloc(24);
|
||||
// PNG signature
|
||||
buf[0] = 0x89;
|
||||
buf[1] = 0x50;
|
||||
buf[2] = 0x4e;
|
||||
buf[3] = 0x47;
|
||||
buf[4] = 0x0d;
|
||||
buf[5] = 0x0a;
|
||||
buf[6] = 0x1a;
|
||||
buf[7] = 0x0a;
|
||||
// IHDR chunk length
|
||||
buf.writeUInt32BE(13, 8);
|
||||
// "IHDR"
|
||||
buf.write("IHDR", 12, "ascii");
|
||||
// Width and height
|
||||
buf.writeUInt32BE(width, 16);
|
||||
buf.writeUInt32BE(height, 20);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("getImageSizeFromUrl", () => {
|
||||
beforeEach(() => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("fetchRemoteMedia options contract", () => {
|
||||
it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: buildPngHeader(800, 600),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await getImageSizeFromUrl("https://cdn.example.com/photo.png");
|
||||
|
||||
expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledOnce();
|
||||
const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0];
|
||||
|
||||
expect(opts.url).toBe("https://cdn.example.com/photo.png");
|
||||
expect(opts.maxBytes).toBe(65_536);
|
||||
expect(opts.maxRedirects).toBe(0);
|
||||
// Generic public-network-only policy: no hostname allowlist
|
||||
expect(opts.ssrfPolicy).toEqual({});
|
||||
expect(opts.requestInit.headers).toEqual({
|
||||
Range: "bytes=0-65535",
|
||||
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("threads caller abort signal through requestInit", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: buildPngHeader(100, 100),
|
||||
});
|
||||
|
||||
await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000);
|
||||
|
||||
const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0];
|
||||
expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSRF blocking (fetchRemoteMedia rejects)", () => {
|
||||
it("returns null when fetchRemoteMedia throws for loopback", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: loopback address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://127.0.0.1/img.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetchRemoteMedia throws for IPv6 loopback", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: loopback address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://[::1]/img.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetchRemoteMedia throws for link-local/metadata", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: link-local address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://169.254.169.254/latest/meta-data/");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetchRemoteMedia throws for RFC1918 addresses", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: private address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://10.0.0.1/img.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on http error from fetchRemoteMedia", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden"));
|
||||
|
||||
const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("happy path", () => {
|
||||
it("returns parsed dimensions for a valid PNG", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: buildPngHeader(1920, 1080),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const size = await getImageSizeFromUrl("https://cdn.example.com/banner.png");
|
||||
|
||||
expect(size).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it("returns null when the buffer is not a recognized image format", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("not an image"),
|
||||
contentType: "text/html",
|
||||
});
|
||||
|
||||
const size = await getImageSizeFromUrl("https://cdn.example.com/notimage.html");
|
||||
|
||||
expect(size).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImageSize", () => {
|
||||
it("parses PNG dimensions", () => {
|
||||
const size = parseImageSize(buildPngHeader(640, 480));
|
||||
expect(size).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it("returns null for unrecognized data", () => {
|
||||
expect(parseImageSize(Buffer.from("hello"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty buffer", () => {
|
||||
expect(parseImageSize(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
257
openclaw/extensions/qqbot/src/utils/image-size.ts
Normal file
257
openclaw/extensions/qqbot/src/utils/image-size.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Image dimension helpers for QQ Bot markdown image syntax.
|
||||
*
|
||||
* QQ Bot markdown images use ``.
|
||||
*/
|
||||
|
||||
import { Buffer } from "buffer";
|
||||
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { debugLog } from "./debug-log.js";
|
||||
|
||||
export interface ImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Default dimensions used when probing fails. */
|
||||
export const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 };
|
||||
|
||||
/**
|
||||
* Parse image dimensions from the PNG header.
|
||||
*/
|
||||
function parsePngSize(buffer: Buffer): ImageSize | null {
|
||||
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (buffer.length < 24) {
|
||||
return null;
|
||||
}
|
||||
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
|
||||
return null;
|
||||
}
|
||||
// The IHDR chunk begins at byte 8, with width/height at 16..23.
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/** Parse image dimensions from JPEG SOF0/SOF2 markers. */
|
||||
function parseJpegSize(buffer: Buffer): ImageSize | null {
|
||||
// JPEG signature: FF D8 FF
|
||||
if (buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
while (offset < buffer.length - 9) {
|
||||
if (buffer[offset] !== 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
// SOF0 (0xC0) and SOF2 (0xC2) contain dimensions.
|
||||
if (marker === 0xc0 || marker === 0xc2) {
|
||||
// Layout: FF C0 length(2) precision(1) height(2) width(2)
|
||||
if (offset + 9 <= buffer.length) {
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the current block.
|
||||
if (offset + 3 < buffer.length) {
|
||||
const blockLength = buffer.readUInt16BE(offset + 2);
|
||||
offset += 2 + blockLength;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse image dimensions from the GIF header. */
|
||||
function parseGifSize(buffer: Buffer): ImageSize | null {
|
||||
if (buffer.length < 10) {
|
||||
return null;
|
||||
}
|
||||
const signature = buffer.toString("ascii", 0, 6);
|
||||
if (signature !== "GIF87a" && signature !== "GIF89a") {
|
||||
return null;
|
||||
}
|
||||
const width = buffer.readUInt16LE(6);
|
||||
const height = buffer.readUInt16LE(8);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/** Parse image dimensions from WebP headers. */
|
||||
function parseWebpSize(buffer: Buffer): ImageSize | null {
|
||||
if (buffer.length < 30) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check the RIFF and WEBP signatures.
|
||||
const riff = buffer.toString("ascii", 0, 4);
|
||||
const webp = buffer.toString("ascii", 8, 12);
|
||||
if (riff !== "RIFF" || webp !== "WEBP") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunkType = buffer.toString("ascii", 12, 16);
|
||||
|
||||
// VP8 (lossy)
|
||||
if (chunkType === "VP8 ") {
|
||||
// The VP8 frame header starts at byte 23 and uses the 9D 01 2A signature.
|
||||
if (buffer.length >= 30 && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
|
||||
const width = buffer.readUInt16LE(26) & 0x3fff;
|
||||
const height = buffer.readUInt16LE(28) & 0x3fff;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// VP8L (lossless)
|
||||
if (chunkType === "VP8L") {
|
||||
// VP8L signature: 0x2F
|
||||
if (buffer.length >= 25 && buffer[20] === 0x2f) {
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = (bits & 0x3fff) + 1;
|
||||
const height = ((bits >> 14) & 0x3fff) + 1;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
// VP8X (extended format)
|
||||
if (chunkType === "VP8X") {
|
||||
if (buffer.length >= 30) {
|
||||
// Width and height live at 24..26 and 27..29 as 24-bit little-endian values.
|
||||
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
|
||||
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
|
||||
return { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse image dimensions from raw image bytes. */
|
||||
export function parseImageSize(buffer: Buffer): ImageSize | null {
|
||||
// Try each supported image format in sequence.
|
||||
return (
|
||||
parsePngSize(buffer) ?? parseJpegSize(buffer) ?? parseGifSize(buffer) ?? parseWebpSize(buffer)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF policy for image-dimension probing. Generic public-network-only blocking
|
||||
* (no hostname allowlist) because markdown image URLs can legitimately point to
|
||||
* any public host, not just QQ-owned CDNs.
|
||||
*/
|
||||
const IMAGE_PROBE_SSRF_POLICY: SsrFPolicy = {};
|
||||
|
||||
/**
|
||||
* Fetch image dimensions from a public URL using only the first 64 KB.
|
||||
*
|
||||
* Uses {@link fetchRemoteMedia} with SSRF guard to block probes against
|
||||
* private/reserved/loopback/link-local/metadata destinations.
|
||||
*/
|
||||
export async function getImageSizeFromUrl(
|
||||
url: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<ImageSize | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const { buffer } = await fetchRemoteMedia({
|
||||
url,
|
||||
maxBytes: 65_536,
|
||||
maxRedirects: 0,
|
||||
ssrfPolicy: IMAGE_PROBE_SSRF_POLICY,
|
||||
requestInit: {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Range: "bytes=0-65535",
|
||||
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const size = parseImageSize(buffer);
|
||||
if (size) {
|
||||
debugLog(
|
||||
`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`,
|
||||
);
|
||||
}
|
||||
return size;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse image dimensions from a Base64 data URL. */
|
||||
export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null {
|
||||
try {
|
||||
// Format: data:image/png;base64,xxxxx
|
||||
const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
|
||||
if (!matches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Data = matches[1];
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
const size = parseImageSize(buffer);
|
||||
if (size) {
|
||||
debugLog(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
|
||||
}
|
||||
|
||||
return size;
|
||||
} catch (err) {
|
||||
debugLog(`[image-size] Error parsing Base64: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve image dimensions from either an HTTP URL or a Base64 data URL.
|
||||
*/
|
||||
export async function getImageSize(source: string): Promise<ImageSize | null> {
|
||||
if (source.startsWith("data:")) {
|
||||
return getImageSizeFromDataUrl(source);
|
||||
}
|
||||
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) {
|
||||
return getImageSizeFromUrl(source);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format a markdown image with QQ Bot width/height annotations. */
|
||||
export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string {
|
||||
const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
|
||||
return ``;
|
||||
}
|
||||
|
||||
/** Return true when markdown already contains QQ Bot size annotations. */
|
||||
export function hasQQBotImageSize(markdownImage: string): boolean {
|
||||
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
|
||||
}
|
||||
|
||||
/** Extract width and height from QQBot markdown image syntax: ``. */
|
||||
export function extractQQBotImageSize(markdownImage: string): ImageSize | null {
|
||||
const match = markdownImage.match(/!\[#(\d+)px\s+#(\d+)px\]/);
|
||||
if (match) {
|
||||
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
32
openclaw/extensions/qqbot/src/utils/media-tags.test.ts
Normal file
32
openclaw/extensions/qqbot/src/utils/media-tags.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { FUZZY_MEDIA_TAG_REGEX, SELF_CLOSING_TAG_REGEX } from "./media-tags.js";
|
||||
|
||||
describe("media-tags with HTML entities", () => {
|
||||
it("extracts URL from entity-encoded fuzzy tag", () => {
|
||||
const input = "<qqimg>https://example.com/a.png</qqimg>";
|
||||
FUZZY_MEDIA_TAG_REGEX.lastIndex = 0;
|
||||
const match = FUZZY_MEDIA_TAG_REGEX.exec(input);
|
||||
expect(match?.[2]).toBe("https://example.com/a.png");
|
||||
});
|
||||
|
||||
it("extracts URL from mixed entity+plain tag", () => {
|
||||
const input = "<qqimg>https://example.com/b.png</qqimg>";
|
||||
FUZZY_MEDIA_TAG_REGEX.lastIndex = 0;
|
||||
const match = FUZZY_MEDIA_TAG_REGEX.exec(input);
|
||||
expect(match?.[2]).toBe("https://example.com/b.png");
|
||||
});
|
||||
|
||||
it("extracts file from entity-encoded self-closing tag", () => {
|
||||
const input = '<qqmedia file="https://example.com/c.zip" />';
|
||||
SELF_CLOSING_TAG_REGEX.lastIndex = 0;
|
||||
const match = SELF_CLOSING_TAG_REGEX.exec(input);
|
||||
expect(match?.[2]).toBe("https://example.com/c.zip");
|
||||
});
|
||||
|
||||
it("does not match invalid input", () => {
|
||||
const input = "no tag here";
|
||||
FUZZY_MEDIA_TAG_REGEX.lastIndex = 0;
|
||||
const match = FUZZY_MEDIA_TAG_REGEX.exec(input);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
151
openclaw/extensions/qqbot/src/utils/media-tags.ts
Normal file
151
openclaw/extensions/qqbot/src/utils/media-tags.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { expandTilde } from "./platform.js";
|
||||
|
||||
// Canonical media tags. `qqmedia` is the generic auto-routing tag.
|
||||
const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const;
|
||||
|
||||
// Lowercased aliases that should normalize to the canonical tag set.
|
||||
const TAG_ALIASES: Record<string, (typeof VALID_TAGS)[number]> = {
|
||||
qq_img: "qqimg",
|
||||
qqimage: "qqimg",
|
||||
qq_image: "qqimg",
|
||||
qqpic: "qqimg",
|
||||
qq_pic: "qqimg",
|
||||
qqpicture: "qqimg",
|
||||
qq_picture: "qqimg",
|
||||
qqphoto: "qqimg",
|
||||
qq_photo: "qqimg",
|
||||
img: "qqimg",
|
||||
image: "qqimg",
|
||||
pic: "qqimg",
|
||||
picture: "qqimg",
|
||||
photo: "qqimg",
|
||||
qq_voice: "qqvoice",
|
||||
qqaudio: "qqvoice",
|
||||
qq_audio: "qqvoice",
|
||||
voice: "qqvoice",
|
||||
audio: "qqvoice",
|
||||
qq_video: "qqvideo",
|
||||
video: "qqvideo",
|
||||
qq_file: "qqfile",
|
||||
qqdoc: "qqfile",
|
||||
qq_doc: "qqfile",
|
||||
file: "qqfile",
|
||||
doc: "qqfile",
|
||||
document: "qqfile",
|
||||
qq_media: "qqmedia",
|
||||
media: "qqmedia",
|
||||
attachment: "qqmedia",
|
||||
attach: "qqmedia",
|
||||
qqattachment: "qqmedia",
|
||||
qq_attachment: "qqmedia",
|
||||
qqsend: "qqmedia",
|
||||
qq_send: "qqmedia",
|
||||
send: "qqmedia",
|
||||
};
|
||||
|
||||
const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)];
|
||||
ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
|
||||
|
||||
const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
|
||||
|
||||
const LEFT_BRACKET = "(?:[<<<]|<)";
|
||||
const RIGHT_BRACKET = "(?:[>>>]|>)";
|
||||
/** Match self-closing media-tag syntax with file/src/path/url attributes. */
|
||||
export const SELF_CLOSING_TAG_REGEX = new RegExp(
|
||||
"`?" +
|
||||
LEFT_BRACKET +
|
||||
"\\s*(" +
|
||||
TAG_NAME_PATTERN +
|
||||
")" +
|
||||
"(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*" +
|
||||
"\\s+(?:file|src|path|url)\\s*=\\s*" +
|
||||
"[\"']?" +
|
||||
"([^\"'\\s>>]+?)" +
|
||||
"[\"']?" +
|
||||
"(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*" +
|
||||
"\\s*/?" +
|
||||
"\\s*" +
|
||||
RIGHT_BRACKET +
|
||||
"`?",
|
||||
"gi",
|
||||
);
|
||||
|
||||
/** Match malformed wrapped media tags that should be normalized. */
|
||||
export const FUZZY_MEDIA_TAG_REGEX = new RegExp(
|
||||
"`?" +
|
||||
LEFT_BRACKET +
|
||||
"\\s*(" +
|
||||
TAG_NAME_PATTERN +
|
||||
")\\s*" +
|
||||
RIGHT_BRACKET +
|
||||
"[\"']?\\s*" +
|
||||
"([^<<<>>\"'`]+?)" +
|
||||
"\\s*[\"']?" +
|
||||
LEFT_BRACKET +
|
||||
"\\s*/?\\s*(?:" +
|
||||
TAG_NAME_PATTERN +
|
||||
")\\s*" +
|
||||
RIGHT_BRACKET +
|
||||
"`?",
|
||||
"gi",
|
||||
);
|
||||
|
||||
/** Normalize a raw tag name into the canonical tag set. */
|
||||
function resolveTagName(raw: string): (typeof VALID_TAGS)[number] {
|
||||
const lower = raw.trim().toLowerCase();
|
||||
if ((VALID_TAGS as readonly string[]).includes(lower)) {
|
||||
return lower as (typeof VALID_TAGS)[number];
|
||||
}
|
||||
return TAG_ALIASES[lower] ?? "qqimg";
|
||||
}
|
||||
|
||||
/** Match wrapped tags whose bodies need newline and tab cleanup. */
|
||||
const MULTILINE_TAG_CLEANUP = new RegExp(
|
||||
"(" +
|
||||
LEFT_BRACKET +
|
||||
"\\s*(?:" +
|
||||
TAG_NAME_PATTERN +
|
||||
")\\s*" +
|
||||
RIGHT_BRACKET +
|
||||
")" +
|
||||
"([\\s\\S]*?)" +
|
||||
"(" +
|
||||
LEFT_BRACKET +
|
||||
"\\s*/?\\s*(?:" +
|
||||
TAG_NAME_PATTERN +
|
||||
")\\s*" +
|
||||
RIGHT_BRACKET +
|
||||
")",
|
||||
"gi",
|
||||
);
|
||||
|
||||
/** Normalize malformed media-tag output into canonical wrapped tags. */
|
||||
export function normalizeMediaTags(text: string): string {
|
||||
let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, (_match, rawTag: string, content: string) => {
|
||||
const tag = resolveTagName(rawTag);
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return _match;
|
||||
}
|
||||
const expanded = expandTilde(trimmed);
|
||||
return `<${tag}>${expanded}</${tag}>`;
|
||||
});
|
||||
|
||||
cleaned = cleaned.replace(
|
||||
MULTILINE_TAG_CLEANUP,
|
||||
(_m, open: string, body: string, close: string) => {
|
||||
const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ");
|
||||
return open + flat + close;
|
||||
},
|
||||
);
|
||||
|
||||
return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag: string, content: string) => {
|
||||
const tag = resolveTagName(rawTag);
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return _match;
|
||||
}
|
||||
const expanded = expandTilde(trimmed);
|
||||
return `<${tag}>${expanded}</${tag}>`;
|
||||
});
|
||||
}
|
||||
161
openclaw/extensions/qqbot/src/utils/payload.ts
Normal file
161
openclaw/extensions/qqbot/src/utils/payload.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
||||
/** Structured reminder payload emitted by the model. */
|
||||
export interface CronReminderPayload {
|
||||
type: "cron_reminder";
|
||||
content: string;
|
||||
targetType: "c2c" | "group";
|
||||
targetAddress: string;
|
||||
originalMessageId?: string;
|
||||
}
|
||||
|
||||
/** Structured media payload emitted by the model. */
|
||||
export interface MediaPayload {
|
||||
type: "media";
|
||||
mediaType: "image" | "audio" | "video" | "file";
|
||||
source: "url" | "file";
|
||||
path: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export type QQBotPayload = CronReminderPayload | MediaPayload;
|
||||
|
||||
/** Result of parsing model output into a structured payload. */
|
||||
export interface ParseResult {
|
||||
isPayload: boolean;
|
||||
payload?: QQBotPayload;
|
||||
text?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:";
|
||||
const CRON_PREFIX = "QQBOT_CRON:";
|
||||
|
||||
/** Parse model output that may start with the QQ Bot structured payload prefix. */
|
||||
export function parseQQBotPayload(text: string): ParseResult {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (!trimmedText.startsWith(PAYLOAD_PREFIX)) {
|
||||
return {
|
||||
isPayload: false,
|
||||
text: text,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim();
|
||||
|
||||
if (!jsonContent) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: "Payload body is empty",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(jsonContent) as QQBotPayload;
|
||||
|
||||
if (!payload.type) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: "Payload is missing the type field",
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === "cron_reminder") {
|
||||
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error:
|
||||
"cron_reminder payload is missing required fields (content, targetType, targetAddress)",
|
||||
};
|
||||
}
|
||||
} else if (payload.type === "media") {
|
||||
if (!payload.mediaType || !payload.source || !payload.path) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: "media payload is missing required fields (mediaType, source, path)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPayload: true,
|
||||
payload,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isPayload: true,
|
||||
error: `Failed to parse JSON: ${formatErrorMessage(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode a cron reminder payload into the stored cron-message format. */
|
||||
export function encodePayloadForCron(payload: CronReminderPayload): string {
|
||||
const jsonString = JSON.stringify(payload);
|
||||
const base64 = Buffer.from(jsonString, "utf-8").toString("base64");
|
||||
return `${CRON_PREFIX}${base64}`;
|
||||
}
|
||||
|
||||
/** Decode a stored cron payload. */
|
||||
export function decodeCronPayload(message: string): {
|
||||
isCronPayload: boolean;
|
||||
payload?: CronReminderPayload;
|
||||
error?: string;
|
||||
} {
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
if (!trimmedMessage.startsWith(CRON_PREFIX)) {
|
||||
return {
|
||||
isCronPayload: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base64Content = trimmedMessage.slice(CRON_PREFIX.length);
|
||||
|
||||
if (!base64Content) {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: "Cron payload body is empty",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonString = Buffer.from(base64Content, "base64").toString("utf-8");
|
||||
const payload = JSON.parse(jsonString) as CronReminderPayload;
|
||||
|
||||
if (payload.type !== "cron_reminder") {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: `Expected type cron_reminder but got ${String(payload.type)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: "Cron payload is missing required fields",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isCronPayload: true,
|
||||
payload,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
isCronPayload: true,
|
||||
error: `Failed to decode cron payload: ${formatErrorMessage(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Type guard for cron reminder payloads. */
|
||||
export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload {
|
||||
return payload.type === "cron_reminder";
|
||||
}
|
||||
|
||||
/** Type guard for media payloads. */
|
||||
export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload {
|
||||
return payload.type === "media";
|
||||
}
|
||||
132
openclaw/extensions/qqbot/src/utils/platform.test.ts
Normal file
132
openclaw/extensions/qqbot/src/utils/platform.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getHomeDir,
|
||||
resolveQQBotLocalMediaPath,
|
||||
resolveQQBotPayloadLocalFilePath,
|
||||
} from "./platform.js";
|
||||
|
||||
describe("qqbot local media path remapping", () => {
|
||||
const createdPaths: string[] = [];
|
||||
|
||||
function createOpenClawTestRoot() {
|
||||
const actualHome = getHomeDir();
|
||||
const openclawDir = path.join(actualHome, ".openclaw");
|
||||
fs.mkdirSync(openclawDir, { recursive: true });
|
||||
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
|
||||
createdPaths.push(testRoot);
|
||||
return { actualHome, testRootName: path.basename(testRoot) };
|
||||
}
|
||||
|
||||
function createQqbotMediaFile(fileName: string) {
|
||||
const { actualHome, testRootName } = createOpenClawTestRoot();
|
||||
const mediaFile = path.join(
|
||||
actualHome,
|
||||
".openclaw",
|
||||
"media",
|
||||
"qqbot",
|
||||
"downloads",
|
||||
testRootName,
|
||||
fileName,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
|
||||
fs.writeFileSync(mediaFile, "image", "utf8");
|
||||
createdPaths.push(path.dirname(mediaFile));
|
||||
return { actualHome, testRootName, mediaFile };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const target of createdPaths.splice(0)) {
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("remaps missing workspace media paths to the real media directory", () => {
|
||||
const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("example.png");
|
||||
|
||||
const missingWorkspacePath = path.join(
|
||||
actualHome,
|
||||
".openclaw",
|
||||
"workspace",
|
||||
"qqbot",
|
||||
"downloads",
|
||||
testRootName,
|
||||
"example.png",
|
||||
);
|
||||
|
||||
expect(resolveQQBotLocalMediaPath(missingWorkspacePath)).toBe(mediaFile);
|
||||
});
|
||||
|
||||
it("leaves existing media paths unchanged", () => {
|
||||
const { mediaFile } = createQqbotMediaFile("existing.png");
|
||||
|
||||
expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile);
|
||||
});
|
||||
|
||||
it("blocks structured payload files outside QQ Bot storage", () => {
|
||||
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-platform-outside-"));
|
||||
createdPaths.push(outsideRoot);
|
||||
|
||||
const outsideFile = path.join(outsideRoot, "secret.txt");
|
||||
fs.writeFileSync(outsideFile, "secret", "utf8");
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(outsideFile)).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks structured payload paths that escape QQ Bot media via '..'", () => {
|
||||
const escapedPath = path.join(
|
||||
getHomeDir(),
|
||||
".openclaw",
|
||||
"media",
|
||||
"qqbot",
|
||||
"..",
|
||||
"..",
|
||||
"qqbot-escape.txt",
|
||||
);
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(escapedPath)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows structured payload files inside the QQ Bot media directory", () => {
|
||||
const { mediaFile } = createQqbotMediaFile("allowed.png");
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
|
||||
});
|
||||
|
||||
it("blocks structured payload files inside the QQ Bot data directory", () => {
|
||||
const { actualHome, testRootName } = createOpenClawTestRoot();
|
||||
|
||||
const dataFile = path.join(
|
||||
actualHome,
|
||||
".openclaw",
|
||||
"qqbot",
|
||||
"sessions",
|
||||
testRootName,
|
||||
"session.json",
|
||||
);
|
||||
fs.mkdirSync(path.dirname(dataFile), { recursive: true });
|
||||
fs.writeFileSync(dataFile, "{}", "utf8");
|
||||
createdPaths.push(path.dirname(dataFile));
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(dataFile)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows legacy workspace paths when they remap into QQ Bot media storage", () => {
|
||||
const { actualHome, testRootName, mediaFile } = createQqbotMediaFile("legacy.png");
|
||||
|
||||
const missingWorkspacePath = path.join(
|
||||
actualHome,
|
||||
".openclaw",
|
||||
"workspace",
|
||||
"qqbot",
|
||||
"downloads",
|
||||
testRootName,
|
||||
"legacy.png",
|
||||
);
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile));
|
||||
});
|
||||
});
|
||||
478
openclaw/extensions/qqbot/src/utils/platform.ts
Normal file
478
openclaw/extensions/qqbot/src/utils/platform.ts
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
/**
|
||||
* Cross-platform compatibility helpers.
|
||||
*
|
||||
* This module centralizes home/temp directory discovery, local-path checks,
|
||||
* ffmpeg/ffprobe lookup, native-module compatibility checks, and startup diagnostics.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { debugLog, debugWarn } from "./debug-log.js";
|
||||
|
||||
// Basic platform information.
|
||||
|
||||
export type PlatformType = "darwin" | "linux" | "win32" | "other";
|
||||
|
||||
export function getPlatform(): PlatformType {
|
||||
const p = process.platform;
|
||||
if (p === "darwin" || p === "linux" || p === "win32") {
|
||||
return p;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function isWindows(): boolean {
|
||||
return process.platform === "win32";
|
||||
}
|
||||
|
||||
// Home directory helpers.
|
||||
|
||||
/**
|
||||
* Resolve the current user's home directory safely across platforms.
|
||||
*
|
||||
* Priority:
|
||||
* 1. `os.homedir()`
|
||||
* 2. `$HOME` or `%USERPROFILE%`
|
||||
* 3. the OpenClaw temp directory as a last resort
|
||||
*/
|
||||
export function getHomeDir(): string {
|
||||
try {
|
||||
const home = os.homedir();
|
||||
if (home && fs.existsSync(home)) {
|
||||
return home;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fall back to environment variables.
|
||||
const envHome = process.env.HOME || process.env.USERPROFILE;
|
||||
if (envHome && fs.existsSync(envHome)) {
|
||||
return envHome;
|
||||
}
|
||||
|
||||
// Final fallback.
|
||||
return resolvePreferredOpenClawTmpDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a path under `~/.openclaw/qqbot`, creating it on demand.
|
||||
*/
|
||||
export function getQQBotDataDir(...subPaths: string[]): string {
|
||||
const dir = path.join(getHomeDir(), ".openclaw", "qqbot", ...subPaths);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a path under `~/.openclaw/media/qqbot`, creating it on demand.
|
||||
*
|
||||
* Unlike `getQQBotDataDir`, this lives under OpenClaw's core media allowlist so
|
||||
* downloaded images and audio can be accessed by framework media tooling.
|
||||
*/
|
||||
export function getQQBotMediaDir(...subPaths: string[]): string {
|
||||
const dir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", ...subPaths);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Temporary directory helpers.
|
||||
|
||||
/** Return the preferred OpenClaw temp directory. */
|
||||
export function getTempDir(): string {
|
||||
return resolvePreferredOpenClawTmpDir();
|
||||
}
|
||||
|
||||
// Tilde expansion.
|
||||
|
||||
/**
|
||||
* Expand `~` to the current user's home directory.
|
||||
*
|
||||
* Supports `~` and `~/...`. Other forms are returned unchanged.
|
||||
*/
|
||||
export function expandTilde(p: string): string {
|
||||
if (!p) {
|
||||
return p;
|
||||
}
|
||||
if (p === "~") {
|
||||
return getHomeDir();
|
||||
}
|
||||
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
||||
return path.join(getHomeDir(), p.slice(2));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`.
|
||||
*/
|
||||
export function normalizePath(p: string): string {
|
||||
let result = p.trim();
|
||||
// Strip the local file URI scheme.
|
||||
if (result.startsWith("file://")) {
|
||||
result = result.slice("file://".length);
|
||||
// Decode URL-escaped paths when possible.
|
||||
try {
|
||||
result = decodeURIComponent(result);
|
||||
} catch {
|
||||
// Keep the raw string if decoding fails.
|
||||
}
|
||||
}
|
||||
return expandTilde(result);
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidate: string, root: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap legacy or hallucinated QQ Bot local media paths to real files when possible.
|
||||
*/
|
||||
export function resolveQQBotLocalMediaPath(p: string): string {
|
||||
const normalized = normalizePath(p);
|
||||
if (!isLocalPath(normalized) || fs.existsSync(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const homeDir = getHomeDir();
|
||||
const mediaRoot = getQQBotMediaDir();
|
||||
const dataRoot = getQQBotDataDir();
|
||||
const workspaceRoot = path.join(homeDir, ".openclaw", "workspace", "qqbot");
|
||||
const candidateRoots = [
|
||||
{ from: workspaceRoot, to: mediaRoot },
|
||||
{ from: dataRoot, to: mediaRoot },
|
||||
{ from: mediaRoot, to: dataRoot },
|
||||
];
|
||||
|
||||
for (const { from, to } of candidateRoots) {
|
||||
if (!isPathWithinRoot(normalized, from)) {
|
||||
continue;
|
||||
}
|
||||
const relative = path.relative(from, normalized);
|
||||
const candidate = path.join(to, relative);
|
||||
if (fs.existsSync(candidate)) {
|
||||
debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a structured-payload local file path and enforce that it stays within
|
||||
* QQ Bot-owned storage roots.
|
||||
*/
|
||||
export function resolveQQBotPayloadLocalFilePath(p: string): string | null {
|
||||
const candidate = resolveQQBotLocalMediaPath(p);
|
||||
if (!candidate.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedCandidate = path.resolve(candidate);
|
||||
if (!fs.existsSync(resolvedCandidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canonicalCandidate = fs.realpathSync(resolvedCandidate);
|
||||
const allowedRoots = [getQQBotMediaDir()];
|
||||
|
||||
for (const root of allowedRoots) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const canonicalRoot = fs.existsSync(resolvedRoot)
|
||||
? fs.realpathSync(resolvedRoot)
|
||||
: resolvedRoot;
|
||||
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
|
||||
return canonicalCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filename normalization.
|
||||
|
||||
/**
|
||||
* Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably.
|
||||
*
|
||||
* This decodes percent-escaped names, converts Unicode to NFC, and strips ASCII
|
||||
* control characters.
|
||||
*/
|
||||
export function sanitizeFileName(name: string): string {
|
||||
if (!name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
let result = name.trim();
|
||||
|
||||
// Decode percent-escaped names when they came from URLs.
|
||||
if (result.includes("%")) {
|
||||
try {
|
||||
result = decodeURIComponent(result);
|
||||
} catch {
|
||||
// Keep the raw value if it is not valid percent-encoding.
|
||||
}
|
||||
}
|
||||
|
||||
// Convert macOS-style NFD names into standard NFC form.
|
||||
result = result.normalize("NFC");
|
||||
|
||||
// Drop ASCII control characters while keeping printable Unicode content.
|
||||
result = result.replace(/\p{Cc}/gu, "");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Local path detection.
|
||||
|
||||
/**
|
||||
* Return true when the string looks like a local filesystem path rather than a URL.
|
||||
*/
|
||||
export function isLocalPath(p: string): boolean {
|
||||
if (!p) {
|
||||
return false;
|
||||
}
|
||||
// Local file URI.
|
||||
if (p.startsWith("file://")) {
|
||||
return true;
|
||||
}
|
||||
// Tilde-based Unix path.
|
||||
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
||||
return true;
|
||||
}
|
||||
// Unix absolute path.
|
||||
if (p.startsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
// Windows drive-letter path.
|
||||
if (/^[a-zA-Z]:[\\/]/.test(p)) {
|
||||
return true;
|
||||
}
|
||||
// Windows UNC path.
|
||||
if (p.startsWith("\\\\")) {
|
||||
return true;
|
||||
}
|
||||
// POSIX relative path.
|
||||
if (p.startsWith("./") || p.startsWith("../")) {
|
||||
return true;
|
||||
}
|
||||
// Windows relative path.
|
||||
if (p.startsWith(".\\") || p.startsWith("..\\")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Looser local-path heuristic used for markdown-extracted paths. */
|
||||
export function looksLikeLocalPath(p: string): boolean {
|
||||
if (isLocalPath(p)) {
|
||||
return true;
|
||||
}
|
||||
return /^(?:Users|home|tmp|var|private|[A-Z]:)/i.test(p);
|
||||
}
|
||||
|
||||
let _ffmpegPath: string | null | undefined;
|
||||
let _ffmpegCheckPromise: Promise<string | null> | null = null;
|
||||
|
||||
/** Detect ffmpeg and return an executable path when available. */
|
||||
export function detectFfmpeg(): Promise<string | null> {
|
||||
if (_ffmpegPath !== undefined) {
|
||||
return Promise.resolve(_ffmpegPath);
|
||||
}
|
||||
if (_ffmpegCheckPromise) {
|
||||
return _ffmpegCheckPromise;
|
||||
}
|
||||
|
||||
_ffmpegCheckPromise = (async () => {
|
||||
const envPath = process.env.FFMPEG_PATH;
|
||||
if (envPath) {
|
||||
const ok = await testExecutable(envPath, ["-version"]);
|
||||
if (ok) {
|
||||
_ffmpegPath = envPath;
|
||||
debugLog(`[platform] ffmpeg found via FFMPEG_PATH: ${envPath}`);
|
||||
return _ffmpegPath;
|
||||
}
|
||||
debugWarn(`[platform] FFMPEG_PATH set but not working: ${envPath}`);
|
||||
}
|
||||
|
||||
const cmd = isWindows() ? "ffmpeg.exe" : "ffmpeg";
|
||||
const ok = await testExecutable(cmd, ["-version"]);
|
||||
if (ok) {
|
||||
_ffmpegPath = cmd;
|
||||
debugLog(`[platform] ffmpeg detected in PATH`);
|
||||
return _ffmpegPath;
|
||||
}
|
||||
|
||||
const commonPaths = isWindows()
|
||||
? [
|
||||
"C:\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
path.join(process.env.LOCALAPPDATA || "", "Programs", "ffmpeg", "bin", "ffmpeg.exe"),
|
||||
path.join(process.env.ProgramFiles || "", "ffmpeg", "bin", "ffmpeg.exe"),
|
||||
]
|
||||
: [
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"/usr/bin/ffmpeg",
|
||||
"/snap/bin/ffmpeg",
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (p && fs.existsSync(p)) {
|
||||
const works = await testExecutable(p, ["-version"]);
|
||||
if (works) {
|
||||
_ffmpegPath = p;
|
||||
debugLog(`[platform] ffmpeg found at: ${p}`);
|
||||
return _ffmpegPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ffmpegPath = null;
|
||||
return null;
|
||||
})().finally(() => {
|
||||
_ffmpegCheckPromise = null;
|
||||
});
|
||||
|
||||
return _ffmpegCheckPromise;
|
||||
}
|
||||
|
||||
/** Return true when an executable responds successfully to the given args. */
|
||||
function testExecutable(cmd: string, args: string[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(cmd, args, { timeout: 5000 }, (err) => {
|
||||
resolve(!err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Reset ffmpeg detection state, mainly for tests. */
|
||||
export function resetFfmpegCache(): void {
|
||||
_ffmpegPath = undefined;
|
||||
_ffmpegCheckPromise = null;
|
||||
}
|
||||
|
||||
let _silkWasmAvailable: boolean | null = null;
|
||||
|
||||
/** Check whether silk-wasm can run in the current environment. */
|
||||
export async function checkSilkWasmAvailable(): Promise<boolean> {
|
||||
if (_silkWasmAvailable !== null) {
|
||||
return _silkWasmAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const { isSilk } = await import("silk-wasm");
|
||||
// Use an empty buffer as a cheap smoke test for WASM loading.
|
||||
isSilk(new Uint8Array(0));
|
||||
_silkWasmAvailable = true;
|
||||
debugLog("[platform] silk-wasm: available");
|
||||
} catch (err) {
|
||||
_silkWasmAvailable = false;
|
||||
debugWarn(`[platform] silk-wasm: NOT available (${formatErrorMessage(err)})`);
|
||||
}
|
||||
return _silkWasmAvailable;
|
||||
}
|
||||
|
||||
// Startup environment diagnostics.
|
||||
|
||||
export interface DiagnosticReport {
|
||||
platform: string;
|
||||
arch: string;
|
||||
nodeVersion: string;
|
||||
homeDir: string;
|
||||
tempDir: string;
|
||||
dataDir: string;
|
||||
ffmpeg: string | null;
|
||||
silkWasm: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run startup diagnostics and return an environment report.
|
||||
* Called during gateway startup to log environment details and warnings.
|
||||
*/
|
||||
export async function runDiagnostics(): Promise<DiagnosticReport> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const platform = `${process.platform} (${os.release()})`;
|
||||
const arch = process.arch;
|
||||
const nodeVersion = process.version;
|
||||
const homeDir = getHomeDir();
|
||||
const tempDir = getTempDir();
|
||||
const dataDir = getQQBotDataDir();
|
||||
|
||||
// Check ffmpeg availability.
|
||||
const ffmpegPath = await detectFfmpeg();
|
||||
if (!ffmpegPath) {
|
||||
warnings.push(
|
||||
isWindows()
|
||||
? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with choco install ffmpeg, scoop install ffmpeg, or from https://ffmpeg.org."
|
||||
: getPlatform() === "darwin"
|
||||
? "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with brew install ffmpeg."
|
||||
: "⚠️ ffmpeg is not installed. Audio/video conversion will be limited. Install it with sudo apt install ffmpeg or sudo yum install ffmpeg.",
|
||||
);
|
||||
}
|
||||
|
||||
// Check silk-wasm availability.
|
||||
const silkWasm = await checkSilkWasmAvailable();
|
||||
if (!silkWasm) {
|
||||
warnings.push(
|
||||
"⚠️ silk-wasm is unavailable. QQ voice send/receive will not work. Ensure Node.js >= 16 and WASM support are available.",
|
||||
);
|
||||
}
|
||||
|
||||
// Check whether the data directory is writable.
|
||||
try {
|
||||
const testFile = path.join(dataDir, ".write-test");
|
||||
fs.writeFileSync(testFile, "test");
|
||||
fs.unlinkSync(testFile);
|
||||
} catch {
|
||||
warnings.push(`⚠️ Data directory is not writable: ${dataDir}. Check filesystem permissions.`);
|
||||
}
|
||||
|
||||
// Windows-specific reminder.
|
||||
if (isWindows()) {
|
||||
// Chinese characters or spaces in the home path can break external tools.
|
||||
if (/[\u4e00-\u9fa5]/.test(homeDir) || homeDir.includes(" ")) {
|
||||
warnings.push(
|
||||
`⚠️ Home directory contains Chinese characters or spaces: ${homeDir}. Some tools may fail. Consider setting QQBOT_DATA_DIR to an ASCII-only path.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const report: DiagnosticReport = {
|
||||
platform,
|
||||
arch,
|
||||
nodeVersion,
|
||||
homeDir,
|
||||
tempDir,
|
||||
dataDir,
|
||||
ffmpeg: ffmpegPath,
|
||||
silkWasm,
|
||||
warnings,
|
||||
};
|
||||
|
||||
// Print the report once for startup visibility.
|
||||
debugLog("=== QQBot Environment Diagnostics ===");
|
||||
debugLog(` Platform: ${platform} (${arch})`);
|
||||
debugLog(` Node: ${nodeVersion}`);
|
||||
debugLog(` Home: ${homeDir}`);
|
||||
debugLog(` Data dir: ${dataDir}`);
|
||||
debugLog(` ffmpeg: ${ffmpegPath ?? "not installed"}`);
|
||||
debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`);
|
||||
if (warnings.length > 0) {
|
||||
debugLog(" --- Warnings ---");
|
||||
for (const w of warnings) {
|
||||
debugLog(` ${w}`);
|
||||
}
|
||||
}
|
||||
debugLog("======================");
|
||||
|
||||
return report;
|
||||
}
|
||||
29
openclaw/extensions/qqbot/src/utils/text-parsing.test.ts
Normal file
29
openclaw/extensions/qqbot/src/utils/text-parsing.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parseFaceTags } from "./text-parsing.js";
|
||||
|
||||
describe("parseFaceTags", () => {
|
||||
it("returns empty string when input is undefined", () => {
|
||||
expect(parseFaceTags(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when input is null", () => {
|
||||
expect(parseFaceTags(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when input is empty string", () => {
|
||||
expect(parseFaceTags("")).toBe("");
|
||||
});
|
||||
|
||||
it("skips oversized base64 ext payloads before decoding", () => {
|
||||
const oversizedBase64 = "A".repeat(100_000);
|
||||
const tag = `<faceType=1,faceId="1",ext="${oversizedBase64}">`;
|
||||
const bufferFromSpy = vi.spyOn(Buffer, "from");
|
||||
|
||||
try {
|
||||
expect(parseFaceTags(tag)).toBe("[Emoji: unknown emoji]");
|
||||
expect(bufferFromSpy).not.toHaveBeenCalledWith(oversizedBase64, "base64");
|
||||
} finally {
|
||||
bufferFromSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
127
openclaw/extensions/qqbot/src/utils/text-parsing.ts
Normal file
127
openclaw/extensions/qqbot/src/utils/text-parsing.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { RefAttachmentSummary } from "../ref-index-store.js";
|
||||
|
||||
const MAX_FACE_EXT_BYTES = 64 * 1024;
|
||||
|
||||
function estimateBase64DecodedBytes(base64: string): number {
|
||||
let effectiveLen = 0;
|
||||
for (let i = 0; i < base64.length; i += 1) {
|
||||
if (base64.charCodeAt(i) > 0x20) {
|
||||
effectiveLen += 1;
|
||||
}
|
||||
}
|
||||
if (effectiveLen === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let padding = 0;
|
||||
let end = base64.length - 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 1;
|
||||
end -= 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 2;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor((effectiveLen * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Replace QQ face tags with readable text labels. */
|
||||
export function parseFaceTags(text: string | undefined | null): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return text.replace(/<faceType=\d+,faceId="[^"]*",ext="([^"]*)">/g, (_match, ext: string) => {
|
||||
try {
|
||||
if (estimateBase64DecodedBytes(ext) > MAX_FACE_EXT_BYTES) {
|
||||
return "[Emoji: unknown emoji]";
|
||||
}
|
||||
const decoded = Buffer.from(ext, "base64").toString("utf-8");
|
||||
const parsed = JSON.parse(decoded);
|
||||
const faceName = parsed.text || "unknown emoji";
|
||||
return `[Emoji: ${faceName}]`;
|
||||
} catch {
|
||||
return _match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove internal framework markers before sending text outward. */
|
||||
export function filterInternalMarkers(text: string | undefined | null): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let result = text.replace(/\[\[[a-z_]+:\s*[^\]]*\]\]/gi, "");
|
||||
result = result.replace(/@(?:image|voice|video|file):[a-zA-Z0-9_.-]+/g, "");
|
||||
result = result.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Parse quote-related ref indices from `message_scene.ext`. */
|
||||
export function parseRefIndices(ext?: string[]): { refMsgIdx?: string; msgIdx?: string } {
|
||||
if (!ext || ext.length === 0) {
|
||||
return {};
|
||||
}
|
||||
let refMsgIdx: string | undefined;
|
||||
let msgIdx: string | undefined;
|
||||
for (const item of ext) {
|
||||
if (item.startsWith("ref_msg_idx=")) {
|
||||
refMsgIdx = item.slice("ref_msg_idx=".length);
|
||||
} else if (item.startsWith("msg_idx=")) {
|
||||
msgIdx = item.slice("msg_idx=".length);
|
||||
}
|
||||
}
|
||||
return { refMsgIdx, msgIdx };
|
||||
}
|
||||
|
||||
/** Build attachment summaries for ref-index caching. */
|
||||
export function buildAttachmentSummaries(
|
||||
attachments?: Array<{
|
||||
content_type: string;
|
||||
url: string;
|
||||
filename?: string;
|
||||
voice_wav_url?: string;
|
||||
}>,
|
||||
localPaths?: Array<string | null>,
|
||||
): RefAttachmentSummary[] | undefined {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return attachments.map((att, idx) => {
|
||||
const ct = normalizeLowercaseStringOrEmpty(att.content_type);
|
||||
let type: RefAttachmentSummary["type"] = "unknown";
|
||||
if (ct.startsWith("image/")) {
|
||||
type = "image";
|
||||
} else if (
|
||||
ct === "voice" ||
|
||||
ct.startsWith("audio/") ||
|
||||
ct.includes("silk") ||
|
||||
ct.includes("amr")
|
||||
) {
|
||||
type = "voice";
|
||||
} else if (ct.startsWith("video/")) {
|
||||
type = "video";
|
||||
} else if (ct.startsWith("application/") || ct.startsWith("text/")) {
|
||||
type = "file";
|
||||
}
|
||||
return {
|
||||
type,
|
||||
filename: att.filename,
|
||||
contentType: att.content_type,
|
||||
localPath: localPaths?.[idx] ?? undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
106
openclaw/extensions/qqbot/src/utils/upload-cache.ts
Normal file
106
openclaw/extensions/qqbot/src/utils/upload-cache.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Cache `file_info` values returned by the QQ Bot API so identical uploads can be reused
|
||||
* before the server-side TTL expires.
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import { debugLog } from "./debug-log.js";
|
||||
|
||||
interface CacheEntry {
|
||||
fileInfo: string;
|
||||
fileUuid: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const MAX_CACHE_SIZE = 500;
|
||||
|
||||
/** Compute an MD5 hash used as part of the cache key. */
|
||||
export function computeFileHash(data: string | Buffer): string {
|
||||
const content = typeof data === "string" ? data : data;
|
||||
return crypto.createHash("md5").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** Build the in-memory cache key. */
|
||||
function buildCacheKey(
|
||||
contentHash: string,
|
||||
scope: string,
|
||||
targetId: string,
|
||||
fileType: number,
|
||||
): string {
|
||||
return `${contentHash}:${scope}:${targetId}:${fileType}`;
|
||||
}
|
||||
|
||||
/** Look up a cached `file_info` value. */
|
||||
export function getCachedFileInfo(
|
||||
contentHash: string,
|
||||
scope: "c2c" | "group",
|
||||
targetId: string,
|
||||
fileType: number,
|
||||
): string | null {
|
||||
const key = buildCacheKey(contentHash, scope, targetId, fileType);
|
||||
const entry = cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
debugLog(`[upload-cache] Cache HIT: key=${key.slice(0, 40)}..., fileUuid=${entry.fileUuid}`);
|
||||
return entry.fileInfo;
|
||||
}
|
||||
|
||||
/** Store an upload result in the cache. */
|
||||
export function setCachedFileInfo(
|
||||
contentHash: string,
|
||||
scope: "c2c" | "group",
|
||||
targetId: string,
|
||||
fileType: number,
|
||||
fileInfo: string,
|
||||
fileUuid: string,
|
||||
ttl: number,
|
||||
): void {
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) {
|
||||
if (now >= v.expiresAt) {
|
||||
cache.delete(k);
|
||||
}
|
||||
}
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const keys = Array.from(cache.keys());
|
||||
for (let i = 0; i < keys.length / 2; i++) {
|
||||
cache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = buildCacheKey(contentHash, scope, targetId, fileType);
|
||||
const safetyMargin = 60;
|
||||
const effectiveTtl = Math.max(ttl - safetyMargin, 10);
|
||||
|
||||
cache.set(key, {
|
||||
fileInfo,
|
||||
fileUuid,
|
||||
expiresAt: Date.now() + effectiveTtl * 1000,
|
||||
});
|
||||
|
||||
debugLog(
|
||||
`[upload-cache] Cache SET: key=${key.slice(0, 40)}..., ttl=${effectiveTtl}s, uuid=${fileUuid}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Return cache stats for diagnostics. */
|
||||
export function getUploadCacheStats(): { size: number; maxSize: number } {
|
||||
return { size: cache.size, maxSize: MAX_CACHE_SIZE };
|
||||
}
|
||||
|
||||
/** Clear the upload cache. */
|
||||
export function clearUploadCache(): void {
|
||||
cache.clear();
|
||||
debugLog(`[upload-cache] Cache cleared`);
|
||||
}
|
||||
16
openclaw/extensions/qqbot/tsconfig.json
Normal file
16
openclaw/extensions/qqbot/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue