diff --git a/hub-server/channels/feishu-lark-cli.ts b/hub-server/channels/feishu-lark-cli.ts index a9fad26..d6da373 100644 --- a/hub-server/channels/feishu-lark-cli.ts +++ b/hub-server/channels/feishu-lark-cli.ts @@ -224,6 +224,37 @@ function startSubscription(): void { }); } +/** 飞书把图片以 "[Image: img_xxx]" / "[图片: img_xxx]" 的形式塞在 content 里。 */ +const IMAGE_PLACEHOLDER = /\[(?:Image|图片):\s*(img_[^\]]+)\]/; + +/** + * 把 content 里**每一个**图片占位符就地换成 "[图片] <本地路径>",其余文字原样保留。 + * + * 原实现用 content.match() 只取第一个 key,随后把整个 content 覆盖成那一张的路径—— + * 一条夹带 N 张图和文字的富文本,到 agent 手里只剩第一张图,文字和其余 N-1 张全部丢失。 + * + * 下载失败的那一张保留原占位符,不牵连同一条消息里其它图片。 + * download 注入,便于测试。 + */ +export async function resolveImagePlaceholders( + content: string, + download: (imageKey: string) => Promise, +): Promise { + // 从不带 g 的常量另建带 g 的副本:共享带 g 的正则会因 lastIndex 残留而漏匹配 + const matches = [...content.matchAll(new RegExp(IMAGE_PLACEHOLDER, "g"))]; + if (matches.length === 0) return content; + + let out = ""; + let cursor = 0; + for (const m of matches) { + const start = m.index ?? 0; + const filePath = await download(m[1]); + out += content.slice(cursor, start) + (filePath ? `[图片] ${filePath}` : m[0]); + cursor = start + m[0].length; + } + return out + content.slice(cursor); +} + async function handleMessage(event: Record): Promise { const senderId = (event.sender_id ?? "") as string; const chatId = (event.chat_id ?? "") as string; @@ -268,12 +299,11 @@ async function handleMessage(event: Record): Promise { const messageId = (event.message_id ?? event.id ?? "") as string; let content = (event.content ?? "") as string; - // image key 在 content 里: "[Image: img_v3_xxx]" 或 "[图片: img_v3_xxx]" - 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}]`; + // 一条富文本可以夹带多张图和文字,逐个就地替换、其余文字原样保留 + if (IMAGE_PLACEHOLDER.test(content) && messageId) { + content = await resolveImagePlaceholders(content, (key) => + downloadFeishuMedia(messageId, "image", key), + ); } else if (msgType === "file" && messageId) { // file key 可能在 content 里: "[File: file_v3_xxx]" const fileKeyMatch = content.match(/\[(?:File|文件):\s*(file_[^\]]+)\]/); diff --git a/hub-server/feishu-media-placeholders.test.ts b/hub-server/feishu-media-placeholders.test.ts new file mode 100644 index 0000000..b3d82f4 --- /dev/null +++ b/hub-server/feishu-media-placeholders.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveImagePlaceholders } from "./channels/feishu-lark-cli.js"; + +/** 按 key 返回假路径;返回 null 模拟下载失败。 */ +function fakeDownloader(failFor: string[] = []) { + const calls: string[] = []; + const download = async (key: string): Promise => { + calls.push(key); + return failFor.includes(key) ? null : `/media/${key}.png`; + }; + return { download, calls }; +} + +describe("resolveImagePlaceholders", () => { + test("replaces a single placeholder with the downloaded path", async () => { + const { download } = fakeDownloader(); + + const out = await resolveImagePlaceholders("[图片: img_v3_aaa]", download); + + expect(out).toBe("[图片] /media/img_v3_aaa.png"); + }); + + test("replaces every placeholder when one message carries several images", async () => { + const { download, calls } = fakeDownloader(); + + const out = await resolveImagePlaceholders( + "[图片: img_v3_aaa][图片: img_v3_bbb][图片: img_v3_ccc]", + download, + ); + + expect(calls).toEqual(["img_v3_aaa", "img_v3_bbb", "img_v3_ccc"]); + expect(out).toBe( + "[图片] /media/img_v3_aaa.png[图片] /media/img_v3_bbb.png[图片] /media/img_v3_ccc.png", + ); + }); + + test("keeps the surrounding text of a rich-text message", async () => { + const { download } = fakeDownloader(); + + const out = await resolveImagePlaceholders( + "先看这张 [图片: img_v3_aaa] 再看这张 [图片: img_v3_bbb] 完", + download, + ); + + expect(out).toBe( + "先看这张 [图片] /media/img_v3_aaa.png 再看这张 [图片] /media/img_v3_bbb.png 完", + ); + }); + + test("accepts the English [Image: ...] form", async () => { + const { download } = fakeDownloader(); + + const out = await resolveImagePlaceholders("a [Image: img_v3_aaa] b", download); + + expect(out).toBe("a [图片] /media/img_v3_aaa.png b"); + }); + + test("leaves the placeholder in place when the download fails", async () => { + const { download } = fakeDownloader(["img_v3_bbb"]); + + const out = await resolveImagePlaceholders( + "[图片: img_v3_aaa] 中间 [图片: img_v3_bbb]", + download, + ); + + // 失败的那张保留原占位符,成功的那张照常替换——不因为一张失败就丢掉整条消息 + expect(out).toBe("[图片] /media/img_v3_aaa.png 中间 [图片: img_v3_bbb]"); + }); + + test("returns text without placeholders untouched and downloads nothing", async () => { + const { download, calls } = fakeDownloader(); + + const out = await resolveImagePlaceholders("纯文字,没有图", download); + + expect(out).toBe("纯文字,没有图"); + expect(calls).toEqual([]); + }); +});