@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/dist/src/outbound.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
* QQ Bot 消息发送模块
|
|
3
3
|
*/
|
|
4
4
|
import * as path from "path";
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as crypto from "crypto";
|
|
5
7
|
import { decodeCronPayload } from "./utils/payload.js";
|
|
6
|
-
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage,
|
|
7
|
-
import { isAudioFile,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { isLocalPath as isLocalFilePath, normalizePath,
|
|
8
|
+
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, } from "./api.js";
|
|
9
|
+
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
10
|
+
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
11
|
+
import { chunkedUploadC2C, chunkedUploadGroup } from "./utils/chunked-upload.js";
|
|
12
|
+
import { isLocalPath as isLocalFilePath, normalizePath, getQQBotMediaDir } from "./utils/platform.js";
|
|
11
13
|
import { downloadFile } from "./image-server.js";
|
|
14
|
+
import { parseMediaTagsToSendQueue, executeSendQueue } from "./utils/media-send.js";
|
|
12
15
|
// ============ 消息回复限流器 ============
|
|
13
16
|
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
|
14
17
|
const MESSAGE_REPLY_LIMIT = 4;
|
|
@@ -177,112 +180,86 @@ async function getToken(account) {
|
|
|
177
180
|
}
|
|
178
181
|
return getAccessToken(account.appId, account.clientSecret);
|
|
179
182
|
}
|
|
180
|
-
/** 判断是否应该对公网 URL 执行直传(不下载) */
|
|
181
|
-
function shouldDirectUploadUrl(account) {
|
|
182
|
-
return account.config?.urlDirectUpload !== false; // 默认 true
|
|
183
|
-
}
|
|
184
183
|
/**
|
|
185
184
|
* sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
|
|
186
185
|
*
|
|
187
186
|
* 支持三种来源:
|
|
188
|
-
* -
|
|
189
|
-
* - 公网 HTTP/HTTPS URL
|
|
190
|
-
* - Base64 Data URL
|
|
187
|
+
* - 本地文件路径 → 分片上传
|
|
188
|
+
* - 公网 HTTP/HTTPS URL → 下载到本地 → 分片上传(失败发文本链接兜底)
|
|
189
|
+
* - Base64 Data URL → 直传 QQ API
|
|
191
190
|
*/
|
|
192
|
-
export async function sendPhoto(ctx, imagePath
|
|
191
|
+
export async function sendPhoto(ctx, imagePath,
|
|
192
|
+
/** 原始来源 URL(仅 fallback 路径使用,记录到引用索引) */
|
|
193
|
+
sourceUrl) {
|
|
193
194
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
194
195
|
const mediaPath = normalizePath(imagePath);
|
|
195
196
|
const isLocal = isLocalFilePath(mediaPath);
|
|
196
197
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
197
198
|
const isData = mediaPath.startsWith("data:");
|
|
198
|
-
//
|
|
199
|
-
if (isHttp
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
// 公网 URL
|
|
200
|
+
if (isHttp) {
|
|
201
|
+
// 频道:仅支持公网 URL(Markdown 格式),无需下载
|
|
202
|
+
if (ctx.targetType === "channel") {
|
|
203
|
+
try {
|
|
204
|
+
const token = await getToken(ctx.account);
|
|
205
|
+
const r = await sendChannelMessage(token, ctx.targetId, ``, ctx.replyToId);
|
|
206
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
console.error(`${prefix} sendPhoto: channel Markdown image failed: ${msg}`);
|
|
211
|
+
return { channel: "qqbot", error: msg };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// c2c / group:下载到本地 → 走本地分片上传
|
|
215
|
+
console.log(`${prefix} sendPhoto: downloading URL to local for chunked upload...`);
|
|
216
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto", ctx.account.appId, ctx.targetId);
|
|
217
|
+
if (dl.localFile) {
|
|
218
|
+
return await sendPhoto(ctx, dl.localFile, mediaPath);
|
|
204
219
|
}
|
|
205
|
-
return
|
|
220
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendPhoto");
|
|
206
221
|
}
|
|
207
|
-
let imageUrl = mediaPath;
|
|
208
222
|
if (isLocal) {
|
|
209
|
-
if (!(await fileExistsAsync(mediaPath))) {
|
|
210
|
-
return { channel: "qqbot", error: "Image not found" };
|
|
211
|
-
}
|
|
212
|
-
const sizeCheck = checkFileSize(mediaPath);
|
|
213
|
-
if (!sizeCheck.ok) {
|
|
214
|
-
return { channel: "qqbot", error: sizeCheck.error };
|
|
215
|
-
}
|
|
216
|
-
const fileBuffer = await readFileAsync(mediaPath);
|
|
217
223
|
const ext = path.extname(mediaPath).toLowerCase();
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
221
|
-
};
|
|
222
|
-
const mimeType = mimeTypes[ext];
|
|
223
|
-
if (!mimeType) {
|
|
224
|
+
const supportedImageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
|
|
225
|
+
if (!supportedImageExts.includes(ext)) {
|
|
224
226
|
return { channel: "qqbot", error: `Unsupported image format: ${ext}` };
|
|
225
227
|
}
|
|
226
|
-
|
|
227
|
-
console.log(`${prefix} sendPhoto: local
|
|
228
|
-
|
|
229
|
-
else if (!isHttp && !isData) {
|
|
230
|
-
return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
|
|
228
|
+
// 本地图片统一走分片上传(文件存在/大小校验由 chunkedUploadAndSend 统一处理)
|
|
229
|
+
console.log(`${prefix} sendPhoto: local image, using chunked upload`);
|
|
230
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.IMAGE, prefix, "sendPhoto", { mediaType: "image", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
231
231
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
else if (ctx.targetType === "group") {
|
|
240
|
-
const r = await sendGroupImageMessage(token, ctx.targetId, imageUrl, ctx.replyToId);
|
|
241
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
242
|
-
}
|
|
243
|
-
else {
|
|
244
|
-
// 频道:仅支持公网 URL(Markdown 格式)
|
|
245
|
-
if (isHttp) {
|
|
246
|
-
const r = await sendChannelMessage(token, ctx.targetId, ``, ctx.replyToId);
|
|
247
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
232
|
+
// Data URL (base64):解码写到 downloads 目录 → 分块上传
|
|
233
|
+
if (isData) {
|
|
234
|
+
try {
|
|
235
|
+
const match = mediaPath.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
236
|
+
if (!match) {
|
|
237
|
+
return { channel: "qqbot", error: "无法解析 Data URL 格式" };
|
|
248
238
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
console.
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
239
|
+
const ext = match[1] === "jpeg" ? "jpg" : match[1];
|
|
240
|
+
const base64Data = match[2];
|
|
241
|
+
const buf = Buffer.from(base64Data, "base64");
|
|
242
|
+
const downloadDir = getQQBotMediaDir("downloads", ctx.account.appId, ctx.targetId);
|
|
243
|
+
fs.mkdirSync(downloadDir, { recursive: true });
|
|
244
|
+
const tmpName = `dataurl_${crypto.randomBytes(8).toString("hex")}.${ext}`;
|
|
245
|
+
const localFile = path.join(downloadDir, tmpName);
|
|
246
|
+
fs.writeFileSync(localFile, buf);
|
|
247
|
+
console.log(`${prefix} sendPhoto: Data URL decoded to ${localFile} (${buf.length} bytes), using chunked upload`);
|
|
248
|
+
const result = await chunkedUploadAndSend(ctx, localFile, MediaFileType.IMAGE, prefix, "sendPhoto", { mediaType: "image", mediaLocalPath: localFile });
|
|
249
|
+
// 上传完毕后清理文件
|
|
250
|
+
try {
|
|
251
|
+
fs.unlinkSync(localFile);
|
|
252
|
+
}
|
|
253
|
+
catch { /* ignore */ }
|
|
254
|
+
return result;
|
|
261
255
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* sendPhoto 的 URL fallback:下载远程图片到本地 → 转 Base64 → 重试发送
|
|
268
|
-
* 解决 QQ 开放平台无法拉取某些公网 URL(如海外域名)的问题
|
|
269
|
-
*/
|
|
270
|
-
async function downloadAndRetrySendPhoto(ctx, httpUrl, prefix) {
|
|
271
|
-
try {
|
|
272
|
-
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
|
273
|
-
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
274
|
-
if (!localFile) {
|
|
275
|
-
console.error(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
276
|
-
return null;
|
|
256
|
+
catch (err) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
258
|
+
console.error(`${prefix} sendPhoto Data URL failed: ${msg}`);
|
|
259
|
+
return { channel: "qqbot", error: msg };
|
|
277
260
|
}
|
|
278
|
-
console.log(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`);
|
|
279
|
-
// 递归调用 sendPhoto,此时走本地文件路径
|
|
280
|
-
return await sendPhoto(ctx, localFile);
|
|
281
|
-
}
|
|
282
|
-
catch (err) {
|
|
283
|
-
console.error(`${prefix} sendPhoto fallback error:`, err);
|
|
284
|
-
return null;
|
|
285
261
|
}
|
|
262
|
+
return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
|
|
286
263
|
}
|
|
287
264
|
/**
|
|
288
265
|
* sendVoice — 发送语音消息(对齐 Telegram sendVoice)
|
|
@@ -302,45 +279,20 @@ transcodeEnabled = true) {
|
|
|
302
279
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
303
280
|
const mediaPath = normalizePath(voicePath);
|
|
304
281
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
305
|
-
// 公网 URL
|
|
282
|
+
// 公网 URL:统一下载到本地 → 分块上传(不走平台拉取)
|
|
306
283
|
if (isHttp) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (ctx.targetType === "c2c") {
|
|
312
|
-
const r = await sendC2CVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
313
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
314
|
-
}
|
|
315
|
-
else if (ctx.targetType === "group") {
|
|
316
|
-
const r = await sendGroupVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
317
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
318
|
-
}
|
|
319
|
-
else {
|
|
320
|
-
console.log(`${prefix} sendVoice: voice not supported in channel`);
|
|
321
|
-
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
catch (err) {
|
|
325
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
326
|
-
console.warn(`${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`);
|
|
327
|
-
}
|
|
284
|
+
console.log(`${prefix} sendVoice: downloading URL to local for chunked upload...`);
|
|
285
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendVoice", ctx.account.appId, ctx.targetId);
|
|
286
|
+
if (dl.localFile) {
|
|
287
|
+
return await sendVoiceFromLocal(ctx, dl.localFile, directUploadFormats, transcodeEnabled, prefix, mediaPath);
|
|
328
288
|
}
|
|
329
|
-
|
|
330
|
-
console.log(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`);
|
|
331
|
-
}
|
|
332
|
-
// 下载到本地,然后走本地文件路径(含转码)
|
|
333
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice");
|
|
334
|
-
if (localFile) {
|
|
335
|
-
return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled, prefix);
|
|
336
|
-
}
|
|
337
|
-
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
289
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendVoice");
|
|
338
290
|
}
|
|
339
291
|
// 本地文件
|
|
340
292
|
return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix);
|
|
341
293
|
}
|
|
342
294
|
/** 从本地文件发送语音(sendVoice 的内部辅助) */
|
|
343
|
-
async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix) {
|
|
295
|
+
async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix, sourceUrl) {
|
|
344
296
|
// 等待文件就绪(TTS 异步生成,文件可能还没写完)
|
|
345
297
|
const fileSize = await waitForFile(mediaPath);
|
|
346
298
|
if (fileSize === 0) {
|
|
@@ -354,30 +306,20 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
|
|
|
354
306
|
console.log(`${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`);
|
|
355
307
|
return { channel: "qqbot", error: `语音转码已禁用,格式 ${ext} 不支持直传` };
|
|
356
308
|
}
|
|
309
|
+
const urlMeta = sourceUrl ? { mediaUrl: sourceUrl } : {};
|
|
310
|
+
// 统一走分片上传:需要转码的先转码写入临时文件,不需要转码的直接上传原文件
|
|
357
311
|
try {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
console.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (ctx.targetType === "c2c") {
|
|
370
|
-
const r = await sendC2CVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId, undefined, mediaPath);
|
|
371
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
372
|
-
}
|
|
373
|
-
else if (ctx.targetType === "group") {
|
|
374
|
-
const r = await sendGroupVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId);
|
|
375
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
console.log(`${prefix} sendVoice: voice not supported in channel`);
|
|
379
|
-
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
380
|
-
}
|
|
312
|
+
const uploadPath = needsTranscode
|
|
313
|
+
? await audioFileToSilkFile(mediaPath, directUploadFormats)
|
|
314
|
+
: mediaPath;
|
|
315
|
+
if (!uploadPath) {
|
|
316
|
+
// 转码失败 → fallback: 读取原文件直接上传
|
|
317
|
+
console.warn(`${prefix} sendVoice: SILK conversion failed, uploading raw file via chunked upload`);
|
|
318
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.VOICE, prefix, "sendVoice", { mediaType: "voice", mediaLocalPath: mediaPath, ...urlMeta });
|
|
319
|
+
}
|
|
320
|
+
const uploadSize = await getFileSizeAsync(uploadPath);
|
|
321
|
+
console.log(`${prefix} sendVoice: using chunked upload (${formatFileSize(uploadSize)})${needsTranscode ? " [transcoded]" : ""}`);
|
|
322
|
+
return chunkedUploadAndSend(ctx, uploadPath, MediaFileType.VOICE, prefix, "sendVoice", { mediaType: "voice", mediaLocalPath: mediaPath, ...urlMeta });
|
|
381
323
|
}
|
|
382
324
|
catch (err) {
|
|
383
325
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -394,81 +336,93 @@ export async function sendVideoMsg(ctx, videoPath) {
|
|
|
394
336
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
395
337
|
const mediaPath = normalizePath(videoPath);
|
|
396
338
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
397
|
-
//
|
|
398
|
-
if (isHttp
|
|
399
|
-
console.log(`${prefix} sendVideoMsg:
|
|
400
|
-
const
|
|
401
|
-
if (localFile) {
|
|
402
|
-
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
403
|
-
}
|
|
404
|
-
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
405
|
-
}
|
|
406
|
-
try {
|
|
407
|
-
const token = await getToken(ctx.account);
|
|
408
|
-
if (isHttp) {
|
|
409
|
-
// 公网 URL:先尝试直传平台
|
|
410
|
-
if (ctx.targetType === "c2c") {
|
|
411
|
-
const r = await sendC2CVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
412
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
413
|
-
}
|
|
414
|
-
else if (ctx.targetType === "group") {
|
|
415
|
-
const r = await sendGroupVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
416
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
417
|
-
}
|
|
418
|
-
else {
|
|
419
|
-
console.log(`${prefix} sendVideoMsg: video not supported in channel`);
|
|
420
|
-
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
// 本地文件
|
|
424
|
-
return await sendVideoFromLocal(ctx, mediaPath, prefix);
|
|
425
|
-
}
|
|
426
|
-
catch (err) {
|
|
427
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
428
|
-
// 公网 URL 直传失败 → 插件下载 → Base64 重试
|
|
429
|
-
if (isHttp) {
|
|
430
|
-
console.warn(`${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
431
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
|
|
432
|
-
if (localFile) {
|
|
433
|
-
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
434
|
-
}
|
|
339
|
+
// 公网 URL:统一下载到本地 → 分块上传(不走平台拉取)
|
|
340
|
+
if (isHttp) {
|
|
341
|
+
console.log(`${prefix} sendVideoMsg: downloading URL to local for chunked upload...`);
|
|
342
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg", ctx.account.appId, ctx.targetId);
|
|
343
|
+
if (dl.localFile) {
|
|
344
|
+
return await sendVideoFromLocal(ctx, dl.localFile, prefix, mediaPath);
|
|
435
345
|
}
|
|
436
|
-
|
|
437
|
-
return { channel: "qqbot", error: msg };
|
|
346
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendVideoMsg");
|
|
438
347
|
}
|
|
348
|
+
// 本地文件
|
|
349
|
+
return await sendVideoFromLocal(ctx, mediaPath, prefix);
|
|
439
350
|
}
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
351
|
+
/**
|
|
352
|
+
* 通用分片上传并发送 — 消除 Video/Document/Image/Voice 的重复代码
|
|
353
|
+
*
|
|
354
|
+
* 根据 ctx.targetType 自动选择 C2C / Group 分片上传,上传完成后发送媒体消息。
|
|
355
|
+
* Channel 类型不支持分片上传,返回错误。
|
|
356
|
+
*/
|
|
357
|
+
async function chunkedUploadAndSend(ctx, mediaPath, fileType, prefix,
|
|
358
|
+
/** 调用方名称,用于日志,如 "sendVideoMsg" / "sendDocument" */
|
|
359
|
+
callerName,
|
|
360
|
+
/** 发送消息时的额外 meta 信息(可选) */
|
|
361
|
+
sendMeta) {
|
|
362
|
+
const { appId, clientSecret } = ctx.account;
|
|
363
|
+
if (!appId || !clientSecret) {
|
|
364
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
444
365
|
}
|
|
445
|
-
|
|
446
|
-
if (!
|
|
447
|
-
return { channel: "qqbot", error:
|
|
366
|
+
// 统一前置校验:文件存在 + 非空 + 大小上限
|
|
367
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
368
|
+
return { channel: "qqbot", error: `${callerName}: file not found: ${mediaPath}` };
|
|
448
369
|
}
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
370
|
+
const fileSize = await getFileSizeAsync(mediaPath);
|
|
371
|
+
if (fileSize === 0) {
|
|
372
|
+
return { channel: "qqbot", error: `${callerName}: file is empty: ${mediaPath}` };
|
|
373
|
+
}
|
|
374
|
+
const maxSize = getMaxUploadSize(fileType);
|
|
375
|
+
if (fileSize > maxSize) {
|
|
376
|
+
const typeName = getFileTypeName(fileType);
|
|
377
|
+
const limitMB = Math.round(maxSize / (1024 * 1024));
|
|
378
|
+
return { channel: "qqbot", error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。` };
|
|
379
|
+
}
|
|
380
|
+
if (ctx.targetType === "c2c") {
|
|
381
|
+
console.log(`${prefix} ${callerName}: c2c chunked upload (${formatFileSize(fileSize)})`);
|
|
382
|
+
try {
|
|
383
|
+
const uploadResult = await chunkedUploadC2C(appId, clientSecret, ctx.targetId, mediaPath, fileType, {
|
|
384
|
+
logPrefix: `${prefix} [chunked]`,
|
|
385
|
+
onProgress: (progress) => {
|
|
386
|
+
console.log(`${prefix} ${callerName}: chunked upload progress ${progress.completedParts}/${progress.totalParts} parts, ${formatFileSize(progress.uploadedBytes)}/${formatFileSize(progress.totalBytes)}`);
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
const token = await getToken(ctx.account);
|
|
390
|
+
const r = await sendC2CMediaMessage(token, ctx.targetId, uploadResult.file_info, ctx.replyToId, undefined, sendMeta);
|
|
456
391
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
457
392
|
}
|
|
458
|
-
|
|
459
|
-
const
|
|
393
|
+
catch (err) {
|
|
394
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
395
|
+
console.error(`${prefix} ${callerName}: c2c chunked upload failed: ${msg}`);
|
|
396
|
+
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (ctx.targetType === "group") {
|
|
400
|
+
console.log(`${prefix} ${callerName}: group chunked upload (${formatFileSize(fileSize)})`);
|
|
401
|
+
try {
|
|
402
|
+
const uploadResult = await chunkedUploadGroup(appId, clientSecret, ctx.targetId, mediaPath, fileType, {
|
|
403
|
+
logPrefix: `${prefix} [chunked]`,
|
|
404
|
+
onProgress: (progress) => {
|
|
405
|
+
console.log(`${prefix} ${callerName}: chunked upload progress ${progress.completedParts}/${progress.totalParts} parts, ${formatFileSize(progress.uploadedBytes)}/${formatFileSize(progress.totalBytes)}`);
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
const token = await getToken(ctx.account);
|
|
409
|
+
const r = await sendGroupMediaMessage(token, ctx.targetId, uploadResult.file_info, ctx.replyToId);
|
|
460
410
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
461
411
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
412
|
+
catch (err) {
|
|
413
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
414
|
+
console.error(`${prefix} ${callerName}: group chunked upload failed: ${msg}`);
|
|
415
|
+
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
465
416
|
}
|
|
466
417
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
418
|
+
// Channel: 不支持分片上传
|
|
419
|
+
console.log(`${prefix} ${callerName}: media not supported in channel`);
|
|
420
|
+
return { channel: "qqbot", error: `${callerName}: media not supported in channel` };
|
|
421
|
+
}
|
|
422
|
+
/** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
|
|
423
|
+
async function sendVideoFromLocal(ctx, mediaPath, prefix, sourceUrl) {
|
|
424
|
+
// 文件存在/大小校验由 chunkedUploadAndSend 统一处理
|
|
425
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.VIDEO, prefix, "sendVideoMsg", { mediaType: "video", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
472
426
|
}
|
|
473
427
|
/**
|
|
474
428
|
* sendDocument — 发送文件消息(对齐 Telegram sendDocument)
|
|
@@ -479,105 +433,75 @@ export async function sendDocument(ctx, filePath) {
|
|
|
479
433
|
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
480
434
|
const mediaPath = normalizePath(filePath);
|
|
481
435
|
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
436
|
+
// 公网 URL:统一下载到本地 → 分块上传(不走平台拉取)
|
|
437
|
+
if (isHttp) {
|
|
438
|
+
console.log(`${prefix} sendDocument: downloading URL to local for chunked upload...`);
|
|
439
|
+
const dl = await downloadToFallbackDir(mediaPath, prefix, "sendDocument", ctx.account.appId, ctx.targetId);
|
|
440
|
+
if (dl.localFile) {
|
|
441
|
+
return await sendDocumentFromLocal(ctx, dl.localFile, prefix, mediaPath);
|
|
489
442
|
}
|
|
490
|
-
return
|
|
443
|
+
return sendFallbackLink(ctx, mediaPath, dl.error ?? "下载失败", prefix, "sendDocument");
|
|
491
444
|
}
|
|
445
|
+
// 本地文件
|
|
446
|
+
return await sendDocumentFromLocal(ctx, mediaPath, prefix);
|
|
447
|
+
}
|
|
448
|
+
/** 从本地文件发送文件(sendDocument 的内部辅助) */
|
|
449
|
+
async function sendDocumentFromLocal(ctx, mediaPath, prefix, sourceUrl) {
|
|
450
|
+
// 文件存在/空文件/大小校验由 chunkedUploadAndSend 统一处理
|
|
451
|
+
return chunkedUploadAndSend(ctx, mediaPath, MediaFileType.FILE, prefix, "sendDocument", { mediaType: "file", mediaLocalPath: mediaPath, ...(sourceUrl ? { mediaUrl: sourceUrl } : {}) });
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* 通用辅助:下载远程文件到 fallback 目录
|
|
455
|
+
* 目录结构:~/.openclaw/media/qqbot/downloads/{appId}/{targetId}/
|
|
456
|
+
* 用于各 send* 函数的公网 URL 下载
|
|
457
|
+
*/
|
|
458
|
+
async function downloadToFallbackDir(httpUrl, prefix, caller, appId, targetId) {
|
|
492
459
|
try {
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
}
|
|
500
|
-
else if (ctx.targetType === "group") {
|
|
501
|
-
const r = await sendGroupFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
|
|
502
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
503
|
-
}
|
|
504
|
-
else {
|
|
505
|
-
console.log(`${prefix} sendDocument: file not supported in channel`);
|
|
506
|
-
return { channel: "qqbot", error: "File not supported in channel" };
|
|
507
|
-
}
|
|
460
|
+
const subPaths = ["downloads", ...(appId ? [appId] : []), ...(targetId ? [targetId] : [])];
|
|
461
|
+
const downloadDir = getQQBotMediaDir(...subPaths);
|
|
462
|
+
const result = await downloadFile(httpUrl, undefined, { destDir: downloadDir });
|
|
463
|
+
if (!result.filePath) {
|
|
464
|
+
const errorMsg = result.error ?? "下载失败";
|
|
465
|
+
console.error(`${prefix} ${caller} fallback: download failed for ${httpUrl.slice(0, 80)} — ${errorMsg}`);
|
|
466
|
+
return { localFile: null, error: errorMsg };
|
|
508
467
|
}
|
|
509
|
-
|
|
510
|
-
return
|
|
468
|
+
console.log(`${prefix} ${caller} fallback: downloaded → ${result.filePath}`);
|
|
469
|
+
return { localFile: result.filePath };
|
|
511
470
|
}
|
|
512
471
|
catch (err) {
|
|
513
472
|
const msg = err instanceof Error ? err.message : String(err);
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
console.warn(`${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
517
|
-
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
518
|
-
if (localFile) {
|
|
519
|
-
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
console.error(`${prefix} sendDocument failed: ${msg}`);
|
|
523
|
-
return { channel: "qqbot", error: msg };
|
|
473
|
+
console.error(`${prefix} ${caller} fallback download error:`, err);
|
|
474
|
+
return { localFile: null, error: msg };
|
|
524
475
|
}
|
|
525
476
|
}
|
|
526
|
-
/**
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
const sizeCheck = checkFileSize(mediaPath);
|
|
533
|
-
if (!sizeCheck.ok) {
|
|
534
|
-
return { channel: "qqbot", error: sizeCheck.error };
|
|
535
|
-
}
|
|
536
|
-
const fileBuffer = await readFileAsync(mediaPath);
|
|
537
|
-
if (fileBuffer.length === 0) {
|
|
538
|
-
return { channel: "qqbot", error: `文件内容为空: ${mediaPath}` };
|
|
539
|
-
}
|
|
540
|
-
const fileBase64 = fileBuffer.toString("base64");
|
|
541
|
-
console.log(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`);
|
|
477
|
+
/**
|
|
478
|
+
* 媒体下载/上传失败时的兜底:把原始 URL 以文本链接的形式发给用户。
|
|
479
|
+
* 用户可以手动点击链接在浏览器中打开。
|
|
480
|
+
*/
|
|
481
|
+
async function sendFallbackLink(ctx, httpUrl, errorReason, prefix, caller) {
|
|
482
|
+
console.warn(`${prefix} ${caller}: falling back to text link for "${httpUrl.slice(0, 80)}"`);
|
|
542
483
|
try {
|
|
543
484
|
const token = await getToken(ctx.account);
|
|
485
|
+
const fallbackText = `📎 ${httpUrl}`;
|
|
486
|
+
let r;
|
|
544
487
|
if (ctx.targetType === "c2c") {
|
|
545
|
-
|
|
546
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
488
|
+
r = await sendC2CMessage(token, ctx.targetId, fallbackText, ctx.replyToId);
|
|
547
489
|
}
|
|
548
490
|
else if (ctx.targetType === "group") {
|
|
549
|
-
|
|
550
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
491
|
+
r = await sendGroupMessage(token, ctx.targetId, fallbackText, ctx.replyToId);
|
|
551
492
|
}
|
|
552
493
|
else {
|
|
553
|
-
|
|
554
|
-
return { channel: "qqbot", error: "File not supported in channel" };
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
catch (err) {
|
|
558
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
559
|
-
console.error(`${prefix} sendDocument (local) failed: ${msg}`);
|
|
560
|
-
return { channel: "qqbot", error: msg };
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* 通用辅助:下载远程文件到 fallback 目录
|
|
565
|
-
* 用于各 send* 函数的 URL 直传失败 fallback
|
|
566
|
-
*/
|
|
567
|
-
async function downloadToFallbackDir(httpUrl, prefix, caller) {
|
|
568
|
-
try {
|
|
569
|
-
const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
|
|
570
|
-
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
571
|
-
if (!localFile) {
|
|
572
|
-
console.error(`${prefix} ${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
573
|
-
return null;
|
|
494
|
+
r = await sendChannelMessage(token, ctx.targetId, fallbackText, ctx.replyToId);
|
|
574
495
|
}
|
|
575
|
-
|
|
576
|
-
|
|
496
|
+
// 链接已成功发给用户 → 视为兜底成功,不设 error,
|
|
497
|
+
// 上层不会再发额外的错误文案
|
|
498
|
+
console.log(`${prefix} ${caller}: fallback link sent successfully`);
|
|
499
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
577
500
|
}
|
|
578
|
-
catch (
|
|
579
|
-
|
|
580
|
-
|
|
501
|
+
catch (fallbackErr) {
|
|
502
|
+
const fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
503
|
+
console.error(`${prefix} ${caller}: fallback link send also failed: ${fallbackMsg}`);
|
|
504
|
+
return { channel: "qqbot", error: `${caller}: 媒体发送失败 (${errorReason}),兜底链接也发送失败 (${fallbackMsg})` };
|
|
581
505
|
}
|
|
582
506
|
}
|
|
583
507
|
/**
|
|
@@ -626,166 +550,66 @@ export async function sendText(ctx) {
|
|
|
626
550
|
// <qqvideo>路径或URL</qqvideo> — 视频
|
|
627
551
|
// <qqfile>路径</qqfile> — 文件
|
|
628
552
|
// <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const mediaTagMatches = text.match(mediaTagRegex);
|
|
633
|
-
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
|
634
|
-
console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
635
|
-
// 构建发送队列:根据内容在原文中的实际位置顺序发送
|
|
636
|
-
const sendQueue = [];
|
|
637
|
-
let lastIndex = 0;
|
|
638
|
-
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
639
|
-
let match;
|
|
640
|
-
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
641
|
-
// 添加标签前的文本
|
|
642
|
-
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
643
|
-
if (textBefore) {
|
|
644
|
-
sendQueue.push({ type: "text", content: textBefore });
|
|
645
|
-
}
|
|
646
|
-
const tagName = match[1].toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
|
|
647
|
-
// 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
|
|
648
|
-
let mediaPath = match[2]?.trim() ?? "";
|
|
649
|
-
if (mediaPath.startsWith("MEDIA:")) {
|
|
650
|
-
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
651
|
-
}
|
|
652
|
-
mediaPath = normalizePath(mediaPath);
|
|
653
|
-
// 处理可能被模型转义的路径
|
|
654
|
-
// 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
|
|
655
|
-
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
656
|
-
// 2. 八进制转义序列 + UTF-8 双重编码修复
|
|
657
|
-
try {
|
|
658
|
-
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
659
|
-
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
660
|
-
if (hasOctal || hasNonASCII) {
|
|
661
|
-
console.log(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
|
662
|
-
// Step 1: 将八进制转义转换为字节
|
|
663
|
-
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_, octal) => {
|
|
664
|
-
return String.fromCharCode(parseInt(octal, 8));
|
|
665
|
-
});
|
|
666
|
-
// Step 2: 提取所有字节(包括 Latin-1 字符)
|
|
667
|
-
const bytes = [];
|
|
668
|
-
for (let i = 0; i < decoded.length; i++) {
|
|
669
|
-
const code = decoded.charCodeAt(i);
|
|
670
|
-
if (code <= 0xFF) {
|
|
671
|
-
bytes.push(code);
|
|
672
|
-
}
|
|
673
|
-
else {
|
|
674
|
-
const charBytes = Buffer.from(decoded[i], 'utf8');
|
|
675
|
-
bytes.push(...charBytes);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
// Step 3: 尝试按 UTF-8 解码
|
|
679
|
-
const buffer = Buffer.from(bytes);
|
|
680
|
-
const utf8Decoded = buffer.toString('utf8');
|
|
681
|
-
if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
|
|
682
|
-
mediaPath = utf8Decoded;
|
|
683
|
-
console.log(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
catch (decodeErr) {
|
|
688
|
-
console.error(`[qqbot] sendText: Path decode error: ${decodeErr}`);
|
|
689
|
-
}
|
|
690
|
-
if (mediaPath) {
|
|
691
|
-
if (tagName === "qqmedia") {
|
|
692
|
-
sendQueue.push({ type: "media", content: mediaPath });
|
|
693
|
-
console.log(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
694
|
-
}
|
|
695
|
-
else if (tagName === "qqvoice") {
|
|
696
|
-
sendQueue.push({ type: "voice", content: mediaPath });
|
|
697
|
-
console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
698
|
-
}
|
|
699
|
-
else if (tagName === "qqvideo") {
|
|
700
|
-
sendQueue.push({ type: "video", content: mediaPath });
|
|
701
|
-
console.log(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
|
702
|
-
}
|
|
703
|
-
else if (tagName === "qqfile") {
|
|
704
|
-
sendQueue.push({ type: "file", content: mediaPath });
|
|
705
|
-
console.log(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
|
706
|
-
}
|
|
707
|
-
else {
|
|
708
|
-
sendQueue.push({ type: "image", content: mediaPath });
|
|
709
|
-
console.log(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
lastIndex = match.index + match[0].length;
|
|
713
|
-
}
|
|
714
|
-
// 添加最后一个标签后的文本
|
|
715
|
-
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
|
716
|
-
if (textAfter) {
|
|
717
|
-
sendQueue.push({ type: "text", content: textAfter });
|
|
718
|
-
}
|
|
553
|
+
// 使用 deliver-common.ts 的公共解析器,消除与 gateway.ts 的重复
|
|
554
|
+
const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(text);
|
|
555
|
+
if (hasMedia && sendQueue.length > 0) {
|
|
719
556
|
console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
720
|
-
//
|
|
557
|
+
// 构建统一的媒体发送上下文
|
|
721
558
|
const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
|
|
559
|
+
const mediaSendCtx = {
|
|
560
|
+
mediaTarget,
|
|
561
|
+
qualifiedTarget: to,
|
|
562
|
+
account,
|
|
563
|
+
replyToId: replyToId ?? undefined,
|
|
564
|
+
log: {
|
|
565
|
+
info: (msg) => console.log(msg),
|
|
566
|
+
error: (msg) => console.error(msg),
|
|
567
|
+
debug: (msg) => console.log(msg),
|
|
568
|
+
},
|
|
569
|
+
};
|
|
722
570
|
let lastResult = { channel: "qqbot" };
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
}
|
|
740
|
-
else {
|
|
741
|
-
const result = await sendChannelMessage(accessToken, target.id, item.content, replyToId);
|
|
742
|
-
recordMessageReply(replyToId);
|
|
743
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
744
|
-
}
|
|
571
|
+
// 使用统一的发送队列执行器
|
|
572
|
+
await executeSendQueue(sendQueue, mediaSendCtx, {
|
|
573
|
+
onSendText: async (textContent) => {
|
|
574
|
+
// sendText 场景的文本发送:需要区分主动/被动消息
|
|
575
|
+
if (replyToId) {
|
|
576
|
+
const accessToken = await getToken(account);
|
|
577
|
+
const target = parseTarget(to);
|
|
578
|
+
if (target.type === "c2c") {
|
|
579
|
+
const result = await sendC2CMessage(accessToken, target.id, textContent, replyToId);
|
|
580
|
+
recordMessageReply(replyToId);
|
|
581
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
582
|
+
}
|
|
583
|
+
else if (target.type === "group") {
|
|
584
|
+
const result = await sendGroupMessage(accessToken, target.id, textContent, replyToId);
|
|
585
|
+
recordMessageReply(replyToId);
|
|
586
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
745
587
|
}
|
|
746
588
|
else {
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
|
|
751
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
752
|
-
}
|
|
753
|
-
else if (target.type === "group") {
|
|
754
|
-
const result = await sendProactiveGroupMessage(accessToken, target.id, item.content);
|
|
755
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
756
|
-
}
|
|
757
|
-
else {
|
|
758
|
-
const result = await sendChannelMessage(accessToken, target.id, item.content);
|
|
759
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
760
|
-
}
|
|
589
|
+
const result = await sendChannelMessage(accessToken, target.id, textContent, replyToId);
|
|
590
|
+
recordMessageReply(replyToId);
|
|
591
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
761
592
|
}
|
|
762
|
-
console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
763
|
-
}
|
|
764
|
-
else if (item.type === "image") {
|
|
765
|
-
lastResult = await sendPhoto(mediaTarget, item.content);
|
|
766
|
-
}
|
|
767
|
-
else if (item.type === "voice") {
|
|
768
|
-
lastResult = await sendVoice(mediaTarget, item.content, undefined, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
|
|
769
593
|
}
|
|
770
|
-
else
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
594
|
+
else {
|
|
595
|
+
const accessToken = await getToken(account);
|
|
596
|
+
const target = parseTarget(to);
|
|
597
|
+
if (target.type === "c2c") {
|
|
598
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, textContent);
|
|
599
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
600
|
+
}
|
|
601
|
+
else if (target.type === "group") {
|
|
602
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, textContent);
|
|
603
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
const result = await sendChannelMessage(accessToken, target.id, textContent);
|
|
607
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: result.ext_info?.ref_idx };
|
|
608
|
+
}
|
|
782
609
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
610
|
+
console.log(`[qqbot] sendText: Sent text part: ${textContent.slice(0, 30)}...`);
|
|
611
|
+
},
|
|
612
|
+
});
|
|
789
613
|
return lastResult;
|
|
790
614
|
}
|
|
791
615
|
// ============ 主动消息校验(参考 Telegram 机制) ============
|