Skip to content

Conversation

@clansty
Copy link
Contributor

@clansty clansty commented Jan 10, 2026

拆分的 #678
API 是和 napcat 兼容的

Summary by Sourcery

在保持与现有转发行为兼容的前提下,为 OneBot v11 实现添加对「可自定义」和「嵌套」合并转发消息的支持。

新功能:

  • 允许调用方通过 OneBot 发送接口中的可选 sourcenewssummaryprompt 字段,自定义合并转发消息的外观。
  • 在构建合并转发负载时,支持具有最大嵌套层级限制的嵌套转发和节点消息。

增强改进:

  • 扩展 MessageEncoder 和转发消息处理逻辑,使其同时适用于既有的转发 ID 和动态构建的转发内容,并对空负载进行更安全的处理。
Original summary in English

Summary by Sourcery

Add support for customizable and nested merged-forward messages in the OneBot v11 implementation while preserving compatibility with existing forward behavior.

New Features:

  • Allow callers to customize merged-forward message appearance via optional source, news, summary, and prompt fields in OneBot send APIs.
  • Support nested forward and node messages with a bounded maximum nesting depth when constructing merged-forward payloads.

Enhancements:

  • Extend the MessageEncoder and forward message handling to work with both pre-existing forward IDs and dynamically built forward contents, including safer handling of empty payloads.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Jan 10, 2026

审查者指南

为 OneBot 多消息/合并转发实现可定制展示元数据和嵌套转发消息支持,包括带深度限制的递归编码,以及用于传递自定义字段的 API 扩展。

嵌套转发消息生成与上传的时序图

sequenceDiagram
  actor Client
  participant SendForwardMsg
  participant MessageEncoder_outer as MessageEncoder_outer
  participant MessageEncoder_inner as MessageEncoder_inner
  participant App as App
  participant PMHQ as App_pmhq

  Client ->> SendForwardMsg: _handle(payload with messages and display fields)
  SendForwardMsg ->> MessageEncoder_outer: new MessageEncoder(ctx, peer, 0)
  SendForwardMsg ->> MessageEncoder_outer: generate(nodes, options)
  activate MessageEncoder_outer
  MessageEncoder_outer ->> MessageEncoder_outer: render(content)
  MessageEncoder_outer ->> MessageEncoder_outer: visit(segment type Forward with content)
  alt forward has content and depth < MAX_FORWARD_DEPTH
    MessageEncoder_outer ->> MessageEncoder_inner: new MessageEncoder(ctx, peer, depth+1)
    MessageEncoder_outer ->> MessageEncoder_inner: generate(innerNodes, options)
    activate MessageEncoder_inner
    MessageEncoder_inner ->> MessageEncoder_inner: render(innerNodes)
    MessageEncoder_inner ->> MessageEncoder_inner: visit(segment type Node)
    MessageEncoder_inner ->> MessageEncoder_inner: render(node content)
    MessageEncoder_inner ->> MessageEncoder_inner: flush()
    deactivate MessageEncoder_inner
    MessageEncoder_outer ->> PMHQ: uploadForward(peer, innerRaw.multiMsgItems)
    PMHQ -->> MessageEncoder_outer: resid
    MessageEncoder_outer ->> MessageEncoder_outer: packForwardMessage(resid, options)
    MessageEncoder_outer ->> MessageEncoder_outer: append child element and preview
  else depth >= MAX_FORWARD_DEPTH
    MessageEncoder_outer ->> SendForwardMsg: warn and stop parsing
  end
  MessageEncoder_outer ->> MessageEncoder_outer: flush()
  MessageEncoder_outer -->> SendForwardMsg: MultiMsgRaw
  deactivate MessageEncoder_outer

  SendForwardMsg ->> PMHQ: uploadForward(peer, raw.multiMsgItems)
  PMHQ -->> SendForwardMsg: resid
  SendForwardMsg ->> App: sendMessage(ctx, peer, forward element with custom source, news, summary, prompt)
  App -->> SendForwardMsg: send result
  SendForwardMsg -->> Client: Response with message_id and forward_id
Loading

更新后的 OneBot 转发消息编码类图

