forked from EduCraft/curriculum-project-hub
feat: improve Feishu markdown rendering and processing status feedback
- Add buildOutboundPayload: detect markdown format, isolate code blocks, force plain text for tables, fallback from post to text on API rejection - Add addReaction/removeReaction for processing status lifecycle - Replace THUMBSUP with Typing→(remove on success | CrossMark on failure) - Add unit tests for markdown rendering (15) and reactions (6)
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
_buildMarkdownPostRows,
|
||||
buildOutboundPayload,
|
||||
patchTextMessage,
|
||||
sendTextMessage,
|
||||
type FeishuRuntime,
|
||||
} from "../../src/feishu/client.js";
|
||||
|
||||
describe("Feishu markdown outbound payloads", () => {
|
||||
it("uses text messages for plain text", () => {
|
||||
const payload = buildOutboundPayload("hello from cph");
|
||||
|
||||
expect(payload.msgType).toBe("text");
|
||||
expect(JSON.parse(payload.content)).toEqual({ text: "hello from cph" });
|
||||
});
|
||||
|
||||
it("keeps fenced code blocks in separate post rows", () => {
|
||||
const content = "Before\n```ts\nconst x = 1;\n```\nAfter";
|
||||
|
||||
expect(_buildMarkdownPostRows(content)).toEqual([
|
||||
[{ tag: "md", text: "Before\n" }],
|
||||
[{ tag: "md", text: "```ts\nconst x = 1;\n```" }],
|
||||
[{ tag: "md", text: "\nAfter" }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("forces markdown tables to plain text", () => {
|
||||
const content = "| A | B |\n|---|---|\n| 1 | 2 |";
|
||||
const payload = buildOutboundPayload(content);
|
||||
|
||||
expect(payload.msgType).toBe("text");
|
||||
expect(JSON.parse(payload.content)).toEqual({ text: content });
|
||||
});
|
||||
|
||||
it("uses post messages for markdown headers, bold text, and lists", () => {
|
||||
const content = "# Title\n\nThis is **important**.\n\n- first\n- second";
|
||||
const payload = buildOutboundPayload(content);
|
||||
|
||||
expect(payload.msgType).toBe("post");
|
||||
expect(JSON.parse(payload.content)).toEqual({
|
||||
zh_cn: {
|
||||
content: [[{ tag: "md", text: content }]],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["header", "# Heading"],
|
||||
["list", "- item"],
|
||||
["code block", "```ts\nconst x = 1;\n```"],
|
||||
["inline code", "Use `code` here"],
|
||||
["bold", "**bold**"],
|
||||
["italic", "_italic_"],
|
||||
["strikethrough", "~~removed~~"],
|
||||
["link", "[docs](https://example.com)"],
|
||||
["blockquote", "> quoted"],
|
||||
])("detects markdown %s syntax", (_name, content) => {
|
||||
expect(buildOutboundPayload(content).msgType).toBe("post");
|
||||
});
|
||||
|
||||
it("falls back from rejected post payloads to stripped plain text", async () => {
|
||||
const messageCreate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("content format of the post type is incorrect"))
|
||||
.mockResolvedValueOnce({ data: { message_id: "message-1" } });
|
||||
const rt = mockRuntime({ messageCreate });
|
||||
|
||||
await expect(sendTextMessage(rt, "chat-1", "# Title\n\n**bold** and `code`")).resolves.toBe("message-1");
|
||||
|
||||
expect(messageCreate).toHaveBeenCalledTimes(2);
|
||||
expect(messageCreate).toHaveBeenNthCalledWith(1, {
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "chat-1",
|
||||
msg_type: "post",
|
||||
content: JSON.stringify({
|
||||
zh_cn: {
|
||||
content: [[{ tag: "md", text: "# Title\n\n**bold** and `code`" }]],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(messageCreate).toHaveBeenNthCalledWith(2, {
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: {
|
||||
receive_id: "chat-1",
|
||||
msg_type: "text",
|
||||
content: JSON.stringify({ text: "Title\n\nbold and code" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("patches markdown post rows as separate interactive card elements", async () => {
|
||||
const patch = vi.fn(async () => ({}));
|
||||
const rt = mockRuntime({ patch });
|
||||
|
||||
await patchTextMessage(rt, "message-1", "Before\n```ts\nconst x = 1;\n```\nAfter");
|
||||
|
||||
expect(patch).toHaveBeenCalledWith({
|
||||
path: { message_id: "message-1" },
|
||||
params: { msg_type: "interactive" },
|
||||
data: {
|
||||
content: JSON.stringify({
|
||||
elements: [
|
||||
{ tag: "div", text: { tag: "lark_md", content: "Before\n" } },
|
||||
{ tag: "div", text: { tag: "lark_md", content: "```ts\nconst x = 1;\n```" } },
|
||||
{ tag: "div", text: { tag: "lark_md", content: "\nAfter" } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly messageCreate?: (payload: unknown) => Promise<unknown>;
|
||||
readonly patch?: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
message: {
|
||||
create: options.messageCreate ?? vi.fn(async () => ({ data: { message_id: "message-1" } })),
|
||||
patch: options.patch ?? vi.fn(async () => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user