@ryantest/openclaw-qqbot 0.0.2 → 1.6.6-alpha.0

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 (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
package/src/api.ts CHANGED
@@ -3,20 +3,32 @@
3
3
  * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
4
  */
5
5
 
6
- import { createRequire } from "node:module";
7
6
  import os from "node:os";
8
7
  import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
9
8
  import { sanitizeFileName } from "./utils/platform.js";
10
9
 
10
+ // ============ 自定义错误 ============
11
+
12
+ /** API 请求错误,携带 HTTP status code */
13
+ export class ApiError extends Error {
14
+ constructor(
15
+ message: string,
16
+ public readonly status: number,
17
+ public readonly path: string,
18
+ ) {
19
+ super(message);
20
+ this.name = "ApiError";
21
+ }
22
+ }
23
+
11
24
  const API_BASE = "https://api.sgroup.qq.com";
12
25
  const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
13
26
 
14
27
  // ============ Plugin User-Agent ============
15
28
  // 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
16
29
  // 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
17
- const _require = createRequire(import.meta.url);
18
- let _pluginVersion = "unknown";
19
- try { _pluginVersion = _require("../package.json").version ?? "unknown"; } catch { /* fallback */ }
30
+ import { getPackageVersion } from "./utils/pkg-version.js";
31
+ const _pluginVersion = getPackageVersion(import.meta.url);
20
32
  export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
21
33
 
22
34
  // 运行时配置
@@ -53,7 +65,6 @@ export function onMessageSent(callback: OnMessageSentCallback): void {
53
65
 
54
66
  /**
55
67
  * 初始化 API 配置
56
- * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
57
68
  */
58
69
  export function initApiConfig(options: { markdownSupport?: boolean }): void {
59
70
  currentMarkdownSupport = options.markdownSupport === true;
@@ -285,22 +296,48 @@ export async function apiRequest<T = unknown>(
285
296
  const traceId = res.headers.get("x-tps-trace-id") ?? "";
286
297
  console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
287
298
 
288
- let data: T;
289
299
  let rawBody: string;
290
300
  try {
291
301
  rawBody = await res.text();
292
- console.log(`[qqbot-api] <<< Body:`, rawBody);
293
- data = JSON.parse(rawBody) as T;
294
302
  } catch (err) {
295
- throw new Error(`Failed to parse response[${path}]: ${err instanceof Error ? err.message : String(err)}`);
303
+ throw new Error(`读取响应失败[${path}]: ${err instanceof Error ? err.message : String(err)}`);
296
304
  }
305
+ console.log(`[qqbot-api] <<< Body:`, rawBody);
306
+
307
+ // 检测非 JSON 响应(HTML 网关错误页 / CDN 限流页等)
308
+ const contentType = res.headers.get("content-type") ?? "";
309
+ const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
297
310
 
298
311
  if (!res.ok) {
299
- const error = data as { message?: string; code?: number };
300
- throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`);
312
+ if (isHtmlResponse) {
313
+ // HTML 响应 = 网关/限流层返回的错误页,给出友好提示
314
+ const statusHint = res.status === 502 || res.status === 503 || res.status === 504
315
+ ? "调用发生异常,请稍候重试"
316
+ : res.status === 429
317
+ ? "请求过于频繁,已被限流"
318
+ : `开放平台返回 HTTP ${res.status}`;
319
+ throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
320
+ }
321
+ // JSON 错误响应
322
+ try {
323
+ const error = JSON.parse(rawBody) as { message?: string; code?: number };
324
+ throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
325
+ } catch (parseErr) {
326
+ if (parseErr instanceof ApiError) throw parseErr;
327
+ throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
328
+ }
301
329
  }
302
330
 
303
- return data;
331
+ // 成功响应但不是 JSON(极端异常情况)
332
+ if (isHtmlResponse) {
333
+ throw new Error(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`);
334
+ }
335
+
336
+ try {
337
+ return JSON.parse(rawBody) as T;
338
+ } catch {
339
+ throw new Error(`开放平台响应格式异常(${path}),请稍后重试`);
340
+ }
304
341
  }
305
342
 
306
343
  // ============ 上传重试(指数退避) ============
@@ -342,6 +379,40 @@ async function apiRequestWithRetry<T = unknown>(
342
379
  throw lastError!;
343
380
  }
344
381
 
382
+ // ============ 完成上传重试(无条件,任何错误都重试) ============
383
+
384
+ const COMPLETE_UPLOAD_MAX_RETRIES = 2;
385
+ const COMPLETE_UPLOAD_BASE_DELAY_MS = 2000;
386
+
387
+ /**
388
+ * 完成上传专用重试:无条件重试所有错误(包括 4xx、5xx、网络错误、超时等)
389
+ * 分片上传完成接口的失败往往是平台侧异步处理未就绪,重试通常能成功
390
+ */
391
+ async function completeUploadWithRetry(
392
+ accessToken: string,
393
+ method: string,
394
+ path: string,
395
+ body?: unknown,
396
+ ): Promise<MediaUploadResponse> {
397
+ let lastError: Error | null = null;
398
+
399
+ for (let attempt = 0; attempt <= COMPLETE_UPLOAD_MAX_RETRIES; attempt++) {
400
+ try {
401
+ return await apiRequest<MediaUploadResponse>(accessToken, method, path, body);
402
+ } catch (err) {
403
+ lastError = err instanceof Error ? err : new Error(String(err));
404
+
405
+ if (attempt < COMPLETE_UPLOAD_MAX_RETRIES) {
406
+ const delay = COMPLETE_UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
407
+ console.warn(`[qqbot-api] CompleteUpload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
408
+ await new Promise(resolve => setTimeout(resolve, delay));
409
+ }
410
+ }
411
+ }
412
+
413
+ throw lastError!;
414
+ }
415
+
345
416
  export async function getGatewayUrl(accessToken: string): Promise<string> {
346
417
  const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
347
418
  return data.url;
@@ -524,6 +595,168 @@ export interface UploadMediaResponse {
524
595
  id?: string;
525
596
  }
526
597
 
598
+ // ============ 大文件分片上传 API ============
599
+
600
+ /** 分片信息 */
601
+ export interface UploadPart {
602
+ /** 分片索引(从 1 开始) */
603
+ index: number;
604
+ /** 预签名上传链接 */
605
+ presigned_url: string;
606
+ }
607
+
608
+ /** 申请上传响应 */
609
+ export interface UploadPrepareResponse {
610
+ /** 上传任务 ID */
611
+ upload_id: string;
612
+ /** 分块大小(字节) */
613
+ block_size: number;
614
+ /** 分片列表(含预签名链接) */
615
+ parts: UploadPart[];
616
+ }
617
+
618
+ /** 完成文件上传响应(与 UploadMediaResponse 一致) */
619
+ export interface MediaUploadResponse {
620
+ /** 文件 UUID */
621
+ file_uuid: string;
622
+ /** 文件信息(用于发送消息),是 InnerUploadRsp 的序列化 */
623
+ file_info: string;
624
+ /** 文件信息过期时长(秒) */
625
+ ttl: number;
626
+ }
627
+
628
+ /** 申请上传时的文件哈希信息 */
629
+ export interface UploadPrepareHashes {
630
+ /** 整个文件的 MD5(十六进制) */
631
+ md5: string;
632
+ /** 整个文件的 SHA1(十六进制) */
633
+ sha1: string;
634
+ /** 文件前 10002432 Bytes 的 MD5(十六进制);文件不足该大小时为整文件 MD5 */
635
+ md5_10m: string;
636
+ }
637
+
638
+ /**
639
+ * 申请上传(C2C)
640
+ * POST /v2/users/{user_id}/upload_prepare
641
+ *
642
+ * @param accessToken - 访问令牌
643
+ * @param userId - 用户 openid
644
+ * @param fileType - 业务类型(1=图片, 2=视频, 3=语音, 4=文件)
645
+ * @param fileName - 文件名
646
+ * @param fileSize - 文件大小(字节)
647
+ * @param hashes - 文件哈希信息(md5, sha1, md5_10m)
648
+ * @returns 上传任务 ID、分块大小、分片预签名链接列表
649
+ */
650
+ export async function c2cUploadPrepare(
651
+ accessToken: string,
652
+ userId: string,
653
+ fileType: MediaFileType,
654
+ fileName: string,
655
+ fileSize: number,
656
+ hashes: UploadPrepareHashes,
657
+ ): Promise<UploadPrepareResponse> {
658
+ return apiRequest<UploadPrepareResponse>(
659
+ accessToken, "POST", `/v2/users/${userId}/upload_prepare`,
660
+ { file_type: fileType, file_name: fileName, file_size: fileSize, md5: hashes.md5, sha1: hashes.sha1, md5_10m: hashes.md5_10m },
661
+ );
662
+ }
663
+
664
+ /**
665
+ * 完成分片上传(C2C)
666
+ * POST /v2/users/{user_id}/upload_part_finish
667
+ *
668
+ * @param accessToken - 访问令牌
669
+ * @param userId - 用户 openid
670
+ * @param uploadId - 上传任务 ID
671
+ * @param partIndex - 分片索引(从 1 开始)
672
+ * @param blockSize - 分块大小(字节)
673
+ * @param md5 - 分片数据的 MD5(十六进制)
674
+ */
675
+ export async function c2cUploadPartFinish(
676
+ accessToken: string,
677
+ userId: string,
678
+ uploadId: string,
679
+ partIndex: number,
680
+ blockSize: number,
681
+ md5: string,
682
+ ): Promise<void> {
683
+ await apiRequest<Record<string, unknown>>(
684
+ accessToken, "POST", `/v2/users/${userId}/upload_part_finish`,
685
+ { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
686
+ );
687
+ }
688
+
689
+ /**
690
+ * 完成文件上传(C2C)
691
+ * POST /v2/users/{user_id}/files
692
+ *
693
+ * @param accessToken - 访问令牌
694
+ * @param userId - 用户 openid
695
+ * @param uploadId - 上传任务 ID
696
+ * @returns 文件信息(file_uuid, file_info, ttl)
697
+ */
698
+ export async function c2cCompleteUpload(
699
+ accessToken: string,
700
+ userId: string,
701
+ uploadId: string,
702
+ ): Promise<MediaUploadResponse> {
703
+ return completeUploadWithRetry(
704
+ accessToken, "POST", `/v2/users/${userId}/files`,
705
+ { upload_id: uploadId },
706
+ );
707
+ }
708
+
709
+ /**
710
+ * 申请上传(Group)
711
+ * POST /v2/groups/{group_id}/upload_prepare
712
+ */
713
+ export async function groupUploadPrepare(
714
+ accessToken: string,
715
+ groupId: string,
716
+ fileType: MediaFileType,
717
+ fileName: string,
718
+ fileSize: number,
719
+ hashes: UploadPrepareHashes,
720
+ ): Promise<UploadPrepareResponse> {
721
+ return apiRequest<UploadPrepareResponse>(
722
+ accessToken, "POST", `/v2/groups/${groupId}/upload_prepare`,
723
+ { file_type: fileType, file_name: fileName, file_size: fileSize, md5: hashes.md5, sha1: hashes.sha1, md5_10m: hashes.md5_10m },
724
+ );
725
+ }
726
+
727
+ /**
728
+ * 完成分片上传(Group)
729
+ * POST /v2/groups/{group_id}/upload_part_finish
730
+ */
731
+ export async function groupUploadPartFinish(
732
+ accessToken: string,
733
+ groupId: string,
734
+ uploadId: string,
735
+ partIndex: number,
736
+ blockSize: number,
737
+ md5: string,
738
+ ): Promise<void> {
739
+ await apiRequest<Record<string, unknown>>(
740
+ accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`,
741
+ { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
742
+ );
743
+ }
744
+
745
+ /**
746
+ * 完成文件上传(Group)
747
+ * POST /v2/groups/{group_id}/files
748
+ */
749
+ export async function groupCompleteUpload(
750
+ accessToken: string,
751
+ groupId: string,
752
+ uploadId: string,
753
+ ): Promise<MediaUploadResponse> {
754
+ return completeUploadWithRetry(
755
+ accessToken, "POST", `/v2/groups/${groupId}/files`,
756
+ { upload_id: uploadId },
757
+ );
758
+ }
759
+
527
760
  export async function uploadC2CMedia(
528
761
  accessToken: string,
529
762
  openid: string,
@@ -817,3 +1050,42 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
817
1050
  }
818
1051
  });
819
1052
  }
1053
+
1054
+ // ============ 流式消息 API ============
1055
+
1056
+ import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
1057
+
1058
+ /**
1059
+ * 发送流式消息(C2C 私聊)
1060
+ *
1061
+ * 流式协议:
1062
+ * - 首次调用时不传 stream_msg_id,由平台返回
1063
+ * - 后续分片携带 stream_msg_id 和递增 msg_seq
1064
+ * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
1065
+ *
1066
+ * @param accessToken - access_token
1067
+ * @param openid - 用户 openid
1068
+ * @param req - 流式消息请求体
1069
+ * @returns 流式消息响应
1070
+ */
1071
+ export async function sendC2CStreamMessage(
1072
+ accessToken: string,
1073
+ openid: string,
1074
+ req: StreamMessageRequest,
1075
+ ): Promise<StreamMessageResponse> {
1076
+ const path = `/v2/users/${openid}/stream_messages`;
1077
+ const body: Record<string, unknown> = {
1078
+ input_mode: req.input_mode,
1079
+ input_state: req.input_state,
1080
+ content_type: req.content_type,
1081
+ content_raw: req.content_raw,
1082
+ event_id: req.event_id,
1083
+ msg_id: req.msg_id,
1084
+ msg_seq: req.msg_seq,
1085
+ index: req.index,
1086
+ };
1087
+ if (req.stream_msg_id) {
1088
+ body.stream_msg_id = req.stream_msg_id;
1089
+ }
1090
+ return apiRequest<StreamMessageResponse>(accessToken, "POST", path, body);
1091
+ }
package/src/channel.ts CHANGED
@@ -240,6 +240,18 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
240
240
  console.log(`[qqbot:channel] sendMedia resolved account: id=${account.accountId}, appId=${account.appId}, enabled=${account.enabled}`);
241
241
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
242
242
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
243
+ // 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
244
+ // 用于非 gateway deliver 场景(如 API 直接发送、cron 等)。
245
+ // gateway 消息响应走的是 deliver 回调 → sendPlainReply,不经过此处。
246
+ // 框架拿到 error 后不一定会给用户发文字兜底,所以这里主动发一条。
247
+ if (result.error) {
248
+ try {
249
+ const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
250
+ console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
251
+ } catch (fallbackErr) {
252
+ console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
253
+ }
254
+ }
243
255
  return {
244
256
  channel: "qqbot",
245
257
  messageId: result.messageId,
package/src/config.ts CHANGED
@@ -70,17 +70,10 @@ export function resolveQQBotAccount(
70
70
  let secretSource: "config" | "file" | "env" | "none" = "none";
71
71
 
72
72
  if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
73
- // 默认账户从顶层读取
73
+ // 默认账户从顶层读取(展开所有字段,避免遗漏新增配置项)
74
+ const { accounts: _accounts, ...topLevelConfig } = qqbot ?? {} as QQBotChannelConfig;
74
75
  accountConfig = {
75
- enabled: qqbot?.enabled,
76
- name: qqbot?.name,
77
- appId: qqbot?.appId,
78
- clientSecret: qqbot?.clientSecret,
79
- clientSecretFile: qqbot?.clientSecretFile,
80
- dmPolicy: qqbot?.dmPolicy,
81
- allowFrom: qqbot?.allowFrom,
82
- systemPrompt: qqbot?.systemPrompt,
83
- imageServerBaseUrl: qqbot?.imageServerBaseUrl,
76
+ ...topLevelConfig,
84
77
  markdownSupport: qqbot?.markdownSupport ?? true,
85
78
  };
86
79
  appId = normalizeAppId(qqbot?.appId);
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 出站消息合并回复(Deliver Debounce)模块
3
+ *
4
+ * 解决的问题:
5
+ * 当 openclaw 框架层的 embedded agent 超时或快速连续产生多次 deliver 时,
6
+ * 用户会在短时间内收到大量碎片消息(消息轰炸)。
7
+ *
8
+ * 解决方案:
9
+ * 在 deliver 回调和实际发送之间加入 debounce 层。
10
+ * 短时间内(windowMs)连续到达的多条纯文本 deliver 会被合并为一条消息发送。
11
+ * 含媒体的 deliver 会立即 flush 已缓冲的文本并正常处理媒体。
12
+ */
13
+
14
+ import type { DeliverDebounceConfig } from "./types.js";
15
+
16
+ // ============ 默认值 ============
17
+
18
+ const DEFAULT_WINDOW_MS = 1500;
19
+ const DEFAULT_MAX_WAIT_MS = 8000;
20
+ const DEFAULT_SEPARATOR = "\n\n---\n\n";
21
+
22
+ // ============ 类型定义 ============
23
+
24
+ export interface DeliverPayload {
25
+ text?: string;
26
+ mediaUrls?: string[];
27
+ mediaUrl?: string;
28
+ }
29
+
30
+ export interface DeliverInfo {
31
+ kind: string;
32
+ }
33
+
34
+ /** 实际执行发送的回调 */
35
+ export type DeliverExecutor = (payload: DeliverPayload, info: DeliverInfo) => Promise<void>;
36
+
37
+ // ============ DeliverDebouncer 类 ============
38
+
39
+ export class DeliverDebouncer {
40
+ private readonly windowMs: number;
41
+ private readonly maxWaitMs: number;
42
+ private readonly separator: string;
43
+ private readonly executor: DeliverExecutor;
44
+ private readonly log?: {
45
+ info: (msg: string) => void;
46
+ error: (msg: string) => void;
47
+ };
48
+ private readonly prefix: string;
49
+
50
+ /** 缓冲中的文本片段 */
51
+ private bufferedTexts: string[] = [];
52
+ /** 缓冲中最后一次 deliver 的 info(用于 flush 时传递 kind) */
53
+ private lastInfo: DeliverInfo | null = null;
54
+ /** 缓冲中最后一次 deliver 的 payload(非文本字段,如 mediaUrls) */
55
+ private lastPayload: DeliverPayload | null = null;
56
+ /** debounce 定时器 */
57
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
58
+ /** 最大等待定时器(从第一条 deliver 开始计算) */
59
+ private maxWaitTimer: ReturnType<typeof setTimeout> | null = null;
60
+ /** 是否正在 flush */
61
+ private flushing = false;
62
+ /** 已销毁标记 */
63
+ private disposed = false;
64
+
65
+ constructor(
66
+ config: DeliverDebounceConfig | undefined,
67
+ executor: DeliverExecutor,
68
+ log?: { info: (msg: string) => void; error: (msg: string) => void },
69
+ prefix = "[debounce]",
70
+ ) {
71
+ this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
72
+ this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
73
+ this.separator = config?.separator ?? DEFAULT_SEPARATOR;
74
+ this.executor = executor;
75
+ this.log = log;
76
+ this.prefix = prefix;
77
+ }
78
+
79
+ /**
80
+ * 接收一次 deliver 调用。
81
+ * - 纯文本 deliver → 缓冲并设置 debounce 定时器
82
+ * - 含媒体 deliver → 先 flush 已缓冲文本,再直接执行当前 deliver
83
+ */
84
+ async deliver(payload: DeliverPayload, info: DeliverInfo): Promise<void> {
85
+ if (this.disposed) return;
86
+
87
+ const hasMedia = Boolean(
88
+ (payload.mediaUrls && payload.mediaUrls.length > 0) || payload.mediaUrl,
89
+ );
90
+ const text = (payload.text ?? "").trim();
91
+
92
+ // 含媒体的 deliver:立即 flush 缓冲 + 直接执行
93
+ if (hasMedia) {
94
+ this.log?.info(`${this.prefix} Media deliver detected, flushing ${this.bufferedTexts.length} buffered text(s) first`);
95
+ await this.flush();
96
+ await this.executor(payload, info);
97
+ return;
98
+ }
99
+
100
+ // 空文本 deliver:直接透传(不缓冲)
101
+ if (!text) {
102
+ await this.executor(payload, info);
103
+ return;
104
+ }
105
+
106
+ // 纯文本 deliver:缓冲
107
+ this.bufferedTexts.push(text);
108
+ this.lastInfo = info;
109
+ this.lastPayload = payload;
110
+
111
+ this.log?.info(
112
+ `${this.prefix} Buffered text #${this.bufferedTexts.length} (${text.length} chars), window=${this.windowMs}ms`,
113
+ );
114
+
115
+ // 重置 debounce 定时器
116
+ if (this.debounceTimer) {
117
+ clearTimeout(this.debounceTimer);
118
+ }
119
+ this.debounceTimer = setTimeout(() => {
120
+ this.flush().catch((err) => {
121
+ this.log?.error(`${this.prefix} Flush error (debounce timer): ${err}`);
122
+ });
123
+ }, this.windowMs);
124
+
125
+ // 首次缓冲时启动最大等待定时器
126
+ if (this.bufferedTexts.length === 1) {
127
+ if (this.maxWaitTimer) {
128
+ clearTimeout(this.maxWaitTimer);
129
+ }
130
+ this.maxWaitTimer = setTimeout(() => {
131
+ this.log?.info(`${this.prefix} Max wait (${this.maxWaitMs}ms) reached, force flushing`);
132
+ this.flush().catch((err) => {
133
+ this.log?.error(`${this.prefix} Flush error (max wait timer): ${err}`);
134
+ });
135
+ }, this.maxWaitMs);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 将缓冲中的文本合并为一条消息发送
141
+ */
142
+ async flush(): Promise<void> {
143
+ if (this.flushing || this.bufferedTexts.length === 0) return;
144
+ this.flushing = true;
145
+
146
+ // 清除定时器
147
+ if (this.debounceTimer) {
148
+ clearTimeout(this.debounceTimer);
149
+ this.debounceTimer = null;
150
+ }
151
+ if (this.maxWaitTimer) {
152
+ clearTimeout(this.maxWaitTimer);
153
+ this.maxWaitTimer = null;
154
+ }
155
+
156
+ // 取出缓冲
157
+ const texts = this.bufferedTexts;
158
+ const info = this.lastInfo!;
159
+ const lastPayload = this.lastPayload!;
160
+ this.bufferedTexts = [];
161
+ this.lastInfo = null;
162
+ this.lastPayload = null;
163
+
164
+ try {
165
+ if (texts.length === 1) {
166
+ // 只有一条,直接透传原始 payload
167
+ this.log?.info(`${this.prefix} Flushing single buffered text (${texts[0].length} chars)`);
168
+ await this.executor({ ...lastPayload, text: texts[0] }, info);
169
+ } else {
170
+ // 多条合并
171
+ const merged = texts.join(this.separator);
172
+ this.log?.info(
173
+ `${this.prefix} Merged ${texts.length} buffered texts into one (${merged.length} chars)`,
174
+ );
175
+ await this.executor({ ...lastPayload, text: merged }, info);
176
+ }
177
+ } finally {
178
+ this.flushing = false;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 销毁:flush 剩余缓冲并清除定时器
184
+ */
185
+ async dispose(): Promise<void> {
186
+ this.disposed = true;
187
+ if (this.debounceTimer) {
188
+ clearTimeout(this.debounceTimer);
189
+ this.debounceTimer = null;
190
+ }
191
+ if (this.maxWaitTimer) {
192
+ clearTimeout(this.maxWaitTimer);
193
+ this.maxWaitTimer = null;
194
+ }
195
+ // flush 剩余
196
+ if (this.bufferedTexts.length > 0) {
197
+ this.flushing = false; // 确保 flush 能执行
198
+ await this.flush();
199
+ }
200
+ }
201
+
202
+ /** 当前是否有缓冲中的文本 */
203
+ get hasPending(): boolean {
204
+ return this.bufferedTexts.length > 0;
205
+ }
206
+
207
+ /** 缓冲中的文本数量 */
208
+ get pendingCount(): number {
209
+ return this.bufferedTexts.length;
210
+ }
211
+ }
212
+
213
+ // ============ 工厂函数 ============
214
+
215
+ /**
216
+ * 根据配置创建 debouncer 或返回 null(禁用时)
217
+ */
218
+ export function createDeliverDebouncer(
219
+ config: DeliverDebounceConfig | undefined,
220
+ executor: DeliverExecutor,
221
+ log?: { info: (msg: string) => void; error: (msg: string) => void },
222
+ prefix?: string,
223
+ ): DeliverDebouncer | null {
224
+ // 未配置时默认启用
225
+ if (config?.enabled === false) {
226
+ return null;
227
+ }
228
+ return new DeliverDebouncer(config, executor, log, prefix);
229
+ }