Skip to content

Commit 53a2e72

Browse files
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 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent b0a8909 commit 53a2e72

File tree

4 files changed

+123
-8
lines changed

4 files changed

+123
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
3131
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
3232
- Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.
3333
- Feishu/Post markdown parsing: parse rich-text `post` payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755)
34+
- Feishu/Post embedded media: extract `media` tags from inbound rich-text (`post`) messages and download embedded video/audio files alongside existing embedded-image handling, with regression coverage. (#21786) Thanks @laopuhuluwa.
3435
- Feishu/Local media sends: propagate `mediaLocalRoots` through Feishu outbound media sending into `loadWebMedia` so local path attachments work with post-CVE local-root enforcement. (#27884) Thanks @joelnishanth.
3536
- Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups.<id>.allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild.
3637
- Feishu/Group wildcard policy fallback: honor `channels.feishu.groups["*"]` when no explicit group match exists so unmatched groups inherit wildcard reply-policy settings instead of falling back to global defaults. (#29456) Thanks @WaynePika.

extensions/feishu/src/bot.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,60 @@ describe("handleFeishuMessage command authorization", () => {
682682
);
683683
});
684684

685+
it("downloads embedded media tags from post messages as files", async () => {
686+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
687+
688+
const cfg: ClawdbotConfig = {
689+
channels: {
690+
feishu: {
691+
dmPolicy: "open",
692+
},
693+
},
694+
} as ClawdbotConfig;
695+
696+
const event: FeishuMessageEvent = {
697+
sender: {
698+
sender_id: {
699+
open_id: "ou-sender",
700+
},
701+
},
702+
message: {
703+
message_id: "msg-post-media",
704+
chat_id: "oc-dm",
705+
chat_type: "p2p",
706+
message_type: "post",
707+
content: JSON.stringify({
708+
title: "Rich text",
709+
content: [
710+
[
711+
{
712+
tag: "media",
713+
file_key: "file_post_media_payload",
714+
file_name: "embedded.mov",
715+
},
716+
],
717+
],
718+
}),
719+
},
720+
};
721+
722+
await dispatchMessage({ cfg, event });
723+
724+
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
725+
expect.objectContaining({
726+
messageId: "msg-post-media",
727+
fileKey: "file_post_media_payload",
728+
type: "file",
729+
}),
730+
);
731+
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
732+
expect.any(Buffer),
733+
"video/mp4",
734+
"inbound",
735+
expect.any(Number),
736+
);
737+
});
738+
685739
it("includes message_id in BodyForAgent on its own line", async () => {
686740
mockShouldComputeCommandAuthorized.mockReturnValue(false);
687741

extensions/feishu/src/bot.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -453,14 +453,19 @@ async function resolveFeishuMediaList(params: {
453453
const out: FeishuMediaInfo[] = [];
454454
const core = getFeishuRuntime();
455455

456-
// Handle post (rich text) messages with embedded images
456+
// Handle post (rich text) messages with embedded images/media.
457457
if (messageType === "post") {
458-
const { imageKeys } = parsePostContent(content);
459-
if (imageKeys.length === 0) {
458+
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
459+
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
460460
return [];
461461
}
462462

463-
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
463+
if (imageKeys.length > 0) {
464+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
465+
}
466+
if (postMediaKeys.length > 0) {
467+
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
468+
}
464469

465470
for (const imageKey of imageKeys) {
466471
try {
@@ -497,6 +502,40 @@ async function resolveFeishuMediaList(params: {
497502
}
498503
}
499504

505+
for (const media of postMediaKeys) {
506+
try {
507+
const result = await downloadMessageResourceFeishu({
508+
cfg,
509+
messageId,
510+
fileKey: media.fileKey,
511+
type: "file",
512+
accountId,
513+
});
514+
515+
let contentType = result.contentType;
516+
if (!contentType) {
517+
contentType = await core.media.detectMime({ buffer: result.buffer });
518+
}
519+
520+
const saved = await core.channel.media.saveMediaBuffer(
521+
result.buffer,
522+
contentType,
523+
"inbound",
524+
maxBytes,
525+
);
526+
527+
out.push({
528+
path: saved.path,
529+
contentType: saved.contentType,
530+
placeholder: "<media:video>",
531+
});
532+
533+
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
534+
} catch (err) {
535+
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
536+
}
537+
}
538+
500539
return out;
501540
}
502541

extensions/feishu/src/post.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}\[\]()#+\-!|>~])/g;
66
type PostParseResult = {
77
textContent: string;
88
imageKeys: string[];
9+
mediaKeys: Array<{ fileKey: string; fileName?: string }>;
910
mentionedOpenIds: string[];
1011
};
1112

@@ -125,7 +126,12 @@ function renderCodeBlockElement(element: Record<string, unknown>): string {
125126
return `\`\`\`${language}\n${code}${trailingNewline}\`\`\``;
126127
}
127128

128-
function renderElement(element: unknown, imageKeys: string[], mentionedOpenIds: string[]): string {
129+
function renderElement(
130+
element: unknown,
131+
imageKeys: string[],
132+
mediaKeys: Array<{ fileKey: string; fileName?: string }>,
133+
mentionedOpenIds: string[],
134+
): string {
129135
if (!isRecord(element)) {
130136
return escapeMarkdownText(toStringOrEmpty(element));
131137
}
@@ -152,6 +158,14 @@ function renderElement(element: unknown, imageKeys: string[], mentionedOpenIds:
152158
}
153159
return "![image]";
154160
}
161+
case "media": {
162+
const fileKey = normalizeFeishuExternalKey(toStringOrEmpty(element.file_key));
163+
if (fileKey) {
164+
const fileName = toStringOrEmpty(element.file_name) || undefined;
165+
mediaKeys.push({ fileKey, fileName });
166+
}
167+
return "[media]";
168+
}
155169
case "emotion":
156170
return renderEmotionElement(element);
157171
case "br":
@@ -220,10 +234,16 @@ export function parsePostContent(content: string): PostParseResult {
220234
const parsed = JSON.parse(content);
221235
const payload = resolvePostPayload(parsed);
222236
if (!payload) {
223-
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mentionedOpenIds: [] };
237+
return {
238+
textContent: FALLBACK_POST_TEXT,
239+
imageKeys: [],
240+
mediaKeys: [],
241+
mentionedOpenIds: [],
242+
};
224243
}
225244

226245
const imageKeys: string[] = [];
246+
const mediaKeys: Array<{ fileKey: string; fileName?: string }> = [];
227247
const mentionedOpenIds: string[] = [];
228248
const paragraphs: string[] = [];
229249

@@ -233,7 +253,7 @@ export function parsePostContent(content: string): PostParseResult {
233253
}
234254
let renderedParagraph = "";
235255
for (const element of paragraph) {
236-
renderedParagraph += renderElement(element, imageKeys, mentionedOpenIds);
256+
renderedParagraph += renderElement(element, imageKeys, mediaKeys, mentionedOpenIds);
237257
}
238258
paragraphs.push(renderedParagraph);
239259
}
@@ -245,9 +265,10 @@ export function parsePostContent(content: string): PostParseResult {
245265
return {
246266
textContent: textContent || FALLBACK_POST_TEXT,
247267
imageKeys,
268+
mediaKeys,
248269
mentionedOpenIds,
249270
};
250271
} catch {
251-
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mentionedOpenIds: [] };
272+
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mediaKeys: [], mentionedOpenIds: [] };
252273
}
253274
}

0 commit comments

Comments
 (0)