@tencent-connect/openclaw-qqbot 1.6.7 → 1.7.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +47 -3
  2. package/README.zh.md +10 -9
  3. package/dist/src/api.d.ts +21 -5
  4. package/dist/src/api.js +67 -41
  5. package/dist/src/channel.js +16 -12
  6. package/dist/src/gateway.js +59 -48
  7. package/dist/src/message-queue.d.ts +5 -0
  8. package/dist/src/outbound-deliver.js +8 -8
  9. package/dist/src/outbound.d.ts +15 -0
  10. package/dist/src/outbound.js +41 -4
  11. package/dist/src/ref-index-store.d.ts +31 -0
  12. package/dist/src/ref-index-store.js +49 -1
  13. package/dist/src/runtime.js +3 -0
  14. package/dist/src/slash-commands.js +97 -0
  15. package/dist/src/streaming.d.ts +6 -9
  16. package/dist/src/streaming.js +25 -40
  17. package/dist/src/types.d.ts +25 -19
  18. package/dist/src/types.js +5 -0
  19. package/dist/src/utils/media-send.d.ts +12 -2
  20. package/dist/src/utils/media-send.js +84 -38
  21. package/dist/src/utils/media-tags.js +2 -1
  22. package/dist/src/utils/text-parsing.d.ts +4 -5
  23. package/dist/src/utils/text-parsing.js +17 -12
  24. package/openclaw.plugin.json +6 -1
  25. package/package.json +1 -1
  26. package/scripts/upgrade-via-npm.sh +697 -504
  27. package/scripts/upgrade-via-source.sh +24 -0
  28. package/skills/qqbot-upgrade/SKILL.md +50 -0
  29. package/src/api.ts +82 -44
  30. package/src/channel.ts +17 -11
  31. package/src/gateway.ts +64 -51
  32. package/src/message-queue.ts +5 -0
  33. package/src/openclaw-plugin-sdk.d.ts +2 -0
  34. package/src/outbound-deliver.ts +21 -8
  35. package/src/outbound.ts +51 -3
  36. package/src/ref-index-store.ts +78 -1
  37. package/src/runtime.ts +3 -0
  38. package/src/slash-commands.ts +113 -0
  39. package/src/streaming.ts +29 -54
  40. package/src/types.ts +29 -19
  41. package/src/utils/media-send.ts +89 -38
  42. package/src/utils/media-tags.ts +2 -1
  43. package/src/utils/text-parsing.ts +21 -11
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  **Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
12
12
 
13
- ### 🚀 Current Version: `v1.6.7`
13
+ ### 🚀 Current Version: `v1.7.0`
14
14
 
15
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
16
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -45,11 +45,50 @@ Scan to join the QQ group chat
45
45
  | ⌨️ **Typing Indicator** | "Bot is typing..." status shown in real-time |
46
46
  | 📝 **Markdown** | Full Markdown formatting support |
47
47
  | 🛠️ **Commands** | Native OpenClaw command integration |
48
- | 💬 **Quoted Context** | Resolve QQ `REFIDX_*` quoted messages and inject quote body into AI context |
48
+ | 💬 **Quoted Context** | Parses the original message a user is replying to and injects it into AI context, so the model always knows exactly which message is being referenced |
49
49
  | 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
50
50
 
51
51
  ---
52
52
 
