@ryantest/openclaw-qqbot 0.0.3 → 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
@@ -8,12 +8,12 @@
8
8
 
9
9
  import type { ResolvedQQBotAccount } from "./types.js";
10
10
  import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
11
- import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
11
+ import { sendPhoto, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
12
12
  import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
13
13
  import { getQQBotRuntime } from "./runtime.js";
14
14
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
15
- import { normalizeMediaTags } from "./utils/media-tags.js";
16
- import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js";
15
+ import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
16
+ import { isLocalPath as isLocalFilePath } from "./utils/platform.js";
17
17
  import { filterInternalMarkers } from "./utils/text-parsing.js";
18
18
 
19
19
  // ============ 类型定义 ============
@@ -60,56 +60,16 @@ export async function parseAndSendMediaTags(
60
60
  const { account, log } = actx;
61
61
  const prefix = `[qqbot:${account.accountId}]`;
62
62
 
63
- // 预处理:纠正小模型常见的标签拼写错误和格式问题
64
- const text = normalizeMediaTags(replyText);
63
+ // 使用 media-send.ts 的统一解析器(内含 normalizeMediaTags + 路径编码修复)
64
+ const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(replyText, log);
65
65
 
66
- const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
67
- const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
68
-
69
- if (mediaTagMatches.length === 0) {
70
- return { handled: false, normalizedText: text };
71
- }
72
-
73
- const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1]!.toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {} as Record<string, number>);
74
- log?.info(`${prefix} Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
75
-
76
- // 构建发送队列
77
- type QueueItem = { type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string };
78
- const sendQueue: QueueItem[] = [];
79
-
80
- let lastIndex = 0;
81
- const regex2 = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
82
- let match;
83
-
84
- while ((match = regex2.exec(text)) !== null) {
85
- const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
86
- if (textBefore) {
87
- sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
88
- }
89
-
90
- const tagName = match[1]!.toLowerCase();
91
- let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix);
92
-
93
- if (mediaPath) {
94
- const typeMap: Record<string, QueueItem["type"]> = {
95
- qqmedia: "media", qqvoice: "voice", qqvideo: "video", qqfile: "file",
96
- };
97
- const itemType = typeMap[tagName] ?? "image";
98
- sendQueue.push({ type: itemType, content: mediaPath });
99
- log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`);
100
- }
101
-
102
- lastIndex = match.index + match[0].length;
103
- }
104
-
105
- const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
106
- if (textAfter) {
107
- sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
66
+ if (!hasMedia || sendQueue.length === 0) {
67
+ return { handled: false, normalizedText: replyText };
108
68
  }
109
69
 
110
70
  log?.info(`${prefix} Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
111
71
 
112
- // 按顺序发送
72
+ // 构建统一的媒体发送上下文
113
73
  const mediaTarget: MediaTargetContext = {
114
74
  targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
115
75
  targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
@@ -118,34 +78,22 @@ export async function parseAndSendMediaTags(
118
78
  logPrefix: prefix,
119
79
  };
120
80
 
121
- for (const item of sendQueue) {
122
- if (item.type === "text") {
123
- await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef);
124
- } else if (item.type === "image") {
125
- const result = await sendPhoto(mediaTarget, item.content);
126
- if (result.error) log?.error(`${prefix} sendPhoto error: ${result.error}`);
127
- } else if (item.type === "voice") {
128
- await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix);
129
- } else if (item.type === "video") {
130
- const result = await sendVideoMsg(mediaTarget, item.content);
131
- if (result.error) log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
132
- } else if (item.type === "file") {
133
- const result = await sendDocument(mediaTarget, item.content);
134
- if (result.error) log?.error(`${prefix} sendDocument error: ${result.error}`);
135
- } else if (item.type === "media") {
136
- const result = await sendMediaAuto({
137
- to: actx.qualifiedTarget,
138
- text: "",
139
- mediaUrl: item.content,
140
- accountId: account.accountId,
141
- replyToId: event.messageId,
142
- account,
143
- });
144
- if (result.error) log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
145
- }
146
- }
81
+ const mediaSendCtx: MediaSendContext = {
82
+ mediaTarget,
83
+ qualifiedTarget: actx.qualifiedTarget,
84
+ account,
85
+ replyToId: event.messageId,
86
+ log,
87
+ };
147
88
 
148
- return { handled: true, normalizedText: text };
89
+ // 使用 media-send.ts 的统一执行器
90
+ await executeSendQueue(sendQueue, mediaSendCtx, {
91
+ onSendText: async (textContent) => {
92
+ await sendTextChunks(filterInternalMarkers(textContent), event, actx, sendWithRetry, consumeQuoteRef);
93
+ },
94
+ });
95
+
96
+ return { handled: true, normalizedText: replyText };
149
97
  }
150
98
 
151
99
  // ============ 2. 非结构化消息发送(普通文本 + 图片) ============
@@ -172,6 +120,23 @@ export async function sendPlainReply(
172
120
  const { account, qualifiedTarget, log } = actx;
173
121
  const prefix = `[qqbot:${account.accountId}]`;
174
122
 
123
+ // 预去重:把 payload 自带的媒体 URL 从 toolMediaUrls 中移除,
124
+ // 防止同一个文件既被 payload.mediaUrl/mediaUrls 发送,又被 toolMediaUrls 重复发送
125
+ if (toolMediaUrls.length > 0) {
126
+ const payloadUrls = new Set<string>();
127
+ if (payload.mediaUrl) payloadUrls.add(payload.mediaUrl);
128
+ if (payload.mediaUrls) for (const u of payload.mediaUrls) payloadUrls.add(u);
129
+ if (payloadUrls.size > 0) {
130
+ const before = toolMediaUrls.length;
131
+ const filtered = toolMediaUrls.filter(url => !payloadUrls.has(url));
132
+ if (filtered.length < before) {
133
+ log?.info(`${prefix} Pre-dedup: removed ${before - filtered.length} payload media URL(s) from toolMediaUrls`);
134
+ toolMediaUrls.length = 0;
135
+ toolMediaUrls.push(...filtered);
136
+ }
137
+ }
138
+ }
139
+
175
140
  const collectedImageUrls: string[] = [];
176
141
  const localMediaToSend: string[] = [];
177
142
 
@@ -250,27 +215,43 @@ export async function sendPlainReply(
250
215
  to: qualifiedTarget, text: "", mediaUrl: mediaPath,
251
216
  accountId: account.accountId, replyToId: event.messageId, account,
252
217
  });
253
- if (result.error) log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
254
- else log?.info(`${prefix} Sent local media: ${mediaPath}`);
218
+ if (result.error) {
219
+ log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
220
+ await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
221
+ } else {
222
+ log?.info(`${prefix} Sent local media: ${mediaPath}`);
223
+ }
255
224
  } catch (err) {
256
225
  log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
226
+ await sendTextChunks(`发送媒体失败:${err}`, event, actx, sendWithRetry, consumeQuoteRef);
257
227
  }
258
228
  }
259
229
  }
260
230
 
261
- // 转发 tool 阶段收集的媒体
231
+ // 转发 tool 阶段收集的媒体(去重:跳过已在 localMediaToSend 或 collectedImageUrls 中发送过的路径)
262
232
  if (toolMediaUrls.length > 0) {
263
- log?.info(`${prefix} Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
264
- for (const mediaUrl of toolMediaUrls) {
265
- try {
266
- const result = await sendMediaAuto({
267
- to: qualifiedTarget, text: "", mediaUrl,
268
- accountId: account.accountId, replyToId: event.messageId, account,
269
- });
270
- if (result.error) log?.error(`${prefix} Tool media forward error: ${result.error}`);
271
- else log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
272
- } catch (err) {
273
- log?.error(`${prefix} Tool media forward failed: ${err}`);
233
+ const alreadySent = new Set([...localMediaToSend, ...collectedImageUrls]);
234
+ const dedupedToolMedia = toolMediaUrls.filter(url => !alreadySent.has(url));
235
+ if (dedupedToolMedia.length < toolMediaUrls.length) {
236
+ log?.info(`${prefix} Deduped tool media: ${toolMediaUrls.length} → ${dedupedToolMedia.length} (skipped ${toolMediaUrls.length - dedupedToolMedia.length} already sent via localMedia/collectedImages)`);
237
+ }
238
+ if (dedupedToolMedia.length > 0) {
239
+ log?.info(`${prefix} Forwarding ${dedupedToolMedia.length} tool-collected media URL(s) after block deliver`);
240
+ for (const mediaUrl of dedupedToolMedia) {
241
+ try {
242
+ const result = await sendMediaAuto({
243
+ to: qualifiedTarget, text: "", mediaUrl,
244
+ accountId: account.accountId, replyToId: event.messageId, account,
245
+ });
246
+ if (result.error) {
247
+ log?.error(`${prefix} Tool media forward error: ${result.error}`);
248
+ await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
249
+ } else {
250
+ log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
251
+ }
252
+ } catch (err) {
253
+ log?.error(`${prefix} Tool media forward failed: ${err}`);
254
+ }
274
255
  }
275
256
  }
276
257
  toolMediaUrls.length = 0;
@@ -279,48 +260,6 @@ export async function sendPlainReply(
279
260
 
280
261
  // ============ 内部辅助函数 ============
281
262
 
282
- /** 解码媒体路径:剥离 MEDIA: 前缀、展开 ~、修复转义 */
283
- function decodeMediaPath(raw: string, log: DeliverAccountContext["log"], prefix: string): string {
284
- let mediaPath = raw;
285
- if (mediaPath.startsWith("MEDIA:")) {
286
- mediaPath = mediaPath.slice("MEDIA:".length);
287
- }
288
- mediaPath = normalizePath(mediaPath);
289
- mediaPath = mediaPath.replace(/\\\\/g, "\\");
290
-
291
- try {
292
- const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
293
- const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
294
-
295
- if (hasOctal || hasNonASCII) {
296
- log?.debug?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`);
297
- let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
298
- return String.fromCharCode(parseInt(octal, 8));
299
- });
300
- const bytes: number[] = [];
301
- for (let i = 0; i < decoded.length; i++) {
302
- const code = decoded.charCodeAt(i);
303
- if (code <= 0xFF) {
304
- bytes.push(code);
305
- } else {
306
- const charBytes = Buffer.from(decoded[i], "utf8");
307
- bytes.push(...charBytes);
308
- }
309
- }
310
- const buffer = Buffer.from(bytes);
311
- const utf8Decoded = buffer.toString("utf8");
312
- if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
313
- mediaPath = utf8Decoded;
314
- log?.debug?.(`${prefix} Successfully decoded path: ${mediaPath}`);
315
- }
316
- }
317
- } catch (decodeErr) {
318
- log?.error(`${prefix} Path decode error: ${decodeErr}`);
319
- }
320
-
321
- return mediaPath;
322
- }
323
-
324
263
  /** 发送文本分块(共用逻辑) */
325
264
  async function sendTextChunks(
326
265
  text: string,
@@ -351,30 +290,6 @@ async function sendTextChunks(
351
290
  }
352
291
  }
353
292
 
354
- /** 语音发送(带 45s 超时保护) */
355
- async function sendVoiceWithTimeout(
356
- target: MediaTargetContext,
357
- voicePath: string,
358
- account: ResolvedQQBotAccount,
359
- log: DeliverAccountContext["log"],
360
- prefix: string,
361
- ): Promise<void> {
362
- const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
363
- const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
364
- const voiceTimeout = 45000;
365
- try {
366
- const result = await Promise.race([
367
- sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
368
- new Promise<{ channel: string; error: string }>((resolve) =>
369
- setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout),
370
- ),
371
- ]);
372
- if (result.error) log?.error(`${prefix} sendVoice error: ${result.error}`);
373
- } catch (err) {
374
- log?.error(`${prefix} sendVoice unexpected error: ${err}`);
375
- }
376
- }
377
-
378
293
  /** Markdown 模式发送 */
379
294
  async function sendMarkdownReply(
380
295
  textWithoutImages: string,
@@ -523,10 +438,15 @@ async function sendPlainTextReply(
523
438
  for (const imageUrl of imageUrls) {
524
439
  try {
525
440
  const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
526
- if (imgResult.error) log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
527
- else log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
441
+ if (imgResult.error) {
442
+ log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
443
+ await sendTextChunks(`发送图片失败:${imgResult.error}`, event, actx, sendWithRetry, consumeQuoteRef);
444
+ } else {
445
+ log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
446
+ }
528
447
  } catch (imgErr) {
529
448
  log?.error(`${prefix} Failed to send image: ${imgErr}`);
449
+ await sendTextChunks(`发送图片失败:${imgErr}`, event, actx, sendWithRetry, consumeQuoteRef);
530
450
  }
531
451
  }
532
452