Skip to content

Conversation

@CookSleep
Copy link

背景

在使用 Napcat/合并转发聊天记录时,ChatLuna 的输入消息转换链路可能会过滤掉转发记录元素,导致上下文中无法保留“存在聊天记录”这一信息。

改动

  • 新增配置项 attachForwardMsgIdToContext(位于“对话行为选项”末尾,默认关闭)。
  • 开启后:
    • 检测到消息元素中存在合并转发/转发记录元素时,会递归收集可能的消息 ID 字段(idmessage_idmessageIdres_idresIdforward_idforwardId)。
    • 将收集到的 ID 数组写入 inputMessage.additional_kwargs.forwardMessageIds
    • 向输入消息内容中追加一段文本标记 [聊天记录],避免在过滤后消息为空而被中间件直接丢弃。

兼容性

  • 默认关闭,不影响现有行为。
  • 仅在检测到转发记录元素时才追加标记和附加 IDs。

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 8, 2026

Walkthrough

在配置接口中新增布尔字段 attachForwardMsgIdToContext(默认 false)。聊天读取中间件改为在已转换消息的 additional_kwargs 中使用内部键保存转发历史(而非外部 WeakMap),新增转发检测与收集逻辑、拦截钩子与辅助函数;当配置启用且存在转发历史时,将收集到的 ID 写入 additional_kwargs.forwardMessageIds 并附加 “[聊天记录]” 标签,然后移除内部键。

Changes

Cohort / File(s) Summary
配置扩展
packages/core/src/config.ts
Config 接口新增布尔字段 attachForwardMsgIdToContext,并在导出的 Config schema 中加入 attachForwardMsgIdToContext: Schema.boolean().default(false)
消息处理中间件增强(核心逻辑)
packages/core/src/middlewares/chat/read_chat_message.ts
移除/替换先前基于 WeakMap 的转发历史存储,改为在 transformedMessage.additional_kwargs 使用内部键(forwardHistoryInternalKey)保存 per-message 状态;新增拦截钩子 ctx.chatluna.messageTransformer.forward.message 用于初始化/更新状态并收集转发 ID;在最终转换阶段(若 config.attachForwardMsgIdToContext 为真且存在历史)将规范化后的 state.ids 写入 additional_kwargs.forwardMessageIds,通过 addMessageContent 附加 “[聊天记录]”,随后移除内部键;新增并使用辅助函数 pickForwardMessageIdisForwardMessageElementnormalizeForwardMessageId 来支持从多种属性键(如 message_idmessageId 等)提取并规范化 ID。

Sequence Diagram(s)

sequenceDiagram
  participant Client as Incoming Message
  participant Middleware as read_chat_message
  participant Transformer as Message Transformer
  participant Message as transformedMessage

  Client->>Middleware: 发送包含元素的原始消息
  Middleware->>Transformer: 拦截 forward/message 钩子
  Transformer->>Middleware: 初始化/更新内部状态(state.ids)
  Middleware->>Middleware: 遍历元素,检测 isForwardMessageElement\n调用 pickForwardMessageId -> normalizeForwardMessageId -> 收集到 state.ids
  alt config.attachForwardMsgIdToContext && state.ids 非空
    Middleware->>Message: 确保 additional_kwargs 存在
    Middleware->>Message: 写入 additional_kwargs.forwardMessageIds = state.ids
    Middleware->>Message: 调用 addMessageContent("[聊天记录]")
    Middleware->>Message: 移除内部键 forwardHistoryInternalKey
  end
  Middleware->>Transformer: 将已处理的 transformedMessage 继续下游处理
Loading

预计代码审查工作量

🎯 3 (中等) | ⏱️ ~20 分钟

诗篇

🐰✨
我在代码田野里嗅到线索,
把转发的脚印轻轻串成行,
放入上下文里让记忆不散,
一句“[聊天记录]”是我的名签,
小腿一蹬,悄悄藏好这些光。

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 标题准确总结了主要变更:通过新增可选配置项来选择性地将转发消息 ID 附加到上下文并添加标记。
Description check ✅ Passed PR 描述详细说明了背景、具体改动和兼容性考虑,与实际代码变更高度相关。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
packages/core/src/middlewares/chat/read_chat_message.ts (2)

