@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/gateway.ts
CHANGED
|
@@ -2,18 +2,22 @@ import WebSocket from "ws";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
|
5
|
-
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent } from "./api.js";
|
|
5
|
+
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveC2CMessage, sendProactiveGroupMessage } from "./api.js";
|
|
6
6
|
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
|
7
|
-
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
7
|
+
import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.js";
|
|
8
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
9
9
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
|
|
10
|
+
import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type SlashCommandFileResult, type QueueSnapshot } from "./slash-commands.js";
|
|
11
|
+
import { triggerUpdateCheck } from "./update-checker.js";
|
|
10
12
|
import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
|
11
13
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
|
|
12
14
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
|
|
13
|
-
import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk
|
|
15
|
+
import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk } from "./utils/audio-convert.js";
|
|
14
16
|
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
15
17
|
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
|
|
16
|
-
import { getQQBotDataDir, isLocalPath as isLocalFilePath,
|
|
18
|
+
import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
|
|
19
|
+
import { MSG, formatMediaErrorMessage } from "./user-messages.js";
|
|
20
|
+
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* 通用 OpenAI 兼容 STT(语音转文字)
|
|
@@ -108,27 +112,9 @@ const INTENTS = {
|
|
|
108
112
|
GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
|
|
109
113
|
};
|
|
110
114
|
|
|
111
|
-
//
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
{
|
|
115
|
-
name: "full",
|
|
116
|
-
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
|
|
117
|
-
description: "群聊+私信+频道",
|
|
118
|
-
},
|
|
119
|
-
// Level 1: 群聊 + 频道(无私信)
|
|
120
|
-
{
|
|
121
|
-
name: "group+channel",
|
|
122
|
-
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GROUP_AND_C2C,
|
|
123
|
-
description: "群聊+频道",
|
|
124
|
-
},
|
|
125
|
-
// Level 2: 仅频道(基础权限)
|
|
126
|
-
{
|
|
127
|
-
name: "channel-only",
|
|
128
|
-
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS,
|
|
129
|
-
description: "仅频道消息",
|
|
130
|
-
},
|
|
131
|
-
];
|
|
115
|
+
// 固定使用完整权限(群聊 + 私信 + 频道),不做降级
|
|
116
|
+
const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
|
|
117
|
+
const FULL_INTENTS_DESC = "群聊+私信+频道";
|
|
132
118
|
|
|
133
119
|
// 重连配置
|
|
134
120
|
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
|
|
@@ -235,27 +221,7 @@ function parseFaceTags(text: string): string {
|
|
|
235
221
|
});
|
|
236
222
|
}
|
|
237
223
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* 将媒体上传/发送错误转为对用户友好的提示文案
|
|
242
|
-
*/
|
|
243
|
-
function formatMediaErrorMessage(mediaType: string, err: unknown): string {
|
|
244
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
245
|
-
if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
|
|
246
|
-
return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
|
|
247
|
-
}
|
|
248
|
-
if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
|
|
249
|
-
return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
|
|
250
|
-
}
|
|
251
|
-
if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
|
|
252
|
-
return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
|
|
253
|
-
}
|
|
254
|
-
if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
|
|
255
|
-
return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
|
|
256
|
-
}
|
|
257
|
-
return `抱歉,${mediaType}发送失败了,请稍后再试~`;
|
|
258
|
-
}
|
|
224
|
+
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
259
225
|
|
|
260
226
|
// ============ 内部标记过滤 ============
|
|
261
227
|
|
|
@@ -378,6 +344,49 @@ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: st
|
|
|
378
344
|
}
|
|
379
345
|
}
|
|
380
346
|
|
|
347
|
+
// ============ 启动问候语(首次安装/版本更新 vs 普通重启) ============
|
|
348
|
+
|
|
349
|
+
// 模块级变量:进程生命周期内只有首次为 true
|
|
350
|
+
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
351
|
+
let isFirstReadyGlobal = true;
|
|
352
|
+
|
|
353
|
+
const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 判断是否为首次安装或版本更新,返回对应的问候语。
|
|
357
|
+
* - 首次安装 / 版本变更 → "Haha,我的'灵魂'已上线,随时等你吩咐。"
|
|
358
|
+
* - 普通重启 → "我重新登上了,有事随时找我。"
|
|
359
|
+
*/
|
|
360
|
+
function getStartupGreeting(): string {
|
|
361
|
+
const currentVersion = getPluginVersion();
|
|
362
|
+
let isFirstOrUpdated = true;
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
if (fs.existsSync(STARTUP_MARKER_FILE)) {
|
|
366
|
+
const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
|
|
367
|
+
if (data.version === currentVersion) {
|
|
368
|
+
isFirstOrUpdated = false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// 文件损坏或不存在,视为首次
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 更新 marker 文件
|
|
376
|
+
try {
|
|
377
|
+
fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify({
|
|
378
|
+
version: currentVersion,
|
|
379
|
+
startedAt: new Date().toISOString(),
|
|
380
|
+
}) + "\n");
|
|
381
|
+
} catch {
|
|
382
|
+
// ignore
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return isFirstOrUpdated
|
|
386
|
+
? `Haha,我的'灵魂'已上线,随时等你吩咐。`
|
|
387
|
+
: `我重新登上了,有事随时找我。`;
|
|
388
|
+
}
|
|
389
|
+
|
|
381
390
|
/**
|
|
382
391
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
|
383
392
|
* 支持流式消息发送
|
|
@@ -397,6 +406,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
397
406
|
}
|
|
398
407
|
}
|
|
399
408
|
|
|
409
|
+
// 后台版本检查(detached 子进程,零阻塞)
|
|
410
|
+
triggerUpdateCheck(log);
|
|
411
|
+
|
|
400
412
|
// 初始化 API 配置(markdown 支持)
|
|
401
413
|
initApiConfig({
|
|
402
414
|
markdownSupport: account.markdownSupport,
|
|
@@ -471,8 +483,46 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
471
483
|
let isConnecting = false; // 防止并发连接
|
|
472
484
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
|
|
473
485
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
|
474
|
-
|
|
475
|
-
|
|
486
|
+
// 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
|
|
487
|
+
// health-monitor 重连不会重新初始化为 true
|
|
488
|
+
|
|
489
|
+
/** 异步发送启动问候语(READY 或 RESUMED 时调用) */
|
|
490
|
+
const sendStartupGreetings = (trigger: "READY" | "RESUMED") => {
|
|
491
|
+
(async () => {
|
|
492
|
+
try {
|
|
493
|
+
const greeting = getStartupGreeting();
|
|
494
|
+
log?.info(`[qqbot:${account.accountId}] Sending startup greeting (trigger=${trigger}): "${greeting}"`);
|
|
495
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
496
|
+
const users = listKnownUsers({ accountId: account.accountId, type: "c2c" });
|
|
497
|
+
for (const user of users) {
|
|
498
|
+
try {
|
|
499
|
+
await sendProactiveC2CMessage(token, user.openid, greeting);
|
|
500
|
+
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
|
|
503
|
+
}
|
|
504
|
+
await new Promise(r => setTimeout(r, 500));
|
|
505
|
+
}
|
|
506
|
+
const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
|
|
507
|
+
const sentGroups = new Set<string>();
|
|
508
|
+
for (const user of groups) {
|
|
509
|
+
const gid = user.groupOpenid;
|
|
510
|
+
if (!gid || sentGroups.has(gid)) continue;
|
|
511
|
+
sentGroups.add(gid);
|
|
512
|
+
try {
|
|
513
|
+
await sendProactiveGroupMessage(token, gid, greeting);
|
|
514
|
+
log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
|
|
517
|
+
}
|
|
518
|
+
await new Promise(r => setTimeout(r, 500));
|
|
519
|
+
}
|
|
520
|
+
log?.info(`[qqbot:${account.accountId}] Startup greetings sent (${users.length} c2c, ${sentGroups.size} groups)`);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send startup greetings: ${err}`);
|
|
523
|
+
}
|
|
524
|
+
})();
|
|
525
|
+
};
|
|
476
526
|
|
|
477
527
|
// ============ P1-2: 尝试从持久化存储恢复 Session ============
|
|
478
528
|
// 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
|
|
@@ -480,9 +530,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
480
530
|
if (savedSession) {
|
|
481
531
|
sessionId = savedSession.sessionId;
|
|
482
532
|
lastSeq = savedSession.lastSeq;
|
|
483
|
-
|
|
484
|
-
lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
|
|
485
|
-
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
|
|
533
|
+
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
|
|
486
534
|
}
|
|
487
535
|
|
|
488
536
|
// ============ 按用户并发的消息队列(同用户串行,跨用户并行) ============
|
|
@@ -575,6 +623,101 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
575
623
|
log?.info(`[qqbot:${account.accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`);
|
|
576
624
|
};
|
|
577
625
|
|
|
626
|
+
// 获取队列状态快照(供斜杠指令使用)
|
|
627
|
+
const getQueueSnapshot = (senderPeerId: string): QueueSnapshot => {
|
|
628
|
+
let totalPending = 0;
|
|
629
|
+
for (const [, q] of userQueues) {
|
|
630
|
+
totalPending += q.length;
|
|
631
|
+
}
|
|
632
|
+
const senderQueue = userQueues.get(senderPeerId);
|
|
633
|
+
return {
|
|
634
|
+
totalPending,
|
|
635
|
+
activeUsers: activeUsers.size,
|
|
636
|
+
maxConcurrentUsers: MAX_CONCURRENT_USERS,
|
|
637
|
+
senderPending: senderQueue ? senderQueue.length : 0,
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
|
|
642
|
+
const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
|
|
643
|
+
const content = (msg.content ?? "").trim();
|
|
644
|
+
if (!content.startsWith("/")) {
|
|
645
|
+
enqueueMessage(msg);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const receivedAt = Date.now();
|
|
650
|
+
const peerId = getMessagePeerId(msg);
|
|
651
|
+
|
|
652
|
+
const cmdCtx: SlashCommandContext = {
|
|
653
|
+
type: msg.type,
|
|
654
|
+
senderId: msg.senderId,
|
|
655
|
+
senderName: msg.senderName,
|
|
656
|
+
messageId: msg.messageId,
|
|
657
|
+
eventTimestamp: msg.timestamp,
|
|
658
|
+
receivedAt,
|
|
659
|
+
rawContent: content,
|
|
660
|
+
args: "",
|
|
661
|
+
channelId: msg.channelId,
|
|
662
|
+
groupOpenid: msg.groupOpenid,
|
|
663
|
+
accountId: account.accountId,
|
|
664
|
+
accountConfig: account.config,
|
|
665
|
+
queueSnapshot: getQueueSnapshot(peerId),
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
const reply = await matchSlashCommand(cmdCtx);
|
|
670
|
+
if (reply === null) {
|
|
671
|
+
// 不是插件级指令,正常入队交给框架
|
|
672
|
+
enqueueMessage(msg);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// 命中插件级指令,直接回复
|
|
677
|
+
log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
|
|
678
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
679
|
+
|
|
680
|
+
// 解析回复:纯文本 or 带文件的结果
|
|
681
|
+
const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
|
|
682
|
+
const replyText = isFileResult ? (reply as SlashCommandFileResult).text : reply as string;
|
|
683
|
+
const replyFile = isFileResult ? (reply as SlashCommandFileResult).filePath : null;
|
|
684
|
+
|
|
685
|
+
// 先发送文本回复
|
|
686
|
+
if (msg.type === "c2c") {
|
|
687
|
+
await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
|
|
688
|
+
} else if (msg.type === "group" && msg.groupOpenid) {
|
|
689
|
+
await sendGroupMessage(token, msg.groupOpenid, replyText, msg.messageId);
|
|
690
|
+
} else if (msg.channelId) {
|
|
691
|
+
await sendChannelMessage(token, msg.channelId, replyText, msg.messageId);
|
|
692
|
+
} else if (msg.type === "dm") {
|
|
693
|
+
await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 如果有文件需要发送
|
|
697
|
+
if (replyFile) {
|
|
698
|
+
try {
|
|
699
|
+
const targetType = msg.type === "group" ? "group" : msg.type === "c2c" || msg.type === "dm" ? "c2c" : "channel";
|
|
700
|
+
const targetId = msg.type === "group" ? (msg.groupOpenid || msg.senderId) : msg.type === "c2c" || msg.type === "dm" ? msg.senderId : (msg.channelId || msg.senderId);
|
|
701
|
+
const mediaCtx: MediaTargetContext = {
|
|
702
|
+
targetType,
|
|
703
|
+
targetId,
|
|
704
|
+
account,
|
|
705
|
+
replyToId: msg.messageId,
|
|
706
|
+
logPrefix: `[qqbot:${account.accountId}]`,
|
|
707
|
+
};
|
|
708
|
+
await sendDocument(mediaCtx, replyFile);
|
|
709
|
+
log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`);
|
|
710
|
+
} catch (fileErr) {
|
|
711
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send slash command file: ${fileErr}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
} catch (err) {
|
|
715
|
+
log?.error(`[qqbot:${account.accountId}] Slash command error: ${err}`);
|
|
716
|
+
// 出错时回退到正常入队
|
|
717
|
+
enqueueMessage(msg);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
578
721
|
abortSignal.addEventListener("abort", () => {
|
|
579
722
|
isAborted = true;
|
|
580
723
|
if (reconnectTimer) {
|
|
@@ -654,7 +797,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
654
797
|
|
|
655
798
|
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
|
|
656
799
|
|
|
657
|
-
const ws = new WebSocket(gatewayUrl);
|
|
800
|
+
const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } });
|
|
658
801
|
currentWs = ws;
|
|
659
802
|
|
|
660
803
|
const pluginRuntime = getQQBotRuntime();
|
|
@@ -900,13 +1043,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
900
1043
|
const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
|
|
901
1044
|
if (voiceTranscripts.length > 0) {
|
|
902
1045
|
voiceText = voiceTranscripts.length === 1
|
|
903
|
-
?
|
|
904
|
-
: voiceTranscripts.map((t, i) => {
|
|
905
|
-
const prefix = voiceTranscriptSources[i] === "asr"
|
|
906
|
-
? `[语音${i + 1}(ASR兜底,可能不准确)]`
|
|
907
|
-
: `[语音${i + 1}]`;
|
|
908
|
-
return `${prefix} ${t}`;
|
|
909
|
-
}).join("\n");
|
|
1046
|
+
? `[语音消息] ${voiceTranscripts[0]}`
|
|
1047
|
+
: voiceTranscripts.map((t, i) => `[语音${i + 1}] ${t}`).join("\n");
|
|
910
1048
|
}
|
|
911
1049
|
|
|
912
1050
|
// 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
|
|
@@ -1003,58 +1141,11 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1003
1141
|
+ (asrPreview ? `, asr_preview="${asrPreview}${uniqueVoiceAsrReferTexts[0].length > 50 ? "..." : ""}"` : "")
|
|
1004
1142
|
);
|
|
1005
1143
|
}
|
|
1006
|
-
let receivedMediaSection = "";
|
|
1007
|
-
if (imageUrls.length > 0 || uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
|
|
1008
|
-
const mediaSections: string[] = [];
|
|
1009
|
-
if (imageUrls.length > 0) {
|
|
1010
|
-
const imageEntries = imageUrls.map((p, i) => ` - ${p} (${imageMediaTypes[i] || "unknown"})`);
|
|
1011
|
-
mediaSections.push(`- 图片附件:\n${imageEntries.join("\n")}`);
|
|
1012
|
-
}
|
|
1013
|
-
if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
|
|
1014
|
-
const voiceEntries = [
|
|
1015
|
-
...uniqueVoicePaths.map((p) => ` - ${p} (local audio)`),
|
|
1016
|
-
...uniqueVoiceUrls.map((u) => ` - ${u} (remote audio)`),
|
|
1017
|
-
];
|
|
1018
|
-
mediaSections.push(`- 语音附件:\n${voiceEntries.join("\n")}`);
|
|
1019
|
-
}
|
|
1020
|
-
receivedMediaSection = `\n${mediaSections.join("\n")}`;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
1144
|
// AI 看到的投递地址必须带完整前缀(qqbot:c2c: / qqbot:group:)
|
|
1024
1145
|
const qualifiedTarget = isGroupChat ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`;
|
|
1025
1146
|
|
|
1026
|
-
// 动态检测 TTS
|
|
1147
|
+
// 动态检测 TTS 配置状态
|
|
1027
1148
|
const hasTTS = !!resolveTTSConfig(cfg as Record<string, unknown>);
|
|
1028
|
-
const hasSTT = !!resolveSTTConfig(cfg as Record<string, unknown>);
|
|
1029
|
-
|
|
1030
|
-
// 语音能力说明:<qqvoice> 标签本身只负责发送已有的音频文件,不依赖插件 TTS。
|
|
1031
|
-
// TTS 只是生成音频文件的一种方式,框架侧的 TTS 工具(如 audio_speech)也能生成。
|
|
1032
|
-
// 因此始终暴露 <qqvoice> 能力,但根据 TTS 状态给出不同的使用指引。
|
|
1033
|
-
const ttsHint = hasTTS
|
|
1034
|
-
? `6. 🎤 插件 TTS 已启用: 如果你有 TTS 工具(如 audio_speech),可用它生成音频文件后用 <qqvoice> 发送`
|
|
1035
|
-
: `6. ⚠️ 插件 TTS 未配置: 如果你有 TTS 工具(如 audio_speech),仍可用它生成音频文件后用 <qqvoice> 发送;若无 TTS 工具,则无法主动生成语音`;
|
|
1036
|
-
const sttHint = hasSTT
|
|
1037
|
-
? `\n7. 插件侧 STT 已配置,用户发送的语音消息会尽量自动转录`
|
|
1038
|
-
: `\n7. 插件侧 STT 未配置,插件不会自动转录语音消息`;
|
|
1039
|
-
const asrFallbackHint = hasAsrReferFallback
|
|
1040
|
-
? `\n8. 本条消息包含平台返回的 asr_refer_text 兜底文本(低置信度)。理解用户意图时可参考,但如关键信息不明确应先追问确认。`
|
|
1041
|
-
: "";
|
|
1042
|
-
const voiceForwardHint = uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0
|
|
1043
|
-
? `\n9. 本条消息已附带语音文件路径/URL。若你具备 STT 能力(框架能力或 STT skill),优先直接转写音频;若无 STT 能力或转写失败,再使用 asr_refer_text(若存在)作为兜底。`
|
|
1044
|
-
: "";
|
|
1045
|
-
const voiceSection = `
|
|
1046
|
-
|
|
1047
|
-
【发送语音 - 必须遵守】
|
|
1048
|
-
1. 发语音方法: 在回复文本中写 <qqvoice>本地音频文件路径</qqvoice>,系统自动处理
|
|
1049
|
-
2. 示例: "来听听吧! <qqvoice>/tmp/tts/voice.mp3</qqvoice>"
|
|
1050
|
-
3. 支持格式: .silk, .slk, .slac, .amr, .wav, .mp3, .ogg, .pcm
|
|
1051
|
-
4. ⚠️ <qqvoice> 只用于语音文件,图片请用 <qqimg>;两者不要混用
|
|
1052
|
-
5. 发送语音时,不要重复输出语音中已朗读的文字内容;语音前后的文字应是补充信息而非语音的文字版重复
|
|
1053
|
-
${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
1054
|
-
|
|
1055
|
-
const voiceAsrSection = uniqueVoiceAsrReferTexts.length > 0
|
|
1056
|
-
? `\n- 语音ASR兜底文本:\n${uniqueVoiceAsrReferTexts.map((t, i) => ` ${i + 1}. ${t}`).join("\n")}`
|
|
1057
|
-
: "";
|
|
1058
1149
|
|
|
1059
1150
|
// 引用消息上下文
|
|
1060
1151
|
let quotePart = "";
|
|
@@ -1066,46 +1157,46 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1066
1157
|
}
|
|
1067
1158
|
}
|
|
1068
1159
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1160
|
+
// ============ 构建 contextInfo(静态/动态分离) ============
|
|
1161
|
+
// 设计原则(参考 Telegram/Discord 做法):
|
|
1162
|
+
// - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
|
|
1163
|
+
// 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
|
|
1164
|
+
// 且保证长 session 窗口截断后仍可见。
|
|
1165
|
+
// - 动态标签:每条消息变化的数据(时间、附件、ASR),
|
|
1166
|
+
// 以紧凑的 [ctx] 块标注在用户消息前,最小化 token 开销。
|
|
1167
|
+
|
|
1168
|
+
// --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
|
|
1169
|
+
// 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
|
|
1170
|
+
// 这里只补充 QQBot 独有的:投递地址(cron skill 需要)
|
|
1171
|
+
const staticParts: string[] = [
|
|
1172
|
+
`[QQBot] to=${qualifiedTarget}`,
|
|
1173
|
+
];
|
|
1174
|
+
// TTS 能力声明:仅在启用时告知 AI 可以发语音(与 qqbot-media SKILL.md 互补)
|
|
1175
|
+
// STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
|
|
1176
|
+
if (hasTTS) staticParts.push("语音合成已启用,发送媒体格式:<qqmedia>路径</qqmedia>");
|
|
1177
|
+
const staticInstruction = staticParts.join(" | ");
|
|
1178
|
+
|
|
1179
|
+
// 静态指引作为 systemPrompts 的首项注入
|
|
1180
|
+
systemPrompts.unshift(staticInstruction);
|
|
1181
|
+
|
|
1182
|
+
// --- 动态上下文(仅框架信封未覆盖的附件信息) ---
|
|
1183
|
+
const dynLines: string[] = [];
|
|
1184
|
+
if (imageUrls.length > 0) {
|
|
1185
|
+
dynLines.push(`- 图片: ${imageUrls.join(", ")}`);
|
|
1186
|
+
}
|
|
1187
|
+
if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
|
|
1188
|
+
dynLines.push(`- 语音: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`);
|
|
1189
|
+
}
|
|
1190
|
+
if (uniqueVoiceAsrReferTexts.length > 0) {
|
|
1191
|
+
dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
|
|
1192
|
+
}
|
|
1193
|
+
const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n" : "";
|
|
1194
|
+
|
|
1102
1195
|
// 命令直接透传,不注入上下文
|
|
1103
1196
|
const userMessage = `${quotePart}${userContent}`;
|
|
1104
1197
|
const agentBody = userContent.startsWith("/")
|
|
1105
1198
|
? userContent
|
|
1106
|
-
: systemPrompts.
|
|
1107
|
-
? `${contextInfo}\n\n${systemPrompts.join("\n")}\n\n${userMessage}`
|
|
1108
|
-
: `${contextInfo}\n\n${userMessage}`;
|
|
1199
|
+
: `${systemPrompts.join("\n")}\n\n${dynamicCtx}${userMessage}`;
|
|
1109
1200
|
|
|
1110
1201
|
log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
|
|
1111
1202
|
|
|
@@ -1230,7 +1321,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1230
1321
|
let hasResponse = false;
|
|
1231
1322
|
let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
|
|
1232
1323
|
let toolDeliverCount = 0; // tool deliver 计数
|
|
1233
|
-
const toolTexts: string[] = []; // 收集所有 tool deliver
|
|
1324
|
+
const toolTexts: string[] = []; // 收集所有 tool deliver 文本
|
|
1325
|
+
const toolMediaUrls: string[] = []; // 收集所有 tool deliver 媒体 URL
|
|
1234
1326
|
let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
|
|
1235
1327
|
const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
|
|
1236
1328
|
const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
|
|
@@ -1239,19 +1331,45 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1239
1331
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
1240
1332
|
let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
1241
1333
|
|
|
1242
|
-
//
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1334
|
+
// tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
|
|
1335
|
+
const sendToolFallback = async (): Promise<void> => {
|
|
1336
|
+
// 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
|
|
1337
|
+
if (toolMediaUrls.length > 0) {
|
|
1338
|
+
log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
|
|
1339
|
+
const mediaTimeout = 45000; // 单个媒体发送超时 45s
|
|
1340
|
+
for (const mediaUrl of toolMediaUrls) {
|
|
1341
|
+
try {
|
|
1342
|
+
const result = await Promise.race([
|
|
1343
|
+
sendMediaAuto({
|
|
1344
|
+
to: qualifiedTarget,
|
|
1345
|
+
text: "",
|
|
1346
|
+
mediaUrl,
|
|
1347
|
+
accountId: account.accountId,
|
|
1348
|
+
replyToId: event.messageId,
|
|
1349
|
+
account,
|
|
1350
|
+
}),
|
|
1351
|
+
new Promise<{ channel: string; error: string }>((resolve) =>
|
|
1352
|
+
setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)
|
|
1353
|
+
),
|
|
1354
|
+
]);
|
|
1355
|
+
if (result.error) {
|
|
1356
|
+
log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
|
|
1357
|
+
}
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
return;
|
|
1246
1363
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1364
|
+
// 其次转发工具产出的文本
|
|
1365
|
+
if (toolTexts.length > 0) {
|
|
1366
|
+
const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
|
|
1367
|
+
log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
|
|
1368
|
+
await sendErrorMessage(text);
|
|
1369
|
+
return;
|
|
1252
1370
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1371
|
+
// 既无媒体也无文本,静默处理(仅日志记录)
|
|
1372
|
+
log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
|
|
1255
1373
|
};
|
|
1256
1374
|
|
|
1257
1375
|
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
@@ -1285,7 +1403,41 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1285
1403
|
if (toolText) {
|
|
1286
1404
|
toolTexts.push(toolText);
|
|
1287
1405
|
}
|
|
1288
|
-
|
|
1406
|
+
// 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
|
|
1407
|
+
if (payload.mediaUrls?.length) {
|
|
1408
|
+
toolMediaUrls.push(...payload.mediaUrls);
|
|
1409
|
+
}
|
|
1410
|
+
if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
|
|
1411
|
+
toolMediaUrls.push(payload.mediaUrl);
|
|
1412
|
+
}
|
|
1413
|
+
log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
|
|
1414
|
+
|
|
1415
|
+
// block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
|
|
1416
|
+
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
|
1417
|
+
log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`);
|
|
1418
|
+
const urlsToSend = [...toolMediaUrls];
|
|
1419
|
+
toolMediaUrls.length = 0;
|
|
1420
|
+
for (const mediaUrl of urlsToSend) {
|
|
1421
|
+
try {
|
|
1422
|
+
const result = await sendMediaAuto({
|
|
1423
|
+
to: qualifiedTarget,
|
|
1424
|
+
text: "",
|
|
1425
|
+
mediaUrl,
|
|
1426
|
+
accountId: account.accountId,
|
|
1427
|
+
replyToId: event.messageId,
|
|
1428
|
+
account,
|
|
1429
|
+
});
|
|
1430
|
+
if (result.error) {
|
|
1431
|
+
log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
|
|
1432
|
+
} else {
|
|
1433
|
+
log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
|
|
1434
|
+
}
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1289
1441
|
|
|
1290
1442
|
// 兜底已发送,不再续期
|
|
1291
1443
|
if (toolFallbackSent) {
|
|
@@ -1309,17 +1461,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1309
1461
|
if (!hasBlockResponse && !toolFallbackSent) {
|
|
1310
1462
|
toolFallbackSent = true;
|
|
1311
1463
|
log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
|
|
1312
|
-
const fallback = formatToolFallback();
|
|
1313
1464
|
try {
|
|
1314
|
-
await
|
|
1315
|
-
if (event.type === "c2c") {
|
|
1316
|
-
await sendC2CMessage(token, event.senderId, fallback, event.messageId);
|
|
1317
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1318
|
-
await sendGroupMessage(token, event.groupOpenid, fallback, event.messageId);
|
|
1319
|
-
} else if (event.channelId) {
|
|
1320
|
-
await sendChannelMessage(token, event.channelId, fallback, event.messageId);
|
|
1321
|
-
}
|
|
1322
|
-
});
|
|
1465
|
+
await sendToolFallback();
|
|
1323
1466
|
} catch (sendErr) {
|
|
1324
1467
|
log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
|
|
1325
1468
|
}
|
|
@@ -1358,31 +1501,29 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1358
1501
|
let replyText = payload.text ?? "";
|
|
1359
1502
|
|
|
1360
1503
|
// ============ 媒体标签解析 ============
|
|
1361
|
-
//
|
|
1362
|
-
// <qqimg>路径</qqimg>
|
|
1363
|
-
// <qqvoice>路径</qqvoice>
|
|
1364
|
-
// <qqvideo>路径或URL</qqvideo>
|
|
1365
|
-
// <qqfile>路径</qqfile>
|
|
1504
|
+
// 支持五种标签:
|
|
1505
|
+
// <qqimg>路径</qqimg> — 图片
|
|
1506
|
+
// <qqvoice>路径</qqvoice> — 语音
|
|
1507
|
+
// <qqvideo>路径或URL</qqvideo> — 视频
|
|
1508
|
+
// <qqfile>路径</qqfile> — 文件
|
|
1509
|
+
// <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
|
|
1366
1510
|
// 按文本中出现的位置统一构建发送队列,保持顺序
|
|
1367
1511
|
|
|
1368
1512
|
// 预处理:纠正小模型常见的标签拼写错误和格式问题
|
|
1369
1513
|
replyText = normalizeMediaTags(replyText);
|
|
1370
1514
|
|
|
1371
|
-
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
|
|
1515
|
+
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
1372
1516
|
const mediaTagMatches = [...replyText.matchAll(mediaTagRegex)];
|
|
1373
1517
|
|
|
1374
1518
|
if (mediaTagMatches.length > 0) {
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
const videoCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqvideo").length;
|
|
1378
|
-
const fileCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqfile").length;
|
|
1379
|
-
log?.info(`[qqbot:${account.accountId}] Detected media tags: ${imgCount} <qqimg>, ${voiceCount} <qqvoice>, ${videoCount} <qqvideo>, ${fileCount} <qqfile>`);
|
|
1519
|
+
const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1]!.toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {} as Record<string, number>);
|
|
1520
|
+
log?.info(`[qqbot:${account.accountId}] Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
|
|
1380
1521
|
|
|
1381
1522
|
// 构建发送队列
|
|
1382
|
-
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file"; content: string }> = [];
|
|
1523
|
+
const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
|
|
1383
1524
|
|
|
1384
1525
|
let lastIndex = 0;
|
|
1385
|
-
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
|
|
1526
|
+
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
1386
1527
|
let match;
|
|
1387
1528
|
|
|
1388
1529
|
while ((match = mediaTagRegexWithIndex.exec(replyText)) !== null) {
|
|
@@ -1444,7 +1585,10 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1444
1585
|
}
|
|
1445
1586
|
|
|
1446
1587
|
if (mediaPath) {
|
|
1447
|
-
if (tagName === "
|
|
1588
|
+
if (tagName === "qqmedia") {
|
|
1589
|
+
sendQueue.push({ type: "media", content: mediaPath });
|
|
1590
|
+
log?.info(`[qqbot:${account.accountId}] Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
1591
|
+
} else if (tagName === "qqvoice") {
|
|
1448
1592
|
sendQueue.push({ type: "voice", content: mediaPath });
|
|
1449
1593
|
log?.info(`[qqbot:${account.accountId}] Found voice path in <qqvoice>: ${mediaPath}`);
|
|
1450
1594
|
} else if (tagName === "qqvideo") {
|
|
@@ -1470,10 +1614,17 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1470
1614
|
|
|
1471
1615
|
log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
1472
1616
|
|
|
1473
|
-
//
|
|
1617
|
+
// 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
|
|
1618
|
+
const mediaTarget: MediaTargetContext = {
|
|
1619
|
+
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
|
1620
|
+
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
|
|
1621
|
+
account,
|
|
1622
|
+
replyToId: event.messageId,
|
|
1623
|
+
logPrefix: `[qqbot:${account.accountId}]`,
|
|
1624
|
+
};
|
|
1625
|
+
|
|
1474
1626
|
for (const item of sendQueue) {
|
|
1475
1627
|
if (item.type === "text") {
|
|
1476
|
-
// 发送文本
|
|
1477
1628
|
try {
|
|
1478
1629
|
await sendWithTokenRetry(async (token) => {
|
|
1479
1630
|
const ref = consumeQuoteRef();
|
|
@@ -1490,250 +1641,56 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1490
1641
|
log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
|
|
1491
1642
|
}
|
|
1492
1643
|
} else if (item.type === "image") {
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
// 判断是本地文件还是 URL
|
|
1499
|
-
const isLocalPath = isLocalFilePath(imagePath);
|
|
1500
|
-
const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
|
|
1501
|
-
|
|
1502
|
-
if (isLocalPath) {
|
|
1503
|
-
// 本地文件:转换为 Base64 Data URL
|
|
1504
|
-
if (!(await fileExistsAsync(imagePath))) {
|
|
1505
|
-
log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`);
|
|
1506
|
-
await sendErrorMessage(`图片文件不存在: ${imagePath}`);
|
|
1507
|
-
continue;
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
// 文件大小校验
|
|
1511
|
-
const imgSizeCheck = checkFileSize(imagePath);
|
|
1512
|
-
if (!imgSizeCheck.ok) {
|
|
1513
|
-
log?.error(`[qqbot:${account.accountId}] ${imgSizeCheck.error}`);
|
|
1514
|
-
await sendErrorMessage(imgSizeCheck.error!);
|
|
1515
|
-
continue;
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// 大文件进度提示
|
|
1519
|
-
if (isLargeFile(imgSizeCheck.size)) {
|
|
1520
|
-
try {
|
|
1521
|
-
await sendWithTokenRetry(async (token) => {
|
|
1522
|
-
const hint = `⏳ 正在上传图片 (${formatFileSize(imgSizeCheck.size)})...`;
|
|
1523
|
-
if (event.type === "c2c") {
|
|
1524
|
-
await sendC2CMessage(token, event.senderId, hint, event.messageId);
|
|
1525
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1526
|
-
await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
|
|
1527
|
-
}
|
|
1528
|
-
});
|
|
1529
|
-
} catch {}
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
const fileBuffer = await readFileAsync(imagePath);
|
|
1533
|
-
const base64Data = fileBuffer.toString("base64");
|
|
1534
|
-
const ext = path.extname(imagePath).toLowerCase();
|
|
1535
|
-
const mimeTypes: Record<string, string> = {
|
|
1536
|
-
".jpg": "image/jpeg",
|
|
1537
|
-
".jpeg": "image/jpeg",
|
|
1538
|
-
".png": "image/png",
|
|
1539
|
-
".gif": "image/gif",
|
|
1540
|
-
".webp": "image/webp",
|
|
1541
|
-
".bmp": "image/bmp",
|
|
1542
|
-
};
|
|
1543
|
-
const mimeType = mimeTypes[ext];
|
|
1544
|
-
if (!mimeType) {
|
|
1545
|
-
log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
|
|
1546
|
-
await sendErrorMessage(`不支持的图片格式: ${ext}`);
|
|
1547
|
-
continue;
|
|
1548
|
-
}
|
|
1549
|
-
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
|
1550
|
-
log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
1551
|
-
} else if (!isHttpUrl) {
|
|
1552
|
-
log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`);
|
|
1553
|
-
continue;
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// 发送图片(传递原始本地路径以便 refIdx 缓存记录来源)
|
|
1557
|
-
const imgLocalPath = isLocalPath ? imagePath : undefined;
|
|
1558
|
-
await sendWithTokenRetry(async (token) => {
|
|
1559
|
-
if (event.type === "c2c") {
|
|
1560
|
-
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId, undefined, imgLocalPath);
|
|
1561
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1562
|
-
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
|
1563
|
-
} else if (event.channelId) {
|
|
1564
|
-
// 频道使用 Markdown 格式(如果是公网 URL)
|
|
1565
|
-
if (isHttpUrl) {
|
|
1566
|
-
await sendChannelMessage(token, event.channelId, ``, event.messageId);
|
|
1567
|
-
} else {
|
|
1568
|
-
// 频道不支持富媒体 Base64
|
|
1569
|
-
log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`);
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
});
|
|
1573
|
-
log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
|
|
1574
|
-
} catch (err) {
|
|
1575
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${err}`);
|
|
1576
|
-
await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`);
|
|
1644
|
+
const result = await sendPhoto(mediaTarget, item.content);
|
|
1645
|
+
if (result.error) {
|
|
1646
|
+
log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
|
|
1647
|
+
await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
|
|
1577
1648
|
}
|
|
1578
1649
|
} else if (item.type === "voice") {
|
|
1579
|
-
|
|
1580
|
-
const
|
|
1650
|
+
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
1651
|
+
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
|
1652
|
+
// 语音发送加外层超时保护,避免阻塞后续发送队列
|
|
1653
|
+
const voiceTimeout = 45000; // 45 秒(waitForFile 30s + 转码/上传 15s)
|
|
1581
1654
|
try {
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1655
|
+
const result = await Promise.race([
|
|
1656
|
+
sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
|
|
1657
|
+
new Promise<{ channel: string; error: string }>((resolve) =>
|
|
1658
|
+
setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout)
|
|
1659
|
+
),
|
|
1660
|
+
]);
|
|
1661
|
+
if (result.error) {
|
|
1662
|
+
log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
|
|
1663
|
+
await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
|
|
1588
1664
|
}
|
|
1589
|
-
|
|
1590
|
-
// 转换为 SILK 格式(QQ Bot API 语音只支持 SILK),支持配置直传格式跳过转换
|
|
1591
|
-
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
1592
|
-
const silkBase64 = await audioFileToSilkBase64(voicePath, uploadFormats);
|
|
1593
|
-
if (!silkBase64) {
|
|
1594
|
-
const ext = path.extname(voicePath).toLowerCase();
|
|
1595
|
-
log?.error(`[qqbot:${account.accountId}] Voice conversion to SILK failed: ${ext} (${fileSize} bytes). Check [audio-convert] logs for details.`);
|
|
1596
|
-
await sendErrorMessage(`语音格式转换失败,请稍后重试`);
|
|
1597
|
-
continue;
|
|
1598
|
-
}
|
|
1599
|
-
log?.info(`[qqbot:${account.accountId}] Voice file converted to SILK Base64 (${fileSize} bytes)`);
|
|
1600
|
-
|
|
1601
|
-
await sendWithTokenRetry(async (token) => {
|
|
1602
|
-
if (event.type === "c2c") {
|
|
1603
|
-
await sendC2CVoiceMessage(token, event.senderId, silkBase64!, event.messageId, undefined, voicePath);
|
|
1604
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1605
|
-
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64!, event.messageId);
|
|
1606
|
-
} else if (event.channelId) {
|
|
1607
|
-
await sendChannelMessage(token, event.channelId, `[语音消息暂不支持频道发送]`, event.messageId);
|
|
1608
|
-
}
|
|
1609
|
-
});
|
|
1610
|
-
log?.info(`[qqbot:${account.accountId}] Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
|
|
1611
1665
|
} catch (err) {
|
|
1612
|
-
log?.error(`[qqbot:${account.accountId}]
|
|
1666
|
+
log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
|
|
1613
1667
|
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1614
1668
|
}
|
|
1615
1669
|
} else if (item.type === "video") {
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
// 本地视频大文件进度提示
|
|
1622
|
-
if (!isHttpUrl) {
|
|
1623
|
-
const vidCheck = checkFileSize(videoPath);
|
|
1624
|
-
if (vidCheck.ok && isLargeFile(vidCheck.size)) {
|
|
1625
|
-
try {
|
|
1626
|
-
await sendWithTokenRetry(async (token) => {
|
|
1627
|
-
const hint = `⏳ 正在上传视频 (${formatFileSize(vidCheck.size)})...`;
|
|
1628
|
-
if (event.type === "c2c") {
|
|
1629
|
-
await sendC2CMessage(token, event.senderId, hint, event.messageId);
|
|
1630
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1631
|
-
await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
|
|
1632
|
-
}
|
|
1633
|
-
});
|
|
1634
|
-
} catch {}
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
await sendWithTokenRetry(async (token) => {
|
|
1639
|
-
if (isHttpUrl) {
|
|
1640
|
-
// 公网 URL
|
|
1641
|
-
if (event.type === "c2c") {
|
|
1642
|
-
await sendC2CVideoMessage(token, event.senderId, videoPath, undefined, event.messageId);
|
|
1643
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1644
|
-
await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
|
|
1645
|
-
} else if (event.channelId) {
|
|
1646
|
-
await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
|
|
1647
|
-
}
|
|
1648
|
-
} else {
|
|
1649
|
-
// 本地文件:读取为 Base64
|
|
1650
|
-
if (!(await fileExistsAsync(videoPath))) {
|
|
1651
|
-
throw new Error(`视频文件不存在: ${videoPath}`);
|
|
1652
|
-
}
|
|
1653
|
-
// 文件大小校验
|
|
1654
|
-
const vidSizeCheck = checkFileSize(videoPath);
|
|
1655
|
-
if (!vidSizeCheck.ok) {
|
|
1656
|
-
throw new Error(vidSizeCheck.error!);
|
|
1657
|
-
}
|
|
1658
|
-
const fileBuffer = await readFileAsync(videoPath);
|
|
1659
|
-
const videoBase64 = fileBuffer.toString("base64");
|
|
1660
|
-
log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
|
|
1661
|
-
|
|
1662
|
-
if (event.type === "c2c") {
|
|
1663
|
-
await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId, undefined, videoPath);
|
|
1664
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1665
|
-
await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
|
|
1666
|
-
} else if (event.channelId) {
|
|
1667
|
-
await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
});
|
|
1671
|
-
log?.info(`[qqbot:${account.accountId}] Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
|
|
1672
|
-
} catch (err) {
|
|
1673
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send video from <qqvideo>: ${err}`);
|
|
1674
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", err));
|
|
1670
|
+
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
1671
|
+
if (result.error) {
|
|
1672
|
+
log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
|
|
1673
|
+
await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
|
|
1675
1674
|
}
|
|
1676
1675
|
} else if (item.type === "file") {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
});
|
|
1696
|
-
} catch {}
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
await sendWithTokenRetry(async (token) => {
|
|
1701
|
-
if (isHttpUrl) {
|
|
1702
|
-
// 公网 URL
|
|
1703
|
-
if (event.type === "c2c") {
|
|
1704
|
-
await sendC2CFileMessage(token, event.senderId, undefined, filePath, event.messageId, fileName);
|
|
1705
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1706
|
-
await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
|
|
1707
|
-
} else if (event.channelId) {
|
|
1708
|
-
await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
|
|
1709
|
-
}
|
|
1710
|
-
} else {
|
|
1711
|
-
// 本地文件
|
|
1712
|
-
if (!(await fileExistsAsync(filePath))) {
|
|
1713
|
-
throw new Error(`文件不存在: ${filePath}`);
|
|
1714
|
-
}
|
|
1715
|
-
// 文件大小校验
|
|
1716
|
-
const flSizeCheck = checkFileSize(filePath);
|
|
1717
|
-
if (!flSizeCheck.ok) {
|
|
1718
|
-
throw new Error(flSizeCheck.error!);
|
|
1719
|
-
}
|
|
1720
|
-
const fileBuffer = await readFileAsync(filePath);
|
|
1721
|
-
const fileBase64 = fileBuffer.toString("base64");
|
|
1722
|
-
log?.info(`[qqbot:${account.accountId}] Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
|
|
1723
|
-
|
|
1724
|
-
if (event.type === "c2c") {
|
|
1725
|
-
await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName, filePath);
|
|
1726
|
-
} else if (event.type === "group" && event.groupOpenid) {
|
|
1727
|
-
await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
|
|
1728
|
-
} else if (event.channelId) {
|
|
1729
|
-
await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
});
|
|
1733
|
-
log?.info(`[qqbot:${account.accountId}] Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
|
|
1734
|
-
} catch (err) {
|
|
1735
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send file from <qqfile>: ${err}`);
|
|
1736
|
-
await sendErrorMessage(`文件发送失败: ${err}`);
|
|
1676
|
+
const result = await sendDocument(mediaTarget, item.content);
|
|
1677
|
+
if (result.error) {
|
|
1678
|
+
log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
|
|
1679
|
+
await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
|
|
1680
|
+
}
|
|
1681
|
+
} else if (item.type === "media") {
|
|
1682
|
+
// qqmedia: 自动根据扩展名路由到 sendPhoto/sendVoice/sendVideoMsg/sendDocument
|
|
1683
|
+
const result = await sendMediaAuto({
|
|
1684
|
+
to: qualifiedTarget,
|
|
1685
|
+
text: "",
|
|
1686
|
+
mediaUrl: item.content,
|
|
1687
|
+
accountId: account.accountId,
|
|
1688
|
+
replyToId: event.messageId,
|
|
1689
|
+
account,
|
|
1690
|
+
});
|
|
1691
|
+
if (result.error) {
|
|
1692
|
+
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
|
|
1693
|
+
await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
|
|
1737
1694
|
}
|
|
1738
1695
|
}
|
|
1739
1696
|
}
|
|
@@ -1755,7 +1712,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1755
1712
|
if (payloadResult.error) {
|
|
1756
1713
|
// 载荷解析失败,发送错误提示
|
|
1757
1714
|
log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
|
|
1758
|
-
await sendErrorMessage(
|
|
1715
|
+
await sendErrorMessage(MSG.PAYLOAD_PARSE_ERROR);
|
|
1759
1716
|
return;
|
|
1760
1717
|
}
|
|
1761
1718
|
|
|
@@ -1808,12 +1765,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1808
1765
|
if (parsedPayload.source === "file") {
|
|
1809
1766
|
try {
|
|
1810
1767
|
if (!(await fileExistsAsync(imageUrl))) {
|
|
1811
|
-
await sendErrorMessage(
|
|
1768
|
+
await sendErrorMessage(MSG.IMAGE_NOT_FOUND);
|
|
1812
1769
|
return;
|
|
1813
1770
|
}
|
|
1814
1771
|
const imgSzCheck = checkFileSize(imageUrl);
|
|
1815
1772
|
if (!imgSzCheck.ok) {
|
|
1816
|
-
await sendErrorMessage(
|
|
1773
|
+
await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
|
|
1817
1774
|
return;
|
|
1818
1775
|
}
|
|
1819
1776
|
const fileBuffer = await readFileAsync(imageUrl);
|
|
@@ -1829,14 +1786,14 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1829
1786
|
};
|
|
1830
1787
|
const mimeType = mimeTypes[ext];
|
|
1831
1788
|
if (!mimeType) {
|
|
1832
|
-
await sendErrorMessage(
|
|
1789
|
+
await sendErrorMessage(MSG.IMAGE_FORMAT_UNSUPPORTED(ext));
|
|
1833
1790
|
return;
|
|
1834
1791
|
}
|
|
1835
1792
|
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
|
1836
1793
|
log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
1837
1794
|
} catch (readErr) {
|
|
1838
1795
|
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
|
|
1839
|
-
await sendErrorMessage(
|
|
1796
|
+
await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
|
|
1840
1797
|
return;
|
|
1841
1798
|
}
|
|
1842
1799
|
}
|
|
@@ -1875,13 +1832,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1875
1832
|
// TTS 语音发送:文字 → PCM → SILK → QQ 语音
|
|
1876
1833
|
try {
|
|
1877
1834
|
const ttsText = parsedPayload.caption || parsedPayload.path;
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1835
|
+
if (!ttsText?.trim()) {
|
|
1836
|
+
await sendErrorMessage(MSG.VOICE_MISSING_TEXT);
|
|
1837
|
+
} else {
|
|
1838
|
+
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
|
1839
|
+
if (!ttsCfg) {
|
|
1840
|
+
log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
|
|
1841
|
+
await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
|
|
1885
1842
|
} else {
|
|
1886
1843
|
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
1887
1844
|
const ttsDir = getQQBotDataDir("tts");
|
|
@@ -1894,7 +1851,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1894
1851
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1895
1852
|
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
|
|
1896
1853
|
} else if (event.channelId) {
|
|
1897
|
-
await sendChannelMessage(token, event.channelId,
|
|
1854
|
+
await sendChannelMessage(token, event.channelId, `${MSG.VOICE_CHANNEL_UNSUPPORTED}\n${ttsText}`, event.messageId);
|
|
1898
1855
|
}
|
|
1899
1856
|
});
|
|
1900
1857
|
log?.info(`[qqbot:${account.accountId}] Voice message sent`);
|
|
@@ -1902,14 +1859,14 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1902
1859
|
}
|
|
1903
1860
|
} catch (err) {
|
|
1904
1861
|
log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
|
|
1905
|
-
await sendErrorMessage(
|
|
1862
|
+
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1906
1863
|
}
|
|
1907
1864
|
} else if (parsedPayload.mediaType === "video") {
|
|
1908
1865
|
// 视频发送:支持公网 URL 和本地文件
|
|
1909
1866
|
try {
|
|
1910
1867
|
const videoPath = normalizePath(parsedPayload.path ?? "");
|
|
1911
1868
|
if (!videoPath?.trim()) {
|
|
1912
|
-
await sendErrorMessage(
|
|
1869
|
+
await sendErrorMessage(MSG.VIDEO_MISSING_PATH);
|
|
1913
1870
|
} else {
|
|
1914
1871
|
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
|
|
1915
1872
|
log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
|
|
@@ -1922,7 +1879,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1922
1879
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1923
1880
|
await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
|
|
1924
1881
|
} else if (event.channelId) {
|
|
1925
|
-
await sendChannelMessage(token, event.channelId,
|
|
1882
|
+
await sendChannelMessage(token, event.channelId, MSG.VIDEO_CHANNEL_UNSUPPORTED, event.messageId);
|
|
1926
1883
|
}
|
|
1927
1884
|
} else {
|
|
1928
1885
|
// 本地文件:读取为 Base64
|
|
@@ -1942,7 +1899,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1942
1899
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1943
1900
|
await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
|
|
1944
1901
|
} else if (event.channelId) {
|
|
1945
|
-
await sendChannelMessage(token, event.channelId,
|
|
1902
|
+
await sendChannelMessage(token, event.channelId, MSG.VIDEO_CHANNEL_UNSUPPORTED, event.messageId);
|
|
1946
1903
|
}
|
|
1947
1904
|
}
|
|
1948
1905
|
});
|
|
@@ -1970,7 +1927,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1970
1927
|
try {
|
|
1971
1928
|
const filePath = normalizePath(parsedPayload.path ?? "");
|
|
1972
1929
|
if (!filePath?.trim()) {
|
|
1973
|
-
await sendErrorMessage(
|
|
1930
|
+
await sendErrorMessage(MSG.FILE_MISSING_PATH);
|
|
1974
1931
|
} else {
|
|
1975
1932
|
const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
|
|
1976
1933
|
const fileName = sanitizeFileName(path.basename(filePath));
|
|
@@ -1983,7 +1940,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1983
1940
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1984
1941
|
await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
|
|
1985
1942
|
} else if (event.channelId) {
|
|
1986
|
-
await sendChannelMessage(token, event.channelId,
|
|
1943
|
+
await sendChannelMessage(token, event.channelId, MSG.FILE_CHANNEL_UNSUPPORTED, event.messageId);
|
|
1987
1944
|
}
|
|
1988
1945
|
} else {
|
|
1989
1946
|
if (!(await fileExistsAsync(filePath))) {
|
|
@@ -2000,7 +1957,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2000
1957
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
2001
1958
|
await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
|
|
2002
1959
|
} else if (event.channelId) {
|
|
2003
|
-
await sendChannelMessage(token, event.channelId,
|
|
1960
|
+
await sendChannelMessage(token, event.channelId, MSG.FILE_CHANNEL_UNSUPPORTED, event.messageId);
|
|
2004
1961
|
}
|
|
2005
1962
|
}
|
|
2006
1963
|
});
|
|
@@ -2012,7 +1969,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2012
1969
|
}
|
|
2013
1970
|
} else {
|
|
2014
1971
|
log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
|
|
2015
|
-
await sendErrorMessage(
|
|
1972
|
+
await sendErrorMessage(MSG.UNSUPPORTED_MEDIA_TYPE);
|
|
2016
1973
|
}
|
|
2017
1974
|
|
|
2018
1975
|
// 记录活动并返回
|
|
@@ -2025,20 +1982,20 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2025
1982
|
} else {
|
|
2026
1983
|
// 未知的载荷类型
|
|
2027
1984
|
log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`);
|
|
2028
|
-
await sendErrorMessage(
|
|
1985
|
+
await sendErrorMessage(MSG.UNSUPPORTED_PAYLOAD_TYPE);
|
|
2029
1986
|
return;
|
|
2030
1987
|
}
|
|
2031
1988
|
}
|
|
2032
1989
|
}
|
|
2033
1990
|
|
|
2034
1991
|
// ============ 非结构化消息:简化处理 ============
|
|
2035
|
-
// 📝 设计原则:JSON payload (QQBOT_PAYLOAD) 是发送本地图片的唯一方式
|
|
2036
|
-
// 非结构化消息只处理:公网 URL (http/https) 和 Base64 Data URL
|
|
2037
1992
|
const imageUrls: string[] = [];
|
|
1993
|
+
const localMediaToSend: string[] = []; // 本地路径走 sendMedia 自动路由
|
|
2038
1994
|
|
|
2039
1995
|
/**
|
|
2040
|
-
*
|
|
2041
|
-
*
|
|
1996
|
+
* 收集媒体 URL/路径
|
|
1997
|
+
* - 公网 URL / Base64 Data URL → 图片处理管线
|
|
1998
|
+
* - 本地文件路径 → sendMedia 自动路由(根据扩展名识别类型)
|
|
2042
1999
|
*/
|
|
2043
2000
|
const collectImageUrl = (url: string | undefined | null): boolean => {
|
|
2044
2001
|
if (!url) return false;
|
|
@@ -2058,24 +2015,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2058
2015
|
return true;
|
|
2059
2016
|
}
|
|
2060
2017
|
|
|
2061
|
-
//
|
|
2018
|
+
// 本地文件路径:走 sendMedia 自动路由
|
|
2062
2019
|
if (isLocalFilePath(url)) {
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
let mediaDesc = "图片";
|
|
2067
|
-
if (isAudioFile(url)) {
|
|
2068
|
-
suggestedTag = "qqvoice";
|
|
2069
|
-
mediaDesc = "语音";
|
|
2070
|
-
} else if (VIDEO_EXTS.includes(ext)) {
|
|
2071
|
-
suggestedTag = "qqvideo";
|
|
2072
|
-
mediaDesc = "视频";
|
|
2073
|
-
} else if (![".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) {
|
|
2074
|
-
suggestedTag = "qqfile";
|
|
2075
|
-
mediaDesc = "文件";
|
|
2020
|
+
if (!localMediaToSend.includes(url)) {
|
|
2021
|
+
localMediaToSend.push(url);
|
|
2022
|
+
log?.info(`[qqbot:${account.accountId}] Collected local media for auto-routing: ${url}`);
|
|
2076
2023
|
}
|
|
2077
|
-
|
|
2078
|
-
log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <${suggestedTag}>${url}</${suggestedTag}> tag to send local ${mediaDesc}`);
|
|
2024
|
+
return true;
|
|
2079
2025
|
}
|
|
2080
2026
|
return false;
|
|
2081
2027
|
};
|
|
@@ -2101,24 +2047,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2101
2047
|
// 公网 URL:收集并处理
|
|
2102
2048
|
imageUrls.push(url);
|
|
2103
2049
|
log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
|
|
2104
|
-
} else if (
|
|
2105
|
-
//
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
let mediaDesc = "图片";
|
|
2110
|
-
if (isAudioFile(url)) {
|
|
2111
|
-
suggestedTag = "qqvoice";
|
|
2112
|
-
mediaDesc = "语音";
|
|
2113
|
-
} else if (VIDEO_EXTS.includes(ext)) {
|
|
2114
|
-
suggestedTag = "qqvideo";
|
|
2115
|
-
mediaDesc = "视频";
|
|
2116
|
-
} else if (![".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) {
|
|
2117
|
-
suggestedTag = "qqfile";
|
|
2118
|
-
mediaDesc = "文件";
|
|
2050
|
+
} else if (isLocalFilePath(url)) {
|
|
2051
|
+
// 本地路径:走 sendMedia 自动路由
|
|
2052
|
+
if (!localMediaToSend.includes(url)) {
|
|
2053
|
+
localMediaToSend.push(url);
|
|
2054
|
+
log?.info(`[qqbot:${account.accountId}] Collected local media from markdown for auto-routing: ${url}`);
|
|
2119
2055
|
}
|
|
2120
|
-
log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
|
|
2121
|
-
log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <${suggestedTag}>${url}</${suggestedTag}> tag to send local ${mediaDesc}`);
|
|
2122
2056
|
}
|
|
2123
2057
|
}
|
|
2124
2058
|
}
|
|
@@ -2267,7 +2201,14 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2267
2201
|
}
|
|
2268
2202
|
}
|
|
2269
2203
|
} else {
|
|
2270
|
-
// ============
|
|
2204
|
+
// ============ 普通文本模式:使用 sendPhoto 发送图片(内置 URL fallback) ============
|
|
2205
|
+
const imgMediaTarget: MediaTargetContext = {
|
|
2206
|
+
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
|
2207
|
+
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
|
|
2208
|
+
account,
|
|
2209
|
+
replyToId: event.messageId,
|
|
2210
|
+
logPrefix: `[qqbot:${account.accountId}]`,
|
|
2211
|
+
};
|
|
2271
2212
|
// 从文本中移除所有图片相关内容
|
|
2272
2213
|
for (const match of mdMatches) {
|
|
2273
2214
|
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
|
@@ -2282,20 +2223,15 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2282
2223
|
}
|
|
2283
2224
|
|
|
2284
2225
|
try {
|
|
2285
|
-
//
|
|
2226
|
+
// 发送图片(通过 sendPhoto,内置 URL 直传 → 下载 fallback)
|
|
2286
2227
|
for (const imageUrl of imageUrls) {
|
|
2287
2228
|
try {
|
|
2288
|
-
await
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
// 频道暂不支持富媒体,发送文本 URL
|
|
2295
|
-
await sendChannelMessage(token, event.channelId, imageUrl, event.messageId);
|
|
2296
|
-
}
|
|
2297
|
-
});
|
|
2298
|
-
log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`);
|
|
2229
|
+
const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
|
|
2230
|
+
if (imgResult.error) {
|
|
2231
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgResult.error}`);
|
|
2232
|
+
} else {
|
|
2233
|
+
log?.info(`[qqbot:${account.accountId}] Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
|
|
2234
|
+
}
|
|
2299
2235
|
} catch (imgErr) {
|
|
2300
2236
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
|
2301
2237
|
}
|
|
@@ -2320,6 +2256,58 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2320
2256
|
}
|
|
2321
2257
|
}
|
|
2322
2258
|
|
|
2259
|
+
// 发送 localMediaToSend 中收集到的本地媒体(由 payload.mediaUrl 或 markdown 本地路径触发)
|
|
2260
|
+
if (localMediaToSend.length > 0) {
|
|
2261
|
+
log?.info(`[qqbot:${account.accountId}] Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
|
|
2262
|
+
for (const mediaPath of localMediaToSend) {
|
|
2263
|
+
try {
|
|
2264
|
+
const result = await sendMediaAuto({
|
|
2265
|
+
to: qualifiedTarget,
|
|
2266
|
+
text: "",
|
|
2267
|
+
mediaUrl: mediaPath,
|
|
2268
|
+
accountId: account.accountId,
|
|
2269
|
+
replyToId: event.messageId,
|
|
2270
|
+
account,
|
|
2271
|
+
});
|
|
2272
|
+
if (result.error) {
|
|
2273
|
+
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error for ${mediaPath}: ${result.error}`);
|
|
2274
|
+
} else {
|
|
2275
|
+
log?.info(`[qqbot:${account.accountId}] Sent local media: ${mediaPath}`);
|
|
2276
|
+
}
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) failed for ${mediaPath}: ${err}`);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// ============ 转发 tool 阶段收集的媒体(TTS 语音、生成图片等) ============
|
|
2284
|
+
// block 回复已发送,但 tool deliver 阶段收集的媒体 URL 未被发送(tool deliver 只收集不发送)。
|
|
2285
|
+
// 此处主动转发,避免工具产出的媒体被静默丢弃。
|
|
2286
|
+
if (toolMediaUrls.length > 0) {
|
|
2287
|
+
log?.info(`[qqbot:${account.accountId}] Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
|
|
2288
|
+
for (const mediaUrl of toolMediaUrls) {
|
|
2289
|
+
try {
|
|
2290
|
+
const result = await sendMediaAuto({
|
|
2291
|
+
to: qualifiedTarget,
|
|
2292
|
+
text: "",
|
|
2293
|
+
mediaUrl,
|
|
2294
|
+
accountId: account.accountId,
|
|
2295
|
+
replyToId: event.messageId,
|
|
2296
|
+
account,
|
|
2297
|
+
});
|
|
2298
|
+
if (result.error) {
|
|
2299
|
+
log?.error(`[qqbot:${account.accountId}] Tool media forward error: ${result.error}`);
|
|
2300
|
+
} else {
|
|
2301
|
+
log?.info(`[qqbot:${account.accountId}] Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
|
|
2302
|
+
}
|
|
2303
|
+
} catch (err) {
|
|
2304
|
+
log?.error(`[qqbot:${account.accountId}] Tool media forward failed: ${err}`);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
// 清空已转发的 URL,避免 tool-only 兜底重复发送
|
|
2308
|
+
toolMediaUrls.length = 0;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2323
2311
|
pluginRuntime.channel.activity.record({
|
|
2324
2312
|
channel: "qqbot",
|
|
2325
2313
|
accountId: account.accountId,
|
|
@@ -2337,9 +2325,9 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2337
2325
|
// 发送错误提示给用户,显示完整错误信息
|
|
2338
2326
|
const errMsg = String(err);
|
|
2339
2327
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
|
2340
|
-
await sendErrorMessage(
|
|
2328
|
+
await sendErrorMessage(MSG.AI_AUTH_ERROR);
|
|
2341
2329
|
} else {
|
|
2342
|
-
await sendErrorMessage(
|
|
2330
|
+
await sendErrorMessage(MSG.AI_PROCESS_ERROR);
|
|
2343
2331
|
}
|
|
2344
2332
|
},
|
|
2345
2333
|
},
|
|
@@ -2357,7 +2345,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2357
2345
|
}
|
|
2358
2346
|
if (!hasResponse) {
|
|
2359
2347
|
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
|
|
2360
|
-
await sendErrorMessage(
|
|
2348
|
+
await sendErrorMessage(MSG.TIMEOUT_HINT);
|
|
2361
2349
|
}
|
|
2362
2350
|
} finally {
|
|
2363
2351
|
// 清理 tool-only 兜底定时器
|
|
@@ -2369,13 +2357,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2369
2357
|
if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
|
|
2370
2358
|
toolFallbackSent = true;
|
|
2371
2359
|
log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
|
|
2372
|
-
|
|
2373
|
-
await sendErrorMessage(fallback);
|
|
2360
|
+
await sendToolFallback();
|
|
2374
2361
|
}
|
|
2375
2362
|
}
|
|
2376
2363
|
} catch (err) {
|
|
2377
2364
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
2378
|
-
await sendErrorMessage(
|
|
2365
|
+
await sendErrorMessage(MSG.GENERIC_ERROR);
|
|
2379
2366
|
}
|
|
2380
2367
|
};
|
|
2381
2368
|
|
|
@@ -2406,7 +2393,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2406
2393
|
sessionId,
|
|
2407
2394
|
lastSeq,
|
|
2408
2395
|
lastConnectedAt: lastConnectTime,
|
|
2409
|
-
intentLevelIndex:
|
|
2396
|
+
intentLevelIndex: 0,
|
|
2410
2397
|
accountId: account.accountId,
|
|
2411
2398
|
savedAt: Date.now(),
|
|
2412
2399
|
appId: account.appId,
|
|
@@ -2432,16 +2419,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2432
2419
|
},
|
|
2433
2420
|
}));
|
|
2434
2421
|
} else {
|
|
2435
|
-
// 新连接,发送 Identify
|
|
2436
|
-
|
|
2437
|
-
const levelToUse = lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex;
|
|
2438
|
-
const intentLevel = INTENT_LEVELS[Math.min(levelToUse, INTENT_LEVELS.length - 1)];
|
|
2439
|
-
log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intentLevel.intents} (${intentLevel.description})`);
|
|
2422
|
+
// 新连接,发送 Identify,始终使用完整权限
|
|
2423
|
+
log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${FULL_INTENTS} (${FULL_INTENTS_DESC})`);
|
|
2440
2424
|
ws.send(JSON.stringify({
|
|
2441
2425
|
op: 2,
|
|
2442
2426
|
d: {
|
|
2443
2427
|
token: `QQBot ${accessToken}`,
|
|
2444
|
-
intents:
|
|
2428
|
+
intents: FULL_INTENTS,
|
|
2445
2429
|
shard: [0, 1],
|
|
2446
2430
|
},
|
|
2447
2431
|
}));
|
|
@@ -2463,30 +2447,41 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2463
2447
|
if (t === "READY") {
|
|
2464
2448
|
const readyData = d as { session_id: string };
|
|
2465
2449
|
sessionId = readyData.session_id;
|
|
2466
|
-
|
|
2467
|
-
lastSuccessfulIntentLevel = intentLevelIndex;
|
|
2468
|
-
const successLevel = INTENT_LEVELS[intentLevelIndex];
|
|
2469
|
-
log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
|
|
2450
|
+
log?.info(`[qqbot:${account.accountId}] Ready with ${FULL_INTENTS_DESC}, session: ${sessionId}`);
|
|
2470
2451
|
// P1-2: 保存新的 Session 状态
|
|
2471
2452
|
saveSession({
|
|
2472
2453
|
sessionId,
|
|
2473
2454
|
lastSeq,
|
|
2474
2455
|
lastConnectedAt: Date.now(),
|
|
2475
|
-
intentLevelIndex,
|
|
2456
|
+
intentLevelIndex: 0,
|
|
2476
2457
|
accountId: account.accountId,
|
|
2477
2458
|
savedAt: Date.now(),
|
|
2478
2459
|
appId: account.appId,
|
|
2479
2460
|
});
|
|
2480
2461
|
onReady?.(d);
|
|
2462
|
+
|
|
2463
|
+
// 仅 startGateway 后的首次 READY 才发送上线通知
|
|
2464
|
+
// ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
|
|
2465
|
+
if (!isFirstReadyGlobal) {
|
|
2466
|
+
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
|
|
2467
|
+
} else {
|
|
2468
|
+
isFirstReadyGlobal = false;
|
|
2469
|
+
sendStartupGreetings("READY");
|
|
2470
|
+
} // end isFirstReady
|
|
2481
2471
|
} else if (t === "RESUMED") {
|
|
2482
2472
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2473
|
+
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2474
|
+
if (isFirstReadyGlobal) {
|
|
2475
|
+
isFirstReadyGlobal = false;
|
|
2476
|
+
sendStartupGreetings("RESUMED");
|
|
2477
|
+
}
|
|
2483
2478
|
// P1-2: 更新 Session 连接时间
|
|
2484
2479
|
if (sessionId) {
|
|
2485
2480
|
saveSession({
|
|
2486
2481
|
sessionId,
|
|
2487
2482
|
lastSeq,
|
|
2488
2483
|
lastConnectedAt: Date.now(),
|
|
2489
|
-
intentLevelIndex:
|
|
2484
|
+
intentLevelIndex: 0,
|
|
2490
2485
|
accountId: account.accountId,
|
|
2491
2486
|
savedAt: Date.now(),
|
|
2492
2487
|
appId: account.appId,
|
|
@@ -2502,8 +2497,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2502
2497
|
});
|
|
2503
2498
|
// 解析引用索引
|
|
2504
2499
|
const c2cRefs = parseRefIndices(event.message_scene?.ext);
|
|
2505
|
-
//
|
|
2506
|
-
|
|
2500
|
+
// 斜杠指令拦截 → 不匹配则入队
|
|
2501
|
+
trySlashCommandOrEnqueue({
|
|
2507
2502
|
type: "c2c",
|
|
2508
2503
|
senderId: event.author.user_openid,
|
|
2509
2504
|
content: event.content,
|
|
@@ -2523,7 +2518,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2523
2518
|
accountId: account.accountId,
|
|
2524
2519
|
});
|
|
2525
2520
|
const guildRefs = parseRefIndices((event as any).message_scene?.ext);
|
|
2526
|
-
|
|
2521
|
+
trySlashCommandOrEnqueue({
|
|
2527
2522
|
type: "guild",
|
|
2528
2523
|
senderId: event.author.id,
|
|
2529
2524
|
senderName: event.author.username,
|
|
@@ -2546,7 +2541,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2546
2541
|
accountId: account.accountId,
|
|
2547
2542
|
});
|
|
2548
2543
|
const dmRefs = parseRefIndices((event as any).message_scene?.ext);
|
|
2549
|
-
|
|
2544
|
+
trySlashCommandOrEnqueue({
|
|
2550
2545
|
type: "dm",
|
|
2551
2546
|
senderId: event.author.id,
|
|
2552
2547
|
senderName: event.author.username,
|
|
@@ -2568,7 +2563,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2568
2563
|
accountId: account.accountId,
|
|
2569
2564
|
});
|
|
2570
2565
|
const groupRefs = parseRefIndices(event.message_scene?.ext);
|
|
2571
|
-
|
|
2566
|
+
trySlashCommandOrEnqueue({
|
|
2572
2567
|
type: "group",
|
|
2573
2568
|
senderId: event.author.member_openid,
|
|
2574
2569
|
content: event.content,
|
|
@@ -2594,25 +2589,15 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2594
2589
|
|
|
2595
2590
|
case 9: // Invalid Session
|
|
2596
2591
|
const canResume = d as boolean;
|
|
2597
|
-
|
|
2598
|
-
log?.error(`[qqbot:${account.accountId}] Invalid session (${currentLevel.description}), can resume: ${canResume}, raw: ${rawData}`);
|
|
2592
|
+
log?.error(`[qqbot:${account.accountId}] Invalid session (${FULL_INTENTS_DESC}), can resume: ${canResume}, raw: ${rawData}`);
|
|
2599
2593
|
|
|
2600
2594
|
if (!canResume) {
|
|
2601
2595
|
sessionId = null;
|
|
2602
2596
|
lastSeq = null;
|
|
2603
2597
|
// P1-2: 清除持久化的 Session
|
|
2604
2598
|
clearSession(account.accountId);
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
if (intentLevelIndex < INTENT_LEVELS.length - 1) {
|
|
2608
|
-
intentLevelIndex++;
|
|
2609
|
-
const nextLevel = INTENT_LEVELS[intentLevelIndex];
|
|
2610
|
-
log?.info(`[qqbot:${account.accountId}] Downgrading intents to: ${nextLevel.description}`);
|
|
2611
|
-
} else {
|
|
2612
|
-
// 已经是最低权限级别了
|
|
2613
|
-
log?.error(`[qqbot:${account.accountId}] All intent levels failed. Please check AppID/Secret.`);
|
|
2614
|
-
shouldRefreshToken = true;
|
|
2615
|
-
}
|
|
2599
|
+
shouldRefreshToken = true;
|
|
2600
|
+
log?.info(`[qqbot:${account.accountId}] Will refresh token and retry with full intents (${FULL_INTENTS_DESC})`);
|
|
2616
2601
|
}
|
|
2617
2602
|
cleanup();
|
|
2618
2603
|
// Invalid Session 后等待一段时间再重连
|