@tencent-connect/openclaw-qqbot 1.6.5 → 1.6.6-alpha.3

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 (58) hide show
  1. package/dist/src/api.d.ts +127 -1
  2. package/dist/src/api.js +243 -5
  3. package/dist/src/channel.d.ts +1 -1
  4. package/dist/src/channel.js +25 -10
  5. package/dist/src/config.js +3 -10
  6. package/dist/src/gateway.js +149 -4
  7. package/dist/src/image-server.d.ts +27 -8
  8. package/dist/src/image-server.js +179 -71
  9. package/dist/src/inbound-attachments.d.ts +3 -1
  10. package/dist/src/inbound-attachments.js +28 -14
  11. package/dist/src/outbound-deliver.js +78 -148
  12. package/dist/src/outbound.d.ts +6 -4
  13. package/dist/src/outbound.js +278 -442
  14. package/dist/src/reply-dispatcher.js +4 -4
  15. package/dist/src/slash-commands.js +227 -6
  16. package/dist/src/streaming.d.ts +250 -0
  17. package/dist/src/streaming.js +914 -0
  18. package/dist/src/types.d.ts +71 -0
  19. package/dist/src/types.js +17 -1
  20. package/dist/src/utils/audio-convert.d.ts +9 -0
  21. package/dist/src/utils/audio-convert.js +51 -0
  22. package/dist/src/utils/chunked-upload.d.ts +68 -0
  23. package/dist/src/utils/chunked-upload.js +341 -0
  24. package/dist/src/utils/file-utils.d.ts +7 -1
  25. package/dist/src/utils/file-utils.js +24 -2
  26. package/dist/src/utils/media-send.d.ts +148 -0
  27. package/dist/src/utils/media-send.js +456 -0
  28. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  29. package/dist/src/utils/ssrf-guard.js +91 -0
  30. package/index.ts +1 -1
  31. package/openclaw.plugin.json +1 -1
  32. package/package.json +6 -3
  33. package/preload.cjs +33 -0
  34. package/scripts/link-sdk-core.cjs +185 -0
  35. package/scripts/prebuild-stub.cjs +172 -0
  36. package/scripts/upgrade-via-npm.sh +79 -24
  37. package/scripts/upgrade-via-source.sh +76 -8
  38. package/skills/qqbot-media/SKILL.md +9 -5
  39. package/src/api.ts +396 -6
  40. package/src/channel.ts +31 -18
  41. package/src/config.ts +3 -10
  42. package/src/gateway.ts +150 -4
  43. package/src/image-server.ts +213 -77
  44. package/src/inbound-attachments.ts +32 -15
  45. package/src/outbound-deliver.ts +78 -157
  46. package/src/outbound.ts +316 -451
  47. package/src/reply-dispatcher.ts +4 -4
  48. package/src/slash-commands.ts +250 -7
  49. package/src/streaming.ts +1102 -0
  50. package/src/types.ts +80 -0
  51. package/src/utils/audio-convert.ts +56 -0
  52. package/src/utils/chunked-upload.ts +483 -0
  53. package/src/utils/file-utils.ts +28 -2
  54. package/src/utils/media-send.ts +585 -0
  55. package/src/utils/ssrf-guard.ts +102 -0
  56. package/dist/src/user-messages.d.ts +0 -8
  57. package/dist/src/user-messages.js +0 -8
  58. package/src/user-messages.ts +0 -7
package/dist/src/api.d.ts CHANGED
@@ -2,6 +2,20 @@
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
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
10
+ readonly bizCode?: number | undefined;
11
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
12
+ readonly bizMessage?: string | undefined;
13
+ constructor(message: string, status: number, path: string,
14
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
15
+ bizCode?: number | undefined,
16
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
17
+ bizMessage?: string | undefined);
18
+ }
5
19
  export declare const PLUGIN_USER_AGENT: string;
6
20
  /** 出站消息元信息(结构化存储,不做预格式化) */
