@tencent-connect/openclaw-qqbot 1.6.7 → 1.7.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -3
- package/README.zh.md +10 -9
- package/dist/src/api.d.ts +21 -5
- package/dist/src/api.js +67 -41
- package/dist/src/channel.js +16 -12
- package/dist/src/gateway.js +59 -48
- package/dist/src/message-queue.d.ts +5 -0
- package/dist/src/outbound-deliver.js +8 -8
- package/dist/src/outbound.d.ts +15 -0
- package/dist/src/outbound.js +41 -4
- package/dist/src/ref-index-store.d.ts +31 -0
- package/dist/src/ref-index-store.js +49 -1
- package/dist/src/runtime.js +3 -0
- package/dist/src/slash-commands.js +97 -0
- package/dist/src/streaming.d.ts +6 -9
- package/dist/src/streaming.js +25 -40
- package/dist/src/types.d.ts +25 -19
- package/dist/src/types.js +5 -0
- package/dist/src/utils/media-send.d.ts +12 -2
- package/dist/src/utils/media-send.js +84 -38
- package/dist/src/utils/media-tags.js +2 -1
- package/dist/src/utils/text-parsing.d.ts +4 -5
- package/dist/src/utils/text-parsing.js +17 -12
- package/openclaw.plugin.json +6 -1
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.sh +697 -504
- package/scripts/upgrade-via-source.sh +24 -0
- package/skills/qqbot-upgrade/SKILL.md +79 -0
- package/src/api.ts +82 -44
- package/src/channel.ts +17 -11
- package/src/gateway.ts +64 -51
- package/src/message-queue.ts +5 -0
- package/src/openclaw-plugin-sdk.d.ts +2 -0
- package/src/outbound-deliver.ts +21 -8
- package/src/outbound.ts +51 -3
- package/src/ref-index-store.ts +78 -1
- package/src/runtime.ts +3 -0
- package/src/slash-commands.ts +113 -0
- package/src/streaming.ts +29 -54
- package/src/types.ts +29 -19
- package/src/utils/media-send.ts +89 -38
- package/src/utils/media-tags.ts +2 -1
- package/src/utils/text-parsing.ts +21 -11
package/dist/src/gateway.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { MSG_TYPE_QUOTE } from "./types.js";
|
|
5
|
+
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, getPluginUserAgent, acknowledgeInteraction, getApiPluginVersion, setApiLogger } from "./api.js";
|
|
5
6
|
import { loadSession, saveSession, clearSession } from "./session-store.js";
|
|
6
7
|
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
|
7
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
8
9
|
import { isGroupAllowed, resolveGroupName, resolveGroupPrompt, resolveHistoryLimit, resolveGroupPolicy, resolveGroupConfig, resolveIgnoreOtherMentions, resolveMentionPatterns } from "./config.js";
|
|
9
10
|
import { qqbotPlugin, stripMentionText, detectWasMentioned } from "./channel.js";
|
|
10
11
|
import { recordPendingHistoryEntry, buildPendingHistoryContext, buildMergedMessageContext, clearPendingHistory, formatAttachmentTags, formatMessageContent, toAttachmentSummaries, } from "./group-history.js";
|
|
11
|
-
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex } from "./ref-index-store.js";
|
|
12
|
+
import { setRefIndex, getRefIndex, formatRefEntryForAgent, formatMessageReferenceForAgent, flushRefIndex } from "./ref-index-store.js";
|
|
12
13
|
import { matchSlashCommand, getFrameworkVersion, parseFrameworkDateVersion } from "./slash-commands.js";
|
|
13
14
|
import { createMessageQueue } from "./message-queue.js";
|
|
14
15
|
import { triggerUpdateCheck } from "./update-checker.js";
|
|
@@ -338,6 +339,10 @@ export async function startGateway(ctx) {
|
|
|
338
339
|
// 后台版本检查(供 /bot-version、/bot-upgrade 指令被动查询)
|
|
339
340
|
triggerUpdateCheck(log);
|
|
340
341
|
// 初始化 API 配置(markdown 支持)
|
|
342
|
+
// 将框架 log 注入 api 模块,统一日志输出
|
|
343
|
+
if (log) {
|
|
344
|
+
setApiLogger(log);
|
|
345
|
+
}
|
|
341
346
|
initApiConfig({
|
|
342
347
|
markdownSupport: account.markdownSupport,
|
|
343
348
|
});
|
|
@@ -586,7 +591,7 @@ export async function startGateway(ctx) {
|
|
|
586
591
|
log?.info(`[qqbot:${account.accountId}] ✅ Access token obtained successfully`);
|
|
587
592
|
const gatewayUrl = await getGatewayUrl(accessToken);
|
|
588
593
|
log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
|
|
589
|
-
const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent":
|
|
594
|
+
const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": getPluginUserAgent() } });
|
|
590
595
|
currentWs = ws;
|
|
591
596
|
const pluginRuntime = getQQBotRuntime();
|
|
592
597
|
// 群历史消息缓存:非@消息写入此 Map,被@时一次性注入上下文后清空
|
|
@@ -679,26 +684,50 @@ export async function startGateway(ctx) {
|
|
|
679
684
|
let userContent = voiceText
|
|
680
685
|
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
|
|
681
686
|
: parsedContent + attachmentInfo;
|
|
687
|
+
// 统一处理 <@member_openid> → @username / 移除 @bot mention
|
|
688
|
+
if (event.type === "group" && event.mentions?.length) {
|
|
689
|
+
userContent = stripMentionText(userContent, event.mentions) ?? userContent;
|
|
690
|
+
}
|
|
691
|
+
else if (event.mentions?.length) {
|
|
692
|
+
for (const m of event.mentions) {
|
|
693
|
+
if (m.member_openid && m.username) {
|
|
694
|
+
userContent = userContent.replace(new RegExp(`<@${m.member_openid}>`, "g"), `@${m.username}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
682
698
|
// ============ 引用消息处理 ============
|
|
683
699
|
let replyToId;
|
|
684
700
|
let replyToBody;
|
|
685
701
|
let replyToSender;
|
|
686
702
|
let replyToIsQuote = false;
|
|
687
|
-
//
|
|
703
|
+
// 引用消息处理:优先使用本地 refIndex 缓存(同步、已处理),缓存未命中时从 msg_elements[0] 获取
|
|
704
|
+
// refMsgIdx 已由 parseRefIndices 在引用消息类型时合并了 msg_elements[0].msg_idx 的优先级
|
|
688
705
|
if (event.refMsgIdx) {
|
|
689
706
|
const refEntry = getRefIndex(event.refMsgIdx);
|
|
707
|
+
replyToId = event.refMsgIdx;
|
|
708
|
+
replyToIsQuote = true;
|
|
690
709
|
if (refEntry) {
|
|
691
|
-
|
|
710
|
+
// 缓存命中:直接使用已处理好的内容(同步,无需再下载附件)
|
|
692
711
|
replyToBody = formatRefEntryForAgent(refEntry);
|
|
693
712
|
replyToSender = refEntry.senderName ?? refEntry.senderId;
|
|
694
|
-
|
|
695
|
-
|
|
713
|
+
log?.info(`[qqbot:${account.accountId}] Quote detected via refMsgIdx cache: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`);
|
|
714
|
+
}
|
|
715
|
+
else if (event.msgType === MSG_TYPE_QUOTE) {
|
|
716
|
+
// 缓存未命中且为引用消息类型,从 msg_elements[0] 获取被引用消息内容
|
|
717
|
+
const refElement = event.msgElements?.[0];
|
|
718
|
+
if (refElement) {
|
|
719
|
+
const refData = { content: refElement.content ?? "", attachments: refElement.attachments };
|
|
720
|
+
replyToBody = await formatMessageReferenceForAgent(refData, { appId: account.appId, peerId, cfg, log });
|
|
721
|
+
log?.info(`[qqbot:${account.accountId}] Quote detected via msg_elements[0] (cache miss): id=${replyToId}, sender=${replyToSender ?? "unknown"}, content="${(replyToBody ?? "").slice(0, 80)}..."`);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
// 引用消息但 msg_elements 为空:AI 只能知道"用户引用了一条消息"
|
|
725
|
+
log?.info(`[qqbot:${account.accountId}] Quote detected (MSG_TYPE_QUOTE) but no msg_elements: refMsgIdx=${event.refMsgIdx}`);
|
|
726
|
+
}
|
|
696
727
|
}
|
|
697
728
|
else {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
replyToIsQuote = true;
|
|
701
|
-
// 缓存未命中时 replyToBody 为空,AI 只能知道"用户引用了一条消息"
|
|
729
|
+
// 缓存未命中且非引用消息类型:AI 只能知道"用户引用了一条消息"
|
|
730
|
+
log?.info(`[qqbot:${account.accountId}] Quote detected but no cache and msgType=${event.msgType} (not quote): refMsgIdx=${event.refMsgIdx}`);
|
|
702
731
|
}
|
|
703
732
|
}
|
|
704
733
|
// 2. 缓存当前消息自身的 msgIdx(供将来被引用时查找)
|
|
@@ -880,7 +909,7 @@ export async function startGateway(ctx) {
|
|
|
880
909
|
limit: historyLimit,
|
|
881
910
|
entry: {
|
|
882
911
|
sender: senderForHistory,
|
|
883
|
-
body:
|
|
912
|
+
body: userContent,
|
|
884
913
|
timestamp: new Date(event.timestamp).getTime(),
|
|
885
914
|
messageId: event.messageId,
|
|
886
915
|
attachments: historyAttachments,
|
|
@@ -907,7 +936,7 @@ export async function startGateway(ctx) {
|
|
|
907
936
|
limit: historyLimit,
|
|
908
937
|
entry: {
|
|
909
938
|
sender: senderForHistory,
|
|
910
|
-
body:
|
|
939
|
+
body: userContent,
|
|
911
940
|
timestamp: new Date(event.timestamp).getTime(),
|
|
912
941
|
messageId: event.messageId,
|
|
913
942
|
attachments: historyAttachments,
|
|
@@ -938,17 +967,6 @@ export async function startGateway(ctx) {
|
|
|
938
967
|
groupSystemPrompt = [baseHint, behaviorPrompt].filter(Boolean).join("\n");
|
|
939
968
|
}
|
|
940
969
|
const mergedCount = event._mergedCount;
|
|
941
|
-
// 将 <@member_openid> 替换为 @username(使用 mentions 适配器)
|
|
942
|
-
if (event.type === "group" && event.mentions?.length) {
|
|
943
|
-
userContent = stripMentionText(userContent, event.mentions) ?? userContent;
|
|
944
|
-
}
|
|
945
|
-
else if (event.mentions?.length) {
|
|
946
|
-
for (const m of event.mentions) {
|
|
947
|
-
if (m.member_openid && m.username) {
|
|
948
|
-
userContent = userContent.replace(new RegExp(`<@${m.member_openid}>`, "g"), `@${m.username}`);
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
970
|
// 群消息 user prompt 带上发送者昵称(合并消息已内嵌发送者前缀,不再重复添加)
|
|
953
971
|
const isMergedMsg = mergedCount && mergedCount > 1;
|
|
954
972
|
const senderPrefix = (event.type === "group" && !isMergedMsg)
|
|
@@ -1204,9 +1222,9 @@ export async function startGateway(ctx) {
|
|
|
1204
1222
|
const useStreaming = shouldUseStreaming(account, targetType);
|
|
1205
1223
|
log?.info(`[qqbot:${account.accountId}] Streaming ${useStreaming ? "enabled" : "disabled"} for ${targetType} message from ${event.senderId}`);
|
|
1206
1224
|
let streamingController = null;
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1225
|
+
if (useStreaming) {
|
|
1226
|
+
log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
|
|
1227
|
+
streamingController = new StreamingController({
|
|
1210
1228
|
account,
|
|
1211
1229
|
userId: event.senderId,
|
|
1212
1230
|
replyToMsgId: event.messageId,
|
|
@@ -1224,20 +1242,7 @@ export async function startGateway(ctx) {
|
|
|
1224
1242
|
},
|
|
1225
1243
|
log,
|
|
1226
1244
|
},
|
|
1227
|
-
// 回复边界回调:终结旧 controller 后创建新的,用新回复文本继续流式
|
|
1228
|
-
onReplyBoundary: async (newReplyText) => {
|
|
1229
|
-
log?.info(`[qqbot:${account.accountId}] Reply boundary: creating new StreamingController for new reply`);
|
|
1230
|
-
const newCtrl = createStreamingController();
|
|
1231
|
-
streamingController = newCtrl;
|
|
1232
|
-
// 将新回复的初始文本交给新 controller 处理
|
|
1233
|
-
await newCtrl.onPartialReply({ text: newReplyText });
|
|
1234
|
-
},
|
|
1235
1245
|
});
|
|
1236
|
-
return ctrl;
|
|
1237
|
-
};
|
|
1238
|
-
if (useStreaming) {
|
|
1239
|
-
log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
|
|
1240
|
-
streamingController = createStreamingController();
|
|
1241
1246
|
}
|
|
1242
1247
|
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1243
1248
|
ctx: ctxPayload,
|
|
@@ -1517,9 +1522,7 @@ export async function startGateway(ctx) {
|
|
|
1517
1522
|
if (timeoutId) {
|
|
1518
1523
|
clearTimeout(timeoutId);
|
|
1519
1524
|
}
|
|
1520
|
-
|
|
1521
|
-
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
|
|
1522
|
-
}
|
|
1525
|
+
log?.error(`[qqbot:${account.accountId}] Dispatch failed: ${err}${!hasResponse ? " (no response received)" : ""}`);
|
|
1523
1526
|
}
|
|
1524
1527
|
finally {
|
|
1525
1528
|
// 清理 tool-only 兜底定时器
|
|
@@ -1718,7 +1721,7 @@ export async function startGateway(ctx) {
|
|
|
1718
1721
|
accountId: account.accountId,
|
|
1719
1722
|
});
|
|
1720
1723
|
// 解析引用索引
|
|
1721
|
-
const c2cRefs = parseRefIndices(event.message_scene?.ext);
|
|
1724
|
+
const c2cRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
1722
1725
|
// 斜杠指令拦截 → 不匹配则入队
|
|
1723
1726
|
trySlashCommandOrEnqueue({
|
|
1724
1727
|
type: "c2c",
|
|
@@ -1729,6 +1732,8 @@ export async function startGateway(ctx) {
|
|
|
1729
1732
|
attachments: event.attachments,
|
|
1730
1733
|
refMsgIdx: c2cRefs.refMsgIdx,
|
|
1731
1734
|
msgIdx: c2cRefs.msgIdx,
|
|
1735
|
+
msgElements: event.msg_elements,
|
|
1736
|
+
msgType: event.message_type,
|
|
1732
1737
|
});
|
|
1733
1738
|
}
|
|
1734
1739
|
else if (t === "AT_MESSAGE_CREATE") {
|
|
@@ -1740,7 +1745,7 @@ export async function startGateway(ctx) {
|
|
|
1740
1745
|
nickname: event.author.username,
|
|
1741
1746
|
accountId: account.accountId,
|
|
1742
1747
|
});
|
|
1743
|
-
const guildRefs = parseRefIndices(event.message_scene?.ext);
|
|
1748
|
+
const guildRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
1744
1749
|
trySlashCommandOrEnqueue({
|
|
1745
1750
|
type: "guild",
|
|
1746
1751
|
senderId: event.author.id,
|
|
@@ -1753,6 +1758,7 @@ export async function startGateway(ctx) {
|
|
|
1753
1758
|
attachments: event.attachments,
|
|
1754
1759
|
refMsgIdx: guildRefs.refMsgIdx,
|
|
1755
1760
|
msgIdx: guildRefs.msgIdx,
|
|
1761
|
+
msgType: event.message_type,
|
|
1756
1762
|
});
|
|
1757
1763
|
}
|
|
1758
1764
|
else if (t === "DIRECT_MESSAGE_CREATE") {
|
|
@@ -1764,7 +1770,7 @@ export async function startGateway(ctx) {
|
|
|
1764
1770
|
nickname: event.author.username,
|
|
1765
1771
|
accountId: account.accountId,
|
|
1766
1772
|
});
|
|
1767
|
-
const dmRefs = parseRefIndices(event.message_scene?.ext);
|
|
1773
|
+
const dmRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
1768
1774
|
trySlashCommandOrEnqueue({
|
|
1769
1775
|
type: "dm",
|
|
1770
1776
|
senderId: event.author.id,
|
|
@@ -1776,6 +1782,7 @@ export async function startGateway(ctx) {
|
|
|
1776
1782
|
attachments: event.attachments,
|
|
1777
1783
|
refMsgIdx: dmRefs.refMsgIdx,
|
|
1778
1784
|
msgIdx: dmRefs.msgIdx,
|
|
1785
|
+
msgType: event.message_type,
|
|
1779
1786
|
});
|
|
1780
1787
|
}
|
|
1781
1788
|
else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
@@ -1788,7 +1795,7 @@ export async function startGateway(ctx) {
|
|
|
1788
1795
|
groupOpenid: event.group_openid,
|
|
1789
1796
|
accountId: account.accountId,
|
|
1790
1797
|
});
|
|
1791
|
-
const groupRefs = parseRefIndices(event.message_scene?.ext);
|
|
1798
|
+
const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
1792
1799
|
trySlashCommandOrEnqueue({
|
|
1793
1800
|
type: "group",
|
|
1794
1801
|
senderId: event.author.member_openid,
|
|
@@ -1803,6 +1810,8 @@ export async function startGateway(ctx) {
|
|
|
1803
1810
|
eventType: "GROUP_AT_MESSAGE_CREATE",
|
|
1804
1811
|
mentions: event.mentions,
|
|
1805
1812
|
messageScene: event.message_scene,
|
|
1813
|
+
msgElements: event.msg_elements,
|
|
1814
|
+
msgType: event.message_type,
|
|
1806
1815
|
});
|
|
1807
1816
|
}
|
|
1808
1817
|
else if (t === "GROUP_MESSAGE_CREATE") {
|
|
@@ -1814,7 +1823,7 @@ export async function startGateway(ctx) {
|
|
|
1814
1823
|
groupOpenid: event.group_openid,
|
|
1815
1824
|
accountId: account.accountId,
|
|
1816
1825
|
});
|
|
1817
|
-
const groupRefs = parseRefIndices(event.message_scene?.ext);
|
|
1826
|
+
const groupRefs = parseRefIndices(event.message_scene?.ext, event.message_type, event.msg_elements);
|
|
1818
1827
|
trySlashCommandOrEnqueue({
|
|
1819
1828
|
type: "group",
|
|
1820
1829
|
senderId: event.author.member_openid,
|
|
@@ -1830,6 +1839,8 @@ export async function startGateway(ctx) {
|
|
|
1830
1839
|
eventType: "GROUP_MESSAGE_CREATE",
|
|
1831
1840
|
mentions: event.mentions,
|
|
1832
1841
|
messageScene: event.message_scene,
|
|
1842
|
+
msgElements: event.msg_elements,
|
|
1843
|
+
msgType: event.message_type,
|
|
1833
1844
|
});
|
|
1834
1845
|
}
|
|
1835
1846
|
else if (t === "GROUP_ADD_ROBOT") {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { QueueSnapshot } from "./slash-commands.js";
|
|
2
|
+
import type { MsgElement } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* 消息队列项类型(用于异步处理消息,防止阻塞心跳)
|
|
4
5
|
*/
|
|
@@ -42,6 +43,10 @@ export interface QueuedMessage {
|
|
|
42
43
|
source?: string;
|
|
43
44
|
ext?: string[];
|
|
44
45
|
};
|
|
46
|
+
/** 消息元素列表,引用消息时 [0] 为被引用的原始消息 */
|
|
47
|
+
msgElements?: MsgElement[];
|
|
48
|
+
/** 消息类型,参见 MSG_TYPE_* */
|
|
49
|
+
msgType?: number;
|
|
45
50
|
/** 群消息合并标记:记录合并了多少条原始消息 */
|
|
46
51
|
_mergedCount?: number;
|
|
47
52
|
/** 合并前的原始消息列表(用于 gateway 侧逐条格式化信封) */
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* 1. parseAndSendMediaTags — 解析 <qqimg/qqvoice/qqvideo/qqfile/qqmedia> 标签并按顺序发送
|
|
6
6
|
* 2. sendPlainReply — 处理不含媒体标签的普通回复(markdown 图片/纯文本+图片)
|
|
7
7
|
*/
|
|
8
|
-
import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
|
9
|
-
import { sendPhoto, sendMedia as sendMediaAuto } from "./outbound.js";
|
|
8
|
+
import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage, } from "./api.js";
|
|
9
|
+
import { sendPhoto, sendMedia as sendMediaAuto, DEFAULT_MEDIA_SEND_ERROR, resolveUserFacingMediaError, } from "./outbound.js";
|
|
10
10
|
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
|
|
11
11
|
import { getQQBotRuntime } from "./runtime.js";
|
|
12
12
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
|
|
@@ -154,7 +154,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
154
154
|
});
|
|
155
155
|
if (result.error) {
|
|
156
156
|
log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
|
|
157
|
-
await sendTextChunks(
|
|
157
|
+
await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
|
|
158
158
|
}
|
|
159
159
|
else {
|
|
160
160
|
log?.info(`${prefix} Sent local media: ${mediaPath}`);
|
|
@@ -162,7 +162,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
162
162
|
}
|
|
163
163
|
catch (err) {
|
|
164
164
|
log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
|
|
165
|
-
await sendTextChunks(
|
|
165
|
+
await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
}
|
|
@@ -183,7 +183,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
183
183
|
});
|
|
184
184
|
if (result.error) {
|
|
185
185
|
log?.error(`${prefix} Tool media forward error: ${result.error}`);
|
|
186
|
-
await sendTextChunks(
|
|
186
|
+
await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
|
|
187
187
|
}
|
|
188
188
|
else {
|
|
189
189
|
log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
|
|
@@ -191,7 +191,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
|
|
|
191
191
|
}
|
|
192
192
|
catch (err) {
|
|
193
193
|
log?.error(`${prefix} Tool media forward failed: ${err}`);
|
|
194
|
-
await sendTextChunks(
|
|
194
|
+
await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
}
|
|
@@ -356,7 +356,7 @@ async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareU
|
|
|
356
356
|
const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
|
|
357
357
|
if (imgResult.error) {
|
|
358
358
|
log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
|
|
359
|
-
await sendTextChunks(
|
|
359
|
+
await sendTextChunks(resolveUserFacingMediaError(imgResult), event, actx, sendWithRetry, consumeQuoteRef);
|
|
360
360
|
}
|
|
361
361
|
else {
|
|
362
362
|
log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
|
|
@@ -364,7 +364,7 @@ async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareU
|
|
|
364
364
|
}
|
|
365
365
|
catch (imgErr) {
|
|
366
366
|
log?.error(`${prefix} Failed to send image: ${imgErr}`);
|
|
367
|
-
await sendTextChunks(
|
|
367
|
+
await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
if (result.trim()) {
|
package/dist/src/outbound.d.ts
CHANGED
|
@@ -53,14 +53,29 @@ export interface MediaOutboundContext extends OutboundContext {
|
|
|
53
53
|
/** 可选的 MIME 类型,优先于扩展名判断媒体类型 */
|
|
54
54
|
mimeType?: string;
|
|
55
55
|
}
|
|
56
|
+
export declare const OUTBOUND_ERROR_CODES: {
|
|
57
|
+
readonly FILE_TOO_LARGE: "file_too_large";
|
|
58
|
+
readonly UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded";
|
|
59
|
+
};
|
|
60
|
+
export declare const DEFAULT_MEDIA_SEND_ERROR = "\u53D1\u9001\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
|
|
61
|
+
export type OutboundErrorCode = typeof OUTBOUND_ERROR_CODES[keyof typeof OUTBOUND_ERROR_CODES];
|
|
56
62
|
export interface OutboundResult {
|
|
57
63
|
channel: string;
|
|
58
64
|
messageId?: string;
|
|
59
65
|
timestamp?: string | number;
|
|
60
66
|
error?: string;
|
|
67
|
+
/** 稳定错误码,供上层按类型处理,避免依赖 error 文案 */
|
|
68
|
+
errorCode?: OutboundErrorCode;
|
|
69
|
+
/** QQ 开放平台业务错误码(如 upload_prepare 的 40093002) */
|
|
70
|
+
qqBizCode?: number;
|
|
61
71
|
/** 出站消息的引用索引(ext_info.ref_idx),供引用消息缓存使用 */
|
|
62
72
|
refIdx?: string;
|
|
63
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 将媒体发送结果映射为可展示给用户的文案。
|
|
76
|
+
* 只对明确标记为可直接展示的错误码透传原文,其余统一走通用兜底。
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveUserFacingMediaError(result: Pick<OutboundResult, "error" | "errorCode" | "qqBizCode">): string;
|
|
64
79
|
/** 媒体发送的目标上下文(从 deliver 回调或 sendText 中提取) */
|
|
65
80
|
export interface MediaTargetContext {
|
|
66
81
|
/** 目标类型 */
|
package/dist/src/outbound.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as path from "path";
|
|
|
5
5
|
import * as fs from "fs";
|
|
6
6
|
import * as crypto from "crypto";
|
|
7
7
|
import { decodeCronPayload } from "./utils/payload.js";
|
|
8
|
-
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, } from "./api.js";
|
|
8
|
+
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, UPLOAD_PREPARE_FALLBACK_CODE, } from "./api.js";
|
|
9
9
|
import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
|
|
10
10
|
import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
|
|
11
11
|
import { chunkedUploadC2C, chunkedUploadGroup, UploadDailyLimitExceededError } from "./utils/chunked-upload.js";
|
|
@@ -110,6 +110,29 @@ export function getMessageReplyConfig() {
|
|
|
110
110
|
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
+
export const OUTBOUND_ERROR_CODES = {
|
|
114
|
+
FILE_TOO_LARGE: "file_too_large",
|
|
115
|
+
UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded",
|
|
116
|
+
};
|
|
117
|
+
export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。";
|
|
118
|
+
/**
|
|
119
|
+
* 将媒体发送结果映射为可展示给用户的文案。
|
|
120
|
+
* 只对明确标记为可直接展示的错误码透传原文,其余统一走通用兜底。
|
|
121
|
+
*/
|
|
122
|
+
export function resolveUserFacingMediaError(result) {
|
|
123
|
+
if (!result.error)
|
|
124
|
+
return DEFAULT_MEDIA_SEND_ERROR;
|
|
125
|
+
if (result.qqBizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
|
126
|
+
return result.error;
|
|
127
|
+
}
|
|
128
|
+
switch (result.errorCode) {
|
|
129
|
+
case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE:
|
|
130
|
+
case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED:
|
|
131
|
+
return result.error;
|
|
132
|
+
default:
|
|
133
|
+
return DEFAULT_MEDIA_SEND_ERROR;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
113
136
|
/**
|
|
114
137
|
* 解析目标地址
|
|
115
138
|
* 格式:
|
|
@@ -375,7 +398,11 @@ sendMeta) {
|
|
|
375
398
|
if (fileSize > maxSize) {
|
|
376
399
|
const typeName = getFileTypeName(fileType);
|
|
377
400
|
const limitMB = Math.round(maxSize / (1024 * 1024));
|
|
378
|
-
return {
|
|
401
|
+
return {
|
|
402
|
+
channel: "qqbot",
|
|
403
|
+
error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。`,
|
|
404
|
+
errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE,
|
|
405
|
+
};
|
|
379
406
|
}
|
|
380
407
|
if (ctx.targetType === "c2c") {
|
|
381
408
|
console.log(`${prefix} ${callerName}: c2c chunked upload (${formatFileSize(fileSize)})`);
|
|
@@ -397,7 +424,12 @@ sendMeta) {
|
|
|
397
424
|
const dir = path.dirname(err.filePath);
|
|
398
425
|
const name = path.basename(err.filePath);
|
|
399
426
|
const size = formatFileSize(err.fileSize);
|
|
400
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
channel: "qqbot",
|
|
429
|
+
error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
|
|
430
|
+
errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
|
|
431
|
+
qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
|
|
432
|
+
};
|
|
401
433
|
}
|
|
402
434
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
403
435
|
}
|
|
@@ -422,7 +454,12 @@ sendMeta) {
|
|
|
422
454
|
const dir = path.dirname(err.filePath);
|
|
423
455
|
const name = path.basename(err.filePath);
|
|
424
456
|
const size = formatFileSize(err.fileSize);
|
|
425
|
-
return {
|
|
457
|
+
return {
|
|
458
|
+
channel: "qqbot",
|
|
459
|
+
error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
|
|
460
|
+
errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
|
|
461
|
+
qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
|
|
462
|
+
};
|
|
426
463
|
}
|
|
427
464
|
return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
|
|
428
465
|
}
|
|
@@ -55,6 +55,37 @@ export declare function getRefIndex(refIdx: string): RefIndexEntry | null;
|
|
|
55
55
|
* 将引用消息内容格式化为人类可读的描述(供 AI 上下文注入)
|
|
56
56
|
*/
|
|
57
57
|
export declare function formatRefEntryForAgent(entry: RefIndexEntry): string;
|
|
58
|
+
/**
|
|
59
|
+
* 将 QQ 推送事件中的 message_reference 结构格式化为人类可读的描述(供 AI 上下文注入)
|
|
60
|
+
*
|
|
61
|
+
* 完整参考 gateway 中对当前消息的处理流程:
|
|
62
|
+
* 1. 调用 processAttachments 下载附件到本地、语音转录
|
|
63
|
+
* 2. 调用 formatVoiceText 格式化语音转录文本
|
|
64
|
+
* 3. 调用 parseFaceTags 解析 QQ 表情标签
|
|
65
|
+
* 4. 按 gateway 中 userContent 的拼接逻辑组合最终文本
|
|
66
|
+
*/
|
|
67
|
+
export declare function formatMessageReferenceForAgent(ref: {
|
|
68
|
+
content: string;
|
|
69
|
+
attachments?: Array<{
|
|
70
|
+
content_type: string;
|
|
71
|
+
url: string;
|
|
72
|
+
filename?: string;
|
|
73
|
+
height?: number;
|
|
74
|
+
width?: number;
|
|
75
|
+
size?: number;
|
|
76
|
+
voice_wav_url?: string;
|
|
77
|
+
asr_refer_text?: string;
|
|
78
|
+
}>;
|
|
79
|
+
} | undefined, ctx: {
|
|
80
|
+
appId: string;
|
|
81
|
+
peerId?: string;
|
|
82
|
+
cfg: unknown;
|
|
83
|
+
log?: {
|
|
84
|
+
info: (msg: string) => void;
|
|
85
|
+
error: (msg: string) => void;
|
|
86
|
+
debug?: (msg: string) => void;
|
|
87
|
+
};
|
|
88
|
+
}): Promise<string>;
|
|
58
89
|
/**
|
|
59
90
|
* 进程退出前强制 compact(确保数据一致性)
|
|
60
91
|
*/
|
|
@@ -16,6 +16,8 @@ import fs from "node:fs";
|
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { getQQBotDataDir } from "./utils/platform.js";
|
|
18
18
|
import { formatAttachmentTags } from "./group-history.js";
|
|
19
|
+
import { parseFaceTags, buildAttachmentSummaries } from "./utils/text-parsing.js";
|
|
20
|
+
import { processAttachments, formatVoiceText } from "./inbound-attachments.js";
|
|
19
21
|
// ============ 配置 ============
|
|
20
22
|
const STORAGE_DIR = getQQBotDataDir("data");
|
|
21
23
|
const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
|
|
@@ -226,7 +228,53 @@ export function formatRefEntryForAgent(entry) {
|
|
|
226
228
|
if (attachmentDesc) {
|
|
227
229
|
parts.push(attachmentDesc);
|
|
228
230
|
}
|
|
229
|
-
return parts.join("
|
|
231
|
+
return parts.join("\n");
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 将 QQ 推送事件中的 message_reference 结构格式化为人类可读的描述(供 AI 上下文注入)
|
|
235
|
+
*
|
|
236
|
+
* 完整参考 gateway 中对当前消息的处理流程:
|
|
237
|
+
* 1. 调用 processAttachments 下载附件到本地、语音转录
|
|
238
|
+
* 2. 调用 formatVoiceText 格式化语音转录文本
|
|
239
|
+
* 3. 调用 parseFaceTags 解析 QQ 表情标签
|
|
240
|
+
* 4. 按 gateway 中 userContent 的拼接逻辑组合最终文本
|
|
241
|
+
*/
|
|
242
|
+
export async function formatMessageReferenceForAgent(ref, ctx) {
|
|
243
|
+
if (!ref)
|
|
244
|
+
return "";
|
|
245
|
+
// 处理附件(图片等)- 下载到本地供 openclaw 访问(参考 gateway 中 processAttachments 调用)
|
|
246
|
+
const processed = await processAttachments(ref.attachments, ctx);
|
|
247
|
+
const { attachmentInfo, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = processed;
|
|
248
|
+
// 语音转录文本注入(参考 gateway 中 formatVoiceText 调用)
|
|
249
|
+
const voiceText = formatVoiceText(voiceTranscripts);
|
|
250
|
+
// 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
|
|
251
|
+
const parsedContent = parseFaceTags(ref.content ?? "");
|
|
252
|
+
// 最终组合(参考 gateway 中 userContent 的拼接逻辑)
|
|
253
|
+
const userContent = voiceText
|
|
254
|
+
? (parsedContent.trim() ? `${parsedContent}\n${voiceText}` : voiceText) + attachmentInfo
|
|
255
|
+
: parsedContent + attachmentInfo;
|
|
256
|
+
// 构建附件摘要并通过 formatAttachmentTags 统一生成标签
|
|
257
|
+
// 与缓存命中路径 (formatRefEntryForAgent → formatAttachmentTags) 格式完全一致
|
|
258
|
+
const attSummaries = buildAttachmentSummaries(ref.attachments, attachmentLocalPaths);
|
|
259
|
+
if (attSummaries && voiceTranscripts.length > 0) {
|
|
260
|
+
let voiceIdx = 0;
|
|
261
|
+
for (const att of attSummaries) {
|
|
262
|
+
if (att.type === "voice" && voiceIdx < voiceTranscripts.length) {
|
|
263
|
+
att.transcript = voiceTranscripts[voiceIdx];
|
|
264
|
+
if (voiceIdx < voiceTranscriptSources.length) {
|
|
265
|
+
att.transcriptSource = voiceTranscriptSources[voiceIdx];
|
|
266
|
+
}
|
|
267
|
+
voiceIdx++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const attachmentDesc = formatAttachmentTags(attSummaries);
|
|
272
|
+
const parts = [];
|
|
273
|
+
if (userContent.trim())
|
|
274
|
+
parts.push(userContent.trim());
|
|
275
|
+
if (attachmentDesc)
|
|
276
|
+
parts.push(attachmentDesc);
|
|
277
|
+
return parts.join(" ");
|
|
230
278
|
}
|
|
231
279
|
/**
|
|
232
280
|
* 进程退出前强制 compact(确保数据一致性)
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { setOpenClawVersion } from "./api.js";
|
|
1
2
|
let runtime = null;
|
|
2
3
|
export function setQQBotRuntime(next) {
|
|
3
4
|
runtime = next;
|
|
5
|
+
// 将框架版本注入 User-Agent(runtime 注入后才能拿到准确版本)
|
|
6
|
+
setOpenClawVersion(next.version);
|
|
4
7
|
}
|
|
5
8
|
export function getQQBotRuntime() {
|
|
6
9
|
if (!runtime) {
|