classDiagram
  class MessageEncoder {
    +static support string[]
    -results MsgMessageInput[]
    -children MsgElemInput[]
    -deleteAfterSentFiles string[]
    -isGroup boolean
    -tsum number
    -preview string
    -news NewsItem[]
    -name string
    -uin number
    -depth number
    +MessageEncoder(ctx Context, peer Peer, depth number)
    +flush() Promise~void~
    +visit(segment OB11MessageData) Promise~void~
    +packForwardMessage(resid string, options ForwardDisplayOptions) MsgElemInput
    +generate(content OB11MessageData[], options ForwardDisplayOptions) Promise~MultiMsgRaw~
    -render(content OB11MessageData[]) Promise~void~
  }

  class ForwardDisplayOptions {
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class OB11MessageData {
    +type OB11MessageDataType
    +data any
  }

  class OB11MessageNode {
    +type OB11MessageDataType
    +data OB11MessageNodeData
  }

  class OB11MessageNodeData {
    +content OB11MessageData[]
    +uin number
    +user_id number
    +name string
    +nickname string
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class SendForwardMsg {
    +_handle(payload SendForwardPayload) Promise~Response~
    -getMessageNode(msgInfo MsgInfo) Promise~OB11MessageNode~
    -handleFakeForwardNode(peer Peer, nodes OB11MessageNode[], options ForwardDisplayOptions) Promise~Response~
    -handleForwardNode(peer Peer, nodes OB11MessageNode[], msgInfos MsgInfo[]) Promise~Response~
  }

  class SendForwardPayload {
    +messages OB11MessageNode[]
    +message OB11MessageNode[]
    +message_type string
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class OB11PostSendMsg {
    +group_id string
    +message OB11MessageMixType
    +auto_escape boolean
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class AppPMHQ {
    +uploadForward(peer Peer, items MultiMsgItem[]) Promise~string~
  }

  class Context {
    +logger Logger
    +app App
  }

  class App {
    +pmhq AppPMHQ
    +sendMessage(ctx Context, peer Peer, elements MsgElemInput[]) Promise~any~
  }

  MessageEncoder ..> OB11MessageData
  MessageEncoder ..> OB11MessageNode
  MessageEncoder ..> ForwardDisplayOptions
  SendForwardMsg ..> MessageEncoder : uses
  SendForwardMsg ..> SendForwardPayload
  SendForwardMsg ..> OB11MessageNode
  SendForwardMsg ..> ForwardDisplayOptions
  OB11PostSendMsg ..> ForwardDisplayOptions
  MessageEncoder ..> Context
  Context ..> App
  App ..> AppPMHQ
  AppPMHQ ..> MultiMsgRaw
Loading

文件级变更

变更 详细说明 文件
扩展 MessageEncoder,以支持带可自定义转发卡片元数据和深度限制的嵌套转发/node 消息。
  • 在 MessageEncoder 中新增 MAX_FORWARD_DEPTH 和 depth 字段,并通过构造函数传递,用于限制递归转发嵌套深度。
  • 允许 MessageEncoder.support 包含 node 段,并使用 message2List 规范化 node 内容。
  • 增强 packForwardMessagegenerate,使其可以接受可选的 source/news/summary/prompt 覆盖值(带合理默认值),并在返回的 multiMsg 结构中包含 prompt
  • visit 中,处理 OB11MessageDataType.Node:检测嵌套 node 段,在存在内容时递归编码内部转发消息并上传获取 resid,否则渲染普通 node 内容。
  • visit 中,扩展 OB11MessageDataType.Forward 的处理逻辑,支持:使用已有 id(resid)并应用自定义元数据,或基于内容的嵌套 nodes,递归编码并上传。
src/onebot11/helper/createMultiMessage.ts
在 SendForwardMsg 动作中为真实和伪造转发传播自定义转发展示元数据。
  • 扩展 SendForwardMsg 的 payload 接口,增加 source/news/summary/prompt 字段,用于从 API 层传递自定义转发元数据。
  • 收紧消息选择逻辑,优先选择非空的 messages 数组,并校验至少存在一个 node。
  • 将自定义元数据选项传入 handleFakeForwardNode,再传递给 MessageEncoder.generate,并在构造伪造转发的输出 multimsg 卡片 JSON 时使用 prompt
src/onebot11/action/go-cqhttp/SendForwardMsg.ts
在 OneBot 发送后消息类型上暴露自定义转发展示选项。
  • OB11PostSendMsg 中增加可选的 source/news/summary/prompt 字段,以允许调用方指定自定义的合并转发卡片外观。
src/onebot11/types.ts

提示与命令

与 Sourcery 交互

  • 触发新审查: 在 pull request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审查评论。
  • 从审查评论生成 GitHub issue: 在某条审查评论下回复,请 Sourcery 从该评论创建一个 issue。你也可以直接回复 @sourcery-ai issue,从该评论创建 issue。
  • 生成 pull request 标题: 在 pull request 标题的任意位置写上 @sourcery-ai,即可随时生成标题。你也可以在 pull request 中评论 @sourcery-ai title 来(重新)生成标题。
  • 生成 pull request 摘要: 在 pull request body 的任意位置写上 @sourcery-ai summary,即可在指定位置生成 PR 摘要。你也可以评论 @sourcery-ai summary 来(重新)生成摘要。
  • 生成审查者指南: 在 pull request 中评论 @sourcery-ai guide,即可(重新)生成审查者指南。
  • 批量解决所有 Sourcery 评论: 在 pull request 中评论 @sourcery-ai resolve,即可将所有 Sourcery 评论标记为已解决。适合在你已经处理完所有评论且不想再看到它们时使用。
  • 批量撤销所有 Sourcery 审查: 在 pull request 中评论 @sourcery-ai dismiss,即可撤销所有现有的 Sourcery 审查。特别适合你希望以一次全新的审查开始时使用——记得再评论一次 @sourcery-ai review 来触发新的审查!

自定义你的体验

访问你的 dashboard 以:

  • 启用或禁用审查功能,例如 Sourcery 自动生成的 pull request 摘要、审查者指南等。
  • 修改审查语言。
  • 添加、删除或编辑自定义审查指令。
  • 调整其他审查设置。

获取帮助

Original review guide in English

Reviewer's Guide

Implements customizable display metadata and nested forward-message support for OneBot multi-message/merge forwarding, including depth-limited recursive encoding and API extensions to pass custom fields.

Sequence diagram for nested forward message generation and upload

sequenceDiagram
  actor Client
  participant SendForwardMsg
  participant MessageEncoder_outer as MessageEncoder_outer
  participant MessageEncoder_inner as MessageEncoder_inner
  participant App as App
  participant PMHQ as App_pmhq

  Client ->> SendForwardMsg: _handle(payload with messages and display fields)
  SendForwardMsg ->> MessageEncoder_outer: new MessageEncoder(ctx, peer, 0)
  SendForwardMsg ->> MessageEncoder_outer: generate(nodes, options)
  activate MessageEncoder_outer
  MessageEncoder_outer ->> MessageEncoder_outer: render(content)
  MessageEncoder_outer ->> MessageEncoder_outer: visit(segment type Forward with content)
  alt forward has content and depth < MAX_FORWARD_DEPTH
    MessageEncoder_outer ->> MessageEncoder_inner: new MessageEncoder(ctx, peer, depth+1)
    MessageEncoder_outer ->> MessageEncoder_inner: generate(innerNodes, options)
    activate MessageEncoder_inner
    MessageEncoder_inner ->> MessageEncoder_inner: render(innerNodes)
    MessageEncoder_inner ->> MessageEncoder_inner: visit(segment type Node)
    MessageEncoder_inner ->> MessageEncoder_inner: render(node content)
    MessageEncoder_inner ->> MessageEncoder_inner: flush()
    deactivate MessageEncoder_inner
    MessageEncoder_outer ->> PMHQ: uploadForward(peer, innerRaw.multiMsgItems)
    PMHQ -->> MessageEncoder_outer: resid
    MessageEncoder_outer ->> MessageEncoder_outer: packForwardMessage(resid, options)
    MessageEncoder_outer ->> MessageEncoder_outer: append child element and preview
  else depth >= MAX_FORWARD_DEPTH
    MessageEncoder_outer ->> SendForwardMsg: warn and stop parsing
  end
  MessageEncoder_outer ->> MessageEncoder_outer: flush()
  MessageEncoder_outer -->> SendForwardMsg: MultiMsgRaw
  deactivate MessageEncoder_outer

  SendForwardMsg ->> PMHQ: uploadForward(peer, raw.multiMsgItems)
  PMHQ -->> SendForwardMsg: resid
  SendForwardMsg ->> App: sendMessage(ctx, peer, forward element with custom source, news, summary, prompt)
  App -->> SendForwardMsg: send result
  SendForwardMsg -->> Client: Response with message_id and forward_id
Loading

Class diagram for updated OneBot forward message encoding

classDiagram
  class MessageEncoder {
    +static support string[]
    -results MsgMessageInput[]
    -children MsgElemInput[]
    -deleteAfterSentFiles string[]
    -isGroup boolean
    -tsum number
    -preview string
    -news NewsItem[]
    -name string
    -uin number
    -depth number
    +MessageEncoder(ctx Context, peer Peer, depth number)
    +flush() Promise~void~
    +visit(segment OB11MessageData) Promise~void~
    +packForwardMessage(resid string, options ForwardDisplayOptions) MsgElemInput
    +generate(content OB11MessageData[], options ForwardDisplayOptions) Promise~MultiMsgRaw~
    -render(content OB11MessageData[]) Promise~void~
  }

  class ForwardDisplayOptions {
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class OB11MessageData {
    +type OB11MessageDataType
    +data any
  }

  class OB11MessageNode {
    +type OB11MessageDataType
    +data OB11MessageNodeData
  }

  class OB11MessageNodeData {
    +content OB11MessageData[]
    +uin number
    +user_id number
    +name string
    +nickname string
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class SendForwardMsg {
    +_handle(payload SendForwardPayload) Promise~Response~
    -getMessageNode(msgInfo MsgInfo) Promise~OB11MessageNode~
    -handleFakeForwardNode(peer Peer, nodes OB11MessageNode[], options ForwardDisplayOptions) Promise~Response~
    -handleForwardNode(peer Peer, nodes OB11MessageNode[], msgInfos MsgInfo[]) Promise~Response~
  }

  class SendForwardPayload {
    +messages OB11MessageNode[]
    +message OB11MessageNode[]
    +message_type string
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class OB11PostSendMsg {
    +group_id string
    +message OB11MessageMixType
    +auto_escape boolean
    +source string
    +news NewsItem[]
    +summary string
    +prompt string
  }

  class AppPMHQ {
    +uploadForward(peer Peer, items MultiMsgItem[]) Promise~string~
  }

  class Context {
    +logger Logger
    +app App
  }

  class App {
    +pmhq AppPMHQ
    +sendMessage(ctx Context, peer Peer, elements MsgElemInput[]) Promise~any~
  }

  MessageEncoder ..> OB11MessageData
  MessageEncoder ..> OB11MessageNode
  MessageEncoder ..> ForwardDisplayOptions
  SendForwardMsg ..> MessageEncoder : uses
  SendForwardMsg ..> SendForwardPayload
  SendForwardMsg ..> OB11MessageNode
  SendForwardMsg ..> ForwardDisplayOptions
  OB11PostSendMsg ..> ForwardDisplayOptions
  MessageEncoder ..> Context
  Context ..> App
  App ..> AppPMHQ
  AppPMHQ ..> MultiMsgRaw
Loading

File-Level Changes

Change Details Files
Extend MessageEncoder to support nested forward/node messages with customizable forward card metadata and depth limiting.
  • Add MAX_FORWARD_DEPTH and a depth field to MessageEncoder with constructor propagation to limit recursive forward nesting.
  • Allow MessageEncoder.support to include 'node' segments and use message2List to normalize node content.
  • Enhance packForwardMessage and generate to accept optional source/news/summary/prompt overrides with sensible defaults, and include prompt in the returned multiMsg structure.
  • In visit, handle OB11MessageDataType.Node by detecting nested node segments, recursively encoding inner forward messages when present, uploading them to obtain resid, and otherwise rendering normal node content.
  • In visit, extend OB11MessageDataType.Forward handling to support either an existing id (resid) with custom metadata or content-based nested nodes that are encoded and uploaded recursively.
src/onebot11/helper/createMultiMessage.ts
Propagate custom forward display metadata through the SendForwardMsg action for both real and fake forwards.
  • Extend SendForwardMsg payload interface with source/news/summary/prompt fields to carry custom forward metadata from the API layer.
  • Tighten message selection logic to prefer non-empty messages arrays and validate that at least one node exists.
  • Pass custom metadata options into handleFakeForwardNode, thread them into MessageEncoder.generate, and use prompt when constructing the outgoing multimsg card JSON for fake forwards.
src/onebot11/action/go-cqhttp/SendForwardMsg.ts
Expose custom forward display options on the OneBot post-send message type.
  • Add optional source/news/summary/prompt fields to OB11PostSendMsg to allow callers to specify custom merge-forward card appearance.
src/onebot11/types.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - 我发现了两个问题,并留下了一些整体性的反馈:

  • visit 方法中对嵌套转发的处理在 NodeForward 分支都重复了深度检查和构建选项的逻辑;可以考虑提取一个小的辅助函数(例如 buildForwardOptions / createNestedForward)来减少重复,并让递归规则更容易理解。
  • visitForward 分支中,当提供了 content 但没有找到任何 Node 元素时,该方法会记录警告并直接返回,而不会渲染非 node 片段或更新 preview;请澄清这是否是预期行为,如果不是,建议处理或明确拒绝这类 payload,而不是静默丢弃其中的内容。
  • 针对转发选项,目前在多个地方使用了 as any 以及零散的内联类型字面量(例如在 nodeData 处理和 forwardData 中);通过使用共享的接口,或扩展 OB11MessageNode/OB11MessageData,可以让新增的自定义展示字段(sourcenewssummaryprompt)在类型上更安全,也更易维护。
AI 代理的提示词
请根据这次代码评审中的评论进行修改:

## 总体评论
- `visit` 方法中对嵌套转发的处理在 `Node``Forward` 分支都重复了深度检查和构建选项的逻辑;可以考虑提取一个小的辅助函数(例如 `buildForwardOptions` / `createNestedForward`)来减少重复,并让递归规则更容易理解。
-`visit``Forward` 分支中,当提供了 `content` 但没有找到任何 `Node` 元素时,该方法会记录警告并直接返回,而不会渲染非 node 片段或更新 `preview`;请澄清这是否是预期行为,如果不是,建议处理或明确拒绝这类 payload,而不是静默丢弃其中的内容。
- 针对转发选项,目前在多个地方使用了 `as any` 以及零散的内联类型字面量(例如在 `nodeData` 处理和 `forwardData` 中);通过使用共享的接口,或扩展 `OB11MessageNode`/`OB11MessageData`,可以让新增的自定义展示字段(`source``news``summary``prompt`)在类型上更安全,也更易维护。

## 单独评论

### 评论 1
<location> `src/onebot11/helper/createMultiMessage.ts:209-210` </location>
<code_context>
+
+        // 递归生成内层合并转发
+        const innerEncoder = new MessageEncoder(this.ctx, this.peer, this.depth + 1)
+        const innerNodes = content.filter(e => e.type === OB11MessageDataType.Node) as OB11MessageNode[]
+        const innerRaw = await innerEncoder.generate(innerNodes, nestedOptions)
+
+        // 上传内层合并转发,获取 resid
</code_context>

<issue_to_address>
**question (bug_risk):** 嵌套 node 的处理会忽略该 node 的 `content` 中的非 node 元素,可能导致数据丢失。

当 `hasNestedNodes` 为 true 时,`innerEncoder` 只接收 `innerNodes`,会忽略 `content` 中的非 node 条目。如果协议允许在同一内容中混合 node 和其他 segment 类型,那么这些非 node 部分在嵌套转发中就会丢失。如果需要保留混合内容,请将完整的 `content` 传入 `innerEncoder.generate`。否则,请在节点中显式文档化或防护这类混合内容,以避免误用。
</issue_to_address>

### 评论 2
<location> `src/onebot11/helper/createMultiMessage.ts:11` </location>
<code_context>
 import faceConfig from '@/ntqqapi/helper/face_config.json'
 import { InferProtoModelInput } from '@saltify/typeproto'

+// 最大嵌套深度
+const MAX_FORWARD_DEPTH = 3
+
</code_context>

<issue_to_address>
**issue (complexity):** 建议将新的嵌套转发处理逻辑拆分为共享的辅助函数和类型,以去除重复代码,并简化深度检查与转发选项相关的控制流程。

在保留所有新功能的前提下,通过一些小的抽象可以显著降低局部复杂度。

### 1. 集中处理深度检查

`MAX_FORWARD_DEPTH` 的检查在 `Node``Forward` 分支中重复出现;将其抽到一个辅助方法中可以把递归规则集中到一个位置:

```ts
// class MessageEncoder
private ensureDepthAllowed(context: string): boolean {
  if (this.depth >= MAX_FORWARD_DEPTH) {
    this.ctx.logger.warn(`合并转发嵌套深度超过 ${MAX_FORWARD_DEPTH} 层,将停止解析:${context}`)
    return false
  }
  return true
}
```

用法示例:

```ts
if (hasNestedNodes) {
  if (!this.ensureDepthAllowed('node')) return
  // ...
}

} else if (type === OB11MessageDataType.Forward) {
  // ...
  } else if (forwardData.content) {
    if (!this.ensureDepthAllowed('forward')) return
    // ...
  }
}
```

这样可以删除重复的 `if (this.depth >= MAX_FORWARD_DEPTH)` 分支,并让日志格式保持一致。

### 2. 抽取共享的嵌套转发生成逻辑

`Node``Forward` 中的内层转发生成逻辑几乎完全相同。提取一个辅助方法可以提升可读性并减少重复:

```ts
// Shared type; see next section for definition
private async createNestedForward(
  rawContent: OB11MessageData[],
  options: ForwardDisplayOptions,
) {
  const normalized = message2List(rawContent)
  const innerNodes = normalized.filter(
    e => e.type === OB11MessageDataType.Node
  ) as OB11MessageNode[]

  if (innerNodes.length === 0) {
    this.ctx.logger.warn('nested forward content 中没有有效的 node 节点')
    return
  }

  const innerEncoder = new MessageEncoder(this.ctx, this.peer, this.depth + 1)
  const innerRaw = await innerEncoder.generate(innerNodes, options)
  const resid = await this.ctx.app.pmhq.uploadForward(this.peer, innerRaw.multiMsgItems)
  this.deleteAfterSentFiles.push(...innerEncoder.deleteAfterSentFiles)
  this.children.push(this.packForwardMessage(resid, options))
  this.preview += '[聊天记录]'
}
```

然后,两个调用点就会变得简单很多:

```ts
// Node branch
if (hasNestedNodes) {
  if (!this.ensureDepthAllowed('node')) return
  const nestedOptions = this.extractForwardOptionsFromNode(nodeData)
  await this.createNestedForward(content, nestedOptions)
} else {
  await this.render(content)
}

// Forward branch
} else if (forwardData.content) {
  if (!this.ensureDepthAllowed('forward')) return
  await this.createNestedForward(forwardData.content, {
    source: forwardData.source,
    news: forwardData.news,
    summary: forwardData.summary,
    prompt: forwardData.prompt,
  })
}
```

### 3. 引入共享的 `ForwardDisplayOptions` 类型和提取函数

当前选项的结构在多个位置被重复使用,而且部分是未严格建模的类型。通过定义一个共享类型,并为 node 提供一个选项提取函数,可以去除强制类型转换,并在未来更改时集中修改:

```ts
interface ForwardDisplayOptions {
  source?: string
  news?: { text: string }[]
  summary?: string
  prompt?: string
}
```

在函数签名中使用它:

```ts
packForwardMessage(resid: string, options?: ForwardDisplayOptions) { ... }

async generate(content: OB11MessageData[], options?: ForwardDisplayOptions) { ... }
```

并集中处理 node 的选项提取:

```ts
private extractForwardOptionsFromNode(nodeData: OB11MessageNode['data']): ForwardDisplayOptions {
  const anyData = nodeData as any
  return {
    source: anyData.source,
    news: anyData.news,
    summary: anyData.summary,
    prompt: anyData.prompt,
  }
}
```

这样 `visit` 中就可以避免随处进行临时的类型断言:

```ts
const nestedOptions = this.extractForwardOptionsFromNode(nodeData)
````Forward` 分支已经使用了兼容的结构。

### 4. 通过一次性拆分内容来扁平化 `Node` 的处理逻辑

目前代码会先计算 `hasNestedNodes` 再分支;改为将内容一次性拆分为 node 与非 node 段,可以让控制流更线性:

```ts
const content = nodeData.content ? message2List(nodeData.content) : []
const nodeSegments = content.filter(e => e.type === OB11MessageDataType.Node)
const normalSegments = content.filter(e => e.type !== OB11MessageDataType.Node)

if (normalSegments.length) {
  await this.render(normalSegments)
}

if (nodeSegments.length) {
  if (!this.ensureDepthAllowed('node')) return
  const nestedOptions = this.extractForwardOptionsFromNode(nodeData)
  await this.createNestedForward(nodeSegments, nestedOptions)
}
```

这样可以将“渲染普通内容”和“处理嵌套转发”分离开来,避免在同一个 `if/else` 分支中混合不同关注点。

### 5. 统一转发的 prompt/preview 行为

`'[聊天记录]'` 和 prompt 的默认值逻辑目前比较分散。既然 `generate` 已经定义了默认值,可以依赖它,并通过让 `createNestedForward` 统一处理 preview 的更新来移除部分分支中额外的 `this.preview += '[聊天记录]'`,同时对基于 `id` 的已有转发在单一位置处理:

```ts
} else if (type === OB11MessageDataType.Forward) {
  const forwardData: ForwardDisplayOptions & { id?: string; content?: OB11MessageData[] } = data

  if (forwardData.id) {
    this.children.push(this.packForwardMessage(forwardData.id, forwardData))
    this.preview += '[聊天记录]'
  } else if (forwardData.content) {
    if (!this.ensureDepthAllowed('forward')) return
    await this.createNestedForward(forwardData.content, forwardData)
  }
}
```

这样,prompt/preview 的默认值由 `packForwardMessage` / `generate` 统一决定,而转发处理代码只需要决定在何时添加预览标记,而不是在多个位置决定“标记内容是什么”。
</issue_to_address>

Sourcery 对开源项目免费使用——如果你觉得这次评审有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进评审质量。
Original comment in English

Hey - I've found 2 issues, and left some high level feedback:

  • The nested forward handling in visit duplicates depth checks and option-building logic for both Node and Forward branches; consider extracting a small helper (e.g. buildForwardOptions / createNestedForward) to reduce repetition and make the recursion rules easier to reason about.
  • In the Forward branch of visit, when content is provided but no Node elements are found, the method logs a warning and returns without rendering non-node segments or updating preview; clarify whether this is intended or, if not, process or explicitly reject such payloads instead of silently dropping their content.
  • There are several as any and ad-hoc inline type literals for forward options (e.g. in nodeData handling and forwardData); tightening these with shared interfaces or extending OB11MessageNode/OB11MessageData will make the new custom display fields (source, news, summary, prompt) safer and easier to maintain.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The nested forward handling in `visit` duplicates depth checks and option-building logic for both `Node` and `Forward` branches; consider extracting a small helper (e.g. `buildForwardOptions` / `createNestedForward`) to reduce repetition and make the recursion rules easier to reason about.
- In the `Forward` branch of `visit`, when `content` is provided but no `Node` elements are found, the method logs a warning and returns without rendering non-node segments or updating `preview`; clarify whether this is intended or, if not, process or explicitly reject such payloads instead of silently dropping their content.
- There are several `as any` and ad-hoc inline type literals for forward options (e.g. in `nodeData` handling and `forwardData`); tightening these with shared interfaces or extending `OB11MessageNode`/`OB11MessageData` will make the new custom display fields (`source`, `news`, `summary`, `prompt`) safer and easier to maintain.

## Individual Comments

### Comment 1
<location> `src/onebot11/helper/createMultiMessage.ts:209-210` </location>
<code_context>
+
+        // 递归生成内层合并转发
+        const innerEncoder = new MessageEncoder(this.ctx, this.peer, this.depth + 1)
+        const innerNodes = content.filter(e => e.type === OB11MessageDataType.Node) as OB11MessageNode[]
+        const innerRaw = await innerEncoder.generate(innerNodes, nestedOptions)
+
+        // 上传内层合并转发,获取 resid
</code_context>

<issue_to_address>
**question (bug_risk):** Nested node handling ignores non-node elements in a node’s `content`, which may drop data.

When `hasNestedNodes` is true, `innerEncoder` receives only `innerNodes` and ignores any non-node entries in `content`. If the protocol permits mixing nodes with other segment types, those non-node parts will be lost in nested forwards. If mixed content should be preserved, pass the full `content` into `innerEncoder.generate`. Otherwise, explicitly document or guard against mixed content in nodes.
</issue_to_address>

### Comment 2
<location> `src/onebot11/helper/createMultiMessage.ts:11` </location>
<code_context>
 import faceConfig from '@/ntqqapi/helper/face_config.json'
 import { InferProtoModelInput } from '@saltify/typeproto'

+// 最大嵌套深度
+const MAX_FORWARD_DEPTH = 3
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the new nested-forward handling into shared helpers and types to remove duplication and simplify the control flow for depth checks and forward options.

You can keep all the new functionality while shaving off quite a bit of local complexity with some small extra abstractions.

### 1. Centralize depth handling

The `MAX_FORWARD_DEPTH` check is duplicated in both `Node` and `Forward` branches; pulling it into a helper keeps the recursion rules in one place:

```ts
// class MessageEncoder
private ensureDepthAllowed(context: string): boolean {
  if (this.depth >= MAX_FORWARD_DEPTH) {
    this.ctx.logger.warn(`合并转发嵌套深度超过 ${MAX_FORWARD_DEPTH} 层,将停止解析:${context}`)
    return false
  }
  return true
}
```

Usage:

```ts
if (hasNestedNodes) {
  if (!this.ensureDepthAllowed('node')) return
  // ...
}

} else if (type === OB11MessageDataType.Forward) {
  // ...
  } else if (forwardData.content) {
    if (!this.ensureDepthAllowed('forward')) return
    // ...
  }
}
```

This removes the duplicated `if (this.depth >= MAX_FORWARD_DEPTH)` branches and keeps the log format consistent.

### 2. Extract shared nested-forward generation

The inner-forward generation logic is almost identical in `Node` and `Forward`. Extracting a helper keeps the flow readable and reduces duplication:

```ts
// Shared type; see next section for definition
private async createNestedForward(
  rawContent: OB11MessageData[],
  options: ForwardDisplayOptions,
) {
  const normalized = message2List(rawContent)
  const innerNodes = normalized.filter(
    e => e.type === OB11MessageDataType.Node
  ) as OB11MessageNode[]

  if (innerNodes.length === 0) {
    this.ctx.logger.warn('nested forward content 中没有有效的 node 节点')
    return
  }

  const innerEncoder = new MessageEncoder(this.ctx, this.peer, this.depth + 1)
  const innerRaw = await innerEncoder.generate(innerNodes, options)
  const resid = await this.ctx.app.pmhq.uploadForward(this.peer, innerRaw.multiMsgItems)
  this.deleteAfterSentFiles.push(...innerEncoder.deleteAfterSentFiles)
  this.children.push(this.packForwardMessage(resid, options))
  this.preview += '[聊天记录]'
}
```

Then the two call sites become much simpler:

```ts
// Node branch
if (hasNestedNodes) {
  if (!this.ensureDepthAllowed('node')) return
  const nestedOptions = this.extractForwardOptionsFromNode(nodeData)
  await this.createNestedForward(content, nestedOptions)
} else {
  await this.render(content)
}

// Forward branch
} else if (forwardData.content) {
  if (!this.ensureDepthAllowed('forward')) return
  await this.createNestedForward(forwardData.content, {
    source: forwardData.source,
    news: forwardData.news,
    summary: forwardData.summary,
    prompt: forwardData.prompt,
  })
}
```

### 3. Introduce a shared `ForwardDisplayOptions` type + extractor

The options shape is currently duplicated and partly untyped. Defining a shared type and an extractor for nodes removes casts and keeps future changes localized:

```ts
interface ForwardDisplayOptions {
  source?: string
  news?: { text: string }[]
  summary?: string
  prompt?: string
}
```

Use it in signatures:

```ts
packForwardMessage(resid: string, options?: ForwardDisplayOptions) { ... }

async generate(content: OB11MessageData[], options?: ForwardDisplayOptions) { ... }
```

And centralize node option extraction:

```ts
private extractForwardOptionsFromNode(nodeData: OB11MessageNode['data']): ForwardDisplayOptions {
  const anyData = nodeData as any
  return {
    source: anyData.source,
    news: anyData.news,
    summary: anyData.summary,
    prompt: anyData.prompt,
  }
}
```

Then `visit` can avoid ad-hoc casting:

```ts
const nestedOptions = this.extractForwardOptionsFromNode(nodeData)
```

and the `Forward` branch is already using a compatible shape.

### 4. Flatten `Node` handling by splitting content once

Right now you compute `hasNestedNodes` and branch; splitting once into node vs non-node segments can make the control flow more linear:

```ts
const content = nodeData.content ? message2List(nodeData.content) : []
const nodeSegments = content.filter(e => e.type === OB11MessageDataType.Node)
const normalSegments = content.filter(e => e.type !== OB11MessageDataType.Node)

if (normalSegments.length) {
  await this.render(normalSegments)
}

if (nodeSegments.length) {
  if (!this.ensureDepthAllowed('node')) return
  const nestedOptions = this.extractForwardOptionsFromNode(nodeData)
  await this.createNestedForward(nodeSegments, nestedOptions)
}
```

This separates “render normal content” from “handle nested forward” and avoids mixing concerns in a single `if/else`.

### 5. Centralize prompt/preview behavior for forwards

`'[聊天记录]'` and prompt defaulting are scattered. Since `generate` already defines defaults, you can rely on it and remove the extra manual `this.preview += '[聊天记录]'` in some branches by letting `createNestedForward` handle preview updates consistently (as in the helper above), and for existing `id`-based forwards, single place:

```ts
} else if (type === OB11MessageDataType.Forward) {
  const forwardData: ForwardDisplayOptions & { id?: string; content?: OB11MessageData[] } = data

  if (forwardData.id) {
    this.children.push(this.packForwardMessage(forwardData.id, forwardData))
    this.preview += '[聊天记录]'
  } else if (forwardData.content) {
    if (!this.ensureDepthAllowed('forward')) return
    await this.createNestedForward(forwardData.content, forwardData)
  }
}
```

This way, prompt/preview defaults are determined in `packForwardMessage`/`generate`, and forward handling code only decides *when* to append a preview marker, not *what* it is in multiple places.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +209 to +210
const innerNodes = content.filter(e => e.type === OB11MessageDataType.Node) as OB11MessageNode[]
const innerRaw = await innerEncoder.generate(innerNodes, nestedOptions)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): 嵌套 node 的处理会忽略该 node 的 content 中的非 node 元素,可能导致数据丢失。

hasNestedNodes 为 true 时,innerEncoder 只接收 innerNodes,会忽略 content 中的非 node 条目。如果协议允许在同一内容中混合 node 和其他 segment 类型,那么这些非 node 部分在嵌套转发中就会丢失。如果需要保留混合内容,请将完整的 content 传入 innerEncoder.generate。否则,请在节点中显式文档化或防护这类混合内容,以避免误用。

Original comment in English

question (bug_risk): Nested node handling ignores non-node elements in a node’s content, which may drop data.

When hasNestedNodes is true, innerEncoder receives only innerNodes and ignores any non-node entries in content. If the protocol permits mixing nodes with other segment types, those non-node parts will be lost in nested forwards. If mixed content should be preserved, pass the full content into innerEncoder.generate. Otherwise, explicitly document or guard against mixed content in nodes.

@idranme idranme changed the base branch from main to dev January 12, 2026 04:32
@idranme
Copy link
Collaborator

idranme commented Jan 12, 2026

LGTM

@idranme idranme merged commit 11186c5 into LLOneBot:dev Jan 12, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants