@tencent-connect/openclaw-qqbot 1.6.5-alpha.7 → 1.6.6-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 (59) hide show
  1. package/README.md +3 -2
  2. package/README.zh.md +3 -2
  3. package/dist/src/api.d.ts +105 -1
  4. package/dist/src/api.js +153 -5
  5. package/dist/src/channel.d.ts +1 -1
  6. package/dist/src/channel.js +25 -10
  7. package/dist/src/config.js +3 -10
  8. package/dist/src/gateway.js +149 -4
  9. package/dist/src/image-server.d.ts +27 -8
  10. package/dist/src/image-server.js +179 -71
  11. package/dist/src/inbound-attachments.d.ts +3 -1
  12. package/dist/src/inbound-attachments.js +28 -14
  13. package/dist/src/outbound-deliver.js +77 -148
  14. package/dist/src/outbound.d.ts +6 -4
  15. package/dist/src/outbound.js +266 -442
  16. package/dist/src/reply-dispatcher.js +4 -4
  17. package/dist/src/slash-commands.js +227 -6
  18. package/dist/src/streaming.d.ts +250 -0
  19. package/dist/src/streaming.js +914 -0
  20. package/dist/src/types.d.ts +71 -0
  21. package/dist/src/types.js +17 -1
  22. package/dist/src/utils/audio-convert.d.ts +9 -0
  23. package/dist/src/utils/audio-convert.js +51 -0
  24. package/dist/src/utils/chunked-upload.d.ts +59 -0
  25. package/dist/src/utils/chunked-upload.js +289 -0
  26. package/dist/src/utils/file-utils.d.ts +7 -1
  27. package/dist/src/utils/file-utils.js +24 -2
  28. package/dist/src/utils/media-send.d.ts +147 -0
  29. package/dist/src/utils/media-send.js +434 -0
  30. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  31. package/dist/src/utils/ssrf-guard.js +91 -0
  32. package/index.ts +1 -1
  33. package/openclaw.plugin.json +1 -1
  34. package/package.json +4 -2
  35. package/preload.cjs +33 -0
  36. package/scripts/link-sdk-core.cjs +185 -0
  37. package/scripts/upgrade-via-npm.sh +83 -24
  38. package/scripts/upgrade-via-source.sh +201 -127
  39. package/skills/qqbot-media/SKILL.md +9 -5
  40. package/src/api.ts +284 -5
  41. package/src/channel.ts +31 -18
  42. package/src/config.ts +3 -10
  43. package/src/gateway.ts +150 -4
  44. package/src/image-server.ts +213 -77
  45. package/src/inbound-attachments.ts +32 -15
  46. package/src/outbound-deliver.ts +77 -157
  47. package/src/outbound.ts +304 -451
  48. package/src/reply-dispatcher.ts +4 -4
  49. package/src/slash-commands.ts +250 -7
  50. package/src/streaming.ts +1102 -0
  51. package/src/types.ts +80 -0
  52. package/src/utils/audio-convert.ts +56 -0
  53. package/src/utils/chunked-upload.ts +419 -0
  54. package/src/utils/file-utils.ts +28 -2
  55. package/src/utils/media-send.ts +563 -0
  56. package/src/utils/ssrf-guard.ts +102 -0
  57. package/dist/src/user-messages.d.ts +0 -8
  58. package/dist/src/user-messages.js +0 -8
  59. package/src/user-messages.ts +0 -7
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.5`
13
+ ### 🚀 Current Version: `v1.6.6`
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/)
@@ -46,6 +46,7 @@ Scan to join the QQ group chat
46
46
  | 📝 **Markdown** | Full Markdown formatting support |
47
47
  | 🛠️ **Commands** | Native OpenClaw command integration |
48
48
  | 💬 **Quoted Context** | Resolve QQ `REFIDX_*` quoted messages and inject quote body into AI context |
49
+ | 📦 **Large File Support** | Auto chunked upload for large files (parallel upload with retry), up to 100 MB |
49
50
 
50
51
  ---
51
52
 
@@ -129,7 +130,7 @@ This capability depends on OpenClaw cron scheduling and proactive messaging. If
129
130
  >
130
131
  > **QQBot**: *(sends a .txt file)*
131
132
 
132
- AI can send files directly. Any format, up to 20MB.
133
+ AI can send files directly. Any format, up to 100MB. Large files are automatically chunked and uploaded in parallel.
133
134
 
134
135
  <img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="File Sending Demo" />
135
136
 
package/README.zh.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  **让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
11
11
 
12
- ### 🚀 当前版本: `v1.6.5`
12
+ ### 🚀 当前版本: `v1.6.6`
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/)
@@ -41,6 +41,7 @@
41
41
  | 📝 **Markdown** | 完整支持 Markdown 格式消息 |
42
42
  | 🛠️ **原生命令** | 支持 OpenClaw 原生命令 |
43
43
  | 💬 **引用上下文** | 解析 QQ `REFIDX_*` 引用消息,并将引用内容注入 AI 上下文 |
44
+ | 📦 **大文件支持** | 大文件自动分片并行上传,最大支持 100 MB |
44
45
 
45
46
  ---
46
47
 
@@ -124,7 +125,7 @@ AI 可直接发送语音消息。格式:mp3/wav/silk/ogg,无需安装 ffmpeg
124
125
  >
125
126
  > **QQBot**:*(发送 .txt 文件)*
126
127
 
127
- AI 可直接发送文件。任意格式,最大 20MB。
128
+ AI 可直接发送文件。任意格式,最大 100MB。大文件自动分片并行上传。
128
129
 
129
130
  <img width="360" src="docs/images/17cada70df90185d45a2d6dd36e92f2f_720.jpg" alt="发文件演示" />
130
131
 
package/dist/src/api.d.ts CHANGED
@@ -2,6 +2,12 @@
2
2
  * QQ Bot API 鉴权和请求封装
3
3
  * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
4
  */
5
+ /** API 请求错误,携带 HTTP status code */
6
+ export declare class ApiError extends Error {
7
+ readonly status: number;
8
+ readonly path: string;
9
+ constructor(message: string, status: number, path: string);
10
+ }
5
11
  export declare const PLUGIN_USER_AGENT: string;
6
12
  /** 出站消息元信息(结构化存储,不做预格式化) */
7
13
  export interface OutboundMeta {
@@ -25,7 +31,6 @@ type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
25
31
  export declare function onMessageSent(callback: OnMessageSentCallback): void;
26
32
  /**
27
33
  * 初始化 API 配置
28
- * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
29
34
  */
30
35
  export declare function initApiConfig(options: {
31
36
  markdownSupport?: boolean;
@@ -108,6 +113,90 @@ export interface UploadMediaResponse {
108
113
  ttl: number;
109
114
  id?: string;
110
115
  }
116
+ /** 分片信息 */
117
+ export interface UploadPart {
118
+ /** 分片索引(从 1 开始) */
119
+ index: number;
120
+ /** 预签名上传链接 */
121
+ presigned_url: string;
122
+ }
123
+ /** 申请上传响应 */
124
+ export interface UploadPrepareResponse {
125
+ /** 上传任务 ID */
126
+ upload_id: string;
127
+ /** 分块大小(字节) */
128
+ block_size: number;
129
+ /** 分片列表(含预签名链接) */
130
+ parts: UploadPart[];
131
+ }
132
+ /** 完成文件上传响应(与 UploadMediaResponse 一致) */
133
+ export interface MediaUploadResponse {
134
+ /** 文件 UUID */
135
+ file_uuid: string;
136
+ /** 文件信息(用于发送消息),是 InnerUploadRsp 的序列化 */
137
+ file_info: string;
138
+ /** 文件信息过期时长(秒) */
139
+ ttl: number;
140
+ }
141
+ /** 申请上传时的文件哈希信息 */
142
+ export interface UploadPrepareHashes {
143
+ /** 整个文件的 MD5(十六进制) */
144
+ md5: string;
145
+ /** 整个文件的 SHA1(十六进制) */
146
+ sha1: string;
147
+ /** 文件前 10002432 Bytes 的 MD5(十六进制);文件不足该大小时为整文件 MD5 */
148
+ md5_10m: string;
149
+ }
150
+ /**
151
+ * 申请上传(C2C)
152
+ * POST /v2/users/{user_id}/upload_prepare
153
+ *
154
+ * @param accessToken - 访问令牌
155
+ * @param userId - 用户 openid
156
+ * @param fileType - 业务类型(1=图片, 2=视频, 3=语音, 4=文件)
157
+ * @param fileName - 文件名
158
+ * @param fileSize - 文件大小(字节)
159
+ * @param hashes - 文件哈希信息(md5, sha1, md5_10m)
160
+ * @returns 上传任务 ID、分块大小、分片预签名链接列表
161
+ */
162
+ export declare function c2cUploadPrepare(accessToken: string, userId: string, fileType: MediaFileType, fileName: string, fileSize: number, hashes: UploadPrepareHashes): Promise<UploadPrepareResponse>;
163
+ /**
164
+ * 完成分片上传(C2C)
165
+ * POST /v2/users/{user_id}/upload_part_finish
166
+ *
167
+ * @param accessToken - 访问令牌
168
+ * @param userId - 用户 openid
169
+ * @param uploadId - 上传任务 ID
170
+ * @param partIndex - 分片索引(从 1 开始)
171
+ * @param blockSize - 分块大小(字节)
172
+ * @param md5 - 分片数据的 MD5(十六进制)
173
+ */
174
+ export declare function c2cUploadPartFinish(accessToken: string, userId: string, uploadId: string, partIndex: number, blockSize: number, md5: string): Promise<void>;
175
+ /**
176
+ * 完成文件上传(C2C)
177
+ * POST /v2/users/{user_id}/files
178
+ *
179
+ * @param accessToken - 访问令牌
180
+ * @param userId - 用户 openid
181
+ * @param uploadId - 上传任务 ID
182
+ * @returns 文件信息(file_uuid, file_info, ttl)
183
+ */
184
+ export declare function c2cCompleteUpload(accessToken: string, userId: string, uploadId: string): Promise<MediaUploadResponse>;
185
+ /**
186
+ * 申请上传(Group)
187
+ * POST /v2/groups/{group_id}/upload_prepare
188
+ */
189
+ export declare function groupUploadPrepare(accessToken: string, groupId: string, fileType: MediaFileType, fileName: string, fileSize: number, hashes: UploadPrepareHashes): Promise<UploadPrepareResponse>;
190
+ /**
191
+ * 完成分片上传(Group)
192
+ * POST /v2/groups/{group_id}/upload_part_finish
193
+ */
194
+ export declare function groupUploadPartFinish(accessToken: string, groupId: string, uploadId: string, partIndex: number, blockSize: number, md5: string): Promise<void>;
195
+ /**
196
+ * 完成文件上传(Group)
197
+ * POST /v2/groups/{group_id}/files
198
+ */
199
+ export declare function groupCompleteUpload(accessToken: string, groupId: string, uploadId: string): Promise<MediaUploadResponse>;
111
200
  export declare function uploadC2CMedia(accessToken: string, openid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
112
201
  export declare function uploadGroupMedia(accessToken: string, groupOpenid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
113
202
  export declare function sendC2CMediaMessage(accessToken: string, openid: string, fileInfo: string, msgId?: string, content?: string, meta?: OutboundMeta): Promise<MessageResponse>;
@@ -153,4 +242,19 @@ export declare function startBackgroundTokenRefresh(appId: string, clientSecret:
153
242
  */
154
243
  export declare function stopBackgroundTokenRefresh(appId?: string): void;
155
244
  export declare function isBackgroundTokenRefreshRunning(appId?: string): boolean;
245
+ import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
246
+ /**
247
+ * 发送流式消息(C2C 私聊)
248
+ *
249
+ * 流式协议:
250
+ * - 首次调用时不传 stream_msg_id,由平台返回
251
+ * - 后续分片携带 stream_msg_id 和递增 msg_seq
252
+ * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
253
+ *
254
+ * @param accessToken - access_token
255
+ * @param openid - 用户 openid
256
+ * @param req - 流式消息请求体
257
+ * @returns 流式消息响应
258
+ */
259
+ export declare function sendC2CStreamMessage(accessToken: string, openid: string, req: StreamMessageRequest): Promise<StreamMessageResponse>;
156
260
  export {};
package/dist/src/api.js CHANGED
@@ -5,6 +5,18 @@
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
+ // ============ 自定义错误 ============
9
+ /** API 请求错误,携带 HTTP status code */
10
+ export class ApiError extends Error {
11
+ status;
12
+ path;
13
+ constructor(message, status, path) {
14
+ super(message);
15
+ this.status = status;
16
+ this.path = path;
17
+ this.name = "ApiError";
18
+ }
19
+ }
8
20
  const API_BASE = "https://api.sgroup.qq.com";
9
21
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
10
22
  // ============ Plugin User-Agent ============
@@ -26,7 +38,6 @@ export function onMessageSent(callback) {
26
38
  }
27
39
  /**
28
40
  * 初始化 API 配置
29
- * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
30
41
  */
31
42
  export function initApiConfig(options) {
32
43
  currentMarkdownSupport = options.markdownSupport === true;
@@ -248,17 +259,17 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
248
259
  : res.status === 429
249
260
  ? "请求过于频繁,已被限流"
250
261
  : `开放平台返回 HTTP ${res.status}`;
251
- throw new Error(`${statusHint}(${path}),请稍后重试`);
262
+ throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
252
263
  }
253
264
  // JSON 错误响应
254
265
  try {
255
266
  const error = JSON.parse(rawBody);
256
- throw new Error(`API Error [${path}]: ${error.message ?? rawBody}`);
267
+ throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
257
268
  }
258
269
  catch (parseErr) {
259
- if (parseErr instanceof Error && parseErr.message.startsWith("API Error"))
270
+ if (parseErr instanceof ApiError)
260
271
  throw parseErr;
261
- throw new Error(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`);
272
+ throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
262
273
  }
