import fs from
"node:fs/promises" ;
import os from
"node:os" ;
import path from
"node:path" ;
import { beforeEach, describe, expect, it, vi } from
"vitest" ;
import type { ClawdbotConfig } from
"../runtime-api.js" ;
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
const cleanupAmbientCommentTypingReactionMock = vi.hoisted(() => vi.fn(async () =>
false ))
;
vi.mock("./media.js" , () => ({
sendMediaFeishu: sendMediaFeishuMock,
}));
vi.mock("./send.js" , () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
}));
vi.mock("./runtime.js" , () => ({
getFeishuRuntime: () => ({
channel: {
text: {
chunkMarkdownText: (text: string) => [text],
},
},
}),
}));
vi.mock("./client.js" , () => ({
createFeishuClient: vi.fn(() => ({ request: vi.fn() })),
}));
vi.mock("./drive.js" , () => ({
deliverCommentThreadText: deliverCommentThreadTextMock,
}));
vi.mock("./comment-reaction.js" , () => ({
cleanupAmbientCommentTypingReaction: cleanupAmbientCommentTypingReactionMock,
}));
import { feishuOutbound } from "./outbound.js" ;
const sendText = feishuOutbound.sendText!;
const emptyConfig: ClawdbotConfig = {};
const cardRenderConfig: ClawdbotConfig = {
channels: {
feishu: {
renderMode: "card" ,
},
},
};
function resetOutboundMocks() {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
deliverCommentThreadTextMock.mockResolvedValue({
delivery_mode: "reply_comment" ,
reply_id: "reply_msg" ,
});
cleanupAmbientCommentTypingReactionMock.mockResolvedValue(false );
}
describe("feishuOutbound.sendText local-image auto-convert" , () => {
beforeEach(() => {
resetOutboundMocks();
});
it("chunks outbound text without requiring Feishu runtime initialization" , () => {
const chunker = feishuOutbound.chunker;
if (!chunker) {
throw new Error("feishuOutbound.chunker missing" );
}
expect(() => chunker("hello world" , 5 )).not.toThrow();
expect(chunker("hello world" , 5 )).toEqual(["hello" , "world" ]);
});
async function createTmpImage(ext = ".png" ): Promise<{ dir: string; file: string }> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-" ));
const file = path.join(dir, `sample${ext}`);
await fs.writeFile(file, "image-data" );
return { dir, file };
}
it("sends an absolute existing local image path as media" , async () => {
const { dir, file } = await createTmpImage();
try {
const result = await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: file,
accountId: "main" ,
mediaLocalRoots: [dir],
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
mediaUrl: file,
accountId: "main" ,
mediaLocalRoots: [dir],
}),
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({ channel: "feishu" , messageId: "media_msg" }),
);
} finally {
await fs.rm(dir, { recursive: true , force: true });
}
});
it("keeps non-path text on the text-send path" , async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: "please upload /tmp/example.png" ,
accountId: "main" ,
});
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "please upload /tmp/example.png" ,
accountId: "main" ,
}),
);
});
it("falls back to plain text if local-image media send fails" , async () => {
const { dir, file } = await createTmpImage();
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed" ));
try {
await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: file,
accountId: "main" ,
});
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1 );
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: file,
accountId: "main" ,
}),
);
} finally {
await fs.rm(dir, { recursive: true , force: true });
}
});
it("uses markdown cards when renderMode=card" , async () => {
const result = await sendText({
cfg: cardRenderConfig,
to: "chat_1" ,
text: "| a | b |\n| - | - |" ,
accountId: "main" ,
});
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "| a | b |\n| - | - |" ,
accountId: "main" ,
}),
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu" , messageId: "card_msg" }));
});
it("forwards replyToId as replyToMessageId on sendText" , async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: "hello" ,
replyToId: "om_reply_1" ,
accountId: "main" ,
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "hello" ,
replyToMessageId: "om_reply_1" ,
accountId: "main" ,
}),
);
});
it("falls back to threadId when replyToId is empty on sendText" , async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: "hello" ,
replyToId: " " ,
threadId: "om_thread_2" ,
accountId: "main" ,
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "hello" ,
replyToMessageId: "om_thread_2" ,
accountId: "main" ,
}),
);
});
});
describe("feishuOutbound comment-thread routing" , () => {
beforeEach(() => {
resetOutboundMocks();
});
it("routes comment-thread text through deliverCommentThreadText" , async () => {
const result = await sendText({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411" ,
text: "handled in thread" ,
accountId: "main" ,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doxcn123" ,
file_type: "docx" ,
comment_id: "7623358762119646411" ,
content: "handled in thread" ,
}),
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu" , messageId: "reply_msg" }));
});
it("routes comment-thread code-block replies through deliverCommentThreadText instead of IM cards" , async () => {
const result = await sendText({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411" ,
text: "```ts\nconst x = 1\n```" ,
accountId: "main" ,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doxcn123" ,
file_type: "docx" ,
comment_id: "7623358762119646411" ,
content: "```ts\nconst x = 1\n```" ,
}),
);
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu" , messageId: "reply_msg" }));
});
it("routes comment-thread replies through deliverCommentThreadText even when renderMode=card" , async () => {
const result = await sendText({
cfg: cardRenderConfig,
to: "comment:docx:doxcn123:7623358762119646411" ,
text: "handled in thread" ,
accountId: "main" ,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doxcn123" ,
file_type: "docx" ,
comment_id: "7623358762119646411" ,
content: "handled in thread" ,
}),
);
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu" , messageId: "reply_msg" }));
});
it("falls back to a text-only comment reply for media payloads" , async () => {
const result = await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411" ,
text: "see attachment" ,
mediaUrl: "https://example.com/file.png ",
accountId: "main" ,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
content: "see attachment\n\nhttps://example.com/file.png ",
}),
);
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu" , messageId: "reply_msg" }));
});
it("preserves comment-thread routing when deliverCommentThreadText falls back to add_comment" , async () => {
deliverCommentThreadTextMock.mockResolvedValueOnce({
delivery_mode: "add_comment" ,
comment_id: "comment_msg" ,
reply_id: "reply_from_add_comment" ,
});
const result = await sendText({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411" ,
text: "whole-comment follow-up" ,
accountId: "main" ,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doxcn123" ,
file_type: "docx" ,
comment_id: "7623358762119646411" ,
content: "whole-comment follow-up" ,
}),
);
expect(result).toEqual(
expect.objectContaining({
channel: "feishu" ,
messageId: "reply_from_add_comment" ,
}),
);
});
it("does not wait for ambient comment typing cleanup before sending comment-thread replies" , async () => {
let resolveCleanup: ((value: boolean ) => void ) | undefined;
cleanupAmbientCommentTypingReactionMock.mockImplementationOnce(
() =>
new Promise<boolean >((resolve) => {
resolveCleanup = resolve;
}),
);
const sendPromise = sendText({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411" ,
text: "handled in thread" ,
replyToId: "reply_ambient_1" ,
accountId: "main" ,
});
const status = await Promise.race([
sendPromise.then(() => "done" ),
new Promise<string>((resolve) => setTimeout(() => resolve("pending" ), 0 )),
]);
expect(status).toBe("done" );
expect(deliverCommentThreadTextMock).toHaveBeenCalled();
expect(cleanupAmbientCommentTypingReactionMock).toHaveBeenCalledWith({
client: expect.anything(),
deliveryContext: {
channel: "feishu" ,
to: "comment:docx:doxcn123:7623358762119646411" ,
threadId: "reply_ambient_1" ,
},
});
resolveCleanup?.(false );
await sendPromise;
});
});
describe("feishuOutbound.sendText replyToId forwarding" , () => {
beforeEach(() => {
resetOutboundMocks();
});
it("forwards replyToId as replyToMessageId to sendMessageFeishu" , async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: "hello" ,
replyToId: "om_reply_target" ,
accountId: "main" ,
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "hello" ,
replyToMessageId: "om_reply_target" ,
accountId: "main" ,
}),
);
});
it("forwards replyToId to sendStructuredCardFeishu when renderMode=card" , async () => {
await sendText({
cfg: cardRenderConfig,
to: "chat_1" ,
text: "```code```" ,
replyToId: "om_reply_target" ,
accountId: "main" ,
});
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_reply_target" ,
}),
);
});
it("does not pass replyToMessageId when replyToId is absent" , async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1" ,
text: "hello" ,
accountId: "main" ,
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "hello" ,
accountId: "main" ,
}),
);
expect(sendMessageFeishuMock.mock.calls[0 ][0 ].replyToMessageId).toBeUndefined();
});
});
describe("feishuOutbound.sendMedia replyToId forwarding" , () => {
beforeEach(() => {
resetOutboundMocks();
});
it("forwards replyToId to sendMediaFeishu" , async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1" ,
text: "" ,
mediaUrl: "https://example.com/image.png ",
replyToId: "om_reply_target" ,
accountId: "main" ,
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_reply_target" ,
}),
);
});
it("forwards replyToId to text caption send" , async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1" ,
text: "caption text" ,
mediaUrl: "https://example.com/image.png ",
replyToId: "om_reply_target" ,
accountId: "main" ,
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_reply_target" ,
}),
);
});
});
describe("feishuOutbound.sendMedia renderMode" , () => {
beforeEach(() => {
resetOutboundMocks();
});
it("uses markdown cards for captions when renderMode=card" , async () => {
const result = await feishuOutbound.sendMedia?.({
cfg: cardRenderConfig,
to: "chat_1" ,
text: "| a | b |\n| - | - |" ,
mediaUrl: "https://example.com/image.png ",
accountId: "main" ,
});
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "| a | b |\n| - | - |" ,
accountId: "main" ,
}),
);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
mediaUrl: "https://example.com/image.png ",
accountId: "main" ,
}),
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(result).toEqual(expect.objectContaining({ channel: "feishu" , messageId: "media_msg" }));
});
it("uses threadId fallback as replyToMessageId on sendMedia" , async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1" ,
text: "caption" ,
mediaUrl: "https://example.com/image.png ",
threadId: "om_thread_1" ,
accountId: "main" ,
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
mediaUrl: "https://example.com/image.png ",
replyToMessageId: "om_thread_1" ,
accountId: "main" ,
}),
);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1" ,
text: "caption" ,
replyToMessageId: "om_thread_1" ,
accountId: "main" ,
}),
);
});
});
Messung V0.5 in Prozent C=100 H=97 G=98
¤ Dauer der Verarbeitung: 0.5 Sekunden
¤
*© Formatika GbR, Deutschland