@tencent-connect/openclaw-qqbot 1.5.7 → 1.6.0-alpha.2
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 +9 -2
- package/README.zh.md +7 -2
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +85 -115
- package/scripts/upgrade-via-source.sh +203 -35
- package/skills/qqbot-cron/SKILL.md +46 -423
- package/skills/qqbot-media/SKILL.md +29 -182
- package/src/api.ts +16 -5
- package/src/channel.ts +6 -7
- package/src/gateway.ts +510 -525
- package/src/image-server.ts +72 -10
- package/src/openclaw-plugin-sdk.d.ts +1 -1
- package/src/outbound.ts +571 -611
- package/src/ref-index-store.ts +1 -1
- package/src/slash-commands.ts +425 -0
- package/src/types.ts +18 -1
- package/src/update-checker.ts +102 -0
- package/src/user-messages.ts +73 -0
- package/src/utils/audio-convert.ts +69 -4
- package/src/utils/media-tags.ts +46 -4
- package/dist/AI/345/210/233/346/226/260/345/272/224/347/224/250/345/245/226_/347/224/263/346/212/245/344/271/246.md +0 -211
- package/dist/index.d.ts +0 -17
- package/dist/index.js +0 -22
- package/dist/src/api.d.ts +0 -138
- package/dist/src/api.js +0 -525
- package/dist/src/channel.d.ts +0 -3
- package/dist/src/channel.js +0 -337
- package/dist/src/config.d.ts +0 -25
- package/dist/src/config.js +0 -161
- package/dist/src/gateway.d.ts +0 -18
- package/dist/src/gateway.js +0 -2468
- package/dist/src/image-server.d.ts +0 -62
- package/dist/src/image-server.js +0 -401
- package/dist/src/known-users.d.ts +0 -100
- package/dist/src/known-users.js +0 -263
- package/dist/src/onboarding.d.ts +0 -10
- package/dist/src/onboarding.js +0 -203
- package/dist/src/outbound.d.ts +0 -150
- package/dist/src/outbound.js +0 -1175
- package/dist/src/proactive.d.ts +0 -170
- package/dist/src/proactive.js +0 -399
- package/dist/src/runtime.d.ts +0 -3
- package/dist/src/runtime.js +0 -10
- package/dist/src/session-store.d.ts +0 -52
- package/dist/src/session-store.js +0 -254
- package/dist/src/slash-commands.d.ts +0 -48
- package/dist/src/slash-commands.js +0 -212
- package/dist/src/types.d.ts +0 -146
- package/dist/src/types.js +0 -1
- package/dist/src/utils/audio-convert.d.ts +0 -73
- package/dist/src/utils/audio-convert.js +0 -645
- package/dist/src/utils/file-utils.d.ts +0 -46
- package/dist/src/utils/file-utils.js +0 -107
- package/dist/src/utils/image-size.d.ts +0 -51
- package/dist/src/utils/image-size.js +0 -234
- package/dist/src/utils/media-tags.d.ts +0 -14
- package/dist/src/utils/media-tags.js +0 -120
- package/dist/src/utils/payload.d.ts +0 -112
- package/dist/src/utils/payload.js +0 -186
- package/dist/src/utils/platform.d.ts +0 -126
- package/dist/src/utils/platform.js +0 -358
- package/dist/src/utils/upload-cache.d.ts +0 -34
- package/dist/src/utils/upload-cache.js +0 -93
package/src/outbound.ts
CHANGED
|
@@ -21,10 +21,12 @@ import {
|
|
|
21
21
|
sendC2CFileMessage,
|
|
22
22
|
sendGroupFileMessage,
|
|
23
23
|
} from "./api.js";
|
|
24
|
-
import { isAudioFile, audioFileToSilkBase64, waitForFile } from "./utils/audio-convert.js";
|
|
24
|
+
import { isAudioFile, audioFileToSilkBase64, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
25
25
|
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
26
26
|
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
|
|
27
|
-
import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName } from "./utils/platform.js";
|
|
27
|
+
import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, getQQBotDataDir } from "./utils/platform.js";
|
|
28
|
+
import { downloadFile } from "./image-server.js";
|
|
29
|
+
import { MSG } from "./user-messages.js";
|
|
28
30
|
|
|
29
31
|
// ============ 消息回复限流器 ============
|
|
30
32
|
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
|
@@ -163,6 +165,8 @@ export interface OutboundContext {
|
|
|
163
165
|
|
|
164
166
|
export interface MediaOutboundContext extends OutboundContext {
|
|
165
167
|
mediaUrl: string;
|
|
168
|
+
/** 可选的 MIME 类型,优先于扩展名判断媒体类型 */
|
|
169
|
+
mimeType?: string;
|
|
166
170
|
}
|
|
167
171
|
|
|
168
172
|
export interface OutboundResult {
|
|
@@ -233,6 +237,486 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
|
|
|
233
237
|
return { type: "c2c", id };
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
// ============ Telegram 风格的结构化媒体发送接口 ============
|
|
241
|
+
// 类似 Telegram 的 sendPhoto / sendVoice / sendVideo / sendDocument,
|
|
242
|
+
// 每种媒体类型一个独立函数,接收结构化参数,无需标签解析。
|
|
243
|
+
// gateway.ts 的 deliver 回调和 sendText 共用这些函数,消除重复代码。
|
|
244
|
+
|
|
245
|
+
/** 媒体发送的目标上下文(从 deliver 回调或 sendText 中提取) */
|
|
246
|
+
export interface MediaTargetContext {
|
|
247
|
+
/** 目标类型 */
|
|
248
|
+
targetType: "c2c" | "group" | "channel";
|
|
249
|
+
/** 目标 ID */
|
|
250
|
+
targetId: string;
|
|
251
|
+
/** QQ Bot 账户配置 */
|
|
252
|
+
account: ResolvedQQBotAccount;
|
|
253
|
+
/** 被动回复消息 ID(可选) */
|
|
254
|
+
replyToId?: string;
|
|
255
|
+
/** 日志前缀(可选,用于区分调用来源) */
|
|
256
|
+
logPrefix?: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** 从 OutboundContext 构建 MediaTargetContext */
|
|
260
|
+
function buildMediaTarget(ctx: { to: string; account: ResolvedQQBotAccount; replyToId?: string | null }, logPrefix?: string): MediaTargetContext {
|
|
261
|
+
const target = parseTarget(ctx.to);
|
|
262
|
+
return {
|
|
263
|
+
targetType: target.type,
|
|
264
|
+
targetId: target.id,
|
|
265
|
+
account: ctx.account,
|
|
266
|
+
replyToId: ctx.replyToId ?? undefined,
|
|
267
|
+
logPrefix,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 获取已认证的 access token,失败时抛出异常 */
|
|
272
|
+
async function getToken(account: ResolvedQQBotAccount): Promise<string> {
|
|
273
|
+
if (!account.appId || !account.clientSecret) {
|
|
274
|
+
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
|
275
|
+
}
|
|
276
|
+
return getAccessToken(account.appId, account.clientSecret);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 判断是否应该对公网 URL 执行直传(不下载) */
|
|
280
|
+
function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean {
|
|
281
|
+
return account.config?.urlDirectUpload !== false; // 默认 true
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
|
|
286
|
+
*
|
|
287
|
+
* 支持三种来源:
|
|
288
|
+
* - 本地文件路径(自动读取转 Base64)
|
|
289
|
+
* - 公网 HTTP/HTTPS URL(urlDirectUpload=true 时先直传平台,失败自动下载重试;=false 时直接下载)
|
|
290
|
+
* - Base64 Data URL
|
|
291
|
+
*/
|
|
292
|
+
export async function sendPhoto(
|
|
293
|
+
ctx: MediaTargetContext,
|
|
294
|
+
imagePath: string,
|
|
295
|
+
): Promise<OutboundResult> {
|
|
296
|
+
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
297
|
+
const mediaPath = normalizePath(imagePath);
|
|
298
|
+
const isLocal = isLocalFilePath(mediaPath);
|
|
299
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
300
|
+
const isData = mediaPath.startsWith("data:");
|
|
301
|
+
|
|
302
|
+
// urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
|
|
303
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
304
|
+
console.log(`${prefix} sendPhoto: urlDirectUpload=false, downloading URL first...`);
|
|
305
|
+
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto");
|
|
306
|
+
if (localFile) {
|
|
307
|
+
return await sendPhoto(ctx, localFile);
|
|
308
|
+
}
|
|
309
|
+
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let imageUrl = mediaPath;
|
|
313
|
+
|
|
314
|
+
if (isLocal) {
|
|
315
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
316
|
+
return { channel: "qqbot", error: MSG.IMAGE_NOT_FOUND };
|
|
317
|
+
}
|
|
318
|
+
const sizeCheck = checkFileSize(mediaPath);
|
|
319
|
+
if (!sizeCheck.ok) {
|
|
320
|
+
return { channel: "qqbot", error: sizeCheck.error! };
|
|
321
|
+
}
|
|
322
|
+
const fileBuffer = await readFileAsync(mediaPath);
|
|
323
|
+
const ext = path.extname(mediaPath).toLowerCase();
|
|
324
|
+
const mimeTypes: Record<string, string> = {
|
|
325
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
|
326
|
+
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
327
|
+
};
|
|
328
|
+
const mimeType = mimeTypes[ext];
|
|
329
|
+
if (!mimeType) {
|
|
330
|
+
return { channel: "qqbot", error: MSG.IMAGE_FORMAT_UNSUPPORTED(ext) };
|
|
331
|
+
}
|
|
332
|
+
imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
|
333
|
+
console.log(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`);
|
|
334
|
+
} else if (!isHttp && !isData) {
|
|
335
|
+
return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const token = await getToken(ctx.account);
|
|
340
|
+
const localPath = isLocal ? mediaPath : undefined;
|
|
341
|
+
|
|
342
|
+
if (ctx.targetType === "c2c") {
|
|
343
|
+
const r = await sendC2CImageMessage(token, ctx.targetId, imageUrl, ctx.replyToId, undefined, localPath);
|
|
344
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
345
|
+
} else if (ctx.targetType === "group") {
|
|
346
|
+
const r = await sendGroupImageMessage(token, ctx.targetId, imageUrl, ctx.replyToId);
|
|
347
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
348
|
+
} else {
|
|
349
|
+
// 频道:仅支持公网 URL(Markdown 格式)
|
|
350
|
+
if (isHttp) {
|
|
351
|
+
const r = await sendChannelMessage(token, ctx.targetId, ``, ctx.replyToId);
|
|
352
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
353
|
+
}
|
|
354
|
+
console.log(`${prefix} sendPhoto: channel does not support local/Base64 images`);
|
|
355
|
+
return { channel: "qqbot" };
|
|
356
|
+
}
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
359
|
+
|
|
360
|
+
// 公网 URL 直传失败(如 QQ 平台拉取海外域名超时/被墙)→ 插件自己下载 → Base64 重试
|
|
361
|
+
if (isHttp && !isData) {
|
|
362
|
+
console.warn(`${prefix} sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
363
|
+
const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath, prefix);
|
|
364
|
+
if (retryResult) return retryResult;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.error(`${prefix} sendPhoto failed: ${msg}`);
|
|
368
|
+
return { channel: "qqbot", error: msg };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* sendPhoto 的 URL fallback:下载远程图片到本地 → 转 Base64 → 重试发送
|
|
374
|
+
* 解决 QQ 开放平台无法拉取某些公网 URL(如海外域名)的问题
|
|
375
|
+
*/
|
|
376
|
+
async function downloadAndRetrySendPhoto(
|
|
377
|
+
ctx: MediaTargetContext,
|
|
378
|
+
httpUrl: string,
|
|
379
|
+
prefix: string,
|
|
380
|
+
): Promise<OutboundResult | null> {
|
|
381
|
+
try {
|
|
382
|
+
const downloadDir = getQQBotDataDir("downloads", "url-fallback");
|
|
383
|
+
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
384
|
+
if (!localFile) {
|
|
385
|
+
console.error(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
console.log(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`);
|
|
390
|
+
// 递归调用 sendPhoto,此时走本地文件路径
|
|
391
|
+
return await sendPhoto(ctx, localFile);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.error(`${prefix} sendPhoto fallback error:`, err);
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* sendVoice — 发送语音消息(对齐 Telegram sendVoice)
|
|
400
|
+
*
|
|
401
|
+
* 支持本地音频文件和公网 URL:
|
|
402
|
+
* - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
|
|
403
|
+
* - urlDirectUpload=false + 公网URL:直接下载到本地再转码发送
|
|
404
|
+
* - 本地文件:自动转换为 SILK 格式后上传
|
|
405
|
+
*
|
|
406
|
+
* 支持 transcodeEnabled 配置:禁用时非原生格式 fallback 到文件发送。
|
|
407
|
+
*/
|
|
408
|
+
export async function sendVoice(
|
|
409
|
+
ctx: MediaTargetContext,
|
|
410
|
+
voicePath: string,
|
|
411
|
+
/** 直传格式列表(跳过 SILK 转换),可选 */
|
|
412
|
+
directUploadFormats?: string[],
|
|
413
|
+
/** 是否启用转码(默认 true),false 时非原生格式直接返回错误 */
|
|
414
|
+
transcodeEnabled: boolean = true,
|
|
415
|
+
): Promise<OutboundResult> {
|
|
416
|
+
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
417
|
+
const mediaPath = normalizePath(voicePath);
|
|
418
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
419
|
+
|
|
420
|
+
// 公网 URL 处理
|
|
421
|
+
if (isHttp) {
|
|
422
|
+
// urlDirectUpload=true: 先尝试直传平台
|
|
423
|
+
if (shouldDirectUploadUrl(ctx.account)) {
|
|
424
|
+
try {
|
|
425
|
+
const token = await getToken(ctx.account);
|
|
426
|
+
if (ctx.targetType === "c2c") {
|
|
427
|
+
const r = await sendC2CVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
428
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
429
|
+
} else if (ctx.targetType === "group") {
|
|
430
|
+
const r = await sendGroupVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
431
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
432
|
+
} else {
|
|
433
|
+
const r = await sendChannelMessage(token, ctx.targetId, MSG.VOICE_CHANNEL_UNSUPPORTED, ctx.replyToId);
|
|
434
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
438
|
+
console.warn(`${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`);
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
console.log(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 下载到本地,然后走本地文件路径(含转码)
|
|
445
|
+
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice");
|
|
446
|
+
if (localFile) {
|
|
447
|
+
return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled, prefix);
|
|
448
|
+
}
|
|
449
|
+
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 本地文件
|
|
453
|
+
return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** 从本地文件发送语音(sendVoice 的内部辅助) */
|
|
457
|
+
async function sendVoiceFromLocal(
|
|
458
|
+
ctx: MediaTargetContext,
|
|
459
|
+
mediaPath: string,
|
|
460
|
+
directUploadFormats: string[] | undefined,
|
|
461
|
+
transcodeEnabled: boolean,
|
|
462
|
+
prefix: string,
|
|
463
|
+
): Promise<OutboundResult> {
|
|
464
|
+
// 等待文件就绪(TTS 异步生成,文件可能还没写完)
|
|
465
|
+
const fileSize = await waitForFile(mediaPath);
|
|
466
|
+
if (fileSize === 0) {
|
|
467
|
+
return { channel: "qqbot", error: MSG.VOICE_GENERATE_FAILED };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 精细检测:是否需要转码
|
|
471
|
+
const needsTranscode = shouldTranscodeVoice(mediaPath);
|
|
472
|
+
|
|
473
|
+
// 转码已禁用但需要转码 → 提前 fallback
|
|
474
|
+
if (needsTranscode && !transcodeEnabled) {
|
|
475
|
+
const ext = path.extname(mediaPath).toLowerCase();
|
|
476
|
+
console.log(`${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`);
|
|
477
|
+
return { channel: "qqbot", error: `语音转码已禁用,格式 ${ext} 不支持直传` };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats);
|
|
482
|
+
let uploadBase64 = silkBase64;
|
|
483
|
+
|
|
484
|
+
if (!uploadBase64) {
|
|
485
|
+
const buf = await readFileAsync(mediaPath);
|
|
486
|
+
uploadBase64 = buf.toString("base64");
|
|
487
|
+
console.log(`${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
|
|
488
|
+
} else {
|
|
489
|
+
console.log(`${prefix} sendVoice: SILK ready (${fileSize} bytes)`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const token = await getToken(ctx.account);
|
|
493
|
+
|
|
494
|
+
if (ctx.targetType === "c2c") {
|
|
495
|
+
const r = await sendC2CVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId, undefined, mediaPath);
|
|
496
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
497
|
+
} else if (ctx.targetType === "group") {
|
|
498
|
+
const r = await sendGroupVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId);
|
|
499
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
500
|
+
} else {
|
|
501
|
+
const r = await sendChannelMessage(token, ctx.targetId, MSG.VOICE_CHANNEL_UNSUPPORTED, ctx.replyToId);
|
|
502
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
506
|
+
console.error(`${prefix} sendVoice (local) failed: ${msg}`);
|
|
507
|
+
return { channel: "qqbot", error: msg };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* sendVideoMsg — 发送视频消息(对齐 Telegram sendVideo)
|
|
513
|
+
*
|
|
514
|
+
* 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
|
|
515
|
+
*/
|
|
516
|
+
export async function sendVideoMsg(
|
|
517
|
+
ctx: MediaTargetContext,
|
|
518
|
+
videoPath: string,
|
|
519
|
+
): Promise<OutboundResult> {
|
|
520
|
+
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
521
|
+
const mediaPath = normalizePath(videoPath);
|
|
522
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
523
|
+
|
|
524
|
+
// urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
|
|
525
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
526
|
+
console.log(`${prefix} sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
|
|
527
|
+
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
|
|
528
|
+
if (localFile) {
|
|
529
|
+
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
530
|
+
}
|
|
531
|
+
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
const token = await getToken(ctx.account);
|
|
536
|
+
|
|
537
|
+
if (isHttp) {
|
|
538
|
+
// 公网 URL:先尝试直传平台
|
|
539
|
+
if (ctx.targetType === "c2c") {
|
|
540
|
+
const r = await sendC2CVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
541
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
542
|
+
} else if (ctx.targetType === "group") {
|
|
543
|
+
const r = await sendGroupVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
544
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
545
|
+
} else {
|
|
546
|
+
const r = await sendChannelMessage(token, ctx.targetId, MSG.VIDEO_CHANNEL_UNSUPPORTED, ctx.replyToId);
|
|
547
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// 本地文件
|
|
552
|
+
return await sendVideoFromLocal(ctx, mediaPath, prefix);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
555
|
+
|
|
556
|
+
// 公网 URL 直传失败 → 插件下载 → Base64 重试
|
|
557
|
+
if (isHttp) {
|
|
558
|
+
console.warn(`${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
559
|
+
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
|
|
560
|
+
if (localFile) {
|
|
561
|
+
return await sendVideoFromLocal(ctx, localFile, prefix);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
console.error(`${prefix} sendVideoMsg failed: ${msg}`);
|
|
566
|
+
return { channel: "qqbot", error: msg };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
|
|
571
|
+
async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
|
|
572
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
573
|
+
return { channel: "qqbot", error: MSG.VIDEO_NOT_FOUND };
|
|
574
|
+
}
|
|
575
|
+
const sizeCheck = checkFileSize(mediaPath);
|
|
576
|
+
if (!sizeCheck.ok) {
|
|
577
|
+
return { channel: "qqbot", error: sizeCheck.error! };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const fileBuffer = await readFileAsync(mediaPath);
|
|
581
|
+
const videoBase64 = fileBuffer.toString("base64");
|
|
582
|
+
console.log(`${prefix} sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`);
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const token = await getToken(ctx.account);
|
|
586
|
+
if (ctx.targetType === "c2c") {
|
|
587
|
+
const r = await sendC2CVideoMessage(token, ctx.targetId, undefined, videoBase64, ctx.replyToId, undefined, mediaPath);
|
|
588
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
589
|
+
} else if (ctx.targetType === "group") {
|
|
590
|
+
const r = await sendGroupVideoMessage(token, ctx.targetId, undefined, videoBase64, ctx.replyToId);
|
|
591
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
592
|
+
} else {
|
|
593
|
+
const r = await sendChannelMessage(token, ctx.targetId, MSG.VIDEO_CHANNEL_UNSUPPORTED, ctx.replyToId);
|
|
594
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
595
|
+
}
|
|
596
|
+
} catch (err) {
|
|
597
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
598
|
+
console.error(`${prefix} sendVideoMsg (local) failed: ${msg}`);
|
|
599
|
+
return { channel: "qqbot", error: msg };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* sendDocument — 发送文件消息(对齐 Telegram sendDocument)
|
|
605
|
+
*
|
|
606
|
+
* 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
|
|
607
|
+
*/
|
|
608
|
+
export async function sendDocument(
|
|
609
|
+
ctx: MediaTargetContext,
|
|
610
|
+
filePath: string,
|
|
611
|
+
): Promise<OutboundResult> {
|
|
612
|
+
const prefix = ctx.logPrefix ?? "[qqbot]";
|
|
613
|
+
const mediaPath = normalizePath(filePath);
|
|
614
|
+
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
|
|
615
|
+
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
616
|
+
|
|
617
|
+
// urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
|
|
618
|
+
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
|
|
619
|
+
console.log(`${prefix} sendDocument: urlDirectUpload=false, downloading URL first...`);
|
|
620
|
+
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
621
|
+
if (localFile) {
|
|
622
|
+
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
623
|
+
}
|
|
624
|
+
return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
const token = await getToken(ctx.account);
|
|
629
|
+
|
|
630
|
+
if (isHttp) {
|
|
631
|
+
// 公网 URL:先尝试直传平台
|
|
632
|
+
if (ctx.targetType === "c2c") {
|
|
633
|
+
const r = await sendC2CFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
|
|
634
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
635
|
+
} else if (ctx.targetType === "group") {
|
|
636
|
+
const r = await sendGroupFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
|
|
637
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
638
|
+
} else {
|
|
639
|
+
const r = await sendChannelMessage(token, ctx.targetId, MSG.FILE_CHANNEL_UNSUPPORTED, ctx.replyToId);
|
|
640
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// 本地文件
|
|
645
|
+
return await sendDocumentFromLocal(ctx, mediaPath, prefix);
|
|
646
|
+
} catch (err) {
|
|
647
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
648
|
+
|
|
649
|
+
// 公网 URL 直传失败 → 插件下载 → Base64 重试
|
|
650
|
+
if (isHttp) {
|
|
651
|
+
console.warn(`${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
|
|
652
|
+
const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
|
|
653
|
+
if (localFile) {
|
|
654
|
+
return await sendDocumentFromLocal(ctx, localFile, prefix);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
console.error(`${prefix} sendDocument failed: ${msg}`);
|
|
659
|
+
return { channel: "qqbot", error: msg };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** 从本地文件发送文件(sendDocument 的内部辅助) */
|
|
664
|
+
async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
|
|
665
|
+
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
666
|
+
|
|
667
|
+
if (!(await fileExistsAsync(mediaPath))) {
|
|
668
|
+
return { channel: "qqbot", error: MSG.FILE_NOT_FOUND };
|
|
669
|
+
}
|
|
670
|
+
const sizeCheck = checkFileSize(mediaPath);
|
|
671
|
+
if (!sizeCheck.ok) {
|
|
672
|
+
return { channel: "qqbot", error: sizeCheck.error! };
|
|
673
|
+
}
|
|
674
|
+
const fileBuffer = await readFileAsync(mediaPath);
|
|
675
|
+
if (fileBuffer.length === 0) {
|
|
676
|
+
return { channel: "qqbot", error: `文件内容为空: ${mediaPath}` };
|
|
677
|
+
}
|
|
678
|
+
const fileBase64 = fileBuffer.toString("base64");
|
|
679
|
+
console.log(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`);
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const token = await getToken(ctx.account);
|
|
683
|
+
if (ctx.targetType === "c2c") {
|
|
684
|
+
const r = await sendC2CFileMessage(token, ctx.targetId, fileBase64, undefined, ctx.replyToId, fileName, mediaPath);
|
|
685
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
686
|
+
} else if (ctx.targetType === "group") {
|
|
687
|
+
const r = await sendGroupFileMessage(token, ctx.targetId, fileBase64, undefined, ctx.replyToId, fileName);
|
|
688
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
689
|
+
} else {
|
|
690
|
+
const r = await sendChannelMessage(token, ctx.targetId, MSG.FILE_CHANNEL_UNSUPPORTED, ctx.replyToId);
|
|
691
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
692
|
+
}
|
|
693
|
+
} catch (err) {
|
|
694
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
695
|
+
console.error(`${prefix} sendDocument (local) failed: ${msg}`);
|
|
696
|
+
return { channel: "qqbot", error: msg };
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* 通用辅助:下载远程文件到 fallback 目录
|
|
702
|
+
* 用于各 send* 函数的 URL 直传失败 fallback
|
|
703
|
+
*/
|
|
704
|
+
async function downloadToFallbackDir(httpUrl: string, prefix: string, caller: string): Promise<string | null> {
|
|
705
|
+
try {
|
|
706
|
+
const downloadDir = getQQBotDataDir("downloads", "url-fallback");
|
|
707
|
+
const localFile = await downloadFile(httpUrl, downloadDir);
|
|
708
|
+
if (!localFile) {
|
|
709
|
+
console.error(`${prefix} ${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
console.log(`${prefix} ${caller} fallback: downloaded → ${localFile}`);
|
|
713
|
+
return localFile;
|
|
714
|
+
} catch (err) {
|
|
715
|
+
console.error(`${prefix} ${caller} fallback download error:`, err);
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
236
720
|
/**
|
|
237
721
|
* 发送文本消息
|
|
238
722
|
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
|
@@ -275,26 +759,27 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
275
759
|
}
|
|
276
760
|
|
|
277
761
|
// ============ 媒体标签检测与处理 ============
|
|
278
|
-
//
|
|
279
|
-
// <qqimg>路径</qqimg>
|
|
280
|
-
// <qqvoice>路径</qqvoice>
|
|
281
|
-
// <qqvideo>路径或URL</qqvideo>
|
|
282
|
-
// <qqfile>路径</qqfile>
|
|
762
|
+
// 支持五种标签:
|
|
763
|
+
// <qqimg>路径</qqimg> — 图片
|
|
764
|
+
// <qqvoice>路径</qqvoice> — 语音
|
|
765
|
+
// <qqvideo>路径或URL</qqvideo> — 视频
|
|
766
|
+
// <qqfile>路径</qqfile> — 文件
|
|
767
|
+
// <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
|
|
283
768
|
|
|
284
769
|
// 预处理:纠正小模型常见的标签拼写错误和格式问题
|
|
285
770
|
text = normalizeMediaTags(text);
|
|
286
771
|
|
|
287
|
-
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
|
|
772
|
+
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
288
773
|
const mediaTagMatches = text.match(mediaTagRegex);
|
|
289
774
|
|
|
290
775
|
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
|
291
776
|
console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
292
777
|
|
|
293
778
|
// 构建发送队列:根据内容在原文中的实际位置顺序发送
|
|
294
|
-
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file"; content: string }> = [];
|
|
779
|
+
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
|
|
295
780
|
|
|
296
781
|
let lastIndex = 0;
|
|
297
|
-
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
|
|
782
|
+
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
298
783
|
let match;
|
|
299
784
|
|
|
300
785
|
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
@@ -356,7 +841,10 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
356
841
|
}
|
|
357
842
|
|
|
358
843
|
if (mediaPath) {
|
|
359
|
-
if (tagName === "
|
|
844
|
+
if (tagName === "qqmedia") {
|
|
845
|
+
sendQueue.push({ type: "media", content: mediaPath });
|
|
846
|
+
console.log(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
847
|
+
} else if (tagName === "qqvoice") {
|
|
360
848
|
sendQueue.push({ type: "voice", content: mediaPath });
|
|
361
849
|
console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
362
850
|
} else if (tagName === "qqvideo") {
|
|
@@ -382,13 +870,8 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
382
870
|
|
|
383
871
|
console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
384
872
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
391
|
-
const target = parseTarget(to);
|
|
873
|
+
// 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
|
|
874
|
+
const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
|
|
392
875
|
let lastResult: OutboundResult = { channel: "qqbot" };
|
|
393
876
|
|
|
394
877
|
for (const item of sendQueue) {
|
|
@@ -396,7 +879,8 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
396
879
|
if (item.type === "text") {
|
|
397
880
|
// 发送文本
|
|
398
881
|
if (replyToId) {
|
|
399
|
-
|
|
882
|
+
const accessToken = await getToken(account);
|
|
883
|
+
const target = parseTarget(to);
|
|
400
884
|
if (target.type === "c2c") {
|
|
401
885
|
const result = await sendC2CMessage(accessToken, target.id, item.content, replyToId);
|
|
402
886
|
recordMessageReply(replyToId);
|
|
@@ -411,7 +895,8 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
411
895
|
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
412
896
|
}
|
|
413
897
|
} else {
|
|
414
|
-
|
|
898
|
+
const accessToken = await getToken(account);
|
|
899
|
+
const target = parseTarget(to);
|
|
415
900
|
if (target.type === "c2c") {
|
|
416
901
|
const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
|
|
417
902
|
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
|
|
@@ -425,214 +910,23 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
|
425
910
|
}
|
|
426
911
|
console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
427
912
|
} else if (item.type === "image") {
|
|
428
|
-
|
|
429
|
-
const imagePath = item.content;
|
|
430
|
-
const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
|
|
431
|
-
|
|
432
|
-
let imageUrl = imagePath;
|
|
433
|
-
|
|
434
|
-
// 如果是本地文件路径,读取并转换为 Base64
|
|
435
|
-
if (!isHttpUrl && !imagePath.startsWith("data:")) {
|
|
436
|
-
if (!(await fileExistsAsync(imagePath))) {
|
|
437
|
-
console.error(`[qqbot] sendText: Image file not found: ${imagePath}`);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
// 文件大小校验
|
|
441
|
-
const sizeCheck = checkFileSize(imagePath);
|
|
442
|
-
if (!sizeCheck.ok) {
|
|
443
|
-
console.error(`[qqbot] sendText: ${sizeCheck.error}`);
|
|
444
|
-
continue;
|
|
445
|
-
}
|
|
446
|
-
const fileBuffer = await readFileAsync(imagePath);
|
|
447
|
-
const ext = path.extname(imagePath).toLowerCase();
|
|
448
|
-
const mimeTypes: Record<string, string> = {
|
|
449
|
-
".jpg": "image/jpeg",
|
|
450
|
-
".jpeg": "image/jpeg",
|
|
451
|
-
".png": "image/png",
|
|
452
|
-
".gif": "image/gif",
|
|
453
|
-
".webp": "image/webp",
|
|
454
|
-
".bmp": "image/bmp",
|
|
455
|
-
};
|
|
456
|
-
const mimeType = mimeTypes[ext] ?? "image/png";
|
|
457
|
-
imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
|
458
|
-
console.log(`[qqbot] sendText: Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// 发送图片
|
|
462
|
-
if (target.type === "c2c") {
|
|
463
|
-
const result = await sendC2CImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined, undefined, isHttpUrl ? undefined : imagePath);
|
|
464
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
465
|
-
} else if (target.type === "group") {
|
|
466
|
-
const result = await sendGroupImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined);
|
|
467
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
468
|
-
} else if (isHttpUrl) {
|
|
469
|
-
// 频道使用 Markdown 格式(仅支持公网 URL)
|
|
470
|
-
const result = await sendChannelMessage(accessToken, target.id, ``, replyToId ?? undefined);
|
|
471
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
472
|
-
}
|
|
473
|
-
console.log(`[qqbot] sendText: Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
|
|
913
|
+
lastResult = await sendPhoto(mediaTarget, item.content);
|
|
474
914
|
} else if (item.type === "voice") {
|
|
475
|
-
|
|
476
|
-
const voicePath = item.content;
|
|
477
|
-
|
|
478
|
-
// 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
|
|
479
|
-
const fileSize = await waitForFile(voicePath);
|
|
480
|
-
if (fileSize === 0) {
|
|
481
|
-
console.error(`[qqbot] sendText: Voice file not ready after waiting: ${voicePath}`);
|
|
482
|
-
// 发送友好提示给用户
|
|
483
|
-
try {
|
|
484
|
-
if (target.type === "c2c") {
|
|
485
|
-
await sendC2CMessage(accessToken, target.id, "语音生成失败,请稍后重试", replyToId ?? undefined);
|
|
486
|
-
} else if (target.type === "group") {
|
|
487
|
-
await sendGroupMessage(accessToken, target.id, "语音生成失败,请稍后重试", replyToId ?? undefined);
|
|
488
|
-
}
|
|
489
|
-
} catch {}
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// 转换为 SILK 格式(QQ Bot API 语音只支持 SILK)
|
|
494
|
-
const silkBase64 = await audioFileToSilkBase64(voicePath);
|
|
495
|
-
if (!silkBase64) {
|
|
496
|
-
const ext = path.extname(voicePath).toLowerCase();
|
|
497
|
-
console.error(`[qqbot] sendText: Voice conversion to SILK failed: ${ext} (${fileSize} bytes)`);
|
|
498
|
-
try {
|
|
499
|
-
if (target.type === "c2c") {
|
|
500
|
-
await sendC2CMessage(accessToken, target.id, "语音格式转换失败,请稍后重试", replyToId ?? undefined);
|
|
501
|
-
} else if (target.type === "group") {
|
|
502
|
-
await sendGroupMessage(accessToken, target.id, "语音格式转换失败,请稍后重试", replyToId ?? undefined);
|
|
503
|
-
}
|
|
504
|
-
} catch {}
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
console.log(`[qqbot] sendText: Voice converted to SILK (${fileSize} bytes)`);
|
|
508
|
-
|
|
509
|
-
if (target.type === "c2c") {
|
|
510
|
-
const result = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
511
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
512
|
-
} else if (target.type === "group") {
|
|
513
|
-
const result = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
514
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
515
|
-
} else {
|
|
516
|
-
const result = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
517
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
518
|
-
}
|
|
519
|
-
console.log(`[qqbot] sendText: Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
|
|
915
|
+
lastResult = await sendVoice(mediaTarget, item.content, undefined, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
|
|
520
916
|
} else if (item.type === "video") {
|
|
521
|
-
|
|
522
|
-
const videoPath = item.content;
|
|
523
|
-
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
|
|
524
|
-
|
|
525
|
-
if (isHttpUrl) {
|
|
526
|
-
// 公网 URL
|
|
527
|
-
if (target.type === "c2c") {
|
|
528
|
-
const result = await sendC2CVideoMessage(accessToken, target.id, videoPath, undefined, replyToId ?? undefined);
|
|
529
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
530
|
-
} else if (target.type === "group") {
|
|
531
|
-
const result = await sendGroupVideoMessage(accessToken, target.id, videoPath, undefined, replyToId ?? undefined);
|
|
532
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
533
|
-
} else {
|
|
534
|
-
const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
535
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
536
|
-
}
|
|
537
|
-
} else {
|
|
538
|
-
// 本地文件:读取为 Base64
|
|
539
|
-
if (!(await fileExistsAsync(videoPath))) {
|
|
540
|
-
console.error(`[qqbot] sendText: Video file not found: ${videoPath}`);
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
543
|
-
const videoSizeCheck = checkFileSize(videoPath);
|
|
544
|
-
if (!videoSizeCheck.ok) {
|
|
545
|
-
console.error(`[qqbot] sendText: ${videoSizeCheck.error}`);
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
// 大文件进度提示
|
|
549
|
-
if (isLargeFile(videoSizeCheck.size)) {
|
|
550
|
-
try {
|
|
551
|
-
const hint = `⏳ 正在上传视频 (${formatFileSize(videoSizeCheck.size)})...`;
|
|
552
|
-
if (target.type === "c2c") {
|
|
553
|
-
await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
554
|
-
} else if (target.type === "group") {
|
|
555
|
-
await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
556
|
-
}
|
|
557
|
-
} catch {}
|
|
558
|
-
}
|
|
559
|
-
const fileBuffer = await readFileAsync(videoPath);
|
|
560
|
-
const videoBase64 = fileBuffer.toString("base64");
|
|
561
|
-
console.log(`[qqbot] sendText: Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
|
|
562
|
-
|
|
563
|
-
if (target.type === "c2c") {
|
|
564
|
-
const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined, undefined, videoPath);
|
|
565
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
566
|
-
} else if (target.type === "group") {
|
|
567
|
-
const result = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
|
|
568
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
569
|
-
} else {
|
|
570
|
-
const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
571
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
console.log(`[qqbot] sendText: Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
|
|
917
|
+
lastResult = await sendVideoMsg(mediaTarget, item.content);
|
|
575
918
|
} else if (item.type === "file") {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
if (target.type === "c2c") {
|
|
584
|
-
const result = await sendC2CFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
|
|
585
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
586
|
-
} else if (target.type === "group") {
|
|
587
|
-
const result = await sendGroupFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
|
|
588
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
589
|
-
} else {
|
|
590
|
-
const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
591
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
592
|
-
}
|
|
593
|
-
} else {
|
|
594
|
-
// 本地文件:读取转 Base64 上传
|
|
595
|
-
if (!(await fileExistsAsync(filePath))) {
|
|
596
|
-
console.error(`[qqbot] sendText: File not found: ${filePath}`);
|
|
597
|
-
continue;
|
|
598
|
-
}
|
|
599
|
-
const fileSizeCheck = checkFileSize(filePath);
|
|
600
|
-
if (!fileSizeCheck.ok) {
|
|
601
|
-
console.error(`[qqbot] sendText: ${fileSizeCheck.error}`);
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
// 大文件进度提示
|
|
605
|
-
if (isLargeFile(fileSizeCheck.size)) {
|
|
606
|
-
try {
|
|
607
|
-
const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileSizeCheck.size)})...`;
|
|
608
|
-
if (target.type === "c2c") {
|
|
609
|
-
await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
610
|
-
} else if (target.type === "group") {
|
|
611
|
-
await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
612
|
-
}
|
|
613
|
-
} catch {}
|
|
614
|
-
}
|
|
615
|
-
const fileBuffer = await readFileAsync(filePath);
|
|
616
|
-
const fileBase64 = fileBuffer.toString("base64");
|
|
617
|
-
console.log(`[qqbot] sendText: Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
|
|
618
|
-
|
|
619
|
-
if (target.type === "c2c") {
|
|
620
|
-
const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName, filePath);
|
|
621
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
622
|
-
} else if (target.type === "group") {
|
|
623
|
-
const result = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
|
|
624
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
625
|
-
} else {
|
|
626
|
-
const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
627
|
-
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
console.log(`[qqbot] sendText: Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
|
|
919
|
+
lastResult = await sendDocument(mediaTarget, item.content);
|
|
920
|
+
} else if (item.type === "media") {
|
|
921
|
+
// qqmedia: 自动根据扩展名路由
|
|
922
|
+
lastResult = await sendMedia({
|
|
923
|
+
to, text: "", mediaUrl: item.content,
|
|
924
|
+
accountId: account.accountId, replyToId, account,
|
|
925
|
+
});
|
|
631
926
|
}
|
|
632
927
|
} catch (err) {
|
|
633
928
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
634
929
|
console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
|
635
|
-
// 继续发送队列中的其他内容
|
|
636
930
|
}
|
|
637
931
|
}
|
|
638
932
|
|
|
@@ -804,428 +1098,94 @@ export async function sendProactiveMessage(
|
|
|
804
1098
|
* ```
|
|
805
1099
|
*/
|
|
806
1100
|
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
|
807
|
-
const { to, text, replyToId, account } = ctx;
|
|
808
|
-
// 展开波浪线路径:~/Desktop/file.png → /Users/xxx/Desktop/file.png
|
|
1101
|
+
const { to, text, replyToId, account, mimeType } = ctx;
|
|
809
1102
|
const mediaUrl = normalizePath(ctx.mediaUrl);
|
|
810
1103
|
|
|
811
1104
|
if (!account.appId || !account.clientSecret) {
|
|
812
1105
|
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
813
1106
|
}
|
|
814
|
-
|
|
815
1107
|
if (!mediaUrl) {
|
|
816
1108
|
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
|
817
1109
|
}
|
|
818
1110
|
|
|
819
|
-
|
|
820
|
-
const isLocalPath = isLocalFilePath(mediaUrl);
|
|
821
|
-
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
|
1111
|
+
const target = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendMedia]");
|
|
822
1112
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
if (
|
|
830
|
-
|
|
1113
|
+
// 按类型分发(MIME 优先,扩展名回退)
|
|
1114
|
+
// 各 send* 函数内部已自带 URL 直传/下载策略(受 urlDirectUpload 开关控制)
|
|
1115
|
+
if (isAudioFile(mediaUrl, mimeType)) {
|
|
1116
|
+
const formats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
1117
|
+
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
|
1118
|
+
const result = await sendVoice(target, mediaUrl, formats, transcodeEnabled);
|
|
1119
|
+
if (!result.error) {
|
|
1120
|
+
if (text?.trim()) await sendTextAfterMedia(target, text);
|
|
1121
|
+
return result;
|
|
831
1122
|
}
|
|
832
|
-
|
|
833
|
-
|
|
1123
|
+
// 语音发送失败 fallback 到文件发送(保留错误链)
|
|
1124
|
+
const voiceError = result.error;
|
|
1125
|
+
console.warn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`);
|
|
1126
|
+
const fallback = await sendDocument(target, mediaUrl);
|
|
1127
|
+
if (!fallback.error) {
|
|
1128
|
+
if (text?.trim()) await sendTextAfterMedia(target, text);
|
|
1129
|
+
return fallback;
|
|
834
1130
|
}
|
|
1131
|
+
return { channel: "qqbot", error: `voice: ${voiceError} | fallback file: ${fallback.error}` };
|
|
835
1132
|
}
|
|
836
1133
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1134
|
+
if (isVideoFile(mediaUrl, mimeType)) {
|
|
1135
|
+
const result = await sendVideoMsg(target, mediaUrl);
|
|
1136
|
+
if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
|
|
1137
|
+
return result;
|
|
840
1138
|
}
|
|
841
1139
|
|
|
842
|
-
//
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if (isLocalPath) {
|
|
849
|
-
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
|
|
850
|
-
|
|
851
|
-
try {
|
|
852
|
-
if (!(await fileExistsAsync(mediaUrl))) {
|
|
853
|
-
return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// 文件大小校验
|
|
857
|
-
const sizeCheck = checkFileSize(mediaUrl);
|
|
858
|
-
if (!sizeCheck.ok) {
|
|
859
|
-
return { channel: "qqbot", error: sizeCheck.error! };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const fileBuffer = await readFileAsync(mediaUrl);
|
|
863
|
-
const base64Data = fileBuffer.toString("base64");
|
|
864
|
-
|
|
865
|
-
const ext = path.extname(mediaUrl).toLowerCase();
|
|
866
|
-
const mimeTypes: Record<string, string> = {
|
|
867
|
-
".jpg": "image/jpeg",
|
|
868
|
-
".jpeg": "image/jpeg",
|
|
869
|
-
".png": "image/png",
|
|
870
|
-
".gif": "image/gif",
|
|
871
|
-
".webp": "image/webp",
|
|
872
|
-
".bmp": "image/bmp",
|
|
873
|
-
};
|
|
874
|
-
|
|
875
|
-
const mimeType = mimeTypes[ext];
|
|
876
|
-
if (!mimeType) {
|
|
877
|
-
return {
|
|
878
|
-
channel: "qqbot",
|
|
879
|
-
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
|
|
884
|
-
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
|
|
885
|
-
|
|
886
|
-
} catch (readErr) {
|
|
887
|
-
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
|
888
|
-
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
|
|
889
|
-
return { channel: "qqbot", error: `读取本地文件失败: ${errMsg}` };
|
|
890
|
-
}
|
|
891
|
-
} else if (!isHttpUrl && !isDataUrl) {
|
|
892
|
-
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
|
|
893
|
-
return {
|
|
894
|
-
channel: "qqbot",
|
|
895
|
-
error: `不支持的媒体格式: ${mediaUrl.slice(0, 50)}...。支持: 公网 URL、Base64 Data URL 或本地文件路径(图片/音频)。`
|
|
896
|
-
};
|
|
897
|
-
} else if (isDataUrl) {
|
|
898
|
-
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
|
|
899
|
-
} else {
|
|
900
|
-
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
|
|
1140
|
+
// 非图片、非音频、非视频 → 文件发送
|
|
1141
|
+
if (!isImageFile(mediaUrl, mimeType) && !isAudioFile(mediaUrl, mimeType) && !isVideoFile(mediaUrl, mimeType)) {
|
|
1142
|
+
const result = await sendDocument(target, mediaUrl);
|
|
1143
|
+
if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
|
|
1144
|
+
return result;
|
|
901
1145
|
}
|
|
902
1146
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
let imageResult: { id: string; timestamp: number | string };
|
|
908
|
-
if (target.type === "c2c") {
|
|
909
|
-
imageResult = await sendC2CImageMessage(
|
|
910
|
-
accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined, isLocalPath ? mediaUrl : undefined
|
|
911
|
-
);
|
|
912
|
-
} else if (target.type === "group") {
|
|
913
|
-
imageResult = await sendGroupImageMessage(
|
|
914
|
-
accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined
|
|
915
|
-
);
|
|
916
|
-
} else {
|
|
917
|
-
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
|
|
918
|
-
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
|
|
919
|
-
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
|
|
920
|
-
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (text?.trim()) {
|
|
924
|
-
try {
|
|
925
|
-
if (target.type === "c2c") {
|
|
926
|
-
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
927
|
-
} else if (target.type === "group") {
|
|
928
|
-
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
929
|
-
}
|
|
930
|
-
} catch (textErr) {
|
|
931
|
-
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp, refIdx: (imageResult as any).ext_info?.ref_idx };
|
|
936
|
-
} catch (err) {
|
|
937
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
938
|
-
return { channel: "qqbot", error: message };
|
|
939
|
-
}
|
|
1147
|
+
// 默认:图片(sendPhoto 内置 URL fallback)
|
|
1148
|
+
const result = await sendPhoto(target, mediaUrl);
|
|
1149
|
+
if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
|
|
1150
|
+
return result;
|
|
940
1151
|
}
|
|
941
1152
|
|
|
942
|
-
/**
|
|
943
|
-
|
|
944
|
-
* 流程类似图片发送:读取本地音频文件 → 转为 SILK Base64 → 上传 → 发送
|
|
945
|
-
*/
|
|
946
|
-
async function sendVoiceFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
|
947
|
-
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
948
|
-
|
|
949
|
-
console.log(`[qqbot] sendVoiceFile: ${mediaUrl}`);
|
|
950
|
-
|
|
951
|
-
// 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
|
|
952
|
-
const fileSize = await waitForFile(mediaUrl);
|
|
953
|
-
if (fileSize === 0) {
|
|
954
|
-
return { channel: "qqbot", error: `语音生成失败,请稍后重试` };
|
|
955
|
-
}
|
|
956
|
-
|
|
1153
|
+
/** 发送媒体后附带文本说明 */
|
|
1154
|
+
async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): Promise<void> {
|
|
957
1155
|
try {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
|
|
963
|
-
const buf = await readFileAsync(mediaUrl);
|
|
964
|
-
const fallbackBase64 = buf.toString("base64");
|
|
965
|
-
console.log(`[qqbot] sendVoiceFile: not SILK format, uploading raw file (${formatFileSize(buf.length)})`);
|
|
966
|
-
|
|
967
|
-
const accessToken = await getAccessToken(account.appId!, account.clientSecret!);
|
|
968
|
-
const target = parseTarget(to);
|
|
969
|
-
|
|
970
|
-
let result: { id: string; timestamp: number | string };
|
|
971
|
-
if (target.type === "c2c") {
|
|
972
|
-
result = await sendC2CVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
|
|
973
|
-
} else if (target.type === "group") {
|
|
974
|
-
result = await sendGroupVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
|
|
975
|
-
} else {
|
|
976
|
-
const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
977
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
console.log(`[qqbot] sendVoiceFile: SILK format ready, uploading...`);
|
|
984
|
-
|
|
985
|
-
const accessToken = await getAccessToken(account.appId!, account.clientSecret!);
|
|
986
|
-
const target = parseTarget(to);
|
|
987
|
-
|
|
988
|
-
let voiceResult: { id: string; timestamp: number | string };
|
|
989
|
-
if (target.type === "c2c") {
|
|
990
|
-
voiceResult = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
991
|
-
} else if (target.type === "group") {
|
|
992
|
-
voiceResult = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
993
|
-
} else {
|
|
994
|
-
const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
995
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1156
|
+
const token = await getToken(ctx.account);
|
|
1157
|
+
if (ctx.targetType === "c2c") {
|
|
1158
|
+
await sendC2CMessage(token, ctx.targetId, text, ctx.replyToId);
|
|
1159
|
+
} else if (ctx.targetType === "group") {
|
|
1160
|
+
await sendGroupMessage(token, ctx.targetId, text, ctx.replyToId);
|
|
996
1161
|
}
|
|
997
|
-
|
|
998
|
-
// 如果有文本说明,再发送一条文本消息
|
|
999
|
-
if (text?.trim()) {
|
|
1000
|
-
try {
|
|
1001
|
-
if (target.type === "c2c") {
|
|
1002
|
-
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1003
|
-
} else if (target.type === "group") {
|
|
1004
|
-
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1005
|
-
}
|
|
1006
|
-
} catch (textErr) {
|
|
1007
|
-
console.error(`[qqbot] Failed to send text after voice: ${textErr}`);
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
console.log(`[qqbot] sendVoiceFile: voice message sent`);
|
|
1012
|
-
return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp, refIdx: (voiceResult as any).ext_info?.ref_idx };
|
|
1013
1162
|
} catch (err) {
|
|
1014
|
-
|
|
1015
|
-
console.error(`[qqbot] sendVoiceFile: failed: ${message}`);
|
|
1016
|
-
return { channel: "qqbot", error: message };
|
|
1163
|
+
console.error(`[qqbot] sendTextAfterMedia failed: ${err}`);
|
|
1017
1164
|
}
|
|
1018
1165
|
}
|
|
1019
1166
|
|
|
1020
|
-
/**
|
|
1021
|
-
function
|
|
1022
|
-
const
|
|
1023
|
-
return
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/** 判断文件/URL 是否为视频格式 */
|
|
1027
|
-
function isVideoFile(filePath: string): boolean {
|
|
1028
|
-
// 去掉 URL query 参数后判断扩展名
|
|
1029
|
-
const cleanPath = filePath.split("?")[0]!;
|
|
1030
|
-
const ext = path.extname(cleanPath).toLowerCase();
|
|
1031
|
-
return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
|
|
1167
|
+
/** 从路径/URL 中提取扩展名(去除查询参数和 hash) */
|
|
1168
|
+
function getCleanExt(filePath: string): string {
|
|
1169
|
+
const cleanPath = filePath.split("?")[0]!.split("#")[0]!;
|
|
1170
|
+
return path.extname(cleanPath).toLowerCase();
|
|
1032
1171
|
}
|
|
1033
1172
|
|
|
1034
|
-
/**
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
1039
|
-
|
|
1040
|
-
console.log(`[qqbot] sendVideoUrl: ${mediaUrl}`);
|
|
1041
|
-
|
|
1042
|
-
if (!account.appId || !account.clientSecret) {
|
|
1043
|
-
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
try {
|
|
1047
|
-
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1048
|
-
const target = parseTarget(to);
|
|
1049
|
-
|
|
1050
|
-
let videoResult: { id: string; timestamp: number | string };
|
|
1051
|
-
if (target.type === "c2c") {
|
|
1052
|
-
videoResult = await sendC2CVideoMessage(accessToken, target.id, mediaUrl, undefined, replyToId ?? undefined);
|
|
1053
|
-
} else if (target.type === "group") {
|
|
1054
|
-
videoResult = await sendGroupVideoMessage(accessToken, target.id, mediaUrl, undefined, replyToId ?? undefined);
|
|
1055
|
-
} else {
|
|
1056
|
-
const r = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1057
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// 如果有文本说明,再发送一条文本消息
|
|
1061
|
-
if (text?.trim()) {
|
|
1062
|
-
try {
|
|
1063
|
-
if (target.type === "c2c") {
|
|
1064
|
-
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1065
|
-
} else if (target.type === "group") {
|
|
1066
|
-
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1067
|
-
}
|
|
1068
|
-
} catch (textErr) {
|
|
1069
|
-
console.error(`[qqbot] Failed to send text after video: ${textErr}`);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
console.log(`[qqbot] sendVideoUrl: video message sent`);
|
|
1074
|
-
return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp, refIdx: (videoResult as any).ext_info?.ref_idx };
|
|
1075
|
-
} catch (err) {
|
|
1076
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1077
|
-
console.error(`[qqbot] sendVideoUrl: failed: ${message}`);
|
|
1078
|
-
return { channel: "qqbot", error: message };
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
/**
|
|
1083
|
-
* 发送本地视频文件
|
|
1084
|
-
* 流程:读取本地文件 → Base64 → 上传(file_type=2) → 发送
|
|
1085
|
-
*/
|
|
1086
|
-
async function sendVideoFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
|
1087
|
-
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
1088
|
-
|
|
1089
|
-
console.log(`[qqbot] sendVideoFile: ${mediaUrl}`);
|
|
1090
|
-
|
|
1091
|
-
if (!account.appId || !account.clientSecret) {
|
|
1092
|
-
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
try {
|
|
1096
|
-
if (!(await fileExistsAsync(mediaUrl))) {
|
|
1097
|
-
return { channel: "qqbot", error: `视频文件不存在: ${mediaUrl}` };
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// 文件大小校验
|
|
1101
|
-
const sizeCheck = checkFileSize(mediaUrl);
|
|
1102
|
-
if (!sizeCheck.ok) {
|
|
1103
|
-
return { channel: "qqbot", error: sizeCheck.error! };
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const fileBuffer = await readFileAsync(mediaUrl);
|
|
1107
|
-
const videoBase64 = fileBuffer.toString("base64");
|
|
1108
|
-
console.log(`[qqbot] sendVideoFile: Read local video (${formatFileSize(fileBuffer.length)})`);
|
|
1109
|
-
|
|
1110
|
-
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1111
|
-
const target = parseTarget(to);
|
|
1112
|
-
|
|
1113
|
-
let videoResult: { id: string; timestamp: number | string };
|
|
1114
|
-
if (target.type === "c2c") {
|
|
1115
|
-
videoResult = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined, undefined, mediaUrl);
|
|
1116
|
-
} else if (target.type === "group") {
|
|
1117
|
-
videoResult = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
|
|
1118
|
-
} else {
|
|
1119
|
-
const r = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1120
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// 如果有文本说明,再发送一条文本消息
|
|
1124
|
-
if (text?.trim()) {
|
|
1125
|
-
try {
|
|
1126
|
-
if (target.type === "c2c") {
|
|
1127
|
-
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1128
|
-
} else if (target.type === "group") {
|
|
1129
|
-
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1130
|
-
}
|
|
1131
|
-
} catch (textErr) {
|
|
1132
|
-
console.error(`[qqbot] Failed to send text after video: ${textErr}`);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
console.log(`[qqbot] sendVideoFile: video message sent`);
|
|
1137
|
-
return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp, refIdx: (videoResult as any).ext_info?.ref_idx };
|
|
1138
|
-
} catch (err) {
|
|
1139
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1140
|
-
console.error(`[qqbot] sendVideoFile: failed: ${message}`);
|
|
1141
|
-
return { channel: "qqbot", error: message };
|
|
1173
|
+
/** 判断文件是否为图片格式(MIME 优先,扩展名回退) */
|
|
1174
|
+
function isImageFile(filePath: string, mimeType?: string): boolean {
|
|
1175
|
+
if (mimeType) {
|
|
1176
|
+
if (mimeType.startsWith("image/")) return true;
|
|
1142
1177
|
}
|
|
1178
|
+
const ext = getCleanExt(filePath);
|
|
1179
|
+
return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext);
|
|
1143
1180
|
}
|
|
1144
1181
|
|
|
1145
|
-
/**
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
*/
|
|
1150
|
-
async function sendDocumentFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
|
1151
|
-
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
1152
|
-
|
|
1153
|
-
console.log(`[qqbot] sendDocumentFile: ${mediaUrl}`);
|
|
1154
|
-
|
|
1155
|
-
if (!account.appId || !account.clientSecret) {
|
|
1156
|
-
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
|
1160
|
-
const fileName = sanitizeFileName(path.basename(mediaUrl));
|
|
1161
|
-
|
|
1162
|
-
try {
|
|
1163
|
-
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1164
|
-
const target = parseTarget(to);
|
|
1165
|
-
|
|
1166
|
-
let fileResult: { id: string; timestamp: number | string };
|
|
1167
|
-
|
|
1168
|
-
if (isHttpUrl) {
|
|
1169
|
-
// 公网 URL:通过 url 参数上传
|
|
1170
|
-
console.log(`[qqbot] sendDocumentFile: uploading via URL: ${mediaUrl}`);
|
|
1171
|
-
if (target.type === "c2c") {
|
|
1172
|
-
fileResult = await sendC2CFileMessage(accessToken, target.id, undefined, mediaUrl, replyToId ?? undefined, fileName);
|
|
1173
|
-
} else if (target.type === "group") {
|
|
1174
|
-
fileResult = await sendGroupFileMessage(accessToken, target.id, undefined, mediaUrl, replyToId ?? undefined, fileName);
|
|
1175
|
-
} else {
|
|
1176
|
-
const r = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1177
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1178
|
-
}
|
|
1179
|
-
} else {
|
|
1180
|
-
// 本地文件:读取转 Base64 上传
|
|
1181
|
-
if (!(await fileExistsAsync(mediaUrl))) {
|
|
1182
|
-
return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// 文件大小校验
|
|
1186
|
-
const docSizeCheck = checkFileSize(mediaUrl);
|
|
1187
|
-
if (!docSizeCheck.ok) {
|
|
1188
|
-
return { channel: "qqbot", error: docSizeCheck.error! };
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
const fileBuffer = await readFileAsync(mediaUrl);
|
|
1192
|
-
if (fileBuffer.length === 0) {
|
|
1193
|
-
return { channel: "qqbot", error: `文件内容为空: ${mediaUrl}` };
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
const fileBase64 = fileBuffer.toString("base64");
|
|
1197
|
-
console.log(`[qqbot] sendDocumentFile: read local file (${formatFileSize(fileBuffer.length)}), uploading...`);
|
|
1198
|
-
|
|
1199
|
-
if (target.type === "c2c") {
|
|
1200
|
-
fileResult = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName, mediaUrl);
|
|
1201
|
-
} else if (target.type === "group") {
|
|
1202
|
-
fileResult = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
|
|
1203
|
-
} else {
|
|
1204
|
-
const r = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1205
|
-
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
// 如果有附带文本说明,再发送一条文本消息
|
|
1210
|
-
if (text?.trim()) {
|
|
1211
|
-
try {
|
|
1212
|
-
if (target.type === "c2c") {
|
|
1213
|
-
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1214
|
-
} else if (target.type === "group") {
|
|
1215
|
-
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1216
|
-
}
|
|
1217
|
-
} catch (textErr) {
|
|
1218
|
-
console.error(`[qqbot] Failed to send text after file: ${textErr}`);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
console.log(`[qqbot] sendDocumentFile: file message sent`);
|
|
1223
|
-
return { channel: "qqbot", messageId: fileResult.id, timestamp: fileResult.timestamp, refIdx: (fileResult as any).ext_info?.ref_idx };
|
|
1224
|
-
} catch (err) {
|
|
1225
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1226
|
-
console.error(`[qqbot] sendDocumentFile: failed: ${message}`);
|
|
1227
|
-
return { channel: "qqbot", error: message };
|
|
1182
|
+
/** 判断文件/URL 是否为视频格式(MIME 优先,扩展名回退) */
|
|
1183
|
+
function isVideoFile(filePath: string, mimeType?: string): boolean {
|
|
1184
|
+
if (mimeType) {
|
|
1185
|
+
if (mimeType.startsWith("video/")) return true;
|
|
1228
1186
|
}
|
|
1187
|
+
const ext = getCleanExt(filePath);
|
|
1188
|
+
return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
|
|
1229
1189
|
}
|
|
1230
1190
|
|
|
1231
1191
|
/**
|