115-170: forwardmessage 拦截器存在大量重复代码,建议抽取公共函数。

两个拦截器(lines 115-141 和 143-170)的状态初始化、ID 收集、写回逻辑几乎完全相同,仅入口判断不同。可以抽取一个共享的辅助函数来减少重复。

♻️ 建议的重构
+function collectForwardState(
+    element: h,
+    message: Message,
+    internalKey: string
+) {
+    if (!message.additional_kwargs) message.additional_kwargs = {}
+    const additionalKwargs = message.additional_kwargs as Record<string, unknown>
+    const state = additionalKwargs[internalKey] as
+        | { ids: string[]; hasForwardHistory: boolean }
+        | undefined
+
+    const current = state ?? { ids: [], hasForwardHistory: false }
+    current.hasForwardHistory = true
+
+    const normalizedId = pickForwardMessageId(element)
+    if (normalizedId && !current.ids.includes(normalizedId)) {
+        current.ids.push(normalizedId)
+    }
+
+    additionalKwargs[internalKey] = current
+}

然后两个拦截器简化为:

 ctx.chatluna.messageTransformer.intercept(
     'forward',
     async (session, element, message) => {
         if (!config.attachForwardMsgIdToContext) return
-        // ... 20+ lines of duplicated logic
+        collectForwardState(element, message, forwardHistoryInternalKey)
     }
 )

 ctx.chatluna.messageTransformer.intercept(
     'message',
     async (session, element, message) => {
         if (!config.attachForwardMsgIdToContext) return
         if (!isForwardMessageElement(element)) return
-        // ... 20+ lines of duplicated logic
+        collectForwardState(element, message, forwardHistoryInternalKey)
     }
 )

525-536: normalizeForwardMessageId 对大整数 ID 可能存在精度丢失。

valuenumber 类型且超过 Number.MAX_SAFE_INTEGER(如某些平台的 snowflake ID)时,String(value) 会丢失精度。实际场景中,适配器通常以字符串传递此类 ID,但作为防御措施可增加一个安全检查。

