外观
引用与合并转发
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 }),得到该合并转发内的消息数组,再对其中每条消息递归展开(如果它自己又包含转发,且没超过深度上限)。
三个预算共同控制这一步展开多少内容,在同一次消息解析里全局共享(不是每个转发各自独立一份预算):
| 预算 | 配置项 | 默认值 | 作用范围 |
|---|---|---|---|
| 深度 | maxDepth | 2 | 递归展开的层数上限 |
| 节点数 | maxNodes | 20 | 本次解析渲染的转发消息条数上限,跨越整棵嵌套转发树共享 |
| 字符数 | maxChars | 4000 | 引用文本 + 所有转发节点文本合计的字符上限 |
深度(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 在解析开始时会被记录进一个 Set(state.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: "[引用消息获取失败]" }(仍然消耗1个maxNodes预算)。
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 依然能看到直接引用的消息文本,但合并转发只会在正文里显示成占位符 [合并转发](来自 renderSegments 的 PLACEHOLDERS 映射),不会展开成具体内容。