@tencent-connect/openclaw-qqbot 1.5.6 → 1.5.7
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 +46 -146
- package/README.zh.md +46 -146
- package/bin/qqbot-cli.js +6 -6
- 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 +211 -0
- package/dist/src/gateway.js +109 -92
- package/dist/src/slash-commands.d.ts +48 -0
- package/dist/src/slash-commands.js +212 -0
- package/dist/src/utils/audio-convert.d.ts +0 -6
- package/dist/src/utils/audio-convert.js +0 -89
- package/package.json +1 -1
- package/scripts/{upgrade.sh → cleanup-legacy-plugins.sh} +3 -3
- package/scripts/set-markdown.sh +20 -20
- package/scripts/upgrade-via-npm.sh +204 -0
- package/scripts/{upgrade-and-run.sh → upgrade-via-source.sh} +60 -44
- package/src/api.ts +104 -24
- package/src/channel.ts +2 -1
- package/src/gateway.ts +229 -33
- package/src/image-server.ts +5 -2
- package/src/outbound.ts +32 -26
- package/src/ref-index-store.ts +358 -0
- package/src/types.ts +6 -0
- package/src/utils/platform.ts +16 -2
- package/scripts/draw_arch.py +0 -174
- package/scripts/npm-upgrade.sh +0 -120
- package/scripts/pull-latest.sh +0 -316
package/src/gateway.ts
CHANGED
|
@@ -2,10 +2,11 @@ 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 } from "./api.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";
|
|
6
6
|
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
|
7
7
|
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
8
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
9
|
+
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
|
|
9
10
|
import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
|
10
11
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
|
|
11
12
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
|
|
@@ -301,7 +302,55 @@ interface QueuedMessage {
|
|
|
301
302
|
channelId?: string;
|
|
302
303
|
guildId?: string;
|
|
303
304
|
groupOpenid?: string;
|
|
304
|
-
attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string; asr_refer_text?: string
|
|
305
|
+
attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string; asr_refer_text?: string }>;
|
|
306
|
+
/** 被引用消息的 refIdx(用户引用了哪条历史消息) */
|
|
307
|
+
refMsgIdx?: string;
|
|
308
|
+
/** 当前消息自身的 refIdx(供将来被引用) */
|
|
309
|
+
msgIdx?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* 从 message_scene.ext 数组中解析引用索引
|
|
314
|
+
* ext 格式示例: ["", "ref_msg_idx=REFIDX_xxx", "msg_idx=REFIDX_yyy"]
|
|
315
|
+
*/
|
|
316
|
+
function parseRefIndices(ext?: string[]): { refMsgIdx?: string; msgIdx?: string } {
|
|
317
|
+
if (!ext || ext.length === 0) return {};
|
|
318
|
+
let refMsgIdx: string | undefined;
|
|
319
|
+
let msgIdx: string | undefined;
|
|
320
|
+
for (const item of ext) {
|
|
321
|
+
if (item.startsWith("ref_msg_idx=")) {
|
|
322
|
+
refMsgIdx = item.slice("ref_msg_idx=".length);
|
|
323
|
+
} else if (item.startsWith("msg_idx=")) {
|
|
324
|
+
msgIdx = item.slice("msg_idx=".length);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { refMsgIdx, msgIdx };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 从附件列表中构建附件摘要(用于引用索引缓存)
|
|
332
|
+
* @param attachments 原始附件列表
|
|
333
|
+
* @param localPaths 与 attachments 一一对应的本地路径(下载后产生)
|
|
334
|
+
*/
|
|
335
|
+
function buildAttachmentSummaries(
|
|
336
|
+
attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string }>,
|
|
337
|
+
localPaths?: Array<string | null>,
|
|
338
|
+
): RefAttachmentSummary[] | undefined {
|
|
339
|
+
if (!attachments || attachments.length === 0) return undefined;
|
|
340
|
+
return attachments.map((att, idx) => {
|
|
341
|
+
const ct = att.content_type?.toLowerCase() ?? "";
|
|
342
|
+
let type: RefAttachmentSummary["type"] = "unknown";
|
|
343
|
+
if (ct.startsWith("image/")) type = "image";
|
|
344
|
+
else if (ct === "voice" || ct.startsWith("audio/") || ct.includes("silk") || ct.includes("amr")) type = "voice";
|
|
345
|
+
else if (ct.startsWith("video/")) type = "video";
|
|
346
|
+
else if (ct.startsWith("application/") || ct.startsWith("text/")) type = "file";
|
|
347
|
+
return {
|
|
348
|
+
type,
|
|
349
|
+
filename: att.filename,
|
|
350
|
+
contentType: att.content_type,
|
|
351
|
+
localPath: localPaths?.[idx] ?? undefined,
|
|
352
|
+
};
|
|
353
|
+
});
|
|
305
354
|
}
|
|
306
355
|
|
|
307
356
|
/**
|
|
@@ -354,6 +403,40 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
354
403
|
});
|
|
355
404
|
log?.info(`[qqbot:${account.accountId}] API config: markdownSupport=${account.markdownSupport === true}`);
|
|
356
405
|
|
|
406
|
+
// 注册出站消息 refIdx 缓存钩子
|
|
407
|
+
// 所有消息发送函数在拿到 QQ 回包后,如果含 ref_idx 则自动回调此处缓存
|
|
408
|
+
onMessageSent((refIdx, meta) => {
|
|
409
|
+
log?.info(`[qqbot:${account.accountId}] onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`);
|
|
410
|
+
const attachments: RefAttachmentSummary[] = [];
|
|
411
|
+
if (meta.mediaType) {
|
|
412
|
+
const localPath = meta.mediaLocalPath;
|
|
413
|
+
// filename 取路径的 basename,如果没有路径信息则留空
|
|
414
|
+
const filename = localPath ? path.basename(localPath) : undefined;
|
|
415
|
+
const attachment: RefAttachmentSummary = {
|
|
416
|
+
type: meta.mediaType,
|
|
417
|
+
...(localPath ? { localPath } : {}),
|
|
418
|
+
...(filename ? { filename } : {}),
|
|
419
|
+
...(meta.mediaUrl ? { url: meta.mediaUrl } : {}),
|
|
420
|
+
};
|
|
421
|
+
// 如果是语音消息且有 TTS 原文本,保存到 transcript 并标记来源为 tts
|
|
422
|
+
if (meta.mediaType === "voice" && meta.ttsText) {
|
|
423
|
+
attachment.transcript = meta.ttsText;
|
|
424
|
+
attachment.transcriptSource = "tts";
|
|
425
|
+
log?.info(`[qqbot:${account.accountId}] Saving voice transcript (TTS): ${meta.ttsText.slice(0, 50)}`);
|
|
426
|
+
}
|
|
427
|
+
attachments.push(attachment);
|
|
428
|
+
}
|
|
429
|
+
setRefIndex(refIdx, {
|
|
430
|
+
content: (meta.text ?? "").slice(0, 500),
|
|
431
|
+
senderId: account.accountId,
|
|
432
|
+
senderName: account.accountId,
|
|
433
|
+
timestamp: Date.now(),
|
|
434
|
+
isBot: true,
|
|
435
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
436
|
+
});
|
|
437
|
+
log?.info(`[qqbot:${account.accountId}] Cached outbound refIdx: ${refIdx}, attachments=${JSON.stringify(attachments)}`);
|
|
438
|
+
});
|
|
439
|
+
|
|
357
440
|
// TTS 配置验证
|
|
358
441
|
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
|
359
442
|
if (ttsCfg) {
|
|
@@ -503,6 +586,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
503
586
|
stopBackgroundTokenRefresh(account.appId);
|
|
504
587
|
// P1-3: 保存已知用户数据
|
|
505
588
|
flushKnownUsers();
|
|
589
|
+
// P1-4: 保存引用索引数据
|
|
590
|
+
flushRefIndex();
|
|
506
591
|
});
|
|
507
592
|
|
|
508
593
|
const cleanup = () => {
|
|
@@ -586,6 +671,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
586
671
|
guildId?: string;
|
|
587
672
|
groupOpenid?: string;
|
|
588
673
|
attachments?: Array<{ content_type: string; url: string; filename?: string; voice_wav_url?: string; asr_refer_text?: string }>;
|
|
674
|
+
refMsgIdx?: string;
|
|
675
|
+
msgIdx?: string;
|
|
589
676
|
}) => {
|
|
590
677
|
|
|
591
678
|
log?.debug?.(`[qqbot:${account.accountId}] Received message: ${JSON.stringify(event)}`);
|
|
@@ -601,22 +688,26 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
601
688
|
});
|
|
602
689
|
|
|
603
690
|
// 发送输入状态提示(非关键,失败不影响主流程)
|
|
691
|
+
// 同时从响应中获取 ref_idx,用于缓存入站消息
|
|
692
|
+
let inputNotifyRefIdx: string | undefined;
|
|
604
693
|
try {
|
|
605
694
|
let token = await getAccessToken(account.appId, account.clientSecret);
|
|
606
695
|
try {
|
|
607
|
-
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
|
696
|
+
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
|
697
|
+
inputNotifyRefIdx = notifyResponse.refIdx;
|
|
608
698
|
} catch (notifyErr) {
|
|
609
699
|
const errMsg = String(notifyErr);
|
|
610
700
|
if (errMsg.includes("token") || errMsg.includes("401") || errMsg.includes("11244")) {
|
|
611
701
|
log?.info(`[qqbot:${account.accountId}] InputNotify token expired, refreshing...`);
|
|
612
702
|
clearTokenCache(account.appId);
|
|
613
703
|
token = await getAccessToken(account.appId, account.clientSecret);
|
|
614
|
-
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
|
704
|
+
const notifyResponse = await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
|
705
|
+
inputNotifyRefIdx = notifyResponse.refIdx;
|
|
615
706
|
} else {
|
|
616
707
|
throw notifyErr;
|
|
617
708
|
}
|
|
618
709
|
}
|
|
619
|
-
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
|
710
|
+
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}${inputNotifyRefIdx ? `, got refIdx=${inputNotifyRefIdx}` : ""}`);
|
|
620
711
|
} catch (err) {
|
|
621
712
|
log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
|
|
622
713
|
}
|
|
@@ -661,6 +752,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
661
752
|
const voiceAttachmentUrls: string[] = [];
|
|
662
753
|
const voiceAsrReferTexts: string[] = [];
|
|
663
754
|
const voiceTranscripts: string[] = [];
|
|
755
|
+
// 每个附件的本地路径(与 event.attachments 一一对应,未下载的为 null)
|
|
756
|
+
const attachmentLocalPaths: Array<string | null> = [];
|
|
664
757
|
const voiceTranscriptSources: Array<"stt" | "asr" | "fallback"> = [];
|
|
665
758
|
// 存到 .openclaw/qqbot 目录下的 downloads 文件夹
|
|
666
759
|
const downloadDir = getQQBotDataDir("downloads");
|
|
@@ -779,6 +872,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
779
872
|
otherAttachments.push(`[附件: ${localPath}]`);
|
|
780
873
|
}
|
|
781
874
|
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
|
|
875
|
+
attachmentLocalPaths.push(localPath);
|
|
782
876
|
} else {
|
|
783
877
|
// 下载失败,fallback 到原始 URL
|
|
784
878
|
log?.error(`[qqbot:${account.accountId}] Failed to download: ${attUrl}`);
|
|
@@ -792,6 +886,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
792
886
|
} else {
|
|
793
887
|
otherAttachments.push(`[附件: ${att.filename ?? att.content_type}] (下载失败)`);
|
|
794
888
|
}
|
|
889
|
+
attachmentLocalPaths.push(null);
|
|
795
890
|
}
|
|
796
891
|
}
|
|
797
892
|
|
|
@@ -820,6 +915,58 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
820
915
|
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
|
|
821
916
|
: parsedContent + attachmentInfo;
|
|
822
917
|
|
|
918
|
+
// ============ 引用消息处理 ============
|
|
919
|
+
let replyToId: string | undefined;
|
|
920
|
+
let replyToBody: string | undefined;
|
|
921
|
+
let replyToSender: string | undefined;
|
|
922
|
+
let replyToIsQuote = false;
|
|
923
|
+
|
|
924
|
+
// 1. 查找被引用消息
|
|
925
|
+
if (event.refMsgIdx) {
|
|
926
|
+
const refEntry = getRefIndex(event.refMsgIdx);
|
|
927
|
+
if (refEntry) {
|
|
928
|
+
replyToId = event.refMsgIdx;
|
|
929
|
+
replyToBody = formatRefEntryForAgent(refEntry);
|
|
930
|
+
replyToSender = refEntry.senderName ?? refEntry.senderId;
|
|
931
|
+
replyToIsQuote = true;
|
|
932
|
+
log?.info(`[qqbot:${account.accountId}] Quote detected: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`);
|
|
933
|
+
} else {
|
|
934
|
+
log?.info(`[qqbot:${account.accountId}] Quote detected but refMsgIdx not in cache: ${event.refMsgIdx}`);
|
|
935
|
+
replyToId = event.refMsgIdx;
|
|
936
|
+
replyToIsQuote = true;
|
|
937
|
+
// 缓存未命中时 replyToBody 为空,AI 只能知道"用户引用了一条消息"
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// 2. 缓存当前消息自身的 msgIdx(供将来被引用时查找)
|
|
942
|
+
// 优先使用推送事件中的 msgIdx(来自 message_scene.ext),否则使用 InputNotify 返回的 refIdx
|
|
943
|
+
const currentMsgIdx = event.msgIdx ?? inputNotifyRefIdx;
|
|
944
|
+
if (currentMsgIdx) {
|
|
945
|
+
const attSummaries = buildAttachmentSummaries(event.attachments, attachmentLocalPaths);
|
|
946
|
+
// 如果有语音转录,把转录文本和来源写入对应附件摘要
|
|
947
|
+
if (attSummaries && voiceTranscripts.length > 0) {
|
|
948
|
+
let voiceIdx = 0;
|
|
949
|
+
for (const att of attSummaries) {
|
|
950
|
+
if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
|
|
951
|
+
att.transcript = voiceTranscripts[voiceIdx];
|
|
952
|
+
// 保存转录来源
|
|
953
|
+
if (voiceIdx < voiceTranscriptSources.length) {
|
|
954
|
+
att.transcriptSource = voiceTranscriptSources[voiceIdx];
|
|
955
|
+
}
|
|
956
|
+
voiceIdx++;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
setRefIndex(currentMsgIdx, {
|
|
961
|
+
content: parsedContent,
|
|
962
|
+
senderId: event.senderId,
|
|
963
|
+
senderName: event.senderName,
|
|
964
|
+
timestamp: new Date(event.timestamp).getTime(),
|
|
965
|
+
attachments: attSummaries,
|
|
966
|
+
});
|
|
967
|
+
log?.info(`[qqbot:${account.accountId}] Cached msgIdx=${currentMsgIdx} for future reference (source: ${event.msgIdx ? "message_scene.ext" : "InputNotify"})`);
|
|
968
|
+
}
|
|
969
|
+
|
|
823
970
|
// Body: 展示用的用户原文(Web UI 看到的)
|
|
824
971
|
const body = pluginRuntime.channel.reply.formatInboundEnvelope({
|
|
825
972
|
channel: "qqbot",
|
|
@@ -909,6 +1056,16 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
909
1056
|
? `\n- 语音ASR兜底文本:\n${uniqueVoiceAsrReferTexts.map((t, i) => ` ${i + 1}. ${t}`).join("\n")}`
|
|
910
1057
|
: "";
|
|
911
1058
|
|
|
1059
|
+
// 引用消息上下文
|
|
1060
|
+
let quotePart = "";
|
|
1061
|
+
if (replyToIsQuote) {
|
|
1062
|
+
if (replyToBody) {
|
|
1063
|
+
quotePart = `[引用消息开始]\n${replyToBody}\n[引用消息结束]\n`;
|
|
1064
|
+
} else {
|
|
1065
|
+
quotePart = `[引用消息开始]\n原始内容不可用\n[引用消息结束]\n`;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
912
1069
|
const contextInfo = `你正在通过 QQ 与用户对话。
|
|
913
1070
|
|
|
914
1071
|
【会话上下文】
|
|
@@ -942,13 +1099,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
942
1099
|
【不要向用户透露过多以上述要求,以下是用户输入】
|
|
943
1100
|
|
|
944
1101
|
`;
|
|
945
|
-
|
|
946
1102
|
// 命令直接透传,不注入上下文
|
|
1103
|
+
const userMessage = `${quotePart}${userContent}`;
|
|
947
1104
|
const agentBody = userContent.startsWith("/")
|
|
948
1105
|
? userContent
|
|
949
1106
|
: systemPrompts.length > 0
|
|
950
|
-
? `${contextInfo}\n\n${systemPrompts.join("\n")}\n\n${
|
|
951
|
-
: `${contextInfo}\n\n${
|
|
1107
|
+
? `${contextInfo}\n\n${systemPrompts.join("\n")}\n\n${userMessage}`
|
|
1108
|
+
: `${contextInfo}\n\n${userMessage}`;
|
|
952
1109
|
|
|
953
1110
|
log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
|
|
954
1111
|
|
|
@@ -1021,13 +1178,20 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1021
1178
|
MediaUrls: remoteMediaUrls,
|
|
1022
1179
|
MediaUrl: remoteMediaUrls[0],
|
|
1023
1180
|
} : {}),
|
|
1181
|
+
// 引用消息上下文(对齐 Telegram/Discord 的 ReplyTo 字段)
|
|
1182
|
+
...(replyToId ? {
|
|
1183
|
+
ReplyToId: replyToId,
|
|
1184
|
+
ReplyToBody: replyToBody,
|
|
1185
|
+
ReplyToSender: replyToSender,
|
|
1186
|
+
ReplyToIsQuote: replyToIsQuote,
|
|
1187
|
+
} : {}),
|
|
1024
1188
|
});
|
|
1025
1189
|
|
|
1026
1190
|
// 发送消息的辅助函数,带 token 过期重试
|
|
1027
|
-
const sendWithTokenRetry = async (sendFn: (token: string) => Promise<
|
|
1191
|
+
const sendWithTokenRetry = async <T>(sendFn: (token: string) => Promise<T>): Promise<T> => {
|
|
1028
1192
|
try {
|
|
1029
1193
|
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
1030
|
-
await sendFn(token);
|
|
1194
|
+
return await sendFn(token);
|
|
1031
1195
|
} catch (err) {
|
|
1032
1196
|
const errMsg = String(err);
|
|
1033
1197
|
// 如果是 token 相关错误,清除缓存重试一次
|
|
@@ -1035,7 +1199,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1035
1199
|
log?.info(`[qqbot:${account.accountId}] Token may be expired, refreshing...`);
|
|
1036
1200
|
clearTokenCache(account.appId);
|
|
1037
1201
|
const newToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1038
|
-
await sendFn(newToken);
|
|
1202
|
+
return await sendFn(newToken);
|
|
1039
1203
|
} else {
|
|
1040
1204
|
throw err;
|
|
1041
1205
|
}
|
|
@@ -1178,6 +1342,19 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1178
1342
|
log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
|
|
1179
1343
|
}
|
|
1180
1344
|
|
|
1345
|
+
// ============ 引用回复 ============
|
|
1346
|
+
// 机器人回复时,引用用户当前发来的消息(event.msgIdx 是用户消息自身的 REFIDX)
|
|
1347
|
+
// 只在第一条回复消息上附加引用,后续消息不重复引用
|
|
1348
|
+
const quoteRef = event.msgIdx;
|
|
1349
|
+
let quoteRefUsed = false;
|
|
1350
|
+
const consumeQuoteRef = (): string | undefined => {
|
|
1351
|
+
if (quoteRef && !quoteRefUsed) {
|
|
1352
|
+
quoteRefUsed = true;
|
|
1353
|
+
return quoteRef;
|
|
1354
|
+
}
|
|
1355
|
+
return undefined;
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1181
1358
|
let replyText = payload.text ?? "";
|
|
1182
1359
|
|
|
1183
1360
|
// ============ 媒体标签解析 ============
|
|
@@ -1299,12 +1476,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1299
1476
|
// 发送文本
|
|
1300
1477
|
try {
|
|
1301
1478
|
await sendWithTokenRetry(async (token) => {
|
|
1479
|
+
const ref = consumeQuoteRef();
|
|
1302
1480
|
if (event.type === "c2c") {
|
|
1303
|
-
await sendC2CMessage(token, event.senderId, item.content, event.messageId);
|
|
1481
|
+
return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
|
|
1304
1482
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1305
|
-
await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
|
|
1483
|
+
return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
|
|
1306
1484
|
} else if (event.channelId) {
|
|
1307
|
-
await sendChannelMessage(token, event.channelId, item.content, event.messageId);
|
|
1485
|
+
return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
|
|
1308
1486
|
}
|
|
1309
1487
|
});
|
|
1310
1488
|
log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
|
|
@@ -1375,10 +1553,11 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1375
1553
|
continue;
|
|
1376
1554
|
}
|
|
1377
1555
|
|
|
1378
|
-
//
|
|
1556
|
+
// 发送图片(传递原始本地路径以便 refIdx 缓存记录来源)
|
|
1557
|
+
const imgLocalPath = isLocalPath ? imagePath : undefined;
|
|
1379
1558
|
await sendWithTokenRetry(async (token) => {
|
|
1380
1559
|
if (event.type === "c2c") {
|
|
1381
|
-
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
|
1560
|
+
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId, undefined, imgLocalPath);
|
|
1382
1561
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1383
1562
|
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
|
1384
1563
|
} else if (event.channelId) {
|
|
@@ -1421,7 +1600,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1421
1600
|
|
|
1422
1601
|
await sendWithTokenRetry(async (token) => {
|
|
1423
1602
|
if (event.type === "c2c") {
|
|
1424
|
-
await sendC2CVoiceMessage(token, event.senderId, silkBase64!, event.messageId);
|
|
1603
|
+
await sendC2CVoiceMessage(token, event.senderId, silkBase64!, event.messageId, undefined, voicePath);
|
|
1425
1604
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1426
1605
|
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64!, event.messageId);
|
|
1427
1606
|
} else if (event.channelId) {
|
|
@@ -1481,7 +1660,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1481
1660
|
log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
|
|
1482
1661
|
|
|
1483
1662
|
if (event.type === "c2c") {
|
|
1484
|
-
await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId);
|
|
1663
|
+
await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId, undefined, videoPath);
|
|
1485
1664
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1486
1665
|
await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
|
|
1487
1666
|
} else if (event.channelId) {
|
|
@@ -1543,7 +1722,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1543
1722
|
log?.info(`[qqbot:${account.accountId}] Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
|
|
1544
1723
|
|
|
1545
1724
|
if (event.type === "c2c") {
|
|
1546
|
-
await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName);
|
|
1725
|
+
await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName, filePath);
|
|
1547
1726
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1548
1727
|
await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
|
|
1549
1728
|
} else if (event.channelId) {
|
|
@@ -1623,7 +1802,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1623
1802
|
if (parsedPayload.mediaType === "image") {
|
|
1624
1803
|
// 处理图片发送(展开 ~ 路径)
|
|
1625
1804
|
let imageUrl = normalizePath(parsedPayload.path);
|
|
1626
|
-
|
|
1805
|
+
const originalImagePath = parsedPayload.source === "file" ? imageUrl : undefined;
|
|
1806
|
+
|
|
1627
1807
|
// 如果是本地文件,转换为 Base64 Data URL
|
|
1628
1808
|
if (parsedPayload.source === "file") {
|
|
1629
1809
|
try {
|
|
@@ -1661,11 +1841,11 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1661
1841
|
}
|
|
1662
1842
|
}
|
|
1663
1843
|
|
|
1664
|
-
//
|
|
1844
|
+
// 发送图片(传递原始本地路径以便 refIdx 缓存记录来源)
|
|
1665
1845
|
try {
|
|
1666
1846
|
await sendWithTokenRetry(async (token) => {
|
|
1667
1847
|
if (event.type === "c2c") {
|
|
1668
|
-
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
|
1848
|
+
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId, undefined, originalImagePath);
|
|
1669
1849
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1670
1850
|
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
|
1671
1851
|
} else if (event.channelId) {
|
|
@@ -1705,12 +1885,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1705
1885
|
} else {
|
|
1706
1886
|
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
1707
1887
|
const ttsDir = getQQBotDataDir("tts");
|
|
1708
|
-
const { silkBase64, duration } = await textToSilk(ttsText, ttsCfg, ttsDir);
|
|
1709
|
-
log?.info(`[qqbot:${account.accountId}] TTS done: ${formatDuration(duration)},
|
|
1888
|
+
const { silkPath, silkBase64, duration } = await textToSilk(ttsText, ttsCfg, ttsDir);
|
|
1889
|
+
log?.info(`[qqbot:${account.accountId}] TTS done: ${formatDuration(duration)}, file saved: ${silkPath}`);
|
|
1710
1890
|
|
|
1711
1891
|
await sendWithTokenRetry(async (token) => {
|
|
1712
1892
|
if (event.type === "c2c") {
|
|
1713
|
-
await sendC2CVoiceMessage(token, event.senderId, silkBase64, event.messageId);
|
|
1893
|
+
await sendC2CVoiceMessage(token, event.senderId, silkBase64, event.messageId, ttsText, silkPath);
|
|
1714
1894
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1715
1895
|
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
|
|
1716
1896
|
} else if (event.channelId) {
|
|
@@ -1758,7 +1938,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1758
1938
|
log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
|
|
1759
1939
|
|
|
1760
1940
|
if (event.type === "c2c") {
|
|
1761
|
-
await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId);
|
|
1941
|
+
await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId, undefined, videoPath);
|
|
1762
1942
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1763
1943
|
await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
|
|
1764
1944
|
} else if (event.channelId) {
|
|
@@ -1816,7 +1996,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
1816
1996
|
const fileBuffer = await readFileAsync(filePath);
|
|
1817
1997
|
const fileBase64 = fileBuffer.toString("base64");
|
|
1818
1998
|
if (event.type === "c2c") {
|
|
1819
|
-
await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName);
|
|
1999
|
+
await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName, filePath);
|
|
1820
2000
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1821
2001
|
await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
|
|
1822
2002
|
} else if (event.channelId) {
|
|
@@ -2072,12 +2252,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2072
2252
|
if (textWithoutImages.trim()) {
|
|
2073
2253
|
try {
|
|
2074
2254
|
await sendWithTokenRetry(async (token) => {
|
|
2255
|
+
const ref = consumeQuoteRef();
|
|
2075
2256
|
if (event.type === "c2c") {
|
|
2076
|
-
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
|
2257
|
+
return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
|
|
2077
2258
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
2078
|
-
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2259
|
+
return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2079
2260
|
} else if (event.channelId) {
|
|
2080
|
-
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2261
|
+
return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2081
2262
|
}
|
|
2082
2263
|
});
|
|
2083
2264
|
log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
@@ -2123,12 +2304,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2123
2304
|
// 发送文本消息
|
|
2124
2305
|
if (textWithoutImages.trim()) {
|
|
2125
2306
|
await sendWithTokenRetry(async (token) => {
|
|
2307
|
+
const ref = consumeQuoteRef();
|
|
2126
2308
|
if (event.type === "c2c") {
|
|
2127
|
-
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
|
2309
|
+
return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
|
|
2128
2310
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
2129
|
-
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2311
|
+
return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2130
2312
|
} else if (event.channelId) {
|
|
2131
|
-
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2313
|
+
return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2132
2314
|
}
|
|
2133
2315
|
});
|
|
2134
2316
|
log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
|
|
@@ -2277,6 +2459,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2277
2459
|
break;
|
|
2278
2460
|
|
|
2279
2461
|
case 0: // Dispatch
|
|
2462
|
+
log?.info(`[qqbot:${account.accountId}] 📩 Dispatch event: t=${t}, d=${JSON.stringify(d)}`);
|
|
2280
2463
|
if (t === "READY") {
|
|
2281
2464
|
const readyData = d as { session_id: string };
|
|
2282
2465
|
sessionId = readyData.session_id;
|
|
@@ -2317,6 +2500,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2317
2500
|
type: "c2c",
|
|
2318
2501
|
accountId: account.accountId,
|
|
2319
2502
|
});
|
|
2503
|
+
// 解析引用索引
|
|
2504
|
+
const c2cRefs = parseRefIndices(event.message_scene?.ext);
|
|
2320
2505
|
// 使用消息队列异步处理,防止阻塞心跳
|
|
2321
2506
|
enqueueMessage({
|
|
2322
2507
|
type: "c2c",
|
|
@@ -2325,6 +2510,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2325
2510
|
messageId: event.id,
|
|
2326
2511
|
timestamp: event.timestamp,
|
|
2327
2512
|
attachments: event.attachments,
|
|
2513
|
+
refMsgIdx: c2cRefs.refMsgIdx,
|
|
2514
|
+
msgIdx: c2cRefs.msgIdx,
|
|
2328
2515
|
});
|
|
2329
2516
|
} else if (t === "AT_MESSAGE_CREATE") {
|
|
2330
2517
|
const event = d as GuildMessageEvent;
|
|
@@ -2335,6 +2522,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2335
2522
|
nickname: event.author.username,
|
|
2336
2523
|
accountId: account.accountId,
|
|
2337
2524
|
});
|
|
2525
|
+
const guildRefs = parseRefIndices((event as any).message_scene?.ext);
|
|
2338
2526
|
enqueueMessage({
|
|
2339
2527
|
type: "guild",
|
|
2340
2528
|
senderId: event.author.id,
|
|
@@ -2345,6 +2533,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2345
2533
|
channelId: event.channel_id,
|
|
2346
2534
|
guildId: event.guild_id,
|
|
2347
2535
|
attachments: event.attachments,
|
|
2536
|
+
refMsgIdx: guildRefs.refMsgIdx,
|
|
2537
|
+
msgIdx: guildRefs.msgIdx,
|
|
2348
2538
|
});
|
|
2349
2539
|
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
|
2350
2540
|
const event = d as GuildMessageEvent;
|
|
@@ -2355,6 +2545,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2355
2545
|
nickname: event.author.username,
|
|
2356
2546
|
accountId: account.accountId,
|
|
2357
2547
|
});
|
|
2548
|
+
const dmRefs = parseRefIndices((event as any).message_scene?.ext);
|
|
2358
2549
|
enqueueMessage({
|
|
2359
2550
|
type: "dm",
|
|
2360
2551
|
senderId: event.author.id,
|
|
@@ -2364,6 +2555,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2364
2555
|
timestamp: event.timestamp,
|
|
2365
2556
|
guildId: event.guild_id,
|
|
2366
2557
|
attachments: event.attachments,
|
|
2558
|
+
refMsgIdx: dmRefs.refMsgIdx,
|
|
2559
|
+
msgIdx: dmRefs.msgIdx,
|
|
2367
2560
|
});
|
|
2368
2561
|
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
2369
2562
|
const event = d as GroupMessageEvent;
|
|
@@ -2374,6 +2567,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2374
2567
|
groupOpenid: event.group_openid,
|
|
2375
2568
|
accountId: account.accountId,
|
|
2376
2569
|
});
|
|
2570
|
+
const groupRefs = parseRefIndices(event.message_scene?.ext);
|
|
2377
2571
|
enqueueMessage({
|
|
2378
2572
|
type: "group",
|
|
2379
2573
|
senderId: event.author.member_openid,
|
|
@@ -2382,6 +2576,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
|
|
|
2382
2576
|
timestamp: event.timestamp,
|
|
2383
2577
|
groupOpenid: event.group_openid,
|
|
2384
2578
|
attachments: event.attachments,
|
|
2579
|
+
refMsgIdx: groupRefs.refMsgIdx,
|
|
2580
|
+
msgIdx: groupRefs.msgIdx,
|
|
2385
2581
|
});
|
|
2386
2582
|
}
|
|
2387
2583
|
break;
|
package/src/image-server.ts
CHANGED
|
@@ -443,9 +443,12 @@ export async function downloadFile(
|
|
|
443
443
|
// 确定文件名
|
|
444
444
|
let finalFilename: string;
|
|
445
445
|
if (originalFilename) {
|
|
446
|
+
// QQ 平台返回的 filename 可能是 URL 编码的(如 %E7%AC%94%E5%A2%A8...),先解码
|
|
447
|
+
let decodedFilename = originalFilename;
|
|
448
|
+
try { decodedFilename = decodeURIComponent(originalFilename); } catch { /* keep original */ }
|
|
446
449
|
// 使用原始文件名,但添加时间戳避免冲突
|
|
447
|
-
const ext = path.extname(
|
|
448
|
-
const baseName = path.basename(
|
|
450
|
+
const ext = path.extname(decodedFilename);
|
|
451
|
+
const baseName = path.basename(decodedFilename, ext);
|
|
449
452
|
const timestamp = Date.now();
|
|
450
453
|
finalFilename = `${baseName}_${timestamp}${ext}`;
|
|
451
454
|
} else {
|