@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.
Files changed (43) hide show
  1. package/README.md +47 -3
  2. package/README.zh.md +10 -9
  3. package/dist/src/api.d.ts +21 -5
  4. package/dist/src/api.js +67 -41
  5. package/dist/src/channel.js +16 -12
  6. package/dist/src/gateway.js +59 -48
  7. package/dist/src/message-queue.d.ts +5 -0
  8. package/dist/src/outbound-deliver.js +8 -8
  9. package/dist/src/outbound.d.ts +15 -0
  10. package/dist/src/outbound.js +41 -4
  11. package/dist/src/ref-index-store.d.ts +31 -0
  12. package/dist/src/ref-index-store.js +49 -1
  13. package/dist/src/runtime.js +3 -0
  14. package/dist/src/slash-commands.js +97 -0
  15. package/dist/src/streaming.d.ts +6 -9
  16. package/dist/src/streaming.js +25 -40
  17. package/dist/src/types.d.ts +25 -19
  18. package/dist/src/types.js +5 -0
  19. package/dist/src/utils/media-send.d.ts +12 -2
  20. package/dist/src/utils/media-send.js +84 -38
  21. package/dist/src/utils/media-tags.js +2 -1
  22. package/dist/src/utils/text-parsing.d.ts +4 -5
  23. package/dist/src/utils/text-parsing.js +17 -12
  24. package/openclaw.plugin.json +6 -1
  25. package/package.json +1 -1
  26. package/scripts/upgrade-via-npm.sh +697 -504
  27. package/scripts/upgrade-via-source.sh +24 -0
  28. package/skills/qqbot-upgrade/SKILL.md +79 -0
  29. package/src/api.ts +82 -44
  30. package/src/channel.ts +17 -11
  31. package/src/gateway.ts +64 -51
  32. package/src/message-queue.ts +5 -0
  33. package/src/openclaw-plugin-sdk.d.ts +2 -0
  34. package/src/outbound-deliver.ts +21 -8
  35. package/src/outbound.ts +51 -3
  36. package/src/ref-index-store.ts +78 -1
  37. package/src/runtime.ts +3 -0
  38. package/src/slash-commands.ts +113 -0
  39. package/src/streaming.ts +29 -54
  40. package/src/types.ts +29 -19
  41. package/src/utils/media-send.ts +89 -38
  42. package/src/utils/media-tags.ts +2 -1
  43. package/src/utils/text-parsing.ts +21 -11
@@ -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 { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, acknowledgeInteraction, getApiPluginVersion } from "./api.js";
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": PLUGIN_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
- // 1. 查找被引用消息
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
- replyToId = event.refMsgIdx;
710
+ // 缓存命中:直接使用已处理好的内容(同步,无需再下载附件)
692
711
  replyToBody = formatRefEntryForAgent(refEntry);
693
712
  replyToSender = refEntry.senderName ?? refEntry.senderId;
694
- replyToIsQuote = true;
695
- log?.info(`[qqbot:${account.accountId}] Quote detected: refMsgIdx=${event.refMsgIdx}, sender=${replyToSender}, content="${replyToBody.slice(0, 80)}..."`);
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
- log?.info(`[qqbot:${account.accountId}] Quote detected but refMsgIdx not in cache: ${event.refMsgIdx}`);
699
- replyToId = event.refMsgIdx;
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: parseFaceTags(event.content),
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: parseFaceTags(event.content),
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
- /** 创建一个新的 StreamingController 实例(用于初始创建和回复边界时重建) */
1208
- const createStreamingController = () => {
1209
- const ctrl = new StreamingController({
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
- if (!hasResponse) {
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("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
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("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
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("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
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("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
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(`发送图片失败:${imgResult.error}`, event, actx, sendWithRetry, consumeQuoteRef);
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(`发送图片失败:${imgErr}`, event, actx, sendWithRetry, consumeQuoteRef);
367
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
368
368
  }
369
369
  }
370
370
  if (result.trim()) {
@@ -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
  /** 目标类型 */
@@ -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 { channel: "qqbot", error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。` };
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 { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
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 { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
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(确保数据一致性)
@@ -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) {