Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions hub-server/channels/feishu-lark-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>,
): Promise<string> {
// 从不带 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<string, unknown>): Promise<void> {
const senderId = (event.sender_id ?? "") as string;
const chatId = (event.chat_id ?? "") as string;
Expand Down Expand Up @@ -268,12 +299,11 @@ async function handleMessage(event: Record<string, unknown>): Promise<void> {
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_[^\]]+)\]/);
Expand Down
79 changes: 79 additions & 0 deletions hub-server/feishu-media-placeholders.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
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([]);
});
});
Loading