diff --git a/CHANGELOG.md b/CHANGELOG.md index b60ad10f638..5b643466bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Channels/preview streaming: stream tool-progress updates into live preview edits for Discord, Slack, and Telegram so in-flight replies show incremental tool state in the same preview message before finalization. (#69611) Thanks @thewilloftheshadow. - Ollama/onboard: populate the cloud-only model list from `ollama.com/api/tags` so `openclaw onboard` reflects the live cloud catalog instead of a static three-model seed; cap the discovered list at 500 and fall back to the previous hardcoded suggestions when ollama.com is unreachable or returns no models. (#68463) Thanks @BruceMacD. - Matrix/startup: narrow Matrix runtime registration and defer setup/doctor surfaces so cold plugin registration spends about 1.8s less in `setChannelRuntime`. (#69782) Thanks @gumadeiras. +- QQBot: extract a self-contained `engine/` architecture with QR-code onboarding, native approval handling via `/bot-approve`, per-account isolated resource stacks and multi-account logger, credential backup/restore, shared `~/.openclaw/media` payload root, and unified API/bridge/gateway modules. (#67960) Thanks @cxyhhhhh. ### Fixes diff --git a/extensions/qqbot/api.ts b/extensions/qqbot/api.ts index 6d412084628..9fed28c161e 100644 --- a/extensions/qqbot/api.ts +++ b/extensions/qqbot/api.ts @@ -1,9 +1,10 @@ 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 { getFrameworkCommands } from "./src/engine/commands/slash-commands-impl.js"; +export { registerChannelTool } from "./src/bridge/tools/channel.js"; +export { registerRemindTool } from "./src/bridge/tools/remind.js"; +export { registerQQBotTools } from "./src/bridge/tools/index.js"; +export { registerQQBotFull } from "./src/bridge/channel-entry.js"; export * from "./src/types.js"; -export * from "./src/config.js"; -export * from "./src/outbound.js"; -export * from "./src/proactive.js"; +export * from "./src/bridge/config.js"; +export * from "./src/engine/messaging/outbound.js"; diff --git a/extensions/qqbot/index.ts b/extensions/qqbot/index.ts index 0521489b789..5ec615f92f7 100644 --- a/extensions/qqbot/index.ts +++ b/extensions/qqbot/index.ts @@ -2,89 +2,12 @@ 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) => Promise; -}; - -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 - >(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 { +function registerQQBotFull(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", + exportName: "registerQQBotFull", }); register(api); } @@ -102,108 +25,5 @@ export default defineBundledChannelEntry({ 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::" e.g. "qqbot:c2c:" - 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 - : "⚠️ 命令返回了意外结果。", - }; - }, - }); - } - }, + registerFull: registerQQBotFull, }); diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json index cfb5ff1be49..8dc5dba5202 100644 --- a/extensions/qqbot/openclaw.plugin.json +++ b/extensions/qqbot/openclaw.plugin.json @@ -24,30 +24,6 @@ "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, @@ -139,7 +115,6 @@ "items": { "type": "string" } }, "audioFormatPolicy": { "$ref": "#/$defs/audioFormatPolicy" }, - "tts": { "$ref": "#/$defs/tts" }, "stt": { "$ref": "#/$defs/stt" }, "urlDirectUpload": { "type": "boolean" }, "upgradeUrl": { "type": "string" }, diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index 922cfc987a5..6d49788ddb7 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -5,6 +5,7 @@ "description": "OpenClaw QQ Bot channel plugin", "type": "module", "dependencies": { + "@tencent-connect/qqbot-connector": "^1.1.0", "mpg123-decoder": "^1.0.3", "silk-wasm": "^3.7.1", "ws": "^8.20.0", diff --git a/extensions/qqbot/runtime-api.ts b/extensions/qqbot/runtime-api.ts index c864cbfdbff..004f9a255a1 100644 --- a/extensions/qqbot/runtime-api.ts +++ b/extensions/qqbot/runtime-api.ts @@ -6,4 +6,4 @@ export type { PluginLogger, } from "openclaw/plugin-sdk/core"; export type { ResolvedQQBotAccount, QQBotAccountConfig } from "./src/types.js"; -export { getQQBotRuntime, setQQBotRuntime } from "./src/runtime.js"; +export { getQQBotRuntime, setQQBotRuntime } from "./src/bridge/runtime.js"; diff --git a/extensions/qqbot/skills/qqbot-remind/SKILL.md b/extensions/qqbot/skills/qqbot-remind/SKILL.md index f5a38b1edcf..751208ee56e 100644 --- a/extensions/qqbot/skills/qqbot-remind/SKILL.md +++ b/extensions/qqbot/skills/qqbot-remind/SKILL.md @@ -49,15 +49,16 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb > **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"` | 隔离会话避免污染 | +| 字段 | 固定值 | 原因 | +| -------------------- | ------------- | ---------------------------- | +| `payload.kind` | `"agentTurn"` | `systemEvent` 不会发 QQ 消息 | +| `delivery.mode` | `"announce"` | 主动投递模式 | +| `delivery.channel` | `"qqbot"` | QQ 通道标识 | +| `delivery.to` | 目标地址 | 从当前会话上下文获取 | +| `delivery.accountId` | 当前账户 ID | 多账号场景下不可省略 | +| `sessionTarget` | `"isolated"` | 隔离会话避免污染 | > `schedule.atMs` 必须是**绝对毫秒时间戳**(如 `1770733800000`),不支持 `"5m"` 等相对字符串。 > 计算方式:`当前时间戳ms + 延迟毫秒`。 @@ -75,10 +76,13 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb "deleteAfterRun": true, "payload": { "kind": "agentTurn", - "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀", - "deliver": true, + "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀" + }, + "delivery": { + "mode": "announce", "channel": "qqbot", - "to": "{openid}" + "to": "qqbot:c2c:{openid}", + "accountId": "{accountId}" } } } @@ -96,16 +100,20 @@ metadata: { "openclaw": { "emoji": "⏰", "requires": { "config": ["channels.qqb "wakeMode": "now", "payload": { "kind": "agentTurn", - "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀", - "deliver": true, + "message": "你是一个暖心的提醒助手。请用温暖、有趣的方式提醒用户:{提醒内容}。要求:(1) 不要回复HEARTBEAT_OK (2) 不要解释你是谁 (3) 直接输出一条暖心的提醒消息 (4) 可以加一句简短的鸡汤或关怀的话 (5) 控制在2-3句话以内 (6) 用emoji点缀" + }, + "delivery": { + "mode": "announce", "channel": "qqbot", - "to": "{openid}" + "to": "qqbot:c2c:{openid}", + "accountId": "{accountId}" } } } ``` -> 周期任务**不加** `deleteAfterRun`。群聊 `to` 格式为 `"group:{group_openid}"`。 +> 周期任务**不加** `deleteAfterRun`。群聊 `delivery.to` 格式为 `"qqbot:group:{group_openid}"`。 +> 若通过 `qqbot_remind` 工具生成 cronParams,**必须**原样传给 `cron` 工具,不要修改或省略任何字段,特别是 `delivery.accountId`。 --- diff --git a/extensions/qqbot/src/api.security.test.ts b/extensions/qqbot/src/api.security.test.ts deleted file mode 100644 index 78dcc58110c..00000000000 --- a/extensions/qqbot/src/api.security.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const ssrfMocks = vi.hoisted(() => ({ - fetchWithSsrFGuard: vi.fn(), - resolvePinnedHostnameWithPolicy: vi.fn(), -})); - -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: ssrfMocks.fetchWithSsrFGuard, - resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy, -})); - -vi.mock("./utils/debug-log.js", () => ({ - debugError: vi.fn(), - debugLog: vi.fn(), -})); - -import { MediaFileType, uploadC2CMedia, uploadGroupMedia } from "./api.js"; -import { clearUploadCache, computeFileHash, setCachedFileInfo } from "./utils/upload-cache.js"; - -describe("qqbot direct upload SSRF guard", () => { - beforeEach(() => { - vi.clearAllMocks(); - clearUploadCache(); - ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({ - hostname: "example.com", - addresses: ["203.0.113.10"], - lookup: vi.fn(), - }); - ssrfMocks.fetchWithSsrFGuard.mockResolvedValue({ - response: new Response(JSON.stringify({ file_uuid: "uuid", file_info: "info", ttl: 3600 }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - release: async () => {}, - }); - }); - - it("blocks direct-upload URLs that target private or internal hosts", async () => { - ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce( - new Error("Blocked hostname or private/internal/special-use IP address"), - ); - - await expect( - uploadC2CMedia( - "access-token", - "user-1", - MediaFileType.IMAGE, - "https://169.254.169.254/latest/meta-data/iam/security-credentials/", - ), - ).rejects.toThrow("Blocked hostname or private/internal/special-use IP address"); - - expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); - }); - - it("blocks non-HTTPS direct-upload URLs before the QQ upload request", async () => { - await expect( - uploadGroupMedia( - "access-token", - "group-1", - MediaFileType.FILE, - "http://cdn.qpic.cn/payload.txt", - ), - ).rejects.toThrow("Direct-upload media URL must use HTTPS"); - - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); - expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); - }); - - it("allows public HTTPS direct-upload URLs", async () => { - const result = await uploadC2CMedia( - "access-token", - "user-1", - MediaFileType.IMAGE, - "https://example.com/payload.png", - ); - - expect(result).toEqual({ file_uuid: "uuid", file_info: "info", ttl: 3600 }); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com"); - expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1); - }); - - it("allows public HTTPS direct-upload URLs for group uploads", async () => { - const result = await uploadGroupMedia( - "access-token", - "group-1", - MediaFileType.FILE, - "https://example.com/payload.txt", - ); - - expect(result).toEqual({ file_uuid: "uuid", file_info: "info", ttl: 3600 }); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("example.com"); - expect(ssrfMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(1); - }); - - it("skips URL validation on c2c cache hits when fileData is reused", async () => { - const fileData = "cached-file-data"; - setCachedFileInfo( - computeFileHash(fileData), - "c2c", - "user-1", - MediaFileType.IMAGE, - "cached-info", - "cached-uuid", - 3600, - ); - - const result = await uploadC2CMedia( - "access-token", - "user-1", - MediaFileType.IMAGE, - "https://example.com/stale.png", - fileData, - ); - - expect(result).toEqual({ file_uuid: "", file_info: "cached-info", ttl: 0 }); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); - expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); - }); - - it("skips URL validation on group cache hits when fileData is reused", async () => { - const fileData = "cached-group-file-data"; - setCachedFileInfo( - computeFileHash(fileData), - "group", - "group-1", - MediaFileType.FILE, - "cached-group-info", - "cached-group-uuid", - 3600, - ); - - const result = await uploadGroupMedia( - "access-token", - "group-1", - MediaFileType.FILE, - "https://example.com/stale.txt", - fileData, - ); - - expect(result).toEqual({ file_uuid: "", file_info: "cached-group-info", ttl: 0 }); - expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled(); - expect(ssrfMocks.fetchWithSsrFGuard).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/qqbot/src/api.ts b/extensions/qqbot/src/api.ts deleted file mode 100644 index 1bbe1e62eb6..00000000000 --- a/extensions/qqbot/src/api.ts +++ /dev/null @@ -1,1052 +0,0 @@ -import { createRequire } from "node:module"; -import os from "node:os"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -import { - fetchWithSsrFGuard, - resolvePinnedHostnameWithPolicy, -} from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { debugLog, debugError } from "./utils/debug-log.js"; -import { sanitizeFileName } from "./utils/platform.js"; -import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js"; - -const API_BASE = "https://api.sgroup.qq.com"; -const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; - -// Plugin User-Agent format: QQBotPlugin/{version} (Node/{nodeVersion}; {os}) -const _require = createRequire(import.meta.url); -const _pluginVersion = readPluginPackageVersion({ require: _require }); -export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`; - -// ========================================================================= -// Per-appId runtime config (avoids multi-account global state conflicts) -// ========================================================================= -const markdownSupportMap = new Map(); - -/** Structured metadata recorded for outbound messages. */ -export interface OutboundMeta { - text?: string; - mediaType?: "image" | "voice" | "video" | "file"; - mediaUrl?: string; - mediaLocalPath?: string; - ttsText?: string; -} - -type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void; -const onMessageSentHookMap = new Map(); - -/** Register an outbound-message hook scoped to one appId. */ -export function onMessageSent(appId: string, callback: OnMessageSentCallback): void { - onMessageSentHookMap.set(normalizeOptionalString(appId) ?? "", callback); -} - -/** Initialize per-app API behavior such as markdown support. */ -export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void { - markdownSupportMap.set(normalizeOptionalString(appId) ?? "", options.markdownSupport === true); -} - -/** Return whether markdown is enabled for the given appId. */ -export function isMarkdownSupport(appId: string): boolean { - return markdownSupportMap.get(normalizeOptionalString(appId) ?? "") ?? false; -} - -// Keep token state per appId to avoid multi-account cross-talk. -const tokenCacheMap = new Map(); -const tokenFetchPromises = new Map>(); - -/** - * Resolve an access token with caching and singleflight semantics. - */ -export async function getAccessToken(appId: string, clientSecret: string): Promise { - const normalizedAppId = normalizeOptionalString(appId) ?? ""; - const cachedToken = tokenCacheMap.get(normalizedAppId); - - // Refresh slightly ahead of expiry without making short-lived tokens unusable. - const REFRESH_AHEAD_MS = cachedToken - ? Math.min(5 * 60 * 1000, (cachedToken.expiresAt - Date.now()) / 3) - : 0; - if (cachedToken && Date.now() < cachedToken.expiresAt - REFRESH_AHEAD_MS) { - return cachedToken.token; - } - - let fetchPromise = tokenFetchPromises.get(normalizedAppId); - if (fetchPromise) { - debugLog( - `[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`, - ); - return fetchPromise; - } - - fetchPromise = (async () => { - try { - return await doFetchToken(normalizedAppId, clientSecret); - } finally { - tokenFetchPromises.delete(normalizedAppId); - } - })(); - - tokenFetchPromises.set(normalizedAppId, fetchPromise); - return fetchPromise; -} - -/** Perform the token fetch request. */ -async function doFetchToken(appId: string, clientSecret: string): Promise { - const requestBody = { appId, clientSecret }; - const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT }; - - debugLog(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`); - - let response: Response; - let release = async () => {}; - try { - const guarded = await fetchWithSsrFGuard({ - url: TOKEN_URL, - init: { - method: "POST", - headers: requestHeaders, - body: JSON.stringify(requestBody), - }, - auditContext: "qqbot.token", - }); - response = guarded.response; - release = guarded.release; - } catch (err) { - debugError(`[qqbot-api:${appId}] <<< Network error:`, err); - throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, { - cause: err, - }); - } - - try { - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - const tokenTraceId = response.headers.get("x-tps-trace-id") ?? ""; - debugLog( - `[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`, - ); - - let data: { access_token?: string; expires_in?: number }; - let rawBody: string; - try { - rawBody = await response.text(); - // Redact the token before logging the raw response body. - const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"'); - debugLog(`[qqbot-api:${appId}] <<< Body:`, logBody); - data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number }; - } catch (err) { - debugError(`[qqbot-api:${appId}] <<< Parse error:`, err); - throw new Error(`Failed to parse access_token response: ${formatErrorMessage(err)}`, { - cause: err, - }); - } - - if (!data.access_token) { - throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`); - } - - const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000; - - tokenCacheMap.set(appId, { - token: data.access_token, - expiresAt, - appId, - }); - - debugLog(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`); - return data.access_token; - } finally { - await release(); - } -} - -/** Clear one token cache or all token caches. */ -export function clearTokenCache(appId?: string): void { - if (appId) { - const normalizedAppId = normalizeOptionalString(appId) ?? ""; - tokenCacheMap.delete(normalizedAppId); - debugLog(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`); - } else { - tokenCacheMap.clear(); - debugLog(`[qqbot-api] All token caches cleared.`); - } -} - -/** Return token-cache status for diagnostics. */ -export function getTokenStatus(appId: string): { - status: "valid" | "expired" | "refreshing" | "none"; - expiresAt: number | null; -} { - if (tokenFetchPromises.has(appId)) { - return { status: "refreshing", expiresAt: tokenCacheMap.get(appId)?.expiresAt ?? null }; - } - const cached = tokenCacheMap.get(appId); - if (!cached) { - return { status: "none", expiresAt: null }; - } - const remaining = cached.expiresAt - Date.now(); - const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3); - return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt }; -} - -/** Generate a message sequence in the 0..65535 range. */ -export function getNextMsgSeq(_msgId: string): number { - const timePart = Date.now() % 100000000; - const random = Math.floor(Math.random() * 65536); - return (timePart ^ random) % 65536; -} - -const DEFAULT_API_TIMEOUT = 30000; -const FILE_UPLOAD_TIMEOUT = 120000; - -/** Shared API request wrapper. */ -export async function apiRequest( - accessToken: string, - method: string, - path: string, - body?: unknown, - timeoutMs?: number, -): Promise { - const url = `${API_BASE}${path}`; - const headers: Record = { - Authorization: `QQBot ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": PLUGIN_USER_AGENT, - }; - - const isFileUpload = path.includes("/files"); - const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => { - controller.abort(); - }, timeout); - - const options: RequestInit = { - method, - headers, - signal: controller.signal, - }; - - if (body) { - options.body = JSON.stringify(body); - } - - debugLog(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`); - if (body) { - const logBody = { ...body } as Record; - if (typeof logBody.file_data === "string") { - logBody.file_data = ``; - } - debugLog(`[qqbot-api] >>> Body:`, JSON.stringify(logBody)); - } - - let res: Response; - let release = async () => {}; - try { - const guarded = await fetchWithSsrFGuard({ - url, - init: options, - auditContext: `qqbot.api${path}`, - }); - res = guarded.response; - release = guarded.release; - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof Error && err.name === "AbortError") { - debugError(`[qqbot-api] <<< Request timeout after ${timeout}ms`); - throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`, { cause: err }); - } - debugError(`[qqbot-api] <<< Network error:`, err); - throw new Error(`Network error [${path}]: ${formatErrorMessage(err)}`, { cause: err }); - } finally { - clearTimeout(timeoutId); - } - - const responseHeaders: Record = {}; - res.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - const traceId = res.headers.get("x-tps-trace-id") ?? ""; - debugLog( - `[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`, - ); - - try { - let data: T; - const rawBody = await res.text(); - debugLog(`[qqbot-api] <<< Body:`, rawBody); - data = JSON.parse(rawBody) as T; - - if (!res.ok) { - const error = data as { message?: string; code?: number }; - throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`); - } - - return data; - } catch (err) { - throw new Error(`Failed to parse response[${path}]: ${formatErrorMessage(err)}`, { - cause: err, - }); - } finally { - await release(); - } -} - -// Upload retry with exponential backoff. - -const UPLOAD_MAX_RETRIES = 2; -const UPLOAD_BASE_DELAY_MS = 1000; - -async function apiRequestWithRetry( - accessToken: string, - method: string, - path: string, - body?: unknown, - maxRetries = UPLOAD_MAX_RETRIES, -): Promise { - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await apiRequest(accessToken, method, path, body); - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - - const errMsg = lastError.message; - if ( - errMsg.includes("400") || - errMsg.includes("401") || - errMsg.includes("Invalid") || - errMsg.includes("upload timeout") || - errMsg.includes("timeout") || - errMsg.includes("Timeout") - ) { - throw lastError; - } - - if (attempt < maxRetries) { - const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt); - debugLog( - `[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`, - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - } - - throw lastError!; -} - -export async function getGatewayUrl(accessToken: string): Promise { - const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway"); - return data.url; -} - -// Message sending. - -export interface MessageResponse { - id: string; - timestamp: number | string; - ext_info?: { - ref_idx?: string; - }; -} - -/** - * Send a message and invoke the refIdx hook when QQ returns one. - */ -async function sendAndNotify( - appId: string, - accessToken: string, - method: string, - path: string, - body: unknown, - meta: OutboundMeta, -): Promise { - const result = await apiRequest(accessToken, method, path, body); - const hook = onMessageSentHookMap.get(normalizeOptionalString(appId) ?? ""); - if (result.ext_info?.ref_idx && hook) { - try { - hook(result.ext_info.ref_idx, meta); - } catch (err) { - debugError(`[qqbot-api:${appId}] onMessageSent hook error: ${String(err)}`); - } - } - return result; -} - -function buildMessageBody( - appId: string, - content: string, - msgId: string | undefined, - msgSeq: number, - messageReference?: string, -): Record { - const md = isMarkdownSupport(appId); - const body: Record = md - ? { - markdown: { content }, - msg_type: 2, - msg_seq: msgSeq, - } - : { - content, - msg_type: 0, - msg_seq: msgSeq, - }; - - if (msgId) { - body.msg_id = msgId; - } - if (messageReference && !md) { - body.message_reference = { message_id: messageReference }; - } - return body; -} - -export async function sendC2CMessage( - appId: string, - accessToken: string, - openid: string, - content: string, - msgId?: string, - messageReference?: string, -): Promise { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = buildMessageBody(appId, content, msgId, msgSeq, messageReference); - return sendAndNotify(appId, accessToken, "POST", `/v2/users/${openid}/messages`, body, { - text: content, - }); -} - -export async function sendC2CInputNotify( - accessToken: string, - openid: string, - msgId?: string, - inputSecond: number = 60, -): Promise<{ refIdx?: string }> { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = { - msg_type: 6, - input_notify: { - input_type: 1, - input_second: inputSecond, - }, - msg_seq: msgSeq, - ...(msgId ? { msg_id: msgId } : {}), - }; - const response = await apiRequest<{ ext_info?: { ref_idx?: string } }>( - accessToken, - "POST", - `/v2/users/${openid}/messages`, - body, - ); - return { refIdx: response.ext_info?.ref_idx }; -} - -export async function sendChannelMessage( - accessToken: string, - channelId: string, - content: string, - msgId?: string, -): Promise { - return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, { - content, - ...(msgId ? { msg_id: msgId } : {}), - }); -} - -/** Send a direct-message payload inside a guild DM session. */ -export async function sendDmMessage( - accessToken: string, - guildId: string, - content: string, - msgId?: string, -): Promise<{ id: string; timestamp: string }> { - return apiRequest(accessToken, "POST", `/dms/${guildId}/messages`, { - content, - ...(msgId ? { msg_id: msgId } : {}), - }); -} - -export async function sendGroupMessage( - appId: string, - accessToken: string, - groupOpenid: string, - content: string, - msgId?: string, -): Promise { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - const body = buildMessageBody(appId, content, msgId, msgSeq); - return sendAndNotify(appId, accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { - text: content, - }); -} - -function buildProactiveMessageBody(appId: string, content: string): Record { - if (!content || content.trim().length === 0) { - throw new Error("Proactive message content must not be empty (markdown.content is empty)"); - } - if (isMarkdownSupport(appId)) { - return { markdown: { content }, msg_type: 2 }; - } else { - return { content, msg_type: 0 }; - } -} - -export async function sendProactiveC2CMessage( - appId: string, - accessToken: string, - openid: string, - content: string, -): Promise { - const body = buildProactiveMessageBody(appId, content); - return sendAndNotify(appId, accessToken, "POST", `/v2/users/${openid}/messages`, body, { - text: content, - }); -} - -export async function sendProactiveGroupMessage( - appId: string, - accessToken: string, - groupOpenid: string, - content: string, -): Promise { - const body = buildProactiveMessageBody(appId, content); - return sendAndNotify(appId, accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { - text: content, - }); -} - -// Rich media message support. - -export enum MediaFileType { - IMAGE = 1, - VIDEO = 2, - VOICE = 3, - FILE = 4, -} - -export interface UploadMediaResponse { - file_uuid: string; - file_info: string; - ttl: number; - id?: string; -} - -async function assertDirectUploadUrlAllowed(url: string): Promise { - let parsed: URL; - try { - parsed = new URL(url); - } catch (err) { - throw new Error(`Invalid media URL: ${formatErrorMessage(err)}`, { cause: err }); - } - - if (parsed.protocol !== "https:") { - throw new Error("Direct-upload media URL must use HTTPS"); - } - - await resolvePinnedHostnameWithPolicy(parsed.hostname); - return parsed.toString(); -} - -export async function uploadC2CMedia( - accessToken: string, - openid: string, - fileType: MediaFileType, - url?: string, - fileData?: string, - srvSendMsg = false, - fileName?: string, -): Promise { - if (!url && !fileData) { - throw new Error("uploadC2CMedia: url or fileData is required"); - } - - if (fileData) { - const contentHash = computeFileHash(fileData); - const cachedInfo = getCachedFileInfo(contentHash, "c2c", openid, fileType); - if (cachedInfo) { - return { file_uuid: "", file_info: cachedInfo, ttl: 0 }; - } - } - - const body: Record = { file_type: fileType, srv_send_msg: srvSendMsg }; - if (url) { - body.url = await assertDirectUploadUrlAllowed(url); - } else if (fileData) { - body.file_data = fileData; - } - if (fileType === MediaFileType.FILE && fileName) { - body.file_name = sanitizeFileName(fileName); - } - - const result = await apiRequestWithRetry( - accessToken, - "POST", - `/v2/users/${openid}/files`, - body, - ); - - if (fileData && result.file_info && result.ttl > 0) { - const contentHash = computeFileHash(fileData); - setCachedFileInfo( - contentHash, - "c2c", - openid, - fileType, - result.file_info, - result.file_uuid, - result.ttl, - ); - } - return result; -} - -export async function uploadGroupMedia( - accessToken: string, - groupOpenid: string, - fileType: MediaFileType, - url?: string, - fileData?: string, - srvSendMsg = false, - fileName?: string, -): Promise { - if (!url && !fileData) { - throw new Error("uploadGroupMedia: url or fileData is required"); - } - - if (fileData) { - const contentHash = computeFileHash(fileData); - const cachedInfo = getCachedFileInfo(contentHash, "group", groupOpenid, fileType); - if (cachedInfo) { - return { file_uuid: "", file_info: cachedInfo, ttl: 0 }; - } - } - - const body: Record = { file_type: fileType, srv_send_msg: srvSendMsg }; - if (url) { - body.url = await assertDirectUploadUrlAllowed(url); - } else if (fileData) { - body.file_data = fileData; - } - if (fileType === MediaFileType.FILE && fileName) { - body.file_name = sanitizeFileName(fileName); - } - - const result = await apiRequestWithRetry( - accessToken, - "POST", - `/v2/groups/${groupOpenid}/files`, - body, - ); - - if (fileData && result.file_info && result.ttl > 0) { - const contentHash = computeFileHash(fileData); - setCachedFileInfo( - contentHash, - "group", - groupOpenid, - fileType, - result.file_info, - result.file_uuid, - result.ttl, - ); - } - return result; -} - -export async function sendC2CMediaMessage( - appId: string, - accessToken: string, - openid: string, - fileInfo: string, - msgId?: string, - content?: string, - meta?: OutboundMeta, -): Promise { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - return sendAndNotify( - appId, - accessToken, - "POST", - `/v2/users/${openid}/messages`, - { - msg_type: 7, - media: { file_info: fileInfo }, - msg_seq: msgSeq, - ...(content ? { content } : {}), - ...(msgId ? { msg_id: msgId } : {}), - }, - meta ?? { text: content }, - ); -} - -export async function sendGroupMediaMessage( - accessToken: string, - groupOpenid: string, - fileInfo: string, - msgId?: string, - content?: string, -): Promise<{ id: string; timestamp: string }> { - const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; - return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, { - msg_type: 7, - media: { file_info: fileInfo }, - msg_seq: msgSeq, - ...(content ? { content } : {}), - ...(msgId ? { msg_id: msgId } : {}), - }); -} - -export async function sendC2CImageMessage( - appId: string, - accessToken: string, - openid: string, - imageUrl: string, - msgId?: string, - content?: string, - localPath?: string, -): Promise { - let uploadResult: UploadMediaResponse; - const isBase64 = imageUrl.startsWith("data:"); - if (isBase64) { - const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); - if (!matches) { - throw new Error("Invalid Base64 Data URL format"); - } - uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.IMAGE, - undefined, - matches[2], - false, - ); - } else { - uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.IMAGE, - imageUrl, - undefined, - false, - ); - } - const meta: OutboundMeta = { - text: content, - mediaType: "image", - ...(!isBase64 ? { mediaUrl: imageUrl } : {}), - ...(localPath ? { mediaLocalPath: localPath } : {}), - }; - return sendC2CMediaMessage( - appId, - accessToken, - openid, - uploadResult.file_info, - msgId, - content, - meta, - ); -} - -export async function sendGroupImageMessage( - appId: string, - accessToken: string, - groupOpenid: string, - imageUrl: string, - msgId?: string, - content?: string, -): Promise<{ id: string; timestamp: string }> { - let uploadResult: UploadMediaResponse; - const isBase64 = imageUrl.startsWith("data:"); - if (isBase64) { - const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); - if (!matches) { - throw new Error("Invalid Base64 Data URL format"); - } - uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.IMAGE, - undefined, - matches[2], - false, - ); - } else { - uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.IMAGE, - imageUrl, - undefined, - false, - ); - } - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); -} - -export async function sendC2CVoiceMessage( - appId: string, - accessToken: string, - openid: string, - voiceBase64?: string, - voiceUrl?: string, - msgId?: string, - ttsText?: string, - filePath?: string, -): Promise { - const uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.VOICE, - voiceUrl, - voiceBase64, - false, - ); - return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, undefined, { - mediaType: "voice", - ...(ttsText ? { ttsText } : {}), - ...(filePath ? { mediaLocalPath: filePath } : {}), - }); -} - -export async function sendGroupVoiceMessage( - appId: string, - accessToken: string, - groupOpenid: string, - voiceBase64?: string, - voiceUrl?: string, - msgId?: string, -): Promise<{ id: string; timestamp: string }> { - const uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.VOICE, - voiceUrl, - voiceBase64, - false, - ); - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId); -} - -export async function sendC2CFileMessage( - appId: string, - accessToken: string, - openid: string, - fileBase64?: string, - fileUrl?: string, - msgId?: string, - fileName?: string, - localFilePath?: string, -): Promise { - const uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.FILE, - fileUrl, - fileBase64, - false, - fileName, - ); - return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, undefined, { - mediaType: "file", - mediaUrl: fileUrl, - mediaLocalPath: localFilePath ?? fileName, - }); -} - -export async function sendGroupFileMessage( - appId: string, - accessToken: string, - groupOpenid: string, - fileBase64?: string, - fileUrl?: string, - msgId?: string, - fileName?: string, -): Promise<{ id: string; timestamp: string }> { - const uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.FILE, - fileUrl, - fileBase64, - false, - fileName, - ); - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId); -} - -export async function sendC2CVideoMessage( - appId: string, - accessToken: string, - openid: string, - videoUrl?: string, - videoBase64?: string, - msgId?: string, - content?: string, - localPath?: string, -): Promise { - const uploadResult = await uploadC2CMedia( - accessToken, - openid, - MediaFileType.VIDEO, - videoUrl, - videoBase64, - false, - ); - return sendC2CMediaMessage(appId, accessToken, openid, uploadResult.file_info, msgId, content, { - text: content, - mediaType: "video", - ...(videoUrl ? { mediaUrl: videoUrl } : {}), - ...(localPath ? { mediaLocalPath: localPath } : {}), - }); -} - -export async function sendGroupVideoMessage( - appId: string, - accessToken: string, - groupOpenid: string, - videoUrl?: string, - videoBase64?: string, - msgId?: string, - content?: string, -): Promise<{ id: string; timestamp: string }> { - const uploadResult = await uploadGroupMedia( - accessToken, - groupOpenid, - MediaFileType.VIDEO, - videoUrl, - videoBase64, - false, - ); - return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content); -} - -// Background token refresh, isolated per appId. - -interface BackgroundTokenRefreshOptions { - refreshAheadMs?: number; - randomOffsetMs?: number; - minRefreshIntervalMs?: number; - retryDelayMs?: number; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -const backgroundRefreshControllers = new Map(); - -export function startBackgroundTokenRefresh( - appId: string, - clientSecret: string, - options?: BackgroundTokenRefreshOptions, -): void { - if (backgroundRefreshControllers.has(appId)) { - debugLog(`[qqbot-api:${appId}] Background token refresh already running`); - return; - } - - const { - refreshAheadMs = 5 * 60 * 1000, - randomOffsetMs = 30 * 1000, - minRefreshIntervalMs = 60 * 1000, - retryDelayMs = 5 * 1000, - log, - } = options ?? {}; - - const controller = new AbortController(); - backgroundRefreshControllers.set(appId, controller); - const signal = controller.signal; - - const refreshLoop = async () => { - log?.info?.(`[qqbot-api:${appId}] Background token refresh started`); - - while (!signal.aborted) { - try { - await getAccessToken(appId, clientSecret); - const cached = tokenCacheMap.get(appId); - - if (cached) { - const expiresIn = cached.expiresAt - Date.now(); - const randomOffset = Math.random() * randomOffsetMs; - const refreshIn = Math.max( - expiresIn - refreshAheadMs - randomOffset, - minRefreshIntervalMs, - ); - - log?.debug?.( - `[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`, - ); - await sleep(refreshIn, signal); - } else { - log?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`); - await sleep(minRefreshIntervalMs, signal); - } - } catch (err) { - if (signal.aborted) { - break; - } - log?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${String(err)}`); - await sleep(retryDelayMs, signal); - } - } - - backgroundRefreshControllers.delete(appId); - log?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`); - }; - - refreshLoop().catch((err) => { - backgroundRefreshControllers.delete(appId); - log?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`); - }); -} - -/** - * Stop background token refresh. - * @param appId Optional appId to stop a single account instead of all refresh loops. - */ -export function stopBackgroundTokenRefresh(appId?: string): void { - if (appId) { - const controller = backgroundRefreshControllers.get(appId); - if (controller) { - controller.abort(); - backgroundRefreshControllers.delete(appId); - } - } else { - for (const controller of backgroundRefreshControllers.values()) { - controller.abort(); - } - backgroundRefreshControllers.clear(); - } -} - -export function isBackgroundTokenRefreshRunning(appId?: string): boolean { - if (appId) { - return backgroundRefreshControllers.has(appId); - } - return backgroundRefreshControllers.size > 0; -} - -async function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(resolve, ms); - if (signal) { - if (signal.aborted) { - clearTimeout(timer); - reject(new Error("Aborted")); - return; - } - const onAbort = () => { - clearTimeout(timer); - reject(new Error("Aborted")); - }; - signal.addEventListener("abort", onAbort, { once: true }); - } - }); -} diff --git a/extensions/qqbot/src/bridge/approval/capability.ts b/extensions/qqbot/src/bridge/approval/capability.ts new file mode 100644 index 00000000000..c1817d2a604 --- /dev/null +++ b/extensions/qqbot/src/bridge/approval/capability.ts @@ -0,0 +1,242 @@ +/** + * QQ Bot Approval Capability — entry point. + * + * QQBot uses a simpler approval model than Telegram/Slack: any user who + * can see the inline-keyboard buttons can approve. No explicit approver + * list is required — the bot simply sends the approval message to the + * originating conversation and whoever clicks the button resolves it. + * + * When `execApprovals` IS configured, it gates which requests are + * handled natively and who is authorized. When it is NOT configured, + * QQBot falls back to "always handle, anyone can approve". + */ + +import { + createChannelApprovalCapability, + splitChannelApprovalCapability, +} from "openclaw/plugin-sdk/approval-delivery-runtime"; +import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime"; +import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; +import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime"; +import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { resolveApprovalTarget } from "../../engine/approval/index.js"; +import { + isQQBotExecApprovalClientEnabled, + matchesQQBotApprovalAccount, + shouldHandleQQBotExecApprovalRequest, + isQQBotExecApprovalAuthorizedSender, + isQQBotExecApprovalApprover, + resolveQQBotExecApprovalConfig, +} from "../../exec-approvals.js"; +import { ensurePlatformAdapter } from "../bootstrap.js"; +import { resolveQQBotAccount } from "../config.js"; +import { getBridgeLogger } from "../logger.js"; + +/** + * When `execApprovals` is configured, delegate to the profile-based + * check. Otherwise fall back to target-resolvability plus the shared + * per-account ownership rule in `matchesQQBotApprovalAccount` so that + * each QQBot account handler only delivers approvals that originated + * from its own account (openids are account-scoped — cross-account + * delivery fails with 500 on the QQ Bot API). + */ +function shouldHandleRequest(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: { + request: { + sessionKey?: string | null; + turnSourceTo?: string | null; + turnSourceChannel?: string | null; + turnSourceAccountId?: string | null; + }; + }; +}): boolean { + if (hasExecApprovalConfig(params)) { + return shouldHandleQQBotExecApprovalRequest(params as never); + } + if (!canResolveTarget(params.request)) { + return false; + } + return matchesQQBotApprovalAccount({ + cfg: params.cfg, + accountId: params.accountId, + request: params.request as never, + }); +} + +function hasExecApprovalConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + return resolveQQBotExecApprovalConfig(params) !== undefined; +} + +function isNativeDeliveryEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + if (hasExecApprovalConfig(params)) { + return isQQBotExecApprovalClientEnabled(params); + } + const account = resolveQQBotAccount(params.cfg, params.accountId); + return account.enabled && account.secretSource !== "none"; +} + +function canResolveTarget(request: { + request: { sessionKey?: string | null; turnSourceTo?: string | null }; +}): boolean { + const sessionKey = request.request.sessionKey ?? null; + const turnSourceTo = request.request.turnSourceTo ?? null; + + const target = resolveApprovalTarget(sessionKey, turnSourceTo); + if (target) { + return true; + } + + const sessionConversation = resolveApprovalRequestSessionConversation({ + request: request as never, + channel: "qqbot", + bundledFallback: true, + }); + return sessionConversation?.id != null; +} + +function createQQBotApprovalCapability(): ChannelApprovalCapability { + return createChannelApprovalCapability({ + authorizeActorAction: ({ cfg, accountId, senderId, approvalKind }) => { + if (hasExecApprovalConfig({ cfg, accountId })) { + const authorized = + approvalKind === "plugin" + ? isQQBotExecApprovalApprover({ cfg, accountId, senderId }) + : isQQBotExecApprovalAuthorizedSender({ cfg, accountId, senderId }); + return authorized + ? { authorized: true } + : { authorized: false, reason: "You are not authorized to approve this request." }; + } + return { authorized: true }; + }, + + getActionAvailabilityState: ({ + cfg, + accountId, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + action: "approve"; + }) => { + const enabled = isNativeDeliveryEnabled({ cfg, accountId }); + return enabled ? { kind: "enabled" } : { kind: "disabled" }; + }, + + getExecInitiatingSurfaceState: ({ + cfg, + accountId, + }: { + cfg: OpenClawConfig; + accountId?: string | null; + action: "approve"; + }) => { + const enabled = isNativeDeliveryEnabled({ cfg, accountId }); + return enabled ? { kind: "enabled" } : { kind: "disabled" }; + }, + + describeExecApprovalSetup: ({ accountId }: { accountId?: string | null }) => { + const prefix = + accountId && accountId !== "default" + ? `channels.qqbot.accounts.${accountId}` + : "channels.qqbot"; + return `QQBot native exec approvals are enabled by default. To restrict who can approve, configure \`${prefix}.execApprovals.approvers\` with QQ user OpenIDs.`; + }, + + delivery: { + hasConfiguredDmRoute: () => true, + shouldSuppressForwardingFallback: (input) => { + const channel = normalizeOptionalString(input.target?.channel); + if (channel !== "qqbot") { + return false; + } + const accountId = + normalizeOptionalString(input.target?.accountId) ?? + normalizeOptionalString(input.request?.request?.turnSourceAccountId); + const result = isNativeDeliveryEnabled({ cfg: input.cfg, accountId }); + getBridgeLogger().debug?.( + `[qqbot:approval] shouldSuppressForwardingFallback channel=${channel} accountId=${accountId} → ${result}`, + ); + return result; + }, + }, + + native: { + describeDeliveryCapabilities: ({ cfg, accountId }) => ({ + enabled: isNativeDeliveryEnabled({ cfg, accountId }), + preferredSurface: "origin" as const, + supportsOriginSurface: true, + supportsApproverDmSurface: false, + notifyOriginWhenDmOnly: false, + }), + resolveOriginTarget: ({ request }) => { + const sessionKey = request.request.sessionKey ?? null; + const turnSourceTo = request.request.turnSourceTo ?? null; + const target = resolveApprovalTarget(sessionKey, turnSourceTo); + if (target) { + return { to: `${target.type}:${target.id}` }; + } + const sessionConversation = resolveApprovalRequestSessionConversation({ + request: request as never, + channel: "qqbot", + bundledFallback: true, + }); + if (sessionConversation?.id) { + const kind = sessionConversation.kind === "group" ? "group" : "c2c"; + return { to: `${kind}:${sessionConversation.id}` }; + } + return null; + }, + }, + + nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({ + eventKinds: ["exec", "plugin"], + isConfigured: ({ cfg, accountId }) => { + const result = isNativeDeliveryEnabled({ cfg, accountId }); + getBridgeLogger().debug?.( + `[qqbot:approval] nativeRuntime.isConfigured accountId=${accountId} → ${result}`, + ); + return result; + }, + shouldHandle: ({ cfg, accountId, request }) => { + const result = shouldHandleRequest({ + cfg, + accountId, + request: request as never, + }); + getBridgeLogger().debug?.( + `[qqbot:approval] nativeRuntime.shouldHandle accountId=${accountId} → ${result}`, + ); + return result; + }, + load: async () => { + // Ensure PlatformAdapter is registered before handler-runtime uses + // getPlatformAdapter(). When the framework spawns the approval handler + // outside the qqbot gateway startAccount context, channel.ts's + // side-effect `import "./bridge/bootstrap.js"` may not have run yet. + ensurePlatformAdapter(); + return (await import("./handler-runtime.js")) + .qqbotApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter; + }, + }), + }); +} + +export const qqbotApprovalCapability = createQQBotApprovalCapability(); + +export const qqbotNativeApprovalAdapter = splitChannelApprovalCapability(qqbotApprovalCapability); + +let _cachedCapability: ChannelApprovalCapability | undefined; + +export function getQQBotApprovalCapability(): ChannelApprovalCapability { + _cachedCapability ??= qqbotApprovalCapability; + return _cachedCapability; +} diff --git a/extensions/qqbot/src/bridge/approval/handler-runtime.ts b/extensions/qqbot/src/bridge/approval/handler-runtime.ts new file mode 100644 index 00000000000..31bda7f2e2c --- /dev/null +++ b/extensions/qqbot/src/bridge/approval/handler-runtime.ts @@ -0,0 +1,204 @@ +/** + * QQ Bot Native Approval Runtime Adapter. + * + * Implements the framework's ChannelApprovalNativeRuntimeSpec to deliver + * approval requests as QQ messages with inline keyboard buttons and handle + * resolved/expired lifecycle events. + * + * This file is lazily imported by capability.ts to avoid loading + * heavy dependencies on the critical startup path. + */ + +import type { ChannelApprovalNativeRuntimeSpec } from "openclaw/plugin-sdk/approval-handler-runtime"; +import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; +import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime"; +import { resolveApprovalRequestSessionConversation } from "openclaw/plugin-sdk/approval-native-runtime"; +import { + buildExecApprovalText, + buildPluginApprovalText, + buildApprovalKeyboard, + resolveApprovalTarget, + type ExecApprovalRequest, + type PluginApprovalRequest, +} from "../../engine/approval/index.js"; +import { getMessageApi, accountToCreds } from "../../engine/messaging/sender.js"; +import type { ChatScope, InlineKeyboard, MessageResponse } from "../../engine/types.js"; +import { ensurePlatformAdapter } from "../bootstrap.js"; +import { + matchesQQBotApprovalAccount, + resolveQQBotExecApprovalConfig, + isQQBotExecApprovalClientEnabled, + shouldHandleQQBotExecApprovalRequest, +} from "../../exec-approvals.js"; +import { resolveQQBotAccount } from "../config.js"; +import { getBridgeLogger } from "../logger.js"; + +type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest; + +type QQBotPendingEntry = { + messageId?: string; + targetType: ChatScope; + targetId: string; +}; + +type QQBotPendingPayload = { + text: string; + keyboard: InlineKeyboard; +}; + +function isExecRequest(request: ApprovalRequest): request is ExecApprovalRequest { + return "expiresAtMs" in request; +} + +function resolveQQTarget(request: ApprovalRequest): { type: ChatScope; id: string } | null { + const sessionConversation = resolveApprovalRequestSessionConversation({ + request: request as never, + channel: "qqbot", + bundledFallback: true, + }); + + const sessionKey = request.request.sessionKey ?? null; + const turnSourceTo = request.request.turnSourceTo ?? null; + + const target = resolveApprovalTarget(sessionKey, turnSourceTo); + if (target) { + return target; + } + + if (sessionConversation?.id) { + const kind = sessionConversation.kind; + const chatScope: ChatScope = kind === "group" ? "group" : "c2c"; + return { type: chatScope, id: sessionConversation.id }; + } + + return null; +} + +type QQBotPreparedTarget = { type: ChatScope; id: string }; + +const qqbotApprovalRuntimeSpec: ChannelApprovalNativeRuntimeSpec< + QQBotPendingPayload, + QQBotPreparedTarget, + QQBotPendingEntry +> = { + eventKinds: ["exec", "plugin"], + + availability: { + isConfigured: ({ cfg, accountId }) => { + if (resolveQQBotExecApprovalConfig({ cfg, accountId }) !== undefined) { + const result = isQQBotExecApprovalClientEnabled({ cfg, accountId }); + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] isConfigured(profile) accountId=${accountId} → ${result}`, + ); + return result; + } + const account = resolveQQBotAccount(cfg, accountId ?? undefined); + const result = account.enabled && account.secretSource !== "none"; + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] isConfigured(fallback) accountId=${accountId} enabled=${account.enabled} secretSource=${account.secretSource} → ${result}`, + ); + return result; + }, + shouldHandle: ({ cfg, accountId, request }) => { + if (resolveQQBotExecApprovalConfig({ cfg, accountId }) !== undefined) { + const result = shouldHandleQQBotExecApprovalRequest({ cfg, accountId, request }); + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] shouldHandle(profile) accountId=${accountId} → ${result}`, + ); + return result; + } + const target = resolveQQTarget(request as ApprovalRequest); + if (target === null) { + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] shouldHandle(fallback) accountId=${accountId} target=null → false`, + ); + return false; + } + const accountMatches = matchesQQBotApprovalAccount({ + cfg, + accountId, + request: request as ApprovalRequest, + }); + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] shouldHandle(fallback) accountId=${accountId} target=${JSON.stringify( + target, + )} accountMatches=${accountMatches} → ${accountMatches}`, + ); + return accountMatches; + }, + }, + + presentation: { + buildPendingPayload: ({ request, view }) => { + const req = request as ApprovalRequest; + const text = isExecRequest(req) ? buildExecApprovalText(req) : buildPluginApprovalText(req); + const keyboard = buildApprovalKeyboard( + req.id, + view.actions.map((action) => action.decision), + ); + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] buildPendingPayload requestId=${req.id} kind=${ + isExecRequest(req) ? "exec" : "plugin" + }`, + ); + return { text, keyboard }; + }, + buildResolvedResult: () => ({ kind: "leave" }), + buildExpiredResult: () => ({ kind: "leave" }), + }, + + transport: { + prepareTarget: ({ request }) => { + const target = resolveQQTarget(request as ApprovalRequest); + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] prepareTarget requestId=${request.id} target=${JSON.stringify(target)}`, + ); + if (!target) { + return null; + } + return { target, dedupeKey: `${target.type}:${target.id}` }; + }, + + deliverPending: async ({ cfg, accountId, preparedTarget, pendingPayload }) => { + // Ensure the PlatformAdapter is registered — resolveQQBotAccount below + // calls getPlatformAdapter() to resolve secret inputs. + ensurePlatformAdapter(); + const account = resolveQQBotAccount(cfg, accountId ?? undefined); + const creds = accountToCreds(account); + const messageApi = getMessageApi(account.appId); + + let result: MessageResponse; + try { + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] deliverPending accountId=${accountId} target=${preparedTarget.type}:${preparedTarget.id}`, + ); + result = await messageApi.sendMessage( + preparedTarget.type, + preparedTarget.id, + pendingPayload.text, + creds, + { inlineKeyboard: pendingPayload.keyboard }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to send approval message to ${preparedTarget.type}:${preparedTarget.id}: ${msg}`, + { cause: err }, + ); + } + + getBridgeLogger().debug?.( + `[qqbot:approval-runtime] deliverPending success accountId=${accountId} messageId=${result.id ?? ""}`, + ); + return { + messageId: result.id, + targetType: preparedTarget.type, + targetId: preparedTarget.id, + }; + }, + }, +}; + +export const qqbotApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter( + qqbotApprovalRuntimeSpec, +) as unknown as ChannelApprovalNativeRuntimeAdapter; diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts new file mode 100644 index 00000000000..1e9e225d2a7 --- /dev/null +++ b/extensions/qqbot/src/bridge/bootstrap.ts @@ -0,0 +1,135 @@ +/** + * Bootstrap the PlatformAdapter for the built-in version. + * + * ## Design + * + * The adapter is registered via two complementary mechanisms: + * + * 1. **Factory registration** (`registerPlatformAdapterFactory`) — a lightweight + * callback stored in `adapter/index.ts` that is invoked lazily by + * `getPlatformAdapter()` on first access. This guarantees the adapter is + * available regardless of module evaluation order or bundler chunk splitting. + * + * 2. **Eager side-effect** (`ensurePlatformAdapter()`) — called at module + * evaluation time when `channel.ts` imports this file. Provides the adapter + * immediately for code that runs synchronously during startup. + * + * Heavy async-only dependencies (`media-runtime`, `config-runtime`, + * `approval-gateway-runtime`) are lazy-imported inside each async method body + * so that this module evaluates with minimal overhead. + * + * Synchronous dependencies (`secret-input`, `temp-path`) are imported + * statically at the top level so they work reliably in both production and + * vitest (which resolves bare specifiers via `resolve.alias`, not Node CJS). + */ + +import { + hasConfiguredSecretInput, + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { + registerPlatformAdapter, + registerPlatformAdapterFactory, + hasPlatformAdapter, + type PlatformAdapter, +} from "../engine/adapter/index.js"; +import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js"; +import { getBridgeLogger } from "./logger.js"; + +function createBuiltinAdapter(): PlatformAdapter { + return { + async validateRemoteUrl(_url: string, _options?: { allowPrivate?: boolean }): Promise { + // Built-in version delegates SSRF validation to fetchRemoteMedia's ssrfPolicy. + }, + + async resolveSecret(value): Promise { + if (typeof value === "string") { + return value || undefined; + } + return undefined; + }, + + async downloadFile(url: string, destDir: string, filename?: string): Promise { + const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime"); + const result = await fetchRemoteMedia({ url, filePathHint: filename }); + const fs = await import("node:fs"); + const path = await import("node:path"); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + const destPath = path.join(destDir, filename ?? "download"); + fs.writeFileSync(destPath, result.buffer); + return destPath; + }, + + async fetchMedia(options: FetchMediaOptions): Promise { + const { fetchRemoteMedia } = await import("openclaw/plugin-sdk/media-runtime"); + const result = await fetchRemoteMedia({ + url: options.url, + filePathHint: options.filePathHint, + maxBytes: options.maxBytes, + maxRedirects: options.maxRedirects, + ssrfPolicy: options.ssrfPolicy, + requestInit: options.requestInit, + }); + return { buffer: result.buffer, fileName: result.fileName }; + }, + + getTempDir(): string { + return resolvePreferredOpenClawTmpDir(); + }, + + hasConfiguredSecret(value: unknown): boolean { + return hasConfiguredSecretInput(value); + }, + + normalizeSecretInputString(value: unknown): string | undefined { + return normalizeSecretInputString(value) ?? undefined; + }, + + resolveSecretInputString(params: { value: unknown; path: string }): string | undefined { + return normalizeResolvedSecretInputString(params) ?? undefined; + }, + + async resolveApproval(approvalId: string, decision: string): Promise { + try { + const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime"); + const { resolveApprovalOverGateway } = + await import("openclaw/plugin-sdk/approval-gateway-runtime"); + const cfg = loadConfig(); + await resolveApprovalOverGateway({ + cfg, + approvalId, + decision: decision as "allow-once" | "allow-always" | "deny", + clientDisplayName: "QQBot Approval Handler", + }); + return true; + } catch (err) { + getBridgeLogger().error(`[qqbot] resolveApproval failed: ${String(err)}`); + return false; + } + }, + }; +} + +/** + * Ensure the built-in PlatformAdapter is registered. + * + * Safe to call multiple times — only registers on the first invocation. + * Exported for backward compatibility with code that calls it explicitly. + */ +export function ensurePlatformAdapter(): void { + if (!hasPlatformAdapter()) { + registerPlatformAdapter(createBuiltinAdapter()); + } +} + +// Register the adapter factory so getPlatformAdapter() can lazy-init even when +// this module's side-effect import hasn't executed yet (bundler reordering, +// framework-spawned approval handlers, etc.). +registerPlatformAdapterFactory(createBuiltinAdapter); + +// Also eagerly register for the normal startup path (imported by channel.ts). +ensurePlatformAdapter(); diff --git a/extensions/qqbot/src/bridge/channel-entry.ts b/extensions/qqbot/src/bridge/channel-entry.ts new file mode 100644 index 00000000000..dd1df5f8147 --- /dev/null +++ b/extensions/qqbot/src/bridge/channel-entry.ts @@ -0,0 +1,18 @@ +/** + * Orchestrator for the QQBot `registerFull` hook. + * + * Keeping this function in `src/bridge/` (rather than inline in the + * `extensions/qqbot/index.ts` channel-entry contract) lets the composition + * be unit-tested and aligns with the layering described in the double-repo + * migration spec, where bridge-layer composition code is expected to live + * under `src/bridge/` (or `src/bootstrap/` in the standalone variant). + */ + +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { registerQQBotFrameworkCommands } from "./commands/framework-registration.js"; +import { registerQQBotTools } from "./tools/index.js"; + +export function registerQQBotFull(api: OpenClawPluginApi): void { + registerQQBotTools(api); + registerQQBotFrameworkCommands(api); +} diff --git a/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts new file mode 100644 index 00000000000..18b87fa07f7 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-context-adapter.ts @@ -0,0 +1,60 @@ +/** + * Adapter that builds a `SlashCommandContext` from a framework + * `PluginCommandContext`. + * + * Framework-registered commands enter the plugin through + * `api.registerCommand`, which surfaces a `PluginCommandContext` shape. Our + * engine-side command registry, however, is driven by `SlashCommandContext`. + * This adapter bridges the two so handlers authored against the engine + * registry can be reused unchanged on the framework command surface. + */ + +import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import type { SlashCommandContext } from "../../engine/commands/slash-commands.js"; +import type { ResolvedQQBotAccount } from "../../types.js"; +import type { QQBotFromParseResult } from "./from-parser.js"; + +/** + * Default queue snapshot used for framework-registered commands. + * + * Framework-side command dispatch runs outside the per-sender queue, so + * handlers observe an empty snapshot by design. + */ +const DEFAULT_QUEUE_SNAPSHOT = { + totalPending: 0, + activeUsers: 0, + maxConcurrentUsers: 10, + senderPending: 0, +} as const; + +export interface BuildFrameworkSlashContextInput { + ctx: PluginCommandContext; + account: ResolvedQQBotAccount; + from: QQBotFromParseResult; + commandName: string; +} + +export function buildFrameworkSlashContext({ + ctx, + account, + from, + commandName, +}: BuildFrameworkSlashContextInput): SlashCommandContext { + const args = ctx.args ?? ""; + const rawContent = args ? `/${commandName} ${args}` : `/${commandName}`; + + return { + type: from.msgType, + senderId: ctx.senderId ?? "", + messageId: "", + eventTimestamp: new Date().toISOString(), + receivedAt: Date.now(), + rawContent, + args, + accountId: account.accountId, + appId: account.appId, + accountConfig: account.config as unknown as Record, + commandAuthorized: true, + queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT }, + }; +} diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.ts b/extensions/qqbot/src/bridge/commands/framework-registration.ts new file mode 100644 index 00000000000..62db4135039 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/framework-registration.ts @@ -0,0 +1,47 @@ +/** + * Register all `requireAuth: true` slash commands with the framework via + * `api.registerCommand`. + * + * Routing through the framework lets `resolveCommandAuthorization()` apply + * `commands.allowFrom.qqbot` precedence and the `qqbot:` prefix normalization + * before any QQBot command handler runs. + * + * This module is intentionally thin: it wires the engine-side command + * registry (`getFrameworkCommands`) to the framework registration surface via + * the three single-responsibility helpers in this directory. + */ + +import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { getFrameworkCommands } from "../../engine/commands/slash-commands-impl.js"; +import { resolveQQBotAccount } from "../config.js"; +import { buildFrameworkSlashContext } from "./framework-context-adapter.js"; +import { parseQQBotFrom } from "./from-parser.js"; +import { dispatchFrameworkSlashResult } from "./result-dispatcher.js"; + +export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void { + for (const cmd of getFrameworkCommands()) { + api.registerCommand({ + name: cmd.name, + description: cmd.description, + requireAuth: true, + acceptsArgs: true, + handler: async (ctx: PluginCommandContext) => { + const from = parseQQBotFrom(ctx.from); + const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined); + const slashCtx = buildFrameworkSlashContext({ + ctx, + account, + from, + commandName: cmd.name, + }); + const result = await cmd.handler(slashCtx); + return await dispatchFrameworkSlashResult({ + result, + account, + from, + logger: api.logger, + }); + }, + }); + } +} diff --git a/extensions/qqbot/src/bridge/commands/from-parser.test.ts b/extensions/qqbot/src/bridge/commands/from-parser.test.ts new file mode 100644 index 00000000000..0e9e5f0f4d5 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/from-parser.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { parseQQBotFrom } from "./from-parser.js"; + +describe("parseQQBotFrom", () => { + it("parses a group from string", () => { + expect(parseQQBotFrom("qqbot:group:ABCDEF")).toEqual({ + msgType: "group", + targetType: "group", + targetId: "ABCDEF", + }); + }); + + it("parses a channel prefix into the guild msgType", () => { + expect(parseQQBotFrom("qqbot:channel:123")).toEqual({ + msgType: "guild", + targetType: "channel", + targetId: "123", + }); + }); + + it("parses a dm prefix", () => { + expect(parseQQBotFrom("qqbot:dm:456")).toEqual({ + msgType: "dm", + targetType: "dm", + targetId: "456", + }); + }); + + it("parses a c2c prefix", () => { + expect(parseQQBotFrom("qqbot:c2c:user-1")).toEqual({ + msgType: "c2c", + targetType: "c2c", + targetId: "user-1", + }); + }); + + it("is case-insensitive on the qqbot: prefix", () => { + expect(parseQQBotFrom("QQBOT:group:gid")).toEqual({ + msgType: "group", + targetType: "group", + targetId: "gid", + }); + }); + + it("handles target ids that contain a colon", () => { + expect(parseQQBotFrom("qqbot:group:GROUP:ID")).toEqual({ + msgType: "group", + targetType: "group", + targetId: "GROUP:ID", + }); + }); + + it("falls back to c2c for unknown prefixes", () => { + expect(parseQQBotFrom("qqbot:unknown:abc")).toEqual({ + msgType: "c2c", + targetType: "c2c", + targetId: "abc", + }); + }); + + it("falls back to c2c for missing from", () => { + expect(parseQQBotFrom(undefined)).toEqual({ + msgType: "c2c", + targetType: "c2c", + targetId: "", + }); + expect(parseQQBotFrom(null)).toEqual({ + msgType: "c2c", + targetType: "c2c", + targetId: "", + }); + expect(parseQQBotFrom("")).toEqual({ + msgType: "c2c", + targetType: "c2c", + targetId: "", + }); + }); + + it("treats a bare prefix (no colon) as c2c with that id", () => { + expect(parseQQBotFrom("qqbot:c2c")).toEqual({ + msgType: "c2c", + targetType: "c2c", + targetId: "c2c", + }); + }); +}); diff --git a/extensions/qqbot/src/bridge/commands/from-parser.ts b/extensions/qqbot/src/bridge/commands/from-parser.ts new file mode 100644 index 00000000000..d0765183342 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/from-parser.ts @@ -0,0 +1,60 @@ +/** + * Parse the framework `PluginCommandContext.from` string into the QQBot + * message type and send target. + * + * The framework passes `from` in the form `qqbot::` (case-insensitive + * prefix). We split that string once and map `` into the engine-side + * `SlashCommandContext.type` enum and the outbound `MediaTargetContext.targetType` + * enum. Both enums diverge only for guild/channel, so we keep two lookup + * tables to avoid the nested ternary chain the previous implementation used. + */ + +export interface QQBotFromParseResult { + /** Message type consumed by SlashCommandContext.type. */ + msgType: "c2c" | "guild" | "dm" | "group"; + /** Target type consumed by MediaTargetContext.targetType. */ + targetType: "c2c" | "group" | "channel" | "dm"; + /** Raw target id (everything after the first `:`). */ + targetId: string; +} + +type FromKind = "c2c" | "group" | "channel" | "dm"; + +const MSG_TYPE_MAP: Record = { + c2c: "c2c", + dm: "dm", + group: "group", + channel: "guild", +}; + +const TARGET_TYPE_MAP: Record = { + c2c: "c2c", + dm: "dm", + group: "group", + channel: "channel", +}; + +function isFromKind(value: string): value is FromKind { + return value === "c2c" || value === "dm" || value === "group" || value === "channel"; +} + +/** + * Parse `ctx.from` into the structured fields the QQBot bridge expects. + * + * Unknown or missing prefixes fall back to c2c. The remainder after the first + * `:` is returned verbatim as the target id, matching what the previous inline + * implementation did. + */ +export function parseQQBotFrom(from: string | undefined | null): QQBotFromParseResult { + const stripped = (from ?? "").replace(/^qqbot:/iu, ""); + const colonIdx = stripped.indexOf(":"); + const rawPrefix = colonIdx === -1 ? stripped : stripped.slice(0, colonIdx); + const targetId = colonIdx === -1 ? stripped : stripped.slice(colonIdx + 1); + const kind: FromKind = isFromKind(rawPrefix) ? rawPrefix : "c2c"; + + return { + msgType: MSG_TYPE_MAP[kind], + targetType: TARGET_TYPE_MAP[kind], + targetId, + }; +} diff --git a/extensions/qqbot/src/bridge/commands/result-dispatcher.ts b/extensions/qqbot/src/bridge/commands/result-dispatcher.ts new file mode 100644 index 00000000000..0cdca024f21 --- /dev/null +++ b/extensions/qqbot/src/bridge/commands/result-dispatcher.ts @@ -0,0 +1,76 @@ +/** + * Dispatch a slash command result produced on the framework command surface. + * + * Slash command handlers return one of: + * 1. a plain string (text reply), + * 2. a `SlashCommandFileResult` (text plus a local file to upload), or + * 3. null / unexpected value (we surface a generic warning). + * + * This module isolates the text/file branching so the framework registration + * layer stays declarative and so the file-send side effect has a single + * location where logging and error handling live. + */ + +import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; +import type { SlashCommandResult } from "../../engine/commands/slash-commands.js"; +import { sendDocument, type MediaTargetContext } from "../../engine/messaging/outbound.js"; +import type { ResolvedQQBotAccount } from "../../types.js"; +import type { QQBotFromParseResult } from "./from-parser.js"; + +const UNEXPECTED_RESULT_TEXT = "⚠️ 命令返回了意外结果。"; + +export interface FrameworkSlashReply { + text: string; +} + +export interface DispatchFrameworkSlashResultInput { + result: SlashCommandResult; + account: ResolvedQQBotAccount; + from: QQBotFromParseResult; + logger?: PluginLogger; +} + +function hasFilePath(value: unknown): value is { text: string; filePath: string } { + return ( + typeof value === "object" && + value !== null && + "filePath" in value && + typeof (value as { filePath: unknown }).filePath === "string" + ); +} + +function buildMediaTarget( + account: ResolvedQQBotAccount, + from: QQBotFromParseResult, +): MediaTargetContext { + return { + targetType: from.targetType, + targetId: from.targetId, + account: account as unknown as MediaTargetContext["account"], + }; +} + +export async function dispatchFrameworkSlashResult({ + result, + account, + from, + logger, +}: DispatchFrameworkSlashResultInput): Promise { + if (typeof result === "string") { + return { text: result }; + } + + if (hasFilePath(result)) { + const mediaCtx = buildMediaTarget(account, from); + try { + await sendDocument(mediaCtx, result.filePath, { + allowQQBotDataDownloads: true, + }); + } catch (err) { + logger?.warn(`framework slash file send failed: ${String(err)}`); + } + return { text: result.text }; + } + + return { text: UNEXPECTED_RESULT_TEXT }; +} diff --git a/extensions/qqbot/src/channel-config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts similarity index 52% rename from extensions/qqbot/src/channel-config-shared.ts rename to extensions/qqbot/src/bridge/config-shared.ts index 6170a57006b..483c6d820c6 100644 --- a/extensions/qqbot/src/channel-config-shared.ts +++ b/extensions/qqbot/src/bridge/config-shared.ts @@ -1,77 +1,41 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { + applyAccountNameToChannelSection, 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"; +} from "openclaw/plugin-sdk/core"; import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup"; import { - DEFAULT_ACCOUNT_ID, - applyQQBotAccountConfig, + describeAccount as engineDescribeAccount, + formatAllowFrom as engineFormatAllowFrom, + isAccountConfigured as engineIsAccountConfigured, +} from "../engine/config/resolve.js"; +import { + applySetupAccountConfig as engineApplySetupAccountConfig, + validateSetupInput as engineValidateSetupInput, +} from "../engine/config/setup-logic.js"; +import { normalizeLowercaseStringOrEmpty } from "../engine/utils/string-normalize.js"; +import type { ResolvedQQBotAccount } from "../types.js"; +import { listQQBotAccountIds, resolveDefaultQQBotAccountId, resolveQQBotAccount, } from "./config.js"; -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", + selectionLabel: "QQ Bot (Bot API)", 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; + return engineValidateSetupInput(params.accountId, params.input); } export function applyQQBotSetupAccountConfig(params: { @@ -79,61 +43,25 @@ export function applyQQBotSetupAccountConfig(params: { 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, - }); + return engineApplySetupAccountConfig( + params.cfg as unknown as Record, + params.accountId, + params.input, + ) as OpenClawConfig; } export function isQQBotConfigured(account: ResolvedQQBotAccount | undefined): boolean { - return Boolean( - account?.appId && - (Boolean(account?.clientSecret) || - hasConfiguredSecretInput(account?.config?.clientSecret) || - Boolean(account?.config?.clientSecretFile?.trim())), - ); + return engineIsAccountConfigured(account as never); } 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, - }; + return engineDescribeAccount(account as never); } export function formatQQBotAllowFrom(params: { allowFrom: Array | 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()); + return engineFormatAllowFrom(params.allowFrom); } export const qqbotConfigAdapter = { diff --git a/extensions/qqbot/src/bridge/config.ts b/extensions/qqbot/src/bridge/config.ts new file mode 100644 index 00000000000..d8318322067 --- /dev/null +++ b/extensions/qqbot/src/bridge/config.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getPlatformAdapter } from "../engine/adapter/index.js"; +import { + DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID, + applyAccountConfig, + listAccountIds, + resolveAccountBase, + resolveDefaultAccountId, +} from "../engine/config/resolve.js"; +import type { ResolvedQQBotAccount, QQBotAccountConfig } from "../types.js"; + +export const DEFAULT_ACCOUNT_ID = ENGINE_DEFAULT_ACCOUNT_ID; + +interface QQBotChannelConfig extends QQBotAccountConfig { + accounts?: Record; + defaultAccount?: string; +} + + +/** List all configured QQBot account IDs. */ +export function listQQBotAccountIds(cfg: OpenClawConfig): string[] { + return listAccountIds(cfg as unknown as Record); +} + +/** Resolve the default QQBot account ID. */ +export function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string { + return resolveDefaultAccountId(cfg as unknown as Record); +} + +/** Resolve QQBot account config for runtime or setup flows. */ +export function resolveQQBotAccount( + cfg: OpenClawConfig, + accountId?: string | null, + opts?: { allowUnresolvedSecretRef?: boolean }, +): ResolvedQQBotAccount { + const raw = cfg as unknown as Record; + const base = resolveAccountBase(raw, accountId); + + const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined; + const accountConfig: QQBotAccountConfig = + base.accountId === DEFAULT_ACCOUNT_ID + ? (qqbot ?? {}) + : (qqbot?.accounts?.[base.accountId] ?? {}); + + let clientSecret = ""; + let secretSource: "config" | "file" | "env" | "none" = "none"; + + const clientSecretPath = + base.accountId === DEFAULT_ACCOUNT_ID + ? "channels.qqbot.clientSecret" + : `channels.qqbot.accounts.${base.accountId}.clientSecret`; + + const adapter = getPlatformAdapter(); + if (adapter.hasConfiguredSecret(accountConfig.clientSecret)) { + clientSecret = opts?.allowUnresolvedSecretRef + ? (adapter.normalizeSecretInputString(accountConfig.clientSecret) ?? "") + : (adapter.resolveSecretInputString({ + value: accountConfig.clientSecret, + path: clientSecretPath, + }) ?? ""); + secretSource = "config"; + } else if (accountConfig.clientSecretFile) { + try { + clientSecret = fs.readFileSync(accountConfig.clientSecretFile, "utf8").trim(); + secretSource = "file"; + } catch { + secretSource = "none"; + } + } else if (process.env.QQBOT_CLIENT_SECRET && base.accountId === DEFAULT_ACCOUNT_ID) { + clientSecret = process.env.QQBOT_CLIENT_SECRET; + secretSource = "env"; + } + + return { + accountId: base.accountId, + name: accountConfig.name, + enabled: base.enabled, + appId: base.appId, + clientSecret, + secretSource, + systemPrompt: base.systemPrompt, + markdownSupport: base.markdownSupport, + config: accountConfig, + }; +} + +/** Apply account config updates back into the OpenClaw config object. */ +export function applyQQBotAccountConfig( + cfg: OpenClawConfig, + accountId: string, + input: { + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + name?: string; + }, +): OpenClawConfig { + return applyAccountConfig( + cfg as unknown as Record, + accountId, + input, + ) as OpenClawConfig; +} diff --git a/extensions/qqbot/src/bridge/gateway.ts b/extensions/qqbot/src/bridge/gateway.ts new file mode 100644 index 00000000000..47302b9e361 --- /dev/null +++ b/extensions/qqbot/src/bridge/gateway.ts @@ -0,0 +1,180 @@ +/** + * Gateway entry point — thin shell that passes the PluginRuntime to + * core/gateway/gateway.ts. + * + * All module dependencies are imported directly by the core gateway. + * This file only provides the runtime object (which is dynamically + * injected by the framework at startup). + */ + +import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + registerVersionResolver, + registerPluginVersion, + registerApproveRuntimeGetter, +} from "../engine/commands/slash-commands-impl.js"; +import { + startGateway as coreStartGateway, + type CoreGatewayContext, +} from "../engine/gateway/gateway.js"; +import type { GatewayAccount } from "../engine/gateway/types.js"; +import { registerOutboundAudioAdapterFactory } from "../engine/messaging/outbound.js"; +import { initSender, registerAccount } from "../engine/messaging/sender.js"; +import type { EngineLogger } from "../engine/types.js"; +import * as _audioModule from "../engine/utils/audio.js"; +import { debugLog, debugError } from "../engine/utils/log.js"; +import { registerTextChunker } from "../engine/utils/text-chunk.js"; +import type { ResolvedQQBotAccount } from "../types.js"; +import { ensurePlatformAdapter } from "./bootstrap.js"; +import { setBridgeLogger } from "./logger.js"; +import { resolveQQBotPluginVersion } from "./plugin-version.js"; +import { getQQBotRuntime, getQQBotRuntimeForEngine } from "./runtime.js"; + +// Register framework SDK version resolver for core/ slash commands. +registerVersionResolver(resolveRuntimeServiceVersion); + +// Inject plugin + framework versions into sender and into the slash +// command registry. The plugin version is read from this plugin's own +// `package.json` by walking up from this file's URL, which is robust +// against source-vs-dist layout differences. +const _pluginVersion = resolveQQBotPluginVersion(import.meta.url); +initSender({ + pluginVersion: _pluginVersion, + openclawVersion: resolveRuntimeServiceVersion(), +}); +registerPluginVersion(_pluginVersion); + +// Register runtime getter for /bot-approve config management. +registerApproveRuntimeGetter(() => { + const rt = getQQBotRuntime(); + return { + config: rt.config as { + loadConfig: () => Record; + writeConfigFile: (cfg: unknown) => Promise; + }, + }; +}); + +// Register audio adapter factory so outbound.sendMedia can lazy-init even +// when startGateway() hasn't run yet (bundler chunk-splitting scenario). +registerOutboundAudioAdapterFactory(() => { + // Use a synchronous require-like approach: the audio module should already + // be loaded by the time the factory is invoked (gateway has started). + // We import it at the top and reference it here. + return { + audioFileToSilkBase64: async (p: string, f?: string[]) => + (await _audioModule.audioFileToSilkBase64(p, f)) ?? undefined, + isAudioFile: (p: string, m?: string) => _audioModule.isAudioFile(p, m), + shouldTranscodeVoice: (p: string) => _audioModule.shouldTranscodeVoice(p), + waitForFile: (p: string, ms?: number) => _audioModule.waitForFile(p, ms), + }; +}); + +export interface GatewayContext { + account: ResolvedQQBotAccount; + abortSignal: AbortSignal; + cfg: OpenClawConfig; + onReady?: (data: unknown) => void; + onResumed?: (data: unknown) => void; + onError?: (error: Error) => void; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; + channelRuntime?: { + runtimeContexts: { + register: (params: { + channelId: string; + accountId: string; + capability: string; + context: unknown; + abortSignal?: AbortSignal; + }) => { dispose: () => void }; + }; + }; +} + +/** + * Start the Gateway WebSocket connection. + * + * Passes the PluginRuntime to core/gateway/gateway.ts. + * All other dependencies are imported directly by the core module. + */ +export async function startGateway(ctx: GatewayContext): Promise { + // Ensure the PlatformAdapter is registered before any engine code runs. + // When the bundler splits code into separate chunks, bootstrap.ts's + // side-effect registration may not have executed yet at this point. + ensurePlatformAdapter(); + + const runtime = getQQBotRuntimeForEngine(); + + // Create per-account logger with auto [qqbot:{accountId}] prefix. + const accountLogger = createAccountLogger(ctx.log, ctx.account.accountId); + + // Register into engine sender (per-appId logger + API config) and bridge layer. + registerAccount(ctx.account.appId, { + logger: accountLogger, + markdownSupport: ctx.account.markdownSupport, + }); + setBridgeLogger(accountLogger); + + registerTextChunker((text, limit) => runtime.channel.text.chunkMarkdownText(text, limit)); + + if (ctx.channelRuntime) { + accountLogger.info("Registering approval.native runtime context"); + const lease = ctx.channelRuntime.runtimeContexts.register({ + channelId: "qqbot", + accountId: ctx.account.accountId, + capability: "approval.native", + context: { account: ctx.account }, + abortSignal: ctx.abortSignal, + }); + accountLogger.info(`approval.native context registered (lease=${!!lease})`); + } else { + accountLogger.info("No channelRuntime — skipping approval.native registration"); + } + + const coreCtx: CoreGatewayContext = { + account: ctx.account as unknown as GatewayAccount, + abortSignal: ctx.abortSignal, + cfg: ctx.cfg, + onReady: ctx.onReady, + onResumed: ctx.onResumed, + onError: ctx.onError, + log: accountLogger, + runtime, + }; + + return coreStartGateway(coreCtx); +} + +// ============ Per-account logger factory ============ + +/** + * Create an EngineLogger that auto-prefixes all messages with `[qqbot:{accountId}]`. + * + * Follows the WhatsApp pattern of per-connection loggers — each account gets + * its own logger instance so multi-account logs are automatically attributed. + */ +function createAccountLogger( + raw: GatewayContext["log"] | undefined, + accountId: string, +): EngineLogger { + const prefix = `[${accountId}]`; + if (!raw) { + return { + info: (msg) => debugLog(`${prefix} ${msg}`), + error: (msg) => debugError(`${prefix} ${msg}`), + warn: (msg) => debugError(`${prefix} ${msg}`), + debug: (msg) => debugLog(`${prefix} ${msg}`), + }; + } + return { + info: (msg) => raw.info(`${prefix} ${msg}`), + error: (msg) => raw.error(`${prefix} ${msg}`), + warn: (msg) => raw.error(`${prefix} ${msg}`), + debug: (msg) => raw.debug?.(`${prefix} ${msg}`), + }; +} diff --git a/extensions/qqbot/src/bridge/logger.ts b/extensions/qqbot/src/bridge/logger.ts new file mode 100644 index 00000000000..dbf40e3cdd1 --- /dev/null +++ b/extensions/qqbot/src/bridge/logger.ts @@ -0,0 +1,31 @@ +/** + * Bridge-layer logger — holds the framework logger injected at gateway startup. + * + * Bridge modules (approval, tools, etc.) use this instead of `console.log` or + * engine's `debugLog` so that all logs flow through the OpenClaw log system. + */ + +export interface BridgeLogger { + info: (msg: string) => void; + error: (msg: string) => void; + warn?: (msg: string) => void; + debug?: (msg: string) => void; +} + +let _logger: BridgeLogger | null = null; + +/** Register the framework logger. Called once in startGateway(). */ +export function setBridgeLogger(logger: BridgeLogger): void { + _logger = logger; +} + +/** Get the bridge logger. Falls back to console if not yet registered. */ +export function getBridgeLogger(): BridgeLogger { + return ( + _logger ?? { + info: (msg) => console.log(msg), + error: (msg) => console.error(msg), + debug: (msg) => console.log(msg), + } + ); +} diff --git a/extensions/qqbot/src/bridge/plugin-version.test.ts b/extensions/qqbot/src/bridge/plugin-version.test.ts new file mode 100644 index 00000000000..d7f5c0cb4f9 --- /dev/null +++ b/extensions/qqbot/src/bridge/plugin-version.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for `resolveQQBotPluginVersion`. + * + * These exercise the directory-walk lookup against controlled fixture + * trees rather than the repo's real `package.json`, so the behaviour + * is deterministic regardless of where the test runs. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { QQBOT_PLUGIN_VERSION_UNKNOWN, resolveQQBotPluginVersion } from "./plugin-version.js"; + +/** Create a temp directory tree for an individual test and return its root. */ +function createTempTree(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-pkg-version-")); +} + +function writeJson(file: string, data: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data), "utf8"); +} + +function fakeEntryFileUrl(dir: string): string { + const entryPath = path.join(dir, "gateway.ts"); + // File need not exist for `fileURLToPath` to work; the resolver + // only uses its *parent directory* as the walk start point. + return pathToFileURL(entryPath).href; +} + +describe("resolveQQBotPluginVersion", () => { + let tempRoots: string[] = []; + + beforeEach(() => { + tempRoots = []; + }); + + afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + function newTree(): string { + const root = createTempTree(); + tempRoots.push(root); + return root; + } + + it("returns the version from the nearest matching package.json", () => { + const root = newTree(); + const pluginDir = path.join(root, "extensions", "qqbot"); + const bridgeDir = path.join(pluginDir, "src", "bridge"); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/qqbot", + version: "2026.4.16", + }); + fs.mkdirSync(bridgeDir, { recursive: true }); + + const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); + + expect(version).toBe("2026.4.16"); + }); + + it("skips package.json files whose name field does not match", () => { + const root = newTree(); + // Parent package.json belongs to the framework, not the plugin. + writeJson(path.join(root, "package.json"), { + name: "openclaw", + version: "9.9.9", + }); + const pluginDir = path.join(root, "extensions", "qqbot"); + const bridgeDir = path.join(pluginDir, "src", "bridge"); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/qqbot", + version: "2026.4.16", + }); + fs.mkdirSync(bridgeDir, { recursive: true }); + + const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); + + // Must stop at the plugin manifest, never bubble up to the framework one. + expect(version).toBe("2026.4.16"); + }); + + it("ignores manifests with unrelated name and returns unknown when no match is found", () => { + const root = newTree(); + // Only an unrelated manifest exists up the tree. + writeJson(path.join(root, "package.json"), { + name: "some-other-package", + version: "1.0.0", + }); + const startDir = path.join(root, "extensions", "qqbot", "src", "bridge"); + fs.mkdirSync(startDir, { recursive: true }); + + const version = resolveQQBotPluginVersion(fakeEntryFileUrl(startDir)); + + expect(version).toBe(QQBOT_PLUGIN_VERSION_UNKNOWN); + }); + + it("returns unknown when no package.json exists above the start directory", () => { + const root = newTree(); + const startDir = path.join(root, "extensions", "qqbot", "src", "bridge"); + fs.mkdirSync(startDir, { recursive: true }); + + const version = resolveQQBotPluginVersion(fakeEntryFileUrl(startDir)); + + expect(version).toBe(QQBOT_PLUGIN_VERSION_UNKNOWN); + }); + + it("returns unknown when the matching manifest lacks a version field", () => { + const root = newTree(); + const pluginDir = path.join(root, "extensions", "qqbot"); + const bridgeDir = path.join(pluginDir, "src", "bridge"); + writeJson(path.join(pluginDir, "package.json"), { + name: "@openclaw/qqbot", + // version intentionally missing + }); + fs.mkdirSync(bridgeDir, { recursive: true }); + + const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); + + expect(version).toBe(QQBOT_PLUGIN_VERSION_UNKNOWN); + }); + + it("tolerates a malformed package.json and keeps walking", () => { + const root = newTree(); + const pluginDir = path.join(root, "extensions", "qqbot"); + const bridgeDir = path.join(pluginDir, "src", "bridge"); + // Broken manifest at the expected plugin location. + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "package.json"), "{ not valid json", "utf8"); + // Valid matching manifest higher up (unusual layout but still resolvable). + writeJson(path.join(root, "package.json"), { + name: "@openclaw/qqbot", + version: "2026.9.9", + }); + fs.mkdirSync(bridgeDir, { recursive: true }); + + const version = resolveQQBotPluginVersion(fakeEntryFileUrl(bridgeDir)); + + expect(version).toBe("2026.9.9"); + }); +}); diff --git a/extensions/qqbot/src/bridge/plugin-version.ts b/extensions/qqbot/src/bridge/plugin-version.ts new file mode 100644 index 00000000000..cb04fc82923 --- /dev/null +++ b/extensions/qqbot/src/bridge/plugin-version.ts @@ -0,0 +1,102 @@ +/** + * QQBot plugin version resolver. + * + * Reads the version field from this plugin's own `package.json` by + * walking up the directory tree starting from `import.meta.url` of the + * caller until a `package.json` whose `name` field matches the plugin + * package id is located. + * + * Why not a hardcoded relative path? + * - The source file can live at different depths depending on whether + * we run from raw sources (`src/bridge/gateway.ts`) or a future + * compiled output. Hardcoding `"../../package.json"` breaks as soon + * as the source layout changes, which is what caused the previous + * `vunknown` regression. + * - A `name` guard prevents accidentally reading the parent + * `openclaw/package.json` (the framework root) when the plugin + * lives inside the monorepo. + * + * The lookup is performed only once per process at startup, so the + * synchronous file I/O is negligible. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** `name` field in this plugin's `package.json`. */ +const QQBOT_PLUGIN_PKG_NAME = "@openclaw/qqbot"; + +/** Sentinel used when the version cannot be resolved. */ +export const QQBOT_PLUGIN_VERSION_UNKNOWN = "unknown"; + +/** + * Resolve the QQBot plugin version from `package.json`. + * + * @param startUrl — pass `import.meta.url` from the call site so the + * lookup begins at the caller's file regardless of where this helper + * itself lives. Falls back to this module's own location when omitted. + */ +export function resolveQQBotPluginVersion(startUrl?: string): string { + const entryUrl = startUrl ?? import.meta.url; + let dir: string; + try { + dir = path.dirname(fileURLToPath(entryUrl)); + } catch { + return QQBOT_PLUGIN_VERSION_UNKNOWN; + } + + const root = path.parse(dir).root; + while (dir && dir !== root) { + const candidate = path.join(dir, "package.json"); + if (fs.existsSync(candidate)) { + const version = readQQBotVersionFromManifest(candidate); + if (version) { + return version; + } + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return QQBOT_PLUGIN_VERSION_UNKNOWN; +} + +/** + * Read the `version` field from a `package.json` file and return it + * only when the manifest describes the QQBot plugin itself. + * + * Returning `null` for mismatched or malformed manifests lets the + * caller keep walking up the directory tree until the correct package + * boundary is located. + */ +function readQQBotVersionFromManifest(manifestPath: string): string | null { + let raw: string; + try { + raw = fs.readFileSync(manifestPath, "utf8"); + } catch { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== "object") { + return null; + } + const manifest = parsed as { name?: unknown; version?: unknown }; + if (manifest.name !== QQBOT_PLUGIN_PKG_NAME) { + return null; + } + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + return null; + } + return manifest.version; +} diff --git a/extensions/qqbot/src/bridge/runtime.ts b/extensions/qqbot/src/bridge/runtime.ts new file mode 100644 index 00000000000..831a82e0585 --- /dev/null +++ b/extensions/qqbot/src/bridge/runtime.ts @@ -0,0 +1,24 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { GatewayPluginRuntime } from "../engine/gateway/types.js"; +import { setOpenClawVersion } from "../engine/messaging/sender.js"; + +const { setRuntime: _setRuntime, getRuntime: getQQBotRuntime } = + createPluginRuntimeStore({ + pluginId: "qqbot", + errorMessage: "QQBot runtime not initialized", + }); + +/** Set the QQBot runtime and inject the framework version into the User-Agent. */ +function setQQBotRuntime(runtime: PluginRuntime): void { + _setRuntime(runtime); + // Inject the framework version into the User-Agent string (same as standalone). + setOpenClawVersion(runtime.version); +} + +export { getQQBotRuntime, setQQBotRuntime }; + +/** Type-narrowed getter for engine/ modules that need GatewayPluginRuntime. */ +export function getQQBotRuntimeForEngine(): GatewayPluginRuntime { + return getQQBotRuntime() as unknown as GatewayPluginRuntime; +} diff --git a/extensions/qqbot/src/bridge/setup/finalize.ts b/extensions/qqbot/src/bridge/setup/finalize.ts new file mode 100644 index 00000000000..ad2d1f66247 --- /dev/null +++ b/extensions/qqbot/src/bridge/setup/finalize.ts @@ -0,0 +1,151 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { applyQQBotAccountConfig, resolveQQBotAccount } from "../config.js"; + +type SetupPrompter = Parameters>[0]["prompter"]; +type SetupRuntime = Parameters>[0]["runtime"]; + +function isQQBotAccountConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const account = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); + return Boolean(account.appId && account.clientSecret); +} + +export async function detectQQBotConfigured( + cfg: OpenClawConfig, + accountId: string, +): Promise { + return isQQBotAccountConfigured(cfg, accountId); +} + +async function linkViaQrCode(params: { + cfg: OpenClawConfig; + accountId: string; + prompter: SetupPrompter; + runtime: SetupRuntime; +}): Promise { + try { + const { qrConnect } = await import("@tencent-connect/qqbot-connector"); + + const accounts: { appId: string; appSecret: string }[] = await qrConnect({ + source: "openclaw", + }); + + if (accounts.length === 0) { + await params.prompter.note("未获取到任何 QQ Bot 账号信息。", "QQ Bot"); + return params.cfg; + } + + let next = params.cfg; + + for (let i = 0; i < accounts.length; i++) { + const { appId, appSecret } = accounts[i]; + // use current account id for first account, and use app id for subsequent accounts + const targetAccountId = i === 0 ? params.accountId : appId; + + next = applyQQBotAccountConfig(next, targetAccountId, { + appId, + clientSecret: appSecret, + }); + } + + if (accounts.length === 1) { + params.runtime.log(`✔ QQ Bot 绑定成功!(AppID: ${accounts[0].appId})`); + } else { + const idList = accounts.map((a) => a.appId).join(", "); + params.runtime.log(`✔ ${accounts.length} 个 QQ Bot 绑定成功!(AppID: ${idList})`); + } + + return next; + } catch (error) { + params.runtime.error(`QQ Bot 绑定失败: ${String(error)}`); + await params.prompter.note( + [ + "绑定失败,您可以稍后手动配置。", + `文档: ${formatDocsLink("/channels/qqbot", "qqbot")}`, + ].join("\n"), + "QQ Bot", + ); + return params.cfg; + } +} + +async function linkViaManualInput(params: { + cfg: OpenClawConfig; + accountId: string; + prompter: SetupPrompter; +}): Promise { + const appId = await params.prompter.text({ + message: "请输入 QQ Bot AppID", + validate: (value: string) => (value.trim() ? undefined : "AppID 不能为空"), + }); + + const appSecret = await params.prompter.text({ + message: "请输入 QQ Bot AppSecret", + validate: (value: string) => (value.trim() ? undefined : "AppSecret 不能为空"), + }); + + const next = applyQQBotAccountConfig(params.cfg, params.accountId, { + appId: appId.trim(), + clientSecret: appSecret.trim(), + }); + + await params.prompter.note("✔ QQ Bot 配置完成!", "QQ Bot"); + return next; +} + +export async function finalizeQQBotSetup(params: { + cfg: OpenClawConfig; + accountId: string; + forceAllowFrom: boolean; + prompter: SetupPrompter; + runtime: SetupRuntime; +}): Promise<{ cfg: OpenClawConfig }> { + const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID; + let next = params.cfg; + + const configured = isQQBotAccountConfigured(next, accountId); + + const mode = await params.prompter.select({ + message: configured ? "QQ 已绑定,选择操作" : "选择 QQ 绑定方式", + options: [ + { + value: "qr", + label: "扫码绑定(推荐)", + hint: "使用 QQ 扫描二维码自动完成绑定", + }, + { + value: "manual", + label: "手动输入 QQ Bot AppID 和 AppSecret", + hint: "需到 QQ 开放平台 q.qq.com 查看", + }, + { + value: "skip", + label: configured ? "保持当前配置" : "稍后配置", + }, + ], + }); + + if (mode === "qr") { + next = await linkViaQrCode({ + cfg: next, + accountId, + prompter: params.prompter, + runtime: params.runtime, + }); + } else if (mode === "manual") { + next = await linkViaManualInput({ + cfg: next, + accountId, + prompter: params.prompter, + }); + } else if (!configured) { + await params.prompter.note( + ["您可以稍后运行以下命令重新选择 QQ Bot 进行配置:", " openclaw channels add"].join("\n"), + "QQ Bot", + ); + } + + return { cfg: next }; +} diff --git a/extensions/qqbot/src/bridge/setup/surface.ts b/extensions/qqbot/src/bridge/setup/surface.ts new file mode 100644 index 00000000000..aafdbf12ae3 --- /dev/null +++ b/extensions/qqbot/src/bridge/setup/surface.ts @@ -0,0 +1,34 @@ +import { + createStandardChannelSetupStatus, + setSetupChannelEnabled, +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { isAccountConfigured } from "../../engine/config/resolve.js"; +import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; +import { finalizeQQBotSetup } from "./finalize.js"; + +const channel = "qqbot" as const; + +export const qqbotSetupWizard: ChannelSetupWizard = { + channel, + status: createStandardChannelSetupStatus({ + channelLabel: "QQ Bot", + configuredLabel: "configured", + unconfiguredLabel: "needs AppID + AppSercet", + configuredHint: "configured", + unconfiguredHint: "needs AppID + AppSercet", + configuredScore: 1, + unconfiguredScore: 6, + resolveConfigured: ({ cfg, accountId }) => + (accountId ? [accountId] : listQQBotAccountIds(cfg)).some((resolvedAccountId) => { + const account = resolveQQBotAccount(cfg, resolvedAccountId, { + allowUnresolvedSecretRef: true, + }); + return isAccountConfigured(account as never); + }), + }), + credentials: [], + finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => + await finalizeQQBotSetup({ cfg, accountId, forceAllowFrom, prompter, runtime }), + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/extensions/qqbot/src/bridge/tools/channel.ts b/extensions/qqbot/src/bridge/tools/channel.ts new file mode 100644 index 00000000000..da3f25714e2 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/channel.ts @@ -0,0 +1,62 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { getAccessToken } from "../../engine/messaging/sender.js"; +import { ChannelApiSchema, executeChannelApi } from "../../engine/tools/channel-api.js"; +import type { ChannelApiParams } from "../../engine/tools/channel-api.js"; +import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; +import { getBridgeLogger } from "../logger.js"; + +/** + * Register the QQ channel API proxy tool. + * + * The tool acts as an authenticated HTTP proxy for the QQ Open Platform + * channel APIs. Agents learn endpoint details from the skill docs and + * send requests through this proxy. + */ +export function registerChannelTool(api: OpenClawPluginApi): void { + const cfg = api.config; + if (!cfg) { + getBridgeLogger().debug?.("[qqbot-channel-api] No config available, skipping"); + return; + } + + const accountIds = listQQBotAccountIds(cfg); + if (accountIds.length === 0) { + getBridgeLogger().debug?.("[qqbot-channel-api] No QQBot accounts configured, skipping"); + return; + } + + const firstAccountId = accountIds[0]; + const account = resolveQQBotAccount(cfg, firstAccountId); + + if (!account.appId || !account.clientSecret) { + getBridgeLogger().debug?.("[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 accessToken = await getAccessToken(account.appId, account.clientSecret); + return executeChannelApi(params as ChannelApiParams, { accessToken }); + }, + }, + { name: "qqbot_channel_api" }, + ); +} diff --git a/extensions/qqbot/src/bridge/tools/index.ts b/extensions/qqbot/src/bridge/tools/index.ts new file mode 100644 index 00000000000..d074c651eb1 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/index.ts @@ -0,0 +1,18 @@ +/** + * Aggregate QQBot plugin tool registrations. + * + * New tools should be added here rather than in the channel-entry contract + * file so that the plugin-level `index.ts` stays a pure declaration. + */ + +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { registerChannelTool } from "./channel.js"; +import { registerRemindTool } from "./remind.js"; + +export { registerChannelTool } from "./channel.js"; +export { registerRemindTool } from "./remind.js"; + +export function registerQQBotTools(api: OpenClawPluginApi): void { + registerChannelTool(api); + registerRemindTool(api); +} diff --git a/extensions/qqbot/src/bridge/tools/remind.ts b/extensions/qqbot/src/bridge/tools/remind.ts new file mode 100644 index 00000000000..518046248c2 --- /dev/null +++ b/extensions/qqbot/src/bridge/tools/remind.ts @@ -0,0 +1,30 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { RemindSchema, executeRemind } from "../../engine/tools/remind-logic.js"; +import type { RemindParams } from "../../engine/tools/remind-logic.js"; +import { getRequestContext } from "../../engine/utils/request-context.js"; + +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, time=schedule (to is optional, " + + "resolved automatically from the current conversation)\n" + + "List: action=list\n" + + "Remove: action=remove, jobId=job id from list\n" + + 'Time examples: "5m", "1h", "0 8 * * *"', + parameters: RemindSchema, + async execute(_toolCallId, params) { + const ctx = getRequestContext(); + return executeRemind(params as RemindParams, { + fallbackTo: ctx?.target, + fallbackAccountId: ctx?.accountId, + }); + }, + }, + { name: "qqbot_remind" }, + ); +} diff --git a/extensions/qqbot/src/tools/result.ts b/extensions/qqbot/src/bridge/tools/result.ts similarity index 100% rename from extensions/qqbot/src/tools/result.ts rename to extensions/qqbot/src/bridge/tools/result.ts diff --git a/extensions/qqbot/src/channel-base.ts b/extensions/qqbot/src/channel-base.ts deleted file mode 100644 index 940624c8602..00000000000 --- a/extensions/qqbot/src/channel-base.ts +++ /dev/null @@ -1,30 +0,0 @@ -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"; - -export const qqbotBasePluginFields = { - 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, - }, -} satisfies Partial> & { - id: "qqbot"; -}; diff --git a/extensions/qqbot/src/channel.setup.ts b/extensions/qqbot/src/channel.setup.ts index e90641627d5..01871afbb39 100644 --- a/extensions/qqbot/src/channel.setup.ts +++ b/extensions/qqbot/src/channel.setup.ts @@ -1,5 +1,8 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { qqbotBasePluginFields } from "./channel-base.js"; +import "./bridge/bootstrap.js"; +import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js"; +import { qqbotSetupWizard } from "./bridge/setup/surface.js"; +import { qqbotChannelConfigSchema } from "./config-schema.js"; import type { ResolvedQQBotAccount } from "./types.js"; /** @@ -7,5 +10,24 @@ import type { ResolvedQQBotAccount } from "./types.js"; * and `openclaw configure` without pulling the full runtime dependencies. */ export const qqbotSetupPlugin: ChannelPlugin = { - ...qqbotBasePluginFields, + 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, + }, }; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 0eeed94ee09..7bd2fe5e0b2 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -1,69 +1,103 @@ +import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { initApiConfig } from "./api.js"; -import { qqbotBasePluginFields } from "./channel-base.js"; -import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "./config.js"; -import { getQQBotRuntime } from "./runtime.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"; +// Register the PlatformAdapter before any core/ module is used. +import "./bridge/bootstrap.js"; +import { getQQBotApprovalCapability } from "./bridge/approval/capability.js"; +import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js"; +import { + applyQQBotAccountConfig, + DEFAULT_ACCOUNT_ID, + resolveQQBotAccount, +} from "./bridge/config.js"; +import { getQQBotRuntime } from "./bridge/runtime.js"; +import { qqbotSetupWizard } from "./bridge/setup/surface.js"; +import { qqbotChannelConfigSchema } from "./config-schema.js"; +import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js"; +import { clearAccountCredentials } from "./engine/config/credentials.js"; +import { + normalizeTarget as coreNormalizeTarget, + looksLikeQQBotTarget, +} from "./engine/messaging/target-parser.js"; +// Re-export text helpers from core/. +export { chunkText, TEXT_CHUNK_LIMIT } from "./engine/utils/text-chunk.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 | undefined; -let _outboundModulePromise: Promise | undefined; - -function loadGatewayModule(): Promise { - _gatewayModulePromise ??= import("./gateway.js"); +let _gatewayModulePromise: Promise | undefined; +function loadGatewayModule(): Promise { + _gatewayModulePromise ??= import("./bridge/gateway.js"); return _gatewayModulePromise; } -function loadOutboundModule(): Promise { - _outboundModulePromise ??= import("./outbound.js"); - return _outboundModulePromise; +const EXEC_APPROVAL_COMMAND_RE = + /\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i; + +function shouldSuppressLocalQQBotApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: { text?: string; channelData?: unknown }; + hint?: { kind: "approval-pending" | "approval-resolved"; approvalKind: "exec" | "plugin" }; +}): boolean { + if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") { + return false; + } + const account = resolveQQBotAccount(params.cfg, params.accountId); + if (!account.enabled || account.secretSource === "none") { + return false; + } + if (getExecApprovalReplyMetadata(params.payload as never)) { + return true; + } + const text = typeof params.payload.text === "string" ? params.payload.text : ""; + return EXEC_APPROVAL_COMMAND_RE.test(text); } export const qqbotPlugin: ChannelPlugin = { - ...qqbotBasePluginFields, + 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, + /** + * Treat an account as configured when either the live config has + * credentials OR a recoverable credential backup exists. This mirrors + * the standalone plugin and lets the gateway survive a hot upgrade + * that wiped openclaw.json mid-flight. + */ + isConfigured: (account: ResolvedQQBotAccount | undefined) => { + if (qqbotConfigAdapter.isConfigured(account)) { + return true; + } + if (!account) { + return false; + } + const backup = loadCredentialBackup(account.accountId); + return Boolean(backup?.appId && backup?.clientSecret); + }, + }, + setup: { + ...qqbotSetupAdapterShared, + }, + approvalCapability: getQQBotApprovalCapability(), 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; - }, + normalizeTarget: coreNormalizeTarget, 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); - }, + looksLikeId: looksLikeQQBotTarget, hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)", }, }, @@ -72,11 +106,20 @@ export const qqbotPlugin: ChannelPlugin = { chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 5000, + shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) => + shouldSuppressLocalQQBotApprovalPrompt({ + cfg, + accountId, + payload, + hint, + }), sendText: async ({ to, text, accountId, replyToId, cfg }) => { + // Ensure bridge/gateway.ts module-level registrations (audio adapter factory, + // platform adapter, etc.) have executed before engine code runs. + await loadGatewayModule(); const account = resolveQQBotAccount(cfg, accountId); - const { sendText } = await loadOutboundModule(); - initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); - const result = await sendText({ to, text, accountId, replyToId, account }); + const { sendText } = await import("./engine/messaging/outbound.js"); + const result = await sendText({ to, text, accountId, replyToId, account: account as never }); return { channel: "qqbot" as const, messageId: result.messageId ?? "", @@ -84,16 +127,17 @@ export const qqbotPlugin: ChannelPlugin = { }; }, sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => { + // Same guard as sendText — ensure adapters are registered. + await loadGatewayModule(); const account = resolveQQBotAccount(cfg, accountId); - const { sendMedia } = await loadOutboundModule(); - initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + const { sendMedia } = await import("./engine/messaging/outbound.js"); const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, - account, + account: account as never, }); return { channel: "qqbot" as const, @@ -104,8 +148,39 @@ export const qqbotPlugin: ChannelPlugin = { }, gateway: { startAccount: async (ctx) => { - const { account } = ctx; - const { abortSignal, log, cfg } = ctx; + let { account, cfg } = ctx; + const { abortSignal, log } = ctx; + + // Recover credentials from the per-account backup if the live + // config is missing appId/secret (e.g. a hot-upgrade wiped + // openclaw.json). We only restore when both fields are empty so a + // user's intentional clear isn't silently undone. + if (!account.appId || !account.clientSecret) { + const backup = loadCredentialBackup(account.accountId); + if (backup?.appId && backup?.clientSecret) { + try { + const nextCfg = applyQQBotAccountConfig(cfg, account.accountId, { + appId: backup.appId, + clientSecret: backup.clientSecret, + }); + const runtime = getQQBotRuntime(); + const configApi = runtime.config as { + writeConfigFile: (cfg: OpenClawConfig) => Promise; + }; + await configApi.writeConfigFile(nextCfg); + cfg = nextCfg; + account = resolveQQBotAccount(nextCfg, account.accountId); + log?.info( + `[qqbot:${account.accountId}] Restored credentials from backup (appId=${account.appId})`, + ); + } catch (err) { + log?.error( + `[qqbot:${account.accountId}] Failed to restore credentials from backup: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + // Serialize the dynamic import so concurrent multi-account startups // do not hit an ESM circular-dependency race where the gateway chunk's // transitive imports have not finished evaluating yet. @@ -120,6 +195,7 @@ export const qqbotPlugin: ChannelPlugin = { abortSignal, cfg, log, + channelRuntime: ctx.channelRuntime as never, onReady: () => { log?.info(`[qqbot:${account.accountId}] Gateway ready`); ctx.setStatus({ @@ -128,6 +204,23 @@ export const qqbotPlugin: ChannelPlugin = { connected: true, lastConnectedAt: Date.now(), }); + // Snapshot credentials so we can recover from the next hot + // upgrade that might wipe openclaw.json mid-flight. + if (account.appId && account.clientSecret) { + saveCredentialBackup(account.accountId, account.appId, account.clientSecret); + } + }, + onResumed: () => { + log?.info(`[qqbot:${account.accountId}] Gateway resumed`); + ctx.setStatus({ + ...ctx.getStatus(), + running: true, + connected: true, + lastConnectedAt: Date.now(), + }); + if (account.appId && account.clientSecret) { + saveCredentialBackup(account.accountId, account.appId, account.clientSecret); + } }, onError: (error) => { log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`); @@ -139,55 +232,20 @@ export const qqbotPlugin: ChannelPlugin = { }); }, logoutAccount: async ({ accountId, cfg }) => { - const nextCfg = { ...cfg } as OpenClawConfig; - const nextQQBot = cfg.channels?.qqbot ? { ...cfg.channels.qqbot } : undefined; - let cleared = false; - let changed = false; + const { nextCfg, cleared, changed } = clearAccountCredentials( + cfg as unknown as Record, + accountId, + ); - if (nextQQBot) { - const qqbot = nextQQBot as Record; - 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> | undefined; - if (accounts && accountId in accounts) { - const entry = accounts[accountId] as Record | 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 }; + if (changed) { const runtime = getQQBotRuntime(); const configApi = runtime.config as { writeConfigFile: (cfg: OpenClawConfig) => Promise; }; - await configApi.writeConfigFile(nextCfg); + await configApi.writeConfigFile(nextCfg as OpenClawConfig); } - const resolved = resolveQQBotAccount(changed ? nextCfg : cfg, accountId); + const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as OpenClawConfig, accountId); const loggedOut = resolved.secretSource === "none"; const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET); diff --git a/extensions/qqbot/src/config-record-shared.ts b/extensions/qqbot/src/config-record-shared.ts deleted file mode 100644 index e91d30432be..00000000000 --- a/extensions/qqbot/src/config-record-shared.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { asOptionalObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime"; - -export const asRecord = asOptionalObjectRecord; -export const readString = readStringField; diff --git a/extensions/qqbot/src/config-schema.ts b/extensions/qqbot/src/config-schema.ts index 6929f98c733..d6335e878e8 100644 --- a/extensions/qqbot/src/config-schema.ts +++ b/extensions/qqbot/src/config-schema.ts @@ -13,27 +13,17 @@ const AudioFormatPolicySchema = z }) .optional(); -const QQBotSpeechQueryParamsSchema = z.record(z.string(), z.string()).optional(); - -const QQBotSpeechProviderSchema = z.object({ - enabled: z.boolean().optional(), - provider: z.string().optional(), - baseUrl: z.string().optional(), - apiKey: z.string().optional(), - model: z.string().optional(), -}); - -const QQBotTtsSchema = QQBotSpeechProviderSchema.extend({ - voice: z.string().optional(), - authStyle: z.enum(["bearer", "api-key"]).optional(), - queryParams: QQBotSpeechQueryParamsSchema, - speed: z.number().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 QQBotSttSchema = QQBotSpeechProviderSchema.strict().optional(); - const QQBotStreamingSchema = z .union([ z.boolean(), @@ -46,6 +36,20 @@ const QQBotStreamingSchema = z ]) .optional(); +const QQBotExecApprovalsSchema = z + .object({ + enabled: z.union([z.boolean(), z.literal("auto")]).optional(), + approvers: z.array(z.string()).optional(), + agentFilter: z.array(z.string()).optional(), + sessionFilter: z.array(z.string()).optional(), + target: z.enum(["dm", "channel", "both"]).optional(), + }) + .strict() + .optional(); + +const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional(); +const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional(); + const QQBotAccountSchema = z .object({ enabled: z.boolean().optional(), @@ -54,6 +58,9 @@ const QQBotAccountSchema = z clientSecret: buildSecretInputSchema().optional(), clientSecretFile: z.string().optional(), allowFrom: AllowFromListSchema, + groupAllowFrom: AllowFromListSchema, + dmPolicy: QQBotDmPolicySchema, + groupPolicy: QQBotGroupPolicySchema, systemPrompt: z.string().optional(), markdownSupport: z.boolean().optional(), voiceDirectUploadFormats: z.array(z.string()).optional(), @@ -62,11 +69,11 @@ const QQBotAccountSchema = z upgradeUrl: z.string().optional(), upgradeMode: z.enum(["doc", "hot-reload"]).optional(), streaming: QQBotStreamingSchema, + execApprovals: QQBotExecApprovalsSchema, }) .passthrough(); export const QQBotConfigSchema = QQBotAccountSchema.extend({ - tts: QQBotTtsSchema, stt: QQBotSttSchema, accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(), defaultAccount: z.string().optional(), diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts index f6859b852ce..1a895dbd1bb 100644 --- a/extensions/qqbot/src/config.test.ts +++ b/extensions/qqbot/src/config.test.ts @@ -1,11 +1,60 @@ +import fs from "node:fs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; -import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./channel-config-shared.js"; +import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js"; +import { qqbotSetupAdapterShared } from "./bridge/config-shared.js"; +import { + DEFAULT_ACCOUNT_ID, + resolveDefaultQQBotAccountId, + resolveQQBotAccount, +} from "./bridge/config.js"; +import { qqbotSetupPlugin } from "./channel.setup.js"; import { QQBotConfigSchema } from "./config-schema.js"; -import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js"; import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js"; describe("qqbot config", () => { + it("accepts top-level speech overrides in the manifest schema", () => { + const manifest = JSON.parse( + fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), + ) as { configSchema: Record }; + + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "qqbot.manifest.speech-overrides", + value: { + stt: { + provider: "openai", + baseUrl: "https://example.com/v1", + apiKey: "stt-key", + model: "whisper-1", + }, + }, + }); + + expect(result.ok).toBe(true); + }); + + it("accepts defaultAccount in the manifest schema", () => { + const manifest = JSON.parse( + fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), + ) as { configSchema: Record }; + + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "qqbot.manifest.default-account", + value: { + defaultAccount: "bot2", + accounts: { + bot2: { + appId: "654321", + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); + it("honors configured defaultAccount when resolving the default QQ Bot account id", () => { const cfg = { channels: { @@ -62,7 +111,7 @@ describe("qqbot config", () => { accounts: { bot2: { appId: "654321", - tts: { + stt: { provider: "openai", }, }, @@ -144,8 +193,8 @@ describe("qqbot config", () => { expect(resolved.clientSecret).toBe(""); expect(resolved.secretSource).toBe("config"); - expect(qqbotConfigAdapter.isConfigured(resolved)).toBe(true); - expect(qqbotConfigAdapter.describeAccount(resolved).configured).toBe(true); + expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true); + expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true); }); it.each([ @@ -160,7 +209,10 @@ describe("qqbot config", () => { expectedPath: ["channels", "qqbot", "accounts", "bot2"], }, ])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => { - const next = qqbotSetupAdapterShared.applyAccountConfig({ + const setup = qqbotSetupPlugin.setup; + expect(setup).toBeDefined(); + + const next = setup!.applyAccountConfig?.({ cfg: {} as OpenClawConfig, accountId: inputAccountId, input: { @@ -182,9 +234,11 @@ describe("qqbot config", () => { }); }); - it("rejects malformed --token in shared setup config", () => { + it("rejects malformed --token consistently across setup paths", () => { const runtimeSetup = qqbotSetupAdapterShared; + const lightweightSetup = qqbotSetupPlugin.setup; expect(runtimeSetup).toBeDefined(); + expect(lightweightSetup).toBeDefined(); const input = { token: "broken", name: "Bad" }; @@ -195,6 +249,13 @@ describe("qqbot config", () => { input, } as never), ).toBe("QQBot --token must be in appId:clientSecret format"); + expect( + lightweightSetup!.validateInput?.({ + cfg: {} as OpenClawConfig, + accountId: DEFAULT_ACCOUNT_ID, + input, + } as never), + ).toBe("QQBot --token must be in appId:clientSecret format"); expect( runtimeSetup.applyAccountConfig?.({ cfg: {} as OpenClawConfig, @@ -202,11 +263,20 @@ describe("qqbot config", () => { input, } as never), ).toEqual({}); + expect( + lightweightSetup!.applyAccountConfig?.({ + cfg: {} as OpenClawConfig, + accountId: DEFAULT_ACCOUNT_ID, + input, + } as never), + ).toEqual({}); }); - it("preserves the --use-env add flow in shared setup config", () => { + it("preserves the --use-env add flow across setup paths", () => { const runtimeSetup = qqbotSetupAdapterShared; + const lightweightSetup = qqbotSetupPlugin.setup; expect(runtimeSetup).toBeDefined(); + expect(lightweightSetup).toBeDefined(); const input = { useEnv: true, name: "Env Bot" }; @@ -225,6 +295,21 @@ describe("qqbot config", () => { }, }, }); + expect( + lightweightSetup!.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", () => { @@ -239,9 +324,11 @@ describe("qqbot config", () => { ).toBe("bot2"); }); - it("rejects --use-env for named accounts in shared setup config", () => { + it("rejects --use-env for named accounts across setup paths", () => { const runtimeSetup = qqbotSetupAdapterShared; + const lightweightSetup = qqbotSetupPlugin.setup; expect(runtimeSetup).toBeDefined(); + expect(lightweightSetup).toBeDefined(); const input = { useEnv: true, name: "Env Bot" }; @@ -252,6 +339,13 @@ describe("qqbot config", () => { input, } as never), ).toBe("QQBot --use-env only supports the default account"); + expect( + lightweightSetup!.validateInput?.({ + cfg: {} as OpenClawConfig, + accountId: "bot2", + input, + } as never), + ).toBe("QQBot --use-env only supports the default account"); expect( runtimeSetup.applyAccountConfig?.({ cfg: {} as OpenClawConfig, @@ -259,5 +353,12 @@ describe("qqbot config", () => { input, } as never), ).toEqual({}); + expect( + lightweightSetup!.applyAccountConfig?.({ + cfg: {} as OpenClawConfig, + accountId: "bot2", + input, + } as never), + ).toEqual({}); }); }); diff --git a/extensions/qqbot/src/config.ts b/extensions/qqbot/src/config.ts deleted file mode 100644 index 7e18728aa6b..00000000000 --- a/extensions/qqbot/src/config.ts +++ /dev/null @@ -1,227 +0,0 @@ -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; - 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 ""; -} - -function buildQQBotAccountConfigPatch(input: { - appId?: string; - clientSecret?: string; - clientSecretFile?: string; - name?: string; -}): Partial { - return { - ...(input.appId ? { appId: input.appId } : {}), - ...(input.clientSecret - ? { clientSecret: input.clientSecret, clientSecretFile: undefined } - : input.clientSecretFile - ? { clientSecretFile: input.clientSecretFile, clientSecret: undefined } - : {}), - ...(input.name ? { name: input.name } : {}), - }; -} - -/** List all configured QQBot account IDs. */ -export function listQQBotAccountIds(cfg: OpenClawConfig): string[] { - const ids = new Set(); - 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 }; - const accountConfigPatch = buildQQBotAccountConfigPatch(input); - - 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 | undefined), - enabled: true, - allowFrom, - ...accountConfigPatch, - }, - }; - } 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 | undefined), - enabled: true, - accounts: { - ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts, - [accountId]: { - ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId], - enabled: true, - allowFrom, - ...accountConfigPatch, - }, - }, - }, - }; - } - - return next; -} diff --git a/extensions/qqbot/src/engine/access/access-control.test.ts b/extensions/qqbot/src/engine/access/access-control.test.ts new file mode 100644 index 00000000000..9fd7f7db9c5 --- /dev/null +++ b/extensions/qqbot/src/engine/access/access-control.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { resolveQQBotAccess } from "./access-control.js"; +import { QQBOT_ACCESS_REASON } from "./types.js"; + +describe("resolveQQBotAccess", () => { + describe("DM scenarios", () => { + it("allows everyone when no allowFrom is configured (open)", () => { + const result = resolveQQBotAccess({ isGroup: false, senderId: "USER1" }); + expect(result).toMatchObject({ + decision: "allow", + reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN, + dmPolicy: "open", + }); + }); + + it("allows everyone with wildcard allowFrom", () => { + const result = resolveQQBotAccess({ + isGroup: false, + senderId: "USER1", + allowFrom: ["*"], + }); + expect(result.decision).toBe("allow"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_OPEN); + }); + + it("allows sender matching the allowlist", () => { + const result = resolveQQBotAccess({ + isGroup: false, + senderId: "USER1", + allowFrom: ["USER1"], + }); + expect(result.decision).toBe("allow"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED); + expect(result.dmPolicy).toBe("allowlist"); + }); + + it("blocks sender not in allowlist", () => { + const result = resolveQQBotAccess({ + isGroup: false, + senderId: "USER2", + allowFrom: ["USER1"], + }); + expect(result.decision).toBe("block"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED); + }); + + it("blocks DM when dmPolicy=disabled (even with wildcard)", () => { + const result = resolveQQBotAccess({ + isGroup: false, + senderId: "USER1", + allowFrom: ["*"], + dmPolicy: "disabled", + }); + expect(result.decision).toBe("block"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_DISABLED); + }); + + it("blocks DM with allowlist policy but empty allowlist", () => { + const result = resolveQQBotAccess({ + isGroup: false, + senderId: "USER1", + dmPolicy: "allowlist", + }); + expect(result.decision).toBe("block"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST); + }); + + it("normalizes qqbot: prefix and case when matching", () => { + const result = resolveQQBotAccess({ + isGroup: false, + senderId: "qqbot:user1", + allowFrom: ["QQBot:USER1"], + }); + expect(result.decision).toBe("allow"); + }); + }); + + describe("group scenarios", () => { + it("inherits allowFrom for group access when no groupAllowFrom is set", () => { + const allowed = resolveQQBotAccess({ + isGroup: true, + senderId: "USER1", + allowFrom: ["USER1"], + }); + expect(allowed.decision).toBe("allow"); + expect(allowed.groupPolicy).toBe("allowlist"); + + const blocked = resolveQQBotAccess({ + isGroup: true, + senderId: "USER2", + allowFrom: ["USER1"], + }); + expect(blocked.decision).toBe("block"); + expect(blocked.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED); + }); + + it("uses groupAllowFrom when explicitly provided", () => { + const result = resolveQQBotAccess({ + isGroup: true, + senderId: "USER2", + allowFrom: ["USER1"], + groupAllowFrom: ["USER2"], + }); + expect(result.decision).toBe("allow"); + }); + + it("blocks when groupPolicy=disabled", () => { + const result = resolveQQBotAccess({ + isGroup: true, + senderId: "USER1", + allowFrom: ["*"], + groupPolicy: "disabled", + }); + expect(result.decision).toBe("block"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED); + }); + + it("allows anyone when groupPolicy=open", () => { + const result = resolveQQBotAccess({ + isGroup: true, + senderId: "RANDOM_USER", + allowFrom: ["USER1"], + groupPolicy: "open", + }); + expect(result.decision).toBe("allow"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED); + }); + + it("blocks when groupPolicy=allowlist but list is empty", () => { + const result = resolveQQBotAccess({ + isGroup: true, + senderId: "USER1", + groupPolicy: "allowlist", + }); + expect(result.decision).toBe("block"); + expect(result.reasonCode).toBe(QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST); + }); + }); + + describe("backwards compatibility (legacy allowFrom-only configs)", () => { + it("legacy allowFrom=['*'] stays fully open for both DM and group", () => { + const dm = resolveQQBotAccess({ + isGroup: false, + senderId: "RANDOM", + allowFrom: ["*"], + }); + const group = resolveQQBotAccess({ + isGroup: true, + senderId: "RANDOM", + allowFrom: ["*"], + }); + expect(dm.decision).toBe("allow"); + expect(group.decision).toBe("allow"); + }); + + it("legacy allowFrom=['USER1'] locks down both DM and group to USER1", () => { + const allowedDm = resolveQQBotAccess({ + isGroup: false, + senderId: "USER1", + allowFrom: ["USER1"], + }); + const blockedGroup = resolveQQBotAccess({ + isGroup: true, + senderId: "INTRUDER", + allowFrom: ["USER1"], + }); + expect(allowedDm.decision).toBe("allow"); + expect(blockedGroup.decision).toBe("block"); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/access/access-control.ts b/extensions/qqbot/src/engine/access/access-control.ts new file mode 100644 index 00000000000..0997e1e61a1 --- /dev/null +++ b/extensions/qqbot/src/engine/access/access-control.ts @@ -0,0 +1,208 @@ +/** + * QQBot inbound access decision. + * + * This module is the single place where the QQBot engine decides + * whether an inbound message from a given sender is allowed to + * proceed into the outbound pipeline. The implementation mirrors the + * semantics of the framework-wide `resolveDmGroupAccessDecision` + * (`src/security/dm-policy-shared.ts`) but is kept standalone so the + * `engine/` layer does not pull in `openclaw/plugin-sdk/*` modules — + * a hard constraint shared with the standalone `openclaw-qqbot` build. + * + * If in the future we lift the zero-dependency rule in the engine + * layer, this file can be replaced by a thin adapter around the + * framework API with identical semantics. + */ + +import { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js"; +import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "./sender-match.js"; +import { + QQBOT_ACCESS_REASON, + type QQBotAccessResult, + type QQBotDmPolicy, + type QQBotGroupPolicy, +} from "./types.js"; + +export interface QQBotAccessInput extends EffectivePolicyInput { + /** Whether the inbound originated in a group (or guild) chat. */ + isGroup: boolean; + /** The raw inbound sender id as provided by the QQ event. */ + senderId: string; +} + +/** + * Evaluate the inbound access policy. + * + * Semantics (aligned with `resolveDmGroupAccessDecision`): + * - Group message: + * - `groupPolicy=disabled` → block + * - `groupPolicy=open` → allow + * - `groupPolicy=allowlist`: + * - empty effectiveGroupAllowFrom → block (empty_allowlist) + * - sender not in list → block (not_allowlisted) + * - otherwise → allow + * - Direct message: + * - `dmPolicy=disabled` → block + * - `dmPolicy=open` → allow + * - `dmPolicy=allowlist`: + * - empty effectiveAllowFrom → block (empty_allowlist) + * - sender not in list → block (not_allowlisted) + * - otherwise → allow + * + * The function never throws; callers can rely on the returned + * `decision`/`reasonCode` pair for branching. + */ +export function resolveQQBotAccess(input: QQBotAccessInput): QQBotAccessResult { + const { dmPolicy, groupPolicy } = resolveQQBotEffectivePolicies(input); + + // Per framework convention: groupAllowFrom falls back to allowFrom + // when not provided. We preserve that behaviour so a single + // `allowFrom` entry locks down both DM and group. + const rawGroupAllowFrom = + input.groupAllowFrom && input.groupAllowFrom.length > 0 + ? input.groupAllowFrom + : (input.allowFrom ?? []); + + const effectiveAllowFrom = normalizeQQBotAllowFrom(input.allowFrom); + const effectiveGroupAllowFrom = normalizeQQBotAllowFrom(rawGroupAllowFrom); + + const isSenderAllowed = createQQBotSenderMatcher(input.senderId); + + if (input.isGroup) { + return evaluateGroupDecision({ + groupPolicy, + dmPolicy, + effectiveAllowFrom, + effectiveGroupAllowFrom, + isSenderAllowed, + }); + } + + return evaluateDmDecision({ + groupPolicy, + dmPolicy, + effectiveAllowFrom, + effectiveGroupAllowFrom, + isSenderAllowed, + }); +} + +// ---- internal helpers ------------------------------------------------ + +interface DecisionContext { + dmPolicy: QQBotDmPolicy; + groupPolicy: QQBotGroupPolicy; + effectiveAllowFrom: string[]; + effectiveGroupAllowFrom: string[]; + isSenderAllowed: (allowFrom: string[]) => boolean; +} + +function evaluateGroupDecision(ctx: DecisionContext): QQBotAccessResult { + const base = buildResultBase(ctx); + + if (ctx.groupPolicy === "disabled") { + return { + ...base, + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_DISABLED, + reason: "groupPolicy=disabled", + }; + } + + if (ctx.groupPolicy === "open") { + return { + ...base, + decision: "allow", + reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED, + reason: "groupPolicy=open", + }; + } + + // groupPolicy === "allowlist" + if (ctx.effectiveGroupAllowFrom.length === 0) { + return { + ...base, + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST, + reason: "groupPolicy=allowlist (empty allowlist)", + }; + } + + if (!ctx.isSenderAllowed(ctx.effectiveGroupAllowFrom)) { + return { + ...base, + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED, + reason: "groupPolicy=allowlist (not allowlisted)", + }; + } + + return { + ...base, + decision: "allow", + reasonCode: QQBOT_ACCESS_REASON.GROUP_POLICY_ALLOWED, + reason: "groupPolicy=allowlist (allowlisted)", + }; +} + +function evaluateDmDecision(ctx: DecisionContext): QQBotAccessResult { + const base = buildResultBase(ctx); + + if (ctx.dmPolicy === "disabled") { + return { + ...base, + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_DISABLED, + reason: "dmPolicy=disabled", + }; + } + + if (ctx.dmPolicy === "open") { + return { + ...base, + decision: "allow", + reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_OPEN, + reason: "dmPolicy=open", + }; + } + + // dmPolicy === "allowlist" + if (ctx.effectiveAllowFrom.length === 0) { + return { + ...base, + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_EMPTY_ALLOWLIST, + reason: "dmPolicy=allowlist (empty allowlist)", + }; + } + + if (!ctx.isSenderAllowed(ctx.effectiveAllowFrom)) { + return { + ...base, + decision: "block", + reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED, + reason: "dmPolicy=allowlist (not allowlisted)", + }; + } + + return { + ...base, + decision: "allow", + reasonCode: QQBOT_ACCESS_REASON.DM_POLICY_ALLOWLISTED, + reason: "dmPolicy=allowlist (allowlisted)", + }; +} + +function buildResultBase( + ctx: DecisionContext, +): Pick< + QQBotAccessResult, + "effectiveAllowFrom" | "effectiveGroupAllowFrom" | "dmPolicy" | "groupPolicy" +> { + return { + effectiveAllowFrom: ctx.effectiveAllowFrom, + effectiveGroupAllowFrom: ctx.effectiveGroupAllowFrom, + dmPolicy: ctx.dmPolicy, + groupPolicy: ctx.groupPolicy, + }; +} diff --git a/extensions/qqbot/src/engine/access/index.ts b/extensions/qqbot/src/engine/access/index.ts new file mode 100644 index 00000000000..4f6b88e8d60 --- /dev/null +++ b/extensions/qqbot/src/engine/access/index.ts @@ -0,0 +1,22 @@ +/** + * QQBot inbound access control — public entry points. + * + * Consumers (inbound-pipeline and future adapters) should import from + * this barrel to keep the internal module layout opaque. + */ + +export { resolveQQBotAccess, type QQBotAccessInput } from "./access-control.js"; +export { + createQQBotSenderMatcher, + normalizeQQBotAllowFrom, + normalizeQQBotSenderId, +} from "./sender-match.js"; +export { resolveQQBotEffectivePolicies, type EffectivePolicyInput } from "./resolve-policy.js"; +export { + QQBOT_ACCESS_REASON, + type QQBotAccessDecision, + type QQBotAccessReasonCode, + type QQBotAccessResult, + type QQBotDmPolicy, + type QQBotGroupPolicy, +} from "./types.js"; diff --git a/extensions/qqbot/src/engine/access/resolve-policy.test.ts b/extensions/qqbot/src/engine/access/resolve-policy.test.ts new file mode 100644 index 00000000000..643babf4ed1 --- /dev/null +++ b/extensions/qqbot/src/engine/access/resolve-policy.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { resolveQQBotEffectivePolicies } from "./resolve-policy.js"; + +describe("resolveQQBotEffectivePolicies", () => { + describe("backwards-compatible inference", () => { + it("defaults to open when no allowFrom is configured", () => { + expect(resolveQQBotEffectivePolicies({})).toEqual({ + dmPolicy: "open", + groupPolicy: "open", + }); + }); + + it("defaults to open when allowFrom only contains wildcard", () => { + expect(resolveQQBotEffectivePolicies({ allowFrom: ["*"] })).toEqual({ + dmPolicy: "open", + groupPolicy: "open", + }); + }); + + it("infers allowlist when allowFrom has a concrete entry", () => { + expect(resolveQQBotEffectivePolicies({ allowFrom: ["USER1"] })).toEqual({ + dmPolicy: "allowlist", + groupPolicy: "allowlist", + }); + }); + + it("infers group=allowlist when only groupAllowFrom is restricted", () => { + expect( + resolveQQBotEffectivePolicies({ allowFrom: ["*"], groupAllowFrom: ["USER1"] }), + ).toEqual({ + dmPolicy: "open", + groupPolicy: "allowlist", + }); + }); + }); + + describe("explicit policy precedence", () => { + it("honours explicit dmPolicy over inference", () => { + expect( + resolveQQBotEffectivePolicies({ allowFrom: ["USER1"], dmPolicy: "open" }), + ).toMatchObject({ dmPolicy: "open" }); + }); + + it("honours explicit groupPolicy over inference", () => { + expect( + resolveQQBotEffectivePolicies({ + allowFrom: ["USER1"], + groupPolicy: "disabled", + }), + ).toMatchObject({ groupPolicy: "disabled" }); + }); + + it("allows dmPolicy=disabled to cut off DM entirely", () => { + expect(resolveQQBotEffectivePolicies({ dmPolicy: "disabled" })).toMatchObject({ + dmPolicy: "disabled", + }); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/access/resolve-policy.ts b/extensions/qqbot/src/engine/access/resolve-policy.ts new file mode 100644 index 00000000000..5d77ec6947c --- /dev/null +++ b/extensions/qqbot/src/engine/access/resolve-policy.ts @@ -0,0 +1,57 @@ +/** + * Effective-policy resolver. + * + * Maps a raw `QQBotAccountConfig` to the concrete `dmPolicy`/`groupPolicy` + * values that the access engine consumes. Provides backwards-compatible + * defaults for accounts that only have the legacy `allowFrom` field: + * + * - Empty `allowFrom` or containing `"*"` → `"open"` (the historical + * behaviour before P0/P1 landed). + * - Non-empty `allowFrom` without `"*"` → `"allowlist"` (what a + * security-conscious operator almost certainly meant). + * + * An explicit `dmPolicy`/`groupPolicy` always wins over the inference. + */ + +import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js"; + +/** Subset of the account config fields this resolver actually reads. */ +export interface EffectivePolicyInput { + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + dmPolicy?: QQBotDmPolicy | null; + groupPolicy?: QQBotGroupPolicy | null; +} + +function hasRealRestriction(list: Array | null | undefined): boolean { + if (!list || list.length === 0) { + return false; + } + // A list that only contains `"*"` is logically equivalent to open. + return !list.every((entry) => String(entry).trim() === "*"); +} + +/** + * Derive the effective dmPolicy and groupPolicy applied at runtime. + * + * Caller should pass the raw `QQBotAccountConfig`. The resolver does + * not look at `groups[id]` overrides — per-group overrides are layered + * on top elsewhere (see `inbound-pipeline` mention gating). + */ +export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): { + dmPolicy: QQBotDmPolicy; + groupPolicy: QQBotGroupPolicy; +} { + const allowFromRestricted = hasRealRestriction(input.allowFrom); + const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom); + + const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open"); + + // groupPolicy defaults: if an explicit groupAllowFrom is provided and + // restricts, enforce allowlist. Otherwise fall back to the same rule + // as DM (so a single `allowFrom` entry locks down both DM and group). + const groupPolicy: QQBotGroupPolicy = + input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open"); + + return { dmPolicy, groupPolicy }; +} diff --git a/extensions/qqbot/src/engine/access/sender-match.test.ts b/extensions/qqbot/src/engine/access/sender-match.test.ts new file mode 100644 index 00000000000..3c39ee0d8d4 --- /dev/null +++ b/extensions/qqbot/src/engine/access/sender-match.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + createQQBotSenderMatcher, + normalizeQQBotAllowFrom, + normalizeQQBotSenderId, +} from "./sender-match.js"; + +describe("normalizeQQBotSenderId", () => { + it("uppercases and strips qqbot: prefix", () => { + expect(normalizeQQBotSenderId("qqbot:abc123")).toBe("ABC123"); + expect(normalizeQQBotSenderId("QQBot:abc123")).toBe("ABC123"); + }); + + it("trims whitespace", () => { + expect(normalizeQQBotSenderId(" USER1 ")).toBe("USER1"); + }); + + it("returns empty string for non-string input", () => { + expect(normalizeQQBotSenderId(undefined as unknown as string)).toBe(""); + expect(normalizeQQBotSenderId(null as unknown as string)).toBe(""); + expect(normalizeQQBotSenderId({} as unknown as string)).toBe(""); + }); + + it("accepts numeric input", () => { + expect(normalizeQQBotSenderId(42)).toBe("42"); + }); +}); + +describe("normalizeQQBotAllowFrom", () => { + it("normalizes all entries and drops empty ones", () => { + expect(normalizeQQBotAllowFrom(["qqbot:user1", "USER2", "", " "])).toEqual(["USER1", "USER2"]); + }); + + it("returns empty array for undefined/null", () => { + expect(normalizeQQBotAllowFrom(undefined)).toEqual([]); + expect(normalizeQQBotAllowFrom(null)).toEqual([]); + }); +}); + +describe("createQQBotSenderMatcher", () => { + it("matches wildcard regardless of sender", () => { + expect(createQQBotSenderMatcher("USER1")(["*"])).toBe(true); + expect(createQQBotSenderMatcher("")(["*"])).toBe(true); + }); + + it("matches case-insensitive with qqbot: prefix", () => { + const match = createQQBotSenderMatcher("qqbot:USER1"); + expect(match(["qqbot:user1"])).toBe(true); + expect(match(["USER1"])).toBe(true); + expect(match(["USER2"])).toBe(false); + }); + + it("returns false on empty allowlist", () => { + expect(createQQBotSenderMatcher("USER1")([])).toBe(false); + }); + + it("returns false for empty sender against non-wildcard list", () => { + expect(createQQBotSenderMatcher("")(["USER1"])).toBe(false); + }); +}); diff --git a/extensions/qqbot/src/engine/access/sender-match.ts b/extensions/qqbot/src/engine/access/sender-match.ts new file mode 100644 index 00000000000..9b0b5092572 --- /dev/null +++ b/extensions/qqbot/src/engine/access/sender-match.ts @@ -0,0 +1,55 @@ +/** + * QQBot sender normalization and allowlist matching. + * + * Keeps QQ-specific quirks (the `qqbot:` prefix, uppercase-insensitive + * comparison) localized to this module so the policy engine itself can + * stay channel-agnostic. + */ + +/** Normalize a single entry (openid): strip `qqbot:` prefix, uppercase, trim. */ +export function normalizeQQBotSenderId(raw: unknown): string { + if (typeof raw !== "string" && typeof raw !== "number") { + return ""; + } + return String(raw) + .trim() + .replace(/^qqbot:/i, "") + .toUpperCase(); +} + +/** Normalize an entire allowFrom list, dropping empty entries. */ +export function normalizeQQBotAllowFrom(list: Array | undefined | null): string[] { + if (!list || list.length === 0) { + return []; + } + const out: string[] = []; + for (const entry of list) { + const normalized = normalizeQQBotSenderId(entry); + if (normalized) { + out.push(normalized); + } + } + return out; +} + +/** + * Build a matcher closure suitable for passing to the policy engine's + * `isSenderAllowed` callback. The caller supplies the sender once, and + * the returned function can be invoked against different allowlists + * (DM allowlist vs group allowlist) without repeating normalization. + */ +export function createQQBotSenderMatcher(senderId: string): (allowFrom: string[]) => boolean { + const normalizedSender = normalizeQQBotSenderId(senderId); + return (allowFrom: string[]) => { + if (allowFrom.length === 0) { + return false; + } + if (allowFrom.includes("*")) { + return true; + } + if (!normalizedSender) { + return false; + } + return allowFrom.some((entry) => normalizeQQBotSenderId(entry) === normalizedSender); + }; +} diff --git a/extensions/qqbot/src/engine/access/types.ts b/extensions/qqbot/src/engine/access/types.ts new file mode 100644 index 00000000000..038ac5fb029 --- /dev/null +++ b/extensions/qqbot/src/engine/access/types.ts @@ -0,0 +1,52 @@ +/** + * QQBot access-control primitive types. + * + * Mirrors the semantics of the framework-shared `DmPolicy` and + * `DmGroupAccessDecision` types while staying zero-dependency so the + * engine layer remains portable across the built-in and standalone + * plugin builds. + * + * The reason codes here intentionally match + * `src/security/dm-policy-shared.ts::DM_GROUP_ACCESS_REASON` so metric + * dashboards can treat QQBot decisions identically to WhatsApp / + * Telegram / Discord decisions. + */ + +/** DM-level policy selecting between open / allowlist / disabled gating. */ +export type QQBotDmPolicy = "open" | "allowlist" | "disabled"; + +/** Group-level policy selecting between open / allowlist / disabled gating. */ +export type QQBotGroupPolicy = "open" | "allowlist" | "disabled"; + +/** High-level outcome returned by the access gate. */ +export type QQBotAccessDecision = "allow" | "block"; + +/** Structured reason codes used in logs and metrics. */ +export const QQBOT_ACCESS_REASON = { + DM_POLICY_OPEN: "dm_policy_open", + DM_POLICY_DISABLED: "dm_policy_disabled", + DM_POLICY_ALLOWLISTED: "dm_policy_allowlisted", + DM_POLICY_NOT_ALLOWLISTED: "dm_policy_not_allowlisted", + DM_POLICY_EMPTY_ALLOWLIST: "dm_policy_empty_allowlist", + GROUP_POLICY_ALLOWED: "group_policy_allowed", + GROUP_POLICY_DISABLED: "group_policy_disabled", + GROUP_POLICY_EMPTY_ALLOWLIST: "group_policy_empty_allowlist", + GROUP_POLICY_NOT_ALLOWLISTED: "group_policy_not_allowlisted", +} as const; + +export type QQBotAccessReasonCode = (typeof QQBOT_ACCESS_REASON)[keyof typeof QQBOT_ACCESS_REASON]; + +/** Result of the access gate evaluation. */ +export interface QQBotAccessResult { + decision: QQBotAccessDecision; + reasonCode: QQBotAccessReasonCode; + /** Human-readable reason suitable for logging. */ + reason: string; + /** The allowFrom list that was actually compared against. */ + effectiveAllowFrom: string[]; + /** The groupAllowFrom list that was actually compared against. */ + effectiveGroupAllowFrom: string[]; + /** The dm/group policies that were used (after defaults were applied). */ + dmPolicy: QQBotDmPolicy; + groupPolicy: QQBotGroupPolicy; +} diff --git a/extensions/qqbot/src/engine/adapter/index.ts b/extensions/qqbot/src/engine/adapter/index.ts new file mode 100644 index 00000000000..c817a54c2b0 --- /dev/null +++ b/extensions/qqbot/src/engine/adapter/index.ts @@ -0,0 +1,106 @@ +/** + * Platform adapter interface — abstracts framework-specific capabilities + * so core/ modules remain portable between the built-in and standalone versions. + * + * Each version implements this interface in its own `bootstrap/adapter/` directory + * and calls `registerPlatformAdapter()` during startup. + * + * core/ modules access platform capabilities via `getPlatformAdapter()`. + * + * ## Lazy initialization + * + * When the adapter has not been explicitly registered yet, `getPlatformAdapter()` + * will invoke the factory registered via `registerPlatformAdapterFactory()` to + * create and register the adapter on first access. This eliminates fragile + * dependency on side-effect import ordering — the adapter is guaranteed to be + * available whenever any engine module needs it, regardless of which code path + * triggers the first access. + */ + +import type { FetchMediaOptions, FetchMediaResult, SecretInputRef } from "./types.js"; + +/** Platform adapter that core/ modules use for framework-specific operations. */ +export interface PlatformAdapter { + /** Validate that a remote URL is safe to fetch (SSRF protection). */ + validateRemoteUrl(url: string, options?: { allowPrivate?: boolean }): Promise; + + /** Resolve a secret value (SecretInput or plain string) to a plain string. */ + resolveSecret(value: string | SecretInputRef | undefined): Promise; + + /** Download a remote file to a local directory. Returns the local file path. */ + downloadFile(url: string, destDir: string, filename?: string): Promise; + + /** + * Fetch remote media with SSRF protection. + * Replaces direct usage of `fetchRemoteMedia` from `plugin-sdk/media-runtime`. + */ + fetchMedia(options: FetchMediaOptions): Promise; + + /** Return the preferred temporary directory for the platform. */ + getTempDir(): string; + + /** Check whether a secret input value has been configured (non-empty). */ + hasConfiguredSecret(value: unknown): boolean; + + /** + * Normalize a raw SecretInput value into a plain string. + * For unresolved references (e.g. `$secret:xxx`), returns the raw reference string. + */ + normalizeSecretInputString(value: unknown): string | undefined; + + /** + * Resolve a SecretInput value into the final plain-text secret. + * For secret references, resolves them to actual values via the platform's secret store. + */ + resolveSecretInputString(params: { value: unknown; path: string }): string | undefined; + + /** + * Submit an approval decision to the framework's approval gateway. + * Optional — only available when the framework supports approvals. + * Returns true if the decision was submitted successfully. + */ + resolveApproval?(approvalId: string, decision: string): Promise; +} + +let _adapter: PlatformAdapter | null = null; +let _adapterFactory: (() => PlatformAdapter) | null = null; + +/** Register the platform adapter. Called once during startup. */ +export function registerPlatformAdapter(adapter: PlatformAdapter): void { + _adapter = adapter; +} + +/** + * Register a factory that creates the PlatformAdapter on first access. + * + * This decouples adapter availability from side-effect import ordering. + * The factory is invoked at most once — on the first `getPlatformAdapter()` + * call when no adapter has been explicitly registered yet. + */ +export function registerPlatformAdapterFactory(factory: () => PlatformAdapter): void { + _adapterFactory = factory; +} + +/** + * Get the registered platform adapter. + * + * If no adapter has been explicitly registered yet but a factory was provided + * via `registerPlatformAdapterFactory()`, the factory is invoked to create + * and register the adapter automatically. + */ +export function getPlatformAdapter(): PlatformAdapter { + if (!_adapter && _adapterFactory) { + _adapter = _adapterFactory(); + } + if (!_adapter) { + throw new Error( + "PlatformAdapter not registered. Call registerPlatformAdapter() during bootstrap.", + ); + } + return _adapter; +} + +/** Check whether a platform adapter has been registered (or can be created from a factory). */ +export function hasPlatformAdapter(): boolean { + return _adapter !== null || _adapterFactory !== null; +} diff --git a/extensions/qqbot/src/engine/adapter/types.ts b/extensions/qqbot/src/engine/adapter/types.ts new file mode 100644 index 00000000000..61e62f008be --- /dev/null +++ b/extensions/qqbot/src/engine/adapter/types.ts @@ -0,0 +1,38 @@ +/** + * Shared types used by the PlatformAdapter interface. + */ + +/** Reference to a secret stored in the platform's secret management system. */ +export interface SecretInputRef { + source: "env" | "file" | "config"; + id: string; +} + +/** Options for fetching remote media through the platform adapter. */ +export interface FetchMediaOptions { + url: string; + /** Hint for the local filename when saving. */ + filePathHint?: string; + /** Maximum bytes to download. */ + maxBytes?: number; + /** Maximum redirects to follow. */ + maxRedirects?: number; + /** SSRF policy configuration. */ + ssrfPolicy?: SsrfPolicyConfig; + /** Extra fetch() RequestInit options. */ + requestInit?: RequestInit; +} + +/** Result of a remote media fetch operation. */ +export interface FetchMediaResult { + buffer: Buffer; + fileName?: string; +} + +/** SSRF policy configuration — platform-agnostic subset. */ +export interface SsrfPolicyConfig { + /** Hostnames that are always allowed (supports `*.example.com` wildcards). */ + hostnameAllowlist?: string[]; + /** Whether to allow RFC 2544 benchmark ranges (198.18.0.0/15). */ + allowRfc2544BenchmarkRange?: boolean; +} diff --git a/extensions/qqbot/src/engine/api/api-client.ts b/extensions/qqbot/src/engine/api/api-client.ts new file mode 100644 index 00000000000..2cbe9bd33fa --- /dev/null +++ b/extensions/qqbot/src/engine/api/api-client.ts @@ -0,0 +1,196 @@ +/** + * Core HTTP client for the QQ Open Platform REST API. + * + * Key improvements over the old `src/api.ts#apiRequest`: + * - `ApiClient` is an **instance** — config (baseUrl, timeout, logger, UA) + * is injected via the constructor, eliminating module-level globals. + * - Throws structured `ApiError` with httpStatus, bizCode, and path fields. + * - Detects HTML error pages from CDN/gateway and returns user-friendly messages. + * - `redactBodyKeys` replaces the hardcoded `file_data` redaction. + */ + +import { ApiError, type ApiClientConfig, type EngineLogger } from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; + +const DEFAULT_BASE_URL = "https://api.sgroup.qq.com"; +const DEFAULT_TIMEOUT_MS = 30_000; +const FILE_UPLOAD_TIMEOUT_MS = 120_000; + +export interface RequestOptions { + /** Request timeout override in milliseconds. */ + timeoutMs?: number; + /** Body keys to redact in debug logs (e.g. `['file_data']`). */ + redactBodyKeys?: string[]; +} + +/** + * Stateful HTTP client for the QQ Open Platform. + * + * Usage: + * ```ts + * const client = new ApiClient({ logger, userAgent: 'QQBotPlugin/1.0' }); + * const data = await client.request<{ url: string }>(token, 'GET', '/gateway'); + * ``` + */ +export class ApiClient { + private readonly baseUrl: string; + private readonly defaultTimeoutMs: number; + private readonly fileUploadTimeoutMs: number; + private readonly logger?: EngineLogger; + private readonly resolveUserAgent: () => string; + + constructor(config: ApiClientConfig = {}) { + this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; + this.defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; + this.fileUploadTimeoutMs = config.fileUploadTimeoutMs ?? FILE_UPLOAD_TIMEOUT_MS; + this.logger = config.logger; + const ua = config.userAgent ?? "QQBotPlugin/unknown"; + this.resolveUserAgent = typeof ua === "function" ? ua : () => ua; + } + + /** + * Send an authenticated JSON request to the QQ Open Platform. + * + * @param accessToken - Bearer token (`QQBot {token}`). + * @param method - HTTP method. + * @param path - API path (appended to baseUrl). + * @param body - Optional JSON body. + * @param options - Optional request overrides. + * @returns Parsed JSON response. + * @throws {ApiError} On HTTP or parse errors. + */ + async request( + accessToken: string, + method: string, + path: string, + body?: unknown, + options?: RequestOptions, + ): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: Record = { + Authorization: `QQBot ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": this.resolveUserAgent(), + }; + + const isFileUpload = path.includes("/files"); + const timeout = + options?.timeoutMs ?? (isFileUpload ? this.fileUploadTimeoutMs : this.defaultTimeoutMs); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const fetchInit: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (body) { + fetchInit.body = JSON.stringify(body); + } + + // Debug logging with optional body redaction. + this.logger?.debug?.(`[qqbot:api] >>> ${method} ${url} (timeout: ${timeout}ms)`); + if (body && this.logger?.debug) { + const logBody = { ...(body as Record) }; + for (const key of options?.redactBodyKeys ?? ["file_data"]) { + if (typeof logBody[key] === "string") { + logBody[key] = ``; + } + } + this.logger.debug(`[qqbot:api] >>> Body: ${JSON.stringify(logBody)}`); + } + + let res: Response; + try { + res = await fetch(url, fetchInit); + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === "AbortError") { + this.logger?.error?.(`[qqbot:api] <<< Timeout after ${timeout}ms`); + throw new ApiError(`Request timeout [${path}]: exceeded ${timeout}ms`, 0, path); + } + this.logger?.error?.(`[qqbot:api] <<< Network error: ${formatErrorMessage(err)}`); + throw new ApiError(`Network error [${path}]: ${formatErrorMessage(err)}`, 0, path); + } finally { + clearTimeout(timeoutId); + } + + // Log response status and trace ID. + const traceId = res.headers.get("x-tps-trace-id") ?? ""; + this.logger?.info?.( + `[qqbot:api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`, + ); + + let rawBody: string; + try { + rawBody = await res.text(); + } catch (err) { + throw new ApiError( + `Failed to read response [${path}]: ${formatErrorMessage(err)}`, + res.status, + path, + ); + } + this.logger?.debug?.(`[qqbot:api] <<< Body: ${rawBody}`); + + // Detect non-JSON responses (HTML gateway errors, CDN rate-limit pages). + const contentType = res.headers.get("content-type") ?? ""; + const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<"); + + if (!res.ok) { + if (isHtmlResponse) { + const statusHint = + res.status === 502 || res.status === 503 || res.status === 504 + ? "调用发生异常,请稍候重试" + : res.status === 429 + ? "请求过于频繁,已被限流" + : `开放平台返回 HTTP ${res.status}`; + throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path); + } + + // JSON error response. + try { + const error = JSON.parse(rawBody) as { + message?: string; + code?: number; + err_code?: number; + }; + const bizCode = error.code ?? error.err_code; + throw new ApiError( + `API Error [${path}]: ${error.message ?? rawBody}`, + res.status, + path, + bizCode, + error.message, + ); + } catch (parseErr) { + if (parseErr instanceof ApiError) { + throw parseErr; + } + throw new ApiError( + `API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, + res.status, + path, + ); + } + } + + // Successful response but not JSON (extreme edge case). + if (isHtmlResponse) { + throw new ApiError( + `QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`, + res.status, + path, + ); + } + + try { + return JSON.parse(rawBody) as T; + } catch { + throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path); + } + } +} diff --git a/extensions/qqbot/src/engine/api/media.ts b/extensions/qqbot/src/engine/api/media.ts new file mode 100644 index 00000000000..6b37f58db45 --- /dev/null +++ b/extensions/qqbot/src/engine/api/media.ts @@ -0,0 +1,178 @@ +/** + * Media upload API for the QQ Open Platform (small-file direct upload). + * + * Key improvements: + * - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`. + * - Upload cache integration via composition (passed in constructor). + * - Uses `withRetry` from the shared retry engine. + */ + +import { + MediaFileType, + type ChatScope, + type UploadMediaResponse, + type MessageResponse, + type EngineLogger, +} from "../types.js"; +import { ApiClient } from "./api-client.js"; +import { withRetry, UPLOAD_RETRY_POLICY } from "./retry.js"; +import { mediaUploadPath, getNextMsgSeq } from "./routes.js"; +import { TokenManager } from "./token.js"; + +/** Upload cache interface — the caller provides the implementation. */ +export interface UploadCacheAdapter { + computeHash: (data: string) => string; + get: (hash: string, scope: string, targetId: string, fileType: number) => string | null; + set: ( + hash: string, + scope: string, + targetId: string, + fileType: number, + fileInfo: string, + fileUuid: string, + ttl: number, + ) => void; +} + +/** File name sanitizer — injected to avoid importing platform-specific utils. */ +export type SanitizeFileNameFn = (name: string) => string; + +export interface MediaApiConfig { + logger?: EngineLogger; + /** Upload cache adapter (optional, omit to disable caching). */ + uploadCache?: UploadCacheAdapter; + /** File name sanitizer. */ + sanitizeFileName?: SanitizeFileNameFn; +} + +/** + * Small-file media upload module. + * + * Handles base64 and URL-based uploads with optional caching and retry. + */ +export class MediaApi { + private readonly client: ApiClient; + private readonly tokenManager: TokenManager; + private readonly logger?: EngineLogger; + private readonly cache?: UploadCacheAdapter; + private readonly sanitize: SanitizeFileNameFn; + + constructor(client: ApiClient, tokenManager: TokenManager, config: MediaApiConfig = {}) { + this.client = client; + this.tokenManager = tokenManager; + this.logger = config.logger; + this.cache = config.uploadCache; + this.sanitize = config.sanitizeFileName ?? ((n) => n); + } + + /** + * Upload media via base64 or URL to a C2C or Group target. + * + * @param scope - `'c2c'` or `'group'`. + * @param targetId - User openid or group openid. + * @param fileType - Media file type code. + * @param creds - Authentication credentials. + * @param opts - Upload options. + * @returns Upload result containing `file_info` for subsequent message sends. + */ + async uploadMedia( + scope: ChatScope, + targetId: string, + fileType: MediaFileType, + creds: { appId: string; clientSecret: string }, + opts: { + url?: string; + fileData?: string; + srvSendMsg?: boolean; + fileName?: string; + }, + ): Promise { + if (!opts.url && !opts.fileData) { + throw new Error(`uploadMedia: url or fileData is required`); + } + + // Check cache for base64 uploads. + if (opts.fileData && this.cache) { + const hash = this.cache.computeHash(opts.fileData); + const cached = this.cache.get(hash, scope, targetId, fileType); + if (cached) { + return { file_uuid: "", file_info: cached, ttl: 0 }; + } + } + + const body: Record = { + file_type: fileType, + srv_send_msg: opts.srvSendMsg ?? false, + }; + if (opts.url) { + body.url = opts.url; + } else if (opts.fileData) { + body.file_data = opts.fileData; + } + if (fileType === MediaFileType.FILE && opts.fileName) { + body.file_name = this.sanitize(opts.fileName); + } + + const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); + const path = mediaUploadPath(scope, targetId); + + const result = await withRetry( + () => + this.client.request(token, "POST", path, body, { + redactBodyKeys: ["file_data"], + }), + UPLOAD_RETRY_POLICY, + undefined, + this.logger, + ); + + // Cache the result for future dedup. + if (opts.fileData && result.file_info && result.ttl > 0 && this.cache) { + const hash = this.cache.computeHash(opts.fileData); + this.cache.set( + hash, + scope, + targetId, + fileType, + result.file_info, + result.file_uuid, + result.ttl, + ); + } + + return result; + } + + /** + * Send a media message (upload result → message) to a C2C or Group target. + * + * @param scope - `'c2c'` or `'group'`. + * @param targetId - User openid or group openid. + * @param fileInfo - `file_info` from a prior upload. + * @param creds - Authentication credentials. + * @param opts - Message options. + */ + async sendMediaMessage( + scope: ChatScope, + targetId: string, + fileInfo: string, + creds: { appId: string; clientSecret: string }, + opts?: { + msgId?: string; + content?: string; + }, + ): Promise { + const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); + const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1; + const path = + scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`; + + return this.client.request(token, "POST", path, { + msg_type: 7, + media: { file_info: fileInfo }, + msg_seq: msgSeq, + ...(opts?.content ? { content: opts.content } : {}), + ...(opts?.msgId ? { msg_id: opts.msgId } : {}), + }); + } +} diff --git a/extensions/qqbot/src/engine/api/messages.ts b/extensions/qqbot/src/engine/api/messages.ts new file mode 100644 index 00000000000..f42f3a8fc60 --- /dev/null +++ b/extensions/qqbot/src/engine/api/messages.ts @@ -0,0 +1,267 @@ +/** + * Message sending API for the QQ Open Platform. + * + * Key design improvements: + * - Unified `sendMessage(scope, ...)` replaces `sendC2CMessage` + `sendGroupMessage`. + * - `onMessageSent` hook is scoped to the instance, not a module-level global. + * - Markdown support flag is per-instance, not a global Map. + */ + +import type { + ChatScope, + MessageResponse, + OutboundMeta, + EngineLogger, + InlineKeyboard, +} from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { ApiClient } from "./api-client.js"; +import { + messagePath, + channelMessagePath, + dmMessagePath, + gatewayPath, + interactionPath, + getNextMsgSeq, +} from "./routes.js"; +import { TokenManager } from "./token.js"; + +export interface MessageApiConfig { + /** Whether the QQ Bot has markdown permission. */ + markdownSupport: boolean; + /** Logger for diagnostics. */ + logger?: EngineLogger; +} + +type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void; + +/** + * Message sending module. + * + * Usage: + * ```ts + * const api = new MessageApi(client, tokenMgr, { markdownSupport: true }); + * await api.sendMessage('c2c', openid, 'Hello!', { appId, clientSecret, msgId }); + * ``` + */ +export class MessageApi { + private readonly client: ApiClient; + private readonly tokenManager: TokenManager; + private readonly markdownSupport: boolean; + private readonly logger?: EngineLogger; + private messageSentHook: OnMessageSentCallback | null = null; + + constructor(client: ApiClient, tokenManager: TokenManager, config: MessageApiConfig) { + this.client = client; + this.tokenManager = tokenManager; + this.markdownSupport = config.markdownSupport; + this.logger = config.logger; + } + + /** Register a callback invoked when a sent message returns a ref_idx. */ + onMessageSent(callback: OnMessageSentCallback): void { + this.messageSentHook = callback; + } + + /** + * Notify the registered hook about a sent message. + * Use this for media sends that bypass `sendAndNotify`. + */ + notifyMessageSent(refIdx: string, meta: OutboundMeta): void { + if (this.messageSentHook) { + try { + this.messageSentHook(refIdx, meta); + } catch (err) { + this.logger?.error?.( + `[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`, + ); + } + } + } + + // ---- Unified message sending ---- + + /** + * Send a text message to a C2C or Group target. + * + * Automatically constructs the correct path, body format (markdown vs plain), + * and message sequence number. + */ + async sendMessage( + scope: ChatScope, + targetId: string, + content: string, + creds: Credentials, + opts?: { + msgId?: string; + messageReference?: string; + inlineKeyboard?: InlineKeyboard; + }, + ): Promise { + const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); + const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1; + const body = this.buildMessageBody( + content, + opts?.msgId, + msgSeq, + opts?.messageReference, + opts?.inlineKeyboard, + ); + const path = messagePath(scope, targetId); + return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content }); + } + + /** Send a proactive (no msgId) message to a C2C or Group target. */ + async sendProactiveMessage( + scope: ChatScope, + targetId: string, + content: string, + creds: Credentials, + ): Promise { + if (!content?.trim()) { + throw new Error("Proactive message content must not be empty"); + } + const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); + const body = this.buildProactiveBody(content); + const path = messagePath(scope, targetId); + return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content }); + } + + // ---- Channel / DM ---- + + /** Send a channel message. */ + async sendChannelMessage(opts: { + channelId: string; + content: string; + creds: Credentials; + msgId?: string; + }): Promise { + const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); + return this.client.request(token, "POST", channelMessagePath(opts.channelId), { + content: opts.content, + ...(opts.msgId ? { msg_id: opts.msgId } : {}), + }); + } + + /** Send a DM (guild direct message). */ + async sendDmMessage(opts: { + guildId: string; + content: string; + creds: Credentials; + msgId?: string; + }): Promise { + const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); + return this.client.request(token, "POST", dmMessagePath(opts.guildId), { + content: opts.content, + ...(opts.msgId ? { msg_id: opts.msgId } : {}), + }); + } + + // ---- C2C Input Notify ---- + + /** Send a typing indicator to a C2C user. */ + async sendInputNotify(opts: { + openid: string; + creds: Credentials; + msgId?: string; + inputSecond?: number; + }): Promise<{ refIdx?: string }> { + const inputSecond = opts.inputSecond ?? 60; + const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret); + const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1; + const response = await this.client.request<{ ext_info?: { ref_idx?: string } }>( + token, + "POST", + messagePath("c2c", opts.openid), + { + msg_type: 6, + input_notify: { input_type: 1, input_second: inputSecond }, + msg_seq: msgSeq, + ...(opts.msgId ? { msg_id: opts.msgId } : {}), + }, + ); + return { refIdx: response.ext_info?.ref_idx }; + } + + // ---- Interaction ---- + + /** Acknowledge an INTERACTION_CREATE event. */ + async acknowledgeInteraction( + interactionId: string, + creds: Credentials, + code: 0 | 1 | 2 | 3 | 4 | 5 = 0, + ): Promise { + const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); + await this.client.request(token, "PUT", interactionPath(interactionId), { code }); + } + + // ---- Gateway ---- + + /** Get the WebSocket gateway URL. */ + async getGatewayUrl(creds: Credentials): Promise { + const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret); + const data = await this.client.request<{ url: string }>(token, "GET", gatewayPath()); + return data.url; + } + + // ---- Internal ---- + + private async sendAndNotify( + appId: string, + accessToken: string, + method: string, + path: string, + body: unknown, + meta: OutboundMeta, + ): Promise { + const result = await this.client.request(accessToken, method, path, body); + if (result.ext_info?.ref_idx && this.messageSentHook) { + try { + this.messageSentHook(result.ext_info.ref_idx, meta); + } catch (err) { + this.logger?.error?.( + `[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`, + ); + } + } + return result; + } + + private buildMessageBody( + content: string, + msgId: string | undefined, + msgSeq: number, + messageReference?: string, + inlineKeyboard?: InlineKeyboard, + ): Record { + const body: Record = this.markdownSupport + ? { markdown: { content }, msg_type: 2, msg_seq: msgSeq } + : { content, msg_type: 0, msg_seq: msgSeq }; + + if (msgId) { + body.msg_id = msgId; + } + if (messageReference && !this.markdownSupport) { + body.message_reference = { message_id: messageReference }; + } + if (inlineKeyboard) { + body.keyboard = inlineKeyboard; + } + return body; + } + + private buildProactiveBody(content: string): Record { + return this.markdownSupport ? { markdown: { content }, msg_type: 2 } : { content, msg_type: 0 }; + } +} + +// ---- Shared helpers ---- + +/** Credentials needed to authenticate API requests. */ +export interface Credentials { + appId: string; + clientSecret: string; +} + +// Re-export getNextMsgSeq for consumers that import from messages.ts. +export { getNextMsgSeq } from "./routes.js"; diff --git a/extensions/qqbot/src/engine/api/retry.ts b/extensions/qqbot/src/engine/api/retry.ts new file mode 100644 index 00000000000..4b466fb03e1 --- /dev/null +++ b/extensions/qqbot/src/engine/api/retry.ts @@ -0,0 +1,219 @@ +/** + * Generic retry engine for QQ Bot API requests. + * + * Replaces the three separate retry implementations in the old `api.ts`: + * - `apiRequestWithRetry` (upload retry with exponential backoff) + * - `partFinishWithRetry` (part-finish retry + persistent retry on specific biz codes) + * - `completeUploadWithRetry` (unconditional retry for complete-upload) + * + * All three patterns are expressed as a single `withRetry` function + * parameterized by `RetryPolicy` and optional `PersistentRetryPolicy`. + */ + +import type { EngineLogger } from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; + +/** Standard retry policy with exponential or fixed backoff. */ +export interface RetryPolicy { + /** Maximum retry attempts (excluding the initial attempt). */ + maxRetries: number; + /** Base delay in milliseconds. */ + baseDelayMs: number; + /** Backoff strategy. */ + backoff: "exponential" | "fixed"; + /** + * Predicate to decide whether an error is retryable. + * Return `false` to immediately rethrow. + * Defaults to always-retry when omitted. + */ + shouldRetry?: (error: Error, attempt: number) => boolean; +} + +/** + * Persistent retry policy for specific business error codes. + * + * When `shouldPersistRetry` returns true, the engine switches from + * the standard retry loop into a tight fixed-interval loop bounded + * only by the total timeout. + */ +export interface PersistentRetryPolicy { + /** Total timeout in milliseconds for the persistent retry loop. */ + timeoutMs: number; + /** Fixed interval between retries in milliseconds. */ + intervalMs: number; + /** Predicate to decide whether an error triggers persistent retry. */ + shouldPersistRetry: (error: Error) => boolean; +} + +/** + * Execute an async operation with configurable retry semantics. + * + * @param fn - The async operation to retry. + * @param policy - Standard retry configuration. + * @param persistentPolicy - Optional persistent retry for specific error codes. + * @param logger - Optional logger for retry diagnostics. + * @returns The result of the first successful invocation. + */ +export async function withRetry( + fn: () => Promise, + policy: RetryPolicy, + persistentPolicy?: PersistentRetryPolicy, + logger?: EngineLogger, +): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= policy.maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err instanceof Error ? err : new Error(formatErrorMessage(err)); + + // Check for persistent-retry trigger before standard retry logic. + if (persistentPolicy?.shouldPersistRetry(lastError)) { + (logger?.warn ?? logger?.error)?.( + `[qqbot:retry] Hit persistent-retry trigger, entering persistent loop (timeout=${persistentPolicy.timeoutMs / 1000}s)`, + ); + return await persistentRetryLoop(fn, persistentPolicy, logger); + } + + // Check whether this error is retryable under the standard policy. + if (policy.shouldRetry?.(lastError, attempt) === false) { + throw lastError; + } + + // Schedule the next retry with the configured backoff. + if (attempt < policy.maxRetries) { + const delay = + policy.backoff === "exponential" + ? policy.baseDelayMs * Math.pow(2, attempt) + : policy.baseDelayMs; + + logger?.debug?.( + `[qqbot:retry] Attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 100)}`, + ); + await sleep(delay); + } + } + } + + throw lastError!; +} + +/** + * Persistent retry loop: fixed-interval retries bounded by a total timeout. + * + * Used for `upload_part_finish` when the server returns specific business + * error codes indicating the backend is still processing. + */ +async function persistentRetryLoop( + fn: () => Promise, + policy: PersistentRetryPolicy, + logger?: EngineLogger, +): Promise { + const deadline = Date.now() + policy.timeoutMs; + let attempt = 0; + let lastError: Error | null = null; + + while (Date.now() < deadline) { + try { + const result = await fn(); + logger?.debug?.(`[qqbot:retry] Persistent retry succeeded after ${attempt} retries`); + return result; + } catch (err) { + lastError = err instanceof Error ? err : new Error(formatErrorMessage(err)); + + // If the error is no longer retryable, abort immediately. + if (!policy.shouldPersistRetry(lastError)) { + logger?.error?.(`[qqbot:retry] Persistent retry: error is no longer retryable, aborting`); + throw lastError; + } + + attempt++; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + + const actualDelay = Math.min(policy.intervalMs, remaining); + (logger?.warn ?? logger?.error)?.( + `[qqbot:retry] Persistent retry #${attempt}: retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`, + ); + await sleep(actualDelay); + } + } + + logger?.error?.( + `[qqbot:retry] Persistent retry timed out after ${policy.timeoutMs / 1000}s (${attempt} attempts)`, + ); + throw lastError ?? new Error(`Persistent retry timed out (${policy.timeoutMs / 1000}s)`); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ============ Pre-built Retry Policies ============ + +/** Standard upload retry: exponential backoff, skip 400/401/timeout errors. */ +export const UPLOAD_RETRY_POLICY: RetryPolicy = { + maxRetries: 2, + baseDelayMs: 1000, + backoff: "exponential", + shouldRetry: (error) => { + const msg = error.message; + return !( + msg.includes("400") || + msg.includes("401") || + msg.includes("Invalid") || + msg.includes("timeout") || + msg.includes("Timeout") + ); + }, +}; + +/** Complete-upload retry: unconditional retry with exponential backoff. */ +export const COMPLETE_UPLOAD_RETRY_POLICY: RetryPolicy = { + maxRetries: 2, + baseDelayMs: 2000, + backoff: "exponential", + // Always retry — complete-upload failures are often transient server-side. +}; + +/** Part-finish standard retry policy. */ +export const PART_FINISH_RETRY_POLICY: RetryPolicy = { + maxRetries: 2, + baseDelayMs: 1000, + backoff: "exponential", +}; + +/** + * Build a persistent retry policy for part-finish with a specific timeout. + * + * @param retryTimeoutMs - Total timeout (defaults to 2 minutes). + * @param retryableCodes - Business error codes that trigger persistent retry. + */ +export function buildPartFinishPersistentPolicy( + retryTimeoutMs?: number, + retryableCodes: Set = PART_FINISH_RETRYABLE_CODES, +): PersistentRetryPolicy { + return { + timeoutMs: retryTimeoutMs ?? 2 * 60 * 1000, + intervalMs: 1000, + shouldPersistRetry: (error) => { + if (retryableCodes.size === 0) { + return false; + } + // Check for ApiError with matching bizCode. + if ("bizCode" in error && typeof (error as { bizCode?: number }).bizCode === "number") { + return retryableCodes.has((error as { bizCode: number }).bizCode); + } + return false; + }, + }; +} + +/** Business error codes that trigger persistent part-finish retry. */ +export const PART_FINISH_RETRYABLE_CODES: Set = new Set([40093001]); + +/** upload_prepare error code indicating daily limit exceeded. */ +export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002; diff --git a/extensions/qqbot/src/engine/api/routes.ts b/extensions/qqbot/src/engine/api/routes.ts new file mode 100644 index 00000000000..6eaac4931f3 --- /dev/null +++ b/extensions/qqbot/src/engine/api/routes.ts @@ -0,0 +1,95 @@ +/** + * Centralized API route templates for the QQ Open Platform. + * + * Eliminates C2C/Group path duplication by parameterizing on `ChatScope`. + * Inspired by `bot-node-sdk/src/openapi/v1/resource.ts`. + */ + +import type { ChatScope } from "../types.js"; + +/** + * Build the message-send path for C2C or Group. + * + * - C2C: `/v2/users/{id}/messages` + * - Group: `/v2/groups/{id}/messages` + */ +export function messagePath(scope: ChatScope, targetId: string): string { + return scope === "c2c" ? `/v2/users/${targetId}/messages` : `/v2/groups/${targetId}/messages`; +} + +/** Channel message path. */ +export function channelMessagePath(channelId: string): string { + return `/channels/${channelId}/messages`; +} + +/** DM (direct message inside a guild) path. */ +export function dmMessagePath(guildId: string): string { + return `/dms/${guildId}/messages`; +} + +/** + * Build the media upload (small-file) path for C2C or Group. + * + * - C2C: `/v2/users/{id}/files` + * - Group: `/v2/groups/{id}/files` + */ +export function mediaUploadPath(scope: ChatScope, targetId: string): string { + return scope === "c2c" ? `/v2/users/${targetId}/files` : `/v2/groups/${targetId}/files`; +} + +/** + * Build the upload_prepare path for C2C or Group. + * + * - C2C: `/v2/users/{id}/upload_prepare` + * - Group: `/v2/groups/{id}/upload_prepare` + */ +export function uploadPreparePath(scope: ChatScope, targetId: string): string { + return scope === "c2c" + ? `/v2/users/${targetId}/upload_prepare` + : `/v2/groups/${targetId}/upload_prepare`; +} + +/** + * Build the upload_part_finish path for C2C or Group. + */ +export function uploadPartFinishPath(scope: ChatScope, targetId: string): string { + return scope === "c2c" + ? `/v2/users/${targetId}/upload_part_finish` + : `/v2/groups/${targetId}/upload_part_finish`; +} + +/** + * Build the complete-upload (files) path for C2C or Group. + * (Same as mediaUploadPath — the complete endpoint reuses the files path.) + */ +export function uploadCompletePath(scope: ChatScope, targetId: string): string { + return mediaUploadPath(scope, targetId); +} + +/** Stream message path (C2C only). */ +export function streamMessagePath(openid: string): string { + return `/v2/users/${openid}/stream_messages`; +} + +/** Gateway URL path. */ +export function gatewayPath(): string { + return "/gateway"; +} + +/** Interaction acknowledgement path. */ +export function interactionPath(interactionId: string): string { + return `/interactions/${interactionId}`; +} + +// ============ Shared Helpers ============ + +/** + * Generate a message sequence number in the 0..65535 range. + * + * Used by both `messages.ts` and `media.ts` to avoid duplicate definitions. + */ +export function getNextMsgSeq(_msgId: string): number { + const timePart = Date.now() % 100_000_000; + const random = Math.floor(Math.random() * 65536); + return (timePart ^ random) % 65536; +} diff --git a/extensions/qqbot/src/engine/api/token.ts b/extensions/qqbot/src/engine/api/token.ts new file mode 100644 index 00000000000..468cda3d46e --- /dev/null +++ b/extensions/qqbot/src/engine/api/token.ts @@ -0,0 +1,271 @@ +/** + * Token management for the QQ Open Platform. + * + * All state (cache, singleflight promises, background refresh controllers) + * is encapsulated in the `TokenManager` class instance — no module-level + * globals, fully supporting multi-account concurrent operation. + */ + +import type { EngineLogger } from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; + +const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; + +interface CachedToken { + token: string; + expiresAt: number; + appId: string; +} + +export interface BackgroundRefreshOptions { + refreshAheadMs?: number; + randomOffsetMs?: number; + minRefreshIntervalMs?: number; + retryDelayMs?: number; +} + +/** + * Per-appId token manager with caching, singleflight, and background refresh. + * + * Usage: + * ```ts + * const tm = new TokenManager({ logger, userAgent: 'QQBotPlugin/1.0' }); + * const token = await tm.getAccessToken('appId', 'secret'); + * ``` + */ +export class TokenManager { + private readonly cache = new Map(); + private readonly fetchPromises = new Map>(); + private readonly refreshControllers = new Map(); + private readonly logger?: EngineLogger; + private readonly resolveUserAgent: () => string; + + constructor(config?: { logger?: EngineLogger; userAgent?: string | (() => string) }) { + this.logger = config?.logger; + const ua = config?.userAgent ?? "QQBotPlugin/unknown"; + this.resolveUserAgent = typeof ua === "function" ? ua : () => ua; + } + + /** + * Obtain an access token with caching and singleflight semantics. + * + * When multiple callers request a token for the same appId concurrently, + * only one actual HTTP request is made — the others await the same promise. + */ + async getAccessToken(appId: string, clientSecret: string): Promise { + const normalizedId = appId.trim(); + const cached = this.cache.get(normalizedId); + + // Refresh slightly before expiry without making short-lived tokens unusable. + const refreshAheadMs = cached + ? Math.min(5 * 60 * 1000, (cached.expiresAt - Date.now()) / 3) + : 0; + + if (cached && Date.now() < cached.expiresAt - refreshAheadMs) { + return cached.token; + } + + // Singleflight: reuse an in-progress fetch. + let pending = this.fetchPromises.get(normalizedId); + if (pending) { + this.logger?.debug?.(`[qqbot:token:${normalizedId}] Fetch in progress, reusing promise`); + return pending; + } + + pending = (async () => { + try { + return await this.doFetchToken(normalizedId, clientSecret); + } finally { + this.fetchPromises.delete(normalizedId); + } + })(); + + this.fetchPromises.set(normalizedId, pending); + return pending; + } + + /** Clear the cached token for one appId, or all. */ + clearCache(appId?: string): void { + if (appId) { + this.cache.delete(appId.trim()); + this.logger?.debug?.(`[qqbot:token:${appId}] Cache cleared`); + } else { + this.cache.clear(); + this.logger?.debug?.(`[token] All caches cleared`); + } + } + + /** Return token status for diagnostics. */ + getStatus(appId: string): { + status: "valid" | "expired" | "refreshing" | "none"; + expiresAt: number | null; + } { + if (this.fetchPromises.has(appId)) { + return { status: "refreshing", expiresAt: this.cache.get(appId)?.expiresAt ?? null }; + } + const cached = this.cache.get(appId); + if (!cached) { + return { status: "none", expiresAt: null }; + } + const remaining = cached.expiresAt - Date.now(); + const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3); + return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt }; + } + + /** Start a background token refresh loop for one appId. */ + startBackgroundRefresh( + appId: string, + clientSecret: string, + options?: BackgroundRefreshOptions, + ): void { + if (this.refreshControllers.has(appId)) { + this.logger?.info?.(`[qqbot:token:${appId}] Background refresh already running`); + return; + } + + const { + refreshAheadMs = 5 * 60 * 1000, + randomOffsetMs = 30 * 1000, + minRefreshIntervalMs = 60 * 1000, + retryDelayMs = 5 * 1000, + } = options ?? {}; + + const controller = new AbortController(); + this.refreshControllers.set(appId, controller); + const { signal } = controller; + + const loop = async () => { + this.logger?.info?.(`[qqbot:token:${appId}] Background refresh started`); + + while (!signal.aborted) { + try { + await this.getAccessToken(appId, clientSecret); + const cached = this.cache.get(appId); + + if (cached) { + const expiresIn = cached.expiresAt - Date.now(); + const randomOffset = Math.random() * randomOffsetMs; + const refreshIn = Math.max( + expiresIn - refreshAheadMs - randomOffset, + minRefreshIntervalMs, + ); + this.logger?.debug?.( + `[qqbot:token:${appId}] Next refresh in ${Math.round(refreshIn / 1000)}s`, + ); + await this.abortableSleep(refreshIn, signal); + } else { + await this.abortableSleep(minRefreshIntervalMs, signal); + } + } catch (err) { + if (signal.aborted) { + break; + } + this.logger?.error?.( + `[qqbot:token:${appId}] Background refresh failed: ${formatErrorMessage(err)}`, + ); + await this.abortableSleep(retryDelayMs, signal); + } + } + + this.refreshControllers.delete(appId); + this.logger?.info?.(`[qqbot:token:${appId}] Background refresh stopped`); + }; + + loop().catch((err) => { + this.refreshControllers.delete(appId); + this.logger?.error?.(`[qqbot:token:${appId}] Background refresh crashed: ${err}`); + }); + } + + /** Stop background refresh for one appId, or all. */ + stopBackgroundRefresh(appId?: string): void { + if (appId) { + const ctrl = this.refreshControllers.get(appId); + if (ctrl) { + ctrl.abort(); + this.refreshControllers.delete(appId); + } + } else { + for (const ctrl of this.refreshControllers.values()) { + ctrl.abort(); + } + this.refreshControllers.clear(); + } + } + + /** Check whether background refresh is running. */ + isBackgroundRefreshRunning(appId?: string): boolean { + if (appId) { + return this.refreshControllers.has(appId); + } + return this.refreshControllers.size > 0; + } + + // ---- Internal ---- + + private async doFetchToken(appId: string, clientSecret: string): Promise { + this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`); + + let response: Response; + try { + response = await fetch(TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": this.resolveUserAgent(), + }, + body: JSON.stringify({ appId, clientSecret }), + }); + } catch (err) { + this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`); + throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, { + cause: err, + }); + } + + const traceId = response.headers.get("x-tps-trace-id") ?? ""; + this.logger?.debug?.( + `[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`, + ); + + let data: { access_token?: string; expires_in?: number }; + try { + const rawBody = await response.text(); + const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"'); + this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`); + data = JSON.parse(rawBody); + } catch (err) { + throw new Error(`Failed to parse access_token response: ${formatErrorMessage(err)}`, { + cause: err, + }); + } + + if (!data.access_token) { + throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`); + } + + const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000; + this.cache.set(appId, { token: data.access_token, expiresAt, appId }); + this.logger?.debug?.( + `[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`, + ); + + return data.access_token; + } + + private abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + if (signal.aborted) { + clearTimeout(timer); + reject(new Error("Aborted")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(new Error("Aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } +} diff --git a/extensions/qqbot/src/engine/approval/index.test.ts b/extensions/qqbot/src/engine/approval/index.test.ts new file mode 100644 index 00000000000..9b6ab01c8b4 --- /dev/null +++ b/extensions/qqbot/src/engine/approval/index.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { buildApprovalKeyboard } from "./index.js"; + +describe("buildApprovalKeyboard", () => { + it("omits allow-always when the decision is unavailable", () => { + const keyboard = buildApprovalKeyboard("approval-123", ["allow-once", "deny"]); + const buttons = keyboard.content.rows[0]?.buttons ?? []; + + expect(buttons.map((button) => button.id)).toEqual(["allow", "deny"]); + expect(buttons.map((button) => button.action.data)).toEqual([ + "approve:approval-123:allow-once", + "approve:approval-123:deny", + ]); + }); + + it("keeps all buttons when all decisions are allowed", () => { + const keyboard = buildApprovalKeyboard("approval-123", ["allow-once", "allow-always", "deny"]); + const buttons = keyboard.content.rows[0]?.buttons ?? []; + + expect(buttons.map((button) => button.id)).toEqual(["allow", "always", "deny"]); + }); +}); diff --git a/extensions/qqbot/src/engine/approval/index.ts b/extensions/qqbot/src/engine/approval/index.ts new file mode 100644 index 00000000000..de7ba594101 --- /dev/null +++ b/extensions/qqbot/src/engine/approval/index.ts @@ -0,0 +1,238 @@ +/** + * Approval helpers — pure functions, zero framework dependencies. + * + * - Build approval message text + inline keyboard + * - Resolve delivery target from session metadata + * - Parse INTERACTION_CREATE button data + */ + +import type { ChatScope, InlineKeyboard, KeyboardButton } from "../types.js"; + +// ============ Types ============ + +export interface ExecApprovalRequest { + id: string; + expiresAtMs: number; + request: { + commandPreview?: string; + command?: string; + cwd?: string; + agentId?: string; + turnSourceAccountId?: string; + sessionKey?: string; + turnSourceTo?: string; + [key: string]: unknown; + }; +} + +export interface PluginApprovalRequest { + id: string; + request: { + timeoutMs?: number; + severity?: string; + title: string; + description?: string; + toolName?: string; + pluginId?: string; + agentId?: string; + turnSourceAccountId?: string; + sessionKey?: string; + turnSourceTo?: string; + [key: string]: unknown; + }; +} + +export interface ExecApprovalResolved { + id: string; + decision: string; + resolvedBy?: string; + [key: string]: unknown; +} + +export interface PluginApprovalResolved { + id: string; + decision: string; + resolvedBy?: string; + [key: string]: unknown; +} + +export type ApprovalDecision = "allow-once" | "allow-always" | "deny"; + +export interface ApprovalTarget { + type: ChatScope; + id: string; +} + +export interface ParsedApprovalAction { + approvalId: string; + decision: ApprovalDecision; +} + +// ============ Text Builders ============ + +export function buildExecApprovalText(request: ExecApprovalRequest): string { + const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000)); + const lines: string[] = ["\u{1f510} \u547d\u4ee4\u6267\u884c\u5ba1\u6279", ""]; + const cmd = request.request.commandPreview ?? request.request.command ?? ""; + if (cmd) { + lines.push(`\`\`\`\n${cmd.slice(0, 300)}\n\`\`\``); + } + if (request.request.cwd) { + lines.push(`\u{1f4c1} \u76ee\u5f55: ${request.request.cwd}`); + } + if (request.request.agentId) { + lines.push(`\u{1f916} Agent: ${request.request.agentId}`); + } + lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${expiresIn} \u79d2`); + return lines.join("\n"); +} + +export function buildPluginApprovalText(request: PluginApprovalRequest): string { + const timeoutSec = Math.round((request.request.timeoutMs ?? 120_000) / 1000); + const severityIcon = + request.request.severity === "critical" + ? "\u{1f534}" + : request.request.severity === "info" + ? "\u{1f535}" + : "\u{1f7e1}"; + + const lines: string[] = [`${severityIcon} \u5ba1\u6279\u8bf7\u6c42`, ""]; + lines.push(`\u{1f4cb} ${request.request.title}`); + if (request.request.description) { + lines.push(`\u{1f4dd} ${request.request.description}`); + } + if (request.request.toolName) { + lines.push(`\u{1f527} \u5de5\u5177: ${request.request.toolName}`); + } + if (request.request.pluginId) { + lines.push(`\u{1f50c} \u63d2\u4ef6: ${request.request.pluginId}`); + } + if (request.request.agentId) { + lines.push(`\u{1f916} Agent: ${request.request.agentId}`); + } + lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${timeoutSec} \u79d2`); + return lines.join("\n"); +} + +// ============ Keyboard Builder ============ + +/** + * Build the three-button inline keyboard for approval messages. + * + * type=1 (Callback): click triggers INTERACTION_CREATE, button_data = data field. + * group_id "approval": clicking one button grays out the others (mutual exclusion). + * click_limit=1: each user can only click once. + * permission.type=2: all users can interact. + */ +export function buildApprovalKeyboard( + approvalId: string, + allowedDecisions: readonly ApprovalDecision[] = ["allow-once", "allow-always", "deny"], +): InlineKeyboard { + const makeBtn = ( + id: string, + label: string, + visitedLabel: string, + data: string, + style: 0 | 1, + ): KeyboardButton => ({ + id, + render_data: { label, visited_label: visitedLabel, style }, + action: { + type: 1, + data, + permission: { type: 2 }, + click_limit: 1, + }, + group_id: "approval", + }); + + const buttons: KeyboardButton[] = []; + if (allowedDecisions.includes("allow-once")) { + buttons.push( + makeBtn( + "allow", + "\u2705 \u5141\u8bb8\u4e00\u6b21", + "\u5df2\u5141\u8bb8", + `approve:${approvalId}:allow-once`, + 1, + ), + ); + } + if (allowedDecisions.includes("allow-always")) { + buttons.push( + makeBtn( + "always", + "\u2b50 \u59cb\u7ec8\u5141\u8bb8", + "\u5df2\u59cb\u7ec8\u5141\u8bb8", + `approve:${approvalId}:allow-always`, + 1, + ), + ); + } + if (allowedDecisions.includes("deny")) { + buttons.push( + makeBtn("deny", "\u274c \u62d2\u7edd", "\u5df2\u62d2\u7edd", `approve:${approvalId}:deny`, 0), + ); + } + + return { + content: { + rows: [ + { + buttons, + }, + ], + }, + }; +} + +// ============ Target Resolver ============ + +/** + * Extract the delivery target from a sessionKey or turnSourceTo string. + * + * Expected formats: + * agent:main:qqbot:direct:OPENID -> { type: "c2c", id: "OPENID" } + * agent:main:qqbot:c2c:OPENID -> { type: "c2c", id: "OPENID" } + * agent:main:qqbot:group:GROUPID -> { type: "group", id: "GROUPID" } + * + * Returns null if neither field matches the expected pattern. + */ +export function resolveApprovalTarget( + sessionKey: string | null | undefined, + turnSourceTo: string | null | undefined, +): ApprovalTarget | null { + const sk = sessionKey ?? turnSourceTo; + if (!sk) { + return null; + } + const m = sk.match(/qqbot:(c2c|direct|group):([A-F0-9]+)/i); + if (!m) { + return null; + } + const type: ChatScope = m[1].toLowerCase() === "group" ? "group" : "c2c"; + return { type, id: m[2] }; +} + +// ============ Interaction Parser ============ + +/** + * Parse the button_data string from an INTERACTION_CREATE event. + * + * Expected format: `approve::` + * where approvalId may be prefixed with "exec:" or "plugin:". + * + * Returns null if the data does not match the approval button format. + */ +export function parseApprovalButtonData(buttonData: string): ParsedApprovalAction | null { + const m = buttonData.match( + /^approve:((?:(?:exec|plugin):)?[0-9a-f-]+):(allow-once|allow-always|deny)$/i, + ); + if (!m) { + return null; + } + return { + approvalId: m[1], + decision: m[2] as ApprovalDecision, + }; +} diff --git a/extensions/qqbot/src/engine/commands/slash-command-handler.ts b/extensions/qqbot/src/engine/commands/slash-command-handler.ts new file mode 100644 index 00000000000..19b4a2831da --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-command-handler.ts @@ -0,0 +1,139 @@ +/** + * Slash command handler — intercept slash commands before message queue. + * + * Extracted from gateway.ts to keep the gateway connection logic thin. + * Handles urgent commands, normal slash commands, and file delivery. + */ + +import type { QueuedMessage } from "../gateway/message-queue.js"; +import type { GatewayAccount, EngineLogger } from "../gateway/types.js"; +import { sendDocument } from "../messaging/outbound.js"; +import { + sendText as senderSendText, + buildDeliveryTarget, + accountToCreds, +} from "../messaging/sender.js"; +import { matchSlashCommand } from "./slash-commands-impl.js"; +import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js"; + +// ============ Types ============ + +export interface SlashCommandHandlerContext { + account: GatewayAccount; + log?: EngineLogger; + getMessagePeerId: (msg: QueuedMessage) => string; + getQueueSnapshot: (peerId: string) => QueueSnapshot; +} + +// ============ Constants ============ + +const URGENT_COMMANDS = ["/stop"]; + +// ============ trySlashCommandOrEnqueue ============ + +/** + * Check if the message is a slash command and handle it. + * + * @returns `true` if handled (command executed or enqueued as urgent), + * `false` if the message should be queued for normal processing. + */ +export async function trySlashCommand( + msg: QueuedMessage, + ctx: SlashCommandHandlerContext, +): Promise<"handled" | "urgent" | "enqueue"> { + const { account, log } = ctx; + const content = (msg.content ?? "").trim(); + + if (!content.startsWith("/")) { + return "enqueue"; + } + + // Urgent command detection — bypass queue and execute immediately. + const contentLower = content.toLowerCase(); + const isUrgentCommand = URGENT_COMMANDS.some( + (cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "), + ); + if (isUrgentCommand) { + log?.info(`Urgent command detected: ${content.slice(0, 20)}`); + return "urgent"; + } + + // Normal slash command — try to match and execute. + const receivedAt = Date.now(); + const peerId = ctx.getMessagePeerId(msg); + const cmdCtx: SlashCommandContext = { + type: msg.type, + senderId: msg.senderId, + senderName: msg.senderName, + messageId: msg.messageId, + eventTimestamp: msg.timestamp, + receivedAt, + rawContent: content, + args: "", + channelId: msg.channelId, + groupOpenid: msg.groupOpenid, + accountId: account.accountId, + appId: account.appId, + accountConfig: account.config, + commandAuthorized: true, + queueSnapshot: ctx.getQueueSnapshot(peerId), + }; + + try { + const reply = await matchSlashCommand(cmdCtx); + if (reply === null) { + return "enqueue"; + } + + log?.debug?.(`Slash command matched: ${content}`); + + const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply; + const replyText = isFileResult ? (reply as { text: string }).text : reply; + const replyFile = isFileResult ? (reply as { filePath: string }).filePath : null; + + // Send text reply. + if (msg.type === "c2c" || msg.type === "group" || msg.type === "dm" || msg.type === "guild") { + const slashTarget = buildDeliveryTarget(msg); + const slashCreds = accountToCreds(account); + await senderSendText(slashTarget, replyText, slashCreds, { msgId: msg.messageId }); + } + + // Send file attachment if present. + if (replyFile) { + try { + const targetType = + msg.type === "group" + ? "group" + : msg.type === "dm" + ? "dm" + : msg.type === "c2c" + ? "c2c" + : "channel"; + const targetId = + msg.type === "group" + ? msg.groupOpenid || msg.senderId + : msg.type === "dm" + ? msg.guildId || msg.senderId + : msg.type === "c2c" + ? msg.senderId + : msg.channelId || msg.senderId; + await sendDocument( + { + targetType, + targetId, + account, + replyToId: msg.messageId, + }, + replyFile, + ); + } catch (fileErr) { + log?.error(`Failed to send slash command file: ${String(fileErr)}`); + } + } + + return "handled"; + } catch (err) { + log?.error(`Slash command error: ${String(err)}`); + return "enqueue"; + } +} diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts new file mode 100644 index 00000000000..055583ad490 --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts @@ -0,0 +1,987 @@ +/** + * QQBot plugin-level slash command handler. + * + * Type definitions and the command registry/dispatcher are in + * core/gateway/slash-commands.ts. This file contains the concrete + * built-in command implementations that depend on framework SDK. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { debugLog } from "../utils/log.js"; +import { getHomeDir, getQQBotDataDir, isWindows } from "../utils/platform.js"; +import { + SlashCommandRegistry, + type SlashCommandContext, + type SlashCommandResult, + type SlashCommandFileResult, + type QQBotFrameworkCommand, + type QueueSnapshot, +} from "./slash-commands.js"; + +// ---- Injected dependency ---- + +/** Resolve the framework runtime version — injected to avoid plugin-sdk dependency. */ +let _resolveVersion: (() => string) | null = null; + +/** Register the version resolver — called by the outer layer. */ +export function registerVersionResolver(fn: () => string): void { + _resolveVersion = fn; +} + +function resolveRuntimeServiceVersion(): string { + return _resolveVersion?.() ?? "unknown"; +} + +// Re-export core types for backward compatibility. +export type { + SlashCommandContext, + SlashCommandResult, + SlashCommandFileResult, + QQBotFrameworkCommand, + QueueSnapshot, +} from "./slash-commands.js"; + +// Plugin version — injected by the outer layer via registerPluginVersion(). +let PLUGIN_VERSION = "unknown"; + +/** Register the plugin version — called by the outer layer. */ +export function registerPluginVersion(version: string): void { + if (version) { + PLUGIN_VERSION = version; + } +} + +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"; + +// ============ Module-level registry instance ============ + +const registry = new SlashCommandRegistry(); + +function registerCommand(cmd: { + name: string; + description: string; + usage?: string; + requireAuth?: boolean; + handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; +}): void { + registry.register(cmd); +} + +/** + * Return all commands that require authorization, for registration with the + * framework via api.registerCommand() in registerFull(). + */ +export function getFrameworkCommands(): QQBotFrameworkCommand[] { + return registry.getFrameworkCommands(); +} + +// ============ 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 both the QQBot plugin version and the OpenClaw + * framework version. Aligned with the standalone `openclaw-qqbot` + * build so users see the same identification regardless of which + * distribution they run. + * + * Note: unlike the standalone build, the built-in plugin is released + * in-tree with the OpenClaw framework (same version), so an online + * npm dist-tag check is not applicable here and is intentionally + * omitted. + */ +registerCommand({ + name: "bot-version", + description: "查看 QQBot 插件版本和 OpenClaw 框架版本", + usage: [`/bot-version`, ``, `查看当前 QQBot 插件版本和 OpenClaw 框架版本。`].join("\n"), + handler: async () => { + const frameworkVersion = resolveRuntimeServiceVersion(); + const lines = [ + `🦞 OpenClaw 框架版本:${frameworkVersion}`, + `🤖 QQBot 插件版本:v${PLUGIN_VERSION}`, + `🌟 官方 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: (ctx) => { + // Exclude c2c-only commands from group listings. + const GROUP_EXCLUDED = new Set(["bot-upgrade", "bot-clear-storage"]); + const isGroup = ctx.type === "group"; + + const lines = [`### QQBot 内置命令`, ``]; + for (const [name, cmd] of registry.getAllCommands()) { + if (isGroup && GROUP_EXCLUDED.has(name)) { + continue; + } + lines.push(` ${cmd.description}`); + } + lines.push(``, `> 插件版本 v${PLUGIN_VERSION}`); + 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(); + + 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([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(); + 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(); + + 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(); + }, +}); + +// ============ /bot-clear-storage ============ + +/** Recursively scan all files under a directory, sorted by size descending. */ +function scanDirectoryFiles(dirPath: string): { filePath: string; size: number }[] { + const files: { filePath: string; size: number }[] = []; + if (!fs.existsSync(dirPath)) { + return files; + } + const walk = (dir: string) => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile()) { + try { + const stat = fs.statSync(fullPath); + files.push({ filePath: fullPath, size: stat.size }); + } catch { + // Skip inaccessible files. + } + } + } + }; + walk(dirPath); + files.sort((a, b) => b.size - a.size); + return files; +} + +/** Format byte count into a human-readable string. */ +function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** Recursively remove empty directories (leaf-to-root). */ +function removeEmptyDirs(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + return; + } + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + removeEmptyDirs(path.join(dirPath, entry.name)); + } + } + try { + const remaining = fs.readdirSync(dirPath); + if (remaining.length === 0) { + fs.rmdirSync(dirPath); + } + } catch { + // Directory may be in use, skip. + } +} + +/** Maximum number of files to display in the scan preview. */ +const CLEAR_STORAGE_MAX_DISPLAY = 10; + +registerCommand({ + name: "bot-clear-storage", + description: "清理通过 QQBot 对话产生的下载文件,释放主机磁盘空间", + usage: [ + `/bot-clear-storage`, + ``, + `扫描当前机器人产生的下载文件并列出明细。`, + `确认后执行删除,释放主机磁盘空间。`, + ``, + `/bot-clear-storage --force 确认执行清理`, + ``, + `⚠️ 仅在私聊中可用。`, + ].join("\n"), + handler: (ctx) => { + const { appId, type } = ctx; + + if (type !== "c2c") { + return `💡 请在私聊中使用此指令`; + } + + const isForce = ctx.args.trim() === "--force"; + const targetDir = path.join(getHomeDir(), ".openclaw", "media", "qqbot", "downloads", appId); + const displayDir = `~/.openclaw/media/qqbot/downloads/${appId}`; + + if (!isForce) { + // Step 1: scan and display file list with a confirmation button. + const files = scanDirectoryFiles(targetDir); + + if (files.length === 0) { + return [`✅ 当前没有需要清理的文件`, ``, `目录 \`${displayDir}\` 为空或不存在。`].join( + "\n", + ); + } + + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + const lines: string[] = [ + `即将清理 \`${displayDir}\` 目录下所有文件,总共 ${files.length} 个文件,占用磁盘存储空间 ${formatBytes(totalSize)}。`, + ``, + `目录文件概况:`, + ]; + + const displayFiles = files.slice(0, CLEAR_STORAGE_MAX_DISPLAY); + for (const f of displayFiles) { + const relativePath = path.relative(targetDir, f.filePath).replace(/\\/g, "/"); + lines.push(`${relativePath} (${formatBytes(f.size)})`, ``, ``); + } + if (files.length > CLEAR_STORAGE_MAX_DISPLAY) { + lines.push(`...[合计:${files.length} 个文件(${formatBytes(totalSize)})]`, ``); + } + + lines.push( + ``, + `---`, + ``, + `确认清理后,上述保存在 OpenClaw 运行主机磁盘上的文件将永久删除,后续对话过程中 AI 无法再找回相关文件。`, + `‼️ 点击指令确认删除`, + ``, + ); + + return lines.join("\n"); + } + + // Step 2: --force — execute deletion. + const files = scanDirectoryFiles(targetDir); + + if (files.length === 0) { + return `✅ 目录已为空,无需清理`; + } + + let deletedCount = 0; + let deletedSize = 0; + let failedCount = 0; + + for (const f of files) { + try { + fs.unlinkSync(f.filePath); + deletedCount++; + deletedSize += f.size; + } catch { + failedCount++; + } + } + + try { + removeEmptyDirs(targetDir); + } catch { + // Non-critical, silently ignore. + } + + if (failedCount === 0) { + return [ + `✅ 清理成功`, + ``, + `已删除 ${deletedCount} 个文件,释放 ${formatBytes(deletedSize)} 磁盘空间。`, + ].join("\n"); + } + + return [ + `⚠️ 部分清理完成`, + ``, + `已删除 ${deletedCount} 个文件(${formatBytes(deletedSize)}),${failedCount} 个文件删除失败。`, + ].join("\n"); + }, +}); + +// ============ /bot-approve 审批配置管理 ============ + +/** Injected runtime getter — set by the outer bootstrap layer. */ +let _runtimeGetter: + | (() => { + config: { + loadConfig: () => Record; + writeConfigFile: (cfg: unknown) => Promise; + }; + }) + | null = null; + +/** Register the runtime getter — called by the outer layer during startup. */ +export function registerApproveRuntimeGetter( + getter: () => { + config: { + loadConfig: () => Record; + writeConfigFile: (cfg: unknown) => Promise; + }; + }, +): void { + _runtimeGetter = getter; +} + +/** + * /bot-approve — 管理命令执行审批配置 + * + * 修改 openclaw.json 中 tools.exec.security / tools.exec.ask 字段。 + * + * security: deny | allowlist | full + * ask: off | on-miss | always + */ +registerCommand({ + name: "bot-approve", + description: "管理命令执行审批配置", + usage: [ + `/bot-approve 查看操作指引`, + `/bot-approve on 开启审批(白名单模式,推荐)`, + `/bot-approve off 关闭审批,命令直接执行`, + `/bot-approve always 始终审批,每次执行都需审批`, + `/bot-approve reset 恢复框架默认值`, + `/bot-approve status 查看当前审批配置`, + ].join("\n"), + handler: async (ctx) => { + const arg = ctx.args.trim().toLowerCase(); + + let runtime: ReturnType>; + try { + if (!_runtimeGetter) { + throw new Error("runtime not available"); + } + runtime = _runtimeGetter(); + } catch { + // runtime 不可用时返回操作指引 + return [ + `🔐 命令执行审批配置`, + ``, + `❌ 当前环境不支持在线配置修改,请通过 CLI 手动配置:`, + ``, + `\`\`\`shell`, + `# 开启审批(白名单模式)`, + `openclaw config set tools.exec.security allowlist`, + `openclaw config set tools.exec.ask on-miss`, + ``, + `# 关闭审批`, + `openclaw config set tools.exec.security full`, + `openclaw config set tools.exec.ask off`, + `\`\`\``, + ].join("\n"); + } + + const configApi = runtime.config; + + const loadExecConfig = () => { + const cfg = configApi.loadConfig(); + const tools = (cfg.tools ?? {}) as Record; + const exec = (tools.exec ?? {}) as Record; + const security = typeof exec.security === "string" ? exec.security : "deny"; + const ask = typeof exec.ask === "string" ? exec.ask : "on-miss"; + return { security, ask }; + }; + + const writeExecConfig = async (security: string, ask: string) => { + const cfg = structuredClone(configApi.loadConfig()); + const tools = (cfg.tools ?? {}) as Record; + const exec = (tools.exec ?? {}) as Record; + exec.security = security; + exec.ask = ask; + tools.exec = exec; + cfg.tools = tools; + await configApi.writeConfigFile(cfg); + }; + + const formatStatus = (security: string, ask: string) => { + const secIcon = security === "full" ? "🟢" : security === "allowlist" ? "🟡" : "🔴"; + const askIcon = ask === "off" ? "🟢" : ask === "always" ? "🔴" : "🟡"; + return [ + `🔐 当前审批配置`, + ``, + `${secIcon} 安全模式 (security): **${security}**`, + `${askIcon} 审批模式 (ask): **${ask}**`, + ``, + security === "deny" + ? `⚠️ 当前为 deny 模式,所有命令执行被拒绝` + : security === "full" && ask === "off" + ? `✅ 所有命令无需审批直接执行` + : security === "allowlist" && ask === "on-miss" + ? `🛡️ 白名单命令直接执行,其余需审批` + : ask === "always" + ? `🔒 每次命令执行都需要人工审批` + : `ℹ️ security=${security}, ask=${ask}`, + ].join("\n"); + }; + + // 无参数:操作指引 + if (!arg) { + return [ + `🔐 命令执行审批配置`, + ``, + ` 开启审批(白名单模式)`, + ` 关闭审批`, + ` 严格模式`, + ` 恢复默认`, + ` 查看当前配置`, + ].join("\n"); + } + + // status: 查看当前配置 + if (arg === "status") { + const { security, ask } = loadExecConfig(); + return [ + formatStatus(security, ask), + ``, + ` 开启审批`, + ` 关闭审批`, + ` 严格模式`, + ` 恢复默认`, + ].join("\n"); + } + + // on: 开启审批(白名单 + 未命中审批) + if (arg === "on") { + try { + await writeExecConfig("allowlist", "on-miss"); + return [ + `✅ 审批已开启`, + ``, + `• security = allowlist(白名单模式)`, + `• ask = on-miss(未命中白名单时需审批)`, + ``, + `已批准的命令自动加入白名单,下次直接执行。`, + ].join("\n"); + } catch (err: unknown) { + return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; + } + } + + // off: 关闭审批 + if (arg === "off") { + try { + await writeExecConfig("full", "off"); + return [ + `✅ 审批已关闭`, + ``, + `• security = full(允许所有命令)`, + `• ask = off(不需要审批)`, + ``, + `⚠️ 所有命令将直接执行,不会弹出审批确认。`, + ].join("\n"); + } catch (err: unknown) { + return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; + } + } + + // always: 始终审批 + if (arg === "always" || arg === "strict") { + try { + await writeExecConfig("allowlist", "always"); + return [ + `✅ 已切换为严格审批模式`, + ``, + `• security = allowlist`, + `• ask = always(每次执行都需审批)`, + ``, + `每个命令都会弹出审批按钮,需手动确认。`, + ].join("\n"); + } catch (err: unknown) { + return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; + } + } + + // reset: 删除配置,恢复框架默认值 + if (arg === "reset") { + try { + const cfg = structuredClone(configApi.loadConfig()); + const tools = (cfg.tools ?? {}) as Record; + const exec = (tools.exec ?? {}) as Record; + delete exec.security; + delete exec.ask; + if (Object.keys(exec).length === 0) { + delete tools.exec; + } else { + tools.exec = exec; + } + if (Object.keys(tools).length === 0) { + delete cfg.tools; + } else { + cfg.tools = tools; + } + await configApi.writeConfigFile(cfg); + return [ + `✅ 审批配置已重置`, + ``, + `已移除 tools.exec.security 和 tools.exec.ask`, + `框架将使用默认值(security=deny, ask=on-miss)`, + ``, + `如需开启命令执行,请使用 /bot-approve on`, + ].join("\n"); + } catch (err: unknown) { + return `❌ 配置更新失败: ${err instanceof Error ? err.message : String(err)}`; + } + } + + return [ + `❌ 未知参数: ${arg}`, + ``, + `可用选项: on | off | always | reset | status`, + `输入 /bot-approve ? 查看详细用法`, + ].join("\n"); + }, +}); + +// Slash command entry point — delegates to core/ registry. + +/** + * Try to match and execute a plugin-level slash command. + * + * @returns A reply when matched, or null when the message should continue through normal routing. + */ +export async function matchSlashCommand(ctx: SlashCommandContext): Promise { + return registry.matchSlashCommand(ctx, { info: debugLog }); +} + +/** Return the plugin version for external callers. */ +export function getPluginVersion(): string { + return PLUGIN_VERSION; +} + +// Utility used by /bot-logs command. +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?: Record): 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 !== "*"; + }); +} diff --git a/extensions/qqbot/src/engine/commands/slash-commands.ts b/extensions/qqbot/src/engine/commands/slash-commands.ts new file mode 100644 index 00000000000..022ca25929c --- /dev/null +++ b/extensions/qqbot/src/engine/commands/slash-commands.ts @@ -0,0 +1,186 @@ +/** + * Slash command registration and dispatch framework. + * + * This module provides the type definitions, command registry, and + * `matchSlashCommand` dispatcher that both plugin versions share. + * + * Concrete command implementations (e.g. `/bot-ping`, `/bot-logs`) are + * registered by the upper-layer bootstrap code, NOT defined here. + * + * Zero external dependencies. + */ + +// ============ Types ============ + +/** Slash command context (message metadata plus runtime state). */ +export interface SlashCommandContext { + /** Message type. */ + type: "c2c" | "guild" | "dm" | "group"; + /** Sender ID. */ + senderId: string; + /** Sender display name. */ + senderName?: string; + /** Message ID used for passive replies. */ + messageId: string; + /** Event timestamp from QQ as an ISO string. */ + eventTimestamp: string; + /** Local receipt timestamp in milliseconds. */ + receivedAt: number; + /** Raw message content. */ + rawContent: string; + /** Command arguments after stripping the command name. */ + args: string; + /** Channel ID for guild messages. */ + channelId?: string; + /** Group openid for group messages. */ + groupOpenid?: string; + /** Account ID. */ + accountId: string; + /** Bot App ID. */ + appId: string; + /** Account config available to the command handler. */ + accountConfig?: Record; + /** 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 { + totalPending: number; + activeUsers: number; + maxConcurrentUsers: number; + senderPending: number; +} + +/** Slash command result: text, a text+file result, or null to skip handling. */ +export type SlashCommandResult = string | SlashCommandFileResult | null; + +/** Slash command result that sends text first and then a local file. */ +export interface SlashCommandFileResult { + text: string; + /** Local file path to send. */ + filePath: string; +} + +/** Slash command definition. */ +export 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; +} + +/** Framework command definition for commands that require authorization. */ +export interface QQBotFrameworkCommand { + name: string; + description: string; + usage?: string; + handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; +} + +// ============ Command Registry ============ + +/** Lowercase and trim a string. */ +function lc(s: string): string { + return (s ?? "").toLowerCase().trim(); +} + +/** + * Slash command registry. + * + * Maintains two maps: + * - `commands` — pre-dispatch commands (requireAuth: false) + * - `frameworkCommands` — auth-gated commands (requireAuth: true) + */ +export class SlashCommandRegistry { + private readonly commands = new Map(); + private readonly frameworkCommands = new Map(); + + /** Register one command. */ + register(cmd: SlashCommand): void { + if (cmd.requireAuth) { + this.frameworkCommands.set(lc(cmd.name), cmd); + } else { + this.commands.set(lc(cmd.name), cmd); + } + } + + /** Return all auth-gated commands for framework registration. */ + getFrameworkCommands(): QQBotFrameworkCommand[] { + return Array.from(this.frameworkCommands.values()).map((cmd) => ({ + name: cmd.name, + description: cmd.description, + usage: cmd.usage, + handler: cmd.handler, + })); + } + + /** Return all pre-dispatch commands. */ + getPreDispatchCommands(): Map { + return this.commands; + } + + /** Return all registered commands (both maps) for help listing. */ + getAllCommands(): Map { + const all = new Map(); + for (const [k, v] of this.commands) { + all.set(k, v); + } + for (const [k, v] of this.frameworkCommands) { + all.set(k, v); + } + return all; + } + + /** + * Try to match and execute a pre-dispatch slash command. + * + * @returns A reply when matched, or null when the message should continue + * through normal routing. + */ + async matchSlashCommand( + ctx: SlashCommandContext, + log?: { info?: (msg: string) => void }, + ): Promise { + const content = ctx.rawContent.trim(); + if (!content.startsWith("/")) { + return null; + } + + const spaceIdx = content.indexOf(" "); + const cmdName = lc(spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)); + const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim(); + + const cmd = this.commands.get(cmdName); + if (!cmd) { + return null; + } + + // Gate sensitive commands behind the allowFrom authorization check. + if (cmd.requireAuth && !ctx.commandAuthorized) { + log?.info?.( + `[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`, + ); + 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; + return await cmd.handler(ctx); + } +} diff --git a/extensions/qqbot/src/engine/config/allow-from.ts b/extensions/qqbot/src/engine/config/allow-from.ts new file mode 100644 index 00000000000..79af4c72bda --- /dev/null +++ b/extensions/qqbot/src/engine/config/allow-from.ts @@ -0,0 +1,27 @@ +/** + * AllowFrom normalization — zero external dependency version. + * + * Extracted from channel-config-shared.ts. The original used + * `normalizeStringifiedOptionalString` from plugin-sdk, which is + * just `String(x).trim()` for non-null primitives. + */ + +/** Normalize a config entry to a trimmed string (empty string for null/undefined). */ +function normalizeEntry(entry: unknown): string { + if (entry === null || entry === undefined) { + return ""; + } + if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") { + return String(entry).trim(); + } + return ""; +} + +/** Normalize allowFrom entries: strip `qqbot:` prefix, uppercase. */ +export function formatAllowFrom(params: { allowFrom: unknown[] | undefined | null }): string[] { + return (params.allowFrom ?? []) + .map((entry) => normalizeEntry(entry)) + .filter((entry): entry is string => entry.length > 0) + .map((entry) => entry.replace(/^qqbot:/i, "")) + .map((entry) => entry.toUpperCase()); +} diff --git a/extensions/qqbot/src/engine/config/credential-backup.test.ts b/extensions/qqbot/src/engine/config/credential-backup.test.ts new file mode 100644 index 00000000000..49baa8765ba --- /dev/null +++ b/extensions/qqbot/src/engine/config/credential-backup.test.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js"; +import { loadCredentialBackup, saveCredentialBackup } from "./credential-backup.js"; + +/** + * These tests write to `~/.openclaw/qqbot/data` under a test-specific + * accountId prefix and clean up after themselves. Mirrors the approach + * used by `platform.test.ts` in the same package. + */ +describe("engine/config/credential-backup", () => { + const acct = `test-cb-${process.pid}-${Date.now()}`; + const legacyPath = getLegacyCredentialBackupFile(); + let legacyBackup: string | null = null; + + beforeEach(() => { + // Preserve any legacy backup that might happen to live in the user's + // real home so we can restore it after the test. + legacyBackup = null; + if (fs.existsSync(legacyPath)) { + legacyBackup = fs.readFileSync(legacyPath, "utf8"); + fs.unlinkSync(legacyPath); + } + }); + + afterEach(() => { + try { + fs.unlinkSync(getCredentialBackupFile(acct)); + } catch { + /* ignore */ + } + if (fs.existsSync(legacyPath)) { + fs.unlinkSync(legacyPath); + } + if (legacyBackup != null) { + fs.writeFileSync(legacyPath, legacyBackup); + } + }); + + it("round-trips a credential snapshot", () => { + saveCredentialBackup(acct, "app-1", "secret-1"); + const loaded = loadCredentialBackup(acct); + expect(loaded?.appId).toBe("app-1"); + expect(loaded?.clientSecret).toBe("secret-1"); + expect(loaded?.accountId).toBe(acct); + expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(true); + }); + + it("returns null when no backup exists", () => { + expect(loadCredentialBackup(acct)).toBeNull(); + }); + + it("returns null when legacy backup belongs to a different accountId", () => { + fs.writeFileSync( + legacyPath, + JSON.stringify({ + accountId: "other-acct", + appId: "app-old", + clientSecret: "secret-old", + savedAt: new Date().toISOString(), + }), + ); + expect(loadCredentialBackup(acct)).toBeNull(); + }); + + it("migrates legacy single-file backup to per-account path on load", () => { + fs.writeFileSync( + legacyPath, + JSON.stringify({ + accountId: acct, + appId: "app-1", + clientSecret: "secret-1", + savedAt: new Date().toISOString(), + }), + ); + + const loaded = loadCredentialBackup(acct); + expect(loaded?.appId).toBe("app-1"); + expect(fs.existsSync(legacyPath)).toBe(false); + expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(true); + }); + + it("ignores empty appId/clientSecret on save", () => { + saveCredentialBackup(acct, "", "secret"); + saveCredentialBackup(acct, "app", ""); + expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(false); + }); +}); diff --git a/extensions/qqbot/src/engine/config/credential-backup.ts b/extensions/qqbot/src/engine/config/credential-backup.ts new file mode 100644 index 00000000000..c2ea60d4cc3 --- /dev/null +++ b/extensions/qqbot/src/engine/config/credential-backup.ts @@ -0,0 +1,103 @@ +/** + * Credential backup & recovery. + * 凭证暂存与恢复。 + * + * Solves the "hot-upgrade interrupted, appId/secret vanished from + * openclaw.json" failure mode. + * + * Mechanics: + * - After each successful gateway start we snapshot the currently + * resolved `appId` / `clientSecret` to a per-account backup file. + * - During plugin startup, if the live config has an empty appId or + * secret, the gateway consults the backup and restores the values + * via `writeConfigFile`. + * - Backups live under `~/.openclaw/qqbot/data/` so they survive + * plugin directory replacement. + * + * Safety notes: + * - Only restore when credentials are **actually empty** — never + * overwrite a user's intentional config change. + * - Atomic write (temp file + rename) to avoid torn files. + * - Per-account file: `credential-backup-.json`. We do + * **not** also key by appId because recovery happens precisely + * when appId is unknown. + * - Legacy single `credential-backup.json` is migrated automatically + * when the stored accountId matches the caller. + */ + +import fs from "node:fs"; +import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js"; + +interface CredentialBackup { + accountId: string; + appId: string; + clientSecret: string; + savedAt: string; +} + +/** Persist a credential snapshot (called once gateway reaches READY). */ +export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void { + if (!appId || !clientSecret) { + return; + } + try { + const backupPath = getCredentialBackupFile(accountId); + const data: CredentialBackup = { + accountId, + appId, + clientSecret, + savedAt: new Date().toISOString(), + }; + const tmpPath = `${backupPath}.tmp`; + fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + fs.renameSync(tmpPath, backupPath); + } catch { + /* best-effort — ignore */ + } +} + +/** + * Load a credential snapshot for `accountId`. + * + * Consults the new per-account file first; falls back to the legacy + * global backup file and migrates it when the embedded `accountId` + * matches the request. Returns `null` when no usable backup exists. + */ +export function loadCredentialBackup(accountId?: string): CredentialBackup | null { + try { + if (accountId) { + const newPath = getCredentialBackupFile(accountId); + if (fs.existsSync(newPath)) { + const data = JSON.parse(fs.readFileSync(newPath, "utf8")) as CredentialBackup; + if (data?.appId && data.clientSecret) { + return data; + } + } + } + + const legacy = getLegacyCredentialBackupFile(); + if (fs.existsSync(legacy)) { + const data = JSON.parse(fs.readFileSync(legacy, "utf8")) as CredentialBackup; + if (!data?.appId || !data?.clientSecret) { + return null; + } + if (accountId && data.accountId !== accountId) { + return null; + } + if (data.accountId) { + try { + const tmpPath = `${getCredentialBackupFile(data.accountId)}.tmp`; + fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + fs.renameSync(tmpPath, getCredentialBackupFile(data.accountId)); + fs.unlinkSync(legacy); + } catch { + /* ignore migration errors */ + } + } + return data; + } + } catch { + /* corrupt file — ignore */ + } + return null; +} diff --git a/extensions/qqbot/src/engine/config/credentials.ts b/extensions/qqbot/src/engine/config/credentials.ts new file mode 100644 index 00000000000..94fea6fa15e --- /dev/null +++ b/extensions/qqbot/src/engine/config/credentials.ts @@ -0,0 +1,120 @@ +/** + * QQBot credential management (pure logic layer). + * QQBot 凭证管理(纯逻辑层)。 + * + * Credential clearing and field-level cleanup for logout and setup + * flows. All functions operate on plain objects (Record) + * and stay framework-agnostic. + */ + +import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; +import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; + +// ---- Logout: clear all credential fields for an account ---- + +export interface ClearCredentialsResult { + nextCfg: Record; + cleared: boolean; + changed: boolean; +} + +/** + * Remove clientSecret / clientSecretFile from a QQBot account config. + * + * Returns a shallow-cloned config with credentials removed, plus flags + * indicating whether anything actually changed. + */ +export function clearAccountCredentials( + cfg: Record, + accountId: string, +): ClearCredentialsResult { + const nextCfg = { ...cfg }; + const channels = asRecord(cfg.channels); + const nextQQBot = channels?.qqbot ? { ...asRecord(channels.qqbot) } : undefined; + let cleared = false; + let changed = false; + + if (nextQQBot) { + const qqbot = nextQQBot as Record; + 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> | undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId] as Record | undefined; + if (entry && "clientSecret" in entry) { + delete entry.clientSecret; + cleared = true; + changed = true; + } + if (entry && "clientSecretFile" in entry) { + delete entry.clientSecretFile; + cleared = true; + changed = true; + } + if (entry && Object.keys(entry).length === 0) { + delete accounts[accountId]; + changed = true; + } + } + } + + if (changed && nextQQBot) { + nextCfg.channels = { ...channels, qqbot: nextQQBot }; + } + + return { nextCfg, cleared, changed }; +} + +// ---- Setup: clear a single credential field ---- + +export type CredentialField = "appId" | "clientSecret"; + +/** + * Clear a single credential field from a QQBot account config. + * + * Used by setup flows when switching to env-backed credential resolution. + * Returns a new config with the specified field removed. + */ +export function clearCredentialField( + cfg: Record, + accountId: string, + field: CredentialField, +): Record { + const next = { ...cfg }; + const channels = asRecord(cfg.channels); + const qqbot = { ...asRecord(channels?.qqbot) }; + + const clearField = (entry: Record) => { + 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> | undefined) }; + if (accounts[accountId]) { + const entry = { ...accounts[accountId] }; + clearField(entry); + accounts[accountId] = entry; + qqbot.accounts = accounts; + } + } + + next.channels = { ...channels, qqbot }; + return next; +} diff --git a/extensions/qqbot/src/engine/config/resolve.test.ts b/extensions/qqbot/src/engine/config/resolve.test.ts new file mode 100644 index 00000000000..42fd08ce1e2 --- /dev/null +++ b/extensions/qqbot/src/engine/config/resolve.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ACCOUNT_ID, + listAccountIds, + resolveDefaultAccountId, + resolveAccountBase, +} from "./resolve.js"; + +describe("engine/config/resolve", () => { + it("returns empty list when no accounts configured", () => { + expect(listAccountIds({})).toEqual([]); + }); + + it("returns default when top-level appId is set", () => { + const cfg = { + channels: { + qqbot: { appId: "123456" }, + }, + }; + expect(listAccountIds(cfg)).toEqual([DEFAULT_ACCOUNT_ID]); + }); + + it("lists named accounts", () => { + const cfg = { + channels: { + qqbot: { + accounts: { + bot2: { appId: "654321" }, + bot3: { appId: "111222" }, + }, + }, + }, + }; + const ids = listAccountIds(cfg); + expect(ids).toContain("bot2"); + expect(ids).toContain("bot3"); + }); + + it("resolves default account id to 'default' when top-level appId exists", () => { + const cfg = { + channels: { + qqbot: { appId: "123456" }, + }, + }; + expect(resolveDefaultAccountId(cfg)).toBe(DEFAULT_ACCOUNT_ID); + }); + + it("honors configured defaultAccount", () => { + const cfg = { + channels: { + qqbot: { + defaultAccount: "bot2", + accounts: { + bot2: { appId: "654321" }, + }, + }, + }, + }; + expect(resolveDefaultAccountId(cfg)).toBe("bot2"); + }); + + it("falls back to first named account when no default configured", () => { + const cfg = { + channels: { + qqbot: { + accounts: { + mybot: { appId: "999999" }, + }, + }, + }, + }; + expect(resolveDefaultAccountId(cfg)).toBe("mybot"); + }); + + it("resolves base account info for default account", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + name: "Test Bot", + systemPrompt: "You are helpful.", + markdownSupport: true, + }, + }, + }; + const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID); + expect(base.accountId).toBe(DEFAULT_ACCOUNT_ID); + expect(base.appId).toBe("123456"); + expect(base.name).toBe("Test Bot"); + expect(base.systemPrompt).toBe("You are helpful."); + expect(base.markdownSupport).toBe(true); + expect(base.enabled).toBe(true); + }); + + it("resolves base account info for named account", () => { + const cfg = { + channels: { + qqbot: { + accounts: { + bot2: { + appId: "654321", + name: "Bot Two", + enabled: false, + }, + }, + }, + }, + }; + const base = resolveAccountBase(cfg, "bot2"); + expect(base.accountId).toBe("bot2"); + expect(base.appId).toBe("654321"); + expect(base.name).toBe("Bot Two"); + expect(base.enabled).toBe(false); + }); + + it("uses configured defaultAccount when accountId is omitted", () => { + const cfg = { + channels: { + qqbot: { + defaultAccount: "bot2", + accounts: { + bot2: { appId: "654321" }, + }, + }, + }, + }; + const base = resolveAccountBase(cfg); + expect(base.accountId).toBe("bot2"); + expect(base.appId).toBe("654321"); + }); + + it("preserves audioFormatPolicy on the config object", () => { + const cfg = { + channels: { + qqbot: { + appId: "123456", + audioFormatPolicy: { + sttDirectFormats: [".wav"], + uploadDirectFormats: [".mp3"], + transcodeEnabled: false, + }, + }, + }, + }; + const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID); + expect(base.config.audioFormatPolicy).toEqual({ + sttDirectFormats: [".wav"], + uploadDirectFormats: [".mp3"], + transcodeEnabled: false, + }); + }); +}); diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts new file mode 100644 index 00000000000..15db5865e95 --- /dev/null +++ b/extensions/qqbot/src/engine/config/resolve.ts @@ -0,0 +1,283 @@ +/** + * QQBot config resolution (pure logic layer). + * QQBot 配置解析(纯逻辑层)。 + * + * Resolves account IDs, default account selection, and base account + * info from raw config objects. Secret/credential resolution is + * intentionally left to the outer layer (src/bridge/config.ts) so that + * this module stays framework-agnostic and self-contained. + */ + +import { getPlatformAdapter } from "../adapter/index.js"; +import { + asOptionalObjectRecord as asRecord, + normalizeOptionalLowercaseString, + normalizeStringifiedOptionalString, + readStringField as readString, +} from "../utils/string-normalize.js"; + +/** + * Default account ID, used for the unnamed top-level account. + * 默认账号 ID,用于顶层配置中未命名的账号。 + */ +export const DEFAULT_ACCOUNT_ID = "default"; + +/** + * Internal shape of the channels.qqbot config section. + * channels.qqbot 配置节的内部结构。 + */ +interface QQBotChannelConfig { + appId?: unknown; + clientSecret?: unknown; + clientSecretFile?: string; + accounts?: Record>; + defaultAccount?: unknown; + [key: string]: unknown; +} + +/** + * Base account resolution result (without credentials). + * 账号基础解析结果(不含凭证信息)。 + * + * The outer config.ts layer extends this with clientSecret / secretSource. + */ +export interface ResolvedAccountBase { + accountId: string; + name?: string; + enabled: boolean; + appId: string; + systemPrompt?: string; + markdownSupport: boolean; + config: Record; +} + +function normalizeAppId(raw: unknown): string { + if (typeof raw === "string") { + return raw.trim(); + } + if (typeof raw === "number") { + return String(raw); + } + return ""; +} + +function normalizeAccountConfig( + account: Record | undefined, +): Record { + if (!account) { + return {}; + } + const audioPolicy = asRecord(account.audioFormatPolicy); + return { + ...account, + ...(audioPolicy ? { audioFormatPolicy: { ...audioPolicy } } : {}), + }; +} + +function readQQBotSection(cfg: Record): QQBotChannelConfig | undefined { + const channels = asRecord(cfg.channels); + return asRecord(channels?.qqbot) as QQBotChannelConfig | undefined; +} + +/** + * List all configured QQBot account IDs. + * 列出所有已配置的 QQBot 账号 ID。 + */ +export function listAccountIds(cfg: Record): string[] { + const ids = new Set(); + const qqbot = readQQBotSection(cfg); + + 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. + * 解析默认 QQBot 账号 ID(优先级:defaultAccount > 顶层 appId > 第一个命名账号)。 + */ +export function resolveDefaultAccountId(cfg: Record): string { + const qqbot = readQQBotSection(cfg); + const configuredDefaultAccountId = normalizeOptionalLowercaseString(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 base account info (without credentials). + * 解析账号基础信息(不含凭证)。 + * + * Resolves everything except Secret/credential fields. The outer + * config.ts layer calls this and adds Secret handling on top. + */ +export function resolveAccountBase( + cfg: Record, + accountId?: string | null, +): ResolvedAccountBase { + const resolvedAccountId = accountId ?? resolveDefaultAccountId(cfg); + const qqbot = readQQBotSection(cfg); + + let accountConfig: Record = {}; + let appId = ""; + + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + accountConfig = normalizeAccountConfig(asRecord(qqbot)); + appId = normalizeAppId(qqbot?.appId); + } else { + const account = qqbot?.accounts?.[resolvedAccountId]; + accountConfig = normalizeAccountConfig(asRecord(account)); + appId = normalizeAppId(asRecord(account)?.appId); + } + + if (!appId && process.env.QQBOT_APP_ID && resolvedAccountId === DEFAULT_ACCOUNT_ID) { + appId = normalizeAppId(process.env.QQBOT_APP_ID); + } + + return { + accountId: resolvedAccountId, + name: readString(accountConfig, "name"), + enabled: accountConfig.enabled !== false, + appId, + systemPrompt: readString(accountConfig, "systemPrompt"), + markdownSupport: accountConfig.markdownSupport !== false, + config: accountConfig, + }; +} + +// ---- Account config apply ---- + +export interface ApplyAccountInput { + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + name?: string; +} + +/** Apply account config updates into a raw config object. */ +export function applyAccountConfig( + cfg: Record, + accountId: string, + input: ApplyAccountInput, +): Record { + const next = { ...cfg }; + const channels = asRecord(cfg.channels) ?? {}; + const existingQQBot = asRecord(channels.qqbot) ?? {}; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const allowFrom = (existingQQBot.allowFrom as unknown[]) ?? ["*"]; + next.channels = { + ...channels, + qqbot: { + ...existingQQBot, + 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 { + const accounts = (existingQQBot.accounts ?? {}) as Record>; + const existingAccount = accounts[accountId] ?? {}; + const allowFrom = (existingAccount.allowFrom as unknown[]) ?? ["*"]; + next.channels = { + ...channels, + qqbot: { + ...existingQQBot, + enabled: true, + accounts: { + ...accounts, + [accountId]: { + ...existingAccount, + 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; +} + +// ---- Account status helpers ---- + +/** Resolved account shape expected by isAccountConfigured / describeAccount. */ +export interface AccountSnapshot { + accountId: string; + name?: string; + enabled: boolean; + appId: string; + clientSecret?: string; + secretSource?: string; + config: Record & { + clientSecret?: unknown; + clientSecretFile?: string; + }; +} + +/** Check whether a QQBot account has been fully configured. */ +export function isAccountConfigured(account: AccountSnapshot | undefined): boolean { + return Boolean( + account?.appId && + (Boolean(account?.clientSecret) || + getPlatformAdapter().hasConfiguredSecret(account?.config?.clientSecret) || + Boolean(account?.config?.clientSecretFile?.trim())), + ); +} + +/** Build a summary description of an account. */ +export function describeAccount(account: AccountSnapshot | undefined) { + return { + accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID, + name: account?.name, + enabled: account?.enabled ?? false, + configured: isAccountConfigured(account), + tokenSource: account?.secretSource, + }; +} + +/** Normalize allowFrom entries into uppercase strings without the qqbot: prefix. */ +export function formatAllowFrom(allowFrom: Array | undefined | null): string[] { + return (allowFrom ?? []) + .map((entry) => normalizeStringifiedOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)) + .map((entry) => entry.replace(/^qqbot:/i, "")) + .map((entry) => entry.toUpperCase()); +} diff --git a/extensions/qqbot/src/engine/config/setup-logic.ts b/extensions/qqbot/src/engine/config/setup-logic.ts new file mode 100644 index 00000000000..44a03254ca2 --- /dev/null +++ b/extensions/qqbot/src/engine/config/setup-logic.ts @@ -0,0 +1,84 @@ +/** + * QQBot setup business logic (pure layer). + * QQBot setup 相关纯业务逻辑。 + * + * Token parsing, input validation, and setup config application. + * All functions are framework-agnostic and operate on plain objects. + */ + +import { applyAccountConfig } from "./resolve.js"; +import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; + +/** Parse an inline "appId:clientSecret" token string. */ +export function parseInlineToken(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 interface SetupInput { + token?: string; + tokenFile?: string; + useEnv?: boolean; + name?: string; +} + +/** Validate setup input for a QQBot account. Returns an error string or null. */ +export function validateSetupInput(accountId: string, input: SetupInput): string | null { + 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 && !parseInlineToken(input.token)) { + return "QQBot --token must be in appId:clientSecret format"; + } + + return null; +} + +/** Apply setup input to account config. Returns updated config. */ +export function applySetupAccountConfig( + cfg: Record, + accountId: string, + input: SetupInput, +): Record { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return cfg; + } + + let appId = ""; + let clientSecret = ""; + + if (input.token) { + const parsed = parseInlineToken(input.token); + if (!parsed) { + return cfg; + } + appId = parsed.appId; + clientSecret = parsed.clientSecret; + } + + if (!appId && !input.tokenFile && !input.useEnv) { + return cfg; + } + + return applyAccountConfig(cfg, accountId, { + appId, + clientSecret, + clientSecretFile: input.tokenFile, + name: input.name, + }); +} diff --git a/extensions/qqbot/src/engine/gateway/codec.ts b/extensions/qqbot/src/engine/gateway/codec.ts new file mode 100644 index 00000000000..d2a2c492d2b --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/codec.ts @@ -0,0 +1,47 @@ +/** + * Gateway message decoding utilities. + * + * Extracted from `gateway.ts` — handles the various data formats that + * the QQ Bot WebSocket can deliver (string, Buffer, Buffer[], ArrayBuffer). + * + * Zero external dependencies beyond Node.js built-ins. + */ + +/** + * Decode raw WebSocket `data` into a UTF-8 string. + * + * The QQ Bot gateway can send data as a plain string, a single Buffer, + * an array of Buffer chunks, an ArrayBuffer, or a typed array view. + */ +export function decodeGatewayMessageData(data: unknown): string { + if (typeof data === "string") { + return data; + } + if (Buffer.isBuffer(data)) { + return data.toString("utf8"); + } + if (Array.isArray(data) && data.every((chunk) => Buffer.isBuffer(chunk))) { + return Buffer.concat(data).toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); + } + return ""; +} + +/** + * Read the optional `message_scene.ext` array from an event payload. + * + * Guild, C2C, and Group events may carry a `message_scene` object + * with an `ext` string array used for ref-index parsing. + */ +export function readOptionalMessageSceneExt(event: Record): string[] | undefined { + if (!("message_scene" in event)) { + return undefined; + } + const scene = event.message_scene as { ext?: string[] } | undefined; + return scene?.ext; +} diff --git a/extensions/qqbot/src/engine/gateway/constants.ts b/extensions/qqbot/src/engine/gateway/constants.ts new file mode 100644 index 00000000000..e2f669fb9e4 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/constants.ts @@ -0,0 +1,95 @@ +/** + * QQ Bot WebSocket Gateway protocol constants. + * + * Extracted from `gateway.ts` to share between both plugin versions. + * Zero external dependencies. + */ + +/** QQ Bot WebSocket intents grouped by permission level. */ +const INTENTS = { + GUILDS: 1 << 0, + GUILD_MEMBERS: 1 << 1, + PUBLIC_GUILD_MESSAGES: 1 << 30, + DIRECT_MESSAGE: 1 << 12, + GROUP_AND_C2C: 1 << 25, +} as const; + +/** Full intent mask: groups + DMs + channels. */ +export const FULL_INTENTS = + INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C; + +/** Exponential backoff delays for reconnection attempts (ms). */ +export const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000] as const; + +/** Delay after receiving a rate-limit close code (ms). */ +export const RATE_LIMIT_DELAY = 60000; + +/** Maximum reconnection attempts before giving up. */ +export const MAX_RECONNECT_ATTEMPTS = 100; + +/** How many quick disconnects before warning about permissions. */ +export const MAX_QUICK_DISCONNECT_COUNT = 3; + +/** A disconnect within this window (ms) counts as "quick". */ +export const QUICK_DISCONNECT_THRESHOLD = 5000; + +// ============ Opcode Constants ============ + +/** Gateway opcodes used by the QQ Bot WebSocket protocol. */ +export const GatewayOp = { + /** Server → Client: Dispatch event (type + data). */ + DISPATCH: 0, + /** Client → Server: Heartbeat. */ + HEARTBEAT: 1, + /** Client → Server: Identify (initial auth). */ + IDENTIFY: 2, + /** Client → Server: Resume a dropped session. */ + RESUME: 6, + /** Server → Client: Request client to reconnect. */ + RECONNECT: 7, + /** Server → Client: Invalid session. */ + INVALID_SESSION: 9, + /** Server → Client: Hello (heartbeat interval). */ + HELLO: 10, + /** Server → Client: Heartbeat ACK. */ + HEARTBEAT_ACK: 11, +} as const; + +// ============ Close Codes ============ + +/** WebSocket close codes used by the QQ Gateway. */ +export const GatewayCloseCode = { + /** Normal closure — do not reconnect. */ + NORMAL: 1000, + /** Authentication failed — refresh token then reconnect. */ + AUTH_FAILED: 4004, + /** Session invalid — clear session, refresh token, reconnect. */ + INVALID_SESSION: 4006, + /** Sequence number out of range — clear session, refresh token, reconnect. */ + SEQ_OUT_OF_RANGE: 4007, + /** Rate limited — wait before reconnecting. */ + RATE_LIMITED: 4008, + /** Session timed out — clear session, refresh token, reconnect. */ + SESSION_TIMEOUT: 4009, + /** Server internal error (range start) — clear session, refresh token, reconnect. */ + SERVER_ERROR_START: 4900, + /** Server internal error (range end). */ + SERVER_ERROR_END: 4913, + /** Insufficient intents — fatal, do not reconnect. */ + INSUFFICIENT_INTENTS: 4914, + /** Disallowed intents — fatal, do not reconnect. */ + DISALLOWED_INTENTS: 4915, +} as const; + +// ============ Dispatch Event Types ============ + +/** Event type strings dispatched under opcode 0 (DISPATCH). */ +export const GatewayEvent = { + READY: "READY", + RESUMED: "RESUMED", + C2C_MESSAGE_CREATE: "C2C_MESSAGE_CREATE", + AT_MESSAGE_CREATE: "AT_MESSAGE_CREATE", + DIRECT_MESSAGE_CREATE: "DIRECT_MESSAGE_CREATE", + GROUP_AT_MESSAGE_CREATE: "GROUP_AT_MESSAGE_CREATE", + INTERACTION_CREATE: "INTERACTION_CREATE", +} as const; diff --git a/extensions/qqbot/src/engine/gateway/event-dispatcher.ts b/extensions/qqbot/src/engine/gateway/event-dispatcher.ts new file mode 100644 index 00000000000..2af25ef3636 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/event-dispatcher.ts @@ -0,0 +1,155 @@ +/** + * Event dispatcher — convert raw WebSocket op=0 events into QueuedMessage objects. + * + * Pure mapping logic with zero side effects (except known-user recording). + * Independently testable. + */ + +import { recordKnownUser } from "../session/known-users.js"; +import type { InteractionEvent } from "../types.js"; +import { parseRefIndices } from "../utils/text-parsing.js"; +import { readOptionalMessageSceneExt } from "./codec.js"; +import { GatewayEvent } from "./constants.js"; +import type { QueuedMessage } from "./message-queue.js"; +import type { + C2CMessageEvent, + GuildMessageEvent, + GroupMessageEvent, + EngineLogger, +} from "./types.js"; + +// ============ Dispatch result ============ + +export type DispatchResult = + | { action: "ready"; data: unknown; sessionId: string } + | { action: "resumed"; data: unknown } + | { action: "message"; msg: QueuedMessage } + | { action: "interaction"; event: InteractionEvent } + | { action: "ignore" }; + +// ============ dispatchEvent ============ + +/** + * Map a raw op=0 event into a structured dispatch result. + * + * Returns "message" for events that should be queued for processing, + * "ready"/"resumed" for session lifecycle events, and "ignore" otherwise. + */ +export function dispatchEvent( + eventType: string, + data: unknown, + accountId: string, + _log?: EngineLogger, +): DispatchResult { + if (eventType === GatewayEvent.READY) { + const d = data as { session_id: string }; + return { action: "ready", data, sessionId: d.session_id }; + } + + if (eventType === GatewayEvent.RESUMED) { + return { action: "resumed", data }; + } + + if (eventType === GatewayEvent.C2C_MESSAGE_CREATE) { + const ev = data as C2CMessageEvent; + recordKnownUser({ + openid: ev.author.user_openid, + type: "c2c", + accountId, + }); + const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements); + return { + action: "message", + msg: { + type: "c2c", + senderId: ev.author.user_openid, + content: ev.content, + messageId: ev.id, + timestamp: ev.timestamp, + attachments: ev.attachments, + refMsgIdx: refs.refMsgIdx, + msgIdx: refs.msgIdx, + msgType: ev.message_type, + msgElements: ev.msg_elements, + }, + }; + } + + if (eventType === GatewayEvent.AT_MESSAGE_CREATE) { + const ev = data as GuildMessageEvent; + const refs = parseRefIndices( + readOptionalMessageSceneExt(ev as unknown as Record), + ); + return { + action: "message", + msg: { + type: "guild", + senderId: ev.author.id, + senderName: ev.author.username, + content: ev.content, + messageId: ev.id, + timestamp: ev.timestamp, + channelId: ev.channel_id, + guildId: ev.guild_id, + attachments: ev.attachments, + refMsgIdx: refs.refMsgIdx, + msgIdx: refs.msgIdx, + }, + }; + } + + if (eventType === GatewayEvent.DIRECT_MESSAGE_CREATE) { + const ev = data as GuildMessageEvent; + const refs = parseRefIndices( + readOptionalMessageSceneExt(ev as unknown as Record), + ); + return { + action: "message", + msg: { + type: "dm", + senderId: ev.author.id, + senderName: ev.author.username, + content: ev.content, + messageId: ev.id, + timestamp: ev.timestamp, + guildId: ev.guild_id, + attachments: ev.attachments, + refMsgIdx: refs.refMsgIdx, + msgIdx: refs.msgIdx, + }, + }; + } + + if (eventType === GatewayEvent.GROUP_AT_MESSAGE_CREATE) { + const ev = data as GroupMessageEvent; + recordKnownUser({ + openid: ev.author.member_openid, + type: "group", + groupOpenid: ev.group_openid, + accountId, + }); + const refs = parseRefIndices(ev.message_scene?.ext, ev.message_type, ev.msg_elements); + return { + action: "message", + msg: { + type: "group", + senderId: ev.author.member_openid, + content: ev.content, + messageId: ev.id, + timestamp: ev.timestamp, + groupOpenid: ev.group_openid, + attachments: ev.attachments, + refMsgIdx: refs.refMsgIdx, + msgIdx: refs.msgIdx, + msgType: ev.message_type, + msgElements: ev.msg_elements, + }, + }; + } + + if (eventType === GatewayEvent.INTERACTION_CREATE) { + return { action: "interaction", event: data as InteractionEvent }; + } + + return { action: "ignore" }; +} diff --git a/extensions/qqbot/src/engine/gateway/gateway-connection.ts b/extensions/qqbot/src/engine/gateway/gateway-connection.ts new file mode 100644 index 00000000000..8060e03b58c --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/gateway-connection.ts @@ -0,0 +1,371 @@ +/** + * GatewayConnection — WebSocket lifecycle, heartbeat, reconnect, and session persistence. + * + * Encapsulates all connection state as class fields (replaces 11 closure variables). + * Event handling and message processing are delegated to injected handlers. + */ + +import WebSocket from "ws"; +import { + trySlashCommand, + type SlashCommandHandlerContext, +} from "../commands/slash-command-handler.js"; +import { + clearTokenCache, + getAccessToken, + getGatewayUrl, + getPluginUserAgent, + startBackgroundTokenRefresh, + stopBackgroundTokenRefresh, +} from "../messaging/sender.js"; +import { flushRefIndex } from "../ref/store.js"; +import { flushKnownUsers } from "../session/known-users.js"; +import { clearSession, loadSession, saveSession } from "../session/session-store.js"; +import type { InteractionEvent } from "../types.js"; +import { decodeGatewayMessageData } from "./codec.js"; +import { FULL_INTENTS, RATE_LIMIT_DELAY, GatewayOp } from "./constants.js"; +import { dispatchEvent } from "./event-dispatcher.js"; +import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; +import { ReconnectState } from "./reconnect.js"; +import type { GatewayAccount, EngineLogger, GatewayPluginRuntime, WSPayload } from "./types.js"; + +// ============ Connection context ============ + +export interface GatewayConnectionContext { + account: GatewayAccount; + abortSignal: AbortSignal; + cfg: unknown; + log?: EngineLogger; + runtime: GatewayPluginRuntime; + onReady?: (data: unknown) => void; + /** Called when a RESUMED event is received (reconnect success). */ + onResumed?: (data: unknown) => void; + onError?: (error: Error) => void; + /** Process a queued message (inbound pipeline → outbound dispatch). */ + handleMessage: (event: QueuedMessage) => Promise; + /** Called when an INTERACTION_CREATE event is received (e.g. approval button clicks). */ + onInteraction?: (event: InteractionEvent) => void; +} + +// ============ GatewayConnection ============ + +export class GatewayConnection { + // ---- Connection state ---- + private isAborted = false; + private currentWs: WebSocket | null = null; + private heartbeatInterval: ReturnType | null = null; + private sessionId: string | null = null; + private lastSeq: number | null = null; + private isConnecting = false; + private reconnectTimer: ReturnType | null = null; + private shouldRefreshToken = false; + + private readonly reconnect: ReconnectState; + private readonly msgQueue; + private readonly ctx: GatewayConnectionContext; + + constructor(ctx: GatewayConnectionContext) { + this.ctx = ctx; + this.reconnect = new ReconnectState(ctx.account.accountId, ctx.log); + this.msgQueue = createMessageQueue({ + accountId: ctx.account.accountId, + log: ctx.log, + isAborted: () => this.isAborted, + }); + } + + /** Start the connection loop. Resolves when abortSignal fires. */ + async start(): Promise { + this.restoreSession(); + this.registerAbortHandler(); + await this.connect(); + return new Promise((resolve) => { + this.ctx.abortSignal.addEventListener("abort", () => resolve()); + }); + } + + // ============ Session persistence ============ + + private restoreSession(): void { + const { account, log } = this.ctx; + const saved = loadSession(account.accountId, account.appId); + if (saved) { + this.sessionId = saved.sessionId; + this.lastSeq = saved.lastSeq; + log?.info(`Restored session: sessionId=${this.sessionId}, lastSeq=${this.lastSeq}`); + } + } + + private saveCurrentSession(): void { + const { account } = this.ctx; + if (!this.sessionId) { + return; + } + saveSession({ + sessionId: this.sessionId, + lastSeq: this.lastSeq, + lastConnectedAt: Date.now(), + intentLevelIndex: 0, + accountId: account.accountId, + savedAt: Date.now(), + appId: account.appId, + }); + } + + // ============ Abort + cleanup ============ + + private registerAbortHandler(): void { + const { account, abortSignal, log: _log } = this.ctx; + abortSignal.addEventListener("abort", () => { + this.isAborted = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.cleanup(); + stopBackgroundTokenRefresh(account.appId); + flushKnownUsers(); + flushRefIndex(); + }); + } + + private cleanup(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + if ( + this.currentWs && + (this.currentWs.readyState === WebSocket.OPEN || + this.currentWs.readyState === WebSocket.CONNECTING) + ) { + this.currentWs.close(); + } + this.currentWs = null; + } + + // ============ Reconnect ============ + + private scheduleReconnect(customDelay?: number): void { + const { account: _account, log } = this.ctx; + if (this.isAborted || this.reconnect.isExhausted()) { + log?.error(`Max reconnect attempts reached or aborted`); + return; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + const delay = this.reconnect.getNextDelay(customDelay); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.isAborted) { + void this.connect(); + } + }, delay); + } + + // ============ Connect ============ + + private async connect(): Promise { + const { account, log } = this.ctx; + + if (this.isConnecting) { + log?.debug?.(`Already connecting, skip`); + return; + } + this.isConnecting = true; + + try { + this.cleanup(); + if (this.shouldRefreshToken) { + log?.debug?.(`Refreshing token...`); + clearTokenCache(account.appId); + this.shouldRefreshToken = false; + } + + const accessToken = await getAccessToken(account.appId, account.clientSecret); + log?.info(`✅ Access token obtained successfully`); + const gatewayUrl = await getGatewayUrl(accessToken, account.appId); + log?.info(`Connecting to ${gatewayUrl}`); + + const ws = new WebSocket(gatewayUrl, { + headers: { "User-Agent": getPluginUserAgent() }, + }); + this.currentWs = ws; + + // ---- Slash command interception ---- + const slashCtx: SlashCommandHandlerContext = { + account, + log, + getMessagePeerId: (msg) => this.msgQueue.getMessagePeerId(msg), + getQueueSnapshot: (peerId) => this.msgQueue.getSnapshot(peerId), + }; + + const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise => { + const result = await trySlashCommand(msg, slashCtx); + if (result === "enqueue") { + this.msgQueue.enqueue(msg); + } else if (result === "urgent") { + const peerId = this.msgQueue.getMessagePeerId(msg); + this.msgQueue.clearUserQueue(peerId); + this.msgQueue.executeImmediate(msg); + } + // "handled" — command executed, nothing to queue. + }; + + // ---- WebSocket: open ---- + ws.on("open", () => { + log?.info(`WebSocket connected`); + this.isConnecting = false; + this.reconnect.onConnected(); + this.msgQueue.startProcessor(this.ctx.handleMessage); + startBackgroundTokenRefresh(account.appId, account.clientSecret, { log }); + }); + + // ---- WebSocket: message ---- + ws.on("message", async (data) => { + try { + const rawData = decodeGatewayMessageData(data); + const payload = JSON.parse(rawData) as WSPayload; + const { op, d, s, t } = payload; + + if (s) { + this.lastSeq = s; + this.saveCurrentSession(); + } + + switch (op) { + case GatewayOp.HELLO: + this.handleHello(ws, d, accessToken); + break; + + case GatewayOp.DISPATCH: { + log?.debug?.(`Dispatch event: t=${t}, d=${JSON.stringify(d)}`); + const result = dispatchEvent(t ?? "", d, account.accountId, log); + if (result.action === "ready") { + this.sessionId = result.sessionId; + this.saveCurrentSession(); + this.ctx.onReady?.(result.data); + } else if (result.action === "resumed") { + (this.ctx.onResumed ?? this.ctx.onReady)?.(result.data); + this.saveCurrentSession(); + } else if (result.action === "interaction") { + this.ctx.onInteraction?.(result.event); + } else if (result.action === "message") { + void trySlashCommandOrEnqueue(result.msg); + } + break; + } + + case GatewayOp.HEARTBEAT_ACK: + break; + + case GatewayOp.RECONNECT: + this.cleanup(); + this.scheduleReconnect(); + break; + + case GatewayOp.INVALID_SESSION: { + const canResume = d as boolean; + if (!canResume) { + this.sessionId = null; + this.lastSeq = null; + clearSession(account.accountId); + this.shouldRefreshToken = true; + } + this.cleanup(); + this.scheduleReconnect(3000); + break; + } + } + } catch (err) { + log?.error(`Message parse error: ${err instanceof Error ? err.message : String(err)}`); + } + }); + + // ---- WebSocket: close ---- + ws.on("close", (code, reason) => { + log?.info(`WebSocket closed: ${code} ${reason.toString()}`); + this.isConnecting = false; + this.handleClose(code); + }); + + // ---- WebSocket: error ---- + ws.on("error", (err) => { + log?.error(`WebSocket error: ${err.message}`); + this.ctx.onError?.(err); + }); + } catch (err) { + this.isConnecting = false; + const errMsg = err instanceof Error ? err.message : String(err); + log?.error(`Connection failed: ${errMsg}`); + if (errMsg.includes("Too many requests") || errMsg.includes("100001")) { + this.scheduleReconnect(RATE_LIMIT_DELAY); + } else { + this.scheduleReconnect(); + } + } + } + + // ============ Protocol handlers ============ + + private handleHello(ws: WebSocket, d: unknown, accessToken: string): void { + if (this.sessionId && this.lastSeq !== null) { + ws.send( + JSON.stringify({ + op: GatewayOp.RESUME, + d: { + token: `QQBot ${accessToken}`, + session_id: this.sessionId, + seq: this.lastSeq, + }, + }), + ); + } else { + ws.send( + JSON.stringify({ + op: GatewayOp.IDENTIFY, + d: { + token: `QQBot ${accessToken}`, + intents: FULL_INTENTS, + shard: [0, 1], + }, + }), + ); + } + + const interval = (d as { heartbeat_interval: number }).heartbeat_interval; + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + this.heartbeatInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ op: GatewayOp.HEARTBEAT, d: this.lastSeq })); + } + }, interval); + } + + private handleClose(code: number): void { + const { account } = this.ctx; + const action = this.reconnect.handleClose(code, this.isAborted); + + if (action.clearSession) { + this.sessionId = null; + this.lastSeq = null; + clearSession(account.accountId); + } + if (action.refreshToken) { + this.shouldRefreshToken = true; + } + + this.cleanup(); + + if (action.fatal) { + return; + } + if (action.shouldReconnect) { + this.scheduleReconnect(action.reconnectDelay); + } + } +} diff --git a/extensions/qqbot/src/engine/gateway/gateway.ts b/extensions/qqbot/src/engine/gateway/gateway.ts new file mode 100644 index 00000000000..a567509f341 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/gateway.ts @@ -0,0 +1,286 @@ +/** + * Core gateway entry point — thin shell that wires together: + * + * - GatewayConnection: WebSocket lifecycle, heartbeat, reconnect + * - buildInboundContext: content building, attachments, quote resolution + * - dispatchOutbound: AI dispatch, deliver callbacks, timeouts + * + * The only responsibilities of this file are: + * 1. Register audio adapters + * 2. Initialize API config + refIdx cache hook + * 3. Create the message handler (inbound → outbound pipeline) + * 4. Start GatewayConnection + */ + +import path from "node:path"; +import { getPlatformAdapter } from "../adapter/index.js"; +import { parseApprovalButtonData } from "../approval/index.js"; +import { registerOutboundAudioAdapter } from "../messaging/outbound.js"; +import { + clearTokenCache, + getAccessToken, + initApiConfig, + onMessageSent, + sendInputNotify as senderSendInputNotify, + createRawInputNotifyFn, + accountToCreds, + acknowledgeInteraction, +} from "../messaging/sender.js"; +import { setRefIndex } from "../ref/store.js"; +import type { InteractionEvent } from "../types.js"; +import { + audioFileToSilkBase64, + convertSilkToWav, + isVoiceAttachment, + isAudioFile, + shouldTranscodeVoice, + waitForFile, +} from "../utils/audio.js"; +import { runDiagnostics } from "../utils/diagnostics.js"; +import { formatDuration } from "../utils/format.js"; +import { runWithRequestContext } from "../utils/request-context.js"; +import { GatewayConnection } from "./gateway-connection.js"; +import { registerAudioConvertAdapter } from "./inbound-attachments.js"; +import { buildInboundContext } from "./inbound-pipeline.js"; +import type { QueuedMessage } from "./message-queue.js"; +import { dispatchOutbound } from "./outbound-dispatch.js"; +import type { + CoreGatewayContext, + GatewayAccount, + EngineLogger, + RefAttachmentSummary, +} from "./types.js"; +import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js"; + +// Re-export context type for consumers. +export type { CoreGatewayContext } from "./types.js"; + +// ============ startGateway ============ + +/** + * Start the Gateway WebSocket connection with automatic reconnect support. + */ +export async function startGateway(ctx: CoreGatewayContext): Promise { + const { account, log, runtime } = ctx; + + // ---- 1. Register audio adapters ---- + registerAudioConvertAdapter({ convertSilkToWav, isVoiceAttachment, formatDuration }); + registerOutboundAudioAdapter({ + audioFileToSilkBase64: async (p, f) => (await audioFileToSilkBase64(p, f)) ?? undefined, + isAudioFile, + shouldTranscodeVoice, + waitForFile, + }); + + // ---- 2. Validate ---- + if (!account.appId || !account.clientSecret) { + throw new Error("QQBot not configured (missing appId or clientSecret)"); + } + + // ---- 3. Diagnostics ---- + const diag = await runDiagnostics(); + if (diag.warnings.length > 0) { + for (const w of diag.warnings) { + log?.info(w); + } + } + + // ---- 4. API config ---- + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`); + + // ---- 5. Outbound refIdx cache hook ---- + onMessageSent(account.appId, (refIdx, meta) => { + log?.info( + `onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`, + ); + const attachments: RefAttachmentSummary[] = []; + if (meta.mediaType) { + const localPath = meta.mediaLocalPath; + const filename = localPath ? path.basename(localPath) : undefined; + const attachment: RefAttachmentSummary = { + type: meta.mediaType, + ...(localPath ? { localPath } : {}), + ...(filename ? { filename } : {}), + ...(meta.mediaUrl ? { url: meta.mediaUrl } : {}), + }; + if (meta.mediaType === "voice" && meta.ttsText) { + attachment.transcript = meta.ttsText; + attachment.transcriptSource = "tts"; + } + attachments.push(attachment); + } + setRefIndex(refIdx, { + content: meta.text ?? "", + senderId: account.accountId, + senderName: account.accountId, + timestamp: Date.now(), + isBot: true, + ...(attachments.length > 0 ? { attachments } : {}), + }); + }); + + // ---- 6. Message handler ---- + const handleMessage = async (event: QueuedMessage): Promise => { + log?.info(`Processing message from ${event.senderId}: ${event.content}`); + + runtime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "inbound", + }); + + const inbound = await buildInboundContext(event, { + account, + cfg: ctx.cfg, + log, + runtime, + startTyping: (ev) => startTypingForEvent(ev, account, log), + }); + + if (inbound.blocked) { + log?.info(`Dropped inbound qqbot message: ${inbound.blockReason ?? "blocked by allowFrom"}`); + inbound.typing.keepAlive?.stop(); + return; + } + + try { + await runWithRequestContext( + { + accountId: account.accountId, + target: inbound.qualifiedTarget, + targetId: inbound.peerId, + chatType: event.type, + }, + () => dispatchOutbound(inbound, { runtime, cfg: ctx.cfg, account, log }), + ); + } catch (err) { + log?.error(`Message processing failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + inbound.typing.keepAlive?.stop(); + } + }; + + // ---- 7. Interaction handler ---- + const handleInteraction = createApprovalInteractionHandler(account, log); + + // ---- 8. Start connection ---- + const connection = new GatewayConnection({ + account, + abortSignal: ctx.abortSignal, + cfg: ctx.cfg, + log, + runtime, + onReady: ctx.onReady, + onResumed: ctx.onResumed, + onError: ctx.onError, + onInteraction: handleInteraction, + handleMessage, + }); + + await connection.start(); +} + +// ============ Typing helper ============ + +/** + * Start typing indicator for a C2C event. + * Returns the refIdx from InputNotify and a TypingKeepAlive handle. + */ +async function startTypingForEvent( + event: QueuedMessage, + account: GatewayAccount, + log?: EngineLogger, +): Promise<{ refIdx?: string; keepAlive: TypingKeepAlive | null }> { + const isC2C = event.type === "c2c" || event.type === "dm"; + if (!isC2C) { + return { keepAlive: null }; + } + try { + const creds = accountToCreds(account); + const rawNotifyFn = createRawInputNotifyFn(account.appId); + try { + const resp = await senderSendInputNotify({ + openid: event.senderId, + creds, + msgId: event.messageId, + inputSecond: TYPING_INPUT_SECOND, + }); + const keepAlive = new TypingKeepAlive( + () => getAccessToken(account.appId, account.clientSecret), + () => clearTokenCache(account.appId), + rawNotifyFn, + event.senderId, + event.messageId, + log, + ); + keepAlive.start(); + return { refIdx: resp.refIdx, keepAlive }; + } catch (notifyErr) { + const errMsg = String(notifyErr); + if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) { + clearTokenCache(account.appId); + const resp = await senderSendInputNotify({ + openid: event.senderId, + creds, + msgId: event.messageId, + inputSecond: TYPING_INPUT_SECOND, + }); + const keepAlive = new TypingKeepAlive( + () => getAccessToken(account.appId, account.clientSecret), + () => clearTokenCache(account.appId), + rawNotifyFn, + event.senderId, + event.messageId, + log, + ); + keepAlive.start(); + return { refIdx: resp.refIdx, keepAlive }; + } + throw notifyErr; + } + } catch (err) { + log?.error(`sendInputNotify error: ${err instanceof Error ? err.message : String(err)}`); + return { keepAlive: null }; + } +} + +// ============ Interaction handler ============ + +/** + * Default INTERACTION_CREATE handler — ACK the interaction and resolve + * approval button clicks via the registered PlatformAdapter. + */ +function createApprovalInteractionHandler( + account: GatewayAccount, + log?: EngineLogger, +): (event: InteractionEvent) => void { + return (event) => { + const creds = accountToCreds(account); + + // ACK the interaction first to prevent QQ from showing a timeout error. + void acknowledgeInteraction(creds, event.id).catch((err) => { + log?.error(`Interaction ACK failed: ${err instanceof Error ? err.message : String(err)}`); + }); + + const buttonData = event.data?.resolved?.button_data ?? ""; + const parsed = parseApprovalButtonData(buttonData); + if (!parsed) { + return; + } + + const adapter = getPlatformAdapter(); + if (!adapter.resolveApproval) { + log?.error(`resolveApproval not available on PlatformAdapter`); + return; + } + + void adapter.resolveApproval(parsed.approvalId, parsed.decision).then((ok) => { + if (ok) { + log?.info(`Approval resolved: id=${parsed.approvalId}, decision=${parsed.decision}`); + } else { + log?.error(`Approval resolve failed: id=${parsed.approvalId}`); + } + }); + }; +} diff --git a/extensions/qqbot/src/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts similarity index 77% rename from extensions/qqbot/src/inbound-attachments.ts rename to extensions/qqbot/src/engine/gateway/inbound-attachments.ts index ab06282e751..2b39777489b 100644 --- a/extensions/qqbot/src/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -1,8 +1,35 @@ -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"; +import { downloadFile } from "../utils/file-utils.js"; +import { getQQBotMediaDir } from "../utils/platform.js"; +import { normalizeOptionalString } from "../utils/string-normalize.js"; +import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js"; +// Re-export formatVoiceText from core/. +export { formatVoiceText } from "../utils/voice-text.js"; + +// ---- Injected audio-convert dependencies ---- + +/** Audio conversion interface — implemented by the upper-layer audio-convert module. */ +export interface AudioConvertAdapter { + convertSilkToWav( + silkPath: string, + outputDir: string, + ): Promise<{ wavPath: string; duration: number } | null>; + isVoiceAttachment(att: { content_type: string; filename?: string }): boolean; + formatDuration(seconds: number): string; +} + +let _audioAdapter: AudioConvertAdapter | null = null; + +/** Register the audio conversion adapter — called by gateway startup. */ +export function registerAudioConvertAdapter(adapter: AudioConvertAdapter): void { + _audioAdapter = adapter; +} + +function getAudioAdapter(): AudioConvertAdapter { + if (!_audioAdapter) { + throw new Error("AudioConvertAdapter not registered — call registerAudioConvertAdapter first"); + } + return _audioAdapter; +} export interface RawAttachment { content_type: string; @@ -58,9 +85,8 @@ export async function processAttachments( return EMPTY_RESULT; } - const { accountId, cfg, log } = ctx; + const { accountId: _accountId, cfg, log } = ctx; const downloadDir = getQQBotMediaDir("downloads"); - const prefix = `[qqbot:${accountId}]`; const imageUrls: string[] = []; const imageMediaTypes: string[] = []; @@ -75,7 +101,7 @@ export async function processAttachments( // 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 isVoice = getAudioAdapter().isVoiceAttachment(att); const wavUrl = isVoice && att.voice_wav_url ? att.voice_wav_url.startsWith("//") @@ -91,11 +117,9 @@ export async function processAttachments( if (wavLocalPath) { localPath = wavLocalPath; audioPath = wavLocalPath; - log?.info( - `${prefix} Voice attachment: ${att.filename}, downloaded WAV directly (skip SILK→WAV)`, - ); + log?.debug?.(`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`); + log?.error(`Failed to download voice_wav_url, falling back to original URL`); } } @@ -127,10 +151,10 @@ export async function processAttachments( if (localPath) { if (att.content_type?.startsWith("image/")) { - log?.info(`${prefix} Downloaded attachment to: ${localPath}`); + log?.debug?.(`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}`); + log?.debug?.(`Downloaded attachment to: ${localPath}`); return processVoiceAttachment( localPath, audioPath, @@ -139,14 +163,13 @@ export async function processAttachments( cfg, downloadDir, log, - prefix, ); } else { - log?.info(`${prefix} Downloaded attachment to: ${localPath}`); + log?.debug?.(`Downloaded attachment to: ${localPath}`); return { localPath, type: "other" as const, filename: att.filename, meta }; } } else { - log?.error(`${prefix} Failed to download: ${attUrl}`); + log?.error(`Failed to download: ${attUrl}`); if (att.content_type?.startsWith("image/")) { return { localPath: null, @@ -156,7 +179,7 @@ export async function processAttachments( meta, }; } else if (isVoice && asrReferText) { - log?.info(`${prefix} Voice attachment download failed, using asr_refer_text fallback`); + log?.info(`Voice attachment download failed, using asr_refer_text fallback`); return { localPath: null, type: "voice-fallback" as const, @@ -227,15 +250,7 @@ export async function processAttachments( }; } -/** 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"); -} +// formatVoiceText is now in core/utils/voice-text.ts (re-exported above). // Internal helpers. @@ -263,7 +278,6 @@ async function processVoiceAttachment( cfg: unknown, downloadDir: string, log: ProcessContext["log"], - prefix: string, ): Promise { const wavUrl = att.voice_wav_url ? att.voice_wav_url.startsWith("//") @@ -280,14 +294,12 @@ async function processVoiceAttachment( const sttCfg = resolveSTTConfig(cfg as Record); if (!sttCfg) { if (asrReferText) { - log?.info( - `${prefix} Voice attachment: ${att.filename} (STT not configured, using asr_refer_text fallback)`, + log?.debug?.( + `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)`, - ); + log?.debug?.(`Voice attachment: ${att.filename} (STT not configured, skipping transcription)`); return { localPath, type: "voice", @@ -299,20 +311,20 @@ async function processVoiceAttachment( // Convert SILK input to WAV before STT when necessary. if (!audioPath) { - log?.info(`${prefix} Voice attachment: ${att.filename}, converting SILK→WAV...`); + log?.debug?.(`Voice attachment: ${att.filename}, converting SILK→WAV...`); try { - const wavResult = await convertSilkToWav(localPath, downloadDir); + const wavResult = await getAudioAdapter().convertSilkToWav(localPath, downloadDir); if (wavResult) { audioPath = wavResult.wavPath; - log?.info( - `${prefix} Voice converted: ${wavResult.wavPath} (${formatDuration(wavResult.duration)})`, + log?.debug?.( + `Voice converted: ${wavResult.wavPath} (${getAudioAdapter().formatDuration(wavResult.duration)})`, ); } else { audioPath = localPath; } } catch (convertErr) { log?.error( - `${prefix} Voice conversion failed: ${ + `Voice conversion failed: ${ convertErr instanceof Error ? convertErr.message : JSON.stringify(convertErr) }`, ); @@ -339,14 +351,14 @@ async function processVoiceAttachment( try { const transcript = await transcribeAudio(audioPath, cfg as Record); if (transcript) { - log?.info(`${prefix} STT transcript: ${transcript.slice(0, 100)}...`); + log?.debug?.(`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`); + log?.debug?.(`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`); + log?.debug?.(`STT returned empty result`); return { localPath, type: "voice", @@ -355,9 +367,7 @@ async function processVoiceAttachment( meta, }; } catch (sttErr) { - log?.error( - `${prefix} STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`, - ); + log?.error(`STT failed: ${sttErr instanceof Error ? sttErr.message : JSON.stringify(sttErr)}`); if (asrReferText) { return { localPath, type: "voice", transcript: asrReferText, transcriptSource: "asr", meta }; } diff --git a/extensions/qqbot/src/engine/gateway/inbound-context.ts b/extensions/qqbot/src/engine/gateway/inbound-context.ts new file mode 100644 index 00000000000..7e4358e6eb2 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/inbound-context.ts @@ -0,0 +1,119 @@ +/** + * InboundContext — the structured result of the inbound pipeline. + * + * Connects the inbound stage (content building, attachment processing, + * quote resolution) with the outbound stage (AI dispatch, deliver callbacks). + * + * All fields are readonly after construction. The outbound dispatcher + * reads from this object but never mutates it. + */ + +import type { QQBotAccessDecision, QQBotAccessReasonCode } from "../access/index.js"; +import type { QueuedMessage } from "./message-queue.js"; +import type { + GatewayAccount, + EngineLogger, + GatewayPluginRuntime, + ProcessedAttachments, +} from "./types.js"; +import type { TypingKeepAlive } from "./typing-keepalive.js"; + +// ============ InboundContext ============ + +/** Quote (reply-to) metadata resolved during inbound processing. */ +export interface ReplyToInfo { + id: string; + body?: string; + sender?: string; + isQuote: boolean; +} + +/** Fully resolved inbound context passed to the outbound dispatcher. */ +export interface InboundContext { + // ---- Original event ---- + event: QueuedMessage; + + // ---- Routing ---- + route: { sessionKey: string; accountId: string; agentId?: string }; + isGroupChat: boolean; + peerId: string; + /** Fully qualified target address: "qqbot:c2c:xxx" / "qqbot:group:xxx" etc. */ + qualifiedTarget: string; + fromAddress: string; + + // ---- Content ---- + /** event.content after parseFaceTags. */ + parsedContent: string; + /** parsedContent + voiceText + attachmentInfo — the user-visible text. */ + userContent: string; + /** "[Quoted message begins]…[ends]" or empty. */ + quotePart: string; + /** Per-message dynamic metadata lines (images, voice, ASR). */ + dynamicCtx: string; + /** quotePart + userContent. */ + userMessage: string; + /** dynamicCtx + userMessage (or raw content for slash commands). */ + agentBody: string; + /** Formatted inbound envelope (Web UI body). */ + body: string; + + // ---- System prompts ---- + systemPrompts: string[]; + groupSystemPrompt?: string; + + // ---- Attachments ---- + attachments: ProcessedAttachments; + localMediaPaths: string[]; + localMediaTypes: string[]; + remoteMediaUrls: string[]; + remoteMediaTypes: string[]; + + // ---- Voice ---- + uniqueVoicePaths: string[]; + uniqueVoiceUrls: string[]; + uniqueVoiceAsrReferTexts: string[]; + hasAsrReferFallback: boolean; + voiceTranscriptSources: string[]; + + // ---- Reply-to / Quote ---- + replyTo?: ReplyToInfo; + + // ---- Auth ---- + commandAuthorized: boolean; + /** + * Whether the inbound message should be blocked outright (i.e. the bot + * neither routes it to an agent nor replies). Set when the sender is + * not matched by the configured `allowFrom`/`groupAllowFrom` list + * under the active `dmPolicy` / `groupPolicy`. + */ + blocked: boolean; + /** Human-readable reason for `blocked`, for logging only. */ + blockReason?: string; + /** + * Structured reason code for `blocked`, suitable for metrics and + * activity indicators. + */ + blockReasonCode?: QQBotAccessReasonCode; + /** The raw access decision produced by the policy engine. */ + accessDecision?: QQBotAccessDecision; + + // ---- Typing ---- + typing: { keepAlive: TypingKeepAlive | null }; + /** refIdx returned by the initial InputNotify call. */ + inputNotifyRefIdx?: string; +} + +// ============ Pipeline dependencies ============ + +/** Dependencies injected into the inbound pipeline. */ +export interface InboundPipelineDeps { + account: GatewayAccount; + cfg: unknown; + log?: EngineLogger; + runtime: GatewayPluginRuntime; + /** Start typing indicator and return the refIdx from InputNotify. */ + startTyping: (event: QueuedMessage) => Promise<{ + refIdx?: string; + keepAlive: TypingKeepAlive | null; + }>; +} diff --git a/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts new file mode 100644 index 00000000000..b3c65d4599f --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/inbound-pipeline.ts @@ -0,0 +1,444 @@ +/** + * Inbound pipeline — build a fully resolved InboundContext from a raw QueuedMessage. + * + * Responsibilities: + * 1. Route resolution + * 2. Attachment processing (download + STT) + * 3. Content building (parseFaceTags + voiceText + attachmentInfo) + * 4. Quote / reply-to resolution (three-level fallback) + * 5. RefIdx cache write (setRefIndex) + * 6. Body / agentBody / ctxPayload data assembly + * + * No message sending. Independently testable. + */ + +import { + normalizeQQBotSenderId, + resolveQQBotAccess, + type QQBotAccessResult, +} from "../access/index.js"; +import { + formatMessageReferenceForAgent, + type AttachmentProcessor, +} from "../ref/format-message-ref.js"; +import { getRefIndex, setRefIndex, formatRefEntryForAgent } from "../ref/store.js"; +import { parseFaceTags, buildAttachmentSummaries, MSG_TYPE_QUOTE } from "../utils/text-parsing.js"; +import { formatVoiceText } from "../utils/voice-text.js"; +import { processAttachments } from "./inbound-attachments.js"; +import type { InboundContext, InboundPipelineDeps } from "./inbound-context.js"; +import type { QueuedMessage } from "./message-queue.js"; + +// ============ buildInboundContext ============ + +/** + * Process a raw queued message through the full inbound pipeline and return + * a structured {@link InboundContext} ready for outbound dispatch. + */ +export async function buildInboundContext( + event: QueuedMessage, + deps: InboundPipelineDeps, +): Promise { + const { account, cfg, log, runtime } = deps; + + // ---- 1. Route resolution ---- + const isGroupChat = event.type === "guild" || event.type === "group"; + const peerId = + event.type === "guild" + ? (event.channelId ?? "unknown") + : event.type === "group" + ? (event.groupOpenid ?? "unknown") + : event.senderId; + + const route = runtime.channel.routing.resolveAgentRoute({ + cfg, + channel: "qqbot", + accountId: account.accountId, + peer: { kind: isGroupChat ? "group" : "direct", id: peerId }, + }); + + // ---- 1a. Early access control ---- + // + // Evaluate the account-level dmPolicy / groupPolicy + allowFrom / + // groupAllowFrom whitelist before any expensive I/O (typing + // indicator, attachment downloads, quote resolution). Semantics are + // aligned with WhatsApp/Telegram/Discord (see `engine/access/`). + // + // When blocked, we return a minimal stub InboundContext and rely on + // the gateway handler to skip dispatch. + const access = resolveQQBotAccess({ + isGroup: isGroupChat, + senderId: event.senderId, + allowFrom: account.config?.allowFrom, + groupAllowFrom: account.config?.groupAllowFrom, + dmPolicy: account.config?.dmPolicy, + groupPolicy: account.config?.groupPolicy, + }); + + const qualifiedTarget = isGroupChat + ? event.type === "guild" + ? `qqbot:channel:${event.channelId}` + : `qqbot:group:${event.groupOpenid}` + : event.type === "dm" + ? `qqbot:dm:${event.guildId}` + : `qqbot:c2c:${event.senderId}`; + const fromAddress = qualifiedTarget; + + if (access.decision !== "allow") { + log?.info( + `Blocked qqbot inbound: decision=${access.decision} reasonCode=${access.reasonCode} ` + + `reason=${access.reason} senderId=${normalizeQQBotSenderId(event.senderId)} ` + + `accountId=${account.accountId} isGroup=${isGroupChat}`, + ); + return buildBlockedInboundContext({ + event, + route, + isGroupChat, + peerId, + qualifiedTarget, + fromAddress, + access, + }); + } + + // ---- 2. System prompts ---- + const systemPrompts: string[] = []; + if (account.systemPrompt) { + systemPrompts.push(account.systemPrompt); + } + + // ---- 3. Typing indicator (async, await later) ---- + const typingPromise = deps.startTyping(event); + + // ---- 4. Attachment processing ---- + const processed = await processAttachments(event.attachments, { + accountId: account.accountId, + cfg, + log, + }); + const { + attachmentInfo, + imageUrls, + imageMediaTypes, + voiceAttachmentPaths, + voiceAttachmentUrls, + voiceAsrReferTexts, + voiceTranscripts, + voiceTranscriptSources, + attachmentLocalPaths, + } = processed; + + // ---- 5. Content building ---- + const voiceText = formatVoiceText(voiceTranscripts); + const hasAsrReferFallback = voiceTranscriptSources.includes("asr"); + const parsedContent = parseFaceTags(event.content); + const userContent = voiceText + ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo + : parsedContent + attachmentInfo; + + // ---- 6. Quote / reply-to resolution ---- + const replyTo = await resolveQuote(event, account, cfg, log); + + // ---- 7. RefIdx cache write ---- + const typingResult = await typingPromise; + const inputNotifyRefIdx = typingResult.refIdx; + const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx; + if (currentMsgIdx) { + const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths); + if (attSummaries && voiceTranscripts.length > 0) { + let voiceIdx = 0; + for (const att of attSummaries) { + if (att.type === "voice" && voiceIdx < voiceTranscripts.length) { + att.transcript = voiceTranscripts[voiceIdx]; + if (voiceIdx < voiceTranscriptSources.length) { + att.transcriptSource = voiceTranscriptSources[voiceIdx] as + | "stt" + | "asr" + | "tts" + | "fallback"; + } + voiceIdx++; + } + } + } + setRefIndex(currentMsgIdx, { + content: parsedContent, + senderId: event.senderId, + senderName: event.senderName, + timestamp: new Date(event.timestamp).getTime(), + attachments: attSummaries, + }); + } + + // ---- 8. Envelope (Web UI body) ---- + const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg); + const body = runtime.channel.reply.formatInboundEnvelope({ + channel: "qqbot", + from: event.senderName ?? event.senderId, + timestamp: new Date(event.timestamp).getTime(), + body: userContent, + chatType: isGroupChat ? "group" : "direct", + sender: { id: event.senderId, name: event.senderName }, + envelope: envelopeOptions, + ...(imageUrls.length > 0 ? { imageUrls } : {}), + }); + + // ---- 9. Voice dedup ---- + const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)]; + const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)]; + const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean); + + // ---- 11. Quote part ---- + let quotePart = ""; + if (replyTo) { + quotePart = replyTo.body + ? `[Quoted message begins]\n${replyTo.body}\n[Quoted message ends]\n` + : `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`; + } + + // ---- 12. Dynamic context ---- + const dynLines: string[] = []; + if (imageUrls.length > 0) { + dynLines.push(`- Images: ${imageUrls.join(", ")}`); + } + if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) { + dynLines.push(`- Voice: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`); + } + if (uniqueVoiceAsrReferTexts.length > 0) { + dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`); + } + const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n\n" : ""; + + // ---- 13. agentBody ---- + const userMessage = `${quotePart}${userContent}`; + const agentBody = userContent.startsWith("/") ? userContent : `${dynamicCtx}${userMessage}`; + + // ---- 14. GroupSystemPrompt ---- + const qqbotSystemInstruction = systemPrompts.length > 0 ? systemPrompts.join("\n") : ""; + const groupSystemPrompt = qqbotSystemInstruction || undefined; + + // ---- 15. Auth: commandAuthorized semantics ---- + // + // `commandAuthorized=true` means the framework is allowed to honour + // `/xxx` directives (e.g. `/exec host=... ask=...`) from this sender. + // + // We treat the sender as authorized when one of the following holds: + // - DM with policy=open (the bot owner implicitly trusts DMs) + // - DM with policy=allowlist and sender matched + // - Group where the sender is explicitly in groupAllowFrom/allowFrom + // (matches the `allowlist (allowlisted)` reason string). + // + // Notably, a group running in `policy=open` does NOT grant command + // authorization to arbitrary group members, aligning with the other + // channel plugins (Telegram/WhatsApp/Discord) which require explicit + // allowlist membership for command-level gating. + const commandAuthorized = + access.reasonCode === "dm_policy_open" || + access.reasonCode === "dm_policy_allowlisted" || + (access.reasonCode === "group_policy_allowed" && + access.effectiveGroupAllowFrom.length > 0 && + access.groupPolicy === "allowlist"); + + // ---- 16. Media path classification ---- + const localMediaPaths: string[] = []; + const localMediaTypes: string[] = []; + const remoteMediaUrls: string[] = []; + const remoteMediaTypes: string[] = []; + for (let i = 0; i < imageUrls.length; i++) { + const u = imageUrls[i]; + const t = imageMediaTypes[i] ?? "image/png"; + if (u.startsWith("http://") || u.startsWith("https://")) { + remoteMediaUrls.push(u); + remoteMediaTypes.push(t); + } else { + localMediaPaths.push(u); + localMediaTypes.push(t); + } + } + + return { + event, + route, + isGroupChat, + peerId, + qualifiedTarget, + fromAddress, + parsedContent, + userContent, + quotePart, + dynamicCtx, + userMessage, + agentBody, + body, + systemPrompts, + groupSystemPrompt, + attachments: processed, + localMediaPaths, + localMediaTypes, + remoteMediaUrls, + remoteMediaTypes, + uniqueVoicePaths, + uniqueVoiceUrls, + uniqueVoiceAsrReferTexts, + hasAsrReferFallback, + voiceTranscriptSources, + replyTo, + commandAuthorized, + blocked: false, + accessDecision: access.decision, + typing: { keepAlive: typingResult.keepAlive }, + inputNotifyRefIdx, + }; +} + +/** + * Build a stub InboundContext for blocked (unauthorized) messages. + * + * The gateway handler inspects `blocked` and skips outbound dispatch, + * so most fields can be left empty. We still populate routing/peer + * fields so logs and metrics remain meaningful. + */ +function buildBlockedInboundContext(params: { + event: QueuedMessage; + route: { sessionKey: string; accountId: string; agentId?: string }; + isGroupChat: boolean; + peerId: string; + qualifiedTarget: string; + fromAddress: string; + access: QQBotAccessResult; +}): InboundContext { + const emptyProcessed: InboundContext["attachments"] = { + attachmentInfo: "", + imageUrls: [], + imageMediaTypes: [], + voiceAttachmentPaths: [], + voiceAttachmentUrls: [], + voiceAsrReferTexts: [], + voiceTranscripts: [], + voiceTranscriptSources: [], + attachmentLocalPaths: [], + }; + + return { + event: params.event, + route: params.route, + isGroupChat: params.isGroupChat, + peerId: params.peerId, + qualifiedTarget: params.qualifiedTarget, + fromAddress: params.fromAddress, + parsedContent: "", + userContent: "", + quotePart: "", + dynamicCtx: "", + userMessage: "", + agentBody: "", + body: "", + systemPrompts: [], + groupSystemPrompt: undefined, + attachments: emptyProcessed, + localMediaPaths: [], + localMediaTypes: [], + remoteMediaUrls: [], + remoteMediaTypes: [], + uniqueVoicePaths: [], + uniqueVoiceUrls: [], + uniqueVoiceAsrReferTexts: [], + hasAsrReferFallback: false, + voiceTranscriptSources: [], + replyTo: undefined, + commandAuthorized: false, + blocked: true, + blockReason: params.access.reason, + blockReasonCode: params.access.reasonCode, + accessDecision: params.access.decision, + typing: { keepAlive: null }, + inputNotifyRefIdx: undefined, + }; +} + +// ============ Quote resolution (internal) ============ + +async function resolveQuote( + event: QueuedMessage, + account: InboundPipelineDeps["account"], + cfg: unknown, + log?: InboundPipelineDeps["log"], +): Promise { + if (!event.refMsgIdx) { + return undefined; + } + + const refEntry = getRefIndex(event.refMsgIdx); + + if (refEntry) { + log?.debug?.( + `Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${refEntry.senderName ?? refEntry.senderId}`, + ); + return { + id: event.refMsgIdx, + body: formatRefEntryForAgent(refEntry), + sender: refEntry.senderName ?? refEntry.senderId, + isQuote: true, + }; + } + + if (event.msgType === MSG_TYPE_QUOTE && event.msgElements?.[0]) { + try { + const refElement = event.msgElements[0]; + const refData = { + content: refElement.content ?? "", + attachments: refElement.attachments, + }; + const attachmentProcessor: AttachmentProcessor = { + processAttachments: async (atts, refCtx) => { + const result = await processAttachments( + atts as Array<{ + content_type: string; + url: string; + filename?: string; + voice_wav_url?: string; + asr_refer_text?: string; + }>, + { + accountId: account.accountId, + cfg: refCtx.cfg, + log: refCtx.log, + }, + ); + return { + attachmentInfo: result.attachmentInfo, + voiceTranscripts: result.voiceTranscripts, + voiceTranscriptSources: result.voiceTranscriptSources, + attachmentLocalPaths: result.attachmentLocalPaths, + }; + }, + formatVoiceText: (transcripts) => formatVoiceText(transcripts), + }; + const refPeerId = + event.type === "group" && event.groupOpenid ? event.groupOpenid : event.senderId; + const refBody = await formatMessageReferenceForAgent( + refData, + { appId: account.appId, peerId: refPeerId, cfg: account.config, log }, + attachmentProcessor, + ); + log?.debug?.( + `Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${(refBody ?? "").slice(0, 80)}..."`, + ); + return { + id: event.refMsgIdx, + body: refBody || undefined, + isQuote: true, + }; + } catch (refErr) { + log?.error(`Failed to format quoted message from msg_elements: ${String(refErr)}`); + } + } else { + log?.debug?.( + `Quote detected but no cache and msgType=${event.msgType}: refMsgIdx=${event.refMsgIdx}`, + ); + } + + return { + id: event.refMsgIdx, + isQuote: true, + }; +} diff --git a/extensions/qqbot/src/message-queue.ts b/extensions/qqbot/src/engine/gateway/message-queue.ts similarity index 74% rename from extensions/qqbot/src/message-queue.ts rename to extensions/qqbot/src/engine/gateway/message-queue.ts index 098463ec8bf..c3de035387b 100644 --- a/extensions/qqbot/src/message-queue.ts +++ b/extensions/qqbot/src/engine/gateway/message-queue.ts @@ -1,4 +1,14 @@ -import type { QueueSnapshot } from "./slash-commands.js"; +/** + * Per-user concurrent message queue. + * + * Messages are serialized per user (peer) and processed in parallel across + * users, up to a configurable concurrency limit. + * + * This module is independent of any framework SDK — it only needs a logger + * and an abort-state probe supplied via {@link MessageQueueContext}. + */ + +import { formatErrorMessage } from "../utils/format.js"; // Message queue limits. const MESSAGE_QUEUE_SIZE = 1000; @@ -29,6 +39,23 @@ export interface QueuedMessage { refMsgIdx?: string; /** refIdx assigned to this message for future quoting. */ msgIdx?: string; + /** QQ message type (103 = quote). */ + msgType?: number; + /** Referenced message elements (for quote messages). */ + msgElements?: Array<{ + msg_idx?: string; + content?: string; + attachments?: Array<{ + content_type: string; + url: string; + filename?: string; + height?: number; + width?: number; + size?: number; + voice_wav_url?: string; + asr_refer_text?: string; + }>; + }>; } export interface MessageQueueContext { @@ -42,6 +69,14 @@ export interface MessageQueueContext { isAborted: () => boolean; } +/** Snapshot of the queue state for diagnostics. */ +export interface QueueSnapshot { + totalPending: number; + activeUsers: number; + maxConcurrentUsers: number; + senderPending: number; +} + export interface MessageQueue { enqueue: (msg: QueuedMessage) => void; startProcessor: (handleMessageFn: (msg: QueuedMessage) => Promise) => void; @@ -58,7 +93,7 @@ export interface MessageQueue { * Messages are serialized per user and processed in parallel across users. */ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { - const { accountId, log } = ctx; + const { accountId: _accountId, log } = ctx; const userQueues = new Map(); const activeUsers = new Set(); @@ -81,9 +116,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { return; } if (activeUsers.size >= MAX_CONCURRENT_USERS) { - log?.info( - `[qqbot:${accountId}] Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`, - ); + log?.info(`Max concurrent users (${MAX_CONCURRENT_USERS}) reached, ${peerId} will wait`); return; } @@ -105,7 +138,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { messagesProcessed++; } } catch (err) { - log?.error(`[qqbot:${accountId}] Message processor error for ${peerId}: ${String(err)}`); + log?.error(`Message processor error for ${peerId}: ${formatErrorMessage(err)}`); } } } finally { @@ -133,20 +166,20 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { 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}`, + `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`, + `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}`, + `Message enqueued for ${peerId}, user queue: ${queue.length}, active users: ${activeUsers.size}`, ); void drainUserQueue(peerId); @@ -154,8 +187,8 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { const startProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise): void => { handleMessageFnRef = handleMessageFn; - log?.info( - `[qqbot:${accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`, + log?.debug?.( + `Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`, ); }; @@ -187,7 +220,7 @@ export function createMessageQueue(ctx: MessageQueueContext): MessageQueue { const executeImmediate = (msg: QueuedMessage): void => { if (handleMessageFnRef) { handleMessageFnRef(msg).catch((err) => { - log?.error(`[qqbot:${accountId}] Immediate execution error: ${err}`); + log?.error(`Immediate execution error: ${err}`); }); } }; diff --git a/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts new file mode 100644 index 00000000000..4909de7cef8 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/outbound-dispatch.ts @@ -0,0 +1,403 @@ +/** + * Outbound dispatcher — manage AI reply delivery, tool fallback, and timeouts. + * + * Responsibilities: + * 1. Build ctxPayload and call runtime.dispatchReply + * 2. Tool deliver collection + fallback timeout + * 3. Block deliver pipeline (consumeQuoteRef → media tags → structured payload → plain text) + * 4. Timeout / error handling + * + * Separated from gateway.ts for testability and to keep handleMessage thin. + */ + +import { + parseAndSendMediaTags, + sendPlainReply, + type DeliverDeps, +} from "../messaging/outbound-deliver.js"; +import { + sendDocument, + sendMedia, + sendPhoto, + sendVoice, + sendVideoMsg, +} from "../messaging/outbound.js"; +import { + handleStructuredPayload, + sendErrorToTarget, + sendWithTokenRetry, + type ReplyDispatcherDeps, +} from "../messaging/reply-dispatcher.js"; +import { audioFileToSilkBase64 } from "../utils/audio.js"; +import type { InboundContext } from "./inbound-context.js"; +import type { + GatewayAccount, + EngineLogger, + GatewayPluginRuntime, + OutboundResult, +} from "./types.js"; + +// ============ Config ============ + +const RESPONSE_TIMEOUT = 120_000; +const TOOL_ONLY_TIMEOUT = 60_000; +const MAX_TOOL_RENEWALS = 3; +const TOOL_MEDIA_SEND_TIMEOUT = 45_000; + +// ============ Dependencies ============ + +export interface OutboundDispatchDeps { + runtime: GatewayPluginRuntime; + cfg: unknown; + account: GatewayAccount; + log?: EngineLogger; +} + +// ============ dispatchOutbound ============ + +/** + * Dispatch the AI reply for the given inbound context. + * + * Handles tool deliver collection, block deliver pipeline, and timeouts. + * The caller is responsible for stopping typing.keepAlive in `finally`. + */ +export async function dispatchOutbound( + inbound: InboundContext, + deps: OutboundDispatchDeps, +): Promise { + const { runtime, cfg, account, log } = deps; + const { event, qualifiedTarget } = inbound; + + const replyTarget = { + type: event.type, + senderId: event.senderId, + messageId: event.messageId, + channelId: event.channelId, + guildId: event.guildId, + groupOpenid: event.groupOpenid, + }; + const replyCtx = { target: replyTarget, account, cfg, log }; + + const sendWithRetry = (sendFn: (token: string) => Promise) => + sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId); + + const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText); + + // ---- Build ctxPayload ---- + const ctxPayload = buildCtxPayload(inbound, runtime); + + // ---- Deliver state ---- + let hasResponse = false; + let hasBlockResponse = false; + let toolDeliverCount = 0; + const toolTexts: string[] = []; + const toolMediaUrls: string[] = []; + let toolFallbackSent = false; + let toolRenewalCount = 0; + let timeoutId: ReturnType | null = null; + let toolOnlyTimeoutId: ReturnType | null = null; + + // ---- Tool fallback ---- + const sendToolFallback = async (): Promise => { + if (toolMediaUrls.length > 0) { + for (const mediaUrl of toolMediaUrls) { + const ac = new AbortController(); + try { + const result = await Promise.race([ + sendMedia({ + to: qualifiedTarget, + text: "", + mediaUrl, + accountId: account.accountId, + replyToId: event.messageId, + account, + }).then((r) => { + if (ac.signal.aborted) { + return { channel: "qqbot", error: "suppressed" } as OutboundResult; + } + return r; + }), + new Promise((resolve) => + setTimeout(() => { + ac.abort(); + resolve({ channel: "qqbot", error: "timeout" }); + }, TOOL_MEDIA_SEND_TIMEOUT), + ), + ]); + if (result.error) { + log?.error(`Tool fallback error: ${result.error}`); + } + } catch (err) { + log?.error(`Tool fallback failed: ${String(err)}`); + } + } + return; + } + if (toolTexts.length > 0) { + await sendErrorMessage(toolTexts.slice(-3).join("\n---\n").slice(0, 2000)); + } + }; + + // ---- Timeout promise ---- + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + if (!hasResponse) { + reject(new Error("Response timeout")); + } + }, RESPONSE_TIMEOUT); + }); + + // ---- Deliver deps ---- + const deliverDeps: DeliverDeps = { + mediaSender: { + sendPhoto: (target, imageUrl) => sendPhoto(target, imageUrl), + sendVoice: (target, voicePath, uploadFormats, transcodeEnabled) => + sendVoice(target, voicePath, uploadFormats, transcodeEnabled), + sendVideoMsg: (target, videoPath) => sendVideoMsg(target, videoPath), + sendDocument: (target, filePath) => sendDocument(target, filePath), + sendMedia: (opts) => sendMedia(opts), + }, + chunkText: (text, limit) => runtime.channel.text.chunkMarkdownText(text, limit), + }; + + const replyDeps: ReplyDispatcherDeps = { + tts: { + textToSpeech: (params) => runtime.tts.textToSpeech(params), + audioFileToSilkBase64: async (p) => (await audioFileToSilkBase64(p)) ?? undefined, + }, + }; + + const recordOutbound = () => + runtime.channel.activity.record({ + channel: "qqbot", + accountId: account.accountId, + direction: "outbound", + }); + + // ---- Dispatch ---- + const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig( + cfg, + inbound.route.agentId, + ); + + const dispatchPromise = runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg, + dispatcherOptions: { + responsePrefix: messagesConfig.responsePrefix, + deliver: async ( + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, + info: { kind: string }, + ) => { + hasResponse = true; + + // ---- Tool deliver ---- + if (info.kind === "tool") { + toolDeliverCount++; + const toolText = (payload.text ?? "").trim(); + if (toolText) { + toolTexts.push(toolText); + } + if (payload.mediaUrls?.length) { + toolMediaUrls.push(...payload.mediaUrls); + } + if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { + toolMediaUrls.push(payload.mediaUrl); + } + + if (hasBlockResponse && toolMediaUrls.length > 0) { + const urlsToSend = [...toolMediaUrls]; + toolMediaUrls.length = 0; + for (const mediaUrl of urlsToSend) { + try { + await sendMedia({ + to: qualifiedTarget, + text: "", + mediaUrl, + accountId: account.accountId, + replyToId: event.messageId, + account, + }); + } catch {} + } + return; + } + if (toolFallbackSent) { + return; + } + if (toolOnlyTimeoutId) { + if (toolRenewalCount < MAX_TOOL_RENEWALS) { + clearTimeout(toolOnlyTimeoutId); + toolRenewalCount++; + } else { + return; + } + } + toolOnlyTimeoutId = setTimeout(async () => { + if (!hasBlockResponse && !toolFallbackSent) { + toolFallbackSent = true; + try { + await sendToolFallback(); + } catch {} + } + }, TOOL_ONLY_TIMEOUT); + return; + } + + // ---- Block deliver ---- + hasBlockResponse = true; + inbound.typing.keepAlive?.stop(); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (toolOnlyTimeoutId) { + clearTimeout(toolOnlyTimeoutId); + toolOnlyTimeoutId = null; + } + + const quoteRef = event.msgIdx; + let quoteRefUsed = false; + const consumeQuoteRef = (): string | undefined => { + if (quoteRef && !quoteRefUsed) { + quoteRefUsed = true; + return quoteRef; + } + return undefined; + }; + + let replyText = payload.text ?? ""; + const deliverEvent = { + type: event.type, + senderId: event.senderId, + messageId: event.messageId, + channelId: event.channelId, + groupOpenid: event.groupOpenid, + msgIdx: event.msgIdx, + }; + const deliverActx = { account, qualifiedTarget, log }; + + // 1. Media tags + const mediaResult = await parseAndSendMediaTags( + replyText, + deliverEvent, + deliverActx, + sendWithRetry, + consumeQuoteRef, + deliverDeps, + ); + if (mediaResult.handled) { + recordOutbound(); + return; + } + replyText = mediaResult.normalizedText; + + // 2. Structured payload (QQBOT_PAYLOAD:) + const handled = await handleStructuredPayload( + replyCtx, + replyText, + recordOutbound, + replyDeps, + ); + if (handled) { + return; + } + + // 3. Plain text + images + await sendPlainReply( + payload, + replyText, + deliverEvent, + deliverActx, + sendWithRetry, + consumeQuoteRef, + toolMediaUrls, + deliverDeps, + ); + recordOutbound(); + }, + onError: async (err: unknown) => { + const errMsg = err instanceof Error ? err.message : String(err); + log?.error(`Dispatch error: ${errMsg}`); + hasResponse = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + }, + }, + replyOptions: { disableBlockStreaming: account.config.streaming?.mode === "off" }, + }); + + try { + await Promise.race([dispatchPromise, timeoutPromise]); + } catch { + if (timeoutId) { + clearTimeout(timeoutId); + } + } finally { + if (toolOnlyTimeoutId) { + clearTimeout(toolOnlyTimeoutId); + toolOnlyTimeoutId = null; + } + if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) { + toolFallbackSent = true; + await sendToolFallback(); + } + } +} + +// ============ ctxPayload builder ============ + +function buildCtxPayload(inbound: InboundContext, runtime: GatewayPluginRuntime): unknown { + const { event } = inbound; + return runtime.channel.reply.finalizeInboundContext({ + Body: inbound.body, + BodyForAgent: inbound.agentBody, + RawBody: event.content, + CommandBody: event.content, + From: inbound.fromAddress, + To: inbound.fromAddress, + SessionKey: inbound.route.sessionKey, + AccountId: inbound.route.accountId, + ChatType: inbound.isGroupChat ? "group" : "direct", + GroupSystemPrompt: inbound.groupSystemPrompt, + SenderId: event.senderId, + SenderName: event.senderName, + Provider: "qqbot", + Surface: "qqbot", + MessageSid: event.messageId, + Timestamp: new Date(event.timestamp).getTime(), + OriginatingChannel: "qqbot", + OriginatingTo: inbound.fromAddress, + QQChannelId: event.channelId, + QQGuildId: event.guildId, + QQGroupOpenid: event.groupOpenid, + QQVoiceAsrReferAvailable: inbound.hasAsrReferFallback, + QQVoiceTranscriptSources: inbound.voiceTranscriptSources, + QQVoiceAttachmentPaths: inbound.uniqueVoicePaths, + QQVoiceAttachmentUrls: inbound.uniqueVoiceUrls, + QQVoiceAsrReferTexts: inbound.uniqueVoiceAsrReferTexts, + QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback", + CommandAuthorized: inbound.commandAuthorized, + ...(inbound.localMediaPaths.length > 0 + ? { + MediaPaths: inbound.localMediaPaths, + MediaPath: inbound.localMediaPaths[0], + MediaTypes: inbound.localMediaTypes, + MediaType: inbound.localMediaTypes[0], + } + : {}), + ...(inbound.remoteMediaUrls.length > 0 + ? { MediaUrls: inbound.remoteMediaUrls, MediaUrl: inbound.remoteMediaUrls[0] } + : {}), + ...(inbound.replyTo + ? { + ReplyToId: inbound.replyTo.id, + ReplyToBody: inbound.replyTo.body, + ReplyToSender: inbound.replyTo.sender, + ReplyToIsQuote: inbound.replyTo.isQuote, + } + : {}), + }); +} diff --git a/extensions/qqbot/src/engine/gateway/reconnect.ts b/extensions/qqbot/src/engine/gateway/reconnect.ts new file mode 100644 index 00000000000..32d4fd50c0d --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/reconnect.ts @@ -0,0 +1,199 @@ +/** + * WebSocket reconnection state machine and close-code handler. + * + * Encapsulates the reconnect delay scheduling, quick-disconnect detection, + * and close-code interpretation that both plugin versions share. + * + * Zero external dependencies — uses only the constants from `./constants.ts`. + */ + +import type { EngineLogger } from "../types.js"; +import { + RECONNECT_DELAYS, + RATE_LIMIT_DELAY, + MAX_RECONNECT_ATTEMPTS, + MAX_QUICK_DISCONNECT_COUNT, + QUICK_DISCONNECT_THRESHOLD, + GatewayCloseCode, +} from "./constants.js"; + +/** Actions the caller should take after processing a close event. */ +export interface CloseAction { + /** Whether to schedule a reconnect. */ + shouldReconnect: boolean; + /** Custom delay override (ms), or undefined to use the default backoff. */ + reconnectDelay?: number; + /** Whether the session is invalidated and should be cleared. */ + clearSession: boolean; + /** Whether the token should be refreshed before reconnecting. */ + refreshToken: boolean; + /** Whether the bot is fatally blocked (offline/banned) and should stop. */ + fatal: boolean; + /** Human-readable description of the close reason. */ + reason: string; +} + +/** + * Reconnection state machine. + * + * Usage: + * ```ts + * const rs = new ReconnectState('account-1', log); + * // On successful connect: + * rs.onConnected(); + * // On close: + * const action = rs.handleClose(code); + * if (action.shouldReconnect) { + * const delay = rs.getNextDelay(action.reconnectDelay); + * setTimeout(connect, delay); + * } + * ``` + */ +export class ReconnectState { + private attempts = 0; + private lastConnectTime = 0; + private quickDisconnectCount = 0; + + constructor( + private readonly accountId: string, + private readonly log?: EngineLogger, + ) {} + + /** Call when a WebSocket connection is successfully established. */ + onConnected(): void { + this.attempts = 0; + this.lastConnectTime = Date.now(); + } + + /** Whether reconnection attempts are exhausted. */ + isExhausted(): boolean { + return this.attempts >= MAX_RECONNECT_ATTEMPTS; + } + + /** + * Compute the next reconnect delay and increment the attempt counter. + * + * @param customDelay Override from `CloseAction.reconnectDelay`. + * @returns Delay in milliseconds. + */ + getNextDelay(customDelay?: number): number { + const delay = + customDelay ?? RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)]; + this.attempts++; + this.log?.debug?.(`Reconnecting in ${delay}ms (attempt ${this.attempts})`); + return delay; + } + + /** + * Interpret a WebSocket close code and return the appropriate action. + */ + handleClose(code: number, isAborted: boolean): CloseAction { + // Fatal: bot offline or banned. + if ( + code === GatewayCloseCode.INSUFFICIENT_INTENTS || + code === GatewayCloseCode.DISALLOWED_INTENTS + ) { + const reason = + code === GatewayCloseCode.INSUFFICIENT_INTENTS ? "offline/sandbox-only" : "banned"; + this.log?.error(`Bot is ${reason}. Please contact QQ platform.`); + return { + shouldReconnect: false, + clearSession: false, + refreshToken: false, + fatal: true, + reason, + }; + } + + // Invalid token. + if (code === GatewayCloseCode.AUTH_FAILED) { + this.log?.info(`Invalid token (4004), will refresh token and reconnect`); + return { + shouldReconnect: !isAborted, + clearSession: false, + refreshToken: true, + fatal: false, + reason: "invalid token (4004)", + }; + } + + // Rate limited. + if (code === GatewayCloseCode.RATE_LIMITED) { + this.log?.info(`Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms`); + return { + shouldReconnect: !isAborted, + reconnectDelay: RATE_LIMIT_DELAY, + clearSession: false, + refreshToken: false, + fatal: false, + reason: "rate limited (4008)", + }; + } + + // Session invalid / seq invalid / session timeout. + if ( + code === GatewayCloseCode.INVALID_SESSION || + code === GatewayCloseCode.SEQ_OUT_OF_RANGE || + code === GatewayCloseCode.SESSION_TIMEOUT + ) { + const codeDesc: Record = { + [GatewayCloseCode.INVALID_SESSION]: "session no longer valid", + [GatewayCloseCode.SEQ_OUT_OF_RANGE]: "invalid seq on resume", + [GatewayCloseCode.SESSION_TIMEOUT]: "session timed out", + }; + this.log?.info(`Error ${code} (${codeDesc[code]}), will re-identify`); + return { + shouldReconnect: !isAborted, + clearSession: true, + refreshToken: true, + fatal: false, + reason: codeDesc[code], + }; + } + + // Internal server errors. + if (code >= GatewayCloseCode.SERVER_ERROR_START && code <= GatewayCloseCode.SERVER_ERROR_END) { + this.log?.info(`Internal error (${code}), will re-identify`); + return { + shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL, + clearSession: true, + refreshToken: true, + fatal: false, + reason: `internal error (${code})`, + }; + } + + // Quick disconnect detection. + const connectionDuration = Date.now() - this.lastConnectTime; + if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && this.lastConnectTime > 0) { + this.quickDisconnectCount++; + this.log?.debug?.( + `Quick disconnect detected (${connectionDuration}ms), count: ${this.quickDisconnectCount}`, + ); + + if (this.quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { + this.log?.error(`Too many quick disconnects. This may indicate a permission issue.`); + this.quickDisconnectCount = 0; + return { + shouldReconnect: !isAborted && code !== 1000, + reconnectDelay: RATE_LIMIT_DELAY, + clearSession: false, + refreshToken: false, + fatal: false, + reason: "too many quick disconnects", + }; + } + } else { + this.quickDisconnectCount = 0; + } + + // Default: reconnect with backoff. + return { + shouldReconnect: !isAborted && code !== GatewayCloseCode.NORMAL, + clearSession: false, + refreshToken: false, + fatal: false, + reason: `close code ${code}`, + }; + } +} diff --git a/extensions/qqbot/src/engine/gateway/types.ts b/extensions/qqbot/src/engine/gateway/types.ts new file mode 100644 index 00000000000..a6662dc6118 --- /dev/null +++ b/extensions/qqbot/src/engine/gateway/types.ts @@ -0,0 +1,170 @@ +/** + * Gateway types. + * + * core/gateway/gateway.ts now imports all dependencies directly (both + * core/ modules and upper-layer files). The only injected dependency + * is `runtime` (PluginRuntime), which is a framework-provided object. + */ + +// ============ Logger ============ +import type { EngineLogger } from "../types.js"; +export type { EngineLogger }; + +// ============ Account ============ + +/** Re-export GatewayAccount from engine/types.ts (single source of truth). */ +import type { GatewayAccount as _GatewayAccount } from "../types.js"; +export type GatewayAccount = _GatewayAccount; + +// ============ PluginRuntime subset ============ + +/** + * Subset of PluginRuntime used by the gateway. + * + * This is NOT a custom adapter — it's the exact same object shape that + * the framework injects. We define it here so core/ doesn't need to + * depend on the plugin-sdk root barrel. + */ +export interface GatewayPluginRuntime { + channel: { + activity: { + record: (params: { + channel: string; + accountId: string; + direction: "inbound" | "outbound"; + }) => void; + }; + routing: { + resolveAgentRoute: (params: { + cfg: unknown; + channel: string; + accountId: string; + peer: { kind: "group" | "direct"; id: string }; + }) => { sessionKey: string; accountId: string; agentId?: string }; + }; + reply: { + dispatchReplyWithBufferedBlockDispatcher: (params: unknown) => Promise; + resolveEffectiveMessagesConfig: ( + cfg: unknown, + agentId?: string, + ) => { responsePrefix?: string }; + finalizeInboundContext: (fields: Record) => unknown; + formatInboundEnvelope: (params: unknown) => string; + resolveEnvelopeFormatOptions: (cfg: unknown) => unknown; + }; + text: { + chunkMarkdownText: (text: string, limit: number) => string[]; + }; + }; + tts: { + textToSpeech: (params: { text: string; cfg: unknown; channel: string }) => Promise<{ + success: boolean; + audioPath?: string; + provider?: string; + outputFormat?: string; + error?: string; + }>; + }; +} + +// ============ Shared result types ============ + +/** Re-export ProcessedAttachments from inbound-attachments (single source of truth). */ +export type { ProcessedAttachments } from "./inbound-attachments.js"; + +/** Outbound result from media sends. */ +export interface OutboundResult { + channel: string; + messageId?: string; + timestamp?: string | number; + error?: string; +} + +/** Re-export RefAttachmentSummary for convenience. */ +export type { RefAttachmentSummary } from "../ref/types.js"; + +// ============ WebSocket Event Types ============ + +/** Raw WebSocket payload structure. */ +export interface WSPayload { + op: number; + d: unknown; + s?: number; + t?: string; +} + +/** Attachment shape shared by all message event types. */ +export interface RawMessageAttachment { + content_type: string; + url: string; + filename?: string; + voice_wav_url?: string; + asr_refer_text?: string; +} + +/** Referenced message element (used for quote messages). */ +export interface RawMsgElement { + msg_idx?: string; + content?: string; + attachments?: Array< + RawMessageAttachment & { + height?: number; + width?: number; + size?: number; + } + >; +} + +export interface C2CMessageEvent { + id: string; + content: string; + timestamp: string; + author: { user_openid: string }; + attachments?: RawMessageAttachment[]; + message_scene?: { ext?: string[] }; + message_type?: number; + msg_elements?: RawMsgElement[]; +} + +export interface GuildMessageEvent { + id: string; + content: string; + timestamp: string; + author: { id: string; username?: string }; + channel_id: string; + guild_id: string; + attachments?: RawMessageAttachment[]; + message_scene?: { ext?: string[] }; +} + +export interface GroupMessageEvent { + id: string; + content: string; + timestamp: string; + author: { member_openid: string }; + group_openid: string; + attachments?: RawMessageAttachment[]; + message_scene?: { ext?: string[] }; + message_type?: number; + msg_elements?: RawMsgElement[]; +} + +// ============ Gateway Context ============ + +/** Full gateway startup context. Only `runtime` is injected; everything else is imported directly. */ +export interface CoreGatewayContext { + account: GatewayAccount; + abortSignal: AbortSignal; + cfg: unknown; + onReady?: (data: unknown) => void; + /** + * Invoked when a RESUMED event is received after reconnect. + * Falls back to `onReady` when not provided so existing callers + * keep their current behaviour. + */ + onResumed?: (data: unknown) => void; + onError?: (error: Error) => void; + log?: EngineLogger; + /** PluginRuntime injected by the framework — same object in both versions. */ + runtime: GatewayPluginRuntime; +} diff --git a/extensions/qqbot/src/typing-keepalive.ts b/extensions/qqbot/src/engine/gateway/typing-keepalive.ts similarity index 58% rename from extensions/qqbot/src/typing-keepalive.ts rename to extensions/qqbot/src/engine/gateway/typing-keepalive.ts index 59d85cad761..c385651803b 100644 --- a/extensions/qqbot/src/typing-keepalive.ts +++ b/extensions/qqbot/src/engine/gateway/typing-keepalive.ts @@ -1,8 +1,21 @@ -/** Periodically refresh C2C typing state while a response is still in progress. */ +/** + * Periodically refresh C2C typing state while a response is in progress. + * + * All I/O operations are injected via constructor parameters so this + * module has zero external dependencies and can run in both plugin versions. + */ -import { sendC2CInputNotify } from "./api.js"; +import { formatErrorMessage } from "../utils/format.js"; -// Refresh every 50s for the QQ API's 60s input-notify window. +/** Function that sends a typing indicator to one user. */ +export type SendInputNotifyFn = ( + token: string, + openid: string, + msgId: string | undefined, + inputSecond: number, +) => Promise; + +/** 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; @@ -13,6 +26,7 @@ export class TypingKeepAlive { constructor( private readonly getToken: () => Promise, private readonly clearCache: () => void, + private readonly sendInputNotify: SendInputNotifyFn, private readonly openid: string, private readonly msgId: string | undefined, private readonly log?: { @@ -20,7 +34,6 @@ export class TypingKeepAlive { error: (msg: string) => void; debug?: (msg: string) => void; }, - private readonly logPrefix = "[qqbot]", ) {} /** Start periodic keep-alive sends. */ @@ -49,16 +62,16 @@ export class TypingKeepAlive { private async send(): Promise { 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}`); + await this.sendInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND); + this.log?.debug?.(`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); + await this.sendInputNotify(token, this.openid, this.msgId, TYPING_INPUT_SECOND); } catch { this.log?.debug?.( - `${this.logPrefix} Typing keep-alive failed for ${this.openid}: ${String(err)}`, + `Typing keep-alive failed for ${this.openid}: ${formatErrorMessage(err)}`, ); } } diff --git a/extensions/qqbot/src/engine/group/deliver-debounce.ts b/extensions/qqbot/src/engine/group/deliver-debounce.ts new file mode 100644 index 00000000000..a3eb5c6edc3 --- /dev/null +++ b/extensions/qqbot/src/engine/group/deliver-debounce.ts @@ -0,0 +1,155 @@ +/** + * Message deliver debounce — merge multiple rapid deliver calls into one. + * + * When QQ Bot sends multiple messages in quick succession (e.g. streaming + * partial responses), this module buffers them within a configurable time + * window and merges them into a single outbound message. + * + * This prevents "message bombing" in group chats where rapid-fire messages + * flood the chat and annoy users. + * + * The module is a pure function / class with zero I/O dependencies. + */ + +/** Configuration for the deliver debouncer. */ +export interface DeliverDebounceConfig { + /** Whether debouncing is enabled. Defaults to true. */ + enabled: boolean; + /** Time window in milliseconds. Defaults to 1500ms. */ + windowMs?: number; +} + +/** Payload passed to deliver callbacks. */ +export interface DeliverPayload { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; +} + +/** Deliver callback info. */ +export interface DeliverInfo { + kind: string; +} + +/** The actual deliver function signature. */ +export type DeliverFn = (payload: DeliverPayload, info: DeliverInfo) => Promise; + +interface PendingEntry { + texts: string[]; + mediaUrls: string[]; + timer: ReturnType; + resolve: () => void; +} + +/** + * Debouncer that merges rapid-fire deliver calls within a time window. + * + * Usage: + * ```ts + * const debouncer = new DeliverDebouncer({ enabled: true, windowMs: 1500 }); + * + * // In the deliver callback: + * await debouncer.deliver(payload, info, originalDeliverFn); + * ``` + */ +export class DeliverDebouncer { + private readonly enabled: boolean; + private readonly windowMs: number; + private readonly pending = new Map(); + + constructor(config?: DeliverDebounceConfig) { + this.enabled = config?.enabled !== false; + this.windowMs = config?.windowMs ?? 1500; + } + + /** + * Buffer a deliver call and flush after the window expires. + * + * @param payload - The deliver payload. + * @param info - Deliver metadata (kind, etc.). + * @param actualDeliver - The real deliver function to call with merged content. + * @param peerId - Peer identifier for per-conversation debouncing. + */ + async deliver( + payload: DeliverPayload, + info: DeliverInfo, + actualDeliver: DeliverFn, + peerId = "default", + ): Promise { + // Pass through immediately when debouncing is disabled. + if (!this.enabled) { + return actualDeliver(payload, info); + } + + // Media payloads flush any buffered text first, then send immediately. + const hasMedia = (payload.mediaUrls && payload.mediaUrls.length > 0) || !!payload.mediaUrl; + + if (hasMedia) { + await this.flush(peerId, actualDeliver, info); + return actualDeliver(payload, info); + } + + const text = (payload.text ?? "").trim(); + if (!text) { + return; + } + + const existing = this.pending.get(peerId); + if (existing) { + // Extend the buffer with the new text. + existing.texts.push(text); + // Reset the timer. + clearTimeout(existing.timer); + existing.timer = setTimeout(() => { + this.flush(peerId, actualDeliver, info).catch(() => {}); + }, this.windowMs); + // The caller awaits the same promise as the first buffered call. + return new Promise((resolve) => { + const origResolve = existing.resolve; + existing.resolve = () => { + origResolve(); + resolve(); + }; + }); + } + + // First message in a new window — start buffering. + return new Promise((resolve) => { + const entry: PendingEntry = { + texts: [text], + mediaUrls: [], + timer: setTimeout(() => { + this.flush(peerId, actualDeliver, info).catch(() => {}); + }, this.windowMs), + resolve, + }; + this.pending.set(peerId, entry); + }); + } + + /** Flush buffered content for a peer and invoke the actual deliver. */ + private async flush(peerId: string, actualDeliver: DeliverFn, info: DeliverInfo): Promise { + const entry = this.pending.get(peerId); + if (!entry) { + return; + } + + this.pending.delete(peerId); + clearTimeout(entry.timer); + + const mergedText = entry.texts.join("\n").trim(); + if (mergedText) { + await actualDeliver({ text: mergedText }, info); + } + + entry.resolve(); + } + + /** Force-flush all pending entries (e.g. during shutdown). */ + async flushAll(actualDeliver: DeliverFn, info: DeliverInfo): Promise { + const peerIds = [...this.pending.keys()]; + for (const peerId of peerIds) { + await this.flush(peerId, actualDeliver, info); + } + } +} diff --git a/extensions/qqbot/src/engine/group/message-gating.ts b/extensions/qqbot/src/engine/group/message-gating.ts new file mode 100644 index 00000000000..5379075ddde --- /dev/null +++ b/extensions/qqbot/src/engine/group/message-gating.ts @@ -0,0 +1,72 @@ +/** + * Group message gating — three-layer access control for group messages. + * + * 1. `ignoreOtherMentions` — skip messages that @other bots, not this one. + * 2. `shouldBlock` — enforce allowFrom whitelist at the group level. + * 3. `mentionGating` — require explicit @bot mention in group chats. + * + * All functions are **pure** (no side effects, no I/O), making them easy to + * test and safe to share between the built-in and standalone versions. + */ + +/** Result of the group message gate evaluation. */ +export interface GateResult { + /** Whether the message should be blocked (i.e. not processed). */ + blocked: boolean; + /** Reason for blocking (for logging). */ + reason?: string; + /** Whether the sender is authorized for slash commands. */ + commandAuthorized: boolean; +} + +/** Configuration relevant to group message gating. */ +export interface GroupGateConfig { + /** Normalized allowFrom list (uppercase, `qqbot:` prefix stripped). */ + normalizedAllowFrom: string[]; + /** + * Whether to ignore messages that mention other bots. + * When true, messages containing @mentions for other bot IDs are silently dropped. + */ + ignoreOtherMentions?: boolean; +} + +/** + * Evaluate the group message gate for one inbound message. + * + * @param senderId - The sender's openid (raw, not normalized). + * @param config - Group gating configuration. + * @returns The gate evaluation result. + */ +export function resolveGroupMessageGate(senderId: string, config: GroupGateConfig): GateResult { + const { normalizedAllowFrom } = config; + + // Normalize the sender ID for comparison. + const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase(); + + // Open gate: empty allowFrom or wildcard means everyone is allowed. + const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*"); + + const commandAuthorized = allowAll || normalizedAllowFrom.includes(normalizedSenderId); + + return { + blocked: false, + commandAuthorized, + }; +} + +/** + * Normalize an allowFrom list by stripping `qqbot:` prefixes and uppercasing. + * + * @param allowFrom - Raw allowFrom config entries. + * @returns Normalized entries for comparison. + */ +export function normalizeAllowFrom(allowFrom: Array | undefined | null): string[] { + if (!allowFrom) { + return []; + } + return allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^qqbot:/i, "")) + .map((entry) => entry.toUpperCase()); +} diff --git a/extensions/qqbot/src/engine/messaging/decode-media-path.ts b/extensions/qqbot/src/engine/messaging/decode-media-path.ts new file mode 100644 index 00000000000..bf5fa8cb452 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/decode-media-path.ts @@ -0,0 +1,82 @@ +/** + * Media path decoding utility. + * + * Extracted from `outbound-deliver.ts` — handles the `MEDIA:` prefix stripping, + * tilde expansion, octal escape / UTF-8 byte-sequence decoding, and backslash + * unescaping that media tags require. + * + * Zero external dependencies. + */ + +import type { EngineLogger } from "../types.js"; + +/** + * Normalize a file path by expanding `~` to the home directory and trimming. + * + * This is a minimal re-implementation of `utils/platform.ts#normalizePath` + * so that `core/` remains self-contained. + */ +function normalizePath(p: string): string { + let result = p.trim(); + if (result.startsWith("~/") || result === "~") { + const home = + typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined; + if (home) { + result = result === "~" ? home : `${home}${result.slice(1)}`; + } + } + return result; +} + +/** + * Decode a media path by stripping `MEDIA:`, expanding `~`, and unescaping + * octal/UTF-8 byte sequences. + * + * @param raw - Raw path string from a media tag. + * @param log - Optional logger for decode diagnostics. + * @returns The decoded, normalized media path. + */ +export function decodeMediaPath(raw: string, log?: EngineLogger): 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?.(`Decoding path with mixed encoding: ${mediaPath}`); + const 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?.(`Successfully decoded path: ${mediaPath}`); + } + } + } catch (decodeErr) { + log?.error(`Path decode error: ${String(decodeErr)}`); + } + + return mediaPath; +} diff --git a/extensions/qqbot/src/engine/messaging/media-type-detect.ts b/extensions/qqbot/src/engine/messaging/media-type-detect.ts new file mode 100644 index 00000000000..f7cc80f54a3 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/media-type-detect.ts @@ -0,0 +1,122 @@ +/** + * Media type detection — pure functions for classifying files by MIME or extension. + * + * These replace the inline `isImageFile`, `isVideoFile`, `isAudioFile` helpers + * scattered across `outbound.ts`. Centralizing them here ensures consistent + * detection across both the built-in and standalone versions. + */ + +/** Supported media kind for QQ Bot outbound routing. */ +export type MediaKind = "image" | "voice" | "video" | "file"; + +/** Display labels for media kinds. */ +export const MEDIA_KIND_LABELS: Record = { + image: "Image", + voice: "Voice", + video: "Video", + file: "File", + media: "Media", +}; + +const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]); +const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]); +const AUDIO_EXTENSIONS = new Set([ + ".mp3", + ".wav", + ".ogg", + ".flac", + ".aac", + ".m4a", + ".wma", + ".opus", + ".amr", + ".silk", + ".slk", + ".pcm", +]); + +/** + * Extract a lowercase file extension from a path or URL, ignoring query and hash. + */ +export function getCleanExtension(filePath: string): string { + const cleanPath = filePath.split("?")[0].split("#")[0]; + const lastDot = cleanPath.lastIndexOf("."); + if (lastDot < 0) { + return ""; + } + return cleanPath.slice(lastDot).toLowerCase(); +} + +/** Check whether a file is an image using MIME first and extension as fallback. */ +export function isImageFile(filePath: string, mimeType?: string): boolean { + if (mimeType?.startsWith("image/")) { + return true; + } + return IMAGE_EXTENSIONS.has(getCleanExtension(filePath)); +} + +/** Check whether a file is a video using MIME first and extension as fallback. */ +export function isVideoFile(filePath: string, mimeType?: string): boolean { + if (mimeType?.startsWith("video/")) { + return true; + } + return VIDEO_EXTENSIONS.has(getCleanExtension(filePath)); +} + +/** Check whether a file is audio using MIME first and extension as fallback. */ +export function isAudioFile(filePath: string, mimeType?: string): boolean { + if (mimeType) { + if ( + mimeType.startsWith("audio/") || + mimeType === "voice" || + mimeType.includes("silk") || + mimeType.includes("amr") + ) { + return true; + } + } + return AUDIO_EXTENSIONS.has(getCleanExtension(filePath)); +} + +/** + * Auto-detect the media kind from a file path and optional MIME type. + * + * Priority: audio → video → image → file (default). + */ +export function detectMediaKind(filePath: string, mimeType?: string): MediaKind { + if (isAudioFile(filePath, mimeType)) { + return "voice"; + } + if (isVideoFile(filePath, mimeType)) { + return "video"; + } + if (isImageFile(filePath, mimeType)) { + return "image"; + } + return "file"; +} + +/** Return true when the source is a remote HTTP(S) URL. */ +export function isHttpSource(source: string): boolean { + return source.startsWith("http://") || source.startsWith("https://"); +} + +/** Return true when the source is a Base64 data URL. */ +export function isDataSource(source: string): boolean { + return source.startsWith("data:"); +} + +/** Return true when the source is a remote URL or data URL. */ +export function isRemoteOrDataSource(source: string): boolean { + return isHttpSource(source) || isDataSource(source); +} + +/** Common MIME type mapping for image extensions. */ +export const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +}; diff --git a/extensions/qqbot/src/outbound-deliver.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts similarity index 53% rename from extensions/qqbot/src/outbound-deliver.ts rename to extensions/qqbot/src/engine/messaging/outbound-deliver.ts index ed2a43e7d63..db218e0362f 100644 --- a/extensions/qqbot/src/outbound-deliver.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts @@ -1,40 +1,79 @@ /** - * Outbound delivery helpers. + * Outbound delivery helpers — core/ version. * - * The gateway deliver callback uses two pipelines: - * 1. `parseAndSendMediaTags` handles `` tags in order. - * 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media. + * Uses the unified `sender.ts` business function layer for all text and + * image sending. Media sends (photo/voice/video/file) are injected via + * `DeliverDeps.mediaSender`. */ +import type { GatewayAccount } from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js"; +import { normalizeMediaTags } from "../utils/media-tags.js"; +import { isLocalPath as isLocalFilePath } from "../utils/platform.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +} from "../utils/string-normalize.js"; +import { filterInternalMarkers } from "../utils/text-parsing.js"; +import { decodeMediaPath } from "./decode-media-path.js"; 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"; + sendText as senderSendText, + sendImage as senderSendImage, + withTokenRetry, + buildDeliveryTarget, + accountToCreds, +} from "./sender.js"; -// Type definitions. +// ---- Injected dependency interfaces ---- + +/** Media target context — describes where to send media. */ +export interface MediaTargetContext { + targetType: "c2c" | "group" | "channel" | "dm"; + targetId: string; + account: GatewayAccount; + replyToId?: string; +} + +/** Media send result. */ +export interface MediaSendResult { + channel?: string; + error?: string; + messageId?: string; +} + +/** Media sender interface — implemented by the upper-layer outbound.ts module. */ +export interface MediaSender { + sendPhoto(target: MediaTargetContext, imageUrl: string): Promise; + sendVoice( + target: MediaTargetContext, + voicePath: string, + uploadFormats?: string[], + transcodeEnabled?: boolean, + ): Promise; + sendVideoMsg(target: MediaTargetContext, videoPath: string): Promise; + sendDocument(target: MediaTargetContext, filePath: string): Promise; + sendMedia(opts: { + to: string; + text: string; + mediaUrl: string; + accountId: string; + replyToId: string; + account: GatewayAccount; + }): Promise; +} + +/** Delivery dependencies — injected when calling parseAndSendMediaTags / sendPlainReply. */ +export interface DeliverDeps { + mediaSender: MediaSender; + /** Text chunker — delegates to `runtime.channel.text.chunkMarkdownText`. */ + chunkText: (text: string, limit: number) => string[]; +} + +// ---- Exported types ---- + +/** Maximum text length for a single QQ Bot message. */ +export const TEXT_CHUNK_LIMIT = 5000; export interface DeliverEventContext { type: "c2c" | "guild" | "dm" | "group"; @@ -47,7 +86,7 @@ export interface DeliverEventContext { } export interface DeliverAccountContext { - account: ResolvedQQBotAccount; + account: GatewayAccount; qualifiedTarget: string; log?: { info: (msg: string) => void; @@ -62,34 +101,11 @@ export type SendWithRetryFn = (sendFn: (token: string) => Promise) => Prom /** Consume a quote ref exactly once. */ export type ConsumeQuoteRefFn = () => string | undefined; -type ReplyModeParams = { - textWithoutImages: string; - imageUrls: string[]; - mdMatches: RegExpMatchArray[]; - bareUrlMatches: RegExpMatchArray[]; - event: DeliverEventContext; - actx: DeliverAccountContext; - sendWithRetry: SendWithRetryFn; - consumeQuoteRef: ConsumeQuoteRefFn; -}; +// ---- Internal helpers ---- -function resolveReplyModeRuntime(params: ReplyModeParams) { - const { event, actx, sendWithRetry, consumeQuoteRef } = params; - const { account, log } = actx; - return { - event, - account, - log, - sendWithRetry, - consumeQuoteRef, - prefix: `[qqbot:${account.accountId}]`, - }; -} - -function resolveQQBotMediaTargetContext( +function resolveMediaTargetContext( event: DeliverEventContext, - account: ResolvedQQBotAccount, - prefix: string, + account: GatewayAccount, ): MediaTargetContext { return { targetType: @@ -110,15 +126,15 @@ function resolveQQBotMediaTargetContext( : event.channelId!, account, replyToId: event.messageId, - logPrefix: prefix, }; } -async function sendQQBotAutoMediaBatch(params: { +async function autoMediaBatch(params: { qualifiedTarget: string; - account: ResolvedQQBotAccount; + account: GatewayAccount; replyToId: string; mediaUrls: string[]; + mediaSender: MediaSender; log?: DeliverAccountContext["log"]; onResultError: (mediaUrl: string, error: string) => string; onThrownError: (mediaUrl: string, error: string) => string; @@ -126,7 +142,7 @@ async function sendQQBotAutoMediaBatch(params: { }): Promise { for (const mediaUrl of params.mediaUrls) { try { - const result = await sendMediaAuto({ + const result = await params.mediaSender.sendMedia({ to: params.qualifiedTarget, text: "", mediaUrl, @@ -143,12 +159,170 @@ async function sendQQBotAutoMediaBatch(params: { params.log?.info(successMessage); } } catch (err) { - params.log?.error(params.onThrownError(mediaUrl, String(err))); + params.log?.error(params.onThrownError(mediaUrl, formatErrorMessage(err))); } } } -// Media-tag parsing and delivery. +// ---- Text chunk sending ---- + +async function sendTextChunkToTarget(params: { + account: GatewayAccount; + event: DeliverEventContext; + token: string; + text: string; + consumeQuoteRef: ConsumeQuoteRefFn; + allowDm: boolean; +}): Promise { + const { account, event, text, consumeQuoteRef, allowDm } = params; + const ref = consumeQuoteRef(); + const target = buildDeliveryTarget(event); + if (target.type === "dm" && !allowDm) { + return undefined; + } + const creds = accountToCreds(account); + return await senderSendText(target, text, creds, { + msgId: event.messageId, + messageReference: ref, + }); +} + +async function sendTextChunks( + text: string, + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, +): Promise { + const { account, log } = actx; + const chunks = deps.chunkText(text, TEXT_CHUNK_LIMIT); + await sendTextChunksWithRetry({ + account, + event, + chunks, + sendWithRetry, + consumeQuoteRef, + allowDm: true, + log, + onSuccess: (chunk) => + `Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`, + onError: (err) => `Failed to send text chunk: ${formatErrorMessage(err)}`, + }); +} + +async function sendTextChunksWithRetry(params: { + account: GatewayAccount; + event: DeliverEventContext; + chunks: string[]; + sendWithRetry: SendWithRetryFn; + consumeQuoteRef: ConsumeQuoteRefFn; + allowDm: boolean; + log?: DeliverAccountContext["log"]; + onSuccess: (chunk: string) => string; + onError: (err: unknown) => string; +}): Promise { + const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params; + for (const chunk of chunks) { + try { + await sendWithRetry((token) => + sendTextChunkToTarget({ + account, + event, + token, + text: chunk, + consumeQuoteRef, + allowDm, + }), + ); + log?.info(params.onSuccess(chunk)); + } catch (err) { + log?.error(params.onError(err)); + } + } +} + +// ---- Result logging helpers ---- + +async function sendWithResultLogging(params: { + run: () => Promise; + log?: DeliverAccountContext["log"]; + onSuccess?: () => string | undefined; + onError: (error: string) => string; +}): Promise { + 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(formatErrorMessage(err))); + } +} + +async function sendPhotoWithLogging(params: { + target: MediaTargetContext; + imageUrl: string; + mediaSender: MediaSender; + log?: DeliverAccountContext["log"]; + onSuccess?: (imageUrl: string) => string | undefined; + onError: (error: string) => string; +}): Promise { + await sendWithResultLogging({ + run: async () => await params.mediaSender.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: GatewayAccount, + mediaSender: MediaSender, + log: DeliverAccountContext["log"], +): Promise { + const uploadFormats = + account.config?.audioFormatPolicy?.uploadDirectFormats ?? + account.config?.voiceDirectUploadFormats; + const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false; + const voiceTimeout = 45_000; + const ac = new AbortController(); + try { + const result = await Promise.race([ + mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => { + if (ac.signal.aborted) { + log?.debug?.(`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(`sendVoice error: ${result.error}`); + } + } catch (err) { + log?.error(`sendVoice unexpected error: ${formatErrorMessage(err)}`); + } +} + +// ============ Public API ============ /** * Parse media tags from the reply text and send them in order. @@ -162,11 +336,10 @@ export async function parseAndSendMediaTags( actx: DeliverAccountContext, sendWithRetry: SendWithRetryFn, consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, ): 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 = @@ -185,13 +358,12 @@ export async function parseAndSendMediaTags( }, {} as Record, ); - log?.info( - `${prefix} Detected media tags: ${Object.entries(tagCounts) + log?.debug?.( + `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; @@ -213,7 +385,7 @@ export async function parseAndSendMediaTags( } const tagName = normalizeLowercaseStringOrEmpty(match[1]); - let mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log, prefix); + const mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log); if (mediaPath) { const typeMap: Record = { @@ -224,7 +396,7 @@ export async function parseAndSendMediaTags( }; const itemType = typeMap[tagName] ?? "image"; sendQueue.push({ type: itemType, content: mediaPath }); - log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`); + log?.debug?.(`Found ${itemType} in <${tagName}>: ${mediaPath}`); } lastIndex = match.index + match[0].length; @@ -238,39 +410,39 @@ export async function parseAndSendMediaTags( sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) }); } - log?.info(`${prefix} Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); + log?.debug?.(`Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); - // Send queue items in order. - const mediaTarget = resolveQQBotMediaTargetContext(event, account, prefix); + const mediaTarget = resolveMediaTargetContext(event, account); for (const item of sendQueue) { if (item.type === "text") { - await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef); + await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps); } else if (item.type === "image") { - await sendQQBotPhotoWithLogging({ + await sendPhotoWithLogging({ target: mediaTarget, imageUrl: item.content, + mediaSender: deps.mediaSender, log, - onError: (error) => `${prefix} sendPhoto error: ${error}`, + onError: (error) => `sendPhoto error: ${error}`, }); } else if (item.type === "voice") { - await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix); + await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log); } else if (item.type === "video") { - await sendQQBotResultWithLogging({ - run: async () => await sendVideoMsg(mediaTarget, item.content), + await sendWithResultLogging({ + run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content), log, - onError: (error) => `${prefix} sendVideoMsg error: ${error}`, + onError: (error) => `sendVideoMsg error: ${error}`, }); } else if (item.type === "file") { - await sendQQBotResultWithLogging({ - run: async () => await sendDocument(mediaTarget, item.content), + await sendWithResultLogging({ + run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content), log, - onError: (error) => `${prefix} sendDocument error: ${error}`, + onError: (error) => `sendDocument error: ${error}`, }); } else if (item.type === "media") { - await sendQQBotResultWithLogging({ + await sendWithResultLogging({ run: async () => - await sendMediaAuto({ + await deps.mediaSender.sendMedia({ to: actx.qualifiedTarget, text: "", mediaUrl: item.content, @@ -279,7 +451,7 @@ export async function parseAndSendMediaTags( account, }), log, - onError: (error) => `${prefix} sendMedia(auto) error: ${error}`, + onError: (error) => `sendMedia(auto) error: ${error}`, }); } } @@ -287,7 +459,7 @@ export async function parseAndSendMediaTags( return { handled: true, normalizedText: text }; } -// Unstructured reply delivery for plain text and images. +// ---- Plain reply ---- export interface PlainReplyPayload { text?: string; @@ -307,9 +479,9 @@ export async function sendPlainReply( sendWithRetry: SendWithRetryFn, consumeQuoteRef: ConsumeQuoteRefFn, toolMediaUrls: string[], + deps: DeliverDeps, ): Promise { const { account, qualifiedTarget, log } = actx; - const prefix = `[qqbot:${account.accountId}]`; const collectedImageUrls: string[] = []; const localMediaToSend: string[] = []; @@ -323,8 +495,8 @@ export async function sendPlainReply( 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) + "..."}`, + log?.debug?.( + `Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`, ); } return true; @@ -332,7 +504,7 @@ export async function sendPlainReply( if (isLocalFilePath(url)) { if (!localMediaToSend.includes(url)) { localMediaToSend.push(url); - log?.info(`${prefix} Collected local media for auto-routing: ${url}`); + log?.debug?.(`Collected local media for auto-routing: ${url}`); } return true; } @@ -356,11 +528,11 @@ export async function sendPlainReply( 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)}...`); + log?.debug?.(`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}`); + log?.debug?.(`Collected local media from markdown for auto-routing: ${url}`); } } } @@ -374,17 +546,15 @@ export async function sendPlainReply( const url = m[1]; if (url && !collectedImageUrls.includes(url)) { collectedImageUrls.push(url); - log?.info(`${prefix} Extracted bare image URL: ${url.slice(0, 80)}...`); + log?.debug?.(`Extracted bare image URL: ${url.slice(0, 80)}...`); } } const useMarkdown = account.markdownSupport; - log?.info(`${prefix} Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`); + log?.debug?.(`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)) { @@ -393,280 +563,82 @@ export async function sendPlainReply( } if (useMarkdown) { - await sendMarkdownReply({ + await sendMarkdownReply( textWithoutImages, - imageUrls: collectedImageUrls, + collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, - }); + deps, + ); } else { - await sendPlainTextReply({ + await sendPlainTextReply( textWithoutImages, - imageUrls: collectedImageUrls, + collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, - }); + deps, + ); } // 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({ + log?.debug?.(`Sending ${localMediaToSend.length} local media via sendMedia auto-routing`); + await autoMediaBatch({ qualifiedTarget, account, replyToId: event.messageId, mediaUrls: localMediaToSend, + mediaSender: deps.mediaSender, 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}`, + onSuccess: (mediaPath) => `Sent local media: ${mediaPath}`, + onResultError: (mediaPath, error) => `sendMedia(auto) error for ${mediaPath}: ${error}`, + onThrownError: (mediaPath, error) => `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`, + log?.debug?.( + `Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`, ); - await sendQQBotAutoMediaBatch({ + await autoMediaBatch({ qualifiedTarget, account, replyToId: event.messageId, mediaUrls: toolMediaUrls, + mediaSender: deps.mediaSender, 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}`, + onSuccess: (mediaUrl) => `Forwarded tool media: ${mediaUrl.slice(0, 80)}...`, + onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`, + onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`, }); toolMediaUrls.length = 0; } } -// Internal helpers. +// ---- Markdown reply ---- -/** 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 { - 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, +async function sendMarkdownReply( + textWithoutImages: string, + imageUrls: string[], + mdMatches: RegExpMatchArray[], + bareUrlMatches: RegExpMatchArray[], event: DeliverEventContext, actx: DeliverAccountContext, sendWithRetry: SendWithRetryFn, consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, ): Promise { 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 { - 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 { - 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 { - 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 { - 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(params: ReplyModeParams): Promise { - const { textWithoutImages, imageUrls, mdMatches, bareUrlMatches } = params; - const { event, account, log, sendWithRetry, consumeQuoteRef, prefix } = - resolveReplyModeRuntime(params); - - // Split images into public URLs vs. Base64 payloads. const httpImageUrls: string[] = []; const base64ImageUrls: string[] = []; for (const url of imageUrls) { @@ -676,48 +648,32 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { httpImageUrls.push(url); } } - log?.info( - `${prefix} Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`, + log?.debug?.( + `Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`, ); - // Send Base64 images. + // Send Base64 images via Rich Media API. if (base64ImageUrls.length > 0) { - log?.info(`${prefix} Sending ${base64ImageUrls.length} image(s) via Rich Media API...`); + log?.debug?.(`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)`, - ); + const target = buildDeliveryTarget(event); + const creds = accountToCreds(account); + if (target.type === "c2c" || target.type === "group") { + await withTokenRetry(creds, async () => { + await senderSendImage(target, imageUrl, creds, { msgId: event.messageId }); + }); + } else { + log?.debug?.(`${target.type} does not support rich media, skipping Base64 image`); + } + log?.debug?.(`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)}`); + log?.error(`Failed to send Base64 image via Rich Media API: ${String(imgErr)}`); } } } - // Handle public image URLs. + // Handle public image URLs — format as markdown images with dimensions. const existingMdUrls = new Set(mdMatches.map((m) => m[2])); const imagesToAppend: string[] = []; @@ -726,11 +682,11 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { 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)}...`, + log?.debug?.( + `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)}`); + log?.debug?.(`Failed to get image size, using default: ${formatErrorMessage(err)}`); imagesToAppend.push(formatQQBotMarkdownImage(url, null)); } } @@ -746,19 +702,19 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { 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)}...`, + log?.debug?.( + `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)}`, + log?.debug?.( + `Failed to get image size for existing md, using default: ${formatErrorMessage(err)}`, ); result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null)); } } } - // Remove bare image URLs from the text body. + // Remove bare image URLs from text body. for (const m of bareUrlMatches) { result = result.replace(m[0], "").trim(); } @@ -771,8 +727,8 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { // Send markdown text. if (result.trim()) { - const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT); - await sendQQBotTextChunksWithRetry({ + const mdChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT); + await sendTextChunksWithRetry({ account, event, chunks: mdChunks, @@ -781,24 +737,34 @@ async function sendMarkdownReply(params: ReplyModeParams): Promise { 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)}`, + `Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`, + onError: (err) => `Failed to send markdown message chunk: ${formatErrorMessage(err)}`, }); } } -/** Send in plain-text mode. */ -async function sendPlainTextReply(params: ReplyModeParams): Promise { - const { event, account, log, sendWithRetry, consumeQuoteRef, prefix } = - resolveReplyModeRuntime(params); +// ---- Plain-text reply ---- - const imgMediaTarget = resolveQQBotMediaTargetContext(event, account, prefix); +async function sendPlainTextReply( + textWithoutImages: string, + imageUrls: string[], + mdMatches: RegExpMatchArray[], + bareUrlMatches: RegExpMatchArray[], + event: DeliverEventContext, + actx: DeliverAccountContext, + sendWithRetry: SendWithRetryFn, + consumeQuoteRef: ConsumeQuoteRefFn, + deps: DeliverDeps, +): Promise { + const { account, log } = actx; - let result = params.textWithoutImages; - for (const m of params.mdMatches) { + const imgMediaTarget = resolveMediaTargetContext(event, account); + + let result = textWithoutImages; + for (const m of mdMatches) { result = result.replace(m[0], "").trim(); } - for (const m of params.bareUrlMatches) { + for (const m of bareUrlMatches) { result = result.replace(m[0], "").trim(); } @@ -808,20 +774,20 @@ async function sendPlainTextReply(params: ReplyModeParams): Promise { } try { - for (const imageUrl of params.imageUrls) { - await sendQQBotPhotoWithLogging({ + for (const imageUrl of imageUrls) { + await sendPhotoWithLogging({ target: imgMediaTarget, imageUrl, + mediaSender: deps.mediaSender, log, - onSuccess: (nextImageUrl) => - `${prefix} Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`, - onError: (error) => `${prefix} Failed to send image: ${error}`, + onSuccess: (nextImageUrl) => `Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`, + onError: (error) => `Failed to send image: ${error}`, }); } if (result.trim()) { - const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT); - await sendQQBotTextChunksWithRetry({ + const plainChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT); + await sendTextChunksWithRetry({ account, event, chunks: plainChunks, @@ -830,11 +796,11 @@ async function sendPlainTextReply(params: ReplyModeParams): Promise { allowDm: false, log, onSuccess: (chunk) => - `${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, - onError: (err) => `${prefix} Send failed: ${String(err)}`, + `Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`, + onError: (err) => `Send failed: ${formatErrorMessage(err)}`, }); } } catch (err) { - log?.error(`${prefix} Send failed: ${String(err)}`); + log?.error(`Send failed: ${formatErrorMessage(err)}`); } } diff --git a/extensions/qqbot/src/outbound.ts b/extensions/qqbot/src/engine/messaging/outbound.ts similarity index 55% rename from extensions/qqbot/src/outbound.ts rename to extensions/qqbot/src/engine/messaging/outbound.ts index ce411aab1a9..929ced9be44 100644 --- a/extensions/qqbot/src/outbound.ts +++ b/extensions/qqbot/src/engine/messaging/outbound.ts @@ -1,168 +1,135 @@ import * as fs from "node:fs"; import * as path from "path"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { formatErrorMessage } from "../utils/format.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; -import { - getAccessToken, - sendC2CFileMessage, - sendC2CImageMessage, - sendC2CMessage, - sendC2CVideoMessage, - sendC2CVoiceMessage, - sendChannelMessage, - sendDmMessage, - sendGroupFileMessage, - sendGroupImageMessage, - sendGroupMessage, - sendGroupVideoMessage, - sendGroupVoiceMessage, - sendProactiveC2CMessage, - sendProactiveGroupMessage, -} from "./api.js"; -import type { ResolvedQQBotAccount } from "./types.js"; -import { - audioFileToSilkBase64, - isAudioFile, - shouldTranscodeVoice, - waitForFile, -} from "./utils/audio-convert.js"; -import { debugError, debugLog, debugWarn } from "./utils/debug-log.js"; +} from "../utils/string-normalize.js"; + +// ---- Injected audio-convert dependencies ---- + +/** Audio conversion interface — implemented by the upper-layer audio-convert module. */ +export interface OutboundAudioAdapter { + audioFileToSilkBase64( + audioPath: string, + directUploadFormats?: string[], + ): Promise; + isAudioFile(pathOrUrl: string, mimeType?: string): boolean; + shouldTranscodeVoice(filePath: string): boolean; + waitForFile(filePath: string, maxWaitMs?: number): Promise; +} + +let _audioAdapter: OutboundAudioAdapter | null = null; +let _audioAdapterFactory: (() => OutboundAudioAdapter) | null = null; + +/** Register the audio conversion adapter — called by gateway startup. */ +export function registerOutboundAudioAdapter(adapter: OutboundAudioAdapter): void { + _audioAdapter = adapter; +} + +/** Register a factory that creates the adapter on first access (lazy init). */ +export function registerOutboundAudioAdapterFactory(factory: () => OutboundAudioAdapter): void { + _audioAdapterFactory = factory; +} + +function getAudio(): OutboundAudioAdapter { + if (!_audioAdapter && _audioAdapterFactory) { + _audioAdapter = _audioAdapterFactory(); + } + if (!_audioAdapter) { + throw new Error("OutboundAudioAdapter not registered"); + } + return _audioAdapter; +} + +// Re-alias for use in the file. +function audioFileToSilkBase64(p: string, f?: string[]): Promise { + return getAudio().audioFileToSilkBase64(p, f); +} +function isAudioFile(p: string, m?: string): boolean { + // Safe to return false when adapter is unavailable — this is a type-check + // function called by sendMedia's dispatch logic before any audio processing. + try { + return getAudio().isAudioFile(p, m); + } catch { + return false; + } +} +function shouldTranscodeVoice(p: string): boolean { + return getAudio().shouldTranscodeVoice(p); +} +function waitForFile(p: string, ms?: number): Promise { + return getAudio().waitForFile(p, ms); +} +import type { GatewayAccount } from "../types.js"; import { checkFileSize, downloadFile, fileExistsAsync, formatFileSize, readFileAsync, -} from "./utils/file-utils.js"; -import { normalizeMediaTags } from "./utils/media-tags.js"; -import { decodeCronPayload } from "./utils/payload.js"; +} from "../utils/file-utils.js"; +import { debugError, debugLog, debugWarn } from "../utils/log.js"; +import { normalizeMediaTags } from "../utils/media-tags.js"; +import { decodeCronPayload } from "../utils/payload.js"; import { getQQBotDataDir, getQQBotMediaDir, isLocalPath as isLocalFilePath, normalizePath, resolveQQBotPayloadLocalFilePath, - sanitizeFileName, -} from "./utils/platform.js"; +} from "../utils/platform.js"; +import { sanitizeFileName } from "../utils/string-normalize.js"; +import { + isImageFile as coreIsImageFile, + isVideoFile as coreIsVideoFile, +} from "./media-type-detect.js"; +// Bridge to core/ modules — use the canonical implementations from the core +// package so the same logic can be shared with the standalone version. +import { ReplyLimiter, type ReplyLimitResult } from "./reply-limiter.js"; +import { + sendText as senderSendText, + sendImage as senderSendImage, + sendVoiceMessage as senderSendVoice, + sendVideoMessage as senderSendVideo, + sendFileMessage as senderSendFile, + initApiConfig, + accountToCreds, + type DeliveryTarget, +} from "./sender.js"; +import { parseTarget as coreParseTarget } from "./target-parser.js"; + +// Module-level reply limiter instance (replaces the old Map-based tracker). +const replyLimiter = new ReplyLimiter(); // Limit passive replies per message_id within the QQ Bot reply window. +// Delegated to core/messaging/reply-limiter.ts for cross-version sharing. const MESSAGE_REPLY_LIMIT = 4; -const MESSAGE_REPLY_TTL = 60 * 60 * 1000; - -interface MessageReplyRecord { - count: number; - firstReplyAt: number; -} - -type QQMessageResult = { - ext_info?: { - ref_idx?: string; - }; -}; - -const messageReplyTracker = new Map(); - -function getRefIdx(result: QQMessageResult): string | undefined { - return result.ext_info?.ref_idx; -} /** Result of the passive-reply limit check. */ -export interface ReplyLimitResult { - allowed: boolean; - remaining: number; - shouldFallbackToProactive: boolean; - fallbackReason?: "expired" | "limit_exceeded"; - message?: string; -} +export type { ReplyLimitResult }; /** Check whether a message can still receive a passive reply. */ export function checkMessageReplyLimit(messageId: string): ReplyLimitResult { - const now = Date.now(); - const record = messageReplyTracker.get(messageId); - - // Opportunistically evict expired records to keep the tracker bounded. - if (messageReplyTracker.size > 10000) { - for (const [id, rec] of messageReplyTracker) { - if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) { - messageReplyTracker.delete(id); - } - } - } - - if (!record) { - return { - allowed: true, - remaining: MESSAGE_REPLY_LIMIT, - shouldFallbackToProactive: false, - }; - } - - if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { - return { - allowed: false, - remaining: 0, - shouldFallbackToProactive: true, - fallbackReason: "expired", - message: "Message is older than 1 hour; sending as a proactive message instead", - }; - } - - const remaining = MESSAGE_REPLY_LIMIT - record.count; - if (remaining <= 0) { - return { - allowed: false, - remaining: 0, - shouldFallbackToProactive: true, - fallbackReason: "limit_exceeded", - message: `Passive reply limit reached (${MESSAGE_REPLY_LIMIT} per hour); sending proactively instead`, - }; - } - - return { - allowed: true, - remaining, - shouldFallbackToProactive: false, - }; + return replyLimiter.checkLimit(messageId); } /** Record one passive reply against a message. */ export function recordMessageReply(messageId: string): void { - const now = Date.now(); - const record = messageReplyTracker.get(messageId); - - if (!record) { - messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); - } else { - if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) { - messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now }); - } else { - record.count++; - } - } + replyLimiter.record(messageId); debugLog( - `[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`, + `[qqbot] recordMessageReply: ${messageId}, count=${replyLimiter.getStats().totalReplies}`, ); } /** Return reply-tracker stats for diagnostics. */ export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } { - let totalReplies = 0; - for (const record of messageReplyTracker.values()) { - totalReplies += record.count; - } - return { trackedMessages: messageReplyTracker.size, totalReplies }; + return replyLimiter.getStats(); } /** Return the passive-reply configuration. */ export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } { - return { - limit: MESSAGE_REPLY_LIMIT, - ttlMs: MESSAGE_REPLY_TTL, - ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000), - }; + return replyLimiter.getConfig(); } export interface OutboundContext { @@ -170,7 +137,7 @@ export interface OutboundContext { text: string; accountId?: string | null; replyToId?: string | null; - account: ResolvedQQBotAccount; + account: GatewayAccount; } export interface MediaOutboundContext extends OutboundContext { @@ -190,50 +157,9 @@ export interface OutboundResult { function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } { const timestamp = new Date().toISOString(); debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`); - - let id = to.replace(/^qqbot:/i, ""); - - if (id.startsWith("c2c:")) { - const userId = id.slice(4); - if (!userId || userId.length === 0) { - const error = `Invalid c2c target format: ${to} - missing user ID`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - debugLog(`[${timestamp}] [qqbot] parseTarget: c2c target, user ID=${userId}`); - return { type: "c2c", id: userId }; - } - - if (id.startsWith("group:")) { - const groupId = id.slice(6); - if (!groupId || groupId.length === 0) { - const error = `Invalid group target format: ${to} - missing group ID`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - debugLog(`[${timestamp}] [qqbot] parseTarget: group target, group ID=${groupId}`); - return { type: "group", id: groupId }; - } - - if (id.startsWith("channel:")) { - const channelId = id.slice(8); - if (!channelId || channelId.length === 0) { - const error = `Invalid channel target format: ${to} - missing channel ID`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - debugLog(`[${timestamp}] [qqbot] parseTarget: channel target, channel ID=${channelId}`); - return { type: "channel", id: channelId }; - } - - if (!id || id.length === 0) { - const error = `Invalid target format: ${to} - empty ID after removing qqbot: prefix`; - debugError(`[${timestamp}] [qqbot] parseTarget: ${error}`); - throw new Error(error); - } - - debugLog(`[${timestamp}] [qqbot] parseTarget: default c2c target, ID=${id}`); - return { type: "c2c", id }; + const parsed = coreParseTarget(to); + debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`); + return parsed; } // Structured media send helpers shared by gateway delivery and sendText. @@ -242,36 +168,27 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin export interface MediaTargetContext { targetType: "c2c" | "group" | "channel" | "dm"; targetId: string; - account: ResolvedQQBotAccount; + account: GatewayAccount; replyToId?: string; - logPrefix?: string; } /** Build a media target from a normal outbound context. */ -function buildMediaTarget( - ctx: { to: string; account: ResolvedQQBotAccount; replyToId?: string | null }, - logPrefix?: string, -): MediaTargetContext { +function buildMediaTarget(ctx: { + to: string; + account: GatewayAccount; + replyToId?: string | null; +}): MediaTargetContext { const target = parseTarget(ctx.to); return { targetType: target.type, targetId: target.id, account: ctx.account, replyToId: ctx.replyToId ?? undefined, - logPrefix, }; } -/** Resolve an authenticated access token for the account. */ -async function getToken(account: ResolvedQQBotAccount): Promise { - if (!account.appId || !account.clientSecret) { - throw new Error("QQBot not configured (missing appId or clientSecret)"); - } - return getAccessToken(account.appId, account.clientSecret); -} - /** Return true when public URLs should be passed through directly. */ -function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean { +function shouldDirectUploadUrl(account: GatewayAccount): boolean { return account.config?.urlDirectUpload !== false; } @@ -379,7 +296,6 @@ function resolveExistingPathWithinRoots( function resolveOutboundMediaPath( rawPath: string, - prefix: string, mediaKind: QQBotMediaKind, options: ResolveOutboundMediaPathOptions = {}, ): ResolvedOutboundMediaPath { @@ -410,7 +326,7 @@ function resolveOutboundMediaPath( } } - debugWarn(`${prefix} blocked local ${mediaKind} path outside QQ Bot media storage`); + debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`); return { ok: false, error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`, @@ -424,8 +340,7 @@ export async function sendPhoto( ctx: MediaTargetContext, imagePath: string, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; - const resolvedMediaPath = resolveOutboundMediaPath(imagePath, prefix, "image"); + const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image"); if (!resolvedMediaPath.ok) { return { channel: "qqbot", error: resolvedMediaPath.error }; } @@ -436,8 +351,8 @@ export async function sendPhoto( // Force a local download before upload when direct URL upload is disabled. if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`${prefix} sendPhoto: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto"); + debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`); + const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto"); if (localFile) { return await sendPhoto(ctx, localFile); } @@ -469,42 +384,31 @@ export async function sendPhoto( return { channel: "qqbot", error: `Unsupported image format: ${ext}` }; } imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`; - debugLog(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`); + debugLog(`sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`); } else if (!isHttp && !isData) { return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` }; } try { - const token = await getToken(ctx.account); const localPath = isLocal ? mediaPath : undefined; + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (ctx.targetType === "c2c") { - const r = await sendC2CImageMessage( - ctx.account.appId, - token, - ctx.targetId, - imageUrl, - ctx.replyToId, - undefined, + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendImage(target, imageUrl, creds, { + msgId: ctx.replyToId, + content: undefined, localPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupImageMessage( - ctx.account.appId, - token, - ctx.targetId, - imageUrl, - ctx.replyToId, - ); + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - // Channel messages only support public URLs through markdown. if (isHttp) { - const r = await sendChannelMessage(token, ctx.targetId, `![](${mediaPath})`, ctx.replyToId); + const r = await senderSendText(target, `![](${mediaPath})`, creds, { + msgId: ctx.replyToId, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } - debugLog(`${prefix} sendPhoto: channel does not support local/Base64 images`); + debugLog(`sendPhoto: channel does not support local/Base64 images`); return { channel: "qqbot", error: "Channel does not support local/Base64 images" }; } } catch (err) { @@ -513,15 +417,15 @@ export async function sendPhoto( // Fall back to plugin-managed download + Base64 when QQ fails to fetch the URL directly. if (isHttp && !isData) { debugWarn( - `${prefix} sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, + `sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, ); - const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath, prefix); + const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath); if (retryResult) { return retryResult; } } - debugError(`${prefix} sendPhoto failed: ${msg}`); + debugError(`sendPhoto failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -530,20 +434,19 @@ export async function sendPhoto( async function downloadAndRetrySendPhoto( ctx: MediaTargetContext, httpUrl: string, - prefix: string, ): Promise { try { const downloadDir = getQQBotMediaDir("downloads", "url-fallback"); const localFile = await downloadFile(httpUrl, downloadDir); if (!localFile) { - debugError(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`); + debugError(`sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`); return null; } - debugLog(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`); + debugLog(`sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`); return await sendPhoto(ctx, localFile); } catch (err) { - debugError(`${prefix} sendPhoto fallback error:`, err); + debugError(`sendPhoto fallback error:`, err); return null; } } @@ -559,8 +462,7 @@ export async function sendVoice( directUploadFormats?: string[], transcodeEnabled: boolean = true, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; - const resolvedMediaPath = resolveOutboundMediaPath(voicePath, prefix, "voice", { + const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", { allowMissingLocalPath: true, }); if (!resolvedMediaPath.ok) { @@ -572,55 +474,36 @@ export async function sendVoice( if (isHttp) { if (shouldDirectUploadUrl(ctx.account)) { try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - const r = await sendC2CVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, - ); + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVoice(target, creds, { + voiceUrl: mediaPath, + msgId: ctx.replyToId, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVoice: voice not supported in channel`); + debugLog(`sendVoice: voice not supported in channel`); return { channel: "qqbot", error: "Voice not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); debugWarn( - `${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`, + `sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`, ); } } else { - debugLog(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`); + debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`); } - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice"); + const localFile = await downloadToFallbackDir(mediaPath, "sendVoice"); if (localFile) { - return await sendVoiceFromLocal( - ctx, - localFile, - directUploadFormats, - transcodeEnabled, - prefix, - ); + return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled); } return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` }; } - return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix); + return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled); } /** Send voice from a local file. */ @@ -629,7 +512,6 @@ async function sendVoiceFromLocal( mediaPath: string, directUploadFormats: string[] | undefined, transcodeEnabled: boolean, - prefix: string, ): Promise { // TTS can still be flushing the file to disk, so wait for a stable file first. const fileSize = await waitForFile(mediaPath); @@ -640,7 +522,7 @@ async function sendVoiceFromLocal( // Re-check containment after the file appears to prevent symlink-race escapes. const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath); if (!safeMediaPath) { - debugWarn(`${prefix} sendVoice: blocked local voice path outside QQ Bot media storage`); + debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`); return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" }; } @@ -649,7 +531,7 @@ async function sendVoiceFromLocal( if (needsTranscode && !transcodeEnabled) { const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath)); debugLog( - `${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, + `sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`, ); return { channel: "qqbot", @@ -664,44 +546,28 @@ async function sendVoiceFromLocal( if (!uploadBase64) { const buf = await readFileAsync(safeMediaPath); uploadBase64 = buf.toString("base64"); - debugLog( - `${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`, - ); + debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`); } else { - debugLog(`${prefix} sendVoice: SILK ready (${fileSize} bytes)`); + debugLog(`sendVoice: SILK ready (${fileSize} bytes)`); } - const token = await getToken(ctx.account); + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; - if (ctx.targetType === "c2c") { - const r = await sendC2CVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - uploadBase64, - undefined, - ctx.replyToId, - undefined, - safeMediaPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVoiceMessage( - ctx.account.appId, - token, - ctx.targetId, - uploadBase64, - undefined, - ctx.replyToId, - ); + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVoice(target, creds, { + voiceBase64: uploadBase64, + msgId: ctx.replyToId, + filePath: safeMediaPath, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVoice: voice not supported in channel`); + debugLog(`sendVoice: voice not supported in channel`); return { channel: "qqbot", error: "Voice not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); - debugError(`${prefix} sendVoice (local) failed: ${msg}`); + debugError(`sendVoice (local) failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -711,8 +577,7 @@ export async function sendVideoMsg( ctx: MediaTargetContext, videoPath: string, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; - const resolvedMediaPath = resolveOutboundMediaPath(videoPath, prefix, "video"); + const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video"); if (!resolvedMediaPath.ok) { return { channel: "qqbot", error: resolvedMediaPath.error }; } @@ -720,60 +585,46 @@ export async function sendVideoMsg( const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://"); if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`${prefix} sendVideoMsg: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg"); + debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`); + const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); if (localFile) { - return await sendVideoFromLocal(ctx, localFile, prefix); + return await sendVideoFromLocal(ctx, localFile); } return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` }; } try { - const token = await getToken(ctx.account); - if (isHttp) { - if (ctx.targetType === "c2c") { - const r = await sendC2CVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - mediaPath, - undefined, - ctx.replyToId, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - mediaPath, - undefined, - ctx.replyToId, - ); + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVideo(target, creds, { + videoUrl: mediaPath, + msgId: ctx.replyToId, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVideoMsg: video not supported in channel`); + debugLog(`sendVideoMsg: video not supported in channel`); return { channel: "qqbot", error: "Video not supported in channel" }; } } - return await sendVideoFromLocal(ctx, mediaPath, prefix); + return await sendVideoFromLocal(ctx, mediaPath); } catch (err) { const msg = formatErrorMessage(err); // If direct URL upload fails, retry through a local download path. if (isHttp) { debugWarn( - `${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, + `sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, ); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg"); + const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg"); if (localFile) { - return await sendVideoFromLocal(ctx, localFile, prefix); + return await sendVideoFromLocal(ctx, localFile); } } - debugError(`${prefix} sendVideoMsg failed: ${msg}`); + debugError(`sendVideoMsg failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -782,7 +633,6 @@ export async function sendVideoMsg( async function sendVideoFromLocal( ctx: MediaTargetContext, mediaPath: string, - prefix: string, ): Promise { if (!(await fileExistsAsync(mediaPath))) { return { channel: "qqbot", error: "Video not found" }; @@ -794,39 +644,25 @@ async function sendVideoFromLocal( const fileBuffer = await readFileAsync(mediaPath); const videoBase64 = fileBuffer.toString("base64"); - debugLog(`${prefix} sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`); + debugLog(`sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`); try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - const r = await sendC2CVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendVideo(target, creds, { videoBase64, - ctx.replyToId, - undefined, - mediaPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupVideoMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - videoBase64, - ctx.replyToId, - ); + msgId: ctx.replyToId, + localPath: mediaPath, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendVideoMsg: video not supported in channel`); + debugLog(`sendVideoMsg: video not supported in channel`); return { channel: "qqbot", error: "Video not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); - debugError(`${prefix} sendVideoMsg (local) failed: ${msg}`); + debugError(`sendVideoMsg (local) failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -837,11 +673,10 @@ export async function sendDocument( filePath: string, options: SendDocumentOptions = {}, ): Promise { - const prefix = ctx.logPrefix ?? "[qqbot]"; const extraLocalRoots = options.allowQQBotDataDownloads ? [getQQBotDataDir("downloads")] : undefined; - const resolvedMediaPath = resolveOutboundMediaPath(filePath, prefix, "file", { + const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", { extraLocalRoots, }); if (!resolvedMediaPath.ok) { @@ -852,62 +687,47 @@ export async function sendDocument( const fileName = sanitizeFileName(path.basename(mediaPath)); if (isHttp && !shouldDirectUploadUrl(ctx.account)) { - debugLog(`${prefix} sendDocument: urlDirectUpload=false, downloading URL first...`); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument"); + debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`); + const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); if (localFile) { - return await sendDocumentFromLocal(ctx, localFile, prefix); + return await sendDocumentFromLocal(ctx, localFile); } return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` }; } try { - const token = await getToken(ctx.account); - if (isHttp) { - if (ctx.targetType === "c2c") { - const r = await sendC2CFileMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendFile(target, creds, { + fileUrl: mediaPath, + msgId: ctx.replyToId, fileName, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupFileMessage( - ctx.account.appId, - token, - ctx.targetId, - undefined, - mediaPath, - ctx.replyToId, - fileName, - ); + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendDocument: file not supported in channel`); + debugLog(`sendDocument: file not supported in channel`); return { channel: "qqbot", error: "File not supported in channel" }; } } - return await sendDocumentFromLocal(ctx, mediaPath, prefix); + return await sendDocumentFromLocal(ctx, mediaPath); } catch (err) { const msg = formatErrorMessage(err); // If direct URL upload fails, retry through a local download path. if (isHttp) { debugWarn( - `${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, + `sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`, ); - const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument"); + const localFile = await downloadToFallbackDir(mediaPath, "sendDocument"); if (localFile) { - return await sendDocumentFromLocal(ctx, localFile, prefix); + return await sendDocumentFromLocal(ctx, localFile); } } - debugError(`${prefix} sendDocument failed: ${msg}`); + debugError(`sendDocument failed: ${msg}`); return { channel: "qqbot", error: msg }; } } @@ -916,7 +736,6 @@ export async function sendDocument( async function sendDocumentFromLocal( ctx: MediaTargetContext, mediaPath: string, - prefix: string, ): Promise { const fileName = sanitizeFileName(path.basename(mediaPath)); @@ -932,61 +751,43 @@ async function sendDocumentFromLocal( return { channel: "qqbot", error: `File is empty: ${mediaPath}` }; } const fileBase64 = fileBuffer.toString("base64"); - debugLog(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`); + debugLog(`sendDocument: local file (${formatFileSize(fileBuffer.length)})`); try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - const r = await sendC2CFileMessage( - ctx.account.appId, - token, - ctx.targetId, + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + if (target.type === "c2c" || target.type === "group") { + const r = await senderSendFile(target, creds, { fileBase64, - undefined, - ctx.replyToId, + msgId: ctx.replyToId, fileName, - mediaPath, - ); - return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; - } else if (ctx.targetType === "group") { - const r = await sendGroupFileMessage( - ctx.account.appId, - token, - ctx.targetId, - fileBase64, - undefined, - ctx.replyToId, - fileName, - ); + localFilePath: mediaPath, + }); return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp }; } else { - debugLog(`${prefix} sendDocument: file not supported in channel`); + debugLog(`sendDocument: file not supported in channel`); return { channel: "qqbot", error: "File not supported in channel" }; } } catch (err) { const msg = formatErrorMessage(err); - debugError(`${prefix} sendDocument (local) failed: ${msg}`); + debugError(`sendDocument (local) failed: ${msg}`); return { channel: "qqbot", error: msg }; } } /** Download a remote file into the fallback media directory. */ -async function downloadToFallbackDir( - httpUrl: string, - prefix: string, - caller: string, -): Promise { +async function downloadToFallbackDir(httpUrl: string, caller: string): Promise { try { const downloadDir = getQQBotMediaDir("downloads", "url-fallback"); const localFile = await downloadFile(httpUrl, downloadDir); if (!localFile) { - debugError(`${prefix} ${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`); + debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`); return null; } - debugLog(`${prefix} ${caller} fallback: downloaded → ${localFile}`); + debugLog(`${caller} fallback: downloaded → ${localFile}`); return localFile; } catch (err) { - debugError(`${prefix} ${caller} fallback download error:`, err); + debugError(`${caller} fallback download error:`, err); return null; } } @@ -1001,6 +802,8 @@ export async function sendText(ctx: OutboundContext): Promise { let { text, replyToId } = ctx; let fallbackToProactive = false; + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + debugLog( "[qqbot] sendText ctx:", JSON.stringify( @@ -1151,99 +954,30 @@ export async function sendText(ctx: OutboundContext): Promise { debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`); // Send queue items in order. - const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]"); + const mediaTarget = buildMediaTarget({ to, account, replyToId }); let lastResult: OutboundResult = { channel: "qqbot" }; for (const item of sendQueue) { try { if (item.type === "text") { + const target = parseTarget(to); + const creds = accountToCreds(account); + const deliveryTarget: DeliveryTarget = { + type: target.type === "channel" ? "channel" : target.type, + id: target.id, + }; + const result = await senderSendText(deliveryTarget, item.content, creds, { + msgId: replyToId ?? undefined, + }); if (replyToId) { - const accessToken = await getToken(account); - const target = parseTarget(to); - if (target.type === "c2c") { - const result = await sendC2CMessage( - account.appId, - accessToken, - target.id, - item.content, - replyToId, - ); - recordMessageReply(replyToId); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else if (target.type === "group") { - const result = await sendGroupMessage( - account.appId, - accessToken, - target.id, - item.content, - replyToId, - ); - recordMessageReply(replyToId); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else { - const result = await sendChannelMessage( - accessToken, - target.id, - item.content, - replyToId, - ); - recordMessageReply(replyToId); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } - } else { - const accessToken = await getToken(account); - const target = parseTarget(to); - if (target.type === "c2c") { - const result = await sendProactiveC2CMessage( - account.appId, - accessToken, - target.id, - item.content, - ); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else if (target.type === "group") { - const result = await sendProactiveGroupMessage( - account.appId, - accessToken, - target.id, - item.content, - ); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else { - const result = await sendChannelMessage(accessToken, target.id, item.content); - lastResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } + recordMessageReply(replyToId); } + lastResult = { + channel: "qqbot", + messageId: result.id, + timestamp: result.timestamp, + refIdx: result.ext_info?.ref_idx, + }; debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`); } else if (item.type === "image") { lastResult = await sendPhoto(mediaTarget, item.content); @@ -1301,170 +1035,45 @@ export async function sendText(ctx: OutboundContext): Promise { } try { - const accessToken = await getAccessToken(account.appId, account.clientSecret); const target = parseTarget(to); + const creds = accountToCreds(account); + const deliveryTarget: DeliveryTarget = { + type: target.type === "channel" ? "channel" : target.type, + id: target.id, + }; debugLog("[qqbot] sendText target:", JSON.stringify(target)); - if (!replyToId) { - let outResult: OutboundResult; - if (target.type === "c2c") { - const result = await sendProactiveC2CMessage(account.appId, accessToken, target.id, text); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else if (target.type === "group") { - const result = await sendProactiveGroupMessage(account.appId, accessToken, target.id, text); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else { - const result = await sendChannelMessage(accessToken, target.id, text); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } - return outResult; - } - - if (target.type === "c2c") { - const result = await sendC2CMessage(account.appId, accessToken, target.id, text, replyToId); - recordMessageReply(replyToId); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else if (target.type === "group") { - const result = await sendGroupMessage(account.appId, accessToken, target.id, text, replyToId); - recordMessageReply(replyToId); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: result.ext_info?.ref_idx, - }; - } else { - const result = await sendChannelMessage(accessToken, target.id, text, replyToId); - recordMessageReply(replyToId); - return { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; + const result = await senderSendText(deliveryTarget, text, creds, { + msgId: replyToId ?? undefined, + }); + if (replyToId) { + recordMessageReply(replyToId); } + return { + channel: "qqbot", + messageId: result.id, + timestamp: result.timestamp, + refIdx: result.ext_info?.ref_idx, + }; } catch (err) { const message = formatErrorMessage(err); return { channel: "qqbot", error: message }; } } -/** Send a proactive message without a replyToId. */ -export async function sendProactiveMessage( - account: ResolvedQQBotAccount, - to: string, - text: string, -): Promise { - const timestamp = new Date().toISOString(); - - if (!account.appId || !account.clientSecret) { - const errorMsg = "QQBot not configured (missing appId or clientSecret)"; - debugError(`[${timestamp}] [qqbot] sendProactiveMessage: ${errorMsg}`); - return { channel: "qqbot", error: errorMsg }; - } - - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: starting, to=${to}, text length=${text.length}, accountId=${account.accountId}`, - ); - - try { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: getting access token for appId=${account.appId}`, - ); - const accessToken = await getAccessToken(account.appId, account.clientSecret); - - debugLog(`[${timestamp}] [qqbot] sendProactiveMessage: parsing target=${to}`); - const target = parseTarget(to); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`, - ); - - let outResult: OutboundResult; - if (target.type === "c2c") { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`, - ); - const result = await sendProactiveC2CMessage(account.appId, accessToken, target.id, text); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`, - ); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else if (target.type === "group") { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`, - ); - const result = await sendProactiveGroupMessage(account.appId, accessToken, target.id, text); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`, - ); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } else { - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`, - ); - const result = await sendChannelMessage(accessToken, target.id, text); - debugLog( - `[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`, - ); - outResult = { - channel: "qqbot", - messageId: result.id, - timestamp: result.timestamp, - refIdx: getRefIdx(result), - }; - } - return outResult; - } catch (err) { - const errorMessage = formatErrorMessage(err); - debugError(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`); - debugError( - `[${timestamp}] [qqbot] sendProactiveMessage: error stack: ${err instanceof Error ? err.stack : "No stack trace"}`, - ); - return { channel: "qqbot", error: errorMessage }; - } -} - /** Send rich media, auto-routing by media type and source. */ export async function sendMedia(ctx: MediaOutboundContext): Promise { const { to, text, replyToId, account, mimeType } = ctx; + initApiConfig(account.appId, { markdownSupport: account.markdownSupport }); + if (!account.appId || !account.clientSecret) { return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" }; } if (!ctx.mediaUrl) { return { channel: "qqbot", error: "mediaUrl is required for sendMedia" }; } - const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "[qqbot:sendMedia]", "media", { + const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", { allowMissingLocalPath: true, }); if (!resolvedMediaPath.ok) { @@ -1472,7 +1081,7 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise { try { - const token = await getToken(ctx.account); - if (ctx.targetType === "c2c") { - await sendC2CMessage(ctx.account.appId, token, ctx.targetId, text, ctx.replyToId); - } else if (ctx.targetType === "group") { - await sendGroupMessage(ctx.account.appId, token, ctx.targetId, text, ctx.replyToId); - } else if (ctx.targetType === "channel") { - await sendChannelMessage(token, ctx.targetId, text, ctx.replyToId); - } else if (ctx.targetType === "dm") { - await sendDmMessage(token, ctx.targetId, text, ctx.replyToId); - } + const creds = accountToCreds(ctx.account); + const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId }; + await senderSendText(target, text, creds, { msgId: ctx.replyToId }); } catch (err) { - debugError( - `[qqbot] sendTextAfterMedia failed: ${err instanceof Error ? err.message : JSON.stringify(err)}`, - ); + debugError(`[qqbot] sendTextAfterMedia failed: ${formatErrorMessage(err)}`); } } -/** Extract a lowercase extension from a path or URL, ignoring query and hash segments. */ -function getCleanExt(filePath: string): string { - const cleanPath = filePath.split("?")[0].split("#")[0]; - return normalizeLowercaseStringOrEmpty(path.extname(cleanPath)); -} +// Media type detection delegated to core/outbound/media-type-detect.ts. +// Re-alias for backward compatibility within this file. +const isImageFile = coreIsImageFile; +const isVideoFile = coreIsVideoFile; -/** Check whether a file is an image using MIME first and extension as fallback. */ -function isImageFile(filePath: string, mimeType?: string): boolean { - if (mimeType) { - if (mimeType.startsWith("image/")) { - return true; - } - } - const ext = getCleanExt(filePath); - return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext); -} - -/** Check whether a file or URL is a video using MIME first and extension as fallback. */ -function isVideoFile(filePath: string, mimeType?: string): boolean { - if (mimeType) { - if (mimeType.startsWith("video/")) { - return true; - } - } - const ext = getCleanExt(filePath); - return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext); +/** + * Send a proactive (no reply context) text message to a qualified target. + * + * Thin wrapper around {@link sendText} for callers that have a fully-qualified + * target string (e.g. `"qqbot:c2c:"`) and a {@link GatewayAccount}, + * and do not want to manage access tokens or delivery-target parsing manually. + * + * @param account Resolved gateway account. + * @param to Fully-qualified target address (`qqbot:c2c:`, `qqbot:group:`, etc.). + * @param content Message content. + */ +export async function sendProactiveMessage( + account: GatewayAccount, + to: string, + content: string, +): Promise { + return sendText({ account, to, text: content }); } /** @@ -1604,7 +1200,7 @@ function isVideoFile(filePath: string, mimeType?: string): boolean { * ``` */ export async function sendCronMessage( - account: ResolvedQQBotAccount, + account: GatewayAccount, to: string, message: string, ): Promise { @@ -1640,7 +1236,7 @@ export async function sendCronMessage( ); // Send the reminder content. - const result = await sendProactiveMessage(account, targetTo, payload.content); + const result = await sendText({ account, to: targetTo, text: payload.content }); if (result.error) { debugError( @@ -1656,5 +1252,5 @@ export async function sendCronMessage( // Fall back to plain text handling when the payload is not structured. debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`); - return await sendProactiveMessage(account, to, message); + return await sendText({ account, to, text: message }); } diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts new file mode 100644 index 00000000000..6e292b5c458 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts @@ -0,0 +1,551 @@ +/** + * Reply dispatcher — structured payload handling and text routing. + * + * Uses the unified `sender.ts` business function layer for all message + * sending. TTS is injected via `ReplyDispatcherDeps`. + */ + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { GatewayAccount } from "../types.js"; +import { MAX_UPLOAD_SIZE, formatFileSize } from "../utils/file-utils.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { + parseQQBotPayload, + encodePayloadForCron, + isCronReminderPayload, + isMediaPayload, + type MediaPayload, +} from "../utils/payload.js"; +import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js"; +import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js"; +import { sanitizeFileName } from "../utils/string-normalize.js"; +import { + sendText as senderSendText, + sendImage as senderSendImage, + sendVoiceMessage as senderSendVoice, + sendVideoMessage as senderSendVideo, + sendFileMessage as senderSendFile, + withTokenRetry, + buildDeliveryTarget, + accountToCreds, +} from "./sender.js"; + +// ---- Injected dependencies ---- + +/** TTS provider interface — injected from the outer layer. */ +export interface TTSProvider { + /** Framework TTS: text → audio file path. */ + textToSpeech(params: { text: string; cfg: unknown; channel: string }): Promise<{ + success: boolean; + audioPath?: string; + provider?: string; + outputFormat?: string; + error?: string; + }>; + /** Convert any audio file to SILK base64. */ + audioFileToSilkBase64(audioPath: string): Promise; +} + +/** Dependencies injected into reply-dispatcher functions. */ +export interface ReplyDispatcherDeps { + tts: TTSProvider; +} + +// ---- Exported types ---- + +export interface MessageTarget { + type: "c2c" | "guild" | "dm" | "group"; + senderId: string; + messageId: string; + channelId?: string; + guildId?: string; + groupOpenid?: string; +} + +export interface ReplyContext { + target: MessageTarget; + account: GatewayAccount; + cfg: unknown; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; +} + +// ---- Token retry (delegated to sender.ts) ---- + +/** Send a message and retry once if the token appears to have expired. */ +export async function sendWithTokenRetry( + appId: string, + clientSecret: string, + sendFn: (token: string) => Promise, + log?: ReplyContext["log"], + accountId?: string, +): Promise { + return withTokenRetry({ appId, clientSecret }, sendFn, log, accountId); +} + +// ---- Text routing ---- + +/** Route a text message to the correct QQ target type. */ +export async function sendTextToTarget( + ctx: ReplyContext, + text: string, + refIdx?: string, +): Promise { + const { target, account } = ctx; + const deliveryTarget = buildDeliveryTarget(target); + const creds = accountToCreds(account); + await withTokenRetry( + creds, + async () => { + await senderSendText(deliveryTarget, text, creds, { + msgId: target.messageId, + messageReference: refIdx, + }); + }, + ctx.log, + account.accountId, + ); +} + +/** Best-effort delivery for error text back to the user. */ +export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise { + try { + await sendTextToTarget(ctx, errorText); + } catch (sendErr) { + ctx.log?.error(`Failed to send error message: ${String(sendErr)}`); + } +} + +// ---- Structured payload handling ---- + +/** + * 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, + deps?: ReplyDispatcherDeps, +): Promise { + const { account: _account, log } = ctx; + const payloadResult = parseQQBotPayload(replyText); + + if (!payloadResult.isPayload) { + return false; + } + + if (payloadResult.error) { + log?.error(`Payload parse error: ${payloadResult.error}`); + return true; + } + + if (!payloadResult.payload) { + return true; + } + + const parsedPayload = payloadResult.payload; + const unknownPayload = payloadResult.payload as unknown; + log?.info(`Detected structured payload, type: ${parsedPayload.type}`); + + if (isCronReminderPayload(parsedPayload)) { + log?.debug?.(`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?.debug?.(`Cron reminder confirmation sent, cronMessage: ${cronMessage}`); + } catch (err) { + log?.error(`Failed to send cron confirmation: ${formatErrorMessage(err)}`); + } + recordActivity(); + return true; + } + + if (isMediaPayload(parsedPayload)) { + log?.debug?.(`Processing media payload, mediaType: ${parsedPayload.mediaType}`); + + if (parsedPayload.mediaType === "image") { + await handleImagePayload(ctx, parsedPayload); + } else if (parsedPayload.mediaType === "audio") { + await handleAudioPayload(ctx, parsedPayload, deps); + } else if (parsedPayload.mediaType === "video") { + await handleVideoPayload(ctx, parsedPayload); + } else if (parsedPayload.mediaType === "file") { + await handleFilePayload(ctx, parsedPayload); + } else { + log?.error(`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(`Unknown payload type: ${payloadType}`); + return true; +} + +// ---- Media payload handlers ---- + +type StructuredPayloadMediaType = "image" | "video" | "file"; + +function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string { + return mediaType[0].toUpperCase() + mediaType.slice(1); +} + +function validateStructuredPayloadLocalPath( + ctx: ReplyContext, + payloadPath: string, + mediaType: StructuredPayloadMediaType, +): string | null { + const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath); + if (allowedPath) { + return allowedPath; + } + + ctx.log?.error(`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 resolveStructuredPayloadPath( + ctx: ReplyContext, + payload: MediaPayload, + mediaType: StructuredPayloadMediaType, +): { path: string; isHttpUrl: boolean } | null { + const originalPath = payload.path ?? ""; + const normalizedPath = normalizePath(originalPath); + const isHttpUrl = isRemoteHttpUrl(normalizedPath); + const resolvedPath = isHttpUrl + ? normalizedPath + : validateStructuredPayloadLocalPath(ctx, originalPath, mediaType); + if (!resolvedPath) { + return null; + } + if (!resolvedPath.trim()) { + ctx.log?.error( + `[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`, + ); + return null; + } + return { path: resolvedPath, isHttpUrl }; +} + +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 ""; + } + 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 ""; + } +} + +async function readStructuredPayloadLocalFile(filePath: string): Promise { + 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 { + 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( + `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 = { + ".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(`Unsupported image format: ${ext}`); + return; + } + imageUrl = `data:${mimeType};base64,${base64Data}`; + log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`); + } catch (readErr) { + log?.error( + `Failed to read local image: ${ + readErr instanceof Error ? readErr.message : JSON.stringify(readErr) + }`, + ); + return; + } + } + + try { + const deliveryTarget = buildDeliveryTarget(target); + const creds = accountToCreds(account); + + await withTokenRetry( + creds, + async () => { + if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") { + await senderSendImage(deliveryTarget, imageUrl, creds, { + msgId: target.messageId, + localPath: originalImagePath, + }); + } else if (deliveryTarget.type === "dm") { + await senderSendText(deliveryTarget, `![](${payload.path})`, creds, { + msgId: target.messageId, + }); + } else { + await senderSendText(deliveryTarget, `![](${payload.path})`, creds, { + msgId: target.messageId, + }); + } + }, + log, + account.accountId, + ); + log?.debug?.(`Sent image via media payload`); + + if (payload.caption) { + await sendTextToTarget(ctx, payload.caption); + } + } catch (err) { + log?.error(`Failed to send image: ${formatErrorMessage(err)}`); + } +} + +async function handleAudioPayload( + ctx: ReplyContext, + payload: MediaPayload, + deps?: ReplyDispatcherDeps, +): Promise { + const { target, account, cfg, log } = ctx; + if (!deps) { + log?.error(`TTS deps not provided, cannot handle audio payload`); + return; + } + try { + const ttsText = payload.caption || payload.path; + if (!ttsText?.trim()) { + log?.error(`Voice missing text`); + return; + } + + log?.debug?.(`TTS: "${ttsText.slice(0, 50)}..."`); + const ttsResult = await deps.tts.textToSpeech({ + text: ttsText, + cfg, + channel: "qqbot", + }); + if (!ttsResult.success || !ttsResult.audioPath) { + log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`); + return; + } + + const providerLabel = ttsResult.provider ?? "unknown"; + log?.debug?.( + `TTS returned: provider=${providerLabel}, format=${ttsResult.outputFormat}, path=${ttsResult.audioPath}`, + ); + + const silkBase64 = await deps.tts.audioFileToSilkBase64(ttsResult.audioPath); + if (!silkBase64) { + log?.error(`Failed to convert TTS audio to SILK`); + return; + } + const silkPath = ttsResult.audioPath; + + log?.debug?.(`TTS done (${providerLabel}), file: ${silkPath}`); + + const deliveryTarget = buildDeliveryTarget(target); + const creds = accountToCreds(account); + + await withTokenRetry( + creds, + async () => { + if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") { + await senderSendVoice(deliveryTarget, creds, { + voiceBase64: silkBase64, + msgId: target.messageId, + ttsText, + filePath: silkPath, + }); + } else { + log?.error(`Voice not supported in ${deliveryTarget.type}, sending text fallback`); + await senderSendText(deliveryTarget, ttsText, creds, { msgId: target.messageId }); + } + }, + log, + account.accountId, + ); + log?.debug?.(`Voice message sent`); + } catch (err) { + log?.error(`TTS/voice send failed: ${formatErrorMessage(err)}`); + } +} + +async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise { + const { target, account, log } = ctx; + try { + const resolved = resolveStructuredPayloadPath(ctx, payload, "video"); + if (!resolved) { + return; + } + const videoPath = resolved.path; + const isHttpUrl = resolved.isHttpUrl; + + log?.debug?.(`Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`); + + const deliveryTarget = buildDeliveryTarget(target); + const creds = accountToCreds(account); + + if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") { + log?.error(`Video not supported in ${deliveryTarget.type}`); + return; + } + + await withTokenRetry( + creds, + async () => { + if (isHttpUrl) { + await senderSendVideo(deliveryTarget, creds, { + videoUrl: videoPath, + msgId: target.messageId, + }); + } else { + const fileBuffer = await readStructuredPayloadLocalFile(videoPath); + const videoBase64 = fileBuffer.toString("base64"); + log?.debug?.( + `Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`, + ); + await senderSendVideo(deliveryTarget, creds, { + videoBase64, + msgId: target.messageId, + localPath: videoPath, + }); + } + }, + log, + account.accountId, + ); + log?.debug?.(`Video message sent`); + + if (payload.caption) { + await sendTextToTarget(ctx, payload.caption); + } + } catch (err) { + log?.error(`Video send failed: ${formatErrorMessage(err)}`); + } +} + +async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise { + const { target, account, log } = ctx; + try { + const resolved = resolveStructuredPayloadPath(ctx, payload, "file"); + if (!resolved) { + return; + } + const filePath = resolved.path; + const isHttpUrl = resolved.isHttpUrl; + + const fileName = sanitizeFileName(path.basename(filePath)); + log?.debug?.( + `File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`, + ); + + const deliveryTarget = buildDeliveryTarget(target); + const creds = accountToCreds(account); + + if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") { + log?.error(`File not supported in ${deliveryTarget.type}`); + return; + } + + await withTokenRetry( + creds, + async () => { + if (isHttpUrl) { + await senderSendFile(deliveryTarget, creds, { + fileUrl: filePath, + msgId: target.messageId, + fileName, + }); + } else { + const fileBuffer = await readStructuredPayloadLocalFile(filePath); + const fileBase64 = fileBuffer.toString("base64"); + await senderSendFile(deliveryTarget, creds, { + fileBase64, + msgId: target.messageId, + fileName, + localFilePath: filePath, + }); + } + }, + log, + account.accountId, + ); + log?.debug?.(`File message sent`); + } catch (err) { + log?.error(`File send failed: ${formatErrorMessage(err)}`); + } +} diff --git a/extensions/qqbot/src/engine/messaging/reply-limiter.ts b/extensions/qqbot/src/engine/messaging/reply-limiter.ts new file mode 100644 index 00000000000..a86c19186dc --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/reply-limiter.ts @@ -0,0 +1,164 @@ +/** + * Passive reply limiter — enforce per-message reply count and TTL limits. + * + * QQ Bot restricts how many passive replies can be sent in response to a + * single inbound message (4 per hour by default). This module tracks reply + * counts and determines whether the next reply should be passive or + * fall back to proactive mode. + * + * The module is a **class** with zero I/O dependencies, fully supporting + * multi-account concurrent operation via separate instances. + */ + +/** Configuration for the reply limiter. */ +export interface ReplyLimiterConfig { + /** Maximum passive replies per message. Defaults to 4. */ + limit?: number; + /** TTL in milliseconds for the passive reply window. Defaults to 1 hour. */ + ttlMs?: number; + /** Maximum number of tracked messages before eviction. Defaults to 10000. */ + maxTrackedMessages?: number; +} + +/** Result of a passive-reply limit check. */ +export interface ReplyLimitResult { + /** Whether a passive reply is still allowed. */ + allowed: boolean; + /** Number of remaining passive replies. */ + remaining: number; + /** Whether the caller should fall back to proactive mode. */ + shouldFallbackToProactive: boolean; + /** Reason for the fallback. */ + fallbackReason?: "expired" | "limit_exceeded"; + /** Human-readable diagnostic message. */ + message?: string; +} + +interface ReplyRecord { + count: number; + firstReplyAt: number; +} + +const DEFAULT_LIMIT = 4; +const DEFAULT_TTL_MS = 60 * 60 * 1000; +const DEFAULT_MAX_TRACKED = 10_000; + +/** + * Per-account reply limiter with automatic eviction. + * + * Usage: + * ```ts + * const limiter = new ReplyLimiter({ limit: 4, ttlMs: 3600000 }); + * const check = limiter.checkLimit(messageId); + * if (check.allowed) { + * await sendPassiveReply(...); + * limiter.record(messageId); + * } else if (check.shouldFallbackToProactive) { + * await sendProactiveMessage(...); + * } + * ``` + */ +export class ReplyLimiter { + private readonly limit: number; + private readonly ttlMs: number; + private readonly maxTracked: number; + private readonly tracker = new Map(); + + constructor(config?: ReplyLimiterConfig) { + this.limit = config?.limit ?? DEFAULT_LIMIT; + this.ttlMs = config?.ttlMs ?? DEFAULT_TTL_MS; + this.maxTracked = config?.maxTrackedMessages ?? DEFAULT_MAX_TRACKED; + } + + /** Check whether a passive reply is allowed for the given message. */ + checkLimit(messageId: string): ReplyLimitResult { + const now = Date.now(); + this.evictIfNeeded(now); + + const record = this.tracker.get(messageId); + + if (!record) { + return { + allowed: true, + remaining: this.limit, + shouldFallbackToProactive: false, + }; + } + + if (now - record.firstReplyAt > this.ttlMs) { + return { + allowed: false, + remaining: 0, + shouldFallbackToProactive: true, + fallbackReason: "expired", + message: `Message is older than ${this.ttlMs / (60 * 60 * 1000)}h; sending as a proactive message instead`, + }; + } + + const remaining = this.limit - record.count; + if (remaining <= 0) { + return { + allowed: false, + remaining: 0, + shouldFallbackToProactive: true, + fallbackReason: "limit_exceeded", + message: `Passive reply limit reached (${this.limit} per hour); sending proactively instead`, + }; + } + + return { + allowed: true, + remaining, + shouldFallbackToProactive: false, + }; + } + + /** Record one passive reply against a message. */ + record(messageId: string): void { + const now = Date.now(); + const existing = this.tracker.get(messageId); + + if (!existing) { + this.tracker.set(messageId, { count: 1, firstReplyAt: now }); + } else if (now - existing.firstReplyAt > this.ttlMs) { + this.tracker.set(messageId, { count: 1, firstReplyAt: now }); + } else { + existing.count++; + } + } + + /** Return diagnostic stats. */ + getStats(): { trackedMessages: number; totalReplies: number } { + let totalReplies = 0; + for (const record of this.tracker.values()) { + totalReplies += record.count; + } + return { trackedMessages: this.tracker.size, totalReplies }; + } + + /** Return limiter configuration. */ + getConfig(): { limit: number; ttlMs: number; ttlHours: number } { + return { + limit: this.limit, + ttlMs: this.ttlMs, + ttlHours: this.ttlMs / (60 * 60 * 1000), + }; + } + + /** Clear all tracked records. */ + clear(): void { + this.tracker.clear(); + } + + /** Opportunistically evict expired records to keep the tracker bounded. */ + private evictIfNeeded(now: number): void { + if (this.tracker.size <= this.maxTracked) { + return; + } + for (const [id, rec] of this.tracker) { + if (now - rec.firstReplyAt > this.ttlMs) { + this.tracker.delete(id); + } + } + } +} diff --git a/extensions/qqbot/src/engine/messaging/sender.ts b/extensions/qqbot/src/engine/messaging/sender.ts new file mode 100644 index 00000000000..ca9e5a72305 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/sender.ts @@ -0,0 +1,700 @@ +/** + * Unified message sender — per-account resource management + business function layer. + * + * This module is the **single entry point** for all QQ Bot API operations. + * + * ## Architecture + * + * Each account gets its own isolated resource stack: + * + * ``` + * _accountRegistry: Map + * + * AccountContext { + * logger — per-account prefixed logger + * client — per-account ApiClient + * tokenMgr — per-account TokenManager + * mediaApi — per-account MediaApi + * messageApi — per-account MessageApi + * } + * ``` + * + * Upper-layer callers (gateway, outbound, reply-dispatcher, proactive) + * always go through exported functions that resolve the correct + * `AccountContext` by appId. + */ + +import os from "node:os"; +import { ApiClient } from "../api/api-client.js"; +import { MediaApi as MediaApiClass } from "../api/media.js"; +import type { Credentials } from "../api/messages.js"; +import { MessageApi as MessageApiClass } from "../api/messages.js"; +import { getNextMsgSeq } from "../api/routes.js"; +import { TokenManager } from "../api/token.js"; +import { + MediaFileType, + type ChatScope, + type EngineLogger, + type MessageResponse, + type OutboundMeta, +} from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { debugLog, debugError, debugWarn } from "../utils/log.js"; +import { sanitizeFileName } from "../utils/string-normalize.js"; +import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "../utils/upload-cache.js"; + +// ============ Re-exported types ============ + +export { ApiError } from "../types.js"; +export type { OutboundMeta, MessageResponse, UploadMediaResponse } from "../types.js"; +export { MediaFileType } from "../types.js"; + +// ============ Plugin User-Agent ============ + +let _pluginVersion = "unknown"; +let _openclawVersion = "unknown"; + +/** Build the User-Agent string from the current plugin and framework versions. */ +function buildUserAgent(): string { + return `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`; +} + +/** Return the current User-Agent string. */ +export function getPluginUserAgent(): string { + return buildUserAgent(); +} + +/** + * Initialize sender with the plugin version. + * Must be called once during startup before any API calls. + */ +export function initSender(options: { pluginVersion?: string; openclawVersion?: string }): void { + if (options.pluginVersion) { + _pluginVersion = options.pluginVersion; + } + if (options.openclawVersion) { + _openclawVersion = options.openclawVersion; + } +} + +/** Update the OpenClaw framework version in the User-Agent (called after runtime injection). */ +export function setOpenClawVersion(version: string): void { + if (version) { + _openclawVersion = version; + } +} + +// ============ Per-account resource management ============ + +/** Complete resource context for a single account. */ +interface AccountContext { + logger: EngineLogger; + client: ApiClient; + tokenMgr: TokenManager; + mediaApi: MediaApiClass; + messageApi: MessageApiClass; + markdownSupport: boolean; +} + +/** Per-appId account registry — each account owns all its resources. */ +const _accountRegistry = new Map(); + +/** Fallback logger for unregistered accounts (CLI / test scenarios). */ +const _fallbackLogger: EngineLogger = { + info: (msg: string) => debugLog(msg), + error: (msg: string) => debugError(msg), + warn: (msg: string) => debugWarn(msg), + debug: (msg: string) => debugLog(msg), +}; + +/** + * Build a full resource stack for a given logger. + * + * Shared by both `registerAccount` (explicit registration) and + * `resolveAccount` (lazy fallback for unregistered accounts). + */ +function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): AccountContext { + const client = new ApiClient({ logger, userAgent: buildUserAgent }); + const tokenMgr = new TokenManager({ logger, userAgent: buildUserAgent }); + const mediaApi = new MediaApiClass(client, tokenMgr, { + logger, + uploadCache: { + computeHash: computeFileHash, + get: (hash: string, scope: string, targetId: string, fileType: number) => + getCachedFileInfo(hash, scope as ChatScope, targetId, fileType), + set: ( + hash: string, + scope: string, + targetId: string, + fileType: number, + fileInfo: string, + fileUuid: string, + ttl: number, + ) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl), + }, + sanitizeFileName, + }); + const messageApi = new MessageApiClass(client, tokenMgr, { + markdownSupport, + logger, + }); + + return { logger, client, tokenMgr, mediaApi, messageApi, markdownSupport }; +} + +/** + * Register an account — atomically sets up all per-appId resources. + * + * Must be called once per account during gateway startup. + * Creates a complete isolated resource stack (ApiClient, TokenManager, + * MediaApi, MessageApi) with the per-account logger. + */ +export function registerAccount( + appId: string, + options: { + logger: EngineLogger; + markdownSupport?: boolean; + }, +): void { + const key = appId.trim(); + const md = options.markdownSupport === true; + _accountRegistry.set(key, buildAccountContext(options.logger, md)); +} + +/** + * Initialize per-app API behavior such as markdown support. + * + * If the account was already registered via `registerAccount()`, updates its + * MessageApi with the new markdown setting while preserving the existing + * logger and resource stack. Otherwise creates a new context. + */ +export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void { + const key = appId.trim(); + const md = options.markdownSupport === true; + const existing = _accountRegistry.get(key); + if (existing) { + // Re-create only MessageApi with updated config, reuse existing stack. + existing.messageApi = new MessageApiClass(existing.client, existing.tokenMgr, { + markdownSupport: md, + logger: existing.logger, + }); + existing.markdownSupport = md; + } else { + _accountRegistry.set(key, buildAccountContext(_fallbackLogger, md)); + } +} + +/** + * Resolve the AccountContext for a given appId. + * + * If the account was registered via `registerAccount()`, returns the + * pre-built context. Otherwise lazily creates a fallback context. + */ +function resolveAccount(appId: string): AccountContext { + const key = appId.trim(); + let ctx = _accountRegistry.get(key); + if (!ctx) { + ctx = buildAccountContext(_fallbackLogger, false); + _accountRegistry.set(key, ctx); + } + return ctx; +} + +// ============ Instance getters (for advanced callers) ============ + +/** Get the MessageApi instance for the given appId. */ +export function getMessageApi(appId: string): MessageApiClass { + return resolveAccount(appId).messageApi; +} + +/** Get the MediaApi instance for the given appId. */ +export function getMediaApi(appId: string): MediaApiClass { + return resolveAccount(appId).mediaApi; +} + +/** Get the TokenManager instance for the given appId. */ +export function getTokenManager(appId: string): TokenManager { + return resolveAccount(appId).tokenMgr; +} + +/** Get the ApiClient instance for the given appId. */ +export function getApiClient(appId: string): ApiClient { + return resolveAccount(appId).client; +} + +// ============ Per-appId config ============ + +type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void; + +/** Register an outbound-message hook scoped to one appId. */ +export function onMessageSent(appId: string, callback: OnMessageSentCallback): void { + resolveAccount(appId).messageApi.onMessageSent(callback); +} + +/** Return whether markdown is enabled for the given appId. */ +export function isMarkdownSupport(appId: string): boolean { + return _accountRegistry.get(appId.trim())?.markdownSupport ?? false; +} + +// ============ Token management ============ + +export async function getAccessToken(appId: string, clientSecret: string): Promise { + return resolveAccount(appId).tokenMgr.getAccessToken(appId, clientSecret); +} + +export function clearTokenCache(appId?: string): void { + if (appId) { + resolveAccount(appId).tokenMgr.clearCache(appId); + } else { + for (const ctx of _accountRegistry.values()) { + ctx.tokenMgr.clearCache(); + } + } +} + +export function getTokenStatus(appId: string): { + status: "valid" | "expired" | "refreshing" | "none"; + expiresAt: number | null; +} { + return resolveAccount(appId).tokenMgr.getStatus(appId); +} + +export function startBackgroundTokenRefresh( + appId: string, + clientSecret: string, + options?: { + refreshAheadMs?: number; + randomOffsetMs?: number; + minRefreshIntervalMs?: number; + retryDelayMs?: number; + log?: { + info: (msg: string) => void; + error: (msg: string) => void; + debug?: (msg: string) => void; + }; + }, +): void { + resolveAccount(appId).tokenMgr.startBackgroundRefresh(appId, clientSecret, options); +} + +export function stopBackgroundTokenRefresh(appId?: string): void { + if (appId) { + resolveAccount(appId).tokenMgr.stopBackgroundRefresh(appId); + } else { + for (const ctx of _accountRegistry.values()) { + ctx.tokenMgr.stopBackgroundRefresh(); + } + } +} + +export function isBackgroundTokenRefreshRunning(appId?: string): boolean { + if (appId) { + return resolveAccount(appId).tokenMgr.isBackgroundRefreshRunning(appId); + } + for (const ctx of _accountRegistry.values()) { + if (ctx.tokenMgr.isBackgroundRefreshRunning()) { + return true; + } + } + return false; +} + +// ============ Gateway URL ============ + +export async function getGatewayUrl(accessToken: string, appId: string): Promise { + const data = await resolveAccount(appId).client.request<{ url: string }>( + accessToken, + "GET", + "/gateway", + ); + return data.url; +} + +// ============ Interaction ============ + +/** Acknowledge an INTERACTION_CREATE event via PUT /interactions/{id}. */ +export async function acknowledgeInteraction( + creds: AccountCreds, + interactionId: string, + code: 0 | 1 | 2 | 3 | 4 | 5 = 0, +): Promise { + const ctx = resolveAccount(creds.appId); + const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret); + await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, { code }); +} + +// ============ Types ============ + +/** Delivery target resolved from event context. */ +export interface DeliveryTarget { + type: "c2c" | "group" | "channel" | "dm"; + id: string; +} + +/** Account credentials for API authentication. */ +export interface AccountCreds { + appId: string; + clientSecret: string; +} + +// ============ Token retry ============ + +/** + * Execute an API call with automatic token-retry on 401 errors. + */ +export async function withTokenRetry( + creds: AccountCreds, + sendFn: (token: string) => Promise, + log?: EngineLogger, + _accountId?: string, +): Promise { + try { + const token = await getAccessToken(creds.appId, creds.clientSecret); + return await sendFn(token); + } catch (err) { + const errMsg = formatErrorMessage(err); + if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) { + log?.debug?.(`Token may be expired, refreshing...`); + clearTokenCache(creds.appId); + const newToken = await getAccessToken(creds.appId, creds.clientSecret); + return await sendFn(newToken); + } + throw err; + } +} + +// ============ Media hook helper ============ + +/** + * Notify the MessageApi onMessageSent hook after a media send. + */ +function notifyMediaHook(appId: string, result: MessageResponse, meta: OutboundMeta): void { + const refIdx = result.ext_info?.ref_idx; + if (refIdx) { + resolveAccount(appId).messageApi.notifyMessageSent(refIdx, meta); + } +} + +// ============ Text sending ============ + +/** + * Send a text message to any QQ target type. + * + * Automatically routes to the correct API method based on target type. + * Handles passive (with msgId) and proactive (without msgId) modes. + */ +export async function sendText( + target: DeliveryTarget, + content: string, + creds: AccountCreds, + opts?: { msgId?: string; messageReference?: string }, +): Promise { + const api = resolveAccount(creds.appId).messageApi; + const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret }; + + if (target.type === "c2c" || target.type === "group") { + const scope: ChatScope = target.type; + if (opts?.msgId) { + return api.sendMessage(scope, target.id, content, c, { + msgId: opts.msgId, + messageReference: opts.messageReference, + }); + } + return api.sendProactiveMessage(scope, target.id, content, c); + } + + if (target.type === "dm") { + return api.sendDmMessage({ guildId: target.id, content, creds: c, msgId: opts?.msgId }); + } + + return api.sendChannelMessage({ channelId: target.id, content, creds: c, msgId: opts?.msgId }); +} + +/** + * Send text with automatic token-retry. + */ +export async function sendTextWithRetry( + target: DeliveryTarget, + content: string, + creds: AccountCreds, + opts?: { msgId?: string; messageReference?: string }, + log?: EngineLogger, +): Promise { + return withTokenRetry( + creds, + async () => sendText(target, content, creds, opts), + log, + creds.appId, + ); +} + +/** + * Send a proactive text message (no msgId). + */ +export async function sendProactiveText( + target: DeliveryTarget, + content: string, + creds: AccountCreds, +): Promise { + return sendText(target, content, creds); +} + +// ============ Input notify ============ + +/** + * Send a typing indicator to a C2C user. + */ +export async function sendInputNotify(opts: { + openid: string; + creds: AccountCreds; + msgId?: string; + inputSecond?: number; +}): Promise<{ refIdx?: string }> { + const api = resolveAccount(opts.creds.appId).messageApi; + const c: Credentials = { appId: opts.creds.appId, clientSecret: opts.creds.clientSecret }; + return api.sendInputNotify({ + openid: opts.openid, + creds: c, + msgId: opts.msgId, + inputSecond: opts.inputSecond, + }); +} + +/** + * Raw-token input notify — compatible with TypingKeepAlive's callback signature. + */ +export function createRawInputNotifyFn( + appId: string, +): ( + token: string, + openid: string, + msgId: string | undefined, + inputSecond: number, +) => Promise { + return async (token, openid, msgId, inputSecond) => { + const msgSeq = msgId ? getNextMsgSeq(msgId) : 1; + return resolveAccount(appId).client.request(token, "POST", `/v2/users/${openid}/messages`, { + msg_type: 6, + input_notify: { input_type: 1, input_second: inputSecond }, + msg_seq: msgSeq, + ...(msgId ? { msg_id: msgId } : {}), + }); + }; +} + +// ============ Image sending ============ + +/** + * Upload and send an image message to any C2C/Group target. + */ +export async function sendImage( + target: DeliveryTarget, + imageUrl: string, + creds: AccountCreds, + opts?: { msgId?: string; content?: string; localPath?: string }, +): Promise { + if (target.type !== "c2c" && target.type !== "group") { + throw new Error(`Image sending not supported for target type: ${target.type}`); + } + + const ctx = resolveAccount(creds.appId); + const scope: ChatScope = target.type; + const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret }; + + const isBase64 = imageUrl.startsWith("data:"); + let uploadOpts: { url?: string; fileData?: string }; + if (isBase64) { + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) { + throw new Error("Invalid Base64 Data URL format"); + } + uploadOpts = { fileData: matches[2] }; + } else { + uploadOpts = { url: imageUrl }; + } + + const uploadResult = await ctx.mediaApi.uploadMedia( + scope, + target.id, + MediaFileType.IMAGE, + c, + uploadOpts, + ); + + const meta: OutboundMeta = { + text: opts?.content, + mediaType: "image", + ...(!isBase64 ? { mediaUrl: imageUrl } : {}), + ...(opts?.localPath ? { mediaLocalPath: opts.localPath } : {}), + }; + + const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, { + msgId: opts?.msgId, + content: opts?.content, + }); + + notifyMediaHook(creds.appId, result, meta); + + return result; +} + +// ============ Voice sending ============ + +/** + * Upload and send a voice message. + */ +export async function sendVoiceMessage( + target: DeliveryTarget, + creds: AccountCreds, + opts: { + voiceBase64?: string; + voiceUrl?: string; + msgId?: string; + ttsText?: string; + filePath?: string; + }, +): Promise { + if (target.type !== "c2c" && target.type !== "group") { + throw new Error(`Voice sending not supported for target type: ${target.type}`); + } + + const ctx = resolveAccount(creds.appId); + const scope: ChatScope = target.type; + const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret }; + + const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VOICE, c, { + url: opts.voiceUrl, + fileData: opts.voiceBase64, + }); + + const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, { + msgId: opts.msgId, + }); + + notifyMediaHook(creds.appId, result, { + mediaType: "voice", + ...(opts.ttsText ? { ttsText: opts.ttsText } : {}), + ...(opts.filePath ? { mediaLocalPath: opts.filePath } : {}), + }); + + return result; +} + +// ============ Video sending ============ + +/** + * Upload and send a video message. + */ +export async function sendVideoMessage( + target: DeliveryTarget, + creds: AccountCreds, + opts: { + videoUrl?: string; + videoBase64?: string; + msgId?: string; + content?: string; + localPath?: string; + }, +): Promise { + if (target.type !== "c2c" && target.type !== "group") { + throw new Error(`Video sending not supported for target type: ${target.type}`); + } + + const ctx = resolveAccount(creds.appId); + const scope: ChatScope = target.type; + const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret }; + + const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VIDEO, c, { + url: opts.videoUrl, + fileData: opts.videoBase64, + }); + + const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, { + msgId: opts.msgId, + content: opts.content, + }); + + notifyMediaHook(creds.appId, result, { + text: opts.content, + mediaType: "video", + ...(opts.videoUrl ? { mediaUrl: opts.videoUrl } : {}), + ...(opts.localPath ? { mediaLocalPath: opts.localPath } : {}), + }); + + return result; +} + +// ============ File sending ============ + +/** + * Upload and send a file message. + */ +export async function sendFileMessage( + target: DeliveryTarget, + creds: AccountCreds, + opts: { + fileBase64?: string; + fileUrl?: string; + msgId?: string; + fileName?: string; + localFilePath?: string; + }, +): Promise { + if (target.type !== "c2c" && target.type !== "group") { + throw new Error(`File sending not supported for target type: ${target.type}`); + } + + const ctx = resolveAccount(creds.appId); + const scope: ChatScope = target.type; + const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret }; + + const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.FILE, c, { + url: opts.fileUrl, + fileData: opts.fileBase64, + fileName: opts.fileName, + }); + + const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, { + msgId: opts.msgId, + }); + + notifyMediaHook(creds.appId, result, { + mediaType: "file", + mediaUrl: opts.fileUrl, + mediaLocalPath: opts.localFilePath ?? opts.fileName, + }); + + return result; +} + +// ============ Helpers ============ + +/** Build a DeliveryTarget from event context fields. */ +export function buildDeliveryTarget(event: { + type: "c2c" | "guild" | "dm" | "group"; + senderId: string; + channelId?: string; + guildId?: string; + groupOpenid?: string; +}): DeliveryTarget { + switch (event.type) { + case "c2c": + return { type: "c2c", id: event.senderId }; + case "group": + return { type: "group", id: event.groupOpenid! }; + case "dm": + return { type: "dm", id: event.guildId! }; + default: + return { type: "channel", id: event.channelId! }; + } +} + +/** Build AccountCreds from a GatewayAccount. */ +export function accountToCreds(account: { appId: string; clientSecret: string }): AccountCreds { + return { appId: account.appId, clientSecret: account.clientSecret }; +} + +/** Check whether a target type supports rich media (C2C and Group only). */ +export function supportsRichMedia(targetType: string): boolean { + return targetType === "c2c" || targetType === "group"; +} diff --git a/extensions/qqbot/src/engine/messaging/target-parser.ts b/extensions/qqbot/src/engine/messaging/target-parser.ts new file mode 100644 index 00000000000..e0c43be6eda --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/target-parser.ts @@ -0,0 +1,122 @@ +/** + * QQ Bot target address parser — parse "qqbot:c2c:xxx" style addresses + * into structured delivery targets. + * + * All functions are **pure** (no side effects, no I/O), making them easy + * to test and safe to share between the built-in and standalone versions. + */ + +/** Supported target types. */ +export type TargetType = "c2c" | "group" | "channel"; + +/** Parsed delivery target. */ +export interface ParsedTarget { + type: TargetType; + id: string; +} + +/** + * Parse a qqbot target string into a structured delivery target. + * + * Supported formats: + * - `qqbot:c2c:openid` → C2C direct message + * - `qqbot:group:groupid` → Group message + * - `qqbot:channel:channelid` → Channel message + * - `c2c:openid` → C2C (without qqbot: prefix) + * - `group:groupid` → Group (without qqbot: prefix) + * - `channel:channelid` → Channel (without qqbot: prefix) + * - `openid` → C2C (bare openid, default) + * + * @param to - Raw target string. + * @returns Parsed target with type and id. + * @throws {Error} When the target format is invalid. + */ +export function parseTarget(to: string): ParsedTarget { + let id = to.replace(/^qqbot:/i, ""); + + if (id.startsWith("c2c:")) { + const userId = id.slice(4); + if (!userId) { + throw new Error(`Invalid c2c target format: ${to} - missing user ID`); + } + return { type: "c2c", id: userId }; + } + + if (id.startsWith("group:")) { + const groupId = id.slice(6); + if (!groupId) { + throw new Error(`Invalid group target format: ${to} - missing group ID`); + } + return { type: "group", id: groupId }; + } + + if (id.startsWith("channel:")) { + const channelId = id.slice(8); + if (!channelId) { + throw new Error(`Invalid channel target format: ${to} - missing channel ID`); + } + return { type: "channel", id: channelId }; + } + + if (!id) { + throw new Error(`Invalid target format: ${to} - empty ID after removing qqbot: prefix`); + } + + // Default to C2C when no type prefix is present. + return { type: "c2c", id }; +} + +/** + * Map a parsed target type to a ChatScope for API calls. + * + * Channel and DM targets are not C2C/Group scoped and should be handled + * separately by the caller. + * + * @returns `'c2c'` or `'group'`, or `undefined` for channel targets. + */ +export function targetToChatScope(target: ParsedTarget): "c2c" | "group" | undefined { + if (target.type === "c2c") { + return "c2c"; + } + if (target.type === "group") { + return "group"; + } + return undefined; +} + +/** + * Normalize a QQ Bot target string into the canonical `qqbot:...` form. + * + * Returns `undefined` when the target does not look like a QQ Bot address. + */ +export function normalizeTarget(target: string): string | undefined { + const id = target.replace(/^qqbot:/i, ""); + if (id.startsWith("c2c:") || id.startsWith("group:") || id.startsWith("channel:")) { + return `qqbot:${id}`; + } + // 32-char hex openid + if (/^[0-9a-fA-F]{32}$/.test(id)) { + return `qqbot:c2c:${id}`; + } + // UUID-format openid + if (/^[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}$/.test(id)) { + return `qqbot:c2c:${id}`; + } + return undefined; +} + +/** + * Return true when the string looks like a QQ Bot target ID. + */ +export function looksLikeQQBotTarget(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; + } + return /^[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}$/.test(id); +} diff --git a/extensions/qqbot/src/engine/ref/format-message-ref.ts b/extensions/qqbot/src/engine/ref/format-message-ref.ts new file mode 100644 index 00000000000..cc4d0f820bf --- /dev/null +++ b/extensions/qqbot/src/engine/ref/format-message-ref.ts @@ -0,0 +1,142 @@ +/** + * Format a message_reference (from msg_elements[0]) into text for model context. + * + * This handles the cache-miss path: when a user quotes a message we haven't + * cached in the ref-index store, we fall back to the msg_elements[0] data + * pushed by the QQ platform. + * + * The heavy lifting (attachment download, STT, etc.) is delegated to an + * injected `AttachmentProcessor` so this module stays framework-agnostic. + */ + +import type { EngineLogger } from "../types.js"; +import { parseFaceTags, buildAttachmentSummaries } from "../utils/text-parsing.js"; +import { formatRefEntryForAgent } from "./format-ref-entry.js"; +import type { RefAttachmentSummary } from "./types.js"; + +// ============ Injected dependency ============ + +/** Attachment download & voice transcription — injected from the outer layer. */ +export interface AttachmentProcessor { + processAttachments( + attachments: + | Array<{ + content_type: string; + url: string; + filename?: string; + height?: number; + width?: number; + size?: number; + voice_wav_url?: string; + asr_refer_text?: string; + }> + | undefined, + ctx: { appId: string; peerId?: string; cfg: unknown; log?: EngineLogger }, + ): Promise<{ + attachmentInfo: string; + voiceTranscripts: string[]; + voiceTranscriptSources: string[]; + attachmentLocalPaths: Array; + }>; + + formatVoiceText(voiceTranscripts: string[]): string; +} + +// ============ Public API ============ + +/** + * Format a quoted message reference into human-readable text for model context. + * + * This mirrors the independent version's `formatMessageReferenceForAgent` — + * processing attachments (download + STT) and combining them with parsed text. + * + * @param ref - The msg_elements[0] data from the QQ push event. + * @param ctx - Context containing appId, peerId, config, and logger. + * @param processor - Injected attachment processor (download + voice transcription). + */ +export async function formatMessageReferenceForAgent( + ref: + | { + content?: string; + attachments?: Array<{ + content_type: string; + url: string; + filename?: string; + height?: number; + width?: number; + size?: number; + voice_wav_url?: string; + asr_refer_text?: string; + }>; + } + | undefined, + ctx: { + appId: string; + peerId?: string; + cfg: unknown; + log?: EngineLogger; + }, + processor: AttachmentProcessor, +): Promise { + if (!ref) { + return ""; + } + + // Process attachments (download images, transcribe voice, etc.) + const processed = await processor.processAttachments(ref.attachments, ctx); + const { attachmentInfo, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = + processed; + + // Format voice transcript text + const voiceText = processor.formatVoiceText(voiceTranscripts); + + // Parse QQ face tags into readable text + const parsedContent = parseFaceTags(ref.content ?? ""); + + // Combine text content with voice transcript and attachment info + const userContent = voiceText + ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo + : parsedContent + attachmentInfo; + + // Build attachment summaries and inject voice transcripts + const attSummaries = buildAttachmentSummaries( + ref.attachments as Array<{ + content_type: string; + url: string; + filename?: string; + voice_wav_url?: string; + }>, + attachmentLocalPaths, + ); + if (attSummaries && voiceTranscripts.length > 0) { + let voiceIdx = 0; + for (const att of attSummaries) { + if (att.type === "voice" && voiceIdx < voiceTranscripts.length) { + att.transcript = voiceTranscripts[voiceIdx]; + if (voiceIdx < voiceTranscriptSources.length) { + att.transcriptSource = voiceTranscriptSources[ + voiceIdx + ] as RefAttachmentSummary["transcriptSource"]; + } + voiceIdx++; + } + } + } + + // Format using the same function as the cache-hit path + const refEntry = { + content: userContent.trim(), + senderId: "", + timestamp: Date.now(), + attachments: attSummaries, + }; + + const formattedAttachments = formatRefEntryForAgent(refEntry); + // If formatRefEntryForAgent already includes the content, use it directly. + // Otherwise combine manually. + if (formattedAttachments !== "[empty message]") { + return formattedAttachments; + } + + return userContent.trim() || ""; +} diff --git a/extensions/qqbot/src/engine/ref/format-ref-entry.ts b/extensions/qqbot/src/engine/ref/format-ref-entry.ts new file mode 100644 index 00000000000..b0e3cf33d13 --- /dev/null +++ b/extensions/qqbot/src/engine/ref/format-ref-entry.ts @@ -0,0 +1,53 @@ +/** + * Format a ref-index entry into text suitable for model context. + * + * Zero external dependencies — pure string formatting. + */ + +import type { RefIndexEntry } from "./types.js"; + +/** 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: Record = { + 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]"; +} diff --git a/extensions/qqbot/src/ref-index-store.ts b/extensions/qqbot/src/engine/ref/store.ts similarity index 54% rename from extensions/qqbot/src/ref-index-store.ts rename to extensions/qqbot/src/engine/ref/store.ts index 22c924758d4..04cb206f16c 100644 --- a/extensions/qqbot/src/ref-index-store.ts +++ b/extensions/qqbot/src/engine/ref/store.ts @@ -1,28 +1,20 @@ +/** + * Ref-index store — JSONL file-based store for message reference index. + * + * Migrated from src/ref-index-store.ts. Dependencies are only Node.js + * built-ins + log + platform (both zero plugin-sdk). + */ + import fs from "node:fs"; import path from "node:path"; -import { debugLog, debugError } from "./utils/debug-log.js"; -import { getQQBotDataDir } from "./utils/platform.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { debugLog, debugError } from "../utils/log.js"; +import { getQQBotDataDir } from "../utils/platform.js"; +import type { RefIndexEntry } from "./types.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; -} +// Re-export types and format function for convenience. +export type { RefIndexEntry, RefAttachmentSummary } from "./types.js"; +export { formatRefEntryForAgent } from "./format-ref-entry.js"; const STORAGE_DIR = getQQBotDataDir("data"); const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl"); @@ -39,12 +31,10 @@ interface RefIndexLine { let cache: Map | null = null; let totalLinesOnDisk = 0; -/** Lazily load the JSONL store into memory. */ function loadFromFile(): Map { if (cache !== null) { return cache; } - cache = new Map(); totalLinesOnDisk = 0; @@ -52,7 +42,6 @@ function loadFromFile(): Map { 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(); @@ -64,99 +53,84 @@ function loadFromFile(): Map { 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, - }); + 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)}`); + debugError(`[ref-index-store] Failed to load: ${formatErrorMessage(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; +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: ${formatErrorMessage(err)}`); } - return totalLinesOnDisk > cache.size * COMPACT_THRESHOLD_RATIO && totalLinesOnDisk > 1000; +} + +function shouldCompact(): boolean { + return ( + !!cache && 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)); + lines.push( + JSON.stringify({ + k: key, + v: { + content: entry.content, + senderId: entry.senderId, + senderName: entry.senderName, + timestamp: entry.timestamp, + isBot: entry.isBot, + attachments: entry.attachments, + }, + t: entry._createdAt, + }), + ); } - 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)}`, - ); + debugError(`[ref-index-store] Compact failed: ${formatErrorMessage(err)}`); } } @@ -164,14 +138,12 @@ 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); @@ -186,18 +158,8 @@ function evictIfNeeded(): void { 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, - }); - + store.set(refIdx, { ...entry, _createdAt: now }); appendLine({ k: refIdx, v: { @@ -210,7 +172,6 @@ export function setRefIndex(refIdx: string, entry: RefIndexEntry): void { }, t: now, }); - if (shouldCompact()) { compactFile(); } @@ -223,12 +184,10 @@ export function getRefIndex(refIdx: string): RefIndexEntry | null { if (!entry) { return null; } - if (Date.now() - entry._createdAt > TTL_MS) { store.delete(refIdx); return null; } - return { content: entry.content, senderId: entry.senderId, @@ -239,52 +198,6 @@ export function getRefIndex(refIdx: string): RefIndexEntry | null { }; } -/** 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()) { @@ -300,10 +213,5 @@ export function getRefIndexStats(): { filePath: string; } { const store = loadFromFile(); - return { - size: store.size, - maxEntries: MAX_ENTRIES, - totalLinesOnDisk, - filePath: REF_INDEX_FILE, - }; + return { size: store.size, maxEntries: MAX_ENTRIES, totalLinesOnDisk, filePath: REF_INDEX_FILE }; } diff --git a/extensions/qqbot/src/engine/ref/types.ts b/extensions/qqbot/src/engine/ref/types.ts new file mode 100644 index 00000000000..505900b1933 --- /dev/null +++ b/extensions/qqbot/src/engine/ref/types.ts @@ -0,0 +1,27 @@ +/** + * Ref-index types shared between both plugin versions. + * + * These types define the structure of quoted-message metadata + * persisted by the ref-index store. + */ + +/** 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; +} diff --git a/extensions/qqbot/src/known-users.ts b/extensions/qqbot/src/engine/session/known-users.ts similarity index 77% rename from extensions/qqbot/src/known-users.ts rename to extensions/qqbot/src/engine/session/known-users.ts index 99a14174437..47a9c241f9f 100644 --- a/extensions/qqbot/src/known-users.ts +++ b/extensions/qqbot/src/engine/session/known-users.ts @@ -1,11 +1,21 @@ +/** + * Known user tracking — JSON file-based store. + * + * Migrated from src/known-users.ts. Dependencies are only Node.js + * built-ins + log + platform (both zero plugin-sdk). + */ + import fs from "node:fs"; import path from "node:path"; -import { debugLog, debugError } from "./utils/debug-log.js"; +import type { ChatScope } from "../types.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { debugLog, debugError } from "../utils/log.js"; +import { getQQBotDataDir } from "../utils/platform.js"; /** Persisted record for a user who has interacted with the bot. */ export interface KnownUser { openid: string; - type: "c2c" | "group"; + type: ChatScope; nickname?: string; groupOpenid?: string; accountId: string; @@ -14,81 +24,70 @@ export interface KnownUser { 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 | null = null; - const SAVE_THROTTLE_MS = 5000; let saveTimer: ReturnType | 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 makeUserKey(user: Partial): string { + const base = `${user.accountId}:${user.type}:${user.openid}`; + return user.type === "group" && user.groupOpenid ? `${base}:${user.groupOpenid}` : base; +} + function loadUsersFromFile(): Map { 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); + usersCache.set(makeUserKey(user), user); } - debugLog(`[known-users] Loaded ${usersCache.size} users`); } } catch (err) { - debugError(`[known-users] Failed to load users: ${String(err)}`); + debugError(`[known-users] Failed to load users: ${formatErrorMessage(err)}`); usersCache = new Map(); } - return usersCache; } -/** Schedule a throttled write to disk. */ function saveUsersToFile(): void { - if (!isDirty) { + if (!isDirty || saveTimer) { 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"); + fs.writeFileSync( + KNOWN_USERS_FILE, + JSON.stringify(Array.from(usersCache.values()), null, 2), + "utf-8", + ); isDirty = false; } catch (err) { - debugError(`[known-users] Failed to save users: ${String(err)}`); + debugError(`[known-users] Failed to save users: ${formatErrorMessage(err)}`); } } @@ -101,19 +100,10 @@ export function flushKnownUsers(): void { doSaveUsersToFile(); } -/** Build a stable composite key for one user record. */ -function makeUserKey(user: Partial): 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"; + type: ChatScope; nickname?: string; groupOpenid?: string; accountId: string; @@ -121,7 +111,6 @@ export function recordKnownUser(user: { const cache = loadUsersFromFile(); const key = makeUserKey(user); const now = Date.now(); - const existing = cache.get(key); if (existing) { @@ -131,7 +120,7 @@ export function recordKnownUser(user: { existing.nickname = user.nickname; } } else { - const newUser: KnownUser = { + cache.set(key, { openid: user.openid, type: user.type, nickname: user.nickname, @@ -140,11 +129,9 @@ export function recordKnownUser(user: { firstSeenAt: now, lastSeenAt: now, interactionCount: 1, - }; - cache.set(key, newUser); + }); debugLog(`[known-users] New user: ${user.openid} (${user.type})`); } - isDirty = true; saveUsersToFile(); } @@ -153,26 +140,22 @@ export function recordKnownUser(user: { export function getKnownUser( accountId: string, openid: string, - type: "c2c" | "group" = "c2c", + type: ChatScope = "c2c", groupOpenid?: string, ): KnownUser | undefined { - const cache = loadUsersFromFile(); - const key = makeUserKey({ accountId, openid, type, groupOpenid }); - return cache.get(key); + return loadUsersFromFile().get(makeUserKey({ accountId, openid, type, groupOpenid })); } /** List known users with optional filtering and sorting. */ export function listKnownUsers(options?: { accountId?: string; - type?: "c2c" | "group"; + type?: ChatScope; activeWithin?: number; limit?: number; sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount"; sortOrder?: "asc" | "desc"; }): KnownUser[] { - const cache = loadUsersFromFile(); - let users = Array.from(cache.values()); - + let users = Array.from(loadUsersFromFile().values()); if (options?.accountId) { users = users.filter((u) => u.accountId === options.accountId); } @@ -183,19 +166,16 @@ export function listKnownUsers(options?: { 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; + const aV = a[sortBy] ?? 0; + const bV = b[sortBy] ?? 0; + return sortOrder === "asc" ? aV - bV : bV - aV; }); - if (options?.limit && options.limit > 0) { users = users.slice(0, options.limit); } - return users; } @@ -207,11 +187,9 @@ export function getKnownUsersStats(accountId?: string): { activeIn24h: number; activeIn7d: number; } { - let users = listKnownUsers({ accountId }); - + const users = listKnownUsers({ accountId }); const now = Date.now(); - const day = 24 * 60 * 60 * 1000; - + const day = 86400000; return { totalUsers: users.length, c2cUsers: users.filter((u) => u.type === "c2c").length, @@ -225,12 +203,11 @@ export function getKnownUsersStats(accountId?: string): { export function removeKnownUser( accountId: string, openid: string, - type: "c2c" | "group" = "c2c", + type: ChatScope = "c2c", groupOpenid?: string, ): boolean { const cache = loadUsersFromFile(); const key = makeUserKey({ accountId, openid, type, groupOpenid }); - if (cache.has(key)) { cache.delete(key); isDirty = true; @@ -238,7 +215,6 @@ export function removeKnownUser( debugLog(`[known-users] Removed user ${openid}`); return true; } - return false; } @@ -246,7 +222,6 @@ export function removeKnownUser( 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) { @@ -258,20 +233,19 @@ export function clearKnownUsers(accountId?: string): number { 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 listKnownUsers({ accountId, type: "group" }) + .filter((u) => u.openid === openid && u.groupOpenid) + .map((u) => u.groupOpenid!); } /** Return all recorded members for one group. */ diff --git a/extensions/qqbot/src/session-store.ts b/extensions/qqbot/src/engine/session/session-store.ts similarity index 84% rename from extensions/qqbot/src/session-store.ts rename to extensions/qqbot/src/engine/session/session-store.ts index c5bc9a98c91..5fd291dc366 100644 --- a/extensions/qqbot/src/session-store.ts +++ b/extensions/qqbot/src/engine/session/session-store.ts @@ -1,6 +1,15 @@ +/** + * Gateway session persistence — JSONL file-based store. + * + * Migrated from src/session-store.ts. Dependencies are only Node.js + * built-ins + log + platform (both zero plugin-sdk). + */ + import fs from "node:fs"; import path from "node:path"; -import { debugLog, debugError } from "./utils/debug-log.js"; +import { formatErrorMessage } from "../utils/format.js"; +import { debugLog, debugError } from "../utils/log.js"; +import { getQQBotDataDir } from "../utils/platform.js"; /** Persisted gateway session state. */ export interface SessionState { @@ -13,12 +22,10 @@ export interface SessionState { 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, { @@ -28,7 +35,6 @@ const throttleState = new Map< } >(); -/** Ensure the session directory exists. */ function ensureDir(): void { if (!fs.existsSync(SESSION_DIR)) { fs.mkdirSync(SESSION_DIR, { recursive: true }); @@ -44,7 +50,6 @@ function getLegacySessionPath(accountId: string): string { 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`); @@ -76,15 +81,14 @@ export function loadSession(accountId: string, expectedAppId?: string): SessionS 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`, @@ -115,7 +119,9 @@ export function loadSession(accountId: string, expectedAppId?: string): SessionS ); return state; } catch (err) { - debugError(`[session-store] Failed to load session for ${accountId}: ${String(err)}`); + debugError( + `[session-store] Failed to load session for ${accountId}: ${formatErrorMessage(err)}`, + ); return null; } } @@ -123,14 +129,9 @@ export function loadSession(accountId: string, expectedAppId?: string): SessionS /** 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, - }; + throttle = { pendingState: null, lastSaveTime: 0, throttleTimer: null }; throttleState.set(accountId, throttle); } @@ -141,19 +142,17 @@ export function saveSession(state: SessionState): void { 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) { + if (t?.pendingState) { doSaveSession(t.pendingState); t.lastSaveTime = Date.now(); t.pendingState = null; @@ -166,19 +165,12 @@ export function saveSession(state: SessionState): void { } } -/** 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(), - }; - + 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); @@ -187,7 +179,9 @@ function doSaveSession(state: SessionState): void { `[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)}`); + debugError( + `[session-store] Failed to save session for ${state.accountId}: ${formatErrorMessage(err)}`, + ); } } @@ -200,7 +194,6 @@ export function clearSession(accountId: string): void { } throttleState.delete(accountId); } - try { let cleared = false; for (const filePath of getCandidateSessionPaths(accountId)) { @@ -213,25 +206,23 @@ export function clearSession(accountId: string): void { debugLog(`[session-store] Cleared session for ${accountId}`); } } catch (err) { - debugError(`[session-store] Failed to clear session for ${accountId}: ${String(err)}`); + debugError( + `[session-store] Failed to clear session for ${accountId}: ${formatErrorMessage(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, - }); + if (existing?.sessionId) { + saveSession({ ...existing, lastSeq }); } } /** Load all saved sessions from disk. */ export function getAllSessions(): SessionState[] { const sessions = new Map(); - try { ensureDir(); const files = fs.readdirSync(SESSION_DIR); @@ -247,28 +238,20 @@ export function getAllSessions(): SessionState[] { if (!existing || (state.savedAt ?? 0) >= (existing.savedAt ?? 0)) { sessions.set(state.accountId, state); } - } catch { - // Ignore malformed session files here. - } + } catch {} } } - } catch { - // Ignore missing directories and similar filesystem errors. - } - + } catch {} return [...sessions.values()]; } -/** - * Remove expired session files from disk. - */ +/** 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(); + const files = fs.readdirSync(SESSION_DIR); for (const file of files) { if (isSessionFileName(file)) { @@ -282,19 +265,13 @@ export function cleanupExpiredSessions(): number { 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 {} } } } - } catch { - // Ignore missing directories and similar filesystem errors. - } - + } catch {} return cleaned; } diff --git a/extensions/qqbot/src/engine/tools/channel-api.ts b/extensions/qqbot/src/engine/tools/channel-api.ts new file mode 100644 index 00000000000..d5f2f600b65 --- /dev/null +++ b/extensions/qqbot/src/engine/tools/channel-api.ts @@ -0,0 +1,244 @@ +/** + * QQ Channel API proxy tool core logic. + * QQ 频道 API 代理工具核心逻辑。 + * + * Provides an authenticated HTTP proxy for the QQ Open Platform channel + * APIs. The caller (old tools/channel.ts shell) resolves the access + * token and passes it in; this module handles URL building, path + * validation, fetch, and structured response formatting. + */ + +import { formatErrorMessage } from "../utils/format.js"; +import { debugLog, debugError } from "../utils/log.js"; + +const API_BASE = "https://api.sgroup.qq.com"; +const DEFAULT_TIMEOUT_MS = 30000; + +/** + * Channel API call parameters. + * 频道 API 调用参数。 + */ +export interface ChannelApiParams { + method: string; + path: string; + body?: Record; + query?: Record; +} + +/** + * JSON Schema for AI tool parameters (used by framework registration). + * AI Tool 参数的 JSON Schema 定义(供框架注册使用)。 + */ +export 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; + +/** + * Build the full API URL from base + path + query params. + * 拼接 API 基地址 + 路径 + 查询参数。 + */ +export function buildUrl(path: string, query?: Record): 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; +} + +/** + * Validate API path format; returns an error string or null if valid. + * 校验 API 路径格式,返回错误描述或 null(合法)。 + */ +export 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; +} + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +/** + * Options provided by the caller when executing a channel API request. + * 执行频道 API 请求时由调用方提供的选项。 + */ +export interface ChannelApiExecuteOptions { + accessToken: string; +} + +/** + * Execute a channel API proxy request. + * 执行频道 API 代理请求。 + * + * The caller provides the access token; this function handles + * URL building, path validation, HTTP fetch, and structured + * response formatting suitable for AI tool output. + */ +export async function executeChannelApi( + params: ChannelApiParams, + options: ChannelApiExecuteOptions, +) { + if (!params.method) { + return json({ error: "method is required" }); + } + if (!params.path) { + return json({ error: "path is required" }); + } + + const method = params.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(params.path); + if (pathError) { + return json({ error: pathError }); + } + + if ( + (method === "GET" || method === "DELETE") && + params.body && + Object.keys(params.body).length > 0 + ) { + debugLog(`[qqbot-channel-api] ${method} request with body, body will be ignored`); + } + + try { + const url = buildUrl(params.path, params.query); + const headers: Record = { + Authorization: `QQBot ${options.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 (params.body && ["POST", "PUT", "PATCH"].includes(method)) { + fetchOptions.body = JSON.stringify(params.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: params.path, + }); + } + debugError("[qqbot-channel-api] <<< Network error:", err); + return json({ + error: `Network error: ${formatErrorMessage(err)}`, + path: params.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: params.path }); + } + return json({ + error: `API returned ${res.status} ${res.statusText}`, + status: res.status, + path: params.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} ${params.path}]: ${errMsg}`); + return json({ + error: errMsg, + status: res.status, + path: params.path, + details: parsed, + }); + } + + return json({ + success: true, + status: res.status, + path: params.path, + data: parsed, + }); + } catch (err) { + return json({ + error: formatErrorMessage(err), + path: params.path, + }); + } +} diff --git a/extensions/qqbot/src/engine/tools/remind-logic.test.ts b/extensions/qqbot/src/engine/tools/remind-logic.test.ts new file mode 100644 index 00000000000..6323e3d754d --- /dev/null +++ b/extensions/qqbot/src/engine/tools/remind-logic.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { + parseRelativeTime, + isCronExpression, + formatDelay, + generateJobName, + buildReminderPrompt, + executeRemind, +} from "./remind-logic.js"; + +describe("engine/tools/remind-logic", () => { + describe("parseRelativeTime", () => { + it("parses minutes shorthand", () => { + expect(parseRelativeTime("5m")).toBe(5 * 60_000); + }); + + it("parses hours shorthand", () => { + expect(parseRelativeTime("1h")).toBe(3_600_000); + }); + + it("parses combined hours and minutes", () => { + expect(parseRelativeTime("1h30m")).toBe(90 * 60_000); + }); + + it("parses days", () => { + expect(parseRelativeTime("2d")).toBe(2 * 86_400_000); + }); + + it("parses seconds", () => { + expect(parseRelativeTime("45s")).toBe(45_000); + }); + + it("treats plain numbers as minutes", () => { + expect(parseRelativeTime("10")).toBe(10 * 60_000); + }); + + it("returns null for unparseable input", () => { + expect(parseRelativeTime("never")).toBeNull(); + }); + + it("is case insensitive", () => { + expect(parseRelativeTime("5M")).toBe(5 * 60_000); + }); + }); + + describe("isCronExpression", () => { + it("detects standard 5-field cron", () => { + expect(isCronExpression("0 8 * * *")).toBe(true); + }); + + it("detects weekday range cron", () => { + expect(isCronExpression("0 9 * * 1-5")).toBe(true); + }); + + it("rejects short input", () => { + expect(isCronExpression("5m")).toBe(false); + }); + + it("rejects too many fields", () => { + expect(isCronExpression("0 0 0 0 0 0 0")).toBe(false); + }); + }); + + describe("formatDelay", () => { + it("formats seconds", () => { + expect(formatDelay(45_000)).toBe("45s"); + }); + + it("formats minutes", () => { + expect(formatDelay(300_000)).toBe("5m"); + }); + + it("formats hours", () => { + expect(formatDelay(3_600_000)).toBe("1h"); + }); + + it("formats hours and minutes", () => { + expect(formatDelay(5_400_000)).toBe("1h30m"); + }); + }); + + describe("generateJobName", () => { + it("returns short content as-is", () => { + expect(generateJobName("drink water")).toBe("Reminder: drink water"); + }); + + it("truncates long content", () => { + const long = "a very long reminder content that exceeds twenty characters"; + const name = generateJobName(long); + expect(name.length).toBeLessThan(40); + expect(name).toContain("…"); + }); + }); + + describe("buildReminderPrompt", () => { + it("includes the content in the prompt", () => { + const prompt = buildReminderPrompt("drink water"); + expect(prompt).toContain("drink water"); + }); + }); + + describe("executeRemind", () => { + it("returns list instruction", () => { + const result = executeRemind({ action: "list" }); + expect(result.details).toEqual({ + _instruction: expect.any(String), + cronParams: { action: "list" }, + }); + }); + + it("returns error when removing without jobId", () => { + const result = executeRemind({ action: "remove" }); + expect((result.details as { error: string }).error).toContain("jobId"); + }); + + it("returns error when content is missing for add", () => { + const result = executeRemind({ action: "add", to: "qqbot:c2c:123", time: "5m" }); + expect((result.details as { error: string }).error).toContain("content"); + }); + + it("returns error when delay is too short", () => { + const result = executeRemind({ + action: "add", + content: "test", + to: "qqbot:c2c:123", + time: "10s", + }); + expect((result.details as { error: string }).error).toContain("30 seconds"); + }); + + it("builds once job with delivery envelope for relative time", () => { + const result = executeRemind({ + action: "add", + content: "test reminder", + to: "qqbot:c2c:123", + time: "5m", + }); + const details = result.details as { + cronParams: { + job: { + schedule: { kind: string }; + payload: { kind: string; message: string }; + delivery: { mode: string; channel: string; to: string; accountId: string }; + }; + }; + }; + expect(details.cronParams.job.schedule.kind).toBe("at"); + expect(details.cronParams.job.payload.kind).toBe("agentTurn"); + expect(details.cronParams.job.delivery).toEqual({ + mode: "announce", + channel: "qqbot", + to: "qqbot:c2c:123", + accountId: "default", + }); + }); + + it("builds cron job with delivery envelope for cron expression", () => { + const result = executeRemind({ + action: "add", + content: "test reminder", + to: "qqbot:c2c:123", + time: "0 8 * * *", + }); + const details = result.details as { + cronParams: { + job: { + schedule: { kind: string }; + delivery: { channel: string; to: string; accountId: string }; + }; + }; + }; + expect(details.cronParams.job.schedule.kind).toBe("cron"); + expect(details.cronParams.job.delivery.to).toBe("qqbot:c2c:123"); + }); + + it("falls back to ctx.fallbackTo when to is omitted", () => { + const result = executeRemind( + { action: "add", content: "test", time: "5m" }, + { fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" }, + ); + const details = result.details as { + cronParams: { job: { delivery: { to: string; accountId: string } } }; + }; + expect(details.cronParams.job.delivery.to).toBe("qqbot:c2c:ctx-target"); + expect(details.cronParams.job.delivery.accountId).toBe("alt"); + }); + + it("prefers AI-supplied to over ctx fallback", () => { + const result = executeRemind( + { action: "add", content: "test", time: "5m", to: "qqbot:group:ai-chosen" }, + { fallbackTo: "qqbot:c2c:ctx-target", fallbackAccountId: "alt" }, + ); + const details = result.details as { + cronParams: { job: { delivery: { to: string; accountId: string } } }; + }; + expect(details.cronParams.job.delivery.to).toBe("qqbot:group:ai-chosen"); + expect(details.cronParams.job.delivery.accountId).toBe("alt"); + }); + + it("returns error when neither AI nor ctx provides a target", () => { + const result = executeRemind({ action: "add", content: "test", time: "5m" }); + expect((result.details as { error: string }).error).toMatch(/delivery target/i); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/tools/remind-logic.ts b/extensions/qqbot/src/engine/tools/remind-logic.ts new file mode 100644 index 00000000000..ebcc6a243b9 --- /dev/null +++ b/extensions/qqbot/src/engine/tools/remind-logic.ts @@ -0,0 +1,311 @@ +/** + * QQBot reminder tool core logic. + * QQBot 提醒工具核心逻辑。 + * + * Pure functions for time parsing, cron detection, job building, + * and remind execution. The framework registration shell + * (bridge/tools/remind.ts) delegates all business logic here and + * supplies request-level context fallbacks (`to`, `accountId`). + */ + +/** + * Reminder tool input parameters. + * 提醒工具的输入参数。 + */ +export interface RemindParams { + action: "add" | "list" | "remove"; + content?: string; + to?: string; + time?: string; + timezone?: string; + name?: string; + jobId?: string; +} + +/** + * Context supplied by the bridge layer so the engine can remain free of + * framework / AsyncLocalStorage dependencies. `fallbackTo` and + * `fallbackAccountId` are consulted only when the corresponding AI-supplied + * parameter is missing. + */ +export interface RemindExecuteContext { + fallbackTo?: string; + fallbackAccountId?: string; +} + +/** + * JSON Schema for AI tool parameters (used by framework registration). + * AI Tool 参数的 JSON Schema 定义(供框架注册使用)。 + */ +export 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: + "Optional delivery target. The runtime automatically resolves the current " + + "conversation target, so you usually do not need to supply this. " + + "Direct-message format: qqbot:c2c:user_openid. Group format: qqbot:group:group_openid.", + }, + 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; + +/** + * Parse a relative time string into milliseconds. + * 解析相对时间字符串为毫秒数。 + * + * Supports: "5m", "1h", "1h30m", "2d", "45s", plain number (as minutes). + * + * @returns Milliseconds or null if unparseable. + */ +export function parseRelativeTime(timeStr: string): number | null { + const s = timeStr.toLowerCase(); + 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; +} + +/** + * Check whether a time string is a cron expression (3–6 space-separated fields). + * 判断时间字符串是否为 cron 表达式。 + */ +export function isCronExpression(timeStr: string): boolean { + const parts = timeStr.trim().split(/\s+/); + if (parts.length < 3 || parts.length > 6) { + return false; + } + return parts.every((p) => /^[0-9*?/,LW#-]/.test(p)); +} + +/** + * Generate a cron job name from reminder content (first 20 chars). + * 根据提醒内容生成 cron job 名称。 + */ +export function generateJobName(content: string): string { + const trimmed = content.trim(); + const short = trimmed.length > 20 ? `${trimmed.slice(0, 20)}…` : trimmed; + return `Reminder: ${short}`; +} + +/** Build the reminder system prompt sent to the AI. */ +export 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.` + ); +} + +/** Build cron job params for a one-shot delayed reminder. */ +export function buildOnceJob(params: RemindParams, delayMs: number, to: string, accountId: string) { + const atMs = Date.now() + delayMs; + 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), + }, + delivery: { + mode: "announce", + channel: "qqbot", + to, + accountId, + }, + }, + }; +} + +/** Build cron job params for a recurring cron reminder. */ +export function buildCronJob(params: RemindParams, to: string, accountId: string) { + 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), + }, + delivery: { + mode: "announce", + channel: "qqbot", + to, + accountId, + }, + }, + }; +} + +/** Format a delay in milliseconds as a short string (e.g. "5m", "1h30m"). */ +export 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`; +} + +function json(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +/** + * Execute the reminder tool logic. + * 执行提醒工具逻辑。 + * + * Validates params, parses time, and returns a structured result + * containing cron job params that the framework shell passes back + * as the tool output. + * + * When the AI omits `to` / `accountId`, the bridge layer can supply + * `ctx.fallbackTo` / `ctx.fallbackAccountId` (typically resolved from + * the request-scoped AsyncLocalStorage) to fill them in. + */ +export function executeRemind(params: RemindParams, ctx: RemindExecuteContext = {}) { + if (params.action === "list") { + return json({ + _instruction: "Use the cron tool immediately with the following parameters.", + cronParams: { action: "list" }, + }); + } + + if (params.action === "remove") { + if (!params.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: params.jobId }, + }); + } + + if (!params.content) { + return json({ error: "content is required when action=add" }); + } + const resolvedTo = params.to || ctx.fallbackTo; + if (!resolvedTo) { + return json({ + error: + "Unable to determine delivery target for action=add. " + + "The reminder can only be scheduled from within an active conversation.", + }); + } + if (!params.time) { + return json({ error: "time is required when action=add" }); + } + const resolvedAccountId = ctx.fallbackAccountId || "default"; + + if (isCronExpression(params.time)) { + return json({ + _instruction: + "Use the cron tool immediately with the following parameters. " + + "Pass cronParams verbatim — do not modify or omit any field, especially delivery.accountId — then tell the user the reminder has been scheduled.", + cronParams: buildCronJob(params, resolvedTo, resolvedAccountId), + summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || "Asia/Shanghai"})`, + }); + } + + const delayMs = parseRelativeTime(params.time); + if (delayMs == null) { + return json({ + error: `Could not parse time format: ${params.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. " + + "Pass cronParams verbatim — do not modify or omit any field, especially delivery.accountId — then tell the user the reminder has been scheduled.", + cronParams: buildOnceJob(params, delayMs, resolvedTo, resolvedAccountId), + summary: `⏰ Reminder in ${formatDelay(delayMs)}: "${params.content}"`, + }); +} diff --git a/extensions/qqbot/src/engine/types.ts b/extensions/qqbot/src/engine/types.ts new file mode 100644 index 00000000000..dafdb77d0ee --- /dev/null +++ b/extensions/qqbot/src/engine/types.ts @@ -0,0 +1,270 @@ +/** + * Core API layer public types. + * + * These types are independent of the root `src/types.ts` and only define + * what the `core/api/` modules need. The old `src/types.ts` remains + * untouched for backward compatibility. + */ + +// ============ Structured API Error ============ + +/** + * Structured API error with HTTP status, path, and optional business error code. + * + * Compared to the old `api.ts` which throws plain `Error`, this carries + * machine-readable fields for downstream retry/fallback decisions. + */ +export class ApiError extends Error { + override readonly name = "ApiError"; + + constructor( + message: string, + /** HTTP status code returned by the QQ Open Platform. */ + public readonly httpStatus: number, + /** API path that produced the error (e.g. `/v2/users/{id}/messages`). */ + public readonly path: string, + /** Business error code from the response body (`code` or `err_code`). */ + public readonly bizCode?: number, + /** Original error message from the response body. */ + public readonly bizMessage?: string, + ) { + super(message); + } +} + +// ============ Logger ============ + +/** + * Unified logger interface used across all engine/ modules. + * + * Replaces the previously fragmented ApiLogger, GatewayLogger, ReconnectLogger, + * MessageRefLogger, PathLogger, and SenderLogger interfaces. + * + * `info` and `error` are required; `warn` and `debug` are optional because + * some callers (e.g. the framework-injected `ctx.log`) may not provide them. + */ +export interface EngineLogger { + info: (msg: string) => void; + error: (msg: string) => void; + warn?: (msg: string) => void; + debug?: (msg: string) => void; +} + +// ============ Chat Scope ============ + +/** Chat scope used to unify C2C/Group path construction. */ +export type ChatScope = "c2c" | "group"; + +// ============ Message Response ============ + +/** Standard message send response from the QQ Open Platform. */ +export interface MessageResponse { + id: string; + timestamp: number | string; + /** Reference index for future quoting. */ + ext_info?: { + ref_idx?: string; + }; +} + +// ============ Media Types ============ + +/** QQ Open Platform media file type codes. */ +export enum MediaFileType { + IMAGE = 1, + VIDEO = 2, + VOICE = 3, + FILE = 4, +} + +/** Media upload response from the QQ Open Platform. */ +export interface UploadMediaResponse { + file_uuid: string; + file_info: string; + ttl: number; + id?: string; +} + +/** Structured metadata recorded for outbound messages. */ +export interface OutboundMeta { + /** Message text content. */ + text?: string; + /** Media type tag. */ + mediaType?: "image" | "voice" | "video" | "file"; + /** Remote URL of the media source. */ + mediaUrl?: string; + /** Local file path of the media source. */ + mediaLocalPath?: string; + /** Original TTS text (voice messages only). */ + ttsText?: string; +} + +// ============ API Client Config ============ + +/** Configuration for the core HTTP client. */ +export interface ApiClientConfig { + /** Base URL for the QQ Open Platform REST API. */ + baseUrl?: string; + /** Default request timeout in milliseconds. */ + defaultTimeoutMs?: number; + /** File upload request timeout in milliseconds. */ + fileUploadTimeoutMs?: number; + /** Logger instance. */ + logger?: EngineLogger; + /** User-Agent header value, or a getter function for dynamic resolution. */ + userAgent?: string | (() => string); +} + +// ============ Chunked Upload Types ============ + +/** Individual upload part metadata. */ +export interface UploadPart { + /** Part index (1-based). */ + index: number; + /** Pre-signed upload URL. */ + presigned_url: string; +} + +/** Response from the upload_prepare endpoint. */ +export interface UploadPrepareResponse { + /** Upload task identifier. */ + upload_id: string; + /** Block size in bytes. */ + block_size: number; + /** Pre-signed upload parts. */ + parts: UploadPart[]; + /** Server-suggested upload concurrency. */ + concurrency?: number; + /** Server-suggested retry timeout for upload_part_finish (seconds). */ + retry_timeout?: number; +} + +/** Complete upload response. */ +export interface MediaUploadResponse { + file_uuid: string; + file_info: string; + ttl: number; +} + +/** File hash information for upload_prepare. */ +export interface UploadPrepareHashes { + /** Whole-file MD5 (hex). */ + md5: string; + /** Whole-file SHA1 (hex). */ + sha1: string; + /** MD5 of the first 10,002,432 bytes (hex). */ + md5_10m: string; +} + +// ============ Stream Message Types ============ + +/** Stream message input state. */ +export enum StreamInputState { + GENERATING = "1", + DONE = "10", +} + +/** Stream message request body. */ +export interface StreamMessageRequest { + input_mode: string; + input_state: string; + content_type: string; + content_raw: string; + event_id?: string; + msg_id?: string; + msg_seq?: number; + index?: number; + stream_msg_id?: string; +} + +// ============ Inline Keyboard Types ============ + +/** Inline keyboard button for approval/interaction flows. */ +export interface KeyboardButton { + id: string; + render_data: { + label: string; + visited_label: string; + style: number; + }; + action: { + type: number; + permission: { type: number }; + data: string; + click_limit?: number; + }; + group_id?: string; +} + +/** + * Inline keyboard structure attached to messages. + * Sent as the `keyboard` field in the message body: + * `{ "keyboard": { "content": { "rows": [...] } } }` + */ +export interface InlineKeyboard { + content: { + rows: Array<{ buttons: KeyboardButton[] }>; + }; +} + +// ============ Interaction Event Types ============ + +/** Button interaction event (INTERACTION_CREATE). */ +export interface InteractionEvent { + /** Event ID — used to acknowledge the interaction (PUT /interactions/{id}). */ + id: string; + /** Event sub-type: 11=message button, 12=c2c quick menu. */ + type: number; + /** Scene identifier: c2c / group / guild. */ + scene?: string; + /** Chat type: 0=guild, 1=group, 2=c2c. */ + chat_type?: number; + timestamp?: string; + guild_id?: string; + channel_id?: string; + /** C2C user openid (c2c scene only). */ + user_openid?: string; + /** Group openid (group scene only). */ + group_openid?: string; + /** Group member openid (group scene only). */ + group_member_openid?: string; + version: number; + data: { + type: number; + resolved: { + button_data?: string; + button_id?: string; + user_id?: string; + feature_id?: string; + message_id?: string; + }; + }; +} + +// ============ Gateway Account ============ + +/** + * Resolved account configuration — shared across gateway/ and messaging/ layers. + * + * Lifted here from gateway/types.ts to eliminate the circular type dependency + * where messaging/ had to import from gateway/. + */ +export interface GatewayAccount { + accountId: string; + appId: string; + clientSecret: string; + markdownSupport: boolean; + systemPrompt?: string; + config: Record & { + allowFrom?: Array; + groupAllowFrom?: Array; + dmPolicy?: "open" | "allowlist" | "disabled"; + groupPolicy?: "open" | "allowlist" | "disabled"; + streaming?: { mode?: string }; + audioFormatPolicy?: { + uploadDirectFormats?: string[]; + transcodeEnabled?: boolean; + }; + voiceDirectUploadFormats?: string[]; + }; +} diff --git a/extensions/qqbot/src/engine/utils/audio.test.ts b/extensions/qqbot/src/engine/utils/audio.test.ts new file mode 100644 index 00000000000..03792b6d12e --- /dev/null +++ b/extensions/qqbot/src/engine/utils/audio.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { + pcmToWav, + stripAmrHeader, + isVoiceAttachment, + isAudioFile, + shouldTranscodeVoice, + parseWavFallback, +} from "./audio.js"; + +describe("engine/utils/audio", () => { + describe("pcmToWav", () => { + it("produces a valid WAV header", () => { + const pcm = new Uint8Array([0, 0, 1, 0]); + const wav = pcmToWav(pcm, 24000); + + expect(wav.toString("ascii", 0, 4)).toBe("RIFF"); + expect(wav.toString("ascii", 8, 12)).toBe("WAVE"); + expect(wav.toString("ascii", 12, 16)).toBe("fmt "); + expect(wav.toString("ascii", 36, 40)).toBe("data"); + }); + + it("sets correct file size in RIFF header", () => { + const pcm = new Uint8Array(100); + const wav = pcmToWav(pcm, 24000); + const riffSize = wav.readUInt32LE(4); + expect(riffSize).toBe(wav.length - 8); + }); + + it("sets correct sample rate", () => { + const pcm = new Uint8Array(10); + const wav = pcmToWav(pcm, 48000); + expect(wav.readUInt32LE(24)).toBe(48000); + }); + + it("sets correct channel count", () => { + const pcm = new Uint8Array(10); + const wav = pcmToWav(pcm, 24000, 2); + expect(wav.readUInt16LE(22)).toBe(2); + }); + + it("embeds PCM data after the 44-byte header", () => { + const pcm = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + const wav = pcmToWav(pcm, 24000); + expect(wav[44]).toBe(0x01); + expect(wav[45]).toBe(0x02); + expect(wav[46]).toBe(0x03); + expect(wav[47]).toBe(0x04); + }); + + it("sets data chunk size matching PCM length", () => { + const pcm = new Uint8Array(256); + const wav = pcmToWav(pcm, 24000); + const dataSize = wav.readUInt32LE(40); + expect(dataSize).toBe(256); + }); + }); + + describe("stripAmrHeader", () => { + it("strips the #!AMR header when present", () => { + const amrHeader = Buffer.from("#!AMR\n"); + const payload = Buffer.from([0x01, 0x02, 0x03]); + const buf = Buffer.concat([amrHeader, payload]); + + const result = stripAmrHeader(buf); + expect(result).toEqual(payload); + }); + + it("returns the buffer unchanged when no AMR header", () => { + const buf = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]); + const result = stripAmrHeader(buf); + expect(result).toBe(buf); + }); + + it("returns the buffer unchanged when too short", () => { + const buf = Buffer.from([0x01, 0x02]); + const result = stripAmrHeader(buf); + expect(result).toBe(buf); + }); + }); + + describe("isVoiceAttachment", () => { + it("detects voice content_type", () => { + expect(isVoiceAttachment({ content_type: "voice" })).toBe(true); + }); + + it("detects audio/* content_type", () => { + expect(isVoiceAttachment({ content_type: "audio/silk" })).toBe(true); + expect(isVoiceAttachment({ content_type: "audio/amr" })).toBe(true); + }); + + it("detects voice file extensions", () => { + expect(isVoiceAttachment({ filename: "msg.amr" })).toBe(true); + expect(isVoiceAttachment({ filename: "msg.silk" })).toBe(true); + expect(isVoiceAttachment({ filename: "msg.slk" })).toBe(true); + expect(isVoiceAttachment({ filename: "msg.slac" })).toBe(true); + }); + + it("rejects non-voice attachments", () => { + expect(isVoiceAttachment({ content_type: "image/png" })).toBe(false); + expect(isVoiceAttachment({ filename: "photo.jpg" })).toBe(false); + }); + + it("handles missing fields", () => { + expect(isVoiceAttachment({})).toBe(false); + }); + }); + + describe("isAudioFile", () => { + it.each([ + ".silk", + ".slk", + ".amr", + ".wav", + ".mp3", + ".ogg", + ".opus", + ".aac", + ".flac", + ".m4a", + ".wma", + ".pcm", + ])("recognizes %s as audio", (ext) => { + expect(isAudioFile(`file${ext}`)).toBe(true); + }); + + it("recognizes audio MIME types", () => { + expect(isAudioFile("file.bin", "audio/mpeg")).toBe(true); + expect(isAudioFile("file.bin", "voice")).toBe(true); + }); + + it("rejects non-audio files", () => { + expect(isAudioFile("photo.jpg")).toBe(false); + expect(isAudioFile("doc.pdf")).toBe(false); + }); + + it("is case-insensitive on extensions", () => { + expect(isAudioFile("file.MP3")).toBe(true); + expect(isAudioFile("file.Wav")).toBe(true); + }); + }); + + describe("shouldTranscodeVoice", () => { + it("returns false for QQ native MIME types", () => { + expect(shouldTranscodeVoice("file.bin", "audio/silk")).toBe(false); + expect(shouldTranscodeVoice("file.bin", "audio/amr")).toBe(false); + expect(shouldTranscodeVoice("file.bin", "audio/wav")).toBe(false); + expect(shouldTranscodeVoice("file.bin", "audio/mp3")).toBe(false); + }); + + it("returns false for QQ native extensions", () => { + expect(shouldTranscodeVoice("voice.silk")).toBe(false); + expect(shouldTranscodeVoice("voice.amr")).toBe(false); + expect(shouldTranscodeVoice("voice.wav")).toBe(false); + expect(shouldTranscodeVoice("voice.mp3")).toBe(false); + }); + + it("returns true for non-native audio formats", () => { + expect(shouldTranscodeVoice("voice.ogg")).toBe(true); + expect(shouldTranscodeVoice("voice.opus")).toBe(true); + expect(shouldTranscodeVoice("voice.flac")).toBe(true); + expect(shouldTranscodeVoice("voice.aac")).toBe(true); + }); + + it("returns false for non-audio files", () => { + expect(shouldTranscodeVoice("photo.jpg")).toBe(false); + expect(shouldTranscodeVoice("doc.txt")).toBe(false); + }); + }); + + describe("parseWavFallback", () => { + function buildMinimalWav(pcmData: Buffer, sampleRate = 24000, channels = 1): Buffer { + const bitsPerSample = 16; + const byteRate = sampleRate * channels * (bitsPerSample / 8); + const blockAlign = channels * (bitsPerSample / 8); + const dataSize = pcmData.length; + const buf = Buffer.alloc(44 + dataSize); + + buf.write("RIFF", 0); + buf.writeUInt32LE(36 + dataSize, 4); + buf.write("WAVE", 8); + buf.write("fmt ", 12); + buf.writeUInt32LE(16, 16); + buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(channels, 22); + buf.writeUInt32LE(sampleRate, 24); + buf.writeUInt32LE(byteRate, 28); + buf.writeUInt16LE(blockAlign, 32); + buf.writeUInt16LE(bitsPerSample, 34); + buf.write("data", 36); + buf.writeUInt32LE(dataSize, 40); + pcmData.copy(buf, 44); + return buf; + } + + it("extracts PCM from a valid mono 24kHz WAV", () => { + const pcm = Buffer.from([0x01, 0x00, 0x02, 0x00]); + const wav = buildMinimalWav(pcm, 24000, 1); + const result = parseWavFallback(wav); + expect(result).not.toBeNull(); + expect(result!.length).toBe(4); + expect(result![0]).toBe(0x01); + expect(result![1]).toBe(0x00); + }); + + it("returns null for buffers shorter than 44 bytes", () => { + expect(parseWavFallback(Buffer.alloc(20))).toBeNull(); + }); + + it("returns null for non-WAV data", () => { + const buf = Buffer.alloc(44); + buf.write("NOT_", 0); + expect(parseWavFallback(buf)).toBeNull(); + }); + + it("returns null for non-PCM audio formats", () => { + const wav = buildMinimalWav(Buffer.alloc(4), 24000, 1); + wav.writeUInt16LE(3, 20); // IEEE float instead of PCM + expect(parseWavFallback(wav)).toBeNull(); + }); + + it("downmixes stereo to mono", () => { + // 2 samples × 2 channels × 2 bytes = 8 bytes + const stereoPcm = Buffer.alloc(8); + const view = new DataView(stereoPcm.buffer); + view.setInt16(0, 100, true); // L sample 0 + view.setInt16(2, 200, true); // R sample 0 + view.setInt16(4, -100, true); // L sample 1 + view.setInt16(6, -200, true); // R sample 1 + + const wav = buildMinimalWav(stereoPcm, 24000, 2); + const result = parseWavFallback(wav); + expect(result).not.toBeNull(); + // mono output: 2 samples × 2 bytes = 4 bytes + expect(result!.length).toBe(4); + const outView = new DataView(result!.buffer, result!.byteOffset); + expect(outView.getInt16(0, true)).toBe(150); // (100+200)/2 + expect(outView.getInt16(2, true)).toBe(-150); // (-100+-200)/2 + }); + + it("resamples non-24kHz WAV to 24kHz", () => { + // 4 samples at 48kHz → should produce ~2 samples at 24kHz + const pcm48k = Buffer.alloc(8); + const wav = buildMinimalWav(pcm48k, 48000, 1); + const result = parseWavFallback(wav); + expect(result).not.toBeNull(); + expect(result!.length).toBe(4); // 2 samples × 2 bytes + }); + }); +}); diff --git a/extensions/qqbot/src/utils/audio-convert.ts b/extensions/qqbot/src/engine/utils/audio.ts similarity index 54% rename from extensions/qqbot/src/utils/audio-convert.ts rename to extensions/qqbot/src/engine/utils/audio.ts index 6978fa22e71..52fa7140d98 100644 --- a/extensions/qqbot/src/utils/audio-convert.ts +++ b/extensions/qqbot/src/engine/utils/audio.ts @@ -1,17 +1,27 @@ +/** + * Audio format conversion utilities. + * 音频格式转换工具。 + * + * Handles SILK ↔ PCM ↔ WAV ↔ MP3 conversions for QQ Bot voice messaging. + * Prefers ffmpeg when available; falls back to WASM decoders (silk-wasm, + * mpg123-decoder) for environments without native tooling. + * + * Self-contained within engine/ — no framework SDK dependency. + */ + 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 { formatErrorMessage } from "./format.js"; +import { debugLog, debugError, debugWarn } from "./log.js"; import { detectFfmpeg, isWindows } from "./platform.js"; +import { normalizeLowercaseStringOrEmpty as normalizeLowercase } from "./string-normalize.js"; type SilkWasm = typeof import("silk-wasm"); let _silkWasmPromise: Promise | null = null; -function loadSilkWasm(): Promise { +/** Lazy-load the silk-wasm module (singleton cache; returns null on failure). */ +export function loadSilkWasm(): Promise { if (_silkWasmPromise) { return _silkWasmPromise; } @@ -24,8 +34,8 @@ function loadSilkWasm(): Promise { return _silkWasmPromise; } -/** Wrap PCM s16le bytes in a WAV container. */ -function pcmToWav( +/** Wrap raw PCM s16le data into a standard WAV file. */ +export function pcmToWav( pcmData: Uint8Array, sampleRate: number, channels: number = 1, @@ -39,22 +49,19 @@ function pcmToWav( 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.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); 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); @@ -62,8 +69,8 @@ function pcmToWav( return buffer; } -/** Strip a leading AMR header from QQ voice payloads when present. */ -function stripAmrHeader(buf: Buffer): Buffer { +/** Strip the AMR header that may be present in QQ voice payloads. */ +export 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); @@ -71,7 +78,7 @@ function stripAmrHeader(buf: Buffer): Buffer { return buf; } -/** Convert SILK or AMR voice files into WAV. */ +/** Convert a SILK or AMR voice file to WAV format. */ export async function convertSilkToWav( inputPath: string, outputDir?: string, @@ -81,9 +88,7 @@ export async function convertSilkToWav( } const fileBuf = fs.readFileSync(inputPath); - const strippedBuf = stripAmrHeader(fileBuf); - const rawData = new Uint8Array( strippedBuf.buffer, strippedBuf.byteOffset, @@ -95,10 +100,8 @@ export async function convertSilkToWav( 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); @@ -112,34 +115,23 @@ export async function convertSilkToWav( return { wavPath, duration: result.duration }; } -/** Return true when an attachment looks like a voice file. */ +/** Check whether an attachment is a voice file (by MIME type or extension). */ 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)) : ""; + const ext = att.filename ? normalizeLowercase(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`; -} - +/** Check whether a file path is a known audio format. */ 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)); + const ext = normalizeLowercase(path.extname(filePath)); return [ ".silk", ".slk", @@ -156,7 +148,6 @@ export function isAudioFile(filePath: string, mimeType?: string): boolean { ].includes(ext); } -/** Voice MIME types the QQ platform accepts without transcoding. */ const QQ_NATIVE_VOICE_MIMES = new Set([ "audio/silk", "audio/amr", @@ -167,334 +158,33 @@ const QQ_NATIVE_VOICE_MIMES = new Set([ "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. - */ +/** Check whether a voice file needs transcoding for upload (QQ-native formats skip it). */ export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean { - // Prefer MIME when it is available. - if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) { + if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercase(mimeType))) { return false; } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); + const ext = normalizeLowercase(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; - speed?: number; -} - -type QQBotTtsProviderConfig = { - baseUrl?: string; - apiKey?: string; - authStyle?: string; - queryParams?: Record; -}; - -type QQBotTtsBlock = QQBotTtsProviderConfig & { - model?: string; - voice?: string; - speed?: number; -}; - -function readNumber(record: Record | undefined, key: string): number | undefined { - const value = record?.[key]; - return typeof value === "number" ? value : undefined; -} - -function readStringMap(value: unknown): Record { - 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 = { - ...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): 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 } { - 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 = { "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 = { - 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"]; +function normalizeFormats(formats: string[]): string[] { + return formats.map((f) => { + const lower = normalizeLowercase(f); + return lower.startsWith(".") ? lower : `.${lower}`; + }); +} + /** - * Convert a local audio file into an uploadable Base64 payload. + * Convert a local audio file to Base64-encoded SILK for QQ API upload. + * + * Attempts conversion via ffmpeg → WASM decoders → null fallback chain. */ export async function audioFileToSilkBase64( filePath: string, @@ -510,8 +200,7 @@ export async function audioFileToSilkBase64( return null; } - const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); - + const ext = normalizeLowercase(path.extname(filePath)); const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS; @@ -520,7 +209,6 @@ export async function audioFileToSilkBase64( 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); @@ -531,7 +219,6 @@ export async function audioFileToSilkBase64( } } - // 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( @@ -547,8 +234,7 @@ export async function audioFileToSilkBase64( const targetRate = 24000; - // Prefer ffmpeg for broad codec coverage. - const ffmpegCmd = await checkFfmpeg(); + const ffmpegCmd = await detectFfmpeg(); if (ffmpegCmd) { try { debugLog( @@ -567,7 +253,6 @@ export async function audioFileToSilkBase64( } } - // Fall back to WASM decoders when ffmpeg is unavailable. debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`); if (ext === ".pcm") { @@ -603,7 +288,9 @@ export async function audioFileToSilkBase64( } /** - * Wait until a file exists and its size has stabilized. + * Wait for a file to appear and stabilize, then return its final size. + * + * Polls at `pollMs` intervals; returns 0 on timeout or persistent empty file. */ export async function waitForFile( filePath: string, @@ -682,13 +369,25 @@ export async function waitForFile( return 0; } -/** Delegate ffmpeg detection to the platform helper. */ -async function checkFfmpeg(): Promise { - return detectFfmpeg(); +/** Encode PCM s16le data into SILK format. */ +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, + }; } -/** Convert arbitrary audio into mono 24 kHz PCM s16le with ffmpeg. */ -function ffmpegToPCM( +/** Use ffmpeg to convert any audio to mono 24 kHz PCM s16le. */ +export function ffmpegToPCM( ffmpegCmd: string, inputPath: string, sampleRate: number = 24000, @@ -728,19 +427,10 @@ function ffmpegToPCM( }); } -type MpegDecoderConstructor = typeof import("mpg123-decoder").MPEGDecoder; - -let mpegDecoderConstructorPromise: Promise | null = null; - -async function loadMpegDecoderConstructor(): Promise { - 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 { +/** Decode MP3 to PCM via mpg123-decoder WASM (fallback when ffmpeg is unavailable). */ +export async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { try { - const MPEGDecoder = await loadMpegDecoderConstructor(); + const { MPEGDecoder } = await import("mpg123-decoder"); debugLog(`[audio-convert] WASM MP3 decode: size=${buf.length} bytes`); const decoder = new MPEGDecoder(); await decoder.ready; @@ -759,7 +449,6 @@ async function wasmDecodeMp3ToPCM(buf: Buffer, targetRate: number): Promise { - const lower = normalizeLowercaseStringOrEmpty(f); - return lower.startsWith(".") ? lower : `.${lower}`; - }); -} - -/** Parse standard PCM WAV as a no-ffmpeg fallback. */ -function parseWavFallback(buf: Buffer): Buffer | null { +/** Parse a standard PCM WAV and extract mono 24 kHz PCM data (fallback without ffmpeg). */ +export function parseWavFallback(buf: Buffer): Buffer | null { if (buf.length < 44) { return null; } @@ -850,7 +529,6 @@ function parseWavFallback(buf: Buffer): Buffer | null { return null; } - // Find the PCM data chunk. let offset = 36; while (offset < buf.length - 8) { const chunkId = buf.toString("ascii", offset, offset + 4); @@ -860,7 +538,6 @@ function parseWavFallback(buf: Buffer): Buffer | null { 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); @@ -876,7 +553,6 @@ function parseWavFallback(buf: Buffer): Buffer | null { pcm = mono; } - // Resample with simple linear interpolation. const targetRate = 24000; if (sampleRate !== targetRate) { const inSamples = pcm.length / 2; diff --git a/extensions/qqbot/src/engine/utils/data-paths.ts b/extensions/qqbot/src/engine/utils/data-paths.ts new file mode 100644 index 00000000000..e741d87e0e2 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/data-paths.ts @@ -0,0 +1,38 @@ +/** + * Centralised filename helpers for persisted QQBot state. + * + * Every persistence module routes file paths through these helpers so the + * naming convention stays in sync and legacy migrations are handled + * consistently. + * + * Key design decisions: + * - Credential backup is keyed only by `accountId` because recovery runs + * exactly when the appId is missing from config. + */ + +import path from "node:path"; +import { getQQBotDataDir } from "./platform.js"; + +/** + * Normalise an identifier so it is safe to embed in a filename. + * Keeps alphanumerics, dot, underscore, dash; everything else becomes `_`. + */ +export function safeName(id: string): string { + return id.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +// ---- credential backup ---- + +/** + * Per-accountId credential backup file. Not keyed by appId because the + * whole point of this file is to recover credentials when appId is + * missing from the live config. + */ +export function getCredentialBackupFile(accountId: string): string { + return path.join(getQQBotDataDir("data"), `credential-backup-${safeName(accountId)}.json`); +} + +/** Legacy single-file credential backup (pre-multi-account-isolation). */ +export function getLegacyCredentialBackupFile(): string { + return path.join(getQQBotDataDir("data"), "credential-backup.json"); +} diff --git a/extensions/qqbot/src/engine/utils/diagnostics.ts b/extensions/qqbot/src/engine/utils/diagnostics.ts new file mode 100644 index 00000000000..f3503ffdaca --- /dev/null +++ b/extensions/qqbot/src/engine/utils/diagnostics.ts @@ -0,0 +1,109 @@ +/** + * Gateway startup diagnostics — extracted from utils/platform.ts. + * + * Depends on utils/platform.ts for detection functions, but no plugin-sdk. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { debugLog } from "./log.js"; +import { + getHomeDir, + getTempDir, + getQQBotDataDir, + getPlatform, + isWindows, + detectFfmpeg, + checkSilkWasmAvailable, +} from "./platform.js"; + +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 { + 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(); + + 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.", + ); + } + + 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.", + ); + } + + 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.`); + } + + if (isWindows()) { + 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, + }; + + 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; +} diff --git a/extensions/qqbot/src/utils/file-utils-runtime.ts b/extensions/qqbot/src/engine/utils/file-utils-runtime.ts similarity index 100% rename from extensions/qqbot/src/utils/file-utils-runtime.ts rename to extensions/qqbot/src/engine/utils/file-utils-runtime.ts diff --git a/extensions/qqbot/src/utils/file-utils.test.ts b/extensions/qqbot/src/engine/utils/file-utils.test.ts similarity index 76% rename from extensions/qqbot/src/utils/file-utils.test.ts rename to extensions/qqbot/src/engine/utils/file-utils.test.ts index 3dab5901131..ed3be3005a7 100644 --- a/extensions/qqbot/src/utils/file-utils.test.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.test.ts @@ -3,12 +3,14 @@ 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(), +const adapterMocks = vi.hoisted(() => ({ + fetchMedia: vi.fn(), })); -vi.mock("./file-utils-runtime.js", () => ({ - fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), +vi.mock("../adapter/index.js", () => ({ + getPlatformAdapter: () => ({ + fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args), + }), })); import { QQBOT_MEDIA_SSRF_POLICY, downloadFile } from "./file-utils.js"; @@ -17,7 +19,7 @@ describe("qqbot file-utils downloadFile", () => { let tempDir: string; beforeEach(async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockReset(); + adapterMocks.fetchMedia.mockReset(); tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "qqbot-file-utils-")); }); @@ -25,8 +27,8 @@ describe("qqbot file-utils downloadFile", () => { 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({ + it("downloads through the guarded media adapter with the qqbot SSRF policy", async () => { + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: Buffer.from("image-bytes"), contentType: "image/png", fileName: "remote.png", @@ -41,7 +43,7 @@ describe("qqbot file-utils downloadFile", () => { 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({ + expect(adapterMocks.fetchMedia).toHaveBeenCalledWith({ url: "https://media.qq.com/assets/photo.png", filePathHint: "photo.png", ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, @@ -65,6 +67,6 @@ describe("qqbot file-utils downloadFile", () => { const savedPath = await downloadFile("http://media.qq.com/assets/photo.png", tempDir); expect(savedPath).toBeNull(); - expect(mediaRuntimeMocks.fetchRemoteMedia).not.toHaveBeenCalled(); + expect(adapterMocks.fetchMedia).not.toHaveBeenCalled(); }); }); diff --git a/extensions/qqbot/src/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts similarity index 84% rename from extensions/qqbot/src/utils/file-utils.ts rename to extensions/qqbot/src/engine/utils/file-utils.ts index 63020a6bc8c..0d29f06ed34 100644 --- a/extensions/qqbot/src/utils/file-utils.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.ts @@ -1,13 +1,10 @@ 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 { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; -import { fetchRemoteMedia } from "./file-utils-runtime.js"; +import { getPlatformAdapter } from "../adapter/index.js"; +import type { SsrfPolicyConfig } from "../adapter/types.js"; +import { formatErrorMessage } from "./format.js"; +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-normalize.js"; /** Maximum file size accepted by the QQ Bot API. */ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; @@ -16,22 +13,22 @@ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [ - // QQ富媒体 + // QQ rich media "*.qpic.cn", "*.qq.com", "*.weiyun.com", "*.qq.com.cn", - // QQ机器人 + // QQ Bot "*.ugcimg.cn", - // 腾讯云COS + // Tencent Cloud COS "*.myqcloud.com", "*.tencentcos.cn", "*.tencentcos.com", ]; -export const QQBOT_MEDIA_SSRF_POLICY: SsrFPolicy = { +export const QQBOT_MEDIA_SSRF_POLICY: SsrfPolicyConfig = { hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST, allowRfc2544BenchmarkRange: true, }; @@ -152,7 +149,7 @@ export async function downloadFile( fs.mkdirSync(destDir, { recursive: true }); } - const fetched = await fetchRemoteMedia({ + const fetched = await getPlatformAdapter().fetchMedia({ url: parsedUrl.toString(), filePathHint: originalFilename, ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY, @@ -174,7 +171,16 @@ export async function downloadFile( const destPath = path.join(destDir, safeFilename); await fs.promises.writeFile(destPath, fetched.buffer); return destPath; - } catch { + } catch (err) { + console.error( + `[qqbot:downloadFile] FAILED url=${url.slice(0, 120)} error=${err instanceof Error ? err.message : String(err)}`, + ); + if (err instanceof Error && err.stack) { + console.error(`[qqbot:downloadFile] stack=${err.stack.split("\n").slice(0, 3).join(" | ")}`); + } + if (err instanceof Error && err.cause) { + console.error(`[qqbot:downloadFile] cause=${formatErrorMessage(err.cause)}`); + } return null; } } diff --git a/extensions/qqbot/src/engine/utils/format.test.ts b/extensions/qqbot/src/engine/utils/format.test.ts new file mode 100644 index 00000000000..452ebc9577c --- /dev/null +++ b/extensions/qqbot/src/engine/utils/format.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { formatErrorMessage, formatDuration } from "./format.js"; + +describe("engine/utils/format", () => { + describe("formatErrorMessage", () => { + it("extracts message from Error instances", () => { + expect(formatErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("returns strings as-is", () => { + expect(formatErrorMessage("plain text")).toBe("plain text"); + }); + + it("traverses the .cause chain", () => { + const inner = new Error("inner"); + const outer = new Error("outer", { cause: inner }); + expect(formatErrorMessage(outer)).toBe("outer | inner"); + }); + + it("handles string cause", () => { + const err = new Error("outer", { cause: "string cause" }); + expect(formatErrorMessage(err)).toBe("outer | string cause"); + }); + + it("stringifies numbers", () => { + expect(formatErrorMessage(42)).toBe("42"); + }); + + it("stringifies null", () => { + expect(formatErrorMessage(null)).toBe("null"); + }); + + it("stringifies undefined", () => { + expect(formatErrorMessage(undefined)).toBe("undefined"); + }); + + it("JSON-stringifies plain objects", () => { + expect(formatErrorMessage({ code: 500 })).toBe('{"code":500}'); + }); + }); + + describe("formatDuration", () => { + it("formats zero", () => { + expect(formatDuration(0)).toBe("0s"); + }); + + it("formats sub-minute durations as seconds", () => { + expect(formatDuration(45_000)).toBe("45s"); + }); + + it("formats exactly 60 seconds as 1m", () => { + expect(formatDuration(60_000)).toBe("1m"); + }); + + it("formats mixed minutes and seconds", () => { + expect(formatDuration(90_000)).toBe("1m 30s"); + }); + + it("formats exact minutes without trailing seconds", () => { + expect(formatDuration(300_000)).toBe("5m"); + }); + + it("rounds sub-second values", () => { + expect(formatDuration(1_499)).toBe("1s"); + expect(formatDuration(1_500)).toBe("2s"); + }); + }); +}); diff --git a/extensions/qqbot/src/engine/utils/format.ts b/extensions/qqbot/src/engine/utils/format.ts new file mode 100644 index 00000000000..b4b52211f33 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/format.ts @@ -0,0 +1,70 @@ +/** + * General formatting and string utilities. + * 通用格式化与字符串工具。 + * + * Pure utility functions with zero external dependencies. + * Replaces `openclaw/plugin-sdk/error-runtime` and `text-runtime` + * helpers for use inside engine/. + * + * NOTE: The framework `formatErrorMessage` also applies `redactSensitiveText()` + * for token masking. We intentionally omit that here — the framework's log + * pipeline handles redaction at a higher level. + */ + +/** + * Format any error object into a readable string. + * 将任意错误对象格式化为可读字符串。 + * + * Traverses the `.cause` chain for nested Error objects to include + * the full error context (e.g. network errors wrapped inside HTTP errors). + */ +export function formatErrorMessage(err: unknown): string { + if (err instanceof Error) { + let formatted = err.message || err.name || "Error"; + let cause: unknown = err.cause; + const seen = new Set([err]); + while (cause && !seen.has(cause)) { + seen.add(cause); + if (cause instanceof Error) { + if (cause.message) { + formatted += ` | ${cause.message}`; + } + cause = cause.cause; + } else if (typeof cause === "string") { + formatted += ` | ${cause}`; + break; + } else { + break; + } + } + return formatted; + } + if (typeof err === "string") { + return err; + } + if ( + err === null || + err === undefined || + typeof err === "number" || + typeof err === "boolean" || + typeof err === "bigint" + ) { + return String(err); + } + try { + return JSON.stringify(err); + } catch { + return Object.prototype.toString.call(err); + } +} + +/** Format a millisecond duration into a human-readable string (e.g. "5m 30s"). */ +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`; +} diff --git a/extensions/qqbot/src/utils/image-size.test.ts b/extensions/qqbot/src/engine/utils/image-size.test.ts similarity index 64% rename from extensions/qqbot/src/utils/image-size.test.ts rename to extensions/qqbot/src/engine/utils/image-size.test.ts index 9bab2c6239d..47727a630c1 100644 --- a/extensions/qqbot/src/utils/image-size.test.ts +++ b/extensions/qqbot/src/engine/utils/image-size.test.ts @@ -1,12 +1,14 @@ import { Buffer } from "buffer"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const mediaRuntimeMocks = vi.hoisted(() => ({ - fetchRemoteMedia: vi.fn(), +const adapterMocks = vi.hoisted(() => ({ + fetchMedia: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ - fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), +vi.mock("../adapter/index.js", () => ({ + getPlatformAdapter: () => ({ + fetchMedia: (...args: unknown[]) => adapterMocks.fetchMedia(...args), + }), })); import { getImageSizeFromUrl, parseImageSize } from "./image-size.js"; @@ -35,20 +37,20 @@ function buildPngHeader(width: number, height: number): Buffer { describe("getImageSizeFromUrl", () => { beforeEach(() => { - mediaRuntimeMocks.fetchRemoteMedia.mockReset(); + adapterMocks.fetchMedia.mockReset(); }); - describe("fetchRemoteMedia options contract", () => { + describe("fetchMedia options contract", () => { it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.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(adapterMocks.fetchMedia).toHaveBeenCalledOnce(); + const opts = adapterMocks.fetchMedia.mock.calls[0][0]; expect(opts.url).toBe("https://cdn.example.com/photo.png"); expect(opts.maxBytes).toBe(65_536); @@ -62,60 +64,52 @@ describe("getImageSizeFromUrl", () => { }); it("threads caller abort signal through requestInit", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: buildPngHeader(100, 100), }); await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000); - const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0]; + const opts = adapterMocks.fetchMedia.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"), - ); + describe("SSRF blocking (adapter.fetchMedia rejects)", () => { + it("returns null when adapter.fetchMedia throws for loopback", async () => { + adapterMocks.fetchMedia.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"), - ); + it("returns null when adapter.fetchMedia throws for IPv6 loopback", async () => { + adapterMocks.fetchMedia.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"), - ); + it("returns null when adapter.fetchMedia throws for link-local/metadata", async () => { + adapterMocks.fetchMedia.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"), - ); + it("returns null when adapter.fetchMedia throws for RFC1918 addresses", async () => { + adapterMocks.fetchMedia.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")); + it("returns null on http error from adapter.fetchMedia", async () => { + adapterMocks.fetchMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden")); const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png"); @@ -125,7 +119,7 @@ describe("getImageSizeFromUrl", () => { describe("happy path", () => { it("returns parsed dimensions for a valid PNG", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: buildPngHeader(1920, 1080), contentType: "image/png", }); @@ -136,7 +130,7 @@ describe("getImageSizeFromUrl", () => { }); it("returns null when the buffer is not a recognized image format", async () => { - mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({ + adapterMocks.fetchMedia.mockResolvedValueOnce({ buffer: Buffer.from("not an image"), contentType: "text/html", }); diff --git a/extensions/qqbot/src/utils/image-size.ts b/extensions/qqbot/src/engine/utils/image-size.ts similarity index 94% rename from extensions/qqbot/src/utils/image-size.ts rename to extensions/qqbot/src/engine/utils/image-size.ts index b488bcbe894..9838eda84ac 100644 --- a/extensions/qqbot/src/utils/image-size.ts +++ b/extensions/qqbot/src/engine/utils/image-size.ts @@ -5,9 +5,10 @@ */ 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"; +import { getPlatformAdapter } from "../adapter/index.js"; +import type { SsrfPolicyConfig } from "../adapter/types.js"; +import { formatErrorMessage } from "./format.js"; +import { debugLog } from "./log.js"; export interface ImageSize { width: number; @@ -150,7 +151,7 @@ export function parseImageSize(buffer: Buffer): ImageSize | null { * (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 = {}; +const IMAGE_PROBE_SSRF_POLICY: SsrfPolicyConfig = {}; /** * Fetch image dimensions from a public URL using only the first 64 KB. @@ -167,7 +168,7 @@ export async function getImageSizeFromUrl( const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { - const { buffer } = await fetchRemoteMedia({ + const { buffer } = await getPlatformAdapter().fetchMedia({ url, maxBytes: 65_536, maxRedirects: 0, @@ -192,7 +193,7 @@ export async function getImageSizeFromUrl( clearTimeout(timeoutId); } } catch (err) { - debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${String(err)}`); + debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${formatErrorMessage(err)}`); return null; } } @@ -216,7 +217,7 @@ export function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null { return size; } catch (err) { - debugLog(`[image-size] Error parsing Base64: ${String(err)}`); + debugLog(`[image-size] Error parsing Base64: ${formatErrorMessage(err)}`); return null; } } diff --git a/extensions/qqbot/src/engine/utils/log.ts b/extensions/qqbot/src/engine/utils/log.ts new file mode 100644 index 00000000000..dd054bdd92e --- /dev/null +++ b/extensions/qqbot/src/engine/utils/log.ts @@ -0,0 +1,32 @@ +/** + * QQBot debug logging utilities. + * QQBot 调试日志工具。 + * + * Only outputs when the QQBOT_DEBUG environment variable is set, + * preventing user message content from leaking in production logs. + * + * Self-contained within engine/ — no framework SDK dependency. + */ + +const isDebug = () => !!process.env.QQBOT_DEBUG; + +/** Debug-level log; only outputs when QQBOT_DEBUG is enabled. */ +export function debugLog(...args: unknown[]): void { + if (isDebug()) { + console.log(...args); + } +} + +/** Debug-level warning; only outputs when QQBOT_DEBUG is enabled. */ +export function debugWarn(...args: unknown[]): void { + if (isDebug()) { + console.warn(...args); + } +} + +/** Debug-level error; only outputs when QQBOT_DEBUG is enabled. */ +export function debugError(...args: unknown[]): void { + if (isDebug()) { + console.error(...args); + } +} diff --git a/extensions/qqbot/src/utils/media-tags.test.ts b/extensions/qqbot/src/engine/utils/media-tags.test.ts similarity index 100% rename from extensions/qqbot/src/utils/media-tags.test.ts rename to extensions/qqbot/src/engine/utils/media-tags.test.ts diff --git a/extensions/qqbot/src/utils/media-tags.ts b/extensions/qqbot/src/engine/utils/media-tags.ts similarity index 76% rename from extensions/qqbot/src/utils/media-tags.ts rename to extensions/qqbot/src/engine/utils/media-tags.ts index e57c2a93caf..efc7324a0aa 100644 --- a/extensions/qqbot/src/utils/media-tags.ts +++ b/extensions/qqbot/src/engine/utils/media-tags.ts @@ -1,4 +1,35 @@ -import { expandTilde } from "./platform.js"; +/** + * Media tag normalization for QQ Bot messages. + * + * Normalizes malformed ``, ``, etc. tags emitted by + * smaller models into canonical wrapped-tag format. + * + * Zero external dependencies. + */ + +/** Lowercase and trim a string, returning empty string for falsy input. */ +function lc(s: string): string { + return (s ?? "").toLowerCase().trim(); +} + +/** Expand `~` prefix to the process home directory. */ +function expandTilde(p: string): string { + if (!p) { + return p; + } + const home = + typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined; + if (!home) { + return p; + } + if (p === "~") { + return home; + } + if (p.startsWith("~/") || p.startsWith("~\\")) { + return `${home}/${p.slice(2)}`; + } + return p; +} // Canonical media tags. `qqmedia` is the generic auto-routing tag. const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const; @@ -48,8 +79,9 @@ ALL_TAG_NAMES.sort((a, b) => b.length - a.length); const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|"); -const LEFT_BRACKET = "(?:[<<<]|<)"; -const RIGHT_BRACKET = "(?:[>>>]|>)"; +const LEFT_BRACKET = "(?:[<\uff1c\u003c]|<)"; +const RIGHT_BRACKET = "(?:[>\uff1e\u003e]|>)"; + /** Match self-closing media-tag syntax with file/src/path/url attributes. */ export const SELF_CLOSING_TAG_REGEX = new RegExp( "`?" + @@ -57,12 +89,12 @@ export const SELF_CLOSING_TAG_REGEX = new RegExp( "\\s*(" + TAG_NAME_PATTERN + ")" + - "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*" + + "(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" + "\\s+(?:file|src|path|url)\\s*=\\s*" + "[\"']?" + - "([^\"'\\s>>]+?)" + + "([^\"'\\s>\uff1e]+?)" + "[\"']?" + - "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s<<>>>]*?[\"']?)*" + + "(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" + "\\s*/?" + "\\s*" + RIGHT_BRACKET + @@ -79,7 +111,7 @@ export const FUZZY_MEDIA_TAG_REGEX = new RegExp( ")\\s*" + RIGHT_BRACKET + "[\"']?\\s*" + - "([^<<<>>\"'`]+?)" + + "([^<\uff1c<\uff1e>\"'`]+?)" + "\\s*[\"']?" + LEFT_BRACKET + "\\s*/?\\s*(?:" + @@ -92,7 +124,7 @@ export const FUZZY_MEDIA_TAG_REGEX = new RegExp( /** Normalize a raw tag name into the canonical tag set. */ function resolveTagName(raw: string): (typeof VALID_TAGS)[number] { - const lower = raw.trim().toLowerCase(); + const lower = lc(raw); if ((VALID_TAGS as readonly string[]).includes(lower)) { return lower as (typeof VALID_TAGS)[number]; } diff --git a/extensions/qqbot/src/utils/payload.ts b/extensions/qqbot/src/engine/utils/payload.ts similarity index 75% rename from extensions/qqbot/src/utils/payload.ts rename to extensions/qqbot/src/engine/utils/payload.ts index 5021d30bf8a..a5bf529d2ca 100644 --- a/extensions/qqbot/src/utils/payload.ts +++ b/extensions/qqbot/src/engine/utils/payload.ts @@ -1,10 +1,19 @@ -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +/** + * Structured payload parsing and encoding for QQ Bot messages. + * + * Handles `QQBOT_PAYLOAD:` (model-emitted structured payloads) and + * `QQBOT_CRON:` (persisted cron reminder payloads). + * + * Zero external dependencies. + */ + +import type { ChatScope } from "../types.js"; /** Structured reminder payload emitted by the model. */ export interface CronReminderPayload { type: "cron_reminder"; content: string; - targetType: "c2c" | "group"; + targetType: ChatScope; targetAddress: string; originalMessageId?: string; } @@ -31,34 +40,29 @@ export interface ParseResult { const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:"; const CRON_PREFIX = "QQBOT_CRON:"; +function formatErr(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + /** 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, - }; + return { isPayload: false, text }; } const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim(); if (!jsonContent) { - return { - isPayload: true, - error: "Payload body is empty", - }; + 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", - }; + return { isPayload: true, error: "Payload is missing the type field" }; } if (payload.type === "cron_reminder") { @@ -78,15 +82,9 @@ export function parseQQBotPayload(text: string): ParseResult { } } - return { - isPayload: true, - payload, - }; + return { isPayload: true, payload }; } catch (e) { - return { - isPayload: true, - error: `Failed to parse JSON: ${formatErrorMessage(e)}`, - }; + return { isPayload: true, error: `Failed to parse JSON: ${formatErr(e)}` }; } } @@ -106,18 +104,13 @@ export function decodeCronPayload(message: string): { const trimmedMessage = message.trim(); if (!trimmedMessage.startsWith(CRON_PREFIX)) { - return { - isCronPayload: false, - }; + return { isCronPayload: false }; } const base64Content = trimmedMessage.slice(CRON_PREFIX.length); if (!base64Content) { - return { - isCronPayload: true, - error: "Cron payload body is empty", - }; + return { isCronPayload: true, error: "Cron payload body is empty" }; } try { @@ -132,21 +125,12 @@ export function decodeCronPayload(message: string): { } if (!payload.content || !payload.targetType || !payload.targetAddress) { - return { - isCronPayload: true, - error: "Cron payload is missing required fields", - }; + return { isCronPayload: true, error: "Cron payload is missing required fields" }; } - return { - isCronPayload: true, - payload, - }; + return { isCronPayload: true, payload }; } catch (e) { - return { - isCronPayload: true, - error: `Failed to decode cron payload: ${formatErrorMessage(e)}`, - }; + return { isCronPayload: true, error: `Failed to decode cron payload: ${formatErr(e)}` }; } } diff --git a/extensions/qqbot/src/utils/platform.test.ts b/extensions/qqbot/src/engine/utils/platform.test.ts similarity index 81% rename from extensions/qqbot/src/utils/platform.test.ts rename to extensions/qqbot/src/engine/utils/platform.test.ts index 208d6a507cd..ed4f64a392f 100644 --- a/extensions/qqbot/src/utils/platform.test.ts +++ b/extensions/qqbot/src/engine/utils/platform.test.ts @@ -96,6 +96,22 @@ describe("qqbot local media path remapping", () => { expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); }); + it("allows structured payload files inside sibling OpenClaw media subdirectories", () => { + // Core helpers such as `saveMediaBuffer(..., "outbound", ...)` place framework + // attachments under sibling directories of `media/qqbot/`. The plugin must + // trust the shared `~/.openclaw/media` root so auto-routed sends can access + // those files without the path-outside-storage guard firing. + const actualHome = getHomeDir(); + const outboundDir = path.join(actualHome, ".openclaw", "media", "outbound"); + fs.mkdirSync(outboundDir, { recursive: true }); + const outboundFile = fs.mkdtempSync(path.join(outboundDir, "qqbot-outbound-")); + const mediaFile = path.join(outboundFile, "tts.mp3"); + fs.writeFileSync(mediaFile, "audio", "utf8"); + createdPaths.push(outboundFile); + + expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile)); + }); + it("blocks structured payload files inside the QQ Bot data directory", () => { const { actualHome, testRootName } = createOpenClawTestRoot(); diff --git a/extensions/qqbot/src/utils/platform.ts b/extensions/qqbot/src/engine/utils/platform.ts similarity index 58% rename from extensions/qqbot/src/utils/platform.ts rename to extensions/qqbot/src/engine/utils/platform.ts index a4e59ffed60..bec897074f3 100644 --- a/extensions/qqbot/src/utils/platform.ts +++ b/extensions/qqbot/src/engine/utils/platform.ts @@ -1,35 +1,19 @@ /** - * Cross-platform compatibility helpers. + * Cross-platform path and detection helpers for core/ modules. * - * This module centralizes home/temp directory discovery, local-path checks, - * ffmpeg/ffprobe lookup, native-module compatibility checks, and startup diagnostics. + * Provides home/data/media directory helpers, platform detection, + * ffmpeg/silk-wasm availability checks — all without importing + * `openclaw/plugin-sdk`. The temp-directory fallback is delegated + * to the PlatformAdapter. */ 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. +import { getPlatformAdapter } from "../adapter/index.js"; +import { formatErrorMessage } from "./format.js"; +import { debugLog, debugWarn } from "./log.js"; /** * Resolve the current user's home directory safely across platforms. @@ -37,7 +21,7 @@ export function isWindows(): boolean { * Priority: * 1. `os.homedir()` * 2. `$HOME` or `%USERPROFILE%` - * 3. the OpenClaw temp directory as a last resort + * 3. PlatformAdapter.getTempDir() as a last resort */ export function getHomeDir(): string { try { @@ -45,21 +29,19 @@ export function getHomeDir(): string { if (home && fs.existsSync(home)) { return home; } - } catch {} + } catch { + /* fallback */ + } - // 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 getPlatformAdapter().getTempDir(); } -/** - * Return a path under `~/.openclaw/qqbot`, creating it on demand. - */ +/** 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)) { @@ -82,201 +64,42 @@ export function getQQBotMediaDir(...subPaths: string[]): string { return dir; } -// Temporary directory helpers. - -/** Return the preferred OpenClaw temp directory. */ -export function getTempDir(): string { - return resolvePreferredOpenClawTmpDir(); +/** + * Return `~/.openclaw/media`, OpenClaw's shared media root. + * + * This mirrors the directory that core's `buildMediaLocalRoots` exposes as an + * allowlisted location (see `openclaw/src/media/local-roots.ts`). Using it as a + * QQ Bot payload root lets the plugin trust framework-produced files that live + * in sibling subdirectories such as `outbound/` (written by + * `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping + * the check anchored to a single, well-known directory. + */ +export function getOpenClawMediaDir(): string { + return path.join(getHomeDir(), ".openclaw", "media"); } -// Tilde expansion. +// ---- Basic platform information ---- -/** - * Expand `~` to the current user's home directory. - * - * Supports `~` and `~/...`. Other forms are returned unchanged. - */ -export function expandTilde(p: string): string { - if (!p) { +export type PlatformType = "darwin" | "linux" | "win32" | "other"; + +export function getPlatform(): PlatformType { + const p = process.platform; + if (p === "darwin" || p === "linux" || p === "win32") { return p; } - if (p === "~") { - return getHomeDir(); - } - if (p.startsWith("~/") || p.startsWith("~\\")) { - return path.join(getHomeDir(), p.slice(2)); - } - return p; + return "other"; } -/** - * 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); +export function isWindows(): boolean { + return process.platform === "win32"; } -function isPathWithinRoot(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +/** Return the preferred temporary directory. */ +export function getTempDir(): string { + return getPlatformAdapter().getTempDir(); } -/** - * 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); -} +// ---- ffmpeg detection ---- let _ffmpegPath: string | null | undefined; let _ffmpegCheckPromise: Promise | null = null; @@ -358,6 +181,8 @@ export function resetFfmpegCache(): void { _ffmpegCheckPromise = null; } +// ---- silk-wasm detection ---- + let _silkWasmAvailable: boolean | null = null; /** Check whether silk-wasm can run in the current environment. */ @@ -365,10 +190,8 @@ export async function checkSilkWasmAvailable(): Promise { 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"); @@ -379,100 +202,146 @@ export async function checkSilkWasmAvailable(): Promise { return _silkWasmAvailable; } -// Startup environment diagnostics. +// ---- Tilde expansion and path normalization ---- -export interface DiagnosticReport { - platform: string; - arch: string; - nodeVersion: string; - homeDir: string; - tempDir: string; - dataDir: string; - ffmpeg: string | null; - silkWasm: boolean; - warnings: string[]; +/** Expand `~` to the current user's home directory. */ +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(); + if (result.startsWith("file://")) { + result = result.slice("file://".length); + try { + result = decodeURIComponent(result); + } catch { + // Keep the raw string if decoding fails. + } + } + return expandTilde(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; + } + if (p.startsWith("file://")) { + return true; + } + if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) { + return true; + } + if (p.startsWith("/")) { + return true; + } + if (/^[a-zA-Z]:[\\/]/.test(p)) { + return true; + } + if (p.startsWith("\\\\")) { + return true; + } + if (p.startsWith("./") || p.startsWith("../")) { + return true; + } + 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); +} + +// ---- QQBot media path resolution ---- + +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; } /** - * Run startup diagnostics and return an environment report. - * Called during gateway startup to log environment details and warnings. + * Resolve a structured-payload local file path and enforce that it stays within + * QQ Bot-owned storage roots. */ -export async function runDiagnostics(): Promise { - 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.", - ); +export function resolveQQBotPayloadLocalFilePath(p: string): string | null { + const candidate = resolveQQBotLocalMediaPath(p); + if (!candidate.trim()) { + return null; } - // 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.", - ); + const resolvedCandidate = path.resolve(candidate); + if (!fs.existsSync(resolvedCandidate)) { + return null; } - // 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.`); - } + const canonicalCandidate = fs.realpathSync(resolvedCandidate); + // Trust both the QQ Bot-owned subdirectory and OpenClaw's shared `~/.openclaw/media` + // root. Core helpers like `saveMediaBuffer(..., "outbound", ...)` place framework + // attachments under sibling directories (e.g. `media/outbound/`) that are already + // part of the core media allowlist; we mirror that so auto-routed sends work + // without leaving the plugin's trust boundary. + const allowedRoots = [getOpenClawMediaDir(), getQQBotMediaDir()]; - // 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.`, - ); + 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; } } - 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; + return null; } diff --git a/extensions/qqbot/src/engine/utils/request-context.ts b/extensions/qqbot/src/engine/utils/request-context.ts new file mode 100644 index 00000000000..ac579cdd0f9 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/request-context.ts @@ -0,0 +1,75 @@ +/** + * Request-level context using AsyncLocalStorage. + * + * Provides ambient context (accountId, target openid, chat type, etc.) + * throughout the request lifecycle without explicit parameter threading. + * + * Gateway establishes the scope around each inbound message via + * `runWithRequestContext()`; any async code within that scope (including + * AI agent calls and tool `execute` callbacks) can retrieve the current + * request via `getRequestContext()` without racing with concurrent + * inbound messages. + * + * This is a pure Node.js module with zero framework dependencies, + * making it trivially portable between the built-in and standalone + * versions of QQBot. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; + +/** Context values available during one inbound message handling cycle. */ +export interface RequestContext { + /** The account ID handling this request. */ + accountId: string; + /** + * Fully qualified delivery target, e.g. `qqbot:c2c:` or + * `qqbot:group:`. This is what downstream code (e.g. the + * `qqbot_remind` tool building a cron job) uses verbatim. + */ + target?: string; + /** The target openid (C2C) or group openid (group). */ + targetId?: string; + /** Chat type of the originating event. */ + chatType?: "c2c" | "group" | "guild" | "dm" | "channel"; +} + +const store = new AsyncLocalStorage(); + +/** + * Execute an async function with request-scoped context. + * + * All code running within `fn` (including nested async calls) can + * retrieve the context via `getRequestContext()`. + * + * @param ctx - The context to attach to this request. + * @param fn - The async function to run within the context. + * @returns The return value of `fn`. + */ +export function runWithRequestContext(ctx: RequestContext, fn: () => T): T { + return store.run(ctx, fn); +} + +/** + * Retrieve the current request context. + * + * Returns `undefined` when called outside of a `runWithRequestContext` + * scope. + */ +export function getRequestContext(): RequestContext | undefined { + return store.getStore(); +} + +/** + * Convenience accessor for the current request's fully qualified + * delivery target. + */ +export function getRequestTarget(): string | undefined { + return store.getStore()?.target; +} + +/** + * Convenience accessor for the current request's account ID. + */ +export function getRequestAccountId(): string | undefined { + return store.getStore()?.accountId; +} diff --git a/extensions/qqbot/src/engine/utils/string-normalize.ts b/extensions/qqbot/src/engine/utils/string-normalize.ts new file mode 100644 index 00000000000..491479e78c6 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/string-normalize.ts @@ -0,0 +1,137 @@ +/** + * String normalization and record-coercion helpers. + * + * These are self-contained re-implementations of the functions that + * the plugin previously imported from `openclaw/plugin-sdk/text-runtime` + * and `openclaw/plugin-sdk/text-runtime` (via record-coerce / string-coerce). + * + * core/ modules use these instead of importing plugin-sdk, keeping the + * shared layer portable between the built-in and standalone versions. + */ + +// ---- String coercion ---- + +/** Return the trimmed string or `null` when the value is not a non-empty string. */ +export function normalizeNullableString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +/** Return the trimmed string or `undefined` when the value is not a non-empty string. */ +export function normalizeOptionalString(value: unknown): string | undefined { + return normalizeNullableString(value) ?? undefined; +} + +/** + * Stringify then normalize. Accepts `string | number | boolean | bigint`. + * Returns `undefined` for objects, arrays, null, and undefined. + */ +export function normalizeStringifiedOptionalString(value: unknown): string | undefined { + if (typeof value === "string") { + return normalizeOptionalString(value); + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return normalizeOptionalString(String(value)); + } + return undefined; +} + +/** Return the trimmed lowercase string or `undefined`. */ +export function normalizeOptionalLowercaseString(value: unknown): string | undefined { + return normalizeOptionalString(value)?.toLowerCase(); +} + +/** Return the trimmed lowercase string or `""`. */ +export function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalLowercaseString(value) ?? ""; +} + +/** Return the raw string value or `undefined`. No trimming. */ +export function readStringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** Return true when the value is a non-empty trimmed string. */ +export function hasNonEmptyString(value: unknown): value is string { + return normalizeOptionalString(value) !== undefined; +} + +// ---- Record coercion ---- + +/** Coerce a value into a `Record`, defaulting to `{}`. */ +export function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null ? (value as Record) : {}; +} + +/** Coerce a value into a `Record` or `undefined`. */ +export function asOptionalObjectRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +/** Read a string field from a record. */ +export function readStringField( + record: Record | null | undefined, + key: string, +): string | undefined { + const v = record?.[key]; + return typeof v === "string" ? v : undefined; +} + +/** Read a number field from a record. */ +export function readNumberField( + record: Record | null | undefined, + key: string, +): number | undefined { + const v = record?.[key]; + return typeof v === "number" ? v : undefined; +} + +/** Read a boolean field from a record. */ +export function readBooleanField( + record: Record | null | undefined, + key: string, +): boolean | undefined { + const v = record?.[key]; + return typeof v === "boolean" ? v : undefined; +} + +/** Coerce a value into a string→string map, filtering out non-string values. */ +export function readStringMap(value: unknown): Record { + const record = asOptionalObjectRecord(value); + if (!record) { + return {}; + } + return Object.fromEntries( + Object.entries(record).flatMap(([key, entryValue]) => + typeof entryValue === "string" ? [[key, entryValue]] : [], + ), + ); +} + +// ---- Filename normalization ---- + +/** + * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably. + * + * 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(); + if (result.includes("%")) { + try { + result = decodeURIComponent(result); + } catch { + // Keep the raw value if it is not valid percent-encoding. + } + } + result = result.normalize("NFC"); + result = result.replace(/\p{Cc}/gu, ""); + return result; +} diff --git a/extensions/qqbot/src/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts similarity index 85% rename from extensions/qqbot/src/stt.ts rename to extensions/qqbot/src/engine/utils/stt.ts index 24801180263..30332995abd 100644 --- a/extensions/qqbot/src/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -1,14 +1,18 @@ /** - * OpenAI-compatible STT used at the plugin layer. + * OpenAI-compatible STT (Speech-to-Text) configuration and transcription. * - * This avoids pushing raw WAV PCM into the framework media-understanding pipeline. + * Migrated from `src/stt.ts` — uses core/utils/string-normalize instead + * of openclaw/plugin-sdk/text-runtime. */ 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"; +import { + normalizeOptionalString, + asOptionalObjectRecord as asRecord, + readStringField as readString, + sanitizeFileName, +} from "./string-normalize.js"; export interface STTConfig { baseUrl: string; @@ -16,6 +20,7 @@ export interface STTConfig { model: string; } +/** Resolve the STT configuration from the nested config object. */ export function resolveSTTConfig(cfg: Record): STTConfig | null { const channels = asRecord(cfg.channels); const qqbot = asRecord(channels?.qqbot); @@ -55,6 +60,7 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null return null; } +/** Send audio to an OpenAI-compatible STT endpoint and return the transcript. */ export async function transcribeAudio( audioPath: string, cfg: Record, diff --git a/extensions/qqbot/src/engine/utils/text-chunk.ts b/extensions/qqbot/src/engine/utils/text-chunk.ts new file mode 100644 index 00000000000..ef4df0bb4f0 --- /dev/null +++ b/extensions/qqbot/src/engine/utils/text-chunk.ts @@ -0,0 +1,39 @@ +/** + * Text chunking — core/ version. + * + * The actual chunking logic is provided by the framework runtime + * (`runtime.channel.text.chunkMarkdownText`). This module exposes a + * registerable adapter so core/ modules can call `chunkText()` without + * importing the plugin-sdk runtime store. + */ + +/** Maximum text length for a single QQ Bot message. */ +export const TEXT_CHUNK_LIMIT = 5000; + +/** Text chunker function signature. */ +export type ChunkTextFn = (text: string, limit: number) => string[]; + +let _chunkText: ChunkTextFn | null = null; + +/** Register the text chunker — called by the outer-layer startup. */ +export function registerTextChunker(fn: ChunkTextFn): void { + _chunkText = fn; +} + +/** + * Markdown-aware text chunking. + * + * Delegates to the registered chunker (framework runtime). + * Falls back to a naive split when no chunker is registered. + */ +export function chunkText(text: string, limit: number = TEXT_CHUNK_LIMIT): string[] { + if (_chunkText) { + return _chunkText(text, limit); + } + // Naive fallback: split by limit without markdown awareness. + const chunks: string[] = []; + for (let i = 0; i < text.length; i += limit) { + chunks.push(text.slice(i, i + limit)); + } + return chunks.length > 0 ? chunks : [text]; +} diff --git a/extensions/qqbot/src/utils/text-parsing.test.ts b/extensions/qqbot/src/engine/utils/text-parsing.test.ts similarity index 100% rename from extensions/qqbot/src/utils/text-parsing.test.ts rename to extensions/qqbot/src/engine/utils/text-parsing.test.ts diff --git a/extensions/qqbot/src/engine/utils/text-parsing.ts b/extensions/qqbot/src/engine/utils/text-parsing.ts new file mode 100644 index 00000000000..ee385ce142d --- /dev/null +++ b/extensions/qqbot/src/engine/utils/text-parsing.ts @@ -0,0 +1,155 @@ +/** + * Text parsing utilities — zero external dependency. + * + * Contains pure functions for message text processing. + */ + +import type { RefAttachmentSummary } from "../ref/types.js"; + +// ============ Internal markers ============ + +const INTERNAL_MARKER_RE = /\[internal:?\s*[^\]]*\]|\[debug:?\s*[^\]]*\]|\[system:?\s*[^\]]*\]/gi; + +/** Remove internal markers like `[internal:...]`, `[debug:...]`, `[system:...]`. */ +export function filterInternalMarkers(text: string | undefined | null): string { + if (!text) { + return ""; + } + return text.replace(INTERNAL_MARKER_RE, "").trim(); +} + +// ============ Ref indices ============ + +/** QQ 引用(回复)消息类型常量。 */ +export const MSG_TYPE_QUOTE = 103; + +/** + * Parse message_scene.ext to extract refMsgIdx and msgIdx. + * + * Supports both ext prefix formats: + * - `ref_msg_idx=` / `msg_idx=` (platform native format) + * - `refMsgIdx:` / `msgIdx:` (legacy internal format) + * + * When `messageType` equals `MSG_TYPE_QUOTE` (103) and `msgElements` is + * provided, `msgElements[0].msg_idx` takes precedence over the ext-parsed + * `refMsgIdx` value — the element-level index is more authoritative for + * quote messages. + */ +export function parseRefIndices( + ext?: string[], + messageType?: number, + msgElements?: Array<{ msg_idx?: string }>, +): { refMsgIdx?: string; msgIdx?: string } { + let refMsgIdx: string | undefined; + let msgIdx: string | undefined; + + if (ext && ext.length > 0) { + for (const item of ext) { + if (typeof item !== "string") { + continue; + } + // Platform native format: ref_msg_idx= / msg_idx= + if (item.startsWith("ref_msg_idx=")) { + refMsgIdx = item.slice("ref_msg_idx=".length).trim(); + } else if (item.startsWith("msg_idx=")) { + msgIdx = item.slice("msg_idx=".length).trim(); + } + // Legacy internal format: refMsgIdx: / msgIdx: + else if (item.startsWith("refMsgIdx:")) { + refMsgIdx = item.slice("refMsgIdx:".length).trim(); + } else if (item.startsWith("msgIdx:")) { + msgIdx = item.slice("msgIdx:".length).trim(); + } + } + } + + // For quote messages, msg_elements[0].msg_idx is more authoritative. + if (messageType === MSG_TYPE_QUOTE) { + const refElement = msgElements?.[0]; + if (refElement?.msg_idx) { + refMsgIdx = refElement.msg_idx; + } + } + + return { refMsgIdx, msgIdx }; +} + +// ============ Face tags ============ + +const MAX_FACE_EXT_BYTES = 64 * 1024; + +/** Estimate Base64 decoded byte size (replaces plugin-sdk estimateBase64DecodedBytes). */ +function estimateBase64Size(base64: string): number { + const len = base64.length; + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.ceil((len * 3) / 4) - padding; +} + +/** Replace QQ face tags with readable text labels. */ +export function parseFaceTags(text: string | undefined | null): string { + if (!text) { + return ""; + } + + return text.replace(//g, (_match, ext: string) => { + try { + if (estimateBase64Size(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; + } + }); +} + +// ============ Attachment summaries ============ + +/** Lowercase a string safely (replaces plugin-sdk normalizeLowercaseStringOrEmpty). */ +function lc(s: string | undefined | null): string { + return (s ?? "").toLowerCase(); +} + +/** 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, +): RefAttachmentSummary[] | undefined { + if (!attachments || attachments.length === 0) { + return undefined; + } + + return attachments.map((att, idx) => { + const ct = lc(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, + }; + }); +} diff --git a/extensions/qqbot/src/utils/upload-cache.ts b/extensions/qqbot/src/engine/utils/upload-cache.ts similarity index 95% rename from extensions/qqbot/src/utils/upload-cache.ts rename to extensions/qqbot/src/engine/utils/upload-cache.ts index f659da6ef48..fcb912abd97 100644 --- a/extensions/qqbot/src/utils/upload-cache.ts +++ b/extensions/qqbot/src/engine/utils/upload-cache.ts @@ -4,7 +4,8 @@ */ import * as crypto from "node:crypto"; -import { debugLog } from "./debug-log.js"; +import type { ChatScope } from "../types.js"; +import { debugLog } from "./log.js"; interface CacheEntry { fileInfo: string; @@ -34,7 +35,7 @@ function buildCacheKey( /** Look up a cached `file_info` value. */ export function getCachedFileInfo( contentHash: string, - scope: "c2c" | "group", + scope: ChatScope, targetId: string, fileType: number, ): string | null { @@ -57,7 +58,7 @@ export function getCachedFileInfo( /** Store an upload result in the cache. */ export function setCachedFileInfo( contentHash: string, - scope: "c2c" | "group", + scope: ChatScope, targetId: string, fileType: number, fileInfo: string, diff --git a/extensions/qqbot/src/engine/utils/voice-text.ts b/extensions/qqbot/src/engine/utils/voice-text.ts new file mode 100644 index 00000000000..3e2d22cb5de --- /dev/null +++ b/extensions/qqbot/src/engine/utils/voice-text.ts @@ -0,0 +1,15 @@ +/** + * Voice transcript formatting utility. + * + * Zero external dependencies — pure string formatting. + */ + +/** 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"); +} diff --git a/extensions/qqbot/src/exec-approvals.ts b/extensions/qqbot/src/exec-approvals.ts new file mode 100644 index 00000000000..f63a1f9e8a9 --- /dev/null +++ b/extensions/qqbot/src/exec-approvals.ts @@ -0,0 +1,226 @@ +import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime"; +import { + createChannelExecApprovalProfile, + isChannelExecApprovalClientEnabledFromConfig, + matchesApprovalRequestFilters, +} from "openclaw/plugin-sdk/approval-client-runtime"; +import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ExecApprovalRequest, PluginApprovalRequest } from "openclaw/plugin-sdk/infra-runtime"; +import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; +import { listQQBotAccountIds, resolveQQBotAccount } from "./bridge/config.js"; +import type { QQBotExecApprovalConfig } from "./types.js"; + +function normalizeApproverId(value: string | number): string | undefined { + const trimmed = normalizeOptionalString(String(value)); + return trimmed || undefined; +} + +export function resolveQQBotExecApprovalConfig(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): QQBotExecApprovalConfig | undefined { + const account = resolveQQBotAccount(params.cfg, params.accountId); + const config = account.config.execApprovals; + if (!config) { + return undefined; + } + return { + ...config, + enabled: account.enabled && account.secretSource !== "none" ? config.enabled : false, + }; +} + +export function getQQBotExecApprovalApprovers(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const accountConfig = resolveQQBotAccount(params.cfg, params.accountId).config; + return resolveApprovalApprovers({ + explicit: resolveQQBotExecApprovalConfig(params)?.approvers, + allowFrom: accountConfig.allowFrom, + normalizeApprover: normalizeApproverId, + }); +} + +function countQQBotExecApprovalEligibleAccounts(params: { + cfg: OpenClawConfig; + request: ExecApprovalRequest | PluginApprovalRequest; +}): number { + return listQQBotAccountIds(params.cfg).filter((accountId) => { + const account = resolveQQBotAccount(params.cfg, accountId); + if (!account.enabled || account.secretSource === "none") { + return false; + } + const config = resolveQQBotExecApprovalConfig({ + cfg: params.cfg, + accountId, + }); + return ( + isChannelExecApprovalClientEnabledFromConfig({ + enabled: config?.enabled, + approverCount: getQQBotExecApprovalApprovers({ cfg: params.cfg, accountId }).length, + }) && + matchesApprovalRequestFilters({ + request: params.request.request, + agentFilter: config?.agentFilter, + sessionFilter: config?.sessionFilter, + fallbackAgentIdFromSessionKey: true, + }) + ); + }).length; +} + +function matchesQQBotRequestAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: ExecApprovalRequest | PluginApprovalRequest; +}): boolean { + const turnSourceChannel = normalizeLowercaseStringOrEmpty( + params.request.request.turnSourceChannel, + ); + const boundAccountId = resolveApprovalRequestChannelAccountId({ + cfg: params.cfg, + request: params.request, + channel: "qqbot", + }); + if (turnSourceChannel && turnSourceChannel !== "qqbot" && !boundAccountId) { + return ( + countQQBotExecApprovalEligibleAccounts({ + cfg: params.cfg, + request: params.request, + }) <= 1 + ); + } + return ( + !boundAccountId || + !params.accountId || + normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId) + ); +} + +/** + * Count QQBot accounts that could actually deliver a native approval + * message — i.e. accounts that are enabled and have resolvable secrets. + * Disabled or unconfigured accounts never spawn a handler, so they + * must not contribute to the single-account shortcut in the fallback + * ownership check below. + */ +function countQQBotFallbackEligibleAccounts(cfg: OpenClawConfig): number { + return listQQBotAccountIds(cfg).filter((accountId) => { + const account = resolveQQBotAccount(cfg, accountId); + return account.enabled && account.secretSource !== "none"; + }).length; +} + +/** + * Fallback account-ownership check — applied when `execApprovals` is NOT + * configured for any QQBot account. In this mode every enabled account + * handler would otherwise race to deliver the same approval to its own + * openid namespace, so we must enforce per-account isolation. + * + * Rules: + * - If the request carries a bound account (via `turnSourceAccountId` + * or session binding), only the handler whose `accountId` matches it + * delivers the approval. This is strict: a handler with an unknown + * `accountId` (null/undefined) must not claim a bound request. + * - If no account is bound, only deliver when there is a single + * *eligible* QQBot account (enabled + secret resolved). Disabled or + * unconfigured accounts never deliver anyway, so they shouldn't + * block the remaining single account from handling the approval. + * Multiple eligible accounts cannot safely race because openids are + * account-scoped — cross-account delivery hits the QQ Bot API with + * a mismatched token and fails. + */ +function matchesQQBotFallbackRequestAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: ExecApprovalRequest | PluginApprovalRequest; +}): boolean { + const boundAccountId = resolveApprovalRequestChannelAccountId({ + cfg: params.cfg, + request: params.request, + channel: "qqbot", + }); + + if (boundAccountId) { + if (!params.accountId) { + return false; + } + return normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId); + } + + return countQQBotFallbackEligibleAccounts(params.cfg) <= 1; +} + +/** + * Minimal structural shape required to evaluate per-account ownership. + * + * The SDK types (`ExecApprovalRequest` / `PluginApprovalRequest`) and the + * channel-local approval request types (see `engine/approval/index.ts`) + * share the same logical fields but differ on bookkeeping metadata + * (e.g. `createdAtMs`), so we accept any object exposing the relevant + * routing fields. Consumers can pass either flavor safely. + */ +type QQBotApprovalAccountOwnershipRequest = { + request: { + sessionKey?: string | null; + turnSourceChannel?: string | null; + turnSourceTo?: string | null; + turnSourceAccountId?: string | null; + }; +}; + +/** + * Unified per-account ownership check used by both the profile and + * fallback approval paths. Dispatches to the profile rules when the + * current account has `execApprovals` configured, otherwise uses the + * fallback rules. + * + * This is the single source of truth for "does this QQBot handler own + * this approval request?" and is consumed by both the capability + * gate (shouldHandle) and the lazy native runtime adapter. + */ +export function matchesQQBotApprovalAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; + request: QQBotApprovalAccountOwnershipRequest; +}): boolean { + const normalized = { + cfg: params.cfg, + accountId: params.accountId, + request: params.request as unknown as ExecApprovalRequest | PluginApprovalRequest, + }; + if (resolveQQBotExecApprovalConfig(normalized) !== undefined) { + return matchesQQBotRequestAccount(normalized); + } + return matchesQQBotFallbackRequestAccount(normalized); +} + +const qqbotExecApprovalProfile = createChannelExecApprovalProfile({ + resolveConfig: resolveQQBotExecApprovalConfig, + resolveApprovers: getQQBotExecApprovalApprovers, + matchesRequestAccount: matchesQQBotRequestAccount, + fallbackAgentIdFromSessionKey: true, + requireClientEnabledForLocalPromptSuppression: false, +}); + +export const isQQBotExecApprovalClientEnabled = qqbotExecApprovalProfile.isClientEnabled; +export const isQQBotExecApprovalApprover = qqbotExecApprovalProfile.isApprover; +export const isQQBotExecApprovalAuthorizedSender = qqbotExecApprovalProfile.isAuthorizedSender; +export const resolveQQBotExecApprovalTarget = qqbotExecApprovalProfile.resolveTarget; +export const shouldHandleQQBotExecApprovalRequest = qqbotExecApprovalProfile.shouldHandleRequest; + +export function isQQBotExecApprovalHandlerConfigured(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + return isChannelExecApprovalClientEnabledFromConfig({ + enabled: resolveQQBotExecApprovalConfig(params)?.enabled, + approverCount: getQQBotExecApprovalApprovers(params).length, + }); +} diff --git a/extensions/qqbot/src/gateway.ts b/extensions/qqbot/src/gateway.ts deleted file mode 100644 index f6111387f8f..00000000000 --- a/extensions/qqbot/src/gateway.ts +++ /dev/null @@ -1,1530 +0,0 @@ -import path from "node:path"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import WebSocket from "ws"; -import { - clearTokenCache, - getAccessToken, - getGatewayUrl, - initApiConfig, - onMessageSent, - PLUGIN_USER_AGENT, - sendC2CInputNotify, - sendC2CMessage, - sendChannelMessage, - sendDmMessage, - sendGroupMessage, - startBackgroundTokenRefresh, - stopBackgroundTokenRefresh, -} from "./api.js"; -import { formatQQBotAllowFrom } from "./channel-config-shared.js"; -import { formatVoiceText, processAttachments } from "./inbound-attachments.js"; -import { flushKnownUsers, recordKnownUser } from "./known-users.js"; -import { createMessageQueue, type QueuedMessage } from "./message-queue.js"; -import { - parseAndSendMediaTags, - sendPlainReply, - type DeliverAccountContext, - type DeliverEventContext, -} from "./outbound-deliver.js"; -import { sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js"; -import { - flushRefIndex, - formatRefEntryForAgent, - getRefIndex, - setRefIndex, - type RefAttachmentSummary, -} from "./ref-index-store.js"; -import { - handleStructuredPayload, - sendErrorToTarget, - sendWithTokenRetry, - type MessageTarget, - type ReplyContext, -} from "./reply-dispatcher.js"; -import { getQQBotRuntime } from "./runtime.js"; -import { clearSession, loadSession, saveSession } from "./session-store.js"; -import { matchSlashCommand, type SlashCommandContext } from "./slash-commands.js"; -import type { - C2CMessageEvent, - GroupMessageEvent, - GuildMessageEvent, - ResolvedQQBotAccount, - WSPayload, -} from "./types.js"; -import { TYPING_INPUT_SECOND, TypingKeepAlive } from "./typing-keepalive.js"; -import { isGlobalTTSAvailable, resolveTTSConfig } from "./utils/audio-convert.js"; -import { runDiagnostics } from "./utils/platform.js"; -import { buildAttachmentSummaries, parseFaceTags, parseRefIndices } from "./utils/text-parsing.js"; - -// QQ Bot intents grouped by permission level. -const INTENTS = { - GUILDS: 1 << 0, - GUILD_MEMBERS: 1 << 1, - PUBLIC_GUILD_MESSAGES: 1 << 30, - DIRECT_MESSAGE: 1 << 12, - GROUP_AND_C2C: 1 << 25, -}; - -// Always request the full intent set for groups, DMs, and guild channels. -const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C; -const FULL_INTENTS_DESC = "groups + DMs + channels"; - -// Reconnect configuration. -const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; -const RATE_LIMIT_DELAY = 60000; -const MAX_RECONNECT_ATTEMPTS = 100; -const MAX_QUICK_DISCONNECT_COUNT = 3; -const QUICK_DISCONNECT_THRESHOLD = 5000; - -function decodeGatewayMessageData(data: unknown): string { - if (typeof data === "string") { - return data; - } - if (Buffer.isBuffer(data)) { - return data.toString("utf8"); - } - if (Array.isArray(data) && data.every((chunk) => Buffer.isBuffer(chunk))) { - return Buffer.concat(data).toString("utf8"); - } - if (data instanceof ArrayBuffer) { - return Buffer.from(data).toString("utf8"); - } - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8"); - } - return ""; -} - -function readOptionalMessageSceneExt( - event: GuildMessageEvent | C2CMessageEvent | GroupMessageEvent, -): string[] | undefined { - if (!("message_scene" in event)) { - return undefined; - } - return event.message_scene?.ext; -} - -export interface GatewayContext { - account: ResolvedQQBotAccount; - abortSignal: AbortSignal; - cfg: OpenClawConfig; - onReady?: (data: unknown) => void; - onError?: (error: Error) => void; - log?: { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }; -} - -/** - * Start the Gateway WebSocket connection with automatic reconnect support. - */ -export async function startGateway(ctx: GatewayContext): Promise { - const { account, abortSignal, cfg, onReady, onError, log } = ctx; - - if (!account.appId || !account.clientSecret) { - throw new Error("QQBot not configured (missing appId or clientSecret)"); - } - - // Run environment diagnostics during startup. - const diag = await runDiagnostics(); - if (diag.warnings.length > 0) { - for (const w of diag.warnings) { - log?.info(`[qqbot:${account.accountId}] ${w}`); - } - } - - // Initialize API behavior such as markdown support. - initApiConfig(account.appId, { - markdownSupport: account.markdownSupport, - }); - log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport}`); - - // Cache outbound refIdx values from QQ delivery responses for future quoting. - onMessageSent(account.appId, (refIdx, meta) => { - log?.info( - `[qqbot:${account.accountId}] onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`, - ); - const attachments: RefAttachmentSummary[] = []; - if (meta.mediaType) { - const localPath = meta.mediaLocalPath; - const filename = localPath ? path.basename(localPath) : undefined; - const attachment: RefAttachmentSummary = { - type: meta.mediaType, - ...(localPath ? { localPath } : {}), - ...(filename ? { filename } : {}), - ...(meta.mediaUrl ? { url: meta.mediaUrl } : {}), - }; - // Preserve the original TTS text for voice messages so later quoting can use it. - if (meta.mediaType === "voice" && meta.ttsText) { - attachment.transcript = meta.ttsText; - attachment.transcriptSource = "tts"; - log?.info( - `[qqbot:${account.accountId}] Saving voice transcript (TTS): ${meta.ttsText.slice(0, 50)}`, - ); - } - attachments.push(attachment); - } - setRefIndex(refIdx, { - content: meta.text ?? "", - senderId: account.accountId, - senderName: account.accountId, - timestamp: Date.now(), - isBot: true, - ...(attachments.length > 0 ? { attachments } : {}), - }); - log?.info( - `[qqbot:${account.accountId}] Cached outbound refIdx: ${refIdx}, attachments=${JSON.stringify(attachments)}`, - ); - }); - - // Log TTS configuration state for diagnostics. - const ttsCfg = resolveTTSConfig(cfg as Record); - if (ttsCfg) { - const maskedKey = - ttsCfg.apiKey.length > 8 - ? `${ttsCfg.apiKey.slice(0, 4)}****${ttsCfg.apiKey.slice(-4)}` - : "****"; - log?.info( - `[qqbot:${account.accountId}] TTS configured (plugin): model=${ttsCfg.model}, voice=${ttsCfg.voice}, authStyle=${ttsCfg.authStyle ?? "bearer"}, baseUrl=${ttsCfg.baseUrl}`, - ); - log?.info( - `[qqbot:${account.accountId}] TTS apiKey: ${maskedKey}${ttsCfg.queryParams ? `, queryParams=${JSON.stringify(ttsCfg.queryParams)}` : ""}${ttsCfg.speed !== undefined ? `, speed=${ttsCfg.speed}` : ""}`, - ); - } else if (isGlobalTTSAvailable(cfg)) { - const globalProvider = cfg.messages?.tts?.provider ?? "auto"; - log?.info( - `[qqbot:${account.accountId}] TTS configured (global fallback): provider=${globalProvider}`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] TTS not configured (voice messages will be unavailable)`, - ); - } - - let reconnectAttempts = 0; - let isAborted = false; - let currentWs: WebSocket | null = null; - let heartbeatInterval: ReturnType | null = null; - let sessionId: string | null = null; - let lastSeq: number | null = null; - let lastConnectTime = 0; - let quickDisconnectCount = 0; - let isConnecting = false; - let reconnectTimer: ReturnType | null = null; - let shouldRefreshToken = false; - - // Restore a persisted session when it still matches the current appId. - const savedSession = loadSession(account.accountId, account.appId); - if (savedSession) { - sessionId = savedSession.sessionId; - lastSeq = savedSession.lastSeq; - log?.info( - `[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`, - ); - } - - // Queue messages per peer while still allowing cross-peer concurrency. - const msgQueue = createMessageQueue({ - accountId: account.accountId, - log, - isAborted: () => isAborted, - }); - - // Intercept plugin-level slash commands before queueing normal traffic. - const URGENT_COMMANDS = ["/stop"]; - - const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise => { - const content = (msg.content ?? "").trim(); - if (!content.startsWith("/")) { - msgQueue.enqueue(msg); - return; - } - - const contentLower = normalizeLowercaseStringOrEmpty(content); - const isUrgentCommand = URGENT_COMMANDS.some( - (cmd) => - contentLower === normalizeLowercaseStringOrEmpty(cmd) || - contentLower.startsWith(normalizeLowercaseStringOrEmpty(cmd) + " "), - ); - if (isUrgentCommand) { - log?.info( - `[qqbot:${account.accountId}] Urgent command detected: ${content.slice(0, 20)}, executing immediately`, - ); - const peerId = msgQueue.getMessagePeerId(msg); - const droppedCount = msgQueue.clearUserQueue(peerId); - if (droppedCount > 0) { - log?.info( - `[qqbot:${account.accountId}] Dropped ${droppedCount} queued messages for ${peerId} due to urgent command`, - ); - } - msgQueue.executeImmediate(msg); - return; - } - - const receivedAt = Date.now(); - const peerId = msgQueue.getMessagePeerId(msg); - - // commandAuthorized is not meaningful for pre-dispatch commands: requireAuth:true - // commands are in frameworkCommands (not in the local registry) and are never - // matched by matchSlashCommand, so the auth gate inside it never fires here. - const cmdCtx: SlashCommandContext = { - type: msg.type, - senderId: msg.senderId, - senderName: msg.senderName, - messageId: msg.messageId, - eventTimestamp: msg.timestamp, - receivedAt, - rawContent: content, - args: "", - channelId: msg.channelId, - groupOpenid: msg.groupOpenid, - accountId: account.accountId, - appId: account.appId, - accountConfig: account.config, - commandAuthorized: true, - queueSnapshot: msgQueue.getSnapshot(peerId), - }; - - try { - const reply = await matchSlashCommand(cmdCtx); - if (reply === null) { - // Not a plugin-level command. Let the normal framework path handle it. - msgQueue.enqueue(msg); - return; - } - - log?.info( - `[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`, - ); - const token = await getAccessToken(account.appId, account.clientSecret); - - // Handle either a plain-text reply or a reply with an attached file. - // Note: all current pre-dispatch commands return plain strings; the file - // path below is retained for forward-compatibility if a future requireAuth:false - // command returns a SlashCommandFileResult. - const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply; - const replyText = isFileResult ? reply.text : reply; - const replyFile = isFileResult ? reply.filePath : null; - - // Send the text portion first. - if (msg.type === "c2c") { - await sendC2CMessage(account.appId, token, msg.senderId, replyText, msg.messageId); - } else if (msg.type === "group" && msg.groupOpenid) { - await sendGroupMessage(account.appId, token, msg.groupOpenid, replyText, msg.messageId); - } else if (msg.channelId) { - await sendChannelMessage(token, msg.channelId, replyText, msg.messageId); - } else if (msg.type === "dm" && msg.guildId) { - await sendDmMessage(token, msg.guildId, replyText, msg.messageId); - } - - // Send the file attachment if the command produced one. - if (replyFile) { - try { - const targetType = - msg.type === "group" - ? "group" - : msg.type === "dm" - ? "dm" - : msg.type === "c2c" - ? "c2c" - : "channel"; - const targetId = - msg.type === "group" - ? msg.groupOpenid || msg.senderId - : msg.type === "dm" - ? msg.guildId || msg.senderId - : msg.type === "c2c" - ? msg.senderId - : msg.channelId || msg.senderId; - const mediaCtx: MediaTargetContext = { - targetType, - targetId, - account, - replyToId: msg.messageId, - logPrefix: `[qqbot:${account.accountId}]`, - }; - await sendDocument(mediaCtx, replyFile); - log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`); - } catch (fileErr) { - log?.error( - `[qqbot:${account.accountId}] Failed to send slash command file: ${String(fileErr)}`, - ); - } - } - } catch (err) { - log?.error(`[qqbot:${account.accountId}] Slash command error: ${String(err)}`); - // Fall back to the normal queue path if the slash command handler fails. - msgQueue.enqueue(msg); - } - }; - - abortSignal.addEventListener("abort", () => { - isAborted = true; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - cleanup(); - stopBackgroundTokenRefresh(account.appId); - flushKnownUsers(); - flushRefIndex(); - }); - - const cleanup = () => { - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - heartbeatInterval = null; - } - if ( - currentWs && - (currentWs.readyState === WebSocket.OPEN || currentWs.readyState === WebSocket.CONNECTING) - ) { - currentWs.close(); - } - currentWs = null; - }; - - const getReconnectDelay = () => { - const idx = Math.min(reconnectAttempts, RECONNECT_DELAYS.length - 1); - return RECONNECT_DELAYS[idx]; - }; - - const scheduleReconnect = (customDelay?: number) => { - if (isAborted || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - log?.error(`[qqbot:${account.accountId}] Max reconnect attempts reached or aborted`); - return; - } - - // Replace any pending reconnect timer with the new one. - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - - const delay = customDelay ?? getReconnectDelay(); - reconnectAttempts++; - log?.info( - `[qqbot:${account.accountId}] Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`, - ); - - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - if (!isAborted) { - void connect(); - } - }, delay); - }; - - const connect = async () => { - // Do not allow overlapping connection attempts. - if (isConnecting) { - log?.debug?.(`[qqbot:${account.accountId}] Already connecting, skip`); - return; - } - isConnecting = true; - - try { - cleanup(); - - // Clear the cached token before reconnecting when forced refresh was requested. - if (shouldRefreshToken) { - log?.info(`[qqbot:${account.accountId}] Refreshing token...`); - clearTokenCache(account.appId); - shouldRefreshToken = false; - } - - const accessToken = await getAccessToken(account.appId, account.clientSecret); - log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`); - const gatewayUrl = await getGatewayUrl(accessToken); - - log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`); - - const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } }); - currentWs = ws; - - const pluginRuntime = getQQBotRuntime(); - - // Handle one inbound gateway message after it has left the queue. - const handleMessage = async (event: { - 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; - }>; - refMsgIdx?: string; - msgIdx?: string; - }) => { - log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`); - log?.info( - `[qqbot:${account.accountId}] Processing message from ${event.senderId}: ${event.content}`, - ); - if (event.attachments?.length) { - log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`); - } - - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "inbound", - }); - - // Send typing state and keep it alive for C2C conversations only. - const isC2C = event.type === "c2c" || event.type === "dm"; - // Keep the mutable handle in an object so TypeScript does not over-narrow it. - const typing: { keepAlive: TypingKeepAlive | null } = { keepAlive: null }; - - const inputNotifyPromise: Promise = (async () => { - if (!isC2C) { - return undefined; - } - try { - let token = await getAccessToken(account.appId, account.clientSecret); - try { - const notifyResponse = await sendC2CInputNotify( - token, - event.senderId, - event.messageId, - TYPING_INPUT_SECOND, - ); - log?.info( - `[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${notifyResponse.refIdx ? `, got refIdx=${notifyResponse.refIdx}` : ""}`, - ); - typing.keepAlive = new TypingKeepAlive( - () => getAccessToken(account.appId, account.clientSecret), - () => clearTokenCache(account.appId), - event.senderId, - event.messageId, - log, - `[qqbot:${account.accountId}]`, - ); - typing.keepAlive.start(); - return notifyResponse.refIdx; - } catch (notifyErr) { - const errMsg = String(notifyErr); - if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) { - log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`); - clearTokenCache(account.appId); - token = await getAccessToken(account.appId, account.clientSecret); - const notifyResponse = await sendC2CInputNotify( - token, - event.senderId, - event.messageId, - TYPING_INPUT_SECOND, - ); - typing.keepAlive = new TypingKeepAlive( - () => getAccessToken(account.appId, account.clientSecret), - () => clearTokenCache(account.appId), - event.senderId, - event.messageId, - log, - `[qqbot:${account.accountId}]`, - ); - typing.keepAlive.start(); - return notifyResponse.refIdx; - } else { - throw notifyErr; - } - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] sendC2CInputNotify error: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - return undefined; - } - })(); - - const isGroupChat = event.type === "guild" || event.type === "group"; - // Keep `peer.id` as the raw peer identifier and let `peer.kind` carry the routing type. - const peerId = - event.type === "guild" - ? (event.channelId ?? "unknown") - : event.type === "group" - ? (event.groupOpenid ?? "unknown") - : event.senderId; - - const route = pluginRuntime.channel.routing.resolveAgentRoute({ - cfg, - channel: "qqbot", - accountId: account.accountId, - peer: { - kind: isGroupChat ? "group" : "direct", - id: peerId, - }, - }); - - const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg); - - // Static prompting lives in the QQ Bot skills. This body only carries dynamic context. - const systemPrompts: string[] = []; - if (account.systemPrompt) { - systemPrompts.push(account.systemPrompt); - } - - const processed = await processAttachments(event.attachments, { - accountId: account.accountId, - cfg, - log, - }); - const { - attachmentInfo, - imageUrls, - imageMediaTypes, - voiceAttachmentPaths, - voiceAttachmentUrls, - voiceAsrReferTexts, - voiceTranscripts, - voiceTranscriptSources, - attachmentLocalPaths, - } = processed; - - const voiceText = formatVoiceText(voiceTranscripts); - const hasAsrReferFallback = voiceTranscriptSources.includes("asr"); - - const parsedContent = parseFaceTags(event.content); - const userContent = voiceText - ? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo - : parsedContent + attachmentInfo; - - let replyToId: string | undefined; - let replyToBody: string | undefined; - let replyToSender: string | undefined; - let replyToIsQuote = false; - - if (event.refMsgIdx) { - const refEntry = getRefIndex(event.refMsgIdx); - if (refEntry) { - replyToId = event.refMsgIdx; - replyToBody = formatRefEntryForAgent(refEntry); - replyToSender = refEntry.senderName ?? refEntry.senderId; - replyToIsQuote = true; - log?.info( - `[qqbot:${account.accountId}] Quote detected: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Quote detected but refMsgIdx not in cache: ${event.refMsgIdx}`, - ); - replyToId = event.refMsgIdx; - replyToIsQuote = true; - } - } - - // Prefer the push-event msgIdx, falling back to the InputNotify refIdx. - const inputNotifyRefIdx = await inputNotifyPromise; - const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx; - if (currentMsgIdx) { - const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths); - // Attach voice transcript metadata to the matching attachment summaries. - if (attSummaries && voiceTranscripts.length > 0) { - let voiceIdx = 0; - for (const att of attSummaries) { - if (att.type === "voice" && voiceIdx < voiceTranscripts.length) { - att.transcript = voiceTranscripts[voiceIdx]; - if (voiceIdx < voiceTranscriptSources.length) { - att.transcriptSource = voiceTranscriptSources[voiceIdx]; - } - voiceIdx++; - } - } - } - setRefIndex(currentMsgIdx, { - content: parsedContent, - senderId: event.senderId, - senderName: event.senderName, - timestamp: new Date(event.timestamp).getTime(), - attachments: attSummaries, - }); - log?.info( - `[qqbot:${account.accountId}] Cached msgIdx=${currentMsgIdx} for future reference (source: ${event.msgIdx ? "message_scene.ext" : "InputNotify"})`, - ); - } - - // Body is the user-visible raw message shown in the Web UI. - const body = pluginRuntime.channel.reply.formatInboundEnvelope({ - channel: "qqbot", - from: event.senderName ?? event.senderId, - timestamp: new Date(event.timestamp).getTime(), - body: userContent, - chatType: isGroupChat ? "group" : "direct", - sender: { - id: event.senderId, - name: event.senderName, - }, - envelope: envelopeOptions, - ...(imageUrls.length > 0 ? { imageUrls } : {}), - }); - - // BodyForAgent is the full model-visible context. - const uniqueVoicePaths = [...new Set(voiceAttachmentPaths)]; - const uniqueVoiceUrls = [...new Set(voiceAttachmentUrls)]; - const uniqueVoiceAsrReferTexts = [...new Set(voiceAsrReferTexts)].filter(Boolean); - const sttTranscriptCount = voiceTranscriptSources.filter((s) => s === "stt").length; - const asrFallbackCount = voiceTranscriptSources.filter((s) => s === "asr").length; - const fallbackCount = voiceTranscriptSources.filter((s) => s === "fallback").length; - if ( - voiceAttachmentPaths.length > 0 || - voiceAttachmentUrls.length > 0 || - uniqueVoiceAsrReferTexts.length > 0 - ) { - const asrPreview = - uniqueVoiceAsrReferTexts.length > 0 ? uniqueVoiceAsrReferTexts[0].slice(0, 50) : ""; - log?.info( - `[qqbot:${account.accountId}] Voice input summary: local=${uniqueVoicePaths.length}, remote=${uniqueVoiceUrls.length}, ` + - `asrReferTexts=${uniqueVoiceAsrReferTexts.length}, transcripts=${voiceTranscripts.length}, ` + - `source(stt/asr/fallback)=${sttTranscriptCount}/${asrFallbackCount}/${fallbackCount}` + - (asrPreview - ? `, asr_preview="${asrPreview}${uniqueVoiceAsrReferTexts[0].length > 50 ? "..." : ""}"` - : ""), - ); - } - const qualifiedTarget = isGroupChat - ? event.type === "guild" - ? `qqbot:channel:${event.channelId}` - : `qqbot:group:${event.groupOpenid}` - : event.type === "dm" - ? `qqbot:dm:${event.guildId}` - : `qqbot:c2c:${event.senderId}`; - - const hasTTS = - !!resolveTTSConfig(cfg as Record) || isGlobalTTSAvailable(cfg); - - let quotePart = ""; - if (replyToIsQuote) { - if (replyToBody) { - quotePart = `[Quoted message begins]\n${replyToBody}\n[Quoted message ends]\n`; - } else { - quotePart = `[Quoted message begins]\nOriginal content unavailable\n[Quoted message ends]\n`; - } - } - - const staticParts: string[] = [`[QQBot] to=${qualifiedTarget}`]; - if (hasTTS) { - staticParts.push("voice synthesis enabled"); - } - const staticInstruction = staticParts.join(" | "); - systemPrompts.unshift(staticInstruction); - - const dynLines: string[] = []; - if (imageUrls.length > 0) { - dynLines.push(`- Images: ${imageUrls.join(", ")}`); - } - if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) { - dynLines.push(`- Voice: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`); - } - if (uniqueVoiceAsrReferTexts.length > 0) { - dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`); - } - const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n" : ""; - - const userMessage = `${quotePart}${userContent}`; - const agentBody = userContent.startsWith("/") - ? userContent - : `${systemPrompts.join("\n")}\n\n${dynamicCtx}${userMessage}`; - - log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`); - - const fromAddress = - event.type === "guild" - ? `qqbot:channel:${event.channelId}` - : event.type === "group" - ? `qqbot:group:${event.groupOpenid}` - : `qqbot:c2c:${event.senderId}`; - const toAddress = fromAddress; - - const rawAllowFrom = account.config?.allowFrom ?? []; - const normalizedAllowFrom = formatQQBotAllowFrom({ - allowFrom: rawAllowFrom, - }); - const normalizedSenderId = event.senderId.replace(/^qqbot:/i, "").toUpperCase(); - const allowAll = - normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*"); - const commandAuthorized = allowAll || normalizedAllowFrom.includes(normalizedSenderId); - - // Split local media paths from remote URLs for framework-native media handling. - const localMediaPaths: string[] = []; - const localMediaTypes: string[] = []; - const remoteMediaUrls: string[] = []; - const remoteMediaTypes: string[] = []; - for (let i = 0; i < imageUrls.length; i++) { - const u = imageUrls[i]; - const t = imageMediaTypes[i] ?? "image/png"; - if (u.startsWith("http://") || u.startsWith("https://")) { - remoteMediaUrls.push(u); - remoteMediaTypes.push(t); - } else { - localMediaPaths.push(u); - localMediaTypes.push(t); - } - } - - const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({ - Body: body, - BodyForAgent: agentBody, - RawBody: event.content, - CommandBody: event.content, - From: fromAddress, - To: toAddress, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isGroupChat ? "group" : "direct", - SenderId: event.senderId, - SenderName: event.senderName, - Provider: "qqbot", - Surface: "qqbot", - MessageSid: event.messageId, - Timestamp: new Date(event.timestamp).getTime(), - OriginatingChannel: "qqbot", - OriginatingTo: toAddress, - QQChannelId: event.channelId, - QQGuildId: event.guildId, - QQGroupOpenid: event.groupOpenid, - QQVoiceAsrReferAvailable: hasAsrReferFallback, - QQVoiceTranscriptSources: voiceTranscriptSources, - QQVoiceAttachmentPaths: uniqueVoicePaths, - QQVoiceAttachmentUrls: uniqueVoiceUrls, - QQVoiceAsrReferTexts: uniqueVoiceAsrReferTexts, - QQVoiceInputStrategy: "prefer_audio_stt_then_asr_fallback", - CommandAuthorized: commandAuthorized, - ...(localMediaPaths.length > 0 - ? { - MediaPaths: localMediaPaths, - MediaPath: localMediaPaths[0], - MediaTypes: localMediaTypes, - MediaType: localMediaTypes[0], - } - : {}), - ...(remoteMediaUrls.length > 0 - ? { - MediaUrls: remoteMediaUrls, - MediaUrl: remoteMediaUrls[0], - } - : {}), - ...(replyToId - ? { - ReplyToId: replyToId, - ReplyToBody: replyToBody, - ReplyToSender: replyToSender, - ReplyToIsQuote: replyToIsQuote, - } - : {}), - }); - - const replyTarget: MessageTarget = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - guildId: event.guildId, - groupOpenid: event.groupOpenid, - }; - const replyCtx: ReplyContext = { target: replyTarget, account, cfg, log }; - - const sendWithRetry = (sendFn: (token: string) => Promise) => - sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId); - - const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText); - - try { - const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig( - cfg, - route.agentId, - ); - - let hasResponse = false; - let hasBlockResponse = false; - let toolDeliverCount = 0; - const toolTexts: string[] = []; - const toolMediaUrls: string[] = []; - let toolFallbackSent = false; - const responseTimeout = 120000; - const toolOnlyTimeout = 60000; - const maxToolRenewals = 3; - let toolRenewalCount = 0; - let timeoutId: ReturnType | null = null; - let toolOnlyTimeoutId: ReturnType | null = null; - - const sendToolFallback = async (): Promise => { - if (toolMediaUrls.length > 0) { - log?.info( - `[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`, - ); - const mediaTimeout = 45000; // Per-media timeout: 45s. - for (const mediaUrl of toolMediaUrls) { - const ac = new AbortController(); - try { - const result = await Promise.race([ - sendMediaAuto({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - }).then((r) => { - if (ac.signal.aborted) { - log?.info( - `[qqbot:${account.accountId}] Tool fallback sendMedia completed after timeout, suppressing late delivery`, - ); - return { - channel: "qqbot", - error: "Media send completed after timeout (suppressed)", - } as typeof r; - } - return r; - }), - new Promise<{ channel: string; error: string }>((resolve) => - setTimeout(() => { - ac.abort(); - resolve({ - channel: "qqbot", - error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)`, - }); - }, mediaTimeout), - ), - ]); - if (result.error) { - log?.error( - `[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`, - ); - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - } - return; - } - if (toolTexts.length > 0) { - const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000); - log?.info( - `[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`, - ); - await sendErrorMessage(text); - return; - } - log?.info( - `[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`, - ); - }; - - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - if (!hasResponse) { - reject(new Error("Response timeout")); - } - }, responseTimeout); - }); - - const dispatchPromise = - pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ - ctx: ctxPayload, - cfg, - dispatcherOptions: { - responsePrefix: messagesConfig.responsePrefix, - deliver: async ( - payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, - info: { kind: string }, - ) => { - hasResponse = true; - - log?.info( - `[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`, - ); - - if (info.kind === "tool") { - toolDeliverCount++; - const toolText = (payload.text ?? "").trim(); - if (toolText) { - toolTexts.push(toolText); - } - if (payload.mediaUrls?.length) { - toolMediaUrls.push(...payload.mediaUrls); - } - if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) { - toolMediaUrls.push(payload.mediaUrl); - } - log?.info( - `[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`, - ); - - if (hasBlockResponse && toolMediaUrls.length > 0) { - log?.info( - `[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`, - ); - const urlsToSend = [...toolMediaUrls]; - toolMediaUrls.length = 0; - for (const mediaUrl of urlsToSend) { - try { - const result = await sendMediaAuto({ - to: qualifiedTarget, - text: "", - mediaUrl, - accountId: account.accountId, - replyToId: event.messageId, - account, - }); - if (result.error) { - log?.error( - `[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`, - ); - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Tool media immediate forward failed: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - } - return; - } - - if (toolFallbackSent) { - return; - } - - if (toolOnlyTimeoutId) { - if (toolRenewalCount < maxToolRenewals) { - clearTimeout(toolOnlyTimeoutId); - toolRenewalCount++; - log?.info( - `[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`, - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`, - ); - return; - } - } - toolOnlyTimeoutId = setTimeout(async () => { - if (!hasBlockResponse && !toolFallbackSent) { - toolFallbackSent = true; - log?.error( - `[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`, - ); - try { - await sendToolFallback(); - } catch (sendErr) { - log?.error( - `[qqbot:${account.accountId}] Failed to send tool-only fallback: ${ - sendErr instanceof Error ? sendErr.message : JSON.stringify(sendErr) - }`, - ); - } - } - }, toolOnlyTimeout); - return; - } - - hasBlockResponse = true; - typing.keepAlive?.stop(); - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - if (toolDeliverCount > 0) { - log?.info( - `[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`, - ); - } - - const quoteRef = event.msgIdx; - let quoteRefUsed = false; - const consumeQuoteRef = (): string | undefined => { - if (quoteRef && !quoteRefUsed) { - quoteRefUsed = true; - return quoteRef; - } - return undefined; - }; - - let replyText = payload.text ?? ""; - - const deliverEvent: DeliverEventContext = { - type: event.type, - senderId: event.senderId, - messageId: event.messageId, - channelId: event.channelId, - groupOpenid: event.groupOpenid, - msgIdx: event.msgIdx, - }; - const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log }; - - const mediaResult = await parseAndSendMediaTags( - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - ); - if (mediaResult.handled) { - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - return; - } - replyText = mediaResult.normalizedText; - - const recordOutboundActivity = () => - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - const handled = await handleStructuredPayload( - replyCtx, - replyText, - recordOutboundActivity, - ); - if (handled) { - return; - } - - await sendPlainReply( - payload, - replyText, - deliverEvent, - deliverActx, - sendWithRetry, - consumeQuoteRef, - toolMediaUrls, - ); - - pluginRuntime.channel.activity.record({ - channel: "qqbot", - accountId: account.accountId, - direction: "outbound", - }); - }, - onError: async (err: unknown) => { - const errMsg = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : JSON.stringify(err); - log?.error(`[qqbot:${account.accountId}] Dispatch error: ${errMsg}`); - hasResponse = true; - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) { - log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`); - } else { - log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`); - } - }, - }, - replyOptions: { - disableBlockStreaming: account.config.streaming?.mode === "off", - }, - }); - - try { - await Promise.race([dispatchPromise, timeoutPromise]); - } catch { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (!hasResponse) { - log?.error(`[qqbot:${account.accountId}] No response within timeout`); - } - } finally { - if (toolOnlyTimeoutId) { - clearTimeout(toolOnlyTimeoutId); - toolOnlyTimeoutId = null; - } - if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) { - toolFallbackSent = true; - log?.error( - `[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`, - ); - await sendToolFallback(); - } - } - } catch (err) { - const errMsg = - err instanceof Error - ? err.message - : typeof err === "string" - ? err - : JSON.stringify(err); - log?.error(`[qqbot:${account.accountId}] Message processing failed: ${errMsg}`); - } finally { - typing.keepAlive?.stop(); - } - }; - - ws.on("open", () => { - log?.info(`[qqbot:${account.accountId}] WebSocket connected`); - isConnecting = false; - reconnectAttempts = 0; - lastConnectTime = Date.now(); - msgQueue.startProcessor(handleMessage); - startBackgroundTokenRefresh(account.appId, account.clientSecret, { - log: log as { - info: (msg: string) => void; - error: (msg: string) => void; - debug?: (msg: string) => void; - }, - }); - }); - - ws.on("message", async (data) => { - try { - const rawData = decodeGatewayMessageData(data); - const payload = JSON.parse(rawData) as WSPayload; - const { op, d, s, t } = payload; - - if (s) { - lastSeq = s; - if (sessionId) { - saveSession({ - sessionId, - lastSeq, - lastConnectedAt: lastConnectTime, - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - } - } - - log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`); - - switch (op) { - case 10: // Hello - log?.info(`[qqbot:${account.accountId}] Hello received`); - - if (sessionId && lastSeq !== null) { - log?.info(`[qqbot:${account.accountId}] Attempting to resume session ${sessionId}`); - ws.send( - JSON.stringify({ - op: 6, // Resume - d: { - token: `QQBot ${accessToken}`, - session_id: sessionId, - seq: lastSeq, - }, - }), - ); - } else { - log?.info( - `[qqbot:${account.accountId}] Sending identify with intents: ${FULL_INTENTS} (${FULL_INTENTS_DESC})`, - ); - ws.send( - JSON.stringify({ - op: 2, - d: { - token: `QQBot ${accessToken}`, - intents: FULL_INTENTS, - shard: [0, 1], - }, - }), - ); - } - - const interval = (d as { heartbeat_interval: number }).heartbeat_interval; - if (heartbeatInterval) { - clearInterval(heartbeatInterval); - } - heartbeatInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ op: 1, d: lastSeq })); - log?.debug?.(`[qqbot:${account.accountId}] Heartbeat sent`); - } - }, interval); - break; - - case 0: // Dispatch - log?.info( - `[qqbot:${account.accountId}] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`, - ); - if (t === "READY") { - const readyData = d as { session_id: string }; - sessionId = readyData.session_id; - log?.info( - `[qqbot:${account.accountId}] Ready with ${FULL_INTENTS_DESC}, session: ${sessionId}`, - ); - saveSession({ - sessionId, - lastSeq, - lastConnectedAt: Date.now(), - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - onReady?.(d); - } else if (t === "RESUMED") { - log?.info(`[qqbot:${account.accountId}] Session resumed`); - onReady?.(d); // Notify the framework so health monitoring sees the connection as recovered. - if (sessionId) { - saveSession({ - sessionId, - lastSeq, - lastConnectedAt: Date.now(), - intentLevelIndex: 0, - accountId: account.accountId, - savedAt: Date.now(), - appId: account.appId, - }); - } - } else if (t === "C2C_MESSAGE_CREATE") { - const event = d as C2CMessageEvent; - recordKnownUser({ - openid: event.author.user_openid, - type: "c2c", - accountId: account.accountId, - }); - const c2cRefs = parseRefIndices(event.message_scene?.ext); - void trySlashCommandOrEnqueue({ - type: "c2c", - senderId: event.author.user_openid, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - attachments: event.attachments, - refMsgIdx: c2cRefs.refMsgIdx, - msgIdx: c2cRefs.msgIdx, - }); - } else if (t === "AT_MESSAGE_CREATE") { - const event = d as GuildMessageEvent; - // Guild users cannot receive proactive C2C messages — skip known-user recording. - const guildRefs = parseRefIndices(readOptionalMessageSceneExt(event)); - void trySlashCommandOrEnqueue({ - type: "guild", - senderId: event.author.id, - senderName: event.author.username, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - channelId: event.channel_id, - guildId: event.guild_id, - attachments: event.attachments, - refMsgIdx: guildRefs.refMsgIdx, - msgIdx: guildRefs.msgIdx, - }); - } else if (t === "DIRECT_MESSAGE_CREATE") { - const event = d as GuildMessageEvent; - // DM author.id is a guild-scoped ID, not a C2C openid — skip known-user recording. - const dmRefs = parseRefIndices(readOptionalMessageSceneExt(event)); - void trySlashCommandOrEnqueue({ - type: "dm", - senderId: event.author.id, - senderName: event.author.username, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - guildId: event.guild_id, - attachments: event.attachments, - refMsgIdx: dmRefs.refMsgIdx, - msgIdx: dmRefs.msgIdx, - }); - } else if (t === "GROUP_AT_MESSAGE_CREATE") { - const event = d as GroupMessageEvent; - recordKnownUser({ - openid: event.author.member_openid, - type: "group", - groupOpenid: event.group_openid, - accountId: account.accountId, - }); - const groupRefs = parseRefIndices(event.message_scene?.ext); - void trySlashCommandOrEnqueue({ - type: "group", - senderId: event.author.member_openid, - content: event.content, - messageId: event.id, - timestamp: event.timestamp, - groupOpenid: event.group_openid, - attachments: event.attachments, - refMsgIdx: groupRefs.refMsgIdx, - msgIdx: groupRefs.msgIdx, - }); - } - break; - - case 11: // Heartbeat ACK - log?.debug?.(`[qqbot:${account.accountId}] Heartbeat ACK`); - break; - - case 7: // Reconnect - log?.info(`[qqbot:${account.accountId}] Server requested reconnect`); - cleanup(); - scheduleReconnect(); - break; - - case 9: // Invalid Session - const canResume = d as boolean; - log?.error( - `[qqbot:${account.accountId}] Invalid session (${FULL_INTENTS_DESC}), can resume: ${canResume}, raw: ${rawData}`, - ); - - if (!canResume) { - sessionId = null; - lastSeq = null; - clearSession(account.accountId); - shouldRefreshToken = true; - log?.info( - `[qqbot:${account.accountId}] Will refresh token and retry with full intents (${FULL_INTENTS_DESC})`, - ); - } - cleanup(); - scheduleReconnect(3000); - break; - } - } catch (err) { - log?.error( - `[qqbot:${account.accountId}] Message parse error: ${ - err instanceof Error ? err.message : JSON.stringify(err) - }`, - ); - } - }); - - ws.on("close", (code, reason) => { - log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`); - isConnecting = false; // Release the connect lock. - - if (code === 4914 || code === 4915) { - log?.error( - `[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`, - ); - cleanup(); - return; - } - - if (code === 4004) { - log?.info( - `[qqbot:${account.accountId}] Invalid token (4004), will refresh token and reconnect`, - ); - shouldRefreshToken = true; - cleanup(); - if (!isAborted) { - scheduleReconnect(); - } - return; - } - - if (code === 4008) { - log?.info( - `[qqbot:${account.accountId}] Rate limited (4008), waiting ${RATE_LIMIT_DELAY}ms before reconnect`, - ); - cleanup(); - if (!isAborted) { - scheduleReconnect(RATE_LIMIT_DELAY); - } - return; - } - - if (code === 4006 || code === 4007 || code === 4009) { - const codeDesc: Record = { - 4006: "session no longer valid", - 4007: "invalid seq on resume", - 4009: "session timed out", - }; - log?.info( - `[qqbot:${account.accountId}] Error ${code} (${codeDesc[code]}), will re-identify`, - ); - sessionId = null; - lastSeq = null; - clearSession(account.accountId); - shouldRefreshToken = true; - } else if (code >= 4900 && code <= 4913) { - log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`); - sessionId = null; - lastSeq = null; - clearSession(account.accountId); - shouldRefreshToken = true; - } - - const connectionDuration = Date.now() - lastConnectTime; - if (connectionDuration < QUICK_DISCONNECT_THRESHOLD && lastConnectTime > 0) { - quickDisconnectCount++; - log?.info( - `[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`, - ); - - if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) { - log?.error( - `[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`, - ); - log?.error( - `[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`, - ); - quickDisconnectCount = 0; - cleanup(); - if (!isAborted && code !== 1000) { - scheduleReconnect(RATE_LIMIT_DELAY); - } - return; - } - } else { - quickDisconnectCount = 0; - } - - cleanup(); - - if (!isAborted && code !== 1000) { - scheduleReconnect(); - } - }); - - ws.on("error", (err) => { - log?.error(`[qqbot:${account.accountId}] WebSocket error: ${err.message}`); - onError?.(err); - }); - } catch (err) { - isConnecting = false; - const errMsg = err instanceof Error ? err.message : (JSON.stringify(err) ?? "Unknown error"); - log?.error(`[qqbot:${account.accountId}] Connection failed: ${errMsg}`); - // Back off more aggressively after rate-limit failures. - if (errMsg.includes("Too many requests") || errMsg.includes("100001")) { - log?.info( - `[qqbot:${account.accountId}] Rate limited, waiting ${RATE_LIMIT_DELAY}ms before retry`, - ); - scheduleReconnect(RATE_LIMIT_DELAY); - } else { - scheduleReconnect(); - } - } - }; - - await connect(); - - return new Promise((resolve) => { - abortSignal.addEventListener("abort", () => resolve()); - }); -} diff --git a/extensions/qqbot/src/outbound-deliver.test.ts b/extensions/qqbot/src/outbound-deliver.test.ts deleted file mode 100644 index 56dd0c48ad7..00000000000 --- a/extensions/qqbot/src/outbound-deliver.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -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( - "beforehttps://example.com/a.pngafter", - 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(); - }); - }); -}); diff --git a/extensions/qqbot/src/outbound.security.test.ts b/extensions/qqbot/src/outbound.security.test.ts deleted file mode 100644 index 6c922e949e2..00000000000 --- a/extensions/qqbot/src/outbound.security.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -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"); - }); -}); diff --git a/extensions/qqbot/src/proactive.test.ts b/extensions/qqbot/src/proactive.test.ts deleted file mode 100644 index 1b46cebfff2..00000000000 --- a/extensions/qqbot/src/proactive.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -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"); - }); -}); diff --git a/extensions/qqbot/src/proactive.ts b/extensions/qqbot/src/proactive.ts deleted file mode 100644 index d26b16f2a1e..00000000000 --- a/extensions/qqbot/src/proactive.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * 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 { - return getKnownUserImpl(accountId, openid, type as "c2c" | "group"); -} - -/** List known users with optional filtering and sorting (adapter). */ -export function listKnownUsers( - options?: ListKnownUsersOptions, -): ReturnType { - 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 { - 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> { - 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 { - 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. - }; -} diff --git a/extensions/qqbot/src/reply-dispatcher.test.ts b/extensions/qqbot/src/reply-dispatcher.test.ts deleted file mode 100644 index 9731883e1b1..00000000000 --- a/extensions/qqbot/src/reply-dispatcher.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -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, - ); - }); -}); diff --git a/extensions/qqbot/src/reply-dispatcher.ts b/extensions/qqbot/src/reply-dispatcher.ts deleted file mode 100644 index 2c879cf2b55..00000000000 --- a/extensions/qqbot/src/reply-dispatcher.ts +++ /dev/null @@ -1,714 +0,0 @@ -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( - appId: string, - clientSecret: string, - sendFn: (token: string) => Promise, - log?: ReplyContext["log"], - accountId?: string, -): Promise { - 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 { - 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 { - 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 { - 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. - -type StructuredPayloadMediaType = "image" | "video" | "file"; - -function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string { - return mediaType[0].toUpperCase() + mediaType.slice(1); -} - -function validateStructuredPayloadLocalPath( - ctx: ReplyContext, - payloadPath: string, - mediaType: StructuredPayloadMediaType, -): 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 resolveStructuredPayloadPath( - ctx: ReplyContext, - payload: MediaPayload, - mediaType: StructuredPayloadMediaType, -): { path: string; isHttpUrl: boolean } | null { - const originalPath = payload.path ?? ""; - const normalizedPath = normalizePath(originalPath); - const isHttpUrl = isRemoteHttpUrl(normalizedPath); - const resolvedPath = isHttpUrl - ? normalizedPath - : validateStructuredPayloadLocalPath(ctx, originalPath, mediaType); - if (!resolvedPath) { - return null; - } - if (!resolvedPath.trim()) { - ctx.log?.error( - `[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`, - ); - return null; - } - return { path: resolvedPath, isHttpUrl }; -} - -function logUnsupportedStructuredMediaTarget( - ctx: ReplyContext, - mediaType: Exclude, -): void { - const label = formatMediaTypeLabel(mediaType); - if (ctx.target.type === "dm") { - ctx.log?.error(`[qqbot:${ctx.account.accountId}] ${label} not supported in DM`); - } else if (ctx.target.channelId) { - ctx.log?.error(`[qqbot:${ctx.account.accountId}] ${label} not supported in channel`); - } -} - -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 ""; - } - - 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 ""; - } -} - -async function readStructuredPayloadLocalFile(filePath: string): Promise { - 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 { - 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 = { - ".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 { - 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); - 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 { - const { target, account, log } = ctx; - try { - const resolved = resolveStructuredPayloadPath(ctx, payload, "video"); - if (!resolved) { - return; - } - const videoPath = resolved.path; - const isHttpUrl = resolved.isHttpUrl; - - 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 { - logUnsupportedStructuredMediaTarget(ctx, "video"); - } - } 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 { - logUnsupportedStructuredMediaTarget(ctx, "video"); - } - } - }, - 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 { - const { target, account, log } = ctx; - try { - const resolved = resolveStructuredPayloadPath(ctx, payload, "file"); - if (!resolved) { - return; - } - const filePath = resolved.path; - const isHttpUrl = resolved.isHttpUrl; - - 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 { - logUnsupportedStructuredMediaTarget(ctx, "file"); - } - } 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 { - logUnsupportedStructuredMediaTarget(ctx, "file"); - } - } - }, - 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}`); - } -} diff --git a/extensions/qqbot/src/runtime.ts b/extensions/qqbot/src/runtime.ts deleted file mode 100644 index 4d2db1314be..00000000000 --- a/extensions/qqbot/src/runtime.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; - -const { setRuntime: setQQBotRuntime, getRuntime: getQQBotRuntime } = - createPluginRuntimeStore({ - pluginId: "qqbot", - errorMessage: "QQBot runtime not initialized", - }); -export { getQQBotRuntime, setQQBotRuntime }; diff --git a/extensions/qqbot/src/session-store.test.ts b/extensions/qqbot/src/session-store.test.ts deleted file mode 100644 index e96151a68cd..00000000000 --- a/extensions/qqbot/src/session-store.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 { - 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 { - 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", - }); - }); -}); diff --git a/extensions/qqbot/src/setup-surface.ts b/extensions/qqbot/src/setup-surface.ts deleted file mode 100644 index 18889e51355..00000000000 --- a/extensions/qqbot/src/setup-surface.ts +++ /dev/null @@ -1,177 +0,0 @@ -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"; -type QQBotSetupCredentialState = { - accountConfigured: boolean; - hasConfiguredSecretValue: boolean; - resolvedAppId?: string; - resolvedClientSecret?: string; -}; - -function resolveQQBotSetupCredentialState( - cfg: OpenClawConfig, - accountId: string, -): QQBotSetupCredentialState { - const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); - const hasConfiguredSecretValue = Boolean( - hasConfiguredSecretInput(resolved.config.clientSecret) || - normalizeOptionalString(resolved.config.clientSecretFile) || - resolved.clientSecret, - ); - return { - accountConfigured: Boolean(resolved.appId && hasConfiguredSecretValue), - hasConfiguredSecretValue, - resolvedAppId: resolved.appId || undefined, - resolvedClientSecret: resolved.clientSecret || undefined, - }; -} - -/** - * 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 | undefined) }; - - const clearField = (entry: Record) => { - 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> | 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 state = resolveQQBotSetupCredentialState(cfg, accountId); - return { - accountConfigured: state.accountConfigured, - hasConfiguredValue: Boolean(state.resolvedAppId), - resolvedValue: state.resolvedAppId, - 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 state = resolveQQBotSetupCredentialState(cfg, accountId); - return { - accountConfigured: state.accountConfigured, - hasConfiguredValue: state.hasConfiguredSecretValue, - resolvedValue: state.resolvedClientSecret, - 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), -}; diff --git a/extensions/qqbot/src/setup.test.ts b/extensions/qqbot/src/setup.test.ts deleted file mode 100644 index 4b0572bee1c..00000000000 --- a/extensions/qqbot/src/setup.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -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 { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.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 = makeQqbotSecretRefConfig(); - - 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: makeQqbotDefaultAccountConfig(), - accountId: undefined, - } as never), - ).toBe("bot2"); - }); -}); diff --git a/extensions/qqbot/src/slash-commands.test.ts b/extensions/qqbot/src/slash-commands.test.ts deleted file mode 100644 index c1de7aaef64..00000000000 --- a/extensions/qqbot/src/slash-commands.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -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 { - 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("未找到日志文件"); - }); -}); diff --git a/extensions/qqbot/src/slash-commands.ts b/extensions/qqbot/src/slash-commands.ts deleted file mode 100644 index c75c2c99593..00000000000 --- a/extensions/qqbot/src/slash-commands.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * 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 { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared"; -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); - -// Read the package version from package.json. -const PLUGIN_VERSION = readPluginPackageVersion({ require }); - -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; -} - -/** Framework command definition for commands that require authorization. */ -export interface QQBotFrameworkCommand { - name: string; - description: string; - usage?: string; - handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise; -} - -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 = 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 = 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(` ${cmd.description}`); - } - for (const [name, cmd] of frameworkCommands) { - lines.push(` ${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(); - - 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([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(); - 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(); - - 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 { - 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; -} diff --git a/extensions/qqbot/src/text-utils.ts b/extensions/qqbot/src/text-utils.ts deleted file mode 100644 index e05e110eb15..00000000000 --- a/extensions/qqbot/src/text-utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -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); -} diff --git a/extensions/qqbot/src/tools/channel.ts b/extensions/qqbot/src/tools/channel.ts deleted file mode 100644 index 87b61414a6c..00000000000 --- a/extensions/qqbot/src/tools/channel.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { getAccessToken } from "../api.js"; -import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; -import { debugError, debugLog } from "../utils/debug-log.js"; -import { jsonToolResult as json } from "./result.js"; - -const API_BASE = "https://api.sgroup.qq.com"; -const DEFAULT_TIMEOUT_MS = 30000; - -interface ChannelApiParams { - method: string; - path: string; - body?: Record; - query?: Record; -} - -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 buildUrl(path: string, query?: Record): 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 = { - 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; - let release = async () => {}; - try { - const guarded = await fetchWithSsrFGuard({ - url, - init: fetchOptions, - auditContext: `qqbot.channel-api${p.path}`, - }); - res = guarded.response; - release = guarded.release; - } 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}`); - - try { - 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, - }); - } finally { - await release(); - } - } catch (err) { - return json({ - error: formatErrorMessage(err), - path: p.path, - }); - } - }, - }, - { name: "qqbot_channel_api" }, - ); -} diff --git a/extensions/qqbot/src/tools/remind.ts b/extensions/qqbot/src/tools/remind.ts deleted file mode 100644 index 5130ed6a82c..00000000000 --- a/extensions/qqbot/src/tools/remind.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { jsonToolResult as json } from "./result.js"; - -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 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" }, - ); -} diff --git a/extensions/qqbot/src/types.ts b/extensions/qqbot/src/types.ts index 77aa2c89b9d..391f6d7539d 100644 --- a/extensions/qqbot/src/types.ts +++ b/extensions/qqbot/src/types.ts @@ -1,4 +1,7 @@ import type { SecretInput } from "openclaw/plugin-sdk/secret-input"; +import type { QQBotDmPolicy, QQBotGroupPolicy } from "./engine/access/index.js"; + +export type { QQBotDmPolicy, QQBotGroupPolicy }; /** QQ Bot base config. */ export interface QQBotConfig { @@ -22,6 +25,15 @@ export interface ResolvedQQBotAccount { config: QQBotAccountConfig; } +/** QQBot-native exec approval delivery + approver authorization. */ +export interface QQBotExecApprovalConfig { + enabled?: boolean | "auto"; + approvers?: string[]; + agentFilter?: string[]; + sessionFilter?: string[]; + target?: "dm" | "channel" | "both"; +} + /** QQ Bot account config from user settings. */ export interface QQBotAccountConfig { enabled?: boolean; @@ -29,11 +41,45 @@ export interface QQBotAccountConfig { appId?: string; clientSecret?: SecretInput; clientSecretFile?: string; + /** + * Sender allowlist for direct-message access control and command + * authorization. Entries accept raw openids, `qqbot:OPENID` prefixed + * form, and the `"*"` wildcard. Matching is case-insensitive. + * + * Semantics depend on {@link dmPolicy}: + * - `dmPolicy="open"` (default when allowFrom is empty or contains `"*"`) + * — everyone can DM the bot; the list only influences command gating. + * - `dmPolicy="allowlist"` (default when a non-wildcard list is configured) + * — only listed openids may DM the bot; other DMs are dropped. + * - `dmPolicy="disabled"` — all DMs are dropped regardless of this list. + * + * For group access, see {@link groupAllowFrom} / {@link groupPolicy}. + */ allowFrom?: string[]; + /** + * Group-scoped sender allowlist. If omitted, group access falls back to + * {@link allowFrom}. Set explicitly when the group whitelist needs to + * differ from the DM whitelist. + */ + groupAllowFrom?: string[]; + /** + * DM access policy. Defaults: + * - omitted + allowFrom empty/wildcard → `"open"` + * - omitted + allowFrom non-wildcard → `"allowlist"` + */ + dmPolicy?: QQBotDmPolicy; + /** + * Group access policy. Defaults mirror {@link dmPolicy}: if either + * `groupAllowFrom` or `allowFrom` has a non-wildcard entry the policy + * is `"allowlist"`, otherwise `"open"`. + */ + groupPolicy?: QQBotGroupPolicy; /** Optional system prompt prepended to user messages. */ systemPrompt?: string; /** Whether markdown output is enabled. Defaults to true. */ markdownSupport?: boolean; + /** QQBot-native exec approval delivery + approver authorization. */ + execApprovals?: QQBotExecApprovalConfig; /** * @deprecated Use audioFormatPolicy.uploadDirectFormats instead. * Legacy list of formats that can upload directly without SILK conversion. diff --git a/extensions/qqbot/src/types/silk-wasm.d.ts b/extensions/qqbot/src/types/silk-wasm.d.ts deleted file mode 100644 index 834e1bf082f..00000000000 --- a/extensions/qqbot/src/types/silk-wasm.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -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; - - export function encode(input: Uint8Array, sampleRate: number): Promise; -} diff --git a/extensions/qqbot/src/utils/debug-log.ts b/extensions/qqbot/src/utils/debug-log.ts deleted file mode 100644 index a7276389682..00000000000 --- a/extensions/qqbot/src/utils/debug-log.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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); - } -} diff --git a/extensions/qqbot/src/utils/text-parsing.ts b/extensions/qqbot/src/utils/text-parsing.ts deleted file mode 100644 index 5cec13dda9a..00000000000 --- a/extensions/qqbot/src/utils/text-parsing.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { estimateBase64DecodedBytes } from "openclaw/plugin-sdk/media-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import type { RefAttachmentSummary } from "../ref-index-store.js"; - -const MAX_FACE_EXT_BYTES = 64 * 1024; - -/** Replace QQ face tags with readable text labels. */ -export function parseFaceTags(text: string | undefined | null): string { - if (!text) { - return ""; - } - - return text.replace(//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, -): 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, - }; - }); -} diff --git a/package.json b/package.json index 930ef9745c2..033a8fa4aba 100644 --- a/package.json +++ b/package.json @@ -1532,6 +1532,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", "@sinclair/typebox": "0.34.49", + "@tencent-connect/qqbot-connector": "^1.1.0", "ajv": "^8.18.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeac6f62d89..e316acc2d8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@sinclair/typebox': specifier: 0.34.49 version: 0.34.49 + '@tencent-connect/qqbot-connector': + specifier: ^1.1.0 + version: 1.1.0 ajv: specifier: ^8.18.0 version: 8.18.0 @@ -1043,6 +1046,9 @@ importers: extensions/qqbot: dependencies: + '@tencent-connect/qqbot-connector': + specifier: ^1.1.0 + version: 1.1.0 mpg123-decoder: specifier: ^1.0.3 version: 1.0.3 @@ -4091,6 +4097,10 @@ packages: '@telegraf/types@7.1.0': resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@tencent-connect/qqbot-connector@1.1.0': + resolution: {integrity: sha512-3nQ2mdyzPRKpBHjd3QiKZDwNzw1F7fBN+rSq8Xms2gg+JWZR4SY2Zdf+doqTyXdyVjG4Y0QM7IA4U42zT9xxzw==} + engines: {node: '>=18.0.0'} + '@thi.ng/bitstream@2.4.46': resolution: {integrity: sha512-p2cZshqkY/YX8EtNZEw29wbNF2vAJfi6A+yTkLUzERMpYIIdQz6bIt8rSJFyPqZcB5cI+tauXWwBXFbbsXuqKg==} engines: {node: '>=18'} @@ -11062,6 +11072,10 @@ snapshots: '@telegraf/types@7.1.0': {} + '@tencent-connect/qqbot-connector@1.1.0': + dependencies: + qrcode-terminal: 0.12.0 + '@thi.ng/bitstream@2.4.46': dependencies: '@thi.ng/errors': 2.6.8 diff --git a/scripts/check-no-raw-channel-fetch.mjs b/scripts/check-no-raw-channel-fetch.mjs index c8a7dbf6327..5c9b8dcec8b 100644 --- a/scripts/check-no-raw-channel-fetch.mjs +++ b/scripts/check-no-raw-channel-fetch.mjs @@ -54,11 +54,10 @@ const allowedRawFetchCallsites = new Set([ bundledPluginCallsite("qa-lab", "web/src/app.ts", 21), bundledPluginCallsite("qa-lab", "web/src/app.ts", 29), bundledPluginCallsite("qa-lab", "web/src/app.ts", 37), - bundledPluginCallsite("qqbot", "src/api.ts", 102), - bundledPluginCallsite("qqbot", "src/api.ts", 237), - bundledPluginCallsite("qqbot", "src/stt.ts", 81), - bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180), - bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377), + bundledPluginCallsite("qqbot", "src/engine/api/api-client.ts", 108), + bundledPluginCallsite("qqbot", "src/engine/api/token.ts", 211), + bundledPluginCallsite("qqbot", "src/engine/tools/channel-api.ts", 178), + bundledPluginCallsite("qqbot", "src/engine/utils/stt.ts", 87), bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224), bundledPluginCallsite("slack", "src/monitor/media.ts", 99), bundledPluginCallsite("slack", "src/monitor/media.ts", 118), diff --git a/src/canvas-host/a2ui/a2ui.bundle.js b/src/canvas-host/a2ui/a2ui.bundle.js index b9984a59141..4193fb64411 100644 --- a/src/canvas-host/a2ui/a2ui.bundle.js +++ b/src/canvas-host/a2ui/a2ui.bundle.js @@ -1,11 +1,11 @@ -var __defProp$2 = Object.defineProperty; +var __defProp$1 = Object.defineProperty; var __exportAll = (all, no_symbols) => { let target = {}; - for (var name in all) __defProp$2(target, name, { + for (var name in all) __defProp$1(target, name, { get: all[name], enumerable: true }); - if (!no_symbols) __defProp$2(target, Symbol.toStringTag, { value: "Module" }); + if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" }); return target; }; /** @@ -1889,943 +1889,6 @@ var A2uiMessageProcessor = class A2uiMessageProcessor { return value; } }; -var __defProp$1 = Object.defineProperty; -var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { - enumerable: true, - configurable: true, - writable: true, - value -}) : obj[key] = value; -var __publicField$1 = (obj, key, value) => { - __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value); - return value; -}; -var __accessCheck$1 = (obj, member, msg) => { - if (!member.has(obj)) throw TypeError("Cannot " + msg); -}; -var __privateIn$1 = (member, obj) => { - if (Object(obj) !== obj) throw TypeError("Cannot use the \"in\" operator on this value"); - return member.has(obj); -}; -var __privateAdd$1 = (obj, member, value) => { - if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); - member instanceof WeakSet ? member.add(obj) : member.set(obj, value); -}; -var __privateMethod$1 = (obj, member, method) => { - __accessCheck$1(obj, member, "access private method"); - return method; -}; -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function defaultEquals$1(a, b) { - return Object.is(a, b); -} -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -let activeConsumer$1 = null; -let inNotificationPhase$1 = false; -let epoch$1 = 1; -const SIGNAL$1 = /* @__PURE__ */ Symbol("SIGNAL"); -function setActiveConsumer$1(consumer) { - const prev = activeConsumer$1; - activeConsumer$1 = consumer; - return prev; -} -function getActiveConsumer$1() { - return activeConsumer$1; -} -function isInNotificationPhase$1() { - return inNotificationPhase$1; -} -const REACTIVE_NODE$1 = { - version: 0, - lastCleanEpoch: 0, - dirty: false, - producerNode: void 0, - producerLastReadVersion: void 0, - producerIndexOfThis: void 0, - nextProducerIndex: 0, - liveConsumerNode: void 0, - liveConsumerIndexOfThis: void 0, - consumerAllowSignalWrites: false, - consumerIsAlwaysLive: false, - producerMustRecompute: () => false, - producerRecomputeValue: () => {}, - consumerMarkedDirty: () => {}, - consumerOnSignalRead: () => {} -}; -function producerAccessed$1(node) { - if (inNotificationPhase$1) throw new Error(typeof ngDevMode !== "undefined" && ngDevMode ? `Assertion error: signal read during notification phase` : ""); - if (activeConsumer$1 === null) return; - activeConsumer$1.consumerOnSignalRead(node); - const idx = activeConsumer$1.nextProducerIndex++; - assertConsumerNode$1(activeConsumer$1); - if (idx < activeConsumer$1.producerNode.length && activeConsumer$1.producerNode[idx] !== node) { - if (consumerIsLive$1(activeConsumer$1)) { - const staleProducer = activeConsumer$1.producerNode[idx]; - producerRemoveLiveConsumerAtIndex$1(staleProducer, activeConsumer$1.producerIndexOfThis[idx]); - } - } - if (activeConsumer$1.producerNode[idx] !== node) { - activeConsumer$1.producerNode[idx] = node; - activeConsumer$1.producerIndexOfThis[idx] = consumerIsLive$1(activeConsumer$1) ? producerAddLiveConsumer$1(node, activeConsumer$1, idx) : 0; - } - activeConsumer$1.producerLastReadVersion[idx] = node.version; -} -function producerIncrementEpoch$1() { - epoch$1++; -} -function producerUpdateValueVersion$1(node) { - if (!node.dirty && node.lastCleanEpoch === epoch$1) return; - if (!node.producerMustRecompute(node) && !consumerPollProducersForChange$1(node)) { - node.dirty = false; - node.lastCleanEpoch = epoch$1; - return; - } - node.producerRecomputeValue(node); - node.dirty = false; - node.lastCleanEpoch = epoch$1; -} -function producerNotifyConsumers$1(node) { - if (node.liveConsumerNode === void 0) return; - const prev = inNotificationPhase$1; - inNotificationPhase$1 = true; - try { - for (const consumer of node.liveConsumerNode) if (!consumer.dirty) consumerMarkDirty$1(consumer); - } finally { - inNotificationPhase$1 = prev; - } -} -function producerUpdatesAllowed$1() { - return (activeConsumer$1 == null ? void 0 : activeConsumer$1.consumerAllowSignalWrites) !== false; -} -function consumerMarkDirty$1(node) { - var _a; - node.dirty = true; - producerNotifyConsumers$1(node); - (_a = node.consumerMarkedDirty) == null || _a.call(node.wrapper ?? node); -} -function consumerBeforeComputation$1(node) { - node && (node.nextProducerIndex = 0); - return setActiveConsumer$1(node); -} -function consumerAfterComputation$1(node, prevConsumer) { - setActiveConsumer$1(prevConsumer); - if (!node || node.producerNode === void 0 || node.producerIndexOfThis === void 0 || node.producerLastReadVersion === void 0) return; - if (consumerIsLive$1(node)) for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) producerRemoveLiveConsumerAtIndex$1(node.producerNode[i], node.producerIndexOfThis[i]); - while (node.producerNode.length > node.nextProducerIndex) { - node.producerNode.pop(); - node.producerLastReadVersion.pop(); - node.producerIndexOfThis.pop(); - } -} -function consumerPollProducersForChange$1(node) { - assertConsumerNode$1(node); - for (let i = 0; i < node.producerNode.length; i++) { - const producer = node.producerNode[i]; - const seenVersion = node.producerLastReadVersion[i]; - if (seenVersion !== producer.version) return true; - producerUpdateValueVersion$1(producer); - if (seenVersion !== producer.version) return true; - } - return false; -} -function producerAddLiveConsumer$1(node, consumer, indexOfThis) { - var _a; - assertProducerNode$1(node); - assertConsumerNode$1(node); - if (node.liveConsumerNode.length === 0) { - (_a = node.watched) == null || _a.call(node.wrapper); - for (let i = 0; i < node.producerNode.length; i++) node.producerIndexOfThis[i] = producerAddLiveConsumer$1(node.producerNode[i], node, i); - } - node.liveConsumerIndexOfThis.push(indexOfThis); - return node.liveConsumerNode.push(consumer) - 1; -} -function producerRemoveLiveConsumerAtIndex$1(node, idx) { - var _a; - assertProducerNode$1(node); - assertConsumerNode$1(node); - if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) throw new Error(`Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`); - if (node.liveConsumerNode.length === 1) { - (_a = node.unwatched) == null || _a.call(node.wrapper); - for (let i = 0; i < node.producerNode.length; i++) producerRemoveLiveConsumerAtIndex$1(node.producerNode[i], node.producerIndexOfThis[i]); - } - const lastIdx = node.liveConsumerNode.length - 1; - node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; - node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; - node.liveConsumerNode.length--; - node.liveConsumerIndexOfThis.length--; - if (idx < node.liveConsumerNode.length) { - const idxProducer = node.liveConsumerIndexOfThis[idx]; - const consumer = node.liveConsumerNode[idx]; - assertConsumerNode$1(consumer); - consumer.producerIndexOfThis[idxProducer] = idx; - } -} -function consumerIsLive$1(node) { - var _a; - return node.consumerIsAlwaysLive || (((_a = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a.length) ?? 0) > 0; -} -function assertConsumerNode$1(node) { - node.producerNode ?? (node.producerNode = []); - node.producerIndexOfThis ?? (node.producerIndexOfThis = []); - node.producerLastReadVersion ?? (node.producerLastReadVersion = []); -} -function assertProducerNode$1(node) { - node.liveConsumerNode ?? (node.liveConsumerNode = []); - node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); -} -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function computedGet$1(node) { - producerUpdateValueVersion$1(node); - producerAccessed$1(node); - if (node.value === ERRORED$1) throw node.error; - return node.value; -} -function createComputed$1(computation) { - const node = Object.create(COMPUTED_NODE$1); - node.computation = computation; - const computed = () => computedGet$1(node); - computed[SIGNAL$1] = node; - return computed; -} -const UNSET$1 = /* @__PURE__ */ Symbol("UNSET"); -const COMPUTING$1 = /* @__PURE__ */ Symbol("COMPUTING"); -const ERRORED$1 = /* @__PURE__ */ Symbol("ERRORED"); -const COMPUTED_NODE$1 = { - ...REACTIVE_NODE$1, - value: UNSET$1, - dirty: true, - error: null, - equal: defaultEquals$1, - producerMustRecompute(node) { - return node.value === UNSET$1 || node.value === COMPUTING$1; - }, - producerRecomputeValue(node) { - if (node.value === COMPUTING$1) throw new Error("Detected cycle in computations."); - const oldValue = node.value; - node.value = COMPUTING$1; - const prevConsumer = consumerBeforeComputation$1(node); - let newValue; - let wasEqual = false; - try { - newValue = node.computation.call(node.wrapper); - wasEqual = oldValue !== UNSET$1 && oldValue !== ERRORED$1 && node.equal.call(node.wrapper, oldValue, newValue); - } catch (err) { - newValue = ERRORED$1; - node.error = err; - } finally { - consumerAfterComputation$1(node, prevConsumer); - } - if (wasEqual) { - node.value = oldValue; - return; - } - node.value = newValue; - node.version++; - } -}; -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function defaultThrowError$1() { - throw new Error(); -} -let throwInvalidWriteToSignalErrorFn$1 = defaultThrowError$1; -function throwInvalidWriteToSignalError$1() { - throwInvalidWriteToSignalErrorFn$1(); -} -/** -* @license -* Copyright Google LLC All Rights Reserved. -* -* Use of this source code is governed by an MIT-style license that can be -* found in the LICENSE file at https://angular.io/license -*/ -function createSignal$1(initialValue) { - const node = Object.create(SIGNAL_NODE$1); - node.value = initialValue; - const getter = () => { - producerAccessed$1(node); - return node.value; - }; - getter[SIGNAL$1] = node; - return getter; -} -function signalGetFn$1() { - producerAccessed$1(this); - return this.value; -} -function signalSetFn$1(node, newValue) { - if (!producerUpdatesAllowed$1()) throwInvalidWriteToSignalError$1(); - if (!node.equal.call(node.wrapper, node.value, newValue)) { - node.value = newValue; - signalValueChanged$1(node); - } -} -const SIGNAL_NODE$1 = { - ...REACTIVE_NODE$1, - equal: defaultEquals$1, - value: void 0 -}; -function signalValueChanged$1(node) { - node.version++; - producerIncrementEpoch$1(); - producerNotifyConsumers$1(node); -} -/** -* @license -* Copyright 2024 Bloomberg Finance L.P. -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ -const NODE$1 = Symbol("node"); -var Signal$1; -((Signal2) => { - var _a, _brand, _b, _brand2; - class State { - constructor(initialValue, options = {}) { - __privateAdd$1(this, _brand); - __publicField$1(this, _a); - const node = createSignal$1(initialValue)[SIGNAL$1]; - this[NODE$1] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) node.equal = equals; - node.watched = options[Signal2.subtle.watched]; - node.unwatched = options[Signal2.subtle.unwatched]; - } - } - get() { - if (!(0, Signal2.isState)(this)) throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); - return signalGetFn$1.call(this[NODE$1]); - } - set(newValue) { - if (!(0, Signal2.isState)(this)) throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); - if (isInNotificationPhase$1()) throw new Error("Writes to signals not permitted during Watcher callback"); - const ref = this[NODE$1]; - signalSetFn$1(ref, newValue); - } - } - _a = NODE$1; - _brand = /* @__PURE__ */ new WeakSet(); - Signal2.isState = (s) => typeof s === "object" && __privateIn$1(_brand, s); - Signal2.State = State; - class Computed { - constructor(computation, options) { - __privateAdd$1(this, _brand2); - __publicField$1(this, _b); - const node = createComputed$1(computation)[SIGNAL$1]; - node.consumerAllowSignalWrites = true; - this[NODE$1] = node; - node.wrapper = this; - if (options) { - const equals = options.equals; - if (equals) node.equal = equals; - node.watched = options[Signal2.subtle.watched]; - node.unwatched = options[Signal2.subtle.unwatched]; - } - } - get() { - if (!(0, Signal2.isComputed)(this)) throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); - return computedGet$1(this[NODE$1]); - } - } - _b = NODE$1; - _brand2 = /* @__PURE__ */ new WeakSet(); - Signal2.isComputed = (c) => typeof c === "object" && __privateIn$1(_brand2, c); - Signal2.Computed = Computed; - ((subtle2) => { - var _a2, _brand3, _assertSignals, assertSignals_fn; - function untrack(cb) { - let output; - let prevActiveConsumer = null; - try { - prevActiveConsumer = setActiveConsumer$1(null); - output = cb(); - } finally { - setActiveConsumer$1(prevActiveConsumer); - } - return output; - } - subtle2.untrack = untrack; - function introspectSources(sink) { - var _a3; - if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) throw new TypeError("Called introspectSources without a Computed or Watcher argument"); - return ((_a3 = sink[NODE$1].producerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; - } - subtle2.introspectSources = introspectSources; - function introspectSinks(signal) { - var _a3; - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) throw new TypeError("Called introspectSinks without a Signal argument"); - return ((_a3 = signal[NODE$1].liveConsumerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; - } - subtle2.introspectSinks = introspectSinks; - function hasSinks(signal) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) throw new TypeError("Called hasSinks without a Signal argument"); - const liveConsumerNode = signal[NODE$1].liveConsumerNode; - if (!liveConsumerNode) return false; - return liveConsumerNode.length > 0; - } - subtle2.hasSinks = hasSinks; - function hasSources(signal) { - if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) throw new TypeError("Called hasSources without a Computed or Watcher argument"); - const producerNode = signal[NODE$1].producerNode; - if (!producerNode) return false; - return producerNode.length > 0; - } - subtle2.hasSources = hasSources; - class Watcher { - constructor(notify) { - __privateAdd$1(this, _brand3); - __privateAdd$1(this, _assertSignals); - __publicField$1(this, _a2); - let node = Object.create(REACTIVE_NODE$1); - node.wrapper = this; - node.consumerMarkedDirty = notify; - node.consumerIsAlwaysLive = true; - node.consumerAllowSignalWrites = false; - node.producerNode = []; - this[NODE$1] = node; - } - watch(...signals) { - if (!(0, Signal2.isWatcher)(this)) throw new TypeError("Called unwatch without Watcher receiver"); - __privateMethod$1(this, _assertSignals, assertSignals_fn).call(this, signals); - const node = this[NODE$1]; - node.dirty = false; - const prev = setActiveConsumer$1(node); - for (const signal of signals) producerAccessed$1(signal[NODE$1]); - setActiveConsumer$1(prev); - } - unwatch(...signals) { - if (!(0, Signal2.isWatcher)(this)) throw new TypeError("Called unwatch without Watcher receiver"); - __privateMethod$1(this, _assertSignals, assertSignals_fn).call(this, signals); - const node = this[NODE$1]; - assertConsumerNode$1(node); - for (let i = node.producerNode.length - 1; i >= 0; i--) if (signals.includes(node.producerNode[i].wrapper)) { - producerRemoveLiveConsumerAtIndex$1(node.producerNode[i], node.producerIndexOfThis[i]); - const lastIdx = node.producerNode.length - 1; - node.producerNode[i] = node.producerNode[lastIdx]; - node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; - node.producerNode.length--; - node.producerIndexOfThis.length--; - node.nextProducerIndex--; - if (i < node.producerNode.length) { - const idxConsumer = node.producerIndexOfThis[i]; - const producer = node.producerNode[i]; - assertProducerNode$1(producer); - producer.liveConsumerIndexOfThis[idxConsumer] = i; - } - } - } - getPending() { - if (!(0, Signal2.isWatcher)(this)) throw new TypeError("Called getPending without Watcher receiver"); - return this[NODE$1].producerNode.filter((n) => n.dirty).map((n) => n.wrapper); - } - } - _a2 = NODE$1; - _brand3 = /* @__PURE__ */ new WeakSet(); - _assertSignals = /* @__PURE__ */ new WeakSet(); - assertSignals_fn = function(signals) { - for (const signal of signals) if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) throw new TypeError("Called watch/unwatch without a Computed or State argument"); - }; - Signal2.isWatcher = (w) => __privateIn$1(_brand3, w); - subtle2.Watcher = Watcher; - function currentComputed() { - var _a3; - return (_a3 = getActiveConsumer$1()) == null ? void 0 : _a3.wrapper; - } - subtle2.currentComputed = currentComputed; - subtle2.watched = Symbol("watched"); - subtle2.unwatched = Symbol("unwatched"); - })(Signal2.subtle || (Signal2.subtle = {})); -})(Signal$1 || (Signal$1 = {})); -/** -* equality check here is always false so that we can dirty the storage -* via setting to _anything_ -* -* -* This is for a pattern where we don't *directly* use signals to back the values used in collections -* so that instanceof checks and getters and other native features "just work" without having -* to do nested proxying. -* -* (though, see deep.ts for nested / deep behavior) -*/ -const createStorage = (initial = null) => new Signal$1.State(initial, { equals: () => false }); -const ARRAY_GETTER_METHODS = new Set([ - Symbol.iterator, - "concat", - "entries", - "every", - "filter", - "find", - "findIndex", - "flat", - "flatMap", - "forEach", - "includes", - "indexOf", - "join", - "keys", - "lastIndexOf", - "map", - "reduce", - "reduceRight", - "slice", - "some", - "values" -]); -const ARRAY_WRITE_THEN_READ_METHODS = new Set([ - "fill", - "push", - "unshift" -]); -function convertToInt(prop) { - if (typeof prop === "symbol") return null; - const num = Number(prop); - if (isNaN(num)) return null; - return num % 1 === 0 ? num : null; -} -var SignalArray = class SignalArray { - /** - * Creates an array from an iterable object. - * @param iterable An iterable object to convert to an array. - */ - /** - * Creates an array from an iterable object. - * @param iterable An iterable object to convert to an array. - * @param mapfn A mapping function to call on every element of the array. - * @param thisArg Value of 'this' used to invoke the mapfn. - */ - static from(iterable, mapfn, thisArg) { - return mapfn ? new SignalArray(Array.from(iterable, mapfn, thisArg)) : new SignalArray(Array.from(iterable)); - } - static of(...arr) { - return new SignalArray(arr); - } - constructor(arr = []) { - let clone = arr.slice(); - let self = this; - let boundFns = /* @__PURE__ */ new Map(); - /** - Flag to track whether we have *just* intercepted a call to `.push()` or - `.unshift()`, since in those cases (and only those cases!) the `Array` - itself checks `.length` to return from the function call. - */ - let nativelyAccessingLengthFromPushOrUnshift = false; - return new Proxy(clone, { - get(target, prop) { - let index = convertToInt(prop); - if (index !== null) { - self.#readStorageFor(index); - self.#collection.get(); - return target[index]; - } - if (prop === "length") { - if (nativelyAccessingLengthFromPushOrUnshift) nativelyAccessingLengthFromPushOrUnshift = false; - else self.#collection.get(); - return target[prop]; - } - if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) nativelyAccessingLengthFromPushOrUnshift = true; - if (ARRAY_GETTER_METHODS.has(prop)) { - let fn = boundFns.get(prop); - if (fn === void 0) { - fn = (...args) => { - self.#collection.get(); - return target[prop](...args); - }; - boundFns.set(prop, fn); - } - return fn; - } - return target[prop]; - }, - set(target, prop, value) { - target[prop] = value; - let index = convertToInt(prop); - if (index !== null) { - self.#dirtyStorageFor(index); - self.#collection.set(null); - } else if (prop === "length") self.#collection.set(null); - return true; - }, - getPrototypeOf() { - return SignalArray.prototype; - } - }); - } - #collection = createStorage(); - #storages = /* @__PURE__ */ new Map(); - #readStorageFor(index) { - let storage = this.#storages.get(index); - if (storage === void 0) { - storage = createStorage(); - this.#storages.set(index, storage); - } - storage.get(); - } - #dirtyStorageFor(index) { - const storage = this.#storages.get(index); - if (storage) storage.set(null); - } -}; -Object.setPrototypeOf(SignalArray.prototype, Array.prototype); -var SignalMap = class { - collection = createStorage(); - storages = /* @__PURE__ */ new Map(); - vals; - readStorageFor(key) { - const { storages } = this; - let storage = storages.get(key); - if (storage === void 0) { - storage = createStorage(); - storages.set(key, storage); - } - storage.get(); - } - dirtyStorageFor(key) { - const storage = this.storages.get(key); - if (storage) storage.set(null); - } - constructor(existing) { - this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); - } - get(key) { - this.readStorageFor(key); - return this.vals.get(key); - } - has(key) { - this.readStorageFor(key); - return this.vals.has(key); - } - entries() { - this.collection.get(); - return this.vals.entries(); - } - keys() { - this.collection.get(); - return this.vals.keys(); - } - values() { - this.collection.get(); - return this.vals.values(); - } - forEach(fn) { - this.collection.get(); - this.vals.forEach(fn); - } - get size() { - this.collection.get(); - return this.vals.size; - } - [Symbol.iterator]() { - this.collection.get(); - return this.vals[Symbol.iterator](); - } - get [Symbol.toStringTag]() { - return this.vals[Symbol.toStringTag]; - } - set(key, value) { - this.dirtyStorageFor(key); - this.collection.set(null); - this.vals.set(key, value); - return this; - } - delete(key) { - this.dirtyStorageFor(key); - this.collection.set(null); - return this.vals.delete(key); - } - clear() { - this.storages.forEach((s) => s.set(null)); - this.collection.set(null); - this.vals.clear(); - } -}; -Object.setPrototypeOf(SignalMap.prototype, Map.prototype); -/** -* Create a reactive Object, backed by Signals, using a Proxy. -* This allows dynamic creation and deletion of signals using the object primitive -* APIs that most folks are familiar with -- the only difference is instantiation. -* ```js -* const obj = new SignalObject({ foo: 123 }); -* -* obj.foo // 123 -* obj.foo = 456 -* obj.foo // 456 -* obj.bar = 2 -* obj.bar // 2 -* ``` -*/ -const SignalObject = class SignalObjectImpl { - static fromEntries(entries) { - return new SignalObjectImpl(Object.fromEntries(entries)); - } - #storages = /* @__PURE__ */ new Map(); - #collection = createStorage(); - constructor(obj = {}) { - let proto = Object.getPrototypeOf(obj); - let descs = Object.getOwnPropertyDescriptors(obj); - let clone = Object.create(proto); - for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); - let self = this; - return new Proxy(clone, { - get(target, prop, receiver) { - self.#readStorageFor(prop); - return Reflect.get(target, prop, receiver); - }, - has(target, prop) { - self.#readStorageFor(prop); - return prop in target; - }, - ownKeys(target) { - self.#collection.get(); - return Reflect.ownKeys(target); - }, - set(target, prop, value, receiver) { - let result = Reflect.set(target, prop, value, receiver); - self.#dirtyStorageFor(prop); - self.#dirtyCollection(); - return result; - }, - deleteProperty(target, prop) { - if (prop in target) { - delete target[prop]; - self.#dirtyStorageFor(prop); - self.#dirtyCollection(); - } - return true; - }, - getPrototypeOf() { - return SignalObjectImpl.prototype; - } - }); - } - #readStorageFor(key) { - let storage = this.#storages.get(key); - if (storage === void 0) { - storage = createStorage(); - this.#storages.set(key, storage); - } - storage.get(); - } - #dirtyStorageFor(key) { - const storage = this.#storages.get(key); - if (storage) storage.set(null); - } - #dirtyCollection() { - this.#collection.set(null); - } -}; -var SignalSet = class { - collection = createStorage(); - storages = /* @__PURE__ */ new Map(); - vals; - storageFor(key) { - const storages = this.storages; - let storage = storages.get(key); - if (storage === void 0) { - storage = createStorage(); - storages.set(key, storage); - } - return storage; - } - dirtyStorageFor(key) { - const storage = this.storages.get(key); - if (storage) storage.set(null); - } - constructor(existing) { - this.vals = new Set(existing); - } - has(value) { - this.storageFor(value).get(); - return this.vals.has(value); - } - entries() { - this.collection.get(); - return this.vals.entries(); - } - keys() { - this.collection.get(); - return this.vals.keys(); - } - values() { - this.collection.get(); - return this.vals.values(); - } - forEach(fn) { - this.collection.get(); - this.vals.forEach(fn); - } - get size() { - this.collection.get(); - return this.vals.size; - } - [Symbol.iterator]() { - this.collection.get(); - return this.vals[Symbol.iterator](); - } - get [Symbol.toStringTag]() { - return this.vals[Symbol.toStringTag]; - } - add(value) { - this.dirtyStorageFor(value); - this.collection.set(null); - this.vals.add(value); - return this; - } - delete(value) { - this.dirtyStorageFor(value); - this.collection.set(null); - return this.vals.delete(value); - } - clear() { - this.storages.forEach((s) => s.set(null)); - this.collection.set(null); - this.vals.clear(); - } -}; -Object.setPrototypeOf(SignalSet.prototype, Set.prototype); -function create() { - return new A2uiMessageProcessor({ - arrayCtor: SignalArray, - mapCtor: SignalMap, - objCtor: SignalObject, - setCtor: SignalSet - }); -} -const Data = { - createSignalA2uiMessageProcessor: create, - A2uiMessageProcessor, - Guards: guards_exports -}; -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ -const t$1 = (t) => (e, o) => { - void 0 !== o ? o.addInitializer(() => { - customElements.define(t, e); - }) : customElements.define(t, e); -}; -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ const o$9 = { - attribute: !0, - type: String, - converter: u$3, - reflect: !1, - hasChanged: f$3 -}, r$7 = (t = o$9, e, r) => { - const { kind: n, metadata: i } = r; - let s = globalThis.litPropertyMetadata.get(i); - if (void 0 === s && globalThis.litPropertyMetadata.set(i, s = /* @__PURE__ */ new Map()), "setter" === n && ((t = Object.create(t)).wrapped = !0), s.set(r.name, t), "accessor" === n) { - const { name: o } = r; - return { - set(r) { - const n = e.get.call(this); - e.set.call(this, r), this.requestUpdate(o, n, t, !0, r); - }, - init(e) { - return void 0 !== e && this.C(o, void 0, t, e), e; - } - }; - } - if ("setter" === n) { - const { name: o } = r; - return function(r) { - const n = this[o]; - e.call(this, r), this.requestUpdate(o, n, t, !0, r); - }; - } - throw Error("Unsupported decorator location: " + n); -}; -function n$6(t) { - return (e, o) => "object" == typeof o ? r$7(t, e, o) : ((t, e, o) => { - const r = e.hasOwnProperty(o); - return e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0; - })(t, e, o); -} -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ function r$6(r) { - return n$6({ - ...r, - state: !0, - attribute: !1 - }); -} -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ -const e$6 = (e, t, c) => (c.configurable = !0, c.enumerable = !0, Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), c); -/** -* @license -* Copyright 2017 Google LLC -* SPDX-License-Identifier: BSD-3-Clause -*/ function e$5(e, r) { - return (n, s, i) => { - const o = (t) => t.renderRoot?.querySelector(e) ?? null; - if (r) { - const { get: e, set: r } = "object" == typeof s ? n : i ?? (() => { - const t = Symbol(); - return { - get() { - return this[t]; - }, - set(e) { - this[t] = e; - } - }; - })(); - return e$6(n, s, { get() { - let t = e.call(this); - return void 0 === t && (t = o(this), (null !== t || this.hasUpdated) && r.call(this, t)), t; - } }); - } - return e$6(n, s, { get() { - return o(this); - } }); - }; -} var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, @@ -3309,6 +2372,461 @@ var Signal; })(Signal2.subtle || (Signal2.subtle = {})); })(Signal || (Signal = {})); /** +* equality check here is always false so that we can dirty the storage +* via setting to _anything_ +* +* +* This is for a pattern where we don't *directly* use signals to back the values used in collections +* so that instanceof checks and getters and other native features "just work" without having +* to do nested proxying. +* +* (though, see deep.ts for nested / deep behavior) +*/ +const createStorage = (initial = null) => new Signal.State(initial, { equals: () => false }); +const ARRAY_GETTER_METHODS = new Set([ + Symbol.iterator, + "concat", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "values" +]); +const ARRAY_WRITE_THEN_READ_METHODS = new Set([ + "fill", + "push", + "unshift" +]); +function convertToInt(prop) { + if (typeof prop === "symbol") return null; + const num = Number(prop); + if (isNaN(num)) return null; + return num % 1 === 0 ? num : null; +} +var SignalArray = class SignalArray { + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + */ + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + * @param mapfn A mapping function to call on every element of the array. + * @param thisArg Value of 'this' used to invoke the mapfn. + */ + static from(iterable, mapfn, thisArg) { + return mapfn ? new SignalArray(Array.from(iterable, mapfn, thisArg)) : new SignalArray(Array.from(iterable)); + } + static of(...arr) { + return new SignalArray(arr); + } + constructor(arr = []) { + let clone = arr.slice(); + let self = this; + let boundFns = /* @__PURE__ */ new Map(); + /** + Flag to track whether we have *just* intercepted a call to `.push()` or + `.unshift()`, since in those cases (and only those cases!) the `Array` + itself checks `.length` to return from the function call. + */ + let nativelyAccessingLengthFromPushOrUnshift = false; + return new Proxy(clone, { + get(target, prop) { + let index = convertToInt(prop); + if (index !== null) { + self.#readStorageFor(index); + self.#collection.get(); + return target[index]; + } + if (prop === "length") { + if (nativelyAccessingLengthFromPushOrUnshift) nativelyAccessingLengthFromPushOrUnshift = false; + else self.#collection.get(); + return target[prop]; + } + if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) nativelyAccessingLengthFromPushOrUnshift = true; + if (ARRAY_GETTER_METHODS.has(prop)) { + let fn = boundFns.get(prop); + if (fn === void 0) { + fn = (...args) => { + self.#collection.get(); + return target[prop](...args); + }; + boundFns.set(prop, fn); + } + return fn; + } + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + let index = convertToInt(prop); + if (index !== null) { + self.#dirtyStorageFor(index); + self.#collection.set(null); + } else if (prop === "length") self.#collection.set(null); + return true; + }, + getPrototypeOf() { + return SignalArray.prototype; + } + }); + } + #collection = createStorage(); + #storages = /* @__PURE__ */ new Map(); + #readStorageFor(index) { + let storage = this.#storages.get(index); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(index, storage); + } + storage.get(); + } + #dirtyStorageFor(index) { + const storage = this.#storages.get(index); + if (storage) storage.set(null); + } +}; +Object.setPrototypeOf(SignalArray.prototype, Array.prototype); +var SignalMap = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + readStorageFor(key) { + const { storages } = this; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + storage.get(); + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); + } + get(key) { + this.readStorageFor(key); + return this.vals.get(key); + } + has(key) { + this.readStorageFor(key); + return this.vals.has(key); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + set(key, value) { + this.dirtyStorageFor(key); + this.collection.set(null); + this.vals.set(key, value); + return this; + } + delete(key) { + this.dirtyStorageFor(key); + this.collection.set(null); + return this.vals.delete(key); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalMap.prototype, Map.prototype); +/** +* Create a reactive Object, backed by Signals, using a Proxy. +* This allows dynamic creation and deletion of signals using the object primitive +* APIs that most folks are familiar with -- the only difference is instantiation. +* ```js +* const obj = new SignalObject({ foo: 123 }); +* +* obj.foo // 123 +* obj.foo = 456 +* obj.foo // 456 +* obj.bar = 2 +* obj.bar // 2 +* ``` +*/ +const SignalObject = class SignalObjectImpl { + static fromEntries(entries) { + return new SignalObjectImpl(Object.fromEntries(entries)); + } + #storages = /* @__PURE__ */ new Map(); + #collection = createStorage(); + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj); + let clone = Object.create(proto); + for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); + let self = this; + return new Proxy(clone, { + get(target, prop, receiver) { + self.#readStorageFor(prop); + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + self.#readStorageFor(prop); + return prop in target; + }, + ownKeys(target) { + self.#collection.get(); + return Reflect.ownKeys(target); + }, + set(target, prop, value, receiver) { + let result = Reflect.set(target, prop, value, receiver); + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + return result; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + } + return true; + }, + getPrototypeOf() { + return SignalObjectImpl.prototype; + } + }); + } + #readStorageFor(key) { + let storage = this.#storages.get(key); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(key, storage); + } + storage.get(); + } + #dirtyStorageFor(key) { + const storage = this.#storages.get(key); + if (storage) storage.set(null); + } + #dirtyCollection() { + this.#collection.set(null); + } +}; +var SignalSet = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + storageFor(key) { + const storages = this.storages; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + return storage; + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = new Set(existing); + } + has(value) { + this.storageFor(value).get(); + return this.vals.has(value); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + add(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + this.vals.add(value); + return this; + } + delete(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + return this.vals.delete(value); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalSet.prototype, Set.prototype); +function create() { + return new A2uiMessageProcessor({ + arrayCtor: SignalArray, + mapCtor: SignalMap, + objCtor: SignalObject, + setCtor: SignalSet + }); +} +const Data = { + createSignalA2uiMessageProcessor: create, + A2uiMessageProcessor, + Guards: guards_exports +}; +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const t$1 = (t) => (e, o) => { + void 0 !== o ? o.addInitializer(() => { + customElements.define(t, e); + }) : customElements.define(t, e); +}; +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ const o$9 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + hasChanged: f$3 +}, r$7 = (t = o$9, e, r) => { + const { kind: n, metadata: i } = r; + let s = globalThis.litPropertyMetadata.get(i); + if (void 0 === s && globalThis.litPropertyMetadata.set(i, s = /* @__PURE__ */ new Map()), "setter" === n && ((t = Object.create(t)).wrapped = !0), s.set(r.name, t), "accessor" === n) { + const { name: o } = r; + return { + set(r) { + const n = e.get.call(this); + e.set.call(this, r), this.requestUpdate(o, n, t, !0, r); + }, + init(e) { + return void 0 !== e && this.C(o, void 0, t, e), e; + } + }; + } + if ("setter" === n) { + const { name: o } = r; + return function(r) { + const n = this[o]; + e.call(this, r), this.requestUpdate(o, n, t, !0, r); + }; + } + throw Error("Unsupported decorator location: " + n); +}; +function n$6(t) { + return (e, o) => "object" == typeof o ? r$7(t, e, o) : ((t, e, o) => { + const r = e.hasOwnProperty(o); + return e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0; + })(t, e, o); +} +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function r$6(r) { + return n$6({ + ...r, + state: !0, + attribute: !1 + }); +} +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ +const e$6 = (e, t, c) => (c.configurable = !0, c.enumerable = !0, Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), c); +/** +* @license +* Copyright 2017 Google LLC +* SPDX-License-Identifier: BSD-3-Clause +*/ function e$5(e, r) { + return (n, s, i) => { + const o = (t) => t.renderRoot?.querySelector(e) ?? null; + if (r) { + const { get: e, set: r } = "object" == typeof s ? n : i ?? (() => { + const t = Symbol(); + return { + get() { + return this[t]; + }, + set(e) { + this[t] = e; + } + }; + })(); + return e$6(n, s, { get() { + let t = e.call(this); + return void 0 === t && (t = o(this), (null !== t || this.hasUpdated) && r.call(this, t)), t; + } }); + } + return e$6(n, s, { get() { + return o(this); + } }); + }; +} +/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: BSD-3-Clause @@ -3498,7 +3016,7 @@ function* o$3(o, f) { } } let pending = false; -let watcher = new Signal$1.subtle.Watcher(() => { +let watcher = new Signal.subtle.Watcher(() => { if (!pending) { pending = true; queueMicrotask(() => { @@ -3516,7 +3034,7 @@ function flushPending() { * This will produce a memory leak. */ function effect(cb) { - let c = new Signal$1.Computed(() => cb()); + let c = new Signal.Computed(() => cb()); watcher.watch(c); c.get(); return () => {