Skip to content

引用与合并转发

QQ 的 OneBot 事件里,一条"回复某消息"或"包含合并转发"的消息,本身只携带一个引用——{type: "reply", data: {id}}{type: "forward", data: {id}},被引用/转发的实际内容并不在事件里。要让 Agent 看到完整上下文,就必须主动再发起一次 SnowLuma 调用去把内容取回来。这正是 src/quote.ts 做的事:收到消息后立即调用 get_msg(解析引用)和 get_forward_msg(展开合并转发),而不是等 Agent 自己去问——Agent 从第一轮对话开始就能看到被引用或转发的完整文本。

行为完全由 channels.snowluma.quote 控制,各字段的默认值见配置参考 · quote

何时会触发解析

  • 只在 realtime 批次里生效——dispatchBatch() 只在 batch.kind === "realtime" 时才会调用 resolveQuoteContext;digest 摘要批次从不解析引用/转发(一份摘要本来就是一堆消息的合集,不需要针对某一条做展开)。
  • 一个 realtime 批次可能包含好几条消息,但只有其中一条会被解析findQuoteSource() 从批次末尾往前找,取最后一条携带 replyToId 或非空 forwardIds 的消息。如果用户连发三句话,只有第三句(假设它是一条引用消息)会被展开;更早的引用会被忽略。
  • quote.enabled: false 时,resolveQuoteContext 直接返回 null——Agent 只能看到"这是一条回复"(reply 消息段在 renderSegments 里被显式跳过,不会出现在正文中),完全看不到被回复的内容。

解析流程

第一步:解析被引用/回复的消息(get_msg

如果这条消息有 replyToId,调用 client.getMessage(Number(replyToId), { timeoutMs }),把返回的 sender/time/message(或 raw_message)字段防御性地解析成 senderId/senderName/time/text。SnowLuma 的 get_msg/get_forward_msg 返回的是未类型化的 JsonObject,字段缺失或形状不对不会抛异常,只会让对应字段变成 undefined

调用失败(超时、网络错误、消息已撤回等)时不会让整条消息处理链路抛出异常——text 会降级为占位符:

text
[引用消息获取失败]

senderId/senderName/time 在失败时保持 undefined

如果被引用的消息自己也包含合并转发(forward 消息段),这些转发 id 会被记录下来,和当前消息自带的 forwardIds 合并后一起进入第二步展开——也就是说,你回复一条别人转发的聊天记录时,Agent 能看到那份转发记录被展开后的内容,而不只是"有一条转发"

第二步:展开合并转发(get_forward_msg

quote.resolveForward: true(默认)时,把"当前消息自带的 forwardIds"和"第一步里从被引用消息中提取出的转发 id"合并成一个列表,依次对每个 id 调用 client.getForwardMessage({ id }, { timeoutMs }),得到该合并转发内的消息数组,再对其中每条消息递归展开(如果它自己又包含转发,且没超过深度上限)。

三个预算共同控制这一步展开多少内容,在同一次消息解析里全局共享(不是每个转发各自独立一份预算):

预算配置项默认值作用范围
深度maxDepth2递归展开的层数上限
节点数maxNodes20本次解析渲染的转发消息条数上限,跨越整棵嵌套转发树共享
字符数maxChars4000引用文本 + 所有转发节点文本合计的字符上限

深度(maxDepth)如何计算

顶层转发展开时 depth = 0。展开某个节点时,只有当当前节点的 depth < maxDepth 才会继续往下递归展开它内部嵌套的转发,递归调用时 depth + 1。默认 maxDepth: 2 意味着:

  • depth = 0(顶层转发内的消息)—— 渲染,且会检查其中是否有嵌套转发(0 < 2,继续展开)
  • depth = 1(顶层转发内某条消息又转发了一层)—— 渲染,且继续检查嵌套(1 < 2,继续展开)
  • depth = 2(再深一层)—— 渲染,但不再检查它内部是否还有更深的嵌套转发(2 < 2 为假)

也就是说 maxDepth: 2 实际会渲染三层节点(depth 0/1/2),第三层内部如果还有转发,只会在文本里显示成占位符 [合并转发],不会被展开。maxDepth: 0 则只展开顶层这一层,完全不递归。

节点数(maxNodes)如何消耗

remaining 计数器在解析开始时初始化为 maxNodes,之后每渲染一个节点(无论是正常节点还是"获取失败"占位节点)都会消耗 1。计数器归零后,还没处理到的转发 id/消息会被跳过,truncated 标记为 true

环检测

每个转发 id 在解析开始时会被记录进一个 Setstate.visited),在整次解析范围内全局共享,不区分层级。一旦某个转发 id 被访问过,后续再次遇到同一个 id(无论出现在哪一层、哪一分支)会被直接跳过——返回空数组,既不渲染、也不消耗 maxNodes 预算、也不标记为截断。这就避免了转发 A 引用转发 B、转发 B 又引用回转发 A 这种循环引用把插件拖入死循环。

字符数(maxChars)如何分配

applyCharBudget() 用一个共享的字符预算(初始值 maxChars)依次处理:先看被引用消息自身的 text——如果它本身就超过预算,直接截断到预算大小,标记 truncated;用掉的字符数从预算里扣除。剩余预算再依次分给各个转发节点:某个节点放不下就截断它并把预算清零,放不下的节点直接被整个丢弃(不放进结果数组),只要有节点被丢弃,truncated 也会被标记为 true

调用失败如何降级

get_msg/get_forward_msg 任意一次调用失败,都只会让那一部分内容降级为占位符,不会让整条消息的处理中断:

  • 引用消息本身的 get_msg 失败 ⇒ text = "[引用消息获取失败]"
  • 某个转发的 get_forward_msg 失败 ⇒ 那个转发在结果里表现为一个单独的节点 { text: "[引用消息获取失败]" }(仍然消耗 1maxNodes 预算)。

dispatch.ts 里对 resolveQuoteContext 的调用本身也包了一层 try/catch:即使解析逻辑本身抛出了预料之外的异常,也只是记一条错误日志、跳过这次引用注入,不会影响这条消息批次继续走完剩余的 Agent 调用流程。

Agent 最终看到什么

formatQuoteContext()src/quote.ts)把解析结果渲染成一段带方括号的文本块,作为整个 realtime 批次正文的前缀quoteText + "\n" + 消息正文):

