diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d19160ac..5953d01fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - 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. - 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. - 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) +- 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. - 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. - Feishu/Group sender allowlist fallback: add global `channels.feishu.groupSenderAllowFrom` sender authorization for group chats, with per-group `groups..allowFrom` precedence and regression coverage for allow/block/precedence behavior. (#29174) Thanks @1MoreBuild. - 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. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 69121a8fb..573685663 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -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); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 8d9ee0edc..94e750165 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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: "", + }); + + 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; } diff --git a/extensions/feishu/src/post.ts b/extensions/feishu/src/post.ts index 384fd7b22..3ab7dc7c2 100644 --- a/extensions/feishu/src/post.ts +++ b/extensions/feishu/src/post.ts @@ -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 { 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: [] }; } }