diff --git a/CHANGELOG.md b/CHANGELOG.md index 31aa606df51..0d19160ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier. - 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/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.ts b/extensions/feishu/src/bot.ts index 8ce16d28a92..f0f3249c32c 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -29,6 +29,7 @@ import { resolveFeishuAllowlistMatch, isFeishuGroupAllowed, } from "./policy.js"; +import { parsePostContent } from "./post.js"; import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { getFeishuRuntime } from "./runtime.js"; import { getMessageFeishu, sendMessageFeishu } from "./send.js"; @@ -192,16 +193,17 @@ export type FeishuBotAddedEvent = { }; function parseMessageContent(content: string, messageType: string): string { + if (messageType === "post") { + // Extract text content from rich text post + const { textContent } = parsePostContent(content); + return textContent; + } + try { const parsed = JSON.parse(content); if (messageType === "text") { return parsed.text || ""; } - if (messageType === "post") { - // Extract text content from rich text post - const { textContent } = parsePostContent(content); - return textContent; - } if (messageType === "share_chat") { // Preserve available summary text for merged/forwarded chat messages. if (parsed && typeof parsed === "object") { @@ -398,82 +400,6 @@ function parseMediaKeys( } } -/** - * Parse post (rich text) content and extract embedded image keys. - * Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] } - */ -function parsePostContent(content: string): { - textContent: string; - imageKeys: string[]; - mentionedOpenIds: string[]; -} { - try { - const parsed = JSON.parse(content); - const title = parsed.title || ""; - const contentBlocks = parsed.content || []; - let textContent = title ? `${title}\n\n` : ""; - const imageKeys: string[] = []; - const mentionedOpenIds: string[] = []; - - for (const paragraph of contentBlocks) { - if (Array.isArray(paragraph)) { - for (const element of paragraph) { - if (element.tag === "text") { - textContent += element.text || ""; - } else if (element.tag === "a") { - // Link: show text or href - textContent += element.text || element.href || ""; - } else if (element.tag === "at") { - // Mention: @username - textContent += `@${element.user_name || element.user_id || ""}`; - if (element.user_id) { - mentionedOpenIds.push(element.user_id); - } - } else if (element.tag === "img" && element.image_key) { - // Embedded image - const imageKey = normalizeFeishuExternalKey(element.image_key); - if (imageKey) { - imageKeys.push(imageKey); - } - } else if (element.tag === "code") { - // Inline code - const code = - typeof element.text === "string" - ? element.text - : typeof element.content === "string" - ? element.content - : ""; - if (code) { - textContent += `\`${code}\``; - } - } else if (element.tag === "code_block" || element.tag === "pre") { - // Multiline code block - const lang = typeof element.language === "string" ? element.language : ""; - const code = - typeof element.text === "string" - ? element.text - : typeof element.content === "string" - ? element.content - : ""; - if (code) { - textContent += `\n\`\`\`${lang}\n${code}\n\`\`\`\n`; - } - } - } - textContent += "\n"; - } - } - - return { - textContent: textContent.trim() || "[Rich text message]", - imageKeys, - mentionedOpenIds, - }; - } catch { - return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] }; - } -} - /** * Map Feishu message type to messageResource.get resource type. * Feishu messageResource API supports only: image | file. diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index 73ea2112fec..9dfb2759066 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -64,7 +64,9 @@ export async function handleFeishuCardAction(params: { }, }; - log(`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`); + log( + `feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`, + ); // Dispatch as normal message await handleFeishuMessage({ diff --git a/extensions/feishu/src/monitor.ts b/extensions/feishu/src/monitor.ts index bf78240871f..5425f84b1f0 100644 --- a/extensions/feishu/src/monitor.ts +++ b/extensions/feishu/src/monitor.ts @@ -351,7 +351,7 @@ function registerEventHandlers( "im.message.reaction.deleted_v1": async () => { // Ignore reaction removals }, - "card.action.trigger": async (data) => { + "card.action.trigger": async (data: unknown) => { try { const event = data as unknown as FeishuCardActionEvent; const promise = handleFeishuCardAction({ diff --git a/extensions/feishu/src/post.test.ts b/extensions/feishu/src/post.test.ts new file mode 100644 index 00000000000..79e3f12e95d --- /dev/null +++ b/extensions/feishu/src/post.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { parsePostContent } from "./post.js"; + +describe("parsePostContent", () => { + it("renders title and styled text as markdown", () => { + const content = JSON.stringify({ + title: "Daily *Plan*", + content: [ + [ + { tag: "text", text: "Bold", style: { bold: true } }, + { tag: "text", text: " " }, + { tag: "text", text: "Italic", style: { italic: true } }, + { tag: "text", text: " " }, + { tag: "text", text: "Underline", style: { underline: true } }, + { tag: "text", text: " " }, + { tag: "text", text: "Strike", style: { strikethrough: true } }, + { tag: "text", text: " " }, + { tag: "text", text: "Code", style: { code: true, bold: true } }, + ], + ], + }); + + const result = parsePostContent(content); + + expect(result.textContent).toBe( + "Daily \\*Plan\\*\n\n**Bold** *Italic* Underline ~~Strike~~ `Code`", + ); + expect(result.imageKeys).toEqual([]); + expect(result.mentionedOpenIds).toEqual([]); + }); + + it("renders links and mentions", () => { + const content = JSON.stringify({ + title: "", + content: [ + [ + { tag: "a", text: "Docs [v2]", href: "https://example.com/guide(a)" }, + { tag: "text", text: " " }, + { tag: "at", user_name: "alice_bob" }, + { tag: "text", text: " " }, + { tag: "at", open_id: "ou_123" }, + { tag: "text", text: " " }, + { tag: "a", href: "https://example.com/no-text" }, + ], + ], + }); + + const result = parsePostContent(content); + + expect(result.textContent).toBe( + "[Docs \\[v2\\]](https://example.com/guide(a)) @alice\\_bob @ou\\_123 [https://example.com/no\\-text](https://example.com/no-text)", + ); + expect(result.mentionedOpenIds).toEqual(["ou_123"]); + }); + + it("inserts image placeholders and collects image keys", () => { + const content = JSON.stringify({ + title: "", + content: [ + [ + { tag: "text", text: "Before " }, + { tag: "img", image_key: "img_1" }, + { tag: "text", text: " after" }, + ], + [{ tag: "img", image_key: "img_2" }], + ], + }); + + const result = parsePostContent(content); + + expect(result.textContent).toBe("Before ![image] after\n![image]"); + expect(result.imageKeys).toEqual(["img_1", "img_2"]); + expect(result.mentionedOpenIds).toEqual([]); + }); + + it("supports locale wrappers", () => { + const wrappedByPost = JSON.stringify({ + post: { + zh_cn: { + title: "标题", + content: [[{ tag: "text", text: "内容A" }]], + }, + }, + }); + const wrappedByLocale = JSON.stringify({ + zh_cn: { + title: "标题", + content: [[{ tag: "text", text: "内容B" }]], + }, + }); + + expect(parsePostContent(wrappedByPost)).toEqual({ + textContent: "标题\n\n内容A", + imageKeys: [], + mentionedOpenIds: [], + }); + expect(parsePostContent(wrappedByLocale)).toEqual({ + textContent: "标题\n\n内容B", + imageKeys: [], + mentionedOpenIds: [], + }); + }); +}); diff --git a/extensions/feishu/src/post.ts b/extensions/feishu/src/post.ts new file mode 100644 index 00000000000..384fd7b22a2 --- /dev/null +++ b/extensions/feishu/src/post.ts @@ -0,0 +1,253 @@ +import { normalizeFeishuExternalKey } from "./external-keys.js"; + +const FALLBACK_POST_TEXT = "[Rich text message]"; +const MARKDOWN_SPECIAL_CHARS = /([\\`*_{}\[\]()#+\-!|>~])/g; + +type PostParseResult = { + textContent: string; + imageKeys: string[]; + mentionedOpenIds: string[]; +}; + +type PostPayload = { + title: string; + content: unknown[]; +}; + +function isRecord(value: unknown): value is Record { + 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 | 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 { + 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 = `${rendered}`; + } + if ( + isStyleEnabled(style, "strikethrough") || + isStyleEnabled(style, "line_through") || + isStyleEnabled(style, "lineThrough") + ) { + rendered = `~~${rendered}~~`; + } + return rendered; +} + +function renderLinkElement(element: Record): 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 { + 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 { + const text = + toStringOrEmpty(element.emoji) || + toStringOrEmpty(element.text) || + toStringOrEmpty(element.emoji_type); + return escapeMarkdownText(text); +} + +function renderCodeBlockElement(element: Record): 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[], 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 "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: [], mentionedOpenIds: [] }; + } + + const imageKeys: 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, 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, + mentionedOpenIds, + }; + } catch { + return { textContent: FALLBACK_POST_TEXT, imageKeys: [], mentionedOpenIds: [] }; + } +}