Skip to content

fix(feishu): 一条消息夹带多张图时只保留第一张、文字全丢 - #49

Open
AmberCXX wants to merge 1 commit into
LinekForge:mainfrom
AmberCXX:fix/feishu-multi-image-message
Open

AmberCXX wants to merge 1 commit into
LinekForge:mainfrom
AmberCXX:fix/feishu-multi-image-message

Conversation

@AmberCXX

@AmberCXX AmberCXX commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

动机

飞书把图片以 [图片: img_xxx] 的形式塞在 content 文本里。入站处理只取第一个 key,然后把整个 content 覆盖成那一张的路径:

const imageKeyMatch = content.match(/\[(?:Image|图片):\s*(img_[^\]]+)\]/);
if (imageKeyMatch && messageId) {
  const imageKey = imageKeyMatch[1];
  const filePath = await downloadFeishuMedia(messageId, "image", imageKey);
  content = filePath ? `[图片] ${filePath}` : `[图片: ${imageKey}]`;
}

两层问题叠在一起:

  1. content.match() 没有 g 标志,只返回第一个匹配
  2. 拿到之后不是就地替换那一小段,而是整条 content 被赋值覆盖

于是一条「文字 + 6 张图」的富文本消息到达 agent 时,只剩下 [图片] <第一张的路径>——文字和其余 5 张图静默消失。

复现:在飞书里发一条包含文字和多张图片的消息(一条富文本,不是分开发的多条)。实际结果:agent 只收到第一张图的路径,文字和后续图片全部丢失。预期结果:文字保留,每张图都替换成本地路径。补充一点,后 5 张根本没有被下载过——媒体目录里只落盘一个文件。所以这不是"下载了但没透出",是解析阶段就丢了。整个过程没有任何错误或告警。

改动概要

抽出一个纯函数:

export async function resolveImagePlaceholders(
  content: string,
  download: (imageKey: string) => Promise<string | null>,
): Promise<string>
  • 用 matchAll 遍历全部占位符,逐个就地替换成 [图片] <路径>,占位符以外的文字原样保留
  • 单张下载失败时只保留该张的原占位符,不牵连同一条消息里其它图片
  • download 以参数注入,函数本身无 IO 依赖,可直接测试
  • 检测用不带 g 的常量正则,遍历时另建带 g 的副本——共享带 g 的正则会因 lastIndex 残留而漏匹配
  • 新增 hub-server/feishu-media-placeholders.test.ts,6 个用例:
场景 期望
单张占位符 替换为路径
多张占位符 全部替换,且对每个 key 各调一次下载
图文混排 占位符以外的文字原样保留
英文 [Image: ...] 形式 同样识别
某张下载失败 保留该张占位符,其余照常替换
纯文字无占位符 原样返回,且一次下载都不发起

最后一条是防回归:如果哪天改成无条件调用下载,它会失败。

影响范围

  • 只动 hub-server/channels/feishu-lark-cli.ts 的入站图片分支和一个新测试文件;文件、语音分支与出站路径不变,其他通道不涉及。
  • 不带图片占位符的消息走原路径,行为完全不变;带 N 张图的消息从 1 次下载变成 N 次(本来就该如此)。
  • 不改 ChannelPlugin / InboundMessage 等公共契约。

self-test 结果

(cd hub-server && bun test)          → 356 pass / 0 fail
bun hub-test-harness/harness.ts      → 8/8
fh hub self-test                     → 8/8(2026-09-13 在含本 PR 的本地 main 部署上补跑)
  • bunx tsc --noEmit 在改动前后各跑一次做对照,两次都只有既有的一条 channels/feishu.ts 缺 @larksuiteoapi/node-sdk,本 PR 未新增类型错误。
  • 安全自检 grep -rE '@im\.wechat|ou_[a-f0-9]{16,}|sk-ant-…|sk-…|[0-9]{9,10}:[A-Za-z0-9_-]{35}' 对本 PR 改动文件 → 无命中。
  • 环境:macOS 14.6.1 · Bun 1.3.13

飞书把图片以 "[图片: img_xxx]" 的形式塞在 content 文本里。入站处理用
content.match() 取 key——没有 g 标志,只返回第一个匹配——随后把**整个
content 覆盖**成那一张的路径:

    const imageKeyMatch = content.match(/\[(?:Image|图片):\s*(img_[^\]]+)\]/);
    if (imageKeyMatch && messageId) {
      const imageKey = imageKeyMatch[1];
      const filePath = await downloadFeishuMedia(messageId, "image", imageKey);
      content = filePath ? `[图片] ${filePath}` : `[图片: ${imageKey}]`;
    }

于是一条「文字 + 6 张图」的富文本到达 agent 时只剩 "[图片] <第一张路径>",
文字和其余 5 张静默消失。用户侧的表现是「一次发六张图,对面只收到第一张」,
而且没有任何错误或日志——后 5 张根本没被下载过。

改动:
- 抽出 resolveImagePlaceholders(content, download):用 matchAll 遍历全部占位符,
  逐个**就地**替换成 "[图片] <路径>",占位符以外的文字原样保留
- 单张下载失败只保留该张的原占位符,不牵连同条消息里其它图片
- download 以参数注入,函数无 IO 依赖,可直接测试
- handleMessage 改用它;检测用不带 g 的常量正则,遍历时另建带 g 的副本,
  避免共享 lastIndex 导致漏匹配

测试 hub-server/feishu-media-placeholders.test.ts(6 个):
- 单张占位符替换为路径
- 多张全部替换,且下载按序对每个 key 各调一次
- 图文混排时占位符以外的文字原样保留
- 兼容英文 [Image: ...] 形式
- 某张下载失败时保留该张占位符,其余照常替换
- 不含占位符的纯文字原样返回,且一次下载都不发起

验证:
- (cd hub-server && bun test) → 356 pass / 0 fail
- bun hub-test-harness/harness.ts → 8/8
- bunx tsc --noEmit:改动前后均只有既有的 channels/feishu.ts 缺
  @larksuiteoapi/node-sdk 一条,未新增

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pGyNVmhbA1Z1vRoxLZbFe
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.

1 participant