263
274
  }
264
275
  // 成功响应但不是 JSON(极端异常情况)
@@ -297,6 +308,51 @@ async function apiRequestWithRetry(accessToken, method, path, body, maxRetries =
297
308
  }
298
309
  throw lastError;
299
310
  }
311
+ // ============ 完成上传重试(无条件,任何错误都重试) ============
312
+ const COMPLETE_UPLOAD_MAX_RETRIES = 2;
313
+ const COMPLETE_UPLOAD_BASE_DELAY_MS = 2000;
314
+ /**
315
+ * 完成上传专用重试:无条件重试所有错误(包括 4xx、5xx、网络错误、超时等)
316
+ * 分片上传完成接口的失败往往是平台侧异步处理未就绪,重试通常能成功
317
+ */
318
+ async function completeUploadWithRetry(accessToken, method, path, body) {
319
+ let lastError = null;
320
+ for (let attempt = 0; attempt <= COMPLETE_UPLOAD_MAX_RETRIES; attempt++) {
321
+ try {
322
+ return await apiRequest(accessToken, method, path, body);
323
+ }
324
+ catch (err) {
325
+ lastError = err instanceof Error ? err : new Error(String(err));
326
+ if (attempt < COMPLETE_UPLOAD_MAX_RETRIES) {
327
+ const delay = COMPLETE_UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
328
+ console.warn(`[qqbot-api] CompleteUpload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
329
+ await new Promise(resolve => setTimeout(resolve, delay));
330
+ }
331
+ }
332
+ }
333
+ throw lastError;
334
+ }
335
+ // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
336
+ const PART_FINISH_MAX_RETRIES = 2;
337
+ const PART_FINISH_BASE_DELAY_MS = 1000;
338
+ async function partFinishWithRetry(accessToken, method, path, body) {
339
+ let lastError = null;
340
+ for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
341
+ try {
342
+ await apiRequest(accessToken, method, path, body);
343
+ return;
344
+ }
345
+ catch (err) {
346
+ lastError = err instanceof Error ? err : new Error(String(err));
347
+ if (attempt < PART_FINISH_MAX_RETRIES) {
348
+ const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
349
+ console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
350
+ await new Promise(resolve => setTimeout(resolve, delay));
351
+ }
352
+ }
353
+ }
354
+ throw lastError;
355
+ }
300
356
  export async function getGatewayUrl(accessToken) {
301
357
  const data = await apiRequest(accessToken, "GET", "/gateway");
302
358
  return data.url;
@@ -405,6 +461,68 @@ export var MediaFileType;
405
461
  MediaFileType[MediaFileType["VOICE"] = 3] = "VOICE";
406
462
  MediaFileType[MediaFileType["FILE"] = 4] = "FILE";
407
463
  })(MediaFileType || (MediaFileType = {}));
464
+ /**
465
+ * 申请上传(C2C)
466
+ * POST /v2/users/{user_id}/upload_prepare
467
+ *
468
+ * @param accessToken - 访问令牌
469
+ * @param userId - 用户 openid
470
+ * @param fileType - 业务类型(1=图片, 2=视频, 3=语音, 4=文件)
471
+ * @param fileName - 文件名
472
+ * @param fileSize - 文件大小(字节)
473
+ * @param hashes - 文件哈希信息(md5, sha1, md5_10m)
474
+ * @returns 上传任务 ID、分块大小、分片预签名链接列表
475
+ */
476
+ export async function c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes) {
477
+ return apiRequest(accessToken, "POST", `/v2/users/${userId}/upload_prepare`, { file_type: fileType, file_name: fileName, file_size: fileSize, md5: hashes.md5, sha1: hashes.sha1, md5_10m: hashes.md5_10m });
478
+ }
479
+ /**
480
+ * 完成分片上传(C2C)
481
+ * POST /v2/users/{user_id}/upload_part_finish
482
+ *
483
+ * @param accessToken - 访问令牌
484
+ * @param userId - 用户 openid
485
+ * @param uploadId - 上传任务 ID
486
+ * @param partIndex - 分片索引(从 1 开始)
487
+ * @param blockSize - 分块大小(字节)
488
+ * @param md5 - 分片数据的 MD5(十六进制)
489
+ */
490
+ export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5) {
491
+ await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
492
+ }
493
+ /**
494
+ * 完成文件上传(C2C)
495
+ * POST /v2/users/{user_id}/files
496
+ *
497
+ * @param accessToken - 访问令牌
498
+ * @param userId - 用户 openid
499
+ * @param uploadId - 上传任务 ID
500
+ * @returns 文件信息(file_uuid, file_info, ttl)
501
+ */
502
+ export async function c2cCompleteUpload(accessToken, userId, uploadId) {
503
+ return completeUploadWithRetry(accessToken, "POST", `/v2/users/${userId}/files`, { upload_id: uploadId });
504
+ }
505
+ /**
506
+ * 申请上传(Group)
507
+ * POST /v2/groups/{group_id}/upload_prepare
508
+ */
509
+ export async function groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes) {
510
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupId}/upload_prepare`, { file_type: fileType, file_name: fileName, file_size: fileSize, md5: hashes.md5, sha1: hashes.sha1, md5_10m: hashes.md5_10m });
511
+ }
512
+ /**
513
+ * 完成分片上传(Group)
514
+ * POST /v2/groups/{group_id}/upload_part_finish
515
+ */
516
+ export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5) {
517
+ await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 });
518
+ }
519
+ /**
520
+ * 完成文件上传(Group)
521
+ * POST /v2/groups/{group_id}/files
522
+ */
523
+ export async function groupCompleteUpload(accessToken, groupId, uploadId) {
524
+ return completeUploadWithRetry(accessToken, "POST", `/v2/groups/${groupId}/files`, { upload_id: uploadId });
525
+ }
408
526
  export async function uploadC2CMedia(accessToken, openid, fileType, url, fileData, srvSendMsg = false, fileName) {
409
527
  if (!url && !fileData)
410
528
  throw new Error("uploadC2CMedia: url or fileData is required");
@@ -619,3 +737,33 @@ async function sleep(ms, signal) {
619
737
  }
620
738
  });
