Skip to content

Commit 8c028f0

Browse files
author
OpenClaw
committed
feat(feishu): enhance interactive card parsing to extract markdown content
- Add card-parser.ts for parsing Feishu card JSON to markdown - Support 15+ element types: heading, list, table, code_block, link, button, etc. - Add card_msg_content_type=raw_card_content param when fetching quoted messages - Add blockquote support with > prefix - Add unit tests with test fixtures - Exclude test/fixtures/ from oxfmt formatting
1 parent d340ea9 commit 8c028f0

6 files changed

Lines changed: 2033 additions & 28 deletions

File tree

.oxfmtrc.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"src/gateway/server-methods/CLAUDE.md",
2222
"src/auto-reply/reply/export-html/",
2323
"Swabble/",
24+
"test/fixtures/",
2425
"vendor/",
2526
],
2627
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { describe, expect, it } from "vitest";
5+
import { parseFeishuCardToMarkdownString } from "./card-parser.js";
6+
7+
const fixturePath = path.resolve(
8+
path.dirname(fileURLToPath(import.meta.url)),
9+
"../../../test/fixtures/feishu-card-parser",
10+
);
11+
12+
describe("parseFeishuCardToMarkdownString", () => {
13+
it("parses complex technical documentation card correctly", () => {
14+
const card = JSON.parse(fs.readFileSync(path.join(fixturePath, "card-input.json"), "utf8"));
15+
const expected = fs.readFileSync(path.join(fixturePath, "card-expected.md"), "utf8").trim();
16+
expect(parseFeishuCardToMarkdownString(card)).toBe(expected);
17+
});
18+
19+
it("returns fallback for invalid JSON string", () => {
20+
expect(parseFeishuCardToMarkdownString("not valid json")).toBe("[Interactive Card]");
21+
});
22+
23+
it("returns fallback for empty object", () => {
24+
expect(parseFeishuCardToMarkdownString({})).toBe("[unknown: ]");
25+
});
26+
27+
it("returns fallback for null / non-object input", () => {
28+
expect(parseFeishuCardToMarkdownString(null)).toBe("[Interactive Card]");
29+
expect(parseFeishuCardToMarkdownString(42)).toBe("[Interactive Card]");
30+
});
31+
32+
it("parses a JSON string input", () => {
33+
const json = JSON.stringify({ elements: [{ tag: "markdown", content: "hello" }] });
34+
expect(parseFeishuCardToMarkdownString(json)).toBe("hello");
35+
});
36+
37+
it("parses heading with correct level from property", () => {
38+
expect(
39+
parseFeishuCardToMarkdownString({
40+
elements: [
41+
{
42+
tag: "heading",
43+
property: {
44+
elements: [{ tag: "plain_text", property: { content: "Title" } }],
45+
level: 3,
46+
},
47+
},
48+
],
49+
}),
50+
).toBe("### Title");
51+
});
52+
53+
it("parses unordered list", () => {
54+
expect(
55+
parseFeishuCardToMarkdownString({
56+
elements: [
57+
{
58+
tag: "list",
59+
property: {
60+
items: [
61+
{ type: "ul", elements: [{ tag: "plain_text", property: { content: "A" } }] },
62+
{ type: "ul", elements: [{ tag: "plain_text", property: { content: "B" } }] },
63+
],
64+
},
65+
},
66+
],
67+
}),
68+
).toBe("- A\n- B");
69+
});
70+
71+
it("parses ordered list with order field", () => {
72+
expect(
73+
parseFeishuCardToMarkdownString({
74+
elements: [
75+
{
76+
tag: "list",
77+
property: {
78+
items: [
79+
{
80+
type: "ol",
81+
order: 1,
82+
elements: [{ tag: "plain_text", property: { content: "First" } }],
83+
},
84+
{
85+
type: "ol",
86+
order: 2,
87+
elements: [{ tag: "plain_text", property: { content: "Second" } }],
88+
},
89+
],
90+
},
91+
},
92+
],
93+
}),
94+
).toBe("1. First\n2. Second");
95+
});
96+
97+
it("parses code_block with language", () => {
98+
expect(
99+
parseFeishuCardToMarkdownString({
100+
elements: [
101+
{
102+
tag: "code_block",
103+
property: {
104+
language: "python",
105+
contents: [{ contents: [{ content: "print('hi')\n" }] }],
106+
},
107+
},
108+
],
109+
}),
110+
).toContain("```python");
111+
});
112+
113+
it("parses code_span", () => {
114+
expect(
115+
parseFeishuCardToMarkdownString({
116+
elements: [{ tag: "code_span", property: { content: "foo" } }],
117+
}),
118+
).toBe("`foo`");
119+
});
120+
121+
it("parses blockquote with > prefix", () => {
122+
expect(
123+
parseFeishuCardToMarkdownString({
124+
elements: [
125+
{
126+
tag: "blockquote",
127+
property: {
128+
elements: [{ tag: "plain_text", property: { content: "quoted text" } }],
129+
},
130+
},
131+
],
132+
}),
133+
).toContain("> quoted text");
134+
});
135+
136+
it("parses table", () => {
137+
const result = parseFeishuCardToMarkdownString({
138+
elements: [
139+
{
140+
tag: "table",
141+
property: {
142+
columns: [
143+
{ displayName: "Name", name: "0" },
144+
{ displayName: "Age", name: "1" },
145+
],
146+
rows: [
147+
{
148+
"0": {
149+
data: {
150+
tag: "markdown",
151+
property: { elements: [{ tag: "plain_text", property: { content: "Alice" } }] },
152+
},
153+
},
154+
"1": {
155+
data: {
156+
tag: "markdown",
157+
property: { elements: [{ tag: "plain_text", property: { content: "30" } }] },
158+
},
159+
},
160+
},
161+
],
162+
},
163+
},
164+
],
165+
});
166+
expect(result).toContain("| Name | Age |");
167+
expect(result).toContain("| Alice | 30 |");
168+
});
169+
170+
it("parses card_header with object title", () => {
171+
expect(
172+
parseFeishuCardToMarkdownString({
173+
elements: [{ tag: "card_header", title: { tag: "plain_text", content: "Card Title" } }],
174+
}),
175+
).toBe("# Card Title");
176+
});
177+
178+
it("parses card_header with string title", () => {
179+
expect(
180+
parseFeishuCardToMarkdownString({
181+
elements: [{ tag: "card_header", title: "Simple Title" }],
182+
}),
183+
).toBe("# Simple Title");
184+
});
185+
186+
it("parses link with url", () => {
187+
expect(
188+
parseFeishuCardToMarkdownString({
189+
elements: [
190+
{ tag: "link", property: { content: "Click me", url: { url: "https://example.com" } } },
191+
],
192+
}),
193+
).toBe("[Click me](https://example.com)");
194+
});
195+
196+
it("parses button with actions", () => {
197+
const result = parseFeishuCardToMarkdownString({
198+
elements: [
199+
{
200+
tag: "button",
201+
property: {
202+
text: { content: "Go" },
203+
actions: [{ action: { url: "https://example.com" } }],
204+
},
205+
},
206+
],
207+
});
208+
expect(result).toBe("[Go](https://example.com)");
209+
});
210+
211+
it("parses button without actions (fallback to label)", () => {
212+
expect(
213+
parseFeishuCardToMarkdownString({
214+
elements: [{ tag: "button", property: { text: { content: "OK" } } }],
215+
}),
216+
).toBe("OK");
217+
});
218+
219+
it("parses hr and br", () => {
220+
const result = parseFeishuCardToMarkdownString({
221+
elements: [
222+
{ tag: "plain_text", content: "above" },
223+
{ tag: "hr" },
224+
{ tag: "plain_text", content: "below" },
225+
],
226+
});
227+
expect(result).toContain("---");
228+
expect(result).toContain("above");
229+
expect(result).toContain("below");
230+
});
231+
232+
it("truncates deeply nested cards", () => {
233+
let card: Record<string, unknown> = { tag: "div", property: { content: "deep" } };
234+
for (let i = 0; i < 15; i++) {
235+
card = { tag: "div", property: { elements: [card] } };
236+
}
237+
const result = parseFeishuCardToMarkdownString({ elements: [card] });
238+
expect(result).toContain("[max recursion depth]");
239+
});
240+
241+
it("parses top-level card with header and body", () => {
242+
const result = parseFeishuCardToMarkdownString({
243+
header: { tag: "card_header", title: { tag: "plain_text", content: "My Card" } },
244+
body: {
245+
tag: "body",
246+
property: {
247+
elements: [{ tag: "markdown", content: "body text" }],
248+
},
249+
},
250+
});
251+
expect(result).toContain("# My Card");
252+
expect(result).toContain("body text");
253+
});
254+
});

0 commit comments

Comments
 (0)