53
+ ## 🆚 Standalone Plugin vs OpenClaw Built-in: Which to Choose?
54
+
55
+ Starting from **OpenClaw 2026.3.31**, a QQBot plugin is bundled with OpenClaw. The two plugins **cannot run at the same time** — choose one based on your needs.
56
+
57
+ | Feature | This Plugin (Standalone) | OpenClaw Built-in |
58
+ |---------|:------------------------:|:-----------------:|
59
+ | 🔒 Multi-scene (C2C / Group) | ✅ | ✅ |
60
+ | 🖼️ Rich media (image / voice / video / file) | ✅ | ✅ |
61
+ | 🎙️ Voice STT / TTS | ✅ | ✅ |
62
+ | 🔥 One-click hot upgrade (`/bot-upgrade`) | ✅ | ❌ |
63
+ | ⏰ Scheduled push (proactive messages) | ✅ | ✅ |
64
+ | 🔗 URL support | ✅ | ✅ |
65
+ | ⌨️ Typing indicator | ✅ | ✅ |
66
+ | 📝 Markdown | ✅ | ✅ |
67
+ | 🛠️ Slash commands / native commands | ✅ | ✅ |
68
+ | 💬 Quoted context (injected into AI) | ✅ | ✅ |
69
+ | 📦 Large file support (up to 100MB) | ✅ | ❌ |
70
+ | Installation | Requires separate install | Bundled, zero setup |
71
+ | Update cadence | Independent releases, faster iteration | Ships with OpenClaw |
72
+
73
+ ### Which should I pick?
74
+
75
+ **Choose the OpenClaw built-in plugin if you:**
76
+
77
+ - Want zero-setup out of the box
78
+ - Are just getting started and want to try QQ Bot quickly
79
+
80
+ **Choose this plugin (standalone) if you:**
81
+
82
+ - Want faster feature iteration and more capabilities
83
+
84
+ > ⚠️ Both plugins cannot run simultaneously. If you've upgraded to OpenClaw 2026.3.31+, run the following command to install this plugin — the built-in version will be disabled automatically:
85
+ > ```bash
86
+ > curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
87
+ > ```
88
+ > After upgrading, you'll unlock large file transfers, message reference context, and all other advanced features.
89
+
90
+ ---
91
+
53
92
  ## 📸 Feature Showcase
54
93
 
55
94
  > **Note:** This plugin serves as a **message channel** only — it relays messages between QQ and OpenClaw. Capabilities like image understanding, voice transcription, drawing, etc. depend on the **AI model** you configure and the **skills** installed in OpenClaw, not on this plugin itself.
@@ -192,6 +231,11 @@ Credentials are automatically backed up before upgrade. Version existence is ver
192
231
 
193
232
  > ⚠️ Hot upgrade is currently not supported on Windows. Sending `/bot-upgrade` on Windows will return a manual upgrade guide instead.
194
233
 
234
+ > ⚠️ v1.6.6 and below do not support hot upgrade via `/bot-upgrade`. Please upgrade using the following command:
235
+ > ```bash
236
+ > curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
237
+ > ```
238
+
195
239
  <img width="360" src="docs/images/hot-update.jpg" alt="Hot Upgrade Demo" />
196
240
 
197
241
  #### `/bot-logs` — Log Export
@@ -252,7 +296,7 @@ curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main
252
296
 
253
297
  One command does it all: download script → cleanup old plugins → install → configure channel → restart service. Once done, open QQ and start chatting!
254
298
 
255
- > `--appid` and `--secret` are **required for first-time install**. For subsequent upgrades:
299
+ > `--appid` and `--secret` are **required for first-time install**. For subsequent upgrades, run the following command to upgrade to the latest version:
256
300
  > ```bash
257
301
  > curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
258
302
  > ```
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
11
11
 
12
- ### 🚀 当前版本: `v1.6.7`
12
+ ### 🚀 当前版本: `v1.7.0`
13
13
 