💡 可选的防御性改进
 function normalizeForwardMessageId(value: unknown): string | null {
     if (typeof value === 'number' && Number.isFinite(value)) {
+        if (!Number.isSafeInteger(value)) {
+            logger.warn(`Forward message ID ${value} exceeds safe integer range, precision may be lost`)
+        }
         return String(value)
     }

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @CookSleep, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求旨在解决 ChatLuna 在处理合并转发聊天记录时可能丢失关键信息的问题。通过引入一个可选的配置项,它允许系统在检测到转发消息时,将其相关的 ID 附加到消息上下文中,并向消息内容追加一个 [聊天记录] 标记。这确保了即使消息内容被转换或过滤,关于转发记录的存在性及其关联 ID 的信息也能被保留,从而提高了系统处理此类消息的鲁棒性和准确性。此功能默认关闭,不影响现有行为。

Highlights

  • 新增配置项: 引入了一个新的配置项 attachForwardMsgIdToContext,默认关闭。此选项控制是否将合并转发消息的 ID 附加到消息上下文中。
  • 转发消息ID收集: 当 attachForwardMsgIdToContext 启用且检测到消息中包含合并转发/转发记录元素时,系统会递归收集消息 ID(包括 id, message_id, messageId, res_id, resId, forward_id, forwardId 等字段)。
  • 上下文增强: 收集到的转发消息 ID 数组将被写入 inputMessage.additional_kwargs.forwardMessageIds,从而在上下文中保留这些关键信息。
  • 消息内容标记: 为了避免在消息内容被过滤后导致消息为空而被中间件丢弃,当检测到转发记录时,会在输入消息内容中追加一个 [聊天记录] 的文本标记。
  • 辅助函数: 新增了 hasForwardMessageElementcollectForwardMessageIdsisForwardMessageElementnormalizeForwardMessageId 等辅助函数,用于检测和处理转发消息元素。

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • packages/core/src/config.ts
    • Config 接口中添加了 attachForwardMsgIdToContext: boolean 属性。
    • 在配置 Schema 中添加了 attachForwardMsgIdToContext 字段,类型为布尔值,默认值为 false
  • packages/core/src/locales/en-US.schema.yml
    • attachForwardMsgIdToContext 配置项添加了英文本地化描述。
  • packages/core/src/locales/zh-CN.schema.yml
    • attachForwardMsgIdToContext 配置项添加了中文本地化描述。
  • packages/core/src/middlewares/chat/read_chat_message.ts
    • 引入了根据 config.attachForwardMsgIdToContexthasForwardMessageElement 判断是否存在转发历史的逻辑。
    • 在存在转发历史时,调用 collectForwardMessageIds 收集消息 ID。
    • 修改了 transformedMessage,在存在转发历史时,将其 additional_kwargs.forwardMessageIds 设置为收集到的 ID,并向内容追加 [聊天记录] 标记。
    • 新增了四个辅助函数:hasForwardMessageElementcollectForwardMessageIdsisForwardMessageElementnormalizeForwardMessageId,用于处理转发消息元素的检测和 ID 提取。
Activity
  • 自创建以来,此拉取请求尚未有评论或审查活动。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

本次 PR 引入了一项处理转发聊天记录的新功能,这是一个很有价值的补充。实现结构清晰,通过增加新的配置项和相应的逻辑来检测、处理和标记转发消息。代码总体上是清晰的,并且遵循了 PR 描述中的逻辑。

我的主要反馈与改进新添加的辅助函数中的 TypeScript 类型有关。为消息元素使用 Koishi 的正确类型将增强类型安全性和代码可读性。对此我提供了一些具体的建议。

总的来说,这是一次不错的贡献。

Comment on lines 429 to 441
function hasForwardMessageElement(elements: h[]): boolean {
return elements.some((element) => {
if (!element) return false
if (isForwardMessageElement(element)) return true

const children = (element as unknown as { children?: unknown }).children
return (
Array.isArray(children) &&
children.length > 0 &&
hasForwardMessageElement(children as h[])
)
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

在 TypeScript 中,h 是 Koishi 框架中用于创建消息元素的函数,而不是一个类型。消息元素的正确类型应该是 h.Element。在此函数中,h 被错误地用作类型注解,这导致了需要使用 as unknown 这样的类型断言来访问 children 属性,降低了代码的类型安全性和可读性。

建议将类型注解中的 h 修正为 h.Element,并移除不必要的类型断言,使代码更加健壮和清晰。

Suggested change
function hasForwardMessageElement(elements: h[]): boolean {
return elements.some((element) => {
if (!element) return false
if (isForwardMessageElement(element)) return true
const children = (element as unknown as { children?: unknown }).children
return (
Array.isArray(children) &&
children.length > 0 &&
hasForwardMessageElement(children as h[])
)
})
}
function hasForwardMessageElement(elements: h.Element[]): boolean {
return elements.some((element) => {
if (!element) return false
if (isForwardMessageElement(element)) return true
const children = element.children
return (
Array.isArray(children) &&
children.length > 0 &&
hasForwardMessageElement(children)
)
})
}

Comment on lines 443 to 478
function collectForwardMessageIds(elements: h[]): string[] {
const forwardMessageIds = new Set<string>()

const visit = (element: h) => {
if (!element) return

if (isForwardMessageElement(element)) {
const attrs = element.attrs ?? {}
for (const key of [
'id',
'message_id',
'messageId',
'res_id',
'resId',
'forward_id',
'forwardId'
]) {
const normalizedId = normalizeForwardMessageId(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(attrs as any)[key]
)
if (normalizedId) {
forwardMessageIds.add(normalizedId)
}
}
}

const children = (element as unknown as { children?: unknown }).children
if (Array.isArray(children) && children.length > 0) {
;(children as h[]).forEach(visit)
}
}

elements.forEach(visit)
return Array.from(forwardMessageIds)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

hasForwardMessageElement 函数类似,此函数也错误地将 h 用作类型。应使用 h.Element 作为正确的元素类型。这可以消除对 children 属性的不安全访问,并提高代码的整体质量。

修正类型后,可以安全地直接访问 element.children 并对其进行迭代,而无需 as unknownas h[] 等类型断言。

function collectForwardMessageIds(elements: h.Element[]): string[] {
    const forwardMessageIds = new Set<string>()

    const visit = (element: h.Element) => {
        if (!element) return

        if (isForwardMessageElement(element)) {
            const attrs = element.attrs ?? {}
            for (const key of [
                'id',
                'message_id',
                'messageId',
                'res_id',
                'resId',
                'forward_id',
                'forwardId'
            ]) {
                const normalizedId = normalizeForwardMessageId(
                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
                    (attrs as any)[key]
                )
                if (normalizedId) {
                    forwardMessageIds.add(normalizedId)
                }
            }
        }

        const children = element.children
        if (Array.isArray(children) && children.length > 0) {
            children.forEach(visit)
        }
    }

    elements.forEach(visit)
    return Array.from(forwardMessageIds)
}

Comment on lines 480 to 503
function isForwardMessageElement(element: h): boolean {
if (!element) return false
if (element.type === 'forward') return true
if (element.type !== 'message') return false

const attrs = element.attrs ?? {}
const forward = attrs['forward']

if (
forward === true ||
forward === 'true' ||
forward === 1 ||
forward === '1'
) {
return true
}

return (
attrs['forward_id'] != null ||
attrs['forwardId'] != null ||
attrs['res_id'] != null ||
attrs['resId'] != null
)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

此函数同样存在将 h 误用为类型的问题,应修正为 h.Element

此外,对 forward 属性的检查可以写得更简洁。通过将其转换为字符串进行比较,可以减少布尔表达式的复杂性,使其更易于阅读和维护。

function isForwardMessageElement(element: h.Element): boolean {
    if (!element) return false
    if (element.type === 'forward') return true
    if (element.type !== 'message') return false

    const attrs = element.attrs ?? {}

    if (['true', '1'].includes(String(attrs.forward))) {
        return true
    }

    return (
        attrs['forward_id'] != null ||
        attrs['forwardId'] != null ||
        attrs['res_id'] != null ||
        attrs['resId'] != null
    )
}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/core/src/middlewares/chat/read_chat_message.ts`:
- Around line 449-467: 当前采集逻辑在 collectForwardMessageIds(使用
normalizeForwardMessageId)里无差别地遍历包括 'id' 的所有键,导致当元素同时存在 forward=true 与自身 id
时可能误取元素自身 ID;为修复,要改为优先使用专用键
['forward_id','forwardId','res_id','resId','message_id','messageId'] 并在找到第一个有效
normalizedId 后停止(或仅在上述专用键全部缺失时才回退到 'id'),或者直接移除 'id' 并在 isForwardMessageElement
中同步将 'id' 纳入判断以保持语义一致;定位到使用 normalizeForwardMessageId 的循环并调整键优先级与短路逻辑(或移除 'id'
并更新 isForwardMessageElement)以避免采集到错误的 ID。
🧹 Nitpick comments (2)
packages/core/src/middlewares/chat/read_chat_message.ts (2)

29-35: 两次递归遍历可合并为一次。

hasForwardMessageElementcollectForwardMessageIds 都递归遍历同一棵元素树。可以只调用 collectForwardMessageIds,根据返回结果是否为空来判断 hasForwardHistory,避免重复遍历。

♻️ 建议合并
-            const hasForwardHistory =
-                config.attachForwardMsgIdToContext &&
-                hasForwardMessageElement(message as h[])
-
-            const forwardMessageIds = hasForwardHistory
-                ? collectForwardMessageIds(message as h[])
-                : []
+            const forwardMessageIds = config.attachForwardMsgIdToContext
+                ? collectForwardMessageIds(message as h[])
+                : []
+
+            const hasForwardHistory = forwardMessageIds.length > 0

注意:此修改会改变语义——如果存在转发元素但未收集到任何 ID,hasForwardHistory 将为 false[聊天记录] 标记也不会被追加。如果希望"只要存在转发元素就追加标记",可以改为使用 collectForwardMessageIds 内部同时返回一个布尔标志。


434-438: children 访问方式可以简化。

koishi 的 h 类型本身就有 children: h[] 属性,无需通过 unknown 中转。当前写法可以正常工作,但不够简洁。

♻️ 简化 children 访问
-        const children = (element as unknown as { children?: unknown }).children
-        return (
-            Array.isArray(children) &&
-            children.length > 0 &&
-            hasForwardMessageElement(children as h[])
-        )
+        return (
+            element.children?.length > 0 &&
+            hasForwardMessageElement(element.children)
+        )

collectForwardMessageIds 中同理:

-        const children = (element as unknown as { children?: unknown }).children
-        if (Array.isArray(children) && children.length > 0) {
-            ;(children as h[]).forEach(visit)
-        }
+        if (element.children?.length > 0) {
+            element.children.forEach(visit)
+        }

Also applies to: 470-473

Copy link
Member

@dingyi222666 dingyi222666 left a comment

Choose a reason for hiding this comment

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

使用 https://chatluna.chat/development/connect-to-core-services/message-read.html 相关 API 实现,稍加修改后,伪装也能支持读取。

ctx.chatluna.messageTransformer.intercept(
    'at',
    async (session, element, message) => {
        const name = element.attrs['name']
        const id = element.attrs['id']

        if (id !== session.bot.selfId) {
            addMessageContent(
                message,
                `<at ${name != null ? `name="${name}"` : ''} id="${id}"/>`
            )
        }
    }
)```

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@packages/core/src/middlewares/chat/read_chat_message.ts`:
- Around line 467-478: pickForwardMessageId currently only looks for
message_id/messageId while isForwardMessageElement treats
forward_id/forwardId/res_id/resId as valid forward markers; update
pickForwardMessageId to check the full set of keys (forward_id, forwardId,
res_id, resId, then fallback to message_id, messageId) in that priority order
and keep using normalizeForwardMessageId for each candidate; also apply the same
key-range change to the other similar extractor referenced around the 480-494
block so forward-detected elements have their IDs collected into state.ids
consistently with isForwardMessageElement.

Comment on lines 467 to 478
function pickForwardMessageId(element: h.Element): string | null {
const attrs = (element.attrs ?? {}) as Record<string, unknown>

// Only use the common message id field used across other message APIs.
// Keep both snake_case and camelCase for adapter compatibility.
for (const key of ['message_id', 'messageId']) {
const normalizedId = normalizeForwardMessageId(attrs[key])
if (normalizedId) return normalizedId
}

return null
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

pickForwardMessageId 提取的键范围与 isForwardMessageElement 检测的键范围不一致。

isForwardMessageElement 通过 forward_idforwardIdres_idresId 判定元素为转发消息,但 pickForwardMessageId 仅从 message_id/messageId 提取 ID。这意味着仅凭 res_idforward_id 识别的转发元素,其 ID 将无法被收集到 state.ids 中。

PR 描述中明确提到需要收集 forward_idforwardIdres_idresId 等字段,建议将这些键也加入 pickForwardMessageId 的提取范围,并按优先级排列(专用键优先,通用键兜底)。

🔧 建议的修复
 function pickForwardMessageId(element: h.Element): string | null {
     const attrs = (element.attrs ?? {}) as Record<string, unknown>
 
-    // Only use the common message id field used across other message APIs.
-    // Keep both snake_case and camelCase for adapter compatibility.
-    for (const key of ['message_id', 'messageId']) {
+    // Prefer specific forward ID keys, fall back to general message ID.
+    // Keep both snake_case and camelCase for adapter compatibility.
+    for (const key of [
+        'forward_id', 'forwardId',
+        'res_id', 'resId',
+        'message_id', 'messageId'
+    ]) {
         const normalizedId = normalizeForwardMessageId(attrs[key])
         if (normalizedId) return normalizedId
     }
 
     return null
 }

Also applies to: 480-494

🤖 Prompt for AI Agents
In `@packages/core/src/middlewares/chat/read_chat_message.ts` around lines 467 -
478, pickForwardMessageId currently only looks for message_id/messageId while
isForwardMessageElement treats forward_id/forwardId/res_id/resId as valid
forward markers; update pickForwardMessageId to check the full set of keys
(forward_id, forwardId, res_id, resId, then fallback to message_id, messageId)
in that priority order and keep using normalizeForwardMessageId for each
candidate; also apply the same key-range change to the other similar extractor
referenced around the 480-494 block so forward-detected elements have their IDs
collected into state.ids consistently with isForwardMessageElement.

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