@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
@@ -6,12 +6,12 @@
6
6
  * 2. sendPlainReply — 处理不含媒体标签的普通回复(markdown 图片/纯文本+图片)
7
7
  */
8
8
  import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
9
- import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto } from "./outbound.js";
9
+ import { sendPhoto, sendMedia as sendMediaAuto } from "./outbound.js";
10
10
  import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
11
11
  import { getQQBotRuntime } from "./runtime.js";
12
12
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
13
- import { normalizeMediaTags } from "./utils/media-tags.js";
14
- import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js";
13
+ import { parseMediaTagsToSendQueue, executeSendQueue } from "./utils/media-send.js";
14
+ import { isLocalPath as isLocalFilePath } from "./utils/platform.js";
15
15
  import { filterInternalMarkers } from "./utils/text-parsing.js";
16
16
  // ============ 1. 媒体标签解析 + 发送 ============
17
17
  /**
@@ -22,42 +22,13 @@ import { filterInternalMarkers } from "./utils/text-parsing.js";
22
22
  export async function parseAndSendMediaTags(replyText, event, actx, sendWithRetry, consumeQuoteRef) {
23
23
  const { account, log } = actx;
24
24
  const prefix = `[qqbot:${account.accountId}]`;
25
- // 预处理:纠正小模型常见的标签拼写错误和格式问题
26
- const text = normalizeMediaTags(replyText);
27
- const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
28
- const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
29
- if (mediaTagMatches.length === 0) {
30
- return { handled: false, normalizedText: text };
31
- }
32
- const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1].toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {});
33
- log?.info(`${prefix} Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
34
- const sendQueue = [];
35
- let lastIndex = 0;
36
- const regex2 = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
37
- let match;
38
- while ((match = regex2.exec(text)) !== null) {
39
- const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
40
- if (textBefore) {
41
- sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
42
- }
43
- const tagName = match[1].toLowerCase();
44
- let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix);
45
- if (mediaPath) {
46
- const typeMap = {
47
- qqmedia: "media", qqvoice: "voice", qqvideo: "video", qqfile: "file",
48
- };
49
- const itemType = typeMap[tagName] ?? "image";
50
- sendQueue.push({ type: itemType, content: mediaPath });
51
- log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`);
52
- }
53
- lastIndex = match.index + match[0].length;
54
- }
55
- const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
56
- if (textAfter) {
57
- sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
25
+ // 使用 media-send.ts 的统一解析器(内含 normalizeMediaTags + 路径编码修复)
26
+ const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(replyText, log);
27
+ if (!hasMedia || sendQueue.length === 0) {
28
+ return { handled: false, normalizedText: replyText };
58
29
  }
59
30
  log?.info(`${prefix} Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
60
- // 按顺序发送
31
+ // 构建统一的媒体发送上下文
61
32
  const mediaTarget = {
62
33
  targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
63
34
  targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.channelId,
@@ -65,42 +36,20 @@ export async function parseAndSendMediaTags(replyText, event, actx, sendWithRetr
65
36
  replyToId: event.messageId,
66
37
  logPrefix: prefix,
67
38
  };
68
- for (const item of sendQueue) {
69
- if (item.type === "text") {
70
- await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef);
71
- }
72
- else if (item.type === "image") {
73
- const result = await sendPhoto(mediaTarget, item.content);
74
- if (result.error)
75
- log?.error(`${prefix} sendPhoto error: ${result.error}`);
76
- }
77
- else if (item.type === "voice") {
78
- await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix);
79
- }
80
- else if (item.type === "video") {
81
- const result = await sendVideoMsg(mediaTarget, item.content);
82
- if (result.error)
83
- log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
84
- }
85
- else if (item.type === "file") {
86
- const result = await sendDocument(mediaTarget, item.content);
87
- if (result.error)
88
- log?.error(`${prefix} sendDocument error: ${result.error}`);
89
- }
90
- else if (item.type === "media") {
91
- const result = await sendMediaAuto({
92
- to: actx.qualifiedTarget,
93
- text: "",
94
- mediaUrl: item.content,
95
- accountId: account.accountId,
96
- replyToId: event.messageId,
97
- account,
98
- });
99
- if (result.error)
100
- log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
101
- }
102
- }
103
- return { handled: true, normalizedText: text };
39
+ const mediaSendCtx = {
40
+ mediaTarget,
41
+ qualifiedTarget: actx.qualifiedTarget,
42
+ account,
43
+ replyToId: event.messageId,
44
+ log,
45
+ };
46
+ // 使用 media-send.ts 的统一执行器
47
+ await executeSendQueue(sendQueue, mediaSendCtx, {
48
+ onSendText: async (textContent) => {
49
+ await sendTextChunks(filterInternalMarkers(textContent), event, actx, sendWithRetry, consumeQuoteRef);
50
+ },
51
+ });
52
+ return { handled: true, normalizedText: replyText };
104
53
  }
105
54
  /**
106
55
  * 发送不含媒体标签的普通回复。
@@ -109,6 +58,25 @@ export async function parseAndSendMediaTags(replyText, event, actx, sendWithRetr
109
58
  export async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, consumeQuoteRef, toolMediaUrls) {
110
59
  const { account, qualifiedTarget, log } = actx;
111
60
  const prefix = `[qqbot:${account.accountId}]`;
61
+ // 预去重:把 payload 自带的媒体 URL 从 toolMediaUrls 中移除,
62
+ // 防止同一个文件既被 payload.mediaUrl/mediaUrls 发送,又被 toolMediaUrls 重复发送
63
+ if (toolMediaUrls.length > 0) {
64
+ const payloadUrls = new Set();
65
+ if (payload.mediaUrl)
66
+ payloadUrls.add(payload.mediaUrl);
67
+ if (payload.mediaUrls)
68
+ for (const u of payload.mediaUrls)
69
+ payloadUrls.add(u);
70
+ if (payloadUrls.size > 0) {
71
+ const before = toolMediaUrls.length;
72
+ const filtered = toolMediaUrls.filter(url => !payloadUrls.has(url));
73
+ if (filtered.length < before) {
74
+ log?.info(`${prefix} Pre-dedup: removed ${before - filtered.length} payload media URL(s) from toolMediaUrls`);
75
+ toolMediaUrls.length = 0;
76
+ toolMediaUrls.push(...filtered);
77
+ }
78
+ }
79
+ }
112
80
  const collectedImageUrls = [];
113
81
  const localMediaToSend = [];
114
82
  const collectImageUrl = (url) => {
@@ -184,78 +152,52 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
184
152
  to: qualifiedTarget, text: "", mediaUrl: mediaPath,
185
153
  accountId: account.accountId, replyToId: event.messageId, account,
186
154
  });
187
- if (result.error)
155
+ if (result.error) {
188
156
  log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
189
- else
157
+ await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
158
+ }
159
+ else {
190
160
  log?.info(`${prefix} Sent local media: ${mediaPath}`);
161
+ }
191
162
  }
192
163
  catch (err) {
193
164
  log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
165
+ await sendTextChunks(`发送媒体失败:${err}`, event, actx, sendWithRetry, consumeQuoteRef);
194
166
  }
195
167
  }
196
168
  }
197
- // 转发 tool 阶段收集的媒体
169
+ // 转发 tool 阶段收集的媒体(去重:跳过已在 localMediaToSend 或 collectedImageUrls 中发送过的路径)
198
170
  if (toolMediaUrls.length > 0) {
199
- log?.info(`${prefix} Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
200
- for (const mediaUrl of toolMediaUrls) {
201
- try {
202
- const result = await sendMediaAuto({
203
- to: qualifiedTarget, text: "", mediaUrl,
204
- accountId: account.accountId, replyToId: event.messageId, account,
205
- });
206
- if (result.error)
207
- log?.error(`${prefix} Tool media forward error: ${result.error}`);
208
- else
209
- log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
210
- }
211
- catch (err) {
212
- log?.error(`${prefix} Tool media forward failed: ${err}`);
213
- }
171
+ const alreadySent = new Set([...localMediaToSend, ...collectedImageUrls]);
172
+ const dedupedToolMedia = toolMediaUrls.filter(url => !alreadySent.has(url));
173
+ if (dedupedToolMedia.length < toolMediaUrls.length) {
174
+ log?.info(`${prefix} Deduped tool media: ${toolMediaUrls.length} → ${dedupedToolMedia.length} (skipped ${toolMediaUrls.length - dedupedToolMedia.length} already sent via localMedia/collectedImages)`);
214
175
  }
215
- toolMediaUrls.length = 0;
216
- }
217
- }
218
- // ============ 内部辅助函数 ============
219
- /** 解码媒体路径:剥离 MEDIA: 前缀、展开 ~、修复转义 */
220
- function decodeMediaPath(raw, log, prefix) {
221
- let mediaPath = raw;
222
- if (mediaPath.startsWith("MEDIA:")) {
223
- mediaPath = mediaPath.slice("MEDIA:".length);
224
- }
225
- mediaPath = normalizePath(mediaPath);
226
- mediaPath = mediaPath.replace(/\\\\/g, "\\");
227
- try {
228
- const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
229
- const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
230
- if (hasOctal || hasNonASCII) {
231
- log?.debug?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`);
232
- let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_, octal) => {
233
- return String.fromCharCode(parseInt(octal, 8));
234
- });
235
- const bytes = [];
236
- for (let i = 0; i < decoded.length; i++) {
237
- const code = decoded.charCodeAt(i);
238
- if (code <= 0xFF) {
239
- bytes.push(code);
176
+ if (dedupedToolMedia.length > 0) {
177
+ log?.info(`${prefix} Forwarding ${dedupedToolMedia.length} tool-collected media URL(s) after block deliver`);
178
+ for (const mediaUrl of dedupedToolMedia) {
179
+ try {
180
+ const result = await sendMediaAuto({
181
+ to: qualifiedTarget, text: "", mediaUrl,
182
+ accountId: account.accountId, replyToId: event.messageId, account,
183
+ });
184
+ if (result.error) {
185
+ log?.error(`${prefix} Tool media forward error: ${result.error}`);
186
+ await sendTextChunks(result.error, event, actx, sendWithRetry, consumeQuoteRef);
187
+ }
188
+ else {
189
+ log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
190
+ }
240
191
  }
241
- else {
242
- const charBytes = Buffer.from(decoded[i], "utf8");
243
- bytes.push(...charBytes);
192
+ catch (err) {
193
+ log?.error(`${prefix} Tool media forward failed: ${err}`);
244
194
  }
245
195
  }
246
- const buffer = Buffer.from(bytes);
247
- const utf8Decoded = buffer.toString("utf8");
248
- if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
249
- mediaPath = utf8Decoded;
250
- log?.debug?.(`${prefix} Successfully decoded path: ${mediaPath}`);
251
- }
252
196
  }
197
+ toolMediaUrls.length = 0;
253
198
  }
254
- catch (decodeErr) {
255
- log?.error(`${prefix} Path decode error: ${decodeErr}`);
256
- }
257
- return mediaPath;
258
199
  }
200
+ // ============ 内部辅助函数 ============
259
201
  /** 发送文本分块(共用逻辑) */
260
202
  async function sendTextChunks(text, event, actx, sendWithRetry, consumeQuoteRef) {
261
203
  const { account, log } = actx;
@@ -282,23 +224,6 @@ async function sendTextChunks(text, event, actx, sendWithRetry, consumeQuoteRef)
282
224
  }
283
225
  }
284
226
  }
285
- /** 语音发送(带 45s 超时保护) */
286
- async function sendVoiceWithTimeout(target, voicePath, account, log, prefix) {
287
- const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
288
- const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
289
- const voiceTimeout = 45000;
290
- try {
291
- const result = await Promise.race([
292
- sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
293
- new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout)),
294
- ]);
295
- if (result.error)
296
- log?.error(`${prefix} sendVoice error: ${result.error}`);
297
- }
298
- catch (err) {
299
- log?.error(`${prefix} sendVoice unexpected error: ${err}`);
300
- }
301
- }
302
227
  /** Markdown 模式发送 */
303
228
  async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef) {
304
229
  const { account, log } = actx;
@@ -428,13 +353,17 @@ async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareU
428
353
  for (const imageUrl of imageUrls) {
429
354
  try {
430
355
  const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
431
- if (imgResult.error)
356
+ if (imgResult.error) {
432
357
  log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
433
- else
358
+ await sendTextChunks(`发送图片失败:${imgResult.error}`, event, actx, sendWithRetry, consumeQuoteRef);
359
+ }
360
+ else {
434
361
  log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
362
+ }
435
363
  }
436
364
  catch (imgErr) {
437
365
  log?.error(`${prefix} Failed to send image: ${imgErr}`);
366
+ await sendTextChunks(`发送图片失败:${imgErr}`, event, actx, sendWithRetry, consumeQuoteRef);
438
367
  }
439
368
  }
440
369
  if (result.trim()) {
@@ -78,11 +78,13 @@ export interface MediaTargetContext {
78
78
  * sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
79
79
  *
80
80
  * 支持三种来源:
81
- * - 本地文件路径(自动读取转 Base64)
82
- * - 公网 HTTP/HTTPS URL(urlDirectUpload=true 时先直传平台,失败自动下载重试;=false 时直接下载)
83
- * - Base64 Data URL
81
+ * - 本地文件路径 → 分片上传
82
+ * - 公网 HTTP/HTTPS URL 下载到本地 → 分片上传(失败发文本链接兜底)
83
+ * - Base64 Data URL → 直传 QQ API
84
84
  */
85
- export declare function sendPhoto(ctx: MediaTargetContext, imagePath: string): Promise<OutboundResult>;
85
+ export declare function sendPhoto(ctx: MediaTargetContext, imagePath: string,
86
+ /** 原始来源 URL(仅 fallback 路径使用,记录到引用索引) */
87
+ sourceUrl?: string): Promise<OutboundResult>;
86
88
  /**
87
89
  * sendVoice — 发送语音消息(对齐 Telegram sendVoice)
88
90
  *