7
21
  export interface OutboundMeta {
@@ -25,7 +39,6 @@ type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
25
39
  export declare function onMessageSent(callback: OnMessageSentCallback): void;
26
40
  /**
27
41
  * 初始化 API 配置
28
- * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
29
42
  */
30
43
  export declare function initApiConfig(options: {
31
44
  markdownSupport?: boolean;
@@ -64,6 +77,16 @@ export declare function getNextMsgSeq(_msgId: string): number;
64
77
  * API 请求封装
65
78
  */
66
79
  export declare function apiRequest<T = unknown>(accessToken: string, method: string, path: string, body?: unknown, timeoutMs?: number): Promise<T>;
80
+ /**
81
+ * 需要持续重试的业务错误码集合
82
+ * 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
83
+ */
84
+ export declare const PART_FINISH_RETRYABLE_CODES: Set<number>;
85
+ /**
86
+ * upload_prepare 接口命中此错误码时,使用回包中的 message 字段作为兜底文案发送给用户
87
+ * 而非走通用的"文件发送失败,请稍后重试"
88
+ */
89
+ export declare const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
67
90
  export declare function getGatewayUrl(accessToken: string): Promise<string>;
68
91
  export interface MessageResponse {
69
92
  id: string;
@@ -108,6 +131,94 @@ export interface UploadMediaResponse {
108
131
  ttl: number;
109
132
  id?: string;
110
133
  }
134
+ /** 分片信息 */
135
+ export interface UploadPart {
136
+ /** 分片索引(从 1 开始) */
137
+ index: number;
138
+ /** 预签名上传链接 */
139
+ presigned_url: string;
140
+ }
141
+ /** 申请上传响应 */
142
+ export interface UploadPrepareResponse {
143
+ /** 上传任务 ID */
144
+ upload_id: string;
145
+ /** 分块大小(字节) */
146
+ block_size: number;
147
+ /** 分片列表(含预签名链接) */
148
+ parts: UploadPart[];
149
+ /** 上传并发数(由服务端控制,可选,不返回时使用客户端默认值) */
150
+ concurrency?: number;
151
+ /** upload_part_finish 特定错误码的重试超时时间(秒),由服务端控制,客户端上限 10 分钟 */
152
+ retry_timeout?: number;
153
+ }
154
+ /** 完成文件上传响应(与 UploadMediaResponse 一致) */
155
+ export interface MediaUploadResponse {
156
+ /** 文件 UUID */
157
+ file_uuid: string;
158
+ /** 文件信息(用于发送消息),是 InnerUploadRsp 的序列化 */
159
+ file_info: string;
160
+ /** 文件信息过期时长(秒) */
161
+ ttl: number;
162
+ }
163
+ /** 申请上传时的文件哈希信息 */
164
+ export interface UploadPrepareHashes {
165
+ /** 整个文件的 MD5(十六进制) */
166
+ md5: string;
167
+ /** 整个文件的 SHA1(十六进制) */
168
+ sha1: string;
169
+ /** 文件前 10002432 Bytes 的 MD5(十六进制);文件不足该大小时为整文件 MD5 */
170
+ md5_10m: string;
171
+ }
172
+ /**
173
+ * 申请上传(C2C)
174
+ * POST /v2/users/{user_id}/upload_prepare
175
+ *
176
+ * @param accessToken - 访问令牌
177
+ * @param userId - 用户 openid
178
+ * @param fileType - 业务类型(1=图片, 2=视频, 3=语音, 4=文件)
179
+ * @param fileName - 文件名
180
+ * @param fileSize - 文件大小(字节)
181
+ * @param hashes - 文件哈希信息(md5, sha1, md5_10m)
182
+ * @returns 上传任务 ID、分块大小、分片预签名链接列表
183
+ */
184
+ export declare function c2cUploadPrepare(accessToken: string, userId: string, fileType: MediaFileType, fileName: string, fileSize: number, hashes: UploadPrepareHashes): Promise<UploadPrepareResponse>;
185
+ /**
186
+ * 完成分片上传(C2C)
187
+ * POST /v2/users/{user_id}/upload_part_finish
188
+ *
189
+ * @param accessToken - 访问令牌
190
+ * @param userId - 用户 openid
191
+ * @param uploadId - 上传任务 ID
192
+ * @param partIndex - 分片索引(从 1 开始)
193
+ * @param blockSize - 分块大小(字节)
194
+ * @param md5 - 分片数据的 MD5(十六进制)
195
+ */
196
+ export declare function c2cUploadPartFinish(accessToken: string, userId: string, uploadId: string, partIndex: number, blockSize: number, md5: string, retryTimeoutMs?: number): Promise<void>;
197
+ /**
198
+ * 完成文件上传(C2C)
199
+ * POST /v2/users/{user_id}/files
200
+ *
201
+ * @param accessToken - 访问令牌
202
+ * @param userId - 用户 openid
203
+ * @param uploadId - 上传任务 ID
204
+ * @returns 文件信息(file_uuid, file_info, ttl)
205
+ */
206
+ export declare function c2cCompleteUpload(accessToken: string, userId: string, uploadId: string): Promise<MediaUploadResponse>;
207
+ /**
208
+ * 申请上传(Group)
209
+ * POST /v2/groups/{group_id}/upload_prepare
210
+ */
211
+ export declare function groupUploadPrepare(accessToken: string, groupId: string, fileType: MediaFileType, fileName: string, fileSize: number, hashes: UploadPrepareHashes): Promise<UploadPrepareResponse>;
212
+ /**
213
+ * 完成分片上传(Group)
214
+ * POST /v2/groups/{group_id}/upload_part_finish
215
+ */
216
+ export declare function groupUploadPartFinish(accessToken: string, groupId: string, uploadId: string, partIndex: number, blockSize: number, md5: string, retryTimeoutMs?: number): Promise<void>;
217
+ /**
218
+ * 完成文件上传(Group)
219
+ * POST /v2/groups/{group_id}/files
220
+ */
221
+ export declare function groupCompleteUpload(accessToken: string, groupId: string, uploadId: string): Promise<MediaUploadResponse>;
111
222
  export declare function uploadC2CMedia(accessToken: string, openid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
112
223
  export declare function uploadGroupMedia(accessToken: string, groupOpenid: string, fileType: MediaFileType, url?: string, fileData?: string, srvSendMsg?: boolean, fileName?: string): Promise<UploadMediaResponse>;
113
224
  export declare function sendC2CMediaMessage(accessToken: string, openid: string, fileInfo: string, msgId?: string, content?: string, meta?: OutboundMeta): Promise<MessageResponse>;
@@ -153,4 +264,19 @@ export declare function startBackgroundTokenRefresh(appId: string, clientSecret:
153
264
  */
154
265
  export declare function stopBackgroundTokenRefresh(appId?: string): void;
155
266
  export declare function isBackgroundTokenRefreshRunning(appId?: string): boolean;
267
+ import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
268
+ /**
269
+ * 发送流式消息(C2C 私聊)
270
+ *
271
+ * 流式协议:
272
+ * - 首次调用时不传 stream_msg_id,由平台返回
273
+ * - 后续分片携带 stream_msg_id 和递增 msg_seq
274
+ * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
275
+ *
276
+ * @param accessToken - access_token
277
+ * @param openid - 用户 openid
278
+ * @param req - 流式消息请求体
279
+ * @returns 流式消息响应
280
+ */
281
+ export declare function sendC2CStreamMessage(accessToken: string, openid: string, req: StreamMessageRequest): Promise<StreamMessageResponse>;
156
282
  export {};
package/dist/src/api.js CHANGED
@@ -5,6 +5,26 @@
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
+ bizCode;
14
+ bizMessage;
15
+ constructor(message, status, path,
16
+ /** 业务错误码(回包中的 code / err_code 字段),不一定存在 */
17
+ bizCode,
18
+ /** 回包中的原始 message 字段(用于向用户展示兜底文案) */
19
+ bizMessage) {
20
+ super(message);
21
+ this.status = status;
22
+ this.path = path;
23
+ this.bizCode = bizCode;
24
+ this.bizMessage = bizMessage;
25
+ this.name = "ApiError";
26
+ }
27
+ }
8
28
  const API_BASE = "https://api.sgroup.qq.com";
9
29
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
10
30
  // ============ Plugin User-Agent ============
@@ -26,7 +46,6 @@ export function onMessageSent(callback) {
26
46
  }
27
47
  /**
28
48
  * 初始化 API 配置
29
- * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
30
49
  */
31
50
  export function initApiConfig(options) {
32
51
  currentMarkdownSupport = options.markdownSupport === true;
@@ -248,17 +267,18 @@ export async function apiRequest(accessToken, method, path, body, timeoutMs) {
248
267
  : res.status === 429
249
268
  ? "请求过于频繁,已被限流"
250
269
  : `开放平台返回 HTTP ${res.status}`;
251
- throw new Error(`${statusHint}(${path}),请稍后重试`);
270
+ throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
252
271
  }
253
272
  // JSON 错误响应
254
273
  try {
255
274
  const error = JSON.parse(rawBody);
256
- throw new Error(`API Error [${path}]: ${error.message ?? rawBody}`);
275
+ const bizCode = error.code ?? error.err_code;
276
+ throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path, bizCode, error.message);
257
277
  }
258
278
  catch (parseErr) {
259
- if (parseErr instanceof Error && parseErr.message.startsWith("API Error"))
279
+ if (parseErr instanceof ApiError)
260
280
  throw parseErr;
261
- throw new Error(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`);
281
+ throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
262
282
  }
263
283
  }
264
284
  // 成功响应但不是 JSON(极端异常情况)
@@ -297,6 +317,132 @@ async function apiRequestWithRetry(accessToken, method, path, body, maxRetries =
297
317
  }
298
318
  throw lastError;
299
319
  }
320
+ // ============ 完成上传重试(无条件,任何错误都重试) ============
321
+ const COMPLETE_UPLOAD_MAX_RETRIES = 2;
322
+ const COMPLETE_UPLOAD_BASE_DELAY_MS = 2000;
323
+ /**
324
+ * 完成上传专用重试:无条件重试所有错误(包括 4xx、5xx、网络错误、超时等)
325
+ * 分片上传完成接口的失败往往是平台侧异步处理未就绪,重试通常能成功
326
+ */
327
+ async function completeUploadWithRetry(accessToken, method, path, body) {
328
+ let lastError = null;
329
+ for (let attempt = 0; attempt <= COMPLETE_UPLOAD_MAX_RETRIES; attempt++) {
330
+ try {
331
+ return await apiRequest(accessToken, method, path, body);
332
+ }
333
+ catch (err) {
334
+ lastError = err instanceof Error ? err : new Error(String(err));
335
+ if (attempt < COMPLETE_UPLOAD_MAX_RETRIES) {
336
+ 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)}`);
338
+ await new Promise(resolve => setTimeout(resolve, delay));
339
+ }
340
+ }
341
+ }
342
+ throw lastError;
343
+ }
344
+ // ============ 分片完成重试 ============
345
+ /** 普通错误最大重试次数 */
346
+ const PART_FINISH_MAX_RETRIES = 2;
347
+ const PART_FINISH_BASE_DELAY_MS = 1000;
348
+ /**
349
+ * 需要持续重试的业务错误码集合
350
+ * 当 upload_part_finish 返回这些错误码时,会以固定 1s 间隔持续重试直到成功或超时
351
+ */
352
+ export const PART_FINISH_RETRYABLE_CODES = new Set([
353
+ 40093001,
354
+ ]);
355
+ /**
356
+ * upload_prepare 接口命中此错误码时,使用回包中的 message 字段作为兜底文案发送给用户
357
+ * 而非走通用的"文件发送失败,请稍后重试"
358
+ */
359
+ export const UPLOAD_PREPARE_FALLBACK_CODE = 40093002;
360
+ /** 特定错误码持续重试的默认超时(服务端未返回 retry_timeout 时的兜底) */
361
+ const PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000;
362
+ /** 特定错误码重试的固定间隔(1 秒) */
363
+ const PART_FINISH_RETRYABLE_INTERVAL_MS = 1000;
364
+ /**
365
+ * 判断错误是否命中"需要持续重试"的业务错误码
366
+ */
367
+ function isRetryableBizCode(err) {
368
+ if (PART_FINISH_RETRYABLE_CODES.size === 0)
369
+ return false;
370
+ if (err instanceof ApiError && err.bizCode !== undefined) {
371
+ return PART_FINISH_RETRYABLE_CODES.has(err.bizCode);
372
+ }
373
+ return false;
374
+ }
375
+ /**
376
+ * 分片完成接口重试策略:
377
+ *
378
+ * 1. 命中 PART_FINISH_RETRYABLE_CODES 的错误码 → 每 1s 重试一次,直到成功或超时
379
+ * 超时时间 = min(API 返回的 retry_timeout, 10 分钟)
380
+ * 2. 其他错误 → 最多重试 PART_FINISH_MAX_RETRIES 次(与之前逻辑一致)
381
+ *
382
+ * 若持续重试超时或普通重试耗尽,抛出错误,调用方(chunkedUpload)
383
+ * 可据此中止后续分片上传。
384
+ *
385
+ * @param retryTimeoutMs - 持续重试的超时时间(毫秒),由 upload_prepare 返回的 retry_timeout 计算得出
386
+ */
387
+ async function partFinishWithRetry(accessToken, method, path, body, retryTimeoutMs) {
388
+ let lastError = null;
389
+ for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
390
+ try {
391
+ await apiRequest(accessToken, method, path, body);
392
+ return;
393
+ }
394
+ catch (err) {
395
+ lastError = err instanceof Error ? err : new Error(String(err));
396
+ // 命中特定错误码 → 进入持续重试模式
397
+ if (isRetryableBizCode(err)) {
398
+ const timeoutMs = retryTimeoutMs ?? PART_FINISH_RETRYABLE_DEFAULT_TIMEOUT_MS;
399
+ console.warn(`[qqbot-api] PartFinish hit retryable bizCode=${err.bizCode}, entering persistent retry (timeout=${timeoutMs / 1000}s, interval=1s)...`);
400
+ await partFinishPersistentRetry(accessToken, method, path, body, timeoutMs);
401
+ return;
402
+ }
403
+ if (attempt < PART_FINISH_MAX_RETRIES) {
404
+ const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
405
+ console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
406
+ await new Promise(resolve => setTimeout(resolve, delay));
407
+ }
408
+ }
409
+ }
410
+ throw lastError;
411
+ }
412
+ /**
413
+ * 特定错误码的持续重试模式
414
+ * 不限次数,仅受总超时时间约束,固定每 1 秒重试一次
415
+ */
416
+ async function partFinishPersistentRetry(accessToken, method, path, body, timeoutMs) {
417
+ const deadline = Date.now() + timeoutMs;
418
+ let attempt = 0;
419
+ let lastError = null;
420
+ while (Date.now() < deadline) {
421
+ try {
422
+ await apiRequest(accessToken, method, path, body);
423
+ console.log(`[qqbot-api] PartFinish persistent retry succeeded after ${attempt} retries`);
424
+ return;
425
+ }
426
+ catch (err) {
427
+ lastError = err instanceof Error ? err : new Error(String(err));
428
+ // 如果不再是可重试的错误码,直接抛出(可能是其他类型的错误)
429
+ if (!isRetryableBizCode(err)) {
430
+ console.error(`[qqbot-api] PartFinish persistent retry: error is no longer retryable (bizCode=${err.bizCode ?? "N/A"}), aborting`);
431
+ throw lastError;
432
+ }
433
+ attempt++;
434
+ const remaining = deadline - Date.now();
435
+ if (remaining <= 0)
436
+ break;
437
+ const actualDelay = Math.min(PART_FINISH_RETRYABLE_INTERVAL_MS, remaining);
438
+ console.warn(`[qqbot-api] PartFinish persistent retry #${attempt}: bizCode=${err.bizCode}, retrying in ${actualDelay}ms (remaining=${Math.round(remaining / 1000)}s)`);
439
+ await new Promise(resolve => setTimeout(resolve, actualDelay));
440
+ }
441
+ }
442
+ // 超时
443
+ console.error(`[qqbot-api] PartFinish persistent retry timed out after ${timeoutMs / 1000}s (${attempt} attempts)`);
444
+ throw new Error(`upload_part_finish 持续重试超时(${timeoutMs / 1000}s, ${attempt} 次重试),中止上传`);
445
+ }
300
446
  export async function getGatewayUrl(accessToken) {
301
447
  const data = await apiRequest(accessToken, "GET", "/gateway");
302
448
  return data.url;
@@ -405,6 +551,68 @@ export var MediaFileType;
405
551
  MediaFileType[MediaFileType["VOICE"] = 3] = "VOICE";
406
552
  MediaFileType[MediaFileType["FILE"] = 4] = "FILE";
407
553
  })(MediaFileType || (MediaFileType = {}));