621
739
  }
740
+ /**
741
+ * 发送流式消息(C2C 私聊)
742
+ *
743
+ * 流式协议:
744
+ * - 首次调用时不传 stream_msg_id,由平台返回
745
+ * - 后续分片携带 stream_msg_id 和递增 msg_seq
746
+ * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
747
+ *
748
+ * @param accessToken - access_token
749
+ * @param openid - 用户 openid
750
+ * @param req - 流式消息请求体
751
+ * @returns 流式消息响应
752
+ */
753
+ export async function sendC2CStreamMessage(accessToken, openid, req) {
754
+ const path = `/v2/users/${openid}/stream_messages`;
755
+ const body = {
756
+ input_mode: req.input_mode,
757
+ input_state: req.input_state,
758
+ content_type: req.content_type,
759
+ content_raw: req.content_raw,
760
+ event_id: req.event_id,
761
+ msg_id: req.msg_id,
762
+ msg_seq: req.msg_seq,
763
+ index: req.index,
764
+ };
765
+ if (req.stream_msg_id) {
766
+ body.stream_msg_id = req.stream_msg_id;
767
+ }
768
+ return apiRequest(accessToken, "POST", path, body);
769
+ }
@@ -1,4 +1,4 @@
1
- import { type ChannelPlugin } from "openclaw/plugin-sdk";
1
+ import { type ChannelPlugin } from "openclaw/plugin-sdk/core";
2
2
  import type { ResolvedQQBotAccount } from "./types.js";
