@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.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as path from "path";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as crypto from "crypto";
|
|
6
8
|
import type { ResolvedQQBotAccount } from "./types.js";
|
|
7
9
|
import { decodeCronPayload } from "./utils/payload.js";
|
|
8
10
|
import {
|
|
@@ -12,20 +14,16 @@ import {
|
|
|
12
14
|
sendGroupMessage,
|
|
13
15
|
sendProactiveC2CMessage,
|
|
14
16
|
sendProactiveGroupMessage,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
sendGroupVoiceMessage,
|
|
19
|
-
sendC2CVideoMessage,
|
|
20
|
-
sendGroupVideoMessage,
|
|
21
|
-
sendC2CFileMessage,
|
|
22
|
-
sendGroupFileMessage,
|
|
17
|
+
sendC2CMediaMessage,
|
|
18
|
+
sendGroupMediaMessage,
|
|
19
|
+
MediaFileType,
|
|
23
20
|
} from "./api.js";
|
|
24
|
-
import { isAudioFile,
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { isLocalPath as isLocalFilePath, normalizePath,
|
|
21
|
+
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
22
|
+
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
23
|
+
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
|
24
|
+
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
|
28
25
|
import { downloadFile } from "./image-server.js";
|
|
26
|
+
import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
|
|
29
27
|
|
|
30
28
|
// ============ 消息回复限流器 ============
|
|
31
29
|
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
|
@@ -275,22 +273,19 @@ async function getToken(account: ResolvedQQBotAccount): Promise<string> {
|
|
|
275
273
|
return getAccessToken(account.appId, account.clientSecret);
|
|
276
274
|
}
|
|
277
275
|
|
|
278
|
-
/** 判断是否应该对公网 URL 执行直传(不下载) */
|
|
279
|
-
function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean {
|
|
280
|
-
return account.config?.urlDirectUpload !== false; // 默认 true
|
|
281
|
-
}
|
|
282
|
-
|
|
283
276
|
/**
|
|
284
277
|
* sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
|
|
285
278
|
*
|
|
286
279
|
* 支持三种来源:
|
|
287
|
-
* -
|
|
288
|
-
* - 公网 HTTP/HTTPS URL
|
|
289
|
-
* - Base64 Data URL
|
|
280
|
+
* - 本地文件路径 → 分片上传
|
|
281
|
+
* - 公网 HTTP/HTTPS URL → 下载到本地 → 分片上传(失败发文本链接兜底)
|
|
282
|
+
* - Base64 Data URL → 直传 QQ API
|
|
290
283
|
*/
|
|
291
284
|
export async function sendPhoto(
|
|
292
285
|
ctx: MediaTargetContext,
|
|
293
286
|
imagePath: string,
|
|
287
|
+
/** 原始来源 URL(仅 fallback 路径使用,记录到引用索引) */
|
|
288
|
+
sourceUrl?: string,
|
|
294
289
|
): Promise<OutboundResult> {
|
|
295
290
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
296
291
|
const mediaPath = normalizePath(imagePath);
|
|
@@ -298,100 +293,75 @@ export async function sendPhoto(
|
|
|
298
293
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
299
294
|
const isData = mediaPath.startsWith("data:");
|
|
300
295
|
|
|
301
|
-
//
|
|
302
|
-
if (isHttp
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
296
|
+
// 公网 URL
|
|
297
|
+
if (isHttp) {
|
|
298
|
+
// 频道:仅支持公网 URL(Markdown 格式),无需下载
|
|
299
|
+
if (ctx.targetType === "channel") {
|
|
300
|
+
try {
|
|
301
|
+
const token = await getToken(ctx.account);
|
|
302
|
+
const r = await sendChannelMessage(token, ctx.targetId, ``, ctx.replyToId);
|
|
303
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
306
|
+
console.error(`${prefix} sendPhoto: channel Markdown image failed: ${msg}`);
|
|
307
|
+
return { channel: "qqbot", error: msg };
|
|
308
|
+
}
|
|
307
309
|
}
|
|
308
|
-
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
309
|
-
}
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
// c2c / group:下载到本地 → 走本地分片上传
|
|
312
|
+
console.log(`${prefix} sendPhoto: downloading URL to local for chunked upload...`);
|
|
313
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto", ctx.account.appId, ctx.targetId);
|
|
314
|
+
if (dl.localFile) {
|
|
315
|
+
return await sendPhoto(ctx, dl.localFile, mediaPath);
|
|
316
|
+
}
|
|
317
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendPhoto");
|
|
318
|
+
}
|
|
312
319
|
|
|
313
320
|
if (isLocal) {
|
|
314
|
-
if (!(await fileExistsAsync(mediaPath))) {
|
|
315
|
-
return { channel: "qqbot", error: "Image not found" };
|
|
316
|
-
}
|
|
317
|
-
const sizeCheck = checkFileSize(mediaPath);
|
|
318
|
-
if (!sizeCheck.ok) {
|
|
319
|
-
return { channel: "qqbot", error: sizeCheck.error! };
|
|
320
|
-
}
|
|
321
|
-
const fileBuffer = await readFileAsync(mediaPath);
|
|
322
321
|
const ext = path.extname(mediaPath).toLowerCase();
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
326
|
-
};
|
|
327
|
-
const mimeType = mimeTypes[ext];
|
|
328
|
-
if (!mimeType) {
|
|
322
|
+
const supportedImageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
|
|
323
|
+
if (!supportedImageExts.includes(ext)) {
|
|
329
324
|
return { channel: "qqbot", error: `Unsupported image format: ${ext}` };
|
|
330
325
|
}
|
|
331
|
-
imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
|
332
|
-
console.log(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`);
|
|
333
|
-
} else if (!isHttp && !isData) {
|
|
334
|
-
return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
|
|
335
|
-
}
|
|
336
326
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
327
|
+
// 本地图片统一走分片上传(文件存在/大小校验由 chunkedUploadAndSend 统一处理)
|
|
328
|
+
console.log(`${prefix} sendPhoto: local image, using chunked upload`);
|
|
329
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.IMAGE, prefix, "sendPhoto",
|
|
330
|
+
{ mediaType: "image", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
331
|
+
}
|
|
340
332
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
} else {
|
|
348
|
-
// 频道:仅支持公网 URL(Markdown 格式)
|
|
349
|
-
if (isHttp) {
|
|
350
|
-
const r = await sendChannelMessage(token, ctx.targetId, ``, ctx.replyToId);
|
|
351
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
333
|
+
// Data URL (base64):解码写到 downloads 目录 → 分块上传
|
|
334
|
+
if (isData) {
|
|
335
|
+
try {
|
|
336
|
+
const match = mediaPath.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
337
|
+
if (!match) {
|
|
338
|
+
return { channel: "qqbot", error: "无法解析 Data URL 格式" };
|
|
352
339
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
340
|
+
const ext = match[1] === "jpeg" ? "jpg" : match[1]!;
|
|
341
|
+
const base64Data = match[2]!;
|
|
342
|
+
const buf = Buffer.from(base64Data, "base64");
|
|
343
|
+
|
|
344
|
+
const downloadDir = getQQBotMediaDir("downloads", ctx.account.appId, ctx.targetId);
|
|
345
|
+
fs.mkdirSync(downloadDir, { recursive: true });
|
|
346
|
+
const tmpName = `dataurl_${crypto.randomBytes(8).toString("hex")}.${ext}`;
|
|
347
|
+
const localFile = path.join(downloadDir, tmpName);
|
|
348
|
+
fs.writeFileSync(localFile, buf);
|
|
349
|
+
|
|
350
|
+
console.log(`${prefix} sendPhoto: Data URL decoded to ${localFile} (${buf.length} bytes), using chunked upload`);
|
|
351
|
+
const result = await chunkedUploadAndSend(ctx, localFile, MediaFileType.IMAGE, prefix, "sendPhoto",
|
|
352
|
+
{ mediaType: "image", mediaLocalPath: localFile });
|
|
353
|
+
|
|
354
|
+
// 上传完毕后清理文件
|
|
355
|
+
try { fs.unlinkSync(localFile); } catch { /* ignore */ }
|
|
356
|
+
return result;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
359
|
+
console.error(`${prefix} sendPhoto Data URL failed: ${msg}`);
|
|
360
|
+
return { channel: "qqbot", error: msg };
|
|
364
361
|
}
|
|
365
|
-
|
|
366
|
-
console.error(`${prefix} sendPhoto failed: ${msg}`);
|
|
367
|
-
return { channel: "qqbot", error: msg };
|
|
368
362
|
}
|
|
369
|
-
}
|
|
370
363
|
|
|
371
|
-
|
|
372
|
-
* sendPhoto 的 URL fallback:下载远程图片到本地 → 转 Base64 → 重试发送
|
|
373
|
-
* 解决 QQ 开放平台无法拉取某些公网 URL(如海外域名)的问题
|
|
374
|
-
*/
|
|
375
|
-
async function downloadAndRetrySendPhoto(
|
|
376
|
-
ctx: MediaTargetContext,
|
|
377
|
-
httpUrl: string,
|
|
378
|
-
prefix: string,
|
|
379
|
-
): Promise<OutboundResult | null> {
|
|
380
|
-
try {
|
|
381
|
-
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
|
382
|
-
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
383
|
-
if (!localFile) {
|
|
384
|
-
console.error(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
385
|
-
return null;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
console.log(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`);
|
|
389
|
-
// 递归调用 sendPhoto,此时走本地文件路径
|
|
390
|
-
return await sendPhoto(ctx, localFile);
|
|
391
|
-
} catch (err) {
|
|
392
|
-
console.error(`${prefix} sendPhoto fallback error:`, err);
|
|
393
|
-
return null;
|
|
394
|
-
}
|
|
364
|
+
return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
|
|
395
365
|
}
|
|
396
366
|
|
|
397
367
|
/**
|
|
@@ -416,36 +386,14 @@ export async function sendVoice(
|
|
|
416
386
|
const mediaPath = normalizePath(voicePath);
|
|
417
387
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
418
388
|
|
|
419
|
-
// 公网 URL
|
|
389
|
+
// 公网 URL:统一下载到本地 → 分块上传(不走平台拉取)
|
|
420
390
|
if (isHttp) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (ctx.targetType === "c2c") {
|
|
426
|
-
const r = await sendC2CVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
427
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
428
|
-
} else if (ctx.targetType === "group") {
|
|
429
|
-
const r = await sendGroupVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
430
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
431
|
-
} else {
|
|
432
|
-
console.log(`${prefix} sendVoice: voice not supported in channel`);
|
|
433
|
-
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
434
|
-
}
|
|
435
|
-
} catch (err) {
|
|
436
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
437
|
-
console.warn(`${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`);
|
|
438
|
-
}
|
|
439
|
-
} else {
|
|
440
|
-
console.log(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`);
|
|
391
|
+
console.log(`${prefix} sendVoice: downloading URL to local for chunked upload...`);
|
|
392
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendVoice", ctx.account.appId, ctx.targetId);
|
|
393
|
+
if (dl.localFile) {
|
|
394
|
+
return await sendVoiceFromLocal(ctx, dl.localFile, directUploadFormats, transcodeEnabled, prefix, mediaPath);
|
|
441
395
|
}
|
|
442
|
-
|
|
443
|
-
// 下载到本地,然后走本地文件路径(含转码)
|
|
444
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice");
|
|
445
|
-
if (localFile) {
|
|
446
|
-
return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled, prefix);
|
|
447
|
-
}
|
|
448
|
-
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
396
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendVoice");
|
|
449
397
|
}
|
|
450
398
|
|
|
451
399
|
// 本地文件
|
|
@@ -459,6 +407,7 @@ async function sendVoiceFromLocal(
|
|
|
459
407
|
directUploadFormats: string[] | undefined,
|
|
460
408
|
transcodeEnabled: boolean,
|
|
461
409
|
prefix: string,
|
|
410
|
+
sourceUrl?: string,
|
|
462
411
|
): Promise<OutboundResult> {
|
|
463
412
|
// 等待文件就绪(TTS 异步生成,文件可能还没写完)
|
|
464
413
|
const fileSize = await waitForFile(mediaPath);
|
|
@@ -476,30 +425,25 @@ async function sendVoiceFromLocal(
|
|
|
476
425
|
return { channel: "qqbot", error: `语音转码已禁用,格式 ${ext} 不支持直传` };
|
|
477
426
|
}
|
|
478
427
|
|
|
479
|
-
|
|
480
|
-
const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats);
|
|
481
|
-
let uploadBase64 = silkBase64;
|
|
428
|
+
const urlMeta = sourceUrl ? { mediaUrl: sourceUrl } : {};
|
|
482
429
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
430
|
+
// 统一走分片上传:需要转码的先转码写入临时文件,不需要转码的直接上传原文件
|
|
431
|
+
try {
|
|
432
|
+
const uploadPath = needsTranscode
|
|
433
|
+
? await audioFileToSilkFile(mediaPath, directUploadFormats)
|
|
434
|
+
: mediaPath;
|
|
435
|
+
|
|
436
|
+
if (!uploadPath) {
|
|
437
|
+
// 转码失败 → fallback: 读取原文件直接上传
|
|
438
|
+
console.warn(`${prefix} sendVoice: SILK conversion failed, uploading raw file via chunked upload`);
|
|
439
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.VOICE, prefix, "sendVoice",
|
|
440
|
+
{ mediaType: "voice", mediaLocalPath: mediaPath, ...urlMeta });
|
|
489
441
|
}
|
|
490
442
|
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
496
|
-
} else if (ctx.targetType === "group") {
|
|
497
|
-
const r = await sendGroupVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId);
|
|
498
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
499
|
-
} else {
|
|
500
|
-
console.log(`${prefix} sendVoice: voice not supported in channel`);
|
|
501
|
-
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
502
|
-
}
|
|
443
|
+
const uploadSize = await getFileSizeAsync(uploadPath);
|
|
444
|
+
console.log(`${prefix} sendVoice: using chunked upload (${formatFileSize(uploadSize)})${needsTranscode ? " [transcoded]" : ""}`);
|
|
445
|
+
return chunkedUploadAndSend(ctx, uploadPath, MediaFileType.VOICE, prefix, "sendVoice",
|
|
446
|
+
{ mediaType: "voice", mediaLocalPath: mediaPath, ...urlMeta });
|
|
503
447
|
} catch (err) {
|
|
504
448
|
const msg = err instanceof Error ? err.message : String(err);
|
|
505
449
|
console.error(`${prefix} sendVoice (local) failed: ${msg}`);
|
|
@@ -520,83 +464,112 @@ export async function sendVideoMsg(
|
|
|
520
464
|
const mediaPath = normalizePath(videoPath);
|
|
521
465
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
522
466
|
|
|
523
|
-
//
|
|
524
|
-
if (isHttp
|
|
525
|
-
console.log(`${prefix} sendVideoMsg:
|
|
526
|
-
const
|
|
527
|
-
if (localFile) {
|
|
528
|
-
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
467
|
+
// 公网 URL:统一下载到本地 → 分块上传(不走平台拉取)
|
|
468
|
+
if (isHttp) {
|
|
469
|
+
console.log(`${prefix} sendVideoMsg: downloading URL to local for chunked upload...`);
|
|
470
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg", ctx.account.appId, ctx.targetId);
|
|
471
|
+
if (dl.localFile) {
|
|
472
|
+
return await sendVideoFromLocal(ctx, dl.localFile, prefix, mediaPath);
|
|
529
473
|
}
|
|
530
|
-
return
|
|
474
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendVideoMsg");
|
|
531
475
|
}
|
|
532
476
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (isHttp) {
|
|
537
|
-
// 公网 URL:先尝试直传平台
|
|
538
|
-
if (ctx.targetType === "c2c") {
|
|
539
|
-
const r = await sendC2CVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
540
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
541
|
-
} else if (ctx.targetType === "group") {
|
|
542
|
-
const r = await sendGroupVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
543
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
544
|
-
} else {
|
|
545
|
-
console.log(`${prefix} sendVideoMsg: video not supported in channel`);
|
|
546
|
-
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// 本地文件
|
|
551
|
-
return await sendVideoFromLocal(ctx, mediaPath, prefix);
|
|
552
|
-
} catch (err) {
|
|
553
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
554
|
-
|
|
555
|
-
// 公网 URL 直传失败 → 插件下载 → Base64 重试
|
|
556
|
-
if (isHttp) {
|
|
557
|
-
console.warn(`${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
558
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
|
|
559
|
-
if (localFile) {
|
|
560
|
-
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
477
|
+
// 本地文件
|
|
478
|
+
return await sendVideoFromLocal(ctx, mediaPath, prefix);
|
|
479
|
+
}
|
|
563
480
|
|
|
564
|
-
|
|
565
|
-
|
|
481
|
+
/**
|
|
482
|
+
* 通用分片上传并发送 — 消除 Video/Document/Image/Voice 的重复代码
|
|
483
|
+
*
|
|
484
|
+
* 根据 ctx.targetType 自动选择 C2C / Group 分片上传,上传完成后发送媒体消息。
|
|
485
|
+
* Channel 类型不支持分片上传,返回错误。
|
|
486
|
+
*/
|
|
487
|
+
async function chunkedUploadAndSend(
|
|
488
|
+
ctx: MediaTargetContext,
|
|
489
|
+
mediaPath: string,
|
|
490
|
+
fileType: MediaFileType,
|
|
491
|
+
prefix: string,
|
|
492
|
+
/** 调用方名称,用于日志,如 "sendVideoMsg" / "sendDocument" */
|
|
493
|
+
callerName: string,
|
|
494
|
+
/** 发送消息时的额外 meta 信息(可选) */
|
|
495
|
+
sendMeta?: Record<string, unknown>,
|
|
496
|
+
): Promise<OutboundResult> {
|
|
497
|
+
const { appId, clientSecret } = ctx.account;
|
|
498
|
+
if (!appId || !clientSecret) {
|
|
499
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
566
500
|
}
|
|
567
|
-
}
|
|
568
501
|
|
|
569
|
-
|
|
570
|
-
async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
|
|
502
|
+
// 统一前置校验:文件存在 + 非空 + 大小上限
|
|
571
503
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
572
|
-
return { channel: "qqbot", error:
|
|
504
|
+
return { channel: "qqbot", error: `${callerName}: file not found: ${mediaPath}` };
|
|
573
505
|
}
|
|
574
|
-
const
|
|
575
|
-
if (
|
|
576
|
-
return { channel: "qqbot", error:
|
|
506
|
+
const fileSize = await getFileSizeAsync(mediaPath);
|
|
507
|
+
if (fileSize === 0) {
|
|
508
|
+
return { channel: "qqbot", error: `${callerName}: file is empty: ${mediaPath}` };
|
|
509
|
+
}
|
|
510
|
+
const maxSize = getMaxUploadSize(fileType);
|
|
511
|
+
if (fileSize > maxSize) {
|
|
512
|
+
const typeName = getFileTypeName(fileType);
|
|
513
|
+
const limitMB = Math.round(maxSize / (1024 * 1024));
|
|
514
|
+
return { channel: "qqbot", error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。` };
|
|
577
515
|
}
|
|
578
516
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
517
|
+
if (ctx.targetType === "c2c") {
|
|
518
|
+
console.log(`${prefix} ${callerName}: c2c chunked upload (${formatFileSize(fileSize)})`);
|
|
519
|
+
try {
|
|
520
|
+
const uploadResult = await chunkedUploadC2C(
|
|
521
|
+
appId, clientSecret, ctx.targetId, mediaPath, fileType,
|
|
522
|
+
{
|
|
523
|
+
logPrefix: `${prefix} [chunked]`,
|
|
524
|
+
onProgress: (progress) => {
|
|
525
|
+
console.log(`${prefix} ${callerName}: chunked upload progress ${progress.completedParts}/${progress.totalParts} parts, ${formatFileSize(progress.uploadedBytes)}/${formatFileSize(progress.totalBytes)}`);
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const token = await getToken(ctx.account);
|
|
531
|
+
const r = await sendC2CMediaMessage(token, ctx.targetId, uploadResult.file_info, ctx.replyToId, undefined, sendMeta);
|
|
587
532
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
588
|
-
}
|
|
589
|
-
const
|
|
533
|
+
} catch (err) {
|
|
534
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
535
|
+
console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
|
|
536
|
+
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (ctx.targetType === "group") {
|
|
541
|
+
console.log(`${prefix} ${callerName}: group chunked upload (${formatFileSize(fileSize)})`);
|
|
542
|
+
try {
|
|
543
|
+
const uploadResult = await chunkedUploadGroup(
|
|
544
|
+
appId, clientSecret, ctx.targetId, mediaPath, fileType,
|
|
545
|
+
{
|
|
546
|
+
logPrefix: `${prefix} [chunked]`,
|
|
547
|
+
onProgress: (progress) => {
|
|
548
|
+
console.log(`${prefix} ${callerName}: chunked upload progress ${progress.completedParts}/${progress.totalParts} parts, ${formatFileSize(progress.uploadedBytes)}/${formatFileSize(progress.totalBytes)}`);
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
const token = await getToken(ctx.account);
|
|
554
|
+
const r = await sendGroupMediaMessage(token, ctx.targetId, uploadResult.file_info, ctx.replyToId);
|
|
590
555
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
|
|
556
|
+
} catch (err) {
|
|
557
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
558
|
+
console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
|
|
559
|
+
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
594
560
|
}
|
|
595
|
-
} catch (err) {
|
|
596
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
597
|
-
console.error(`${prefix} sendVideoMsg (local) failed: ${msg}`);
|
|
598
|
-
return { channel: "qqbot", error: msg };
|
|
599
561
|
}
|
|
562
|
+
|
|
563
|
+
// Channel: 不支持分片上传
|
|
564
|
+
console.log(`${prefix} ${callerName}: media not supported in channel`);
|
|
565
|
+
return { channel: "qqbot", error: `${callerName}: media not supported in channel` };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
|
|
569
|
+
async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string, sourceUrl?: string): Promise<OutboundResult> {
|
|
570
|
+
// 文件存在/大小校验由 chunkedUploadAndSend 统一处理
|
|
571
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.VIDEO, prefix, "sendVideoMsg",
|
|
572
|
+
{ mediaType: "video", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
600
573
|
}
|
|
601
574
|
|
|
602
575
|
/**
|
|
@@ -611,108 +584,92 @@ export async function sendDocument(
|
|
|
611
584
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
612
585
|
const mediaPath = normalizePath(filePath);
|
|
613
586
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
614
|
-
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
615
|
-
|
|
616
|
-
// urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
|
|
617
|
-
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
618
|
-
console.log(`${prefix} sendDocument: urlDirectUpload=false, downloading URL first...`);
|
|
619
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
620
|
-
if (localFile) {
|
|
621
|
-
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
622
|
-
}
|
|
623
|
-
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
try {
|
|
627
|
-
const token = await getToken(ctx.account);
|
|
628
|
-
|
|
629
|
-
if (isHttp) {
|
|
630
|
-
// 公网 URL:先尝试直传平台
|
|
631
|
-
if (ctx.targetType === "c2c") {
|
|
632
|
-
const r = await sendC2CFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
|
|
633
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
634
|
-
} else if (ctx.targetType === "group") {
|
|
635
|
-
const r = await sendGroupFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
|
|
636
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
637
|
-
} else {
|
|
638
|
-
console.log(`${prefix} sendDocument: file not supported in channel`);
|
|
639
|
-
return { channel: "qqbot", error: "File not supported in channel" };
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
587
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if (isHttp) {
|
|
650
|
-
console.warn(`${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
651
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
652
|
-
if (localFile) {
|
|
653
|
-
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
654
|
-
}
|
|
588
|
+
// 公网 URL:统一下载到本地 → 分块上传(不走平台拉取)
|
|
589
|
+
if (isHttp) {
|
|
590
|
+
console.log(`${prefix} sendDocument: downloading URL to local for chunked upload...`);
|
|
591
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendDocument", ctx.account.appId, ctx.targetId);
|
|
592
|
+
if (dl.localFile) {
|
|
593
|
+
return await sendDocumentFromLocal(ctx, dl.localFile, prefix, mediaPath);
|
|
655
594
|
}
|
|
656
|
-
|
|
657
|
-
console.error(`${prefix} sendDocument failed: ${msg}`);
|
|
658
|
-
return { channel: "qqbot", error: msg };
|
|
595
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendDocument");
|
|
659
596
|
}
|
|
597
|
+
|
|
598
|
+
// 本地文件
|
|
599
|
+
return await sendDocumentFromLocal(ctx, mediaPath, prefix);
|
|
660
600
|
}
|
|
661
601
|
|
|
662
602
|
/** 从本地文件发送文件(sendDocument 的内部辅助) */
|
|
663
|
-
async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
|
|
664
|
-
|
|
603
|
+
async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string, sourceUrl?: string): Promise<OutboundResult> {
|
|
604
|
+
// 文件存在/空文件/大小校验由 chunkedUploadAndSend 统一处理
|
|
605
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.FILE, prefix, "sendDocument",
|
|
606
|
+
{ mediaType: "file", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
607
|
+
}
|
|
665
608
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const fileBuffer = await readFileAsync(mediaPath);
|
|
674
|
-
if (fileBuffer.length === 0) {
|
|
675
|
-
return { channel: "qqbot", error: `文件内容为空: ${mediaPath}` };
|
|
676
|
-
}
|
|
677
|
-
const fileBase64 = fileBuffer.toString("base64");
|
|
678
|
-
console.log(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`);
|
|
609
|
+
/** 下载 fallback 的结果 */
|
|
610
|
+
interface DownloadFallbackResult {
|
|
611
|
+
/** 下载成功时的本地文件路径 */
|
|
612
|
+
localFile: string | null;
|
|
613
|
+
/** 下载失败时的错误信息 */
|
|
614
|
+
error?: string;
|
|
615
|
+
}
|
|
679
616
|
|
|
617
|
+
/**
|
|
618
|
+
* 通用辅助:下载远程文件到 fallback 目录
|
|
619
|
+
* 目录结构:~/.openclaw/media/qqbot/downloads/{appId}/{targetId}/
|
|
620
|
+
* 用于各 send* 函数的公网 URL 下载
|
|
621
|
+
*/
|
|
622
|
+
async function downloadToFallbackDir(httpUrl: string, prefix: string, caller: string, appId?: string, targetId?: string): Promise<DownloadFallbackResult> {
|
|
680
623
|
try {
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
return {
|
|
688
|
-
} else {
|
|
689
|
-
console.log(`${prefix} sendDocument: file not supported in channel`);
|
|
690
|
-
return { channel: "qqbot", error: "File not supported in channel" };
|
|
624
|
+
const subPaths = ["downloads", ...(appId ? [appId] : []), ...(targetId ? [targetId] : [])];
|
|
625
|
+
const downloadDir = getQQBotMediaDir(...subPaths);
|
|
626
|
+
const result = await downloadFile(httpUrl, undefined, { destDir: downloadDir });
|
|
627
|
+
if (!result.filePath) {
|
|
628
|
+
const errorMsg = result.error ?? "下载失败";
|
|
629
|
+
console.error(`${prefix} ${caller} fallback: download failed for ${httpUrl.slice(0, 80)} — ${errorMsg}`);
|
|
630
|
+
return { localFile: null, error: errorMsg };
|
|
691
631
|
}
|
|
632
|
+
console.log(`${prefix} ${caller} fallback: downloaded → ${result.filePath}`);
|
|
633
|
+
return { localFile: result.filePath };
|
|
692
634
|
} catch (err) {
|
|
693
635
|
const msg = err instanceof Error ? err.message : String(err);
|
|
694
|
-
console.error(`${prefix}
|
|
695
|
-
return {
|
|
636
|
+
console.error(`${prefix} ${caller} fallback download error:`, err);
|
|
637
|
+
return { localFile: null, error: msg };
|
|
696
638
|
}
|
|
697
639
|
}
|
|
698
640
|
|
|
699
641
|
/**
|
|
700
|
-
*
|
|
701
|
-
*
|
|
642
|
+
* 媒体下载/上传失败时的兜底:把原始 URL 以文本链接的形式发给用户。
|
|
643
|
+
* 用户可以手动点击链接在浏览器中打开。
|
|
702
644
|
*/
|
|
703
|
-
async function
|
|
645
|
+
async function sendFallbackLink(
|
|
646
|
+
ctx: MediaTargetContext,
|
|
647
|
+
httpUrl: string,
|
|
648
|
+
errorReason: string,
|
|
649
|
+
prefix: string,
|
|
650
|
+
caller: string,
|
|
651
|
+
): Promise<OutboundResult> {
|
|
652
|
+
console.warn(`${prefix} ${caller}: falling back to text link for "${httpUrl.slice(0, 80)}"`);
|
|
704
653
|
try {
|
|
705
|
-
const
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
654
|
+
const token = await getToken(ctx.account);
|
|
655
|
+
const fallbackText = `📎 ${httpUrl}`;
|
|
656
|
+
|
|
657
|
+
let r: { id?: string; timestamp?: string | number };
|
|
658
|
+
if (ctx.targetType === "c2c") {
|
|
659
|
+
r = await sendC2CMessage(token, ctx.targetId, fallbackText, ctx.replyToId);
|
|
660
|
+
} else if (ctx.targetType === "group") {
|
|
661
|
+
r = await sendGroupMessage(token, ctx.targetId, fallbackText, ctx.replyToId);
|
|
662
|
+
} else {
|
|
663
|
+
r = await sendChannelMessage(token, ctx.targetId, fallbackText, ctx.replyToId);
|
|
710
664
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
665
|
+
// 链接已成功发给用户 → 视为兜底成功,不设 error,
|
|
666
|
+
// 上层不会再发额外的错误文案
|
|
667
|
+
console.log(`${prefix} ${caller}: fallback link sent successfully`);
|
|
668
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
669
|
+
} catch (fallbackErr) {
|
|
670
|
+
const fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
671
|
+
console.error(`${prefix} ${caller}: fallback link send also failed: ${fallbackMsg}`);
|
|
672
|
+
return { channel: "qqbot", error: `${caller}: 媒体发送失败 (${errorReason}),兜底链接也发送失败 (${fallbackMsg})` };
|
|
716
673
|
}
|
|
717
674
|
}
|
|
718
675
|
|
|
@@ -748,7 +705,7 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
748
705
|
// 不应该发生,但作为保底
|
|
749
706
|
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
|
750
707
|
return {
|
|
751
|
-
channel: "qqbot",
|
|
708
|
+
channel: "qqbot",
|
|
752
709
|
error: limitCheck.message
|
|
753
710
|
};
|
|
754
711
|
}
|
|
@@ -764,170 +721,66 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
764
721
|
// <qqvideo>路径或URL</qqvideo> — 视频
|
|
765
722
|
// <qqfile>路径</qqfile> — 文件
|
|
766
723
|
// <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
|
|
724
|
+
// 使用 deliver-common.ts 的公共解析器,消除与 gateway.ts 的重复
|
|
767
725
|
|
|
768
|
-
|
|
769
|
-
text = normalizeMediaTags(text);
|
|
770
|
-
|
|
771
|
-
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
772
|
-
const mediaTagMatches = text.match(mediaTagRegex);
|
|
726
|
+
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(text);
|
|
773
727
|
|
|
774
|
-
if (
|
|
775
|
-
console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
776
|
-
|
|
777
|
-
// 构建发送队列:根据内容在原文中的实际位置顺序发送
|
|
778
|
-
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
|
|
779
|
-
|
|
780
|
-
let lastIndex = 0;
|
|
781
|
-
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
782
|
-
let match;
|
|
783
|
-
|
|
784
|
-
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
785
|
-
// 添加标签前的文本
|
|
786
|
-
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
787
|
-
if (textBefore) {
|
|
788
|
-
sendQueue.push({ type: "text", content: textBefore });
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const tagName = match[1]!.toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
|
|
792
|
-
|
|
793
|
-
// 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
|
|
794
|
-
let mediaPath = match[2]?.trim() ?? "";
|
|
795
|
-
if (mediaPath.startsWith("MEDIA:")) {
|
|
796
|
-
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
797
|
-
}
|
|
798
|
-
mediaPath = normalizePath(mediaPath);
|
|
799
|
-
|
|
800
|
-
// 处理可能被模型转义的路径
|
|
801
|
-
// 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
|
|
802
|
-
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
803
|
-
|
|
804
|
-
// 2. 八进制转义序列 + UTF-8 双重编码修复
|
|
805
|
-
try {
|
|
806
|
-
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
807
|
-
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
808
|
-
|
|
809
|
-
if (hasOctal || hasNonASCII) {
|
|
810
|
-
console.log(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
|
811
|
-
|
|
812
|
-
// Step 1: 将八进制转义转换为字节
|
|
813
|
-
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
|
814
|
-
return String.fromCharCode(parseInt(octal, 8));
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
// Step 2: 提取所有字节(包括 Latin-1 字符)
|
|
818
|
-
const bytes: number[] = [];
|
|
819
|
-
for (let i = 0; i < decoded.length; i++) {
|
|
820
|
-
const code = decoded.charCodeAt(i);
|
|
821
|
-
if (code <= 0xFF) {
|
|
822
|
-
bytes.push(code);
|
|
823
|
-
} else {
|
|
824
|
-
const charBytes = Buffer.from(decoded[i], 'utf8');
|
|
825
|
-
bytes.push(...charBytes);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// Step 3: 尝试按 UTF-8 解码
|
|
830
|
-
const buffer = Buffer.from(bytes);
|
|
831
|
-
const utf8Decoded = buffer.toString('utf8');
|
|
832
|
-
|
|
833
|
-
if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
|
|
834
|
-
mediaPath = utf8Decoded;
|
|
835
|
-
console.log(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
} catch (decodeErr) {
|
|
839
|
-
console.error(`[qqbot] sendText: Path decode error: ${decodeErr}`);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
if (mediaPath) {
|
|
843
|
-
if (tagName === "qqmedia") {
|
|
844
|
-
sendQueue.push({ type: "media", content: mediaPath });
|
|
845
|
-
console.log(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
846
|
-
} else if (tagName === "qqvoice") {
|
|
847
|
-
sendQueue.push({ type: "voice", content: mediaPath });
|
|
848
|
-
console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
849
|
-
} else if (tagName === "qqvideo") {
|
|
850
|
-
sendQueue.push({ type: "video", content: mediaPath });
|
|
851
|
-
console.log(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
|
852
|
-
} else if (tagName === "qqfile") {
|
|
853
|
-
sendQueue.push({ type: "file", content: mediaPath });
|
|
854
|
-
console.log(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
|
855
|
-
} else {
|
|
856
|
-
sendQueue.push({ type: "image", content: mediaPath });
|
|
857
|
-
console.log(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
lastIndex = match.index + match[0].length;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// 添加最后一个标签后的文本
|
|
865
|
-
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
|
866
|
-
if (textAfter) {
|
|
867
|
-
sendQueue.push({ type: "text", content: textAfter });
|
|
868
|
-
}
|
|
869
|
-
|
|
728
|
+
if (hasMedia && sendQueue.length > 0) {
|
|
870
729
|
console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
871
730
|
|
|
872
|
-
//
|
|
731
|
+
// 构建统一的媒体发送上下文
|
|
873
732
|
const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
|
|
733
|
+
const mediaSendCtx: MediaSendContext = {
|
|
734
|
+
mediaTarget,
|
|
735
|
+
qualifiedTarget: to,
|
|
736
|
+
account,
|
|
737
|
+
replyToId: replyToId ?? undefined,
|
|
738
|
+
log: {
|
|
739
|
+
info: (msg: string) => console.log(msg),
|
|
740
|
+
error: (msg: string) => console.error(msg),
|
|
741
|
+
debug: (msg: string) => console.log(msg),
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
|
|
874
745
|
let lastResult: OutboundResult = { channel: "qqbot" };
|
|
875
746
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
} else {
|
|
892
|
-
const result = await sendChannelMessage(accessToken, target.id, item.content, replyToId);
|
|
893
|
-
recordMessageReply(replyToId);
|
|
894
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
895
|
-
}
|
|
747
|
+
// 使用统一的发送队列执行器
|
|
748
|
+
await executeSendQueue(sendQueue, mediaSendCtx, {
|
|
749
|
+
onSendText: async (textContent) => {
|
|
750
|
+
// sendText 场景的文本发送:需要区分主动/被动消息
|
|
751
|
+
if (replyToId) {
|
|
752
|
+
const accessToken = await getToken(account);
|
|
753
|
+
const target = parseTarget(to);
|
|
754
|
+
if (target.type === "c2c") {
|
|
755
|
+
const result = await sendC2CMessage(accessToken, target.id, textContent, replyToId);
|
|
756
|
+
recordMessageReply(replyToId);
|
|
757
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
758
|
+
} else if (target.type === "group") {
|
|
759
|
+
const result = await sendGroupMessage(accessToken, target.id, textContent, replyToId);
|
|
760
|
+
recordMessageReply(replyToId);
|
|
761
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
896
762
|
} else {
|
|
897
|
-
const
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
763
|
+
const result = await sendChannelMessage(accessToken, target.id, textContent, replyToId);
|
|
764
|
+
recordMessageReply(replyToId);
|
|
765
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
766
|
+
}
|
|
767
|
+
} else {
|
|
768
|
+
const accessToken = await getToken(account);
|
|
769
|
+
const target = parseTarget(to);
|
|
770
|
+
if (target.type === "c2c") {
|
|
771
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, textContent);
|
|
772
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
773
|
+
} else if (target.type === "group") {
|
|
774
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, textContent);
|
|
775
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
776
|
+
} else {
|
|
777
|
+
const result = await sendChannelMessage(accessToken, target.id, textContent);
|
|
778
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
909
779
|
}
|
|
910
|
-
console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
911
|
-
} else if (item.type === "image") {
|
|
912
|
-
lastResult = await sendPhoto(mediaTarget, item.content);
|
|
913
|
-
} else if (item.type === "voice") {
|
|
914
|
-
lastResult = await sendVoice(mediaTarget, item.content, undefined, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
|
|
915
|
-
} else if (item.type === "video") {
|
|
916
|
-
lastResult = await sendVideoMsg(mediaTarget, item.content);
|
|
917
|
-
} else if (item.type === "file") {
|
|
918
|
-
lastResult = await sendDocument(mediaTarget, item.content);
|
|
919
|
-
} else if (item.type === "media") {
|
|
920
|
-
// qqmedia: 自动根据扩展名路由
|
|
921
|
-
lastResult = await sendMedia({
|
|
922
|
-
to, text: "", mediaUrl: item.content,
|
|
923
|
-
accountId: account.accountId, replyToId, account,
|
|
924
|
-
});
|
|
925
780
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
}
|
|
781
|
+
console.log(`[qqbot] sendText: Sent text part: ${textContent.slice(0, 30)}...`);
|
|
782
|
+
},
|
|
783
|
+
});
|
|
931
784
|
|
|
932
785
|
return lastResult;
|
|
933
786
|
}
|