554
+ /**
555
+ * 申请上传(C2C)
556
+ * POST /v2/users/{user_id}/upload_prepare
557
+ *
558
+ * @param accessToken - 访问令牌
559
+ * @param userId - 用户 openid
560
+ * @param fileType - 业务类型(1=图片, 2=视频, 3=语音, 4=文件)
561
+ * @param fileName - 文件名
562
+ * @param fileSize - 文件大小(字节)
563
+ * @param hashes - 文件哈希信息(md5, sha1, md5_10m)
564
+ * @returns 上传任务 ID、分块大小、分片预签名链接列表
565
+ */
566
+ export async function c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes) {
567
+ 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 });
568
+ }
569
+ /**
570
+ * 完成分片上传(C2C)
571
+ * POST /v2/users/{user_id}/upload_part_finish
572
+ *
573
+ * @param accessToken - 访问令牌
574
+ * @param userId - 用户 openid
575
+ * @param uploadId - 上传任务 ID
576
+ * @param partIndex - 分片索引(从 1 开始)
577
+ * @param blockSize - 分块大小(字节)
578
+ * @param md5 - 分片数据的 MD5(十六进制)
579
+ */
580
+ export async function c2cUploadPartFinish(accessToken, userId, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
581
+ await partFinishWithRetry(accessToken, "POST", `/v2/users/${userId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 }, retryTimeoutMs);
582
+ }
583
+ /**
584
+ * 完成文件上传(C2C)
585
+ * POST /v2/users/{user_id}/files
586
+ *
587
+ * @param accessToken - 访问令牌
588
+ * @param userId - 用户 openid
589
+ * @param uploadId - 上传任务 ID
590
+ * @returns 文件信息(file_uuid, file_info, ttl)
591
+ */
592
+ export async function c2cCompleteUpload(accessToken, userId, uploadId) {
593
+ return completeUploadWithRetry(accessToken, "POST", `/v2/users/${userId}/files`, { upload_id: uploadId });
594
+ }
595
+ /**
596
+ * 申请上传(Group)
597
+ * POST /v2/groups/{group_id}/upload_prepare
598
+ */
599
+ export async function groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes) {
600
+ 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 });
601
+ }
602
+ /**
603
+ * 完成分片上传(Group)
604
+ * POST /v2/groups/{group_id}/upload_part_finish
605
+ */
606
+ export async function groupUploadPartFinish(accessToken, groupId, uploadId, partIndex, blockSize, md5, retryTimeoutMs) {
607
+ await partFinishWithRetry(accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`, { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 }, retryTimeoutMs);
608
+ }
609
+ /**
610
+ * 完成文件上传(Group)
611
+ * POST /v2/groups/{group_id}/files
612
+ */
613
+ export async function groupCompleteUpload(accessToken, groupId, uploadId) {
614
+ return completeUploadWithRetry(accessToken, "POST", `/v2/groups/${groupId}/files`, { upload_id: uploadId });
615
+ }
408
616
  export async function uploadC2CMedia(accessToken, openid, fileType, url, fileData, srvSendMsg = false, fileName) {
409
617
  if (!url && !fileData)
410
618
  throw new Error("uploadC2CMedia: url or fileData is required");
@@ -619,3 +827,33 @@ async function sleep(ms, signal) {
619
827
  }
620
828
  });
621
829
  }
830
+ /**
831
+ * 发送流式消息(C2C 私聊)
832
+ *
833
+ * 流式协议:
834
+ * - 首次调用时不传 stream_msg_id,由平台返回
835
+ * - 后续分片携带 stream_msg_id 和递增 msg_seq
836
+ * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
837
+ *
838
+ * @param accessToken - access_token
839
+ * @param openid - 用户 openid
840
+ * @param req - 流式消息请求体
841
+ * @returns 流式消息响应
842
+ */
843
+ export async function sendC2CStreamMessage(accessToken, openid, req) {
844
+ const path = `/v2/users/${openid}/stream_messages`;
845
+ const body = {
846
+ input_mode: req.input_mode,
847
+ input_state: req.input_state,
848
+ content_type: req.content_type,
849
+ content_raw: req.content_raw,
850
+ event_id: req.event_id,
851
+ msg_id: req.msg_id,
852
+ msg_seq: req.msg_seq,
853
+ index: req.index,
854
+ };
855
+ if (req.stream_msg_id) {
856
+ body.stream_msg_id = req.stream_msg_id;
857
+ }
858
+ return apiRequest(accessToken, "POST", path, body);
859
+ }
@@ -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);