3
3
  /** QQ Bot 单条消息文本长度上限 */
4
4
  export declare const TEXT_CHUNK_LIMIT = 5000;
@@ -1,4 +1,4 @@
1
- import { applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk";
1
+ import { applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
2
2
  import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId } from "./config.js";
3
3
  import { sendText, sendMedia } from "./outbound.js";
4
4
  import { startGateway } from "./gateway.js";
@@ -40,6 +40,7 @@ export const qqbotPlugin = {
40
40
  },
41
41
  reload: { configPrefixes: ["channels.qqbot"] },
42
42
  // CLI onboarding wizard
43
+ // @ts-expect-error onboarding removed from ChannelPlugin type in 2026.3.23 but still supported at runtime
43
44
  onboarding: qqbotOnboardingAdapter,
44
45
  config: {
45
46
  listAccountIds: (cfg) => listQQBotAccountIds(cfg),
@@ -76,7 +77,7 @@ export const qqbotPlugin = {
76
77
  }),
77
78
  // 关键:解析 allowFrom 配置,用于命令授权
78
79
  resolveAllowFrom: ({ cfg, accountId }) => {
79
- const account = resolveQQBotAccount(cfg, accountId);
80
+ const account = resolveQQBotAccount(cfg, accountId ?? undefined);
80
81
  const allowFrom = account.config?.allowFrom ?? [];
81
82
  console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
82
83
  return allowFrom.map((entry) => String(entry));
@@ -201,28 +202,42 @@ export const qqbotPlugin = {
201
202
  sendText: async ({ to, text, accountId, replyToId, cfg }) => {
202
203
  console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
203
204
  console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
204
- const account = resolveQQBotAccount(cfg, accountId);
205
+ const account = resolveQQBotAccount(cfg, accountId ?? undefined);
205
206
  initApiConfig({ markdownSupport: account.markdownSupport });
206
207
  console.log(`[qqbot:channel] sendText resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
207
208
  const result = await sendText({ to, text, accountId, replyToId, account });
208
209
  console.log(`[qqbot:channel] sendText result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
210
+ if (result.error)
211
+ throw new Error(result.error);
209
212
  return {
210
213
  channel: "qqbot",
211
- messageId: result.messageId,
212
- error: result.error ? new Error(result.error) : undefined,
214
+ messageId: result.messageId ?? "",
213
215
  };
214
216
  },
215
217
  sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
216
218
  console.log(`[qqbot:channel] sendMedia called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, mediaUrl=${mediaUrl?.slice(0, 80)}, text.length=${text?.length ?? 0}`);
217
- const account = resolveQQBotAccount(cfg, accountId);
219
+ const account = resolveQQBotAccount(cfg, accountId ?? undefined);
218
220
  initApiConfig({ markdownSupport: account.markdownSupport });
219
221
  console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
220
222
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
221
223
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
224
+ // 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
225
+ // 用于非 gateway deliver 场景(如 API 直接发送、cron 等)。
226
+ // gateway 消息响应走的是 deliver 回调 → sendPlainReply,不经过此处。
227
+ // 框架拿到 error 后不一定会给用户发文字兜底,所以这里主动发一条。
228
+ if (result.error) {
229
+ try {
230
+ const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
231
+ console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
232
+ }
233
+ catch (fallbackErr) {
234
+ console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
235
+ }
236
+ throw new Error(result.error);
237
+ }
222
238
  return {
223
239
  channel: "qqbot",
224
- messageId: result.messageId,
225
- error: result.error ? new Error(result.error) : undefined,
240
+ messageId: result.messageId ?? "",
226
241
  };
227
242
  },
228
243
  },
@@ -343,8 +358,8 @@ export const qqbotPlugin = {
343
358
  enabled: account?.enabled ?? false,
344
359
  configured: Boolean(account?.appId && account?.clientSecret),
345
360
  tokenSource: account?.secretSource,
346
- running: runtime?.running ?? false,
347
- connected: runtime?.connected ?? false,
361
+ running: Boolean(runtime?.running ?? false),
362
+ connected: Boolean(runtime?.connected ?? false),
348
363
  lastConnectedAt: runtime?.lastConnectedAt ?? null,
349
364
  lastError: runtime?.lastError ?? null,
350
365
  lastInboundAt: runtime?.lastInboundAt ?? null,
@@ -52,17 +52,10 @@ export function resolveQQBotAccount(cfg, accountId) {
52
52
  let clientSecret = "";
53
53
  let secretSource = "none";
54
54
  if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
55
- // 默认账户从顶层读取
55
+ // 默认账户从顶层读取(展开所有字段,避免遗漏新增配置项)
56
+ const { accounts: _accounts, ...topLevelConfig } = qqbot ?? {};
56
57
  accountConfig = {
57
- enabled: qqbot?.enabled,
58
- name: qqbot?.name,
59
- appId: qqbot?.appId,
60
- clientSecret: qqbot?.clientSecret,
61
- clientSecretFile: qqbot?.clientSecretFile,
62
- dmPolicy: qqbot?.dmPolicy,
63
- allowFrom: qqbot?.allowFrom,
64
- systemPrompt: qqbot?.systemPrompt,
65
- imageServerBaseUrl: qqbot?.imageServerBaseUrl,
58
+ ...topLevelConfig,
66
59
  markdownSupport: qqbot?.markdownSupport ?? true,
67
60
  };
68
61
  appId = normalizeAppId(qqbot?.appId);