mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 04:20:44 +00:00
* 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>
275 lines
7.4 KiB
TypeScript
275 lines
7.4 KiB
TypeScript
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
|
|
const FALLBACK_POST_TEXT = "[Rich text message]";
|
|
const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}\[\]()#+\-!|>~])/g;
|
|
|
|
type PostParseResult = {
|
|
textContent: string;
|
|
imageKeys: string[];
|
|
mediaKeys: Array<{ fileKey: string; fileName?: string }>;
|
|
mentionedOpenIds: string[];
|
|
};
|
|
|
|
type PostPayload = {
|
|
title: string;
|
|
content: unknown[];
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function toStringOrEmpty(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function escapeMarkdownText(text: string): string {
|
|
return text.replace(MARKDOWN_SPECIAL_CHARS, "\\$1");
|
|
}
|
|
|
|
function toBoolean(value: unknown): boolean {
|
|
return value === true || value === 1 || value === "true";
|
|
}
|
|
|
|
function isStyleEnabled(style: Record<string, unknown> | undefined, key: string): boolean {
|
|
if (!style) {
|
|
return false;
|
|
}
|
|
return toBoolean(style[key]);
|
|
}
|
|
|
|
function wrapInlineCode(text: string): string {
|
|
const maxRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
|
|
const fence = "`".repeat(maxRun + 1);
|
|
const needsPadding = text.startsWith("`") || text.endsWith("`");
|
|
const body = needsPadding ? ` ${text} ` : text;
|
|
return `${fence}${body}${fence}`;
|
|
}
|
|
|
|
function sanitizeFenceLanguage(language: string): string {
|
|
return language.trim().replace(/[^A-Za-z0-9_+#.-]/g, "");
|
|
}
|
|
|
|
function renderTextElement(element: Record<string, unknown>): string {
|
|
const text = toStringOrEmpty(element.text);
|
|
const style = isRecord(element.style) ? element.style : undefined;
|
|
|
|
if (isStyleEnabled(style, "code")) {
|
|
return wrapInlineCode(text);
|
|
}
|
|
|
|
let rendered = escapeMarkdownText(text);
|
|
if (!rendered) {
|
|
return "";
|
|
}
|
|
|
|
if (isStyleEnabled(style, "bold")) {
|
|
rendered = `**${rendered}**`;
|
|
}
|
|
if (isStyleEnabled(style, "italic")) {
|
|
rendered = `*${rendered}*`;
|
|
}
|
|
if (isStyleEnabled(style, "underline")) {
|
|
rendered = `<u>${rendered}</u>`;
|
|
}
|
|
if (
|
|
isStyleEnabled(style, "strikethrough") ||
|
|
isStyleEnabled(style, "line_through") ||
|
|
isStyleEnabled(style, "lineThrough")
|
|
) {
|
|
rendered = `~~${rendered}~~`;
|
|
}
|
|
return rendered;
|
|
}
|
|
|
|
function renderLinkElement(element: Record<string, unknown>): string {
|
|
const href = toStringOrEmpty(element.href).trim();
|
|
const rawText = toStringOrEmpty(element.text);
|
|
const text = rawText || href;
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
if (!href) {
|
|
return escapeMarkdownText(text);
|
|
}
|
|
return `[${escapeMarkdownText(text)}](${href})`;
|
|
}
|
|
|
|
function renderMentionElement(element: Record<string, unknown>): string {
|
|
const mention =
|
|
toStringOrEmpty(element.user_name) ||
|
|
toStringOrEmpty(element.user_id) ||
|
|
toStringOrEmpty(element.open_id);
|
|
if (!mention) {
|
|
return "";
|
|
}
|
|
return `@${escapeMarkdownText(mention)}`;
|
|
}
|
|
|
|
function renderEmotionElement(element: Record<string, unknown>): string {
|
|
const text =
|
|
toStringOrEmpty(element.emoji) ||
|
|
toStringOrEmpty(element.text) ||
|
|
toStringOrEmpty(element.emoji_type);
|
|
return escapeMarkdownText(text);
|
|
}
|
|
|
|
function renderCodeBlockElement(element: Record<string, unknown>): string {
|
|
const language = sanitizeFenceLanguage(
|
|
toStringOrEmpty(element.language) || toStringOrEmpty(element.lang),
|
|
);
|
|
const code = (toStringOrEmpty(element.text) || toStringOrEmpty(element.content)).replace(
|
|
/\r\n/g,
|
|
"\n",
|
|
);
|
|
const trailingNewline = code.endsWith("\n") ? "" : "\n";
|
|
return `\`\`\`${language}\n${code}${trailingNewline}\`\`\``;
|
|
}
|
|
|
|
function renderElement(
|
|
element: unknown,
|
|
imageKeys: string[],
|
|
mediaKeys: Array<{ fileKey: string; fileName?: string }>,
|
|
mentionedOpenIds: string[],
|
|
): string {
|
|
if (!isRecord(element)) {
|
|
return escapeMarkdownText(toStringOrEmpty(element));
|
|
}
|
|
|
|
const tag = toStringOrEmpty(element.tag).toLowerCase();
|
|
switch (tag) {
|
|
case "text":
|
|
return renderTextElement(element);
|
|
case "a":
|
|
return renderLinkElement(element);
|
|
case "at":
|
|
{
|
|
const mentioned = toStringOrEmpty(element.open_id) || toStringOrEmpty(element.user_id);
|
|
const normalizedMention = normalizeFeishuExternalKey(mentioned);
|
|
if (normalizedMention) {
|
|
mentionedOpenIds.push(normalizedMention);
|
|
}
|
|
}
|
|
return renderMentionElement(element);
|
|
case "img": {
|
|
const imageKey = normalizeFeishuExternalKey(toStringOrEmpty(element.image_key));
|
|
if (imageKey) {
|
|
imageKeys.push(imageKey);
|
|
}
|
|
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":
|
|
return "\n";
|
|
case "hr":
|
|
return "\n\n---\n\n";
|
|
case "code": {
|
|
const code = toStringOrEmpty(element.text) || toStringOrEmpty(element.content);
|
|
return code ? wrapInlineCode(code) : "";
|
|
}
|
|
case "code_block":
|
|
case "pre":
|
|
return renderCodeBlockElement(element);
|
|
default:
|
|
return escapeMarkdownText(toStringOrEmpty(element.text));
|
|
}
|
|
}
|
|
|
|
function toPostPayload(candidate: unknown): PostPayload | null {
|
|
if (!isRecord(candidate) || !Array.isArray(candidate.content)) {
|
|
return null;
|
|
}
|
|
return {
|
|
title: toStringOrEmpty(candidate.title),
|
|
content: candidate.content,
|
|
};
|
|
}
|
|
|
|
function resolveLocalePayload(candidate: unknown): PostPayload | null {
|
|
const direct = toPostPayload(candidate);
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
if (!isRecord(candidate)) {
|
|
return null;
|
|
}
|
|
for (const value of Object.values(candidate)) {
|
|
const localePayload = toPostPayload(value);
|
|
if (localePayload) {
|
|
return localePayload;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolvePostPayload(parsed: unknown): PostPayload | null {
|
|
const direct = toPostPayload(parsed);
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
|
|
if (!isRecord(parsed)) {
|
|
return null;
|
|
}
|
|
|
|
const wrappedPost = resolveLocalePayload(parsed.post);
|
|
if (wrappedPost) {
|
|
return wrappedPost;
|
|
}
|
|
|
|
return resolveLocalePayload(parsed);
|
|
}
|
|
|
|
export function parsePostContent(content: string): PostParseResult {
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
const payload = resolvePostPayload(parsed);
|
|
if (!payload) {
|
|
return {
|
|
textContent: FALLBACK_POST_TEXT,
|
|
imageKeys: [],
|
|
mediaKeys: [],
|
|
mentionedOpenIds: [],
|
|
};
|
|
}
|
|
|
|
const imageKeys: string[] = [];
|
|
const mediaKeys: Array<{ fileKey: string; fileName?: string }> = [];
|
|
const mentionedOpenIds: string[] = [];
|
|
const paragraphs: string[] = [];
|
|
|
|
for (const paragraph of payload.content) {
|
|
if (!Array.isArray(paragraph)) {
|
|
continue;
|
|
}
|
|
let renderedParagraph = "";
|
|
for (const element of paragraph) {
|
|
renderedParagraph += renderElement(element, imageKeys, mediaKeys, mentionedOpenIds);
|
|
}
|
|
paragraphs.push(renderedParagraph);
|
|
}
|
|
|
|
const title = escapeMarkdownText(payload.title.trim());
|
|
const body = paragraphs.join("\n").trim();
|
|
const textContent = [title, body].filter(Boolean).join("\n\n").trim();
|
|
|
|
return {
|
|
textContent: textContent || FALLBACK_POST_TEXT,
|
|
imageKeys,
|
|
mediaKeys,
|
|
mentionedOpenIds,
|
|
};
|
|
} catch {
|
|
return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mediaKeys: [], mentionedOpenIds: [] };
|
|
}
|
|
}
|