Files
openclaw/extensions/feishu/src/outbound.test.ts
Madoka 63ce7c74bd fix(feishu): comprehensive reply mechanism — outbound replyToId forwarding + topic-aware reply targeting (#33789)
* fix(feishu): comprehensive reply mechanism fix — outbound replyToId forwarding + topic-aware reply targeting

- Forward replyToId from ChannelOutboundContext through sendText/sendMedia
  to sendMessageFeishu/sendMarkdownCardFeishu/sendMediaFeishu, enabling
  reply-to-message via the message tool.

- Fix group reply targeting: use ctx.messageId (triggering message) in
  normal groups to prevent silent topic thread creation (#32980). Preserve
  ctx.rootId targeting for topic-mode groups (group_topic/group_topic_sender)
  and groups with explicit replyInThread config.

- Add regression tests for both fixes.

Fixes #32980
Fixes #32958
Related #19784

* fix: normalize Feishu delivery.to before comparing with messaging tool targets

- Add normalizeDeliveryTarget helper to strip user:/chat: prefixes for Feishu
- Apply normalization in matchesMessagingToolDeliveryTarget before comparison
- This ensures cron duplicate suppression works when session uses prefixed targets
  (user:ou_xxx) but messaging tool extract uses normalized bare IDs (ou_xxx)

Fixes review comment on PR #32755

(cherry picked from commit fc20106f16)

* fix(feishu): catch thrown SDK errors for withdrawn reply targets

The Feishu Lark SDK can throw exceptions (SDK errors with .code or
AxiosErrors with .response.data.code) for withdrawn/deleted reply
targets, in addition to returning error codes in the response object.

Wrap reply calls in sendMessageFeishu and sendCardFeishu with
try-catch to handle thrown withdrawn/not-found errors (230011,
231003) and fall back to client.im.message.create, matching the
existing response-level fallback behavior.

Also extract sendFallbackDirect helper to deduplicate the
direct-send fallback block across both functions.

Closes #33496

(cherry picked from commit ad0901aec1)

* feishu: forward outbound reply target context

(cherry picked from commit c129a691fcf552a1cebe1e8a22ea8611ffc3b377)

* feishu extension: tighten reply target fallback semantics

(cherry picked from commit f85ec610f267020b66713c09e648ec004b2e26f1)

* fix(feishu): align synthesized fallback typing and changelog attribution

* test(feishu): cover group_topic_sender reply targeting

---------

Co-authored-by: Xu Zimo <xuzimojimmy@163.com>
Co-authored-by: Munem Hashmi <munem.hashmi@gmail.com>
Co-authored-by: bmendonca3 <bmendonca3@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-04 20:32:28 -06:00

360 lines
9.8 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock,
}));
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
text: {
chunkMarkdownText: (text: string) => [text],
},
},
}),
}));
import { feishuOutbound } from "./outbound.js";
const sendText = feishuOutbound.sendText!;
describe("feishuOutbound.sendText local-image auto-convert", () => {
beforeEach(() => {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
});
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: {} as any,
to: "chat_1",
text: file,
accountId: "main",
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1",
mediaUrl: file,
accountId: "main",
}),
);
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: {} as any,
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: {} as any,
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: {
channels: {
feishu: {
renderMode: "card",
},
},
} as any,
to: "chat_1",
text: "| a | b |\n| - | - |",
accountId: "main",
});
expect(sendMarkdownCardFeishuMock).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: {} as any,
to: "chat_1",
text: "hello",
replyToId: "om_reply_1",
accountId: "main",
} as any);
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: {} as any,
to: "chat_1",
text: "hello",
replyToId: " ",
threadId: "om_thread_2",
accountId: "main",
} as any);
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1",
text: "hello",
replyToMessageId: "om_thread_2",
accountId: "main",
}),
);
});
});
describe("feishuOutbound.sendText replyToId forwarding", () => {
beforeEach(() => {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
});
it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
await sendText({
cfg: {} as any,
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 sendMarkdownCardFeishu when renderMode=card", async () => {
await sendText({
cfg: {
channels: {
feishu: {
renderMode: "card",
},
},
} as any,
to: "chat_1",
text: "```code```",
replyToId: "om_reply_target",
accountId: "main",
});
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_reply_target",
}),
);
});
it("does not pass replyToMessageId when replyToId is absent", async () => {
await sendText({
cfg: {} as any,
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(() => {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
});
it("forwards replyToId to sendMediaFeishu", async () => {
await feishuOutbound.sendMedia?.({
cfg: {} as any,
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: {} as any,
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(() => {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
});
it("uses markdown cards for captions when renderMode=card", async () => {
const result = await feishuOutbound.sendMedia?.({
cfg: {
channels: {
feishu: {
renderMode: "card",
},
},
} as any,
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: {} as any,
to: "chat_1",
text: "caption",
mediaUrl: "https://example.com/image.png",
threadId: "om_thread_1",
accountId: "main",
} as any);
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",
}),
);
});
});