feat(feishu): render post rich text as markdown (openclaw#12755)

* feat(feishu): parse post rich text as markdown

* chore: rerun ci

* Feishu: resolve post parser rebase conflicts and gate fixes

---------

Co-authored-by: Wilson Liu <wilson.liu@example.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
WilsonLiu95
2026-02-28 13:33:20 +08:00
committed by GitHub
parent 49cf2bceb6
commit 8818464f5f
6 changed files with 368 additions and 83 deletions

View File

@@ -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.<id>.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.

View File

@@ -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.

View File

@@ -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({

View File

@@ -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({

View File

@@ -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* <u>Underline</u> ~~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: [],
});
});
});

View File

@@ -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<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[], 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: [] };
}
}