Skip to content

Commit 8818464

Browse files
WilsonLiu95Wilson LiuTakhoffman
authored
feat(feishu): render post rich text as markdown (#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 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 49cf2bc commit 8818464

File tree

6 files changed

+368
-83
lines changed

6 files changed

+368
-83
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- 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.
3131
- 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.
3232
- 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.
33+
- 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)
3334
- 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.
3435
- 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.
3536
- 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.

extensions/feishu/src/bot.ts

Lines changed: 7 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
resolveFeishuAllowlistMatch,
3030
isFeishuGroupAllowed,
3131
} from "./policy.js";
32+
import { parsePostContent } from "./post.js";
3233
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
3334
import { getFeishuRuntime } from "./runtime.js";
3435
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
@@ -192,16 +193,17 @@ export type FeishuBotAddedEvent = {
192193
};
193194

194195
function parseMessageContent(content: string, messageType: string): string {
196+
if (messageType === "post") {
197+
// Extract text content from rich text post
198+
const { textContent } = parsePostContent(content);
199+
return textContent;
200+
}
201+
195202
try {
196203
const parsed = JSON.parse(content);
197204
if (messageType === "text") {
198205
return parsed.text || "";
199206
}
200-
if (messageType === "post") {
201-
// Extract text content from rich text post
202-
const { textContent } = parsePostContent(content);
203-
return textContent;
204-
}
205207
if (messageType === "share_chat") {
206208
// Preserve available summary text for merged/forwarded chat messages.
207209
if (parsed && typeof parsed === "object") {
@@ -398,82 +400,6 @@ function parseMediaKeys(
398400
}
399401
}
400402

401-
/**
402-
* Parse post (rich text) content and extract embedded image keys.
403-
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
404-
*/
405-
function parsePostContent(content: string): {
406-
textContent: string;
407-
imageKeys: string[];
408-
mentionedOpenIds: string[];
409-
} {
410-
try {
411-
const parsed = JSON.parse(content);
412-
const title = parsed.title || "";
413-
const contentBlocks = parsed.content || [];
414-
let textContent = title ? `${title}\n\n` : "";
415-
const imageKeys: string[] = [];
416-
const mentionedOpenIds: string[] = [];
417-
418-
for (const paragraph of contentBlocks) {
419-
if (Array.isArray(paragraph)) {
420-
for (const element of paragraph) {
421-
if (element.tag === "text") {
422-
textContent += element.text || "";
423-
} else if (element.tag === "a") {
424-
// Link: show text or href
425-
textContent += element.text || element.href || "";
426-
} else if (element.tag === "at") {
427-
// Mention: @username
428-
textContent += `@${element.user_name || element.user_id || ""}`;
429-
if (element.user_id) {
430-
mentionedOpenIds.push(element.user_id);
431-
}
432-
} else if (element.tag === "img" && element.image_key) {
433-
// Embedded image
434-
const imageKey = normalizeFeishuExternalKey(element.image_key);
435-
if (imageKey) {
436-
imageKeys.push(imageKey);
437-
}
438-
} else if (element.tag === "code") {
439-
// Inline code
440-
const code =
441-
typeof element.text === "string"
442-
? element.text
443-
: typeof element.content === "string"
444-
? element.content
445-
: "";
446-
if (code) {
447-
textContent += `\`${code}\``;
448-
}
449-
} else if (element.tag === "code_block" || element.tag === "pre") {
450-
// Multiline code block
451-
const lang = typeof element.language === "string" ? element.language : "";
452-
const code =
453-
typeof element.text === "string"
454-
? element.text
455-
: typeof element.content === "string"
456-
? element.content
457-
: "";
458-
if (code) {
459-
textContent += `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
460-
}
461-
}
462-
}
463-
textContent += "\n";
464-
}
465-
}
466-
467-
return {
468-
textContent: textContent.trim() || "[Rich text message]",
469-
imageKeys,
470-
mentionedOpenIds,
471-
};
472-
} catch {
473-
return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
474-
}
475-
}
476-
477403
/**
478404
* Map Feishu message type to messageResource.get resource type.
479405
* Feishu messageResource API supports only: image | file.

extensions/feishu/src/card-action.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@ export async function handleFeishuCardAction(params: {
6464
},
6565
};
6666

67-
log(`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`);
67+
log(
68+
`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
69+
);
6870

6971
// Dispatch as normal message
7072
await handleFeishuMessage({

extensions/feishu/src/monitor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ function registerEventHandlers(
351351
"im.message.reaction.deleted_v1": async () => {
352352
// Ignore reaction removals
353353
},
354-
"card.action.trigger": async (data) => {
354+
"card.action.trigger": async (data: unknown) => {
355355
try {
356356
const event = data as unknown as FeishuCardActionEvent;
357357
const promise = handleFeishuCardAction({

extensions/feishu/src/post.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parsePostContent } from "./post.js";
3+
4+
describe("parsePostContent", () => {
5+
it("renders title and styled text as markdown", () => {
6+
const content = JSON.stringify({
7+
title: "Daily *Plan*",
8+
content: [
9+
[
10+
{ tag: "text", text: "Bold", style: { bold: true } },
11+
{ tag: "text", text: " " },
12+
{ tag: "text", text: "Italic", style: { italic: true } },
13+
{ tag: "text", text: " " },
14+
{ tag: "text", text: "Underline", style: { underline: true } },
15+
{ tag: "text", text: " " },
16+
{ tag: "text", text: "Strike", style: { strikethrough: true } },
17+
{ tag: "text", text: " " },
18+
{ tag: "text", text: "Code", style: { code: true, bold: true } },
19+
],
20+
],
21+
});
22+
23+
const result = parsePostContent(content);
24+
25+
expect(result.textContent).toBe(
26+
"Daily \\*Plan\\*\n\n**Bold** *Italic* <u>Underline</u> ~~Strike~~ `Code`",
27+
);
28+
expect(result.imageKeys).toEqual([]);
29+
expect(result.mentionedOpenIds).toEqual([]);
30+
});
31+
32+
it("renders links and mentions", () => {
33+
const content = JSON.stringify({
34+
title: "",
35+
content: [
36+
[
37+
{ tag: "a", text: "Docs [v2]", href: "https://example.com/guide(a)" },
38+
{ tag: "text", text: " " },
39+
{ tag: "at", user_name: "alice_bob" },
40+
{ tag: "text", text: " " },
41+
{ tag: "at", open_id: "ou_123" },
42+
{ tag: "text", text: " " },
43+
{ tag: "a", href: "https://example.com/no-text" },
44+
],
45+
],
46+
});
47+
48+
const result = parsePostContent(content);
49+
50+
expect(result.textContent).toBe(
51+
"[Docs \\[v2\\]](https://example.com/guide(a)) @alice\\_bob @ou\\_123 [https://example.com/no\\-text](https://example.com/no-text)",
52+
);
53+
expect(result.mentionedOpenIds).toEqual(["ou_123"]);
54+
});
55+
56+
it("inserts image placeholders and collects image keys", () => {
57+
const content = JSON.stringify({
58+
title: "",
59+
content: [
60+
[
61+
{ tag: "text", text: "Before " },
62+
{ tag: "img", image_key: "img_1" },
63+
{ tag: "text", text: " after" },
64+
],
65+
[{ tag: "img", image_key: "img_2" }],
66+
],
67+
});
68+
69+
const result = parsePostContent(content);
70+
71+
expect(result.textContent).toBe("Before ![image] after\n![image]");
72+
expect(result.imageKeys).toEqual(["img_1", "img_2"]);
73+
expect(result.mentionedOpenIds).toEqual([]);
74+
});
75+
76+
it("supports locale wrappers", () => {
77+
const wrappedByPost = JSON.stringify({
78+
post: {
79+
zh_cn: {
80+
title: "标题",
81+
content: [[{ tag: "text", text: "内容A" }]],
82+
},
83+
},
84+
});
85+
const wrappedByLocale = JSON.stringify({
86+
zh_cn: {
87+
title: "标题",
88+
content: [[{ tag: "text", text: "内容B" }]],
89+
},
90+
});
91+
92+
expect(parsePostContent(wrappedByPost)).toEqual({
93+
textContent: "标题\n\n内容A",
94+
imageKeys: [],
95+
mentionedOpenIds: [],
96+
});
97+
expect(parsePostContent(wrappedByLocale)).toEqual({
98+
textContent: "标题\n\n内容B",
99+
imageKeys: [],
100+
mentionedOpenIds: [],
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)