14
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
15
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](https://bot.q.qq.com/wiki/)
@@ -40,7 +40,7 @@
40
40
  | ⌨️ **输入状态** | 实时显示"Bot 正在输入中…"状态 |
41
41
  | 📝 **Markdown** | 完整支持 Markdown 格式消息 |
42
42
  | 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
43
- | 💬 **引用上下文** | 解析 QQ `REFIDX_*` 引用消息,并将引用内容注入 AI 上下文 |
43
+ | 💬 **引用上下文** | 解析用户回复的原始消息内容,注入 AI 上下文,让模型准确理解"在回复哪条消息" |
44
44
  | 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
45
45
 
46
46
  ---
@@ -49,13 +49,9 @@
49
49
 
50
50
  > **说明:** 本插件仅作为**消息通道**,负责在 QQ 和 OpenClaw 之间传递消息。图片理解、语音转录、AI 画图等能力取决于你配置的 **AI 模型**以及在 OpenClaw 中安装的 **skill**,而非插件本身提供。
51
51
 
52
- ### 💬 引用消息上下文(REFIDX)
52
+ ### 💬 引用消息上下文
53
53
 
54
- QQ 的引用事件通常只携带索引键(如 `REFIDX_xxx`),不直接返回原始消息全文。插件已支持从本地持久化索引中解析引用内容,并注入 AI 上下文,帮助模型更准确理解“用户引用的是哪条消息”。
55
-
56
- - 入站/出站消息中的 `ref_idx` 会自动建立索引。
57
- - 存储位置:`~/.openclaw/qqbot/data/ref-index.jsonl`(网关重启后仍可恢复)。
58
- - 引用内容支持文本 + 媒体摘要(图片/语音/视频/文件)。
54
+ 用户在 QQ 中引用某条消息发送时,插件会自动解析被引用的消息内容并注入 AI 上下文,让模型清楚地知道"用户在回复哪条消息",从而给出更准确的回复。支持文本及媒体消息(图片/语音/视频/文件),换设备后同样可用。
59
55
 
60
56
  <img width="360" src="docs/images/ref-msg.png" alt="引用消息上下文演示" />
61
57
 
@@ -187,6 +183,11 @@ AI 可直接发送视频,支持本地文件和公网 URL。
187
183
 
188
184
  > ⚠️ 热更新指令暂不支持 Windows 系统,在 Windows 上发送 `/bot-upgrade` 会返回手动升级指引。
189
185
 
186
+ > ⚠️ v1.6.6 及以下版本暂不支持通过 `/bot-upgrade` 执行热更新,请通过以下命令升级:
187
+ > ```bash
188
+ > curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
189
+ > ```
190
+
190
191
  <img width="360" src="docs/images/hot-update.jpg" alt="一键热更新演示" />
191
192
 
192
193
  #### `/bot-logs` — 日志导出
@@ -247,7 +248,7 @@ curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main
247
248
 
248
249
  一行命令搞定:下载脚本 → 清理旧插件 → 安装 → 配置通道 → 启动服务。完成后打开 QQ 即可开始聊天!
249
250
 
250
- > 首次安装**必须**传 `--appid` 和 `--secret`。后续升级如已有配置:
251
+ > 首次安装**必须**传 `--appid` 和 `--secret`。后续升级执行此指令可以升级为最新版:
251
252
  > ```bash
252
253
  > curl -fsSL https://raw.githubusercontent.com/tencent-connect/openclaw-qqbot/main/scripts/upgrade-via-npm.sh | bash
253
254
  > ```
package/dist/src/api.d.ts CHANGED
@@ -2,6 +2,17 @@
2
2
  * QQ Bot API 鉴权和请求封装
3
3
  * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
4
  */
5
+ /** API 模块的日志接口,与 GatewayContext.log 对齐 */
6
+ export interface ApiLogger {
7
+ info: (msg: string) => void;
8
+ error: (msg: string) => void;
9
+ warn?: (msg: string) => void;
10
+ debug?: (msg: string) => void;
11
+ }
12
+ /**
13
+ * 注入自定义 logger(在 gateway 启动时调用,将 api 模块的日志统一接入框架日志系统)
14
+ */
15
+ export declare function setApiLogger(logger: ApiLogger): void;
5
16
  /** API 请求错误,携带 HTTP status code 和业务错误码 */
6
17
  export declare class ApiError extends Error {
7
18
  readonly status: number;
@@ -16,7 +27,9 @@ export declare class ApiError extends Error {
16
27
  /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
17
28
  bizMessage?: string | undefined);
18
29
  }
19
- export declare const PLUGIN_USER_AGENT: string;
30
+ /** setQQBotRuntime 调用,将 api.runtime.version 注入到 User-Agent */
31
+ export declare function setOpenClawVersion(version: string): void;
32
+ export declare function getPluginUserAgent(): string;
20
33
  /** 出站消息元信息(结构化存储,不做预格式化) */
21
34
  export interface OutboundMeta {
22
35
  /** 消息文本内容 */
@@ -91,7 +104,7 @@ export declare const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
91
104
  export declare function getGatewayUrl(accessToken: string): Promise<string>;
92
105
  /** 回应按钮交互(INTERACTION_CREATE),避免客户端按钮持续 loading */
93
106
  export declare function acknowledgeInteraction(accessToken: string, interactionId: string, code?: 0 | 1 | 2 | 3 | 4 | 5, data?: Record<string, unknown>): Promise<void>;
94
- /** 获取插件版本号(从 package.json 读取,和 PLUGIN_USER_AGENT 同源) */
107
+ /** 获取插件版本号(从 package.json 读取,和 getPluginUserAgent() 同源) */
95
108
  export declare function getApiPluginVersion(): string;
96
109
  export interface MessageResponse {
97
110
  id: string;
@@ -269,7 +282,7 @@ export declare function startBackgroundTokenRefresh(appId: string, clientSecret:
269
282
  */
270
283
  export declare function stopBackgroundTokenRefresh(appId?: string): void;
271
284
  export declare function isBackgroundTokenRefreshRunning(appId?: string): boolean;
272
- import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
285
+ import type { StreamMessageRequest } from "./types.js";
273
286
  /**
274
287
  * 发送流式消息(C2C 私聊)
275
288
  *
@@ -278,10 +291,13 @@ import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
278
291
  * - 后续分片携带 stream_msg_id 和递增 msg_seq
279
292
  * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
280
293
  *
294
+ * 仅在终结分片(input_state=DONE)时触发 refIdx 回调,
295
+ * 中间分片直接调用 apiRequest,避免存入过多无效的中间态数据。
296
+ *
281
297
  * @param accessToken - access_token
282
298
  * @param openid - 用户 openid
283
299
  * @param req - 流式消息请求体
284
- * @returns 流式消息响应
300
+ * @returns 消息响应(复用 MessageResponse,错误会直接抛出异常)
285
301
  */
286
- export declare function sendC2CStreamMessage(accessToken: string, openid: string, req: StreamMessageRequest): Promise<StreamMessageResponse>;
302
+ export declare function sendC2CStreamMessage(accessToken: string, openid: string, req: StreamMessageRequest): Promise<MessageResponse>;
287
303
  export {};
package/dist/src/api.js CHANGED
@@ -5,6 +5,19 @@
5
5
  import os from "node:os";
6
6
  import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
7
7
  import { sanitizeFileName } from "./utils/platform.js";
8
+ /** 默认使用 console,外部可通过 setApiLogger 注入框架 log */
9
+ let log = {
10
+ info: (msg) => console.log(msg),
11
+ error: (msg) => console.error(msg),
12
+ warn: (msg) => console.warn(msg),
13
+ debug: (msg) => console.debug(msg),
14
+ };
15
+ /**
16
+ * 注入自定义 logger(在 gateway 启动时调用,将 api 模块的日志统一接入框架日志系统)
17
+ */
18
+ export function setApiLogger(logger) {
19
+ log = logger;
20
+ }
8
21
  // ============ 自定义错误 ============
9
22
  /** API 请求错误,携带 HTTP status code 和业务错误码 */
10
23
  export class ApiError extends Error {
@@ -28,11 +41,20 @@ export class ApiError extends Error {
28
41
  const API_BASE = "https://api.sgroup.qq.com";
29
42
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
30
43
  // ============ Plugin User-Agent ============
31
- // 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
32
- // 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
44
+ // 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os}; OpenClaw/{openclawVersion})
45
+ // 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin; OpenClaw/2026.3.31)
33
46
  import { getPackageVersion } from "./utils/pkg-version.js";
34
47
  const _pluginVersion = getPackageVersion(import.meta.url);
35
- export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
48
+ // 初始值为 "unknown",由 setQQBotRuntime 注入后更新为真实版本
49
+ let _openclawVersion = "unknown";
50
+ /** 由 setQQBotRuntime 调用,将 api.runtime.version 注入到 User-Agent */
51
+ export function setOpenClawVersion(version) {
52
+ if (version)
53
+ _openclawVersion = version;
54
+ }
55
+ export function getPluginUserAgent() {
56
+ return `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`;
57
+ }
36
58
  // 运行时配置
37
59
  let currentMarkdownSupport = false;
38
60
  let onMessageSentHook = null;
@@ -83,7 +105,7 @@ export async function getAccessToken(appId, clientSecret) {
83
105
  // Singleflight: 如果当前 appId 已有进行中的 Token 获取请求,复用它
84
106
  let fetchPromise = tokenFetchPromises.get(normalizedAppId);
85
107
  if (fetchPromise) {
86
- console.log(`[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`);
108
+ log.info(`[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`);
87
109
  return fetchPromise;
88
110
  }
89
111
  // 创建新的 Token 获取 Promise(singleflight 入口)
@@ -104,9 +126,9 @@ export async function getAccessToken(appId, clientSecret) {
104
126
  */
105
127
  async function doFetchToken(appId, clientSecret) {
106
128
  const requestBody = { appId, clientSecret };
107
- const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT };
129
+ const requestHeaders = { "Content-Type": "application/json", "User-Agent": getPluginUserAgent() };
108
130
  // 打印请求信息(隐藏敏感信息)
109
- console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL} [secret: ${clientSecret.slice(0, 6)}...len=${clientSecret.length}]`);
131
+ log.info(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL} [secret: ${clientSecret.slice(0, 6)}...len=${clientSecret.length}]`);
110
132
  let response;
111
133
  try {
112
134
  response = await fetch(TOKEN_URL, {
@@ -116,7 +138,7 @@ async function doFetchToken(appId, clientSecret) {
116
138
  });
117
139
  }
118
140
  catch (err) {
119
- console.error(`[qqbot-api:${appId}] <<< Network error:`, err);
141
+ log.error(`[qqbot-api:${appId}] <<< Network error: ${err}`);
120
142
  throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
121
143
  }
122
144
  // 打印响应头
@@ -125,18 +147,18 @@ async function doFetchToken(appId, clientSecret) {
125
147
  responseHeaders[key] = value;
126
148
  });
127
149
  const tokenTraceId = response.headers.get("x-tps-trace-id") ?? "";
128
- console.log(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`);
150
+ log.info(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`);
129
151
  let data;
130
152
  let rawBody;
131
153
  try {
132
154
  rawBody = await response.text();
133
155
  // 隐藏 token 值
134
156
  const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
135
- console.log(`[qqbot-api:${appId}] <<< Body:`, logBody);
157
+ log.info(`[qqbot-api:${appId}] <<< Body: ${logBody}`);
136
158
  data = JSON.parse(rawBody);
137
159
  }
138
160
  catch (err) {
139
- console.error(`[qqbot-api:${appId}] <<< Parse error:`, err);
161
+ log.error(`[qqbot-api:${appId}] <<< Parse error: ${err}`);
140
162
  throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
141
163
  }
142
164
  if (!data.access_token) {
@@ -148,7 +170,7 @@ async function doFetchToken(appId, clientSecret) {
148
170
  expiresAt,
149
171
  appId,
150
172
  });
151
- console.log(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`);
173
+ log.info(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`);
152
174
  return data.access_token;
153
175
  }
154
176
  /**
@@ -159,11 +181,11 @@ export function clearTokenCache(appId) {
159
181
  if (appId) {
160
182
  const normalizedAppId = String(appId).trim();
161
183
  tokenCacheMap.delete(normalizedAppId);
162
- console.log(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`);
184
+ log.info(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`);
163
185
  }
164
186
  else {
165
187
  tokenCacheMap.clear();
166
- console.log(`[qqbot-api] All token caches cleared.`);
188
+ log.info(`[qqbot-api] All token caches cleared.`);
167
189
  }
168
190
  }
169
191
  /**
@@ -198,10 +220,11 @@ const FILE_UPLOAD_TIMEOUT = 120000; // 文件上传 120 秒
198
220
  */
199
221
  export async function apiRequest(accessToken, method, path, body, timeoutMs) {
200
222
  const url = `${API_BASE}${path}`;
223
+ const reqTs = Date.now(); // 毫秒时间戳,用于关联同一次请求的所有日志
201
224
  const headers = {
202
225
  Authorization: `QQBot ${accessToken}`,
203
226
  "Content-Type": "application/json",
204
- "User-Agent": PLUGIN_USER_AGENT,
227
+ "User-Agent": getPluginUserAgent(),
205
228
  };
206
229
  const isFileUpload = path.includes("/files");
207
230
  const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);
@@ -218,13 +241,13 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
218
241
  options.body = JSON.stringify(body);
219
242
  }
220
243
  // 打印请求信息
221
- console.log(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
244
+ log.info(`[qqbot-api][${reqTs}] >>> ${method} ${url} (timeout: ${timeout}ms)`);
222
245
  if (body) {
223
246
  const logBody = { ...body };
224
247
  if (typeof logBody.file_data === "string") {
225
248
  logBody.file_data = `<base64 ${logBody.file_data.length} chars>`;
226
249
  }
227
- console.log(`[qqbot-api] >>> Body:`, JSON.stringify(logBody));
250
+ log.info(`[qqbot-api][${reqTs}] >>> Body: ${JSON.stringify(logBody)}`);
228
251
  }
229
252
  let res;
230
253
  try {
@@ -233,10 +256,10 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
233
256
  catch (err) {
234
257
  clearTimeout(timeoutId);
235
258
  if (err instanceof Error && err.name === "AbortError") {
236
- console.error(`[qqbot-api] <<< Request timeout after ${timeout}ms`);
259
+ log.error(`[qqbot-api][${reqTs}] <<< Request timeout after ${timeout}ms`);
237
260
  throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`);
238
261
  }
239
- console.error(`[qqbot-api] <<< Network error:`, err);
262
+ log.error(`[qqbot-api][${reqTs}] <<< Network error: ${err}`);
240
263
  throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
241
264
  }
242
265
  finally {
@@ -247,7 +270,7 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
247
270
  responseHeaders[key] = value;
248
271
  });
249
272
  const traceId = res.headers.get("x-tps-trace-id") ?? "";
250
- console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
273
+ log.info(`[qqbot-api][${reqTs}] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
251
274
  let rawBody;
252
275
  try {
253
276
  rawBody = await res.text();
@@ -255,7 +278,7 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
255
278
  catch (err) {
256
279
  throw new Error(`读取响应失败[${path}]: ${err instanceof Error ? err.message : String(err)}`);
257
280
  }
258
- console.log(`[qqbot-api] <<< Body:`, rawBody);
281
+ log.info(`[qqbot-api][${reqTs}] <<< Body: ${rawBody}`);
259
282
  // 检测非 JSON 响应(HTML 网关错误页 / CDN 限流页等)
260
283
  const contentType = res.headers.get("content-type") ?? "";
261
284
  const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
@@ -283,13 +306,13 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
283
306
  }
284
307
  // 成功响应但不是 JSON(极端异常情况)
285
308
  if (isHtmlResponse) {
286
- throw new Error(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`);
309
+ throw new ApiError(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`, res.status, path);
287
310
  }
288
311
  try {
289
312
  return JSON.parse(rawBody);
290
313
  }
291
314
  catch {
292
- throw new Error(`开放平台响应格式异常(${path}),请稍后重试`);
315
+ throw new ApiError(`开放平台响应格式异常(${path}),请稍后重试`, res.status, path);
293
316
  }
294
317
  }
295
318
  // ============ 上传重试(指数退避) ============
@@ -310,7 +333,7 @@ async function apiRequestWithRetry(accessToken, method, path, body, maxRetries =
310
333
  }
311
334
  if (attempt < maxRetries) {
312
335
  const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
313
- console.log(`[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`);
336
+ log.info(`[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`);
314
337
  await new Promise(resolve => setTimeout(resolve, delay));
315
338
  }
316
339
  }
@@ -334,7 +357,7 @@ async function completeUploadWithRetry(accessToken, method, path, body) {
334
357
  lastError = err instanceof Error ? err : new Error(String(err));
335
358
  if (attempt < COMPLETE_UPLOAD_MAX_RETRIES) {
336
359
  const delay = COMPLETE_UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
337
- console.warn(`[qqbot-api] CompleteUpload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
360
+ (log.warn ?? log.error)(`[qqbot-api] CompleteUpload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
338
361
  await new Promise(resolve => setTimeout(resolve, delay));
339
362
  }
340
363
  }
@@ -397,13 +420,13 @@ async function partFinishWithRetry(accessToken, method, path, body, retryTimeout
397
420
  // 命中特定错误码 → 进入持续重试模式
398
421
  if (isRetryableBizCode(err)) {
399
422
  const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
400
- console.warn(`[qqbot-api] PartFinish hit retryable bizCode=${err.bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
423
+ (log.warn ?? log.error)(`[qqbot-api] PartFinish hit retryable bizCode=${err.bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
401
424
  await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
402
425
  return;
403
426
  }
404
427
  if (attempt < PART_FINISH_MAX_RETRIES) {
405
428
  const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
406
- console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
429
+ (log.warn ?? log.error)(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
407
430
  await new Promise(resolve => setTimeout(resolve, delay));
408
431
  }
409
432
  }
@@ -421,14 +444,14 @@ async function partFinishPersistentRetry(accessToken, method, path, body, timeou
421
444
  while (Date.now() < deadline) {
422
445
  try {
423
446
  await apiRequest(accessToken, method, path, body);
424
- console.log(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
447
+ log.info(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
425
448
  return;
426
449
  }
427
450
  catch (err) {
428
451
  lastError = err instanceof Error ? err : new Error(String(err));
429
452
  // 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
430
453
  if (!isRetryableBizCode(err)) {
431
- console.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${err.bizCode ?? "N/A"}), aborting`);
454
+ log.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${err.bizCode ?? "N/A"}), aborting`);
432
455
  throw lastError;
433
456
  }
434
457
  attempt++;
@@ -436,12 +459,12 @@ async function partFinishPersistentRetry(accessToken, method, path, body, timeou
436
459
  if (remaining <= 0)
437
460
  break;
438
461
  const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
439
- console.warn(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${err.bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
462
+ (log.warn ?? log.error)(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${err.bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
440
463
  await new Promise(resolve => setTimeout(resolve, actualDelay));
441
464
  }
442
465
  }
443
466
  // 超时
444
- console.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
467
+ log.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
445
468
  throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
446
469
  }
447
470
  export async function getGatewayUrl(accessToken) {
@@ -452,7 +475,7 @@ export async function getGatewayUrl(accessToken) {
452
475
  export async function acknowledgeInteraction(accessToken, interactionId, code = 0, data) {
453
476
  await apiRequest(accessToken, "PUT", `/interactions/${interactionId}`, { code, ...(data ? { data } : {}) });
454
477
  }
455
- /** 获取插件版本号(从 package.json 读取,和 PLUGIN_USER_AGENT 同源) */
478
+ /** 获取插件版本号(从 package.json 读取,和 getPluginUserAgent() 同源) */
456
479
  export function getApiPluginVersion() {
457
480
  return _pluginVersion;
458
481
  }
@@ -467,7 +490,7 @@ async function sendAndNotify(accessToken, method, path, body, meta) {
467
490
  onMessageSentHook(result.ext_info.ref_idx, meta);
468
491
  }
469
492
  catch (err) {
470
- console.error(`[qqbot-api] onMessageSent hook error: ${err}`);
493
+ log.error(`[qqbot-api] onMessageSent hook error: ${err}`);
471
494
  }
472
495
  }
473
496
  return result;
@@ -755,15 +778,15 @@ export async function sendGroupVideoMessage(accessToken, groupOpenid, videoUrl,
755
778
  const backgroundRefreshControllers = new Map();
756
779
  export function startBackgroundTokenRefresh(appId, clientSecret, options) {
757
780
  if (backgroundRefreshControllers.has(appId)) {
758
- console.log(`[qqbot-api:${appId}] Background token refresh already running`);
781
+ log.info(`[qqbot-api:${appId}] Background token refresh already running`);
759
782
  return;
760
783
  }
761
- const { refreshAheadMs = 5 * 60 * 1000, randomOffsetMs = 30 * 1000, minRefreshIntervalMs = 60 * 1000, retryDelayMs = 5 * 1000, log, } = options ?? {};
784
+ const { refreshAheadMs = 5 * 60 * 1000, randomOffsetMs = 30 * 1000, minRefreshIntervalMs = 60 * 1000, retryDelayMs = 5 * 1000, log: refreshLog, } = options ?? {};
762
785
  const controller = new AbortController();
763
786
  backgroundRefreshControllers.set(appId, controller);
764
787
  const signal = controller.signal;
765
788
  const refreshLoop = async () => {
766
- log?.info?.(`[qqbot-api:${appId}] Background token refresh started`);
789
+ refreshLog?.info?.(`[qqbot-api:${appId}] Background token refresh started`);
767
790
  while (!signal.aborted) {
768
791
  try {
769
792
  await getAccessToken(appId, clientSecret);
@@ -772,27 +795,27 @@ export function startBackgroundTokenRefresh(appId, clientSecret, options) {
772
795
  const expiresIn = cached.expiresAt - Date.now();
773
796
  const randomOffset = Math.random() * randomOffsetMs;
774
797
  const refreshIn = Math.max(expiresIn - refreshAheadMs - randomOffset, minRefreshIntervalMs);
775
- log?.debug?.(`[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`);
798
+ refreshLog?.debug?.(`[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`);
776
799
  await sleep(refreshIn, signal);
777
800
  }
778
801
  else {
779
- log?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`);
802
+ refreshLog?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`);
780
803
  await sleep(minRefreshIntervalMs, signal);
781
804
  }
782
805
  }
783
806
  catch (err) {
784
807
  if (signal.aborted)
785
808
  break;
786
- log?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${err}`);
809
+ refreshLog?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${err}`);
787
810
  await sleep(retryDelayMs, signal);
788
811
  }
789
812
  }
790
813
  backgroundRefreshControllers.delete(appId);
791
- log?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`);
814
+ refreshLog?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`);
792
815
  };
793
816
  refreshLoop().catch((err) => {
794
817
  backgroundRefreshControllers.delete(appId);
795
- log?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`);
818
+ refreshLog?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`);
796
819
  });
797
820
  }
798
821
  /**
@@ -844,10 +867,13 @@ async function sleep(ms, signal) {
844
867
  * - 后续分片携带 stream_msg_id 和递增 msg_seq
845
868
  * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
846
869
  *
870
+ * 仅在终结分片(input_state=DONE)时触发 refIdx 回调,
871
+ * 中间分片直接调用 apiRequest,避免存入过多无效的中间态数据。
872
+ *
847
873
  * @param accessToken - access_token
848
874
  * @param openid - 用户 openid
849
875
  * @param req - 流式消息请求体
850
- * @returns 流式消息响应
876
+ * @returns 消息响应(复用 MessageResponse,错误会直接抛出异常)
851
877
  */
852
878
  export async function sendC2CStreamMessage(accessToken, openid, req) {
853
879
  const path = `/v2/users/${openid}/stream_messages`;
@@ -1,6 +1,6 @@
1
1
  import { applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
2
2
  import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
3
- import { sendText, sendMedia } from "./outbound.js";
3
+ import { sendText, sendMedia, resolveUserFacingMediaError } from "./outbound.js";
4
4
  import { startGateway } from "./gateway.js";
5
5
  import { qqbotOnboardingAdapter } from "./onboarding.js";
6
6
  import { getQQBotRuntime } from "./runtime.js";
@@ -17,6 +17,16 @@ export function chunkText(text, limit) {
17
17
  const runtime = getQQBotRuntime();
18
18
  return runtime.channel.text.chunkMarkdownText(text, limit);
19
19
  }
20
+ function buildChannelMediaError(result) {
21
+ const err = new Error(resolveUserFacingMediaError(result));
22
+ if (result.errorCode) {
23
+ err.code = result.errorCode;
24
+ }
25
+ if (result.qqBizCode !== undefined) {
26
+ err.qqBizCode = result.qqBizCode;
27
+ }
28
+ return err;
29
+ }
20
30
  export const qqbotPlugin = {
21
31
  id: "qqbot",
22
32
  meta: {
@@ -264,18 +274,12 @@ export const qqbotPlugin = {
264
274
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
265
275
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
266
276
  // 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
267
- // 用于非 gateway deliver 场景(如 API 直接发送、cron 等)。
268
- // gateway 消息响应走的是 deliver 回调 sendPlainReply,不经过此处。
269
- // 框架拿到 error 后不一定会给用户发文字兜底,所以这里主动发一条。
277
+ // 由框架 deliver.js (deliverOutboundPayloads) message-actions 调用。
278
+ // throw Error 后,框架 pi-tool-definition-adapter 会将错误转化为
279
+ // tool 的 { status: "error" } 返回给 AI 模型,模型会自行生成错误回复给用户。
280
+ // 因此此处不应主动发送兜底文本,否则会与模型的回复重复。
270
281
  if (result.error) {
271
- try {
272
- const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
273
- console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
274
- }
275
- catch (fallbackErr) {
276
- console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
277
- }
278
- throw new Error(result.error);
282
+ throw buildChannelMediaError(result);
279
283
  }
280
284
  return {
281
285
  channel: "qqbot",