@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.
- package/README.md +2 -15
- package/README.zh.md +3 -16
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +69 -34
- package/dist/src/api.d.ts +105 -1
- package/dist/src/api.js +164 -15
- package/dist/src/channel.js +13 -0
- package/dist/src/config.js +3 -10
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +450 -248
- package/dist/src/image-server.d.ts +27 -8
- package/dist/src/image-server.js +179 -71
- package/dist/src/inbound-attachments.d.ts +3 -1
- package/dist/src/inbound-attachments.js +28 -14
- package/dist/src/outbound-deliver.js +77 -148
- package/dist/src/outbound.d.ts +6 -4
- package/dist/src/outbound.js +266 -442
- package/dist/src/reply-dispatcher.js +4 -4
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/slash-commands.js +277 -32
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/streaming.d.ts +244 -0
- package/dist/src/streaming.js +907 -0
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.js +17 -1
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/audio-convert.d.ts +9 -0
- package/dist/src/utils/audio-convert.js +51 -0
- package/dist/src/utils/chunked-upload.d.ts +59 -0
- package/dist/src/utils/chunked-upload.js +289 -0
- package/dist/src/utils/file-utils.d.ts +7 -1
- package/dist/src/utils/file-utils.js +24 -2
- package/dist/src/utils/media-send.d.ts +147 -0
- package/dist/src/utils/media-send.js +434 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/node_modules/ws/index.js +15 -6
- package/node_modules/ws/lib/permessage-deflate.js +6 -6
- package/node_modules/ws/lib/websocket-server.js +5 -5
- package/node_modules/ws/lib/websocket.js +6 -6
- package/node_modules/ws/package.json +4 -3
- package/node_modules/ws/wrapper.mjs +14 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +11 -22
- package/scripts/postinstall-link-sdk.js +113 -0
- package/scripts/upgrade-via-npm.ps1 +161 -6
- package/scripts/upgrade-via-npm.sh +311 -104
- package/scripts/upgrade-via-source.sh +117 -0
- package/skills/qqbot-media/SKILL.md +9 -5
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +76 -35
- package/src/api.ts +284 -12
- package/src/channel.ts +12 -0
- package/src/config.ts +3 -10
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +277 -67
- package/src/image-server.ts +213 -77
- package/src/inbound-attachments.ts +32 -15
- package/src/outbound-deliver.ts +77 -157
- package/src/outbound.ts +304 -451
- package/src/reply-dispatcher.ts +4 -4
- package/src/request-context.ts +39 -0
- package/src/slash-commands.ts +303 -33
- package/src/startup-greeting.ts +35 -13
- package/src/streaming.ts +1096 -0
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +111 -0
- package/src/update-checker.ts +2 -7
- package/src/utils/audio-convert.ts +56 -0
- package/src/utils/chunked-upload.ts +419 -0
- package/src/utils/file-utils.ts +28 -2
- package/src/utils/media-send.ts +563 -0
- package/src/utils/pkg-version.ts +54 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/clawdbot.plugin.json +0 -16
- package/dist/src/user-messages.d.ts +0 -8
- package/dist/src/user-messages.js +0 -8
- package/moltbot.plugin.json +0 -16
- package/scripts/upgrade-via-alt-pkg.sh +0 -307
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
- package/src/gateway.log +0 -43
- package/src/openclaw-2026-03-21.log +0 -3729
- package/src/user-messages.ts +0 -7
package/src/outbound-deliver.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
16
|
-
import {
|
|
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
|
|
63
|
+
// 使用 media-send.ts 的统一解析器(内含 normalizeMediaTags + 路径编码修复)
|
|
64
|
+
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(replyText, log);
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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)
|
|
254
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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)
|
|
527
|
-
|
|
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
|
|