mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 13:27:27 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
103
extensions/feishu/src/post.test.ts
Normal file
103
extensions/feishu/src/post.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
253
extensions/feishu/src/post.ts
Normal file
253
extensions/feishu/src/post.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user