feat(feishu): extract embedded video/media from post (rich text) messages (#21786)

* feat(feishu): extract embedded video/media from post (rich text) messages

Previously, parsePostContent() only extracted embedded images (img tags)
from rich text posts, ignoring embedded video/audio (media tags). Users
sending post messages with embedded videos would not have the media
downloaded or forwarded to the agent.

Changes:
- Extend parsePostContent() to also collect media tags with file_key
- Return new mediaKeys array alongside existing imageKeys
- Update resolveFeishuMediaList() to download embedded media files
  from post messages using the messageResource API
- Add appropriate logging for embedded media discovery and download

* Feishu: keep embedded post media payloads type-safe

* Feishu: format post parser after media tag extraction

---------

Co-authored-by: laopuhuluwa <laopuhuluwa@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
laopuhuluwa
2026-02-28 13:39:24 +08:00
committed by GitHub
parent b0a8909a73
commit 53a2e72fcb
4 changed files with 123 additions and 8 deletions

View File

@@ -682,6 +682,60 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("downloads embedded media tags from post messages as files", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-sender",
},
},
message: {
message_id: "msg-post-media",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "post",
content: JSON.stringify({
title: "Rich text",
content: [
[
{
tag: "media",
file_key: "file_post_media_payload",
file_name: "embedded.mov",
},
],
],
}),
},
};
await dispatchMessage({ cfg, event });
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
expect.objectContaining({
messageId: "msg-post-media",
fileKey: "file_post_media_payload",
type: "file",
}),
);
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"video/mp4",
"inbound",
expect.any(Number),
);
});
it("includes message_id in BodyForAgent on its own line", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@@ -453,14 +453,19 @@ async function resolveFeishuMediaList(params: {
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
// Handle post (rich text) messages with embedded images
// Handle post (rich text) messages with embedded images/media.
if (messageType === "post") {
const { imageKeys } = parsePostContent(content);
if (imageKeys.length === 0) {
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
return [];
}
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
if (imageKeys.length > 0) {
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
}
if (postMediaKeys.length > 0) {
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
}
for (const imageKey of imageKeys) {
try {
@@ -497,6 +502,40 @@ async function resolveFeishuMediaList(params: {
}
}
for (const media of postMediaKeys) {
try {
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: media.fileKey,
type: "file",
accountId,
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:video>",
});
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
}
}
return out;
}

View File

@@ -6,6 +6,7 @@ const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}\[\]()#+\-!|>~])/g;
type PostParseResult = {
textContent: string;
imageKeys: string[];
mediaKeys: Array<{ fileKey: string; fileName?: string }>;
mentionedOpenIds: string[];
};
@@ -125,7 +126,12 @@ function renderCodeBlockElement(element: Record<string, unknown>): string {
return `\`\`\`${language}\n${code}${trailingNewline}\`\`\``;
}
function renderElement(element: unknown, imageKeys: string[], mentionedOpenIds: string[]): string {
function renderElement(
element: unknown,
imageKeys: string[],
mediaKeys: Array<{ fileKey: string; fileName?: string }>,
mentionedOpenIds: string[],
): string {
if (!isRecord(element)) {
return escapeMarkdownText(toStringOrEmpty(element));
}
@@ -152,6 +158,14 @@ function renderElement(element: unknown, imageKeys: string[], mentionedOpenIds:
}
return "![image]";
}
case "media": {
const fileKey = normalizeFeishuExternalKey(toStringOrEmpty(element.file_key));
if (fileKey) {
const fileName = toStringOrEmpty(element.file_name) || undefined;
mediaKeys.push({ fileKey, fileName });
}
return "[media]";
}
case "emotion":
return renderEmotionElement(element);
case "br":
@@ -220,10 +234,16 @@ export function parsePostContent(content: string): PostParseResult {
const parsed = JSON.parse(content);
const payload = resolvePostPayload(parsed);
if (!payload) {
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mentionedOpenIds: [] };
return {
textContent: FALLBACK_POST_TEXT,
imageKeys: [],
mediaKeys: [],
mentionedOpenIds: [],
};
}
const imageKeys: string[] = [];
const mediaKeys: Array<{ fileKey: string; fileName?: string }> = [];
const mentionedOpenIds: string[] = [];
const paragraphs: string[] = [];
@@ -233,7 +253,7 @@ export function parsePostContent(content: string): PostParseResult {
}
let renderedParagraph = "";
for (const element of paragraph) {
renderedParagraph += renderElement(element, imageKeys, mentionedOpenIds);
renderedParagraph += renderElement(element, imageKeys, mediaKeys, mentionedOpenIds);
}
paragraphs.push(renderedParagraph);
}
@@ -245,9 +265,10 @@ export function parsePostContent(content: string): PostParseResult {
return {
textContent: textContent || FALLBACK_POST_TEXT,
imageKeys,
mediaKeys,
mentionedOpenIds,
};
} catch {
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mentionedOpenIds: [] };
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mediaKeys: [], mentionedOpenIds: [] };
}
}