重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

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

View 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
: "⚠️ 命令返回了意外结果。",
};
},
});
}
},
});

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

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

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

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

View 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}
· 头像:
![头像]({user.avatar})
```
> **禁止**将头像 URL 作为纯文本或超链接展示(如 `查看头像`),必须用 `![描述](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 图片语法 `![描述](URL)` 展示,禁止作为纯文本或超链接展示

View file

@ -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} — 删除日程
> ⚠️ 不可逆!需要管理频道权限

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

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

File diff suppressed because it is too large Load diff

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

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

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

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

View file

@ -0,0 +1,4 @@
import { asOptionalObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime";
export const asRecord = asOptionalObjectRecord;
export const readString = readStringField;

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

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

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

File diff suppressed because it is too large Load diff

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

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

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

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

View 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) => `![img](${url})`);
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: ![photo](https://10.0.0.1/internal.png)",
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 ![img](https://169.254.169.254/latest/meta-data/)",
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();
});
});
});

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

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

File diff suppressed because it is too large Load diff

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

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

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

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

View 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, `![](${payload.path})`, target.messageId);
} else if (target.channelId) {
// By design: channel messages only support text/markdown, same approach as DM above.
await sendChannelMessage(
token,
target.channelId,
`![](${payload.path})`,
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}`);
}
}

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

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

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

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

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

View 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("未找到日志文件");
});
});

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1 @@
export { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";

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

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

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

View file

@ -0,0 +1,257 @@
/**
* Image dimension helpers for QQ Bot markdown image syntax.
*
* QQ Bot markdown images use `![#widthpx #heightpx](url)`.
*/
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 `![#${width}px #${height}px](${url})`;
}
/** 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: `![#Wpx #Hpx](url)`. */
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;
}

View 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 = "&lt;qqimg&gt;https://example.com/a.png&lt;/qqimg&gt;";
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 = "&lt;qqimg&gt;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 = '&lt;qqmedia file="https://example.com/c.zip" /&gt;';
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();
});
});

View 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 = "(?:[<<<]|&lt;)";
const RIGHT_BRACKET = "(?:[>>>]|&gt;)";
/** 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}>`;
});
}

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

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

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

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

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

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

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