text
[引用 张三(10001) 于 09:59:59 的消息:今天天气怎么样
  - 李四(10002) 10:00:05:北京今天晴,最高温度 28 度
    - 王五(10003) 10:00:10:记得防晒
]
问一下上海的天气呢

结构说明:

  • 第一行是被引用/回复的消息本身:[引用 <发送者>(<QQ号>) 于 <HH:mm:ss> 的消息:<正文>——如果连发送者信息都没能拿到(get_msg 失败,或者这条消息本来就只是合并转发、没有被回复的"引用消息"这一层),会退化成更简短的 [引用消息:<正文>(没有"于 HH:mm:ss 的消息"这部分)。
  • 后续每一行是展开出来的转发节点,缩进量按 (depth + 1) * 2 个空格递增,格式是 <发送者>(<QQ号>) <HH:mm:ss>:<正文>;发送者信息缺失时退化为纯 QQ 号,QQ 号也没有则显示"未知"。
  • 结尾的 ] 在任意一处发生过截断(字符预算耗尽或转发展开被 maxNodes/环检测截断)时会变成 (已截断)],明确告诉 Agent 它看到的不是完整内容。
  • 最后一行才是这个批次真正的用户文本(已经过 stripLeadingMention 剥离前导 @bot)。

配置示例:关闭转发展开、只解析直接引用

某些场景下你可能只想让 Agent 看到"回复了什么",但不想为一次可能很深的合并转发付出额外的 SnowLuma 调用开销:

json
{
  "quote": {
    "enabled": true,
    "resolveForward": false,
    "timeoutMs": 5000
  }
}

此时 msg.forwardIds 会被完全忽略——Agent 依然能看到直接引用的消息文本,但合并转发只会在正文里显示成占位符 [合并转发](来自 renderSegmentsPLACEHOLDERS 映射),不会展开成具体内容。