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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user