mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
24a0401c3d
* fix(feishu): preserve thread history and delivery contracts * fix(feishu): route native images by verified content signatures * fix(feishu): require authentic HEIC image signatures * fix(feishu): retain accepted delivery when provider omits message ids * fix(feishu): preserve accepted visibility across all delivery owners * fix(feishu): recognize supported HEIC and TIFF local image paths * test(feishu): prevent accepted-reply fixture state leaking between cases * fix(feishu): preserve typed delivery contracts in CI --------- Co-authored-by: Peter Steinberger <steipete@macos.shared>
1086 lines
31 KiB
TypeScript
1086 lines
31 KiB
TypeScript
// Feishu tests cover send plugin behavior.
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
|
|
const {
|
|
mockConvertMarkdownTables,
|
|
mockClientGet,
|
|
mockClientList,
|
|
mockClientPatch,
|
|
mockCreateFeishuClient,
|
|
mockLogVerbose,
|
|
mockResolveMarkdownTableMode,
|
|
mockResolveFeishuAccount,
|
|
mockRuntimeConvertMarkdownTables,
|
|
mockRuntimeResolveMarkdownTableMode,
|
|
} = vi.hoisted(() => ({
|
|
mockConvertMarkdownTables: vi.fn((text: string) => text),
|
|
mockClientGet: vi.fn(),
|
|
mockClientList: vi.fn(),
|
|
mockClientPatch: vi.fn(),
|
|
mockCreateFeishuClient: vi.fn(),
|
|
mockLogVerbose: vi.fn(),
|
|
mockResolveMarkdownTableMode: vi.fn(() => "preserve"),
|
|
mockResolveFeishuAccount: vi.fn(),
|
|
mockRuntimeConvertMarkdownTables: vi.fn((text: string) => text),
|
|
mockRuntimeResolveMarkdownTableMode: vi.fn(() => "preserve"),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/markdown-table-runtime", () => ({
|
|
resolveMarkdownTableMode: mockResolveMarkdownTableMode,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
|
|
return {
|
|
...actual,
|
|
logVerbose: mockLogVerbose,
|
|
};
|
|
});
|
|
|
|
vi.mock("openclaw/plugin-sdk/text-chunking", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-chunking")>();
|
|
return {
|
|
...actual,
|
|
convertMarkdownTables: mockConvertMarkdownTables,
|
|
};
|
|
});
|
|
|
|
vi.mock("./client.js", () => ({
|
|
createFeishuClient: mockCreateFeishuClient,
|
|
}));
|
|
|
|
vi.mock("./accounts.js", () => ({
|
|
resolveFeishuAccount: mockResolveFeishuAccount,
|
|
resolveFeishuRuntimeAccount: mockResolveFeishuAccount,
|
|
}));
|
|
|
|
vi.mock("./runtime.js", () => ({
|
|
getFeishuRuntime: () => ({
|
|
channel: {
|
|
text: {
|
|
resolveMarkdownTableMode: mockRuntimeResolveMarkdownTableMode,
|
|
convertMarkdownTables: mockRuntimeConvertMarkdownTables,
|
|
},
|
|
},
|
|
}),
|
|
}));
|
|
|
|
let editMessageFeishu: typeof import("./send.js").editMessageFeishu;
|
|
let getMessageFeishu: typeof import("./send.js").getMessageFeishu;
|
|
let listFeishuThreadMessages: typeof import("./send.js").listFeishuThreadMessages;
|
|
let resolveFeishuCardTemplate: typeof import("./send.js").resolveFeishuCardTemplate;
|
|
let sendMarkdownCardFeishu: typeof import("./send.js").sendMarkdownCardFeishu;
|
|
let sendMessageFeishu: typeof import("./send.js").sendMessageFeishu;
|
|
let sendStructuredCardFeishu: typeof import("./send.js").sendStructuredCardFeishu;
|
|
|
|
describe("getMessageFeishu", () => {
|
|
beforeAll(async () => {
|
|
({
|
|
editMessageFeishu,
|
|
getMessageFeishu,
|
|
listFeishuThreadMessages,
|
|
resolveFeishuCardTemplate,
|
|
sendMarkdownCardFeishu,
|
|
sendMessageFeishu,
|
|
sendStructuredCardFeishu,
|
|
} = await import("./send.js"));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("openclaw/plugin-sdk/markdown-table-runtime");
|
|
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
|
vi.doUnmock("openclaw/plugin-sdk/text-chunking");
|
|
vi.doUnmock("./client.js");
|
|
vi.doUnmock("./accounts.js");
|
|
vi.doUnmock("./runtime.js");
|
|
vi.resetModules();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockResolveMarkdownTableMode.mockReturnValue("preserve");
|
|
mockConvertMarkdownTables.mockImplementation((text: string) => text);
|
|
mockRuntimeResolveMarkdownTableMode.mockReturnValue("preserve");
|
|
mockRuntimeConvertMarkdownTables.mockImplementation((text: string) => text);
|
|
mockResolveFeishuAccount.mockReturnValue({
|
|
accountId: "default",
|
|
configured: true,
|
|
});
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("sends text without requiring Feishu runtime text helpers", async () => {
|
|
mockRuntimeResolveMarkdownTableMode.mockImplementation(() => {
|
|
throw new Error("Feishu runtime not initialized");
|
|
});
|
|
mockRuntimeConvertMarkdownTables.mockImplementation(() => {
|
|
throw new Error("Feishu runtime not initialized");
|
|
});
|
|
mockClientPatch.mockResolvedValueOnce({ code: 0 });
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create: vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_send" } }),
|
|
reply: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await sendMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_send",
|
|
text: "hello",
|
|
});
|
|
|
|
expect(mockResolveMarkdownTableMode).toHaveBeenCalledWith({
|
|
cfg: {},
|
|
channel: "feishu",
|
|
});
|
|
expect(mockConvertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
|
|
expect(typeof result.receipt.sentAt).toBe("number");
|
|
expect(result).toEqual({
|
|
messageId: "om_send",
|
|
chatId: "oc_send",
|
|
receipt: {
|
|
primaryPlatformMessageId: "om_send",
|
|
platformMessageIds: ["om_send"],
|
|
parts: [
|
|
{
|
|
platformMessageId: "om_send",
|
|
kind: "text",
|
|
index: 0,
|
|
raw: {
|
|
channel: "feishu",
|
|
messageId: "om_send",
|
|
chatId: "oc_send",
|
|
conversationId: "oc_send",
|
|
},
|
|
threadId: "oc_send",
|
|
},
|
|
],
|
|
threadId: "oc_send",
|
|
sentAt: result.receipt.sentAt,
|
|
raw: [
|
|
{
|
|
channel: "feishu",
|
|
messageId: "om_send",
|
|
chatId: "oc_send",
|
|
conversationId: "oc_send",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("materializes prose soft breaks in the public post send path", async () => {
|
|
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_newlines" } });
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create,
|
|
reply: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
|
|
await sendMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_send",
|
|
text: "first line\nsecond line\n\n```ts\nconst value = 1\n```",
|
|
});
|
|
|
|
const request = create.mock.calls[0]?.[0] as { data?: { content?: string } } | undefined;
|
|
const element = JSON.parse(request?.data?.content ?? "null").zh_cn.content[0][0];
|
|
expect(element).toEqual({
|
|
tag: "md",
|
|
text: "first line \nsecond line\n\n```ts\nconst value = 1\n```",
|
|
});
|
|
});
|
|
|
|
it("rejects direct text deliveries that acknowledge no platform message identifier", async () => {
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create: vi.fn().mockResolvedValue({ code: 0, data: {} }),
|
|
reply: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
sendMessageFeishu({ cfg: {} as ClawdbotConfig, to: "oc_send", text: "hello" }),
|
|
).rejects.toThrow("Feishu send failed: no message_id returned");
|
|
});
|
|
|
|
it("sends automatic mentions as native post elements without rewriting body text", async () => {
|
|
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_mentions" } });
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create,
|
|
reply: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await sendMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_send",
|
|
text: 'body <at user_id="ou_body">Body User</at>',
|
|
mentions: [{ openId: "ou_target", name: "Target User", key: "@_user_1" }],
|
|
});
|
|
|
|
expect(mockConvertMarkdownTables).toHaveBeenCalledWith(
|
|
'body <at user_id="ou_body">Body User</at>',
|
|
"preserve",
|
|
);
|
|
expect(create).toHaveBeenCalledWith({
|
|
params: { receive_id_type: "chat_id" },
|
|
data: {
|
|
receive_id: "oc_send",
|
|
msg_type: "post",
|
|
content: JSON.stringify({
|
|
zh_cn: {
|
|
content: [
|
|
[
|
|
{ tag: "at", user_id: "ou_target", user_name: "Target User" },
|
|
{ tag: "md", text: 'body <at user_id="ou_body">Body User</at>' },
|
|
],
|
|
],
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
expect(typeof result.receipt.sentAt).toBe("number");
|
|
expect(result).toEqual({
|
|
messageId: "om_mentions",
|
|
chatId: "oc_send",
|
|
receipt: {
|
|
primaryPlatformMessageId: "om_mentions",
|
|
platformMessageIds: ["om_mentions"],
|
|
parts: [
|
|
{
|
|
platformMessageId: "om_mentions",
|
|
kind: "text",
|
|
index: 0,
|
|
raw: {
|
|
channel: "feishu",
|
|
messageId: "om_mentions",
|
|
chatId: "oc_send",
|
|
conversationId: "oc_send",
|
|
},
|
|
threadId: "oc_send",
|
|
},
|
|
],
|
|
threadId: "oc_send",
|
|
sentAt: result.receipt.sentAt,
|
|
raw: [
|
|
{
|
|
channel: "feishu",
|
|
messageId: "om_mentions",
|
|
chatId: "oc_send",
|
|
conversationId: "oc_send",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "structured",
|
|
send: () =>
|
|
sendStructuredCardFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_card",
|
|
text: "hello",
|
|
header: { title: "Agent", template: "space lobster" },
|
|
}),
|
|
expectedHeader: {
|
|
title: { tag: "plain_text", content: "Agent" },
|
|
template: "blue",
|
|
},
|
|
},
|
|
{
|
|
name: "markdown",
|
|
send: () =>
|
|
sendMarkdownCardFeishu({ cfg: {} as ClawdbotConfig, to: "oc_card", text: "hello" }),
|
|
expectedHeader: undefined,
|
|
},
|
|
])("sends $name cards with schema-2.0 width config", async ({ send, expectedHeader }) => {
|
|
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_card" } });
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create,
|
|
reply: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
|
|
await send();
|
|
|
|
const request = create.mock.calls[0]?.[0] as { data?: { content?: string } } | undefined;
|
|
expect(JSON.parse(request?.data?.content ?? "null")).toEqual({
|
|
schema: "2.0",
|
|
config: { width_mode: "fill" },
|
|
body: { elements: [{ tag: "markdown", content: "hello" }] },
|
|
...(expectedHeader ? { header: expectedHeader } : {}),
|
|
});
|
|
});
|
|
|
|
it("extracts text content from interactive card elements", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_1",
|
|
chat_id: "oc_1",
|
|
msg_type: "interactive",
|
|
body: {
|
|
content: JSON.stringify({
|
|
elements: [
|
|
{ tag: "markdown", content: "hello markdown" },
|
|
{ tag: "div", text: { content: "hello div" } },
|
|
],
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_1",
|
|
});
|
|
|
|
expect(mockClientGet).toHaveBeenCalledWith({
|
|
params: { card_msg_content_type: "user_card_content" },
|
|
path: { message_id: "om_1" },
|
|
});
|
|
expect(result).toEqual({
|
|
messageId: "om_1",
|
|
chatId: "oc_1",
|
|
chatType: undefined,
|
|
senderId: undefined,
|
|
senderOpenId: undefined,
|
|
senderType: undefined,
|
|
content: "hello markdown\nhello div",
|
|
contentType: "interactive",
|
|
createTime: undefined,
|
|
threadId: undefined,
|
|
});
|
|
});
|
|
|
|
it("preserves the canonical root and thread returned by the Feishu message API", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_topic_child",
|
|
root_id: "om_topic_root",
|
|
thread_id: "omt_topic",
|
|
chat_id: "oc_topic_group",
|
|
msg_type: "text",
|
|
body: { content: JSON.stringify({ text: "topic reply" }) },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_topic_child",
|
|
});
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
messageId: "om_topic_child",
|
|
rootId: "om_topic_root",
|
|
threadId: "omt_topic",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("falls through empty interactive card element arrays and locale variants", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_i18n_card",
|
|
chat_id: "oc_i18n_card",
|
|
msg_type: "interactive",
|
|
body: {
|
|
content: JSON.stringify({
|
|
elements: [],
|
|
body: { elements: [] },
|
|
i18n_elements: {
|
|
zh_cn: [],
|
|
en_us: [
|
|
{
|
|
tag: "markdown",
|
|
content: "hello ${count} {{label}} {{metadata}}",
|
|
},
|
|
],
|
|
},
|
|
template_variable: {
|
|
count: 2,
|
|
label: "tasks",
|
|
metadata: { ignored: true },
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_i18n_card",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
messageId: "om_i18n_card",
|
|
chatId: "oc_i18n_card",
|
|
chatType: undefined,
|
|
senderId: undefined,
|
|
senderOpenId: undefined,
|
|
senderType: undefined,
|
|
content: "hello 2 tasks {{metadata}}",
|
|
contentType: "interactive",
|
|
createTime: undefined,
|
|
threadId: undefined,
|
|
});
|
|
});
|
|
|
|
it("falls back to post-format content when interactive card elements are empty", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_post_card",
|
|
chat_id: "oc_post_card",
|
|
msg_type: "interactive",
|
|
body: {
|
|
content: JSON.stringify({
|
|
elements: [],
|
|
post: {
|
|
zh_cn: {
|
|
title: "Card summary",
|
|
content: [[{ tag: "md", text: "**fallback** body" }]],
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_post_card",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
messageId: "om_post_card",
|
|
chatId: "oc_post_card",
|
|
chatType: undefined,
|
|
senderId: undefined,
|
|
senderOpenId: undefined,
|
|
senderType: undefined,
|
|
content: "Card summary\n\n**fallback** body",
|
|
contentType: "interactive",
|
|
createTime: undefined,
|
|
threadId: undefined,
|
|
});
|
|
});
|
|
|
|
it("extracts text content from post messages", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_post",
|
|
chat_id: "oc_post",
|
|
msg_type: "post",
|
|
body: {
|
|
content: JSON.stringify({
|
|
zh_cn: {
|
|
title: "Summary",
|
|
content: [[{ tag: "text", text: "post body" }]],
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_post",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
messageId: "om_post",
|
|
chatId: "oc_post",
|
|
chatType: undefined,
|
|
senderId: undefined,
|
|
senderOpenId: undefined,
|
|
senderType: undefined,
|
|
content: "Summary\n\npost body",
|
|
contentType: "post",
|
|
createTime: undefined,
|
|
threadId: undefined,
|
|
});
|
|
});
|
|
|
|
it("returns text placeholder instead of raw JSON for unsupported message types", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_file",
|
|
chat_id: "oc_file",
|
|
msg_type: "file",
|
|
body: {
|
|
content: JSON.stringify({ file_key: "file_v3_123" }),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_file",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
messageId: "om_file",
|
|
chatId: "oc_file",
|
|
chatType: undefined,
|
|
senderId: undefined,
|
|
senderOpenId: undefined,
|
|
senderType: undefined,
|
|
content: "[file message]",
|
|
contentType: "file",
|
|
createTime: undefined,
|
|
threadId: undefined,
|
|
});
|
|
});
|
|
|
|
it("supports single-object response shape from Feishu API", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
message_id: "om_single",
|
|
chat_id: "oc_single",
|
|
msg_type: "text",
|
|
body: {
|
|
content: JSON.stringify({ text: "single payload" }),
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_single",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
messageId: "om_single",
|
|
chatId: "oc_single",
|
|
chatType: undefined,
|
|
senderId: undefined,
|
|
senderOpenId: undefined,
|
|
senderType: undefined,
|
|
content: "single payload",
|
|
contentType: "text",
|
|
createTime: undefined,
|
|
threadId: undefined,
|
|
});
|
|
});
|
|
|
|
it("reuses the same content parsing for thread history messages", async () => {
|
|
mockClientList.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_root",
|
|
msg_type: "text",
|
|
body: {
|
|
content: JSON.stringify({ text: "root starter" }),
|
|
},
|
|
},
|
|
{
|
|
message_id: "om_card",
|
|
msg_type: "interactive",
|
|
body: {
|
|
content: JSON.stringify({
|
|
body: {
|
|
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
|
|
},
|
|
}),
|
|
},
|
|
sender: {
|
|
id: "app_1",
|
|
sender_type: "app",
|
|
},
|
|
create_time: "1710000000000",
|
|
},
|
|
{
|
|
message_id: "om_file",
|
|
msg_type: "file",
|
|
body: {
|
|
content: JSON.stringify({ file_key: "file_v3_123" }),
|
|
},
|
|
sender: {
|
|
id: "ou_1",
|
|
sender_type: "user",
|
|
},
|
|
create_time: "1710000001000",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await listFeishuThreadMessages({
|
|
cfg: {} as ClawdbotConfig,
|
|
threadId: "omt_1",
|
|
rootMessageId: "om_root",
|
|
});
|
|
|
|
expect(mockClientList).toHaveBeenCalledWith({
|
|
params: {
|
|
container_id_type: "thread",
|
|
container_id: "omt_1",
|
|
sort_type: "ByCreateTimeDesc",
|
|
page_size: 21,
|
|
card_msg_content_type: "user_card_content",
|
|
},
|
|
});
|
|
expect(result).toEqual([
|
|
{
|
|
messageId: "om_file",
|
|
senderId: "ou_1",
|
|
senderType: "user",
|
|
contentType: "file",
|
|
content: "[file message]",
|
|
createTime: 1710000001000,
|
|
},
|
|
{
|
|
messageId: "om_card",
|
|
senderId: "app_1",
|
|
senderType: "app",
|
|
contentType: "interactive",
|
|
content: "hello from card 2.0",
|
|
createTime: 1710000000000,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not partially parse malformed thread history create_time values", async () => {
|
|
mockClientList.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{
|
|
message_id: "om_text",
|
|
msg_type: "text",
|
|
body: {
|
|
content: JSON.stringify({ text: "partial time" }),
|
|
},
|
|
sender: {
|
|
id: "ou_1",
|
|
sender_type: "user",
|
|
},
|
|
create_time: "1710000000000ms",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await listFeishuThreadMessages({
|
|
cfg: {} as ClawdbotConfig,
|
|
threadId: "omt_1",
|
|
rootMessageId: "om_root",
|
|
});
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
messageId: "om_text",
|
|
senderId: "ou_1",
|
|
senderType: "user",
|
|
contentType: "text",
|
|
content: "partial time",
|
|
createTime: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("fills thread history from continuation pages after excluding the current and root messages", async () => {
|
|
mockClientList
|
|
.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
has_more: true,
|
|
page_token: "older-history",
|
|
items: [
|
|
{ message_id: "om_current", body: { content: '{"text":"current"}' } },
|
|
{ message_id: "om_root", body: { content: '{"text":"root"}' } },
|
|
{ message_id: "om_newer", body: { content: '{"text":"newer"}' } },
|
|
],
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
has_more: false,
|
|
items: [{ message_id: "om_older", body: { content: '{"text":"older"}' } }],
|
|
},
|
|
});
|
|
|
|
const result = await listFeishuThreadMessages({
|
|
cfg: {} as ClawdbotConfig,
|
|
threadId: "omt_1",
|
|
currentMessageId: "om_current",
|
|
rootMessageId: "om_root",
|
|
limit: 2,
|
|
});
|
|
|
|
expect(result.map((message) => message.messageId)).toEqual(["om_older", "om_newer"]);
|
|
expect(mockClientList).toHaveBeenNthCalledWith(2, {
|
|
params: {
|
|
container_id_type: "thread",
|
|
container_id: "omt_1",
|
|
sort_type: "ByCreateTimeDesc",
|
|
page_size: 3,
|
|
page_token: "older-history",
|
|
card_msg_content_type: "user_card_content",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("reads thread history beyond the SDK's maximum single-page size", async () => {
|
|
const pageOne = Array.from({ length: 50 }, (_value, index) => ({
|
|
message_id: `om_${String(51 - index)}`,
|
|
body: { content: JSON.stringify({ text: String(51 - index) }) },
|
|
}));
|
|
mockClientList
|
|
.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: { items: pageOne, has_more: true, page_token: "last-message" },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [{ message_id: "om_1", body: { content: '{"text":"1"}' } }],
|
|
has_more: false,
|
|
},
|
|
});
|
|
|
|
const result = await listFeishuThreadMessages({
|
|
cfg: {} as ClawdbotConfig,
|
|
threadId: "omt_1",
|
|
limit: 51,
|
|
});
|
|
|
|
expect(result).toHaveLength(51);
|
|
expect(result[0]?.messageId).toBe("om_1");
|
|
expect(result.at(-1)?.messageId).toBe("om_51");
|
|
});
|
|
|
|
it("deduplicates overlapping continuation pages without consuming the history limit", async () => {
|
|
mockClientList
|
|
.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
has_more: true,
|
|
page_token: "overlapping-page",
|
|
items: [{ message_id: "om_newer", body: { content: '{"text":"newer"}' } }],
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{ message_id: "om_newer", body: { content: '{"text":"duplicate"}' } },
|
|
{ message_id: "om_older", body: { content: '{"text":"older"}' } },
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await listFeishuThreadMessages({
|
|
cfg: {} as ClawdbotConfig,
|
|
threadId: "omt_1",
|
|
limit: 2,
|
|
});
|
|
|
|
expect(result.map((message) => message.messageId)).toEqual(["om_older", "om_newer"]);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "missing", firstToken: undefined, secondToken: undefined },
|
|
{ name: "repeated", firstToken: "same-page", secondToken: "same-page" },
|
|
])("rejects $name thread history continuation tokens", async ({ firstToken, secondToken }) => {
|
|
mockClientList.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: { items: [], has_more: true, ...(firstToken ? { page_token: firstToken } : {}) },
|
|
});
|
|
if (firstToken) {
|
|
mockClientList.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: { items: [], has_more: true, ...(secondToken ? { page_token: secondToken } : {}) },
|
|
});
|
|
}
|
|
|
|
await expect(
|
|
listFeishuThreadMessages({ cfg: {} as ClawdbotConfig, threadId: "omt_1" }),
|
|
).rejects.toThrow(
|
|
`Feishu thread history pagination returned a ${firstToken ? "repeated" : "missing"} page token`,
|
|
);
|
|
});
|
|
|
|
it("logs a safe diagnostic (not raw content) when message content is not valid JSON", async () => {
|
|
mockClientGet.mockResolvedValueOnce({
|
|
code: 0,
|
|
data: {
|
|
message_id: "om_bad_json",
|
|
chat_id: "oc_test",
|
|
chat_type: "group",
|
|
msg_type: "text",
|
|
body: {
|
|
content: "{bad json}",
|
|
},
|
|
sender: {
|
|
id: "ou_1",
|
|
sender_type: "user",
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await getMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_bad_json",
|
|
});
|
|
|
|
expect(mockLogVerbose).toHaveBeenCalledWith(
|
|
expect.stringContaining("feishu message content parse failed for text message"),
|
|
);
|
|
expect(mockLogVerbose.mock.calls.flat().map(String).join("\n")).not.toContain("{bad json}");
|
|
expect(result).toMatchObject({
|
|
messageId: "om_bad_json",
|
|
contentType: "text",
|
|
content: "{bad json}",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("editMessageFeishu", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockResolveFeishuAccount.mockReturnValue({
|
|
accountId: "default",
|
|
configured: true,
|
|
});
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("patches post content for text edits", async () => {
|
|
mockRuntimeResolveMarkdownTableMode.mockImplementation(() => {
|
|
throw new Error("Feishu runtime not initialized");
|
|
});
|
|
mockRuntimeConvertMarkdownTables.mockImplementation(() => {
|
|
throw new Error("Feishu runtime not initialized");
|
|
});
|
|
mockClientPatch.mockResolvedValueOnce({ code: 0 });
|
|
|
|
const result = await editMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_edit",
|
|
text: "updated body",
|
|
});
|
|
|
|
expect(mockClientPatch).toHaveBeenCalledWith({
|
|
path: { message_id: "om_edit" },
|
|
data: {
|
|
content: JSON.stringify({
|
|
zh_cn: {
|
|
content: [
|
|
[
|
|
{
|
|
tag: "md",
|
|
text: "updated body",
|
|
},
|
|
],
|
|
],
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
expect(result).toEqual({ messageId: "om_edit", contentType: "post" });
|
|
});
|
|
|
|
it("normalizes post edits and accepts content beyond the delivery chunk size", async () => {
|
|
mockClientPatch.mockResolvedValueOnce({ code: 0 });
|
|
const text = `${"a".repeat(4_500)}\nsecond line`;
|
|
|
|
await editMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_edit",
|
|
text,
|
|
});
|
|
|
|
const request = mockClientPatch.mock.calls[0]?.[0] as { data?: { content?: string } };
|
|
const element = JSON.parse(request.data?.content ?? "null").zh_cn.content[0][0];
|
|
expect(element.text).toBe(`${"a".repeat(4_500)} \nsecond line`);
|
|
});
|
|
|
|
it("rejects edits that exceed the rich-post byte envelope", async () => {
|
|
await expect(
|
|
editMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_edit",
|
|
text: "界".repeat(11_000),
|
|
}),
|
|
).rejects.toThrow("Feishu message edit exceeds the 30 KB rich-post API limit");
|
|
expect(mockClientPatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("patches interactive content for card edits", async () => {
|
|
mockClientPatch.mockResolvedValueOnce({ code: 0 });
|
|
|
|
const result = await editMessageFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
messageId: "om_card",
|
|
card: { schema: "2.0" },
|
|
});
|
|
|
|
expect(mockClientPatch).toHaveBeenCalledWith({
|
|
path: { message_id: "om_card" },
|
|
data: {
|
|
content: JSON.stringify({ schema: "2.0" }),
|
|
},
|
|
});
|
|
expect(result).toEqual({ messageId: "om_card", contentType: "interactive" });
|
|
});
|
|
});
|
|
|
|
describe("resolveFeishuCardTemplate", () => {
|
|
it("accepts supported Feishu templates", () => {
|
|
expect(resolveFeishuCardTemplate(" purple ")).toBe("purple");
|
|
});
|
|
|
|
it("drops unsupported free-form identity themes", () => {
|
|
expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined();
|
|
});
|
|
});
|
|
describe("Feishu card-mode newline preservation", () => {
|
|
function createCardClient() {
|
|
const create = vi.fn().mockResolvedValue({ code: 0, data: { message_id: "om_card" } });
|
|
mockCreateFeishuClient.mockReturnValue({
|
|
im: {
|
|
message: {
|
|
create,
|
|
reply: vi.fn(),
|
|
get: mockClientGet,
|
|
list: mockClientList,
|
|
patch: mockClientPatch,
|
|
},
|
|
},
|
|
});
|
|
return create;
|
|
}
|
|
|
|
function parseCardContent(create: ReturnType<typeof vi.fn>) {
|
|
const request = create.mock.calls[0]?.[0] as { data?: { content?: string } } | undefined;
|
|
return JSON.parse(request?.data?.content ?? "null") as {
|
|
body: { elements: Array<{ tag: string; content: string }> };
|
|
};
|
|
}
|
|
|
|
it("preserves single newlines in markdown card text", async () => {
|
|
const create = createCardClient();
|
|
await sendMarkdownCardFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_card",
|
|
text: "line one\nline two\nline three",
|
|
});
|
|
expect(parseCardContent(create).body.elements[0]?.content).toBe(
|
|
"line one\nline two\nline three",
|
|
);
|
|
});
|
|
|
|
it("preserves single newlines in structured card text", async () => {
|
|
const create = createCardClient();
|
|
await sendStructuredCardFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_card",
|
|
text: "first\nsecond\nthird",
|
|
});
|
|
expect(parseCardContent(create).body.elements[0]?.content).toBe("first\nsecond\nthird");
|
|
});
|
|
|
|
it("keeps existing double newlines unchanged in markdown card text", async () => {
|
|
const create = createCardClient();
|
|
await sendMarkdownCardFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_card",
|
|
text: "para a\n\npara b",
|
|
});
|
|
expect(parseCardContent(create).body.elements[0]?.content).toBe("para a\n\npara b");
|
|
});
|
|
|
|
it("keeps existing double newlines unchanged in structured card text", async () => {
|
|
const create = createCardClient();
|
|
await sendStructuredCardFeishu({
|
|
cfg: {} as ClawdbotConfig,
|
|
to: "oc_card",
|
|
text: "section 1\n\nsection 2",
|
|
});
|
|
expect(parseCardContent(create).body.elements[0]?.content).toBe("section 1\n\nsection 2");
|
|
});
|
|
});
|