mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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 commitfc20106f16) * 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 commitad0901aec1) * 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>
This commit is contained in:
1
changelog/fragments/pr-feishu-reply-mechanism.md
Normal file
1
changelog/fragments/pr-feishu-reply-mechanism.md
Normal file
@@ -0,0 +1 @@
|
||||
- Feishu reply routing now uses one canonical reply-target path across inbound and outbound flows: normal groups reply to the triggering message while topic-mode groups stay on topic roots, outbound sends preserve `replyToId`/`threadId`, withdrawn reply targets fall back to direct sends, and cron duplicate suppression normalizes Feishu/Lark target IDs consistently (#32980, #32958, #33572, #33526; #33789, #33575, #33515, #33161). Thanks @guoqunabc, @bmendonca3, @MunemHashmi, and @Jimmy-xuzimo.
|
||||
@@ -1517,6 +1517,120 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("replies to triggering message in normal group even when root_id is present (#32980)", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-normal-user" } },
|
||||
message: {
|
||||
message_id: "om_quote_reply",
|
||||
root_id: "om_original_msg",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello in normal group" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_quote_reply",
|
||||
rootId: "om_original_msg",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replies to topic root in topic-mode group with root_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "om_topic_reply",
|
||||
root_id: "om_topic_root",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello in topic group" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_topic_root",
|
||||
rootId: "om_topic_root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replies to topic root in topic-sender group with root_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic_sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-sender-user" } },
|
||||
message: {
|
||||
message_id: "om_topic_sender_reply",
|
||||
root_id: "om_topic_sender_root",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello in topic sender group" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_topic_sender_root",
|
||||
rootId: "om_topic_sender_root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forces thread replies when inbound message contains thread_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -1337,7 +1337,23 @@ export async function handleFeishuMessage(params: {
|
||||
const messageCreateTimeMs = event.message.create_time
|
||||
? parseInt(event.message.create_time, 10)
|
||||
: undefined;
|
||||
const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
|
||||
// Determine reply target based on group session mode:
|
||||
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
|
||||
// root so the bot stays in the same thread.
|
||||
// - Groups with explicit replyInThread config: reply to the root so the bot
|
||||
// stays in the thread the user expects.
|
||||
// - Normal groups (auto-detected threadReply from root_id): reply to the
|
||||
// triggering message itself. Using rootId here would silently push the
|
||||
// reply into a topic thread invisible in the main chat view (#32980).
|
||||
const isTopicSession =
|
||||
isGroup &&
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender");
|
||||
const configReplyInThread =
|
||||
isGroup &&
|
||||
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
||||
const replyTargetMessageId =
|
||||
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
|
||||
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
||||
|
||||
if (broadcastAgents) {
|
||||
|
||||
@@ -136,6 +136,156 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
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", () => {
|
||||
@@ -178,4 +328,32 @@ describe("feishuOutbound.sendMedia renderMode", () => {
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,21 +43,37 @@ function shouldUseCard(text: string): boolean {
|
||||
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
||||
}
|
||||
|
||||
function resolveReplyToMessageId(params: {
|
||||
replyToId?: string | null;
|
||||
threadId?: string | number | null;
|
||||
}): string | undefined {
|
||||
const replyToId = params.replyToId?.trim();
|
||||
if (replyToId) {
|
||||
return replyToId;
|
||||
}
|
||||
if (params.threadId == null) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = String(params.threadId).trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
async function sendOutboundText(params: {
|
||||
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
accountId?: string;
|
||||
}) {
|
||||
const { cfg, to, text, accountId } = params;
|
||||
const { cfg, to, text, accountId, replyToMessageId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const renderMode = account.config?.renderMode ?? "auto";
|
||||
|
||||
if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
|
||||
return sendMarkdownCardFeishu({ cfg, to, text, accountId });
|
||||
return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId });
|
||||
}
|
||||
|
||||
return sendMessageFeishu({ cfg, to, text, accountId });
|
||||
return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId });
|
||||
}
|
||||
|
||||
export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
@@ -65,7 +81,8 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId }) => {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
// Scheme A compatibility shim:
|
||||
// when upstream accidentally returns a local image path as plain text,
|
||||
// auto-upload and send as Feishu image message instead of leaking path text.
|
||||
@@ -77,6 +94,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
to,
|
||||
mediaUrl: localImagePath,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
} catch (err) {
|
||||
@@ -90,10 +108,21 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
replyToId,
|
||||
threadId,
|
||||
}) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
// Send text first if provided
|
||||
if (text?.trim()) {
|
||||
await sendOutboundText({
|
||||
@@ -101,6 +130,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +143,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
mediaUrl,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaLocalRoots,
|
||||
replyToMessageId,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
} catch (err) {
|
||||
@@ -125,6 +156,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
to,
|
||||
text: fallbackText,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
}
|
||||
@@ -136,6 +168,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
to,
|
||||
text: text ?? "",
|
||||
accountId: accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
},
|
||||
|
||||
@@ -102,4 +102,78 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to create when reply throws a withdrawn SDK error", async () => {
|
||||
const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
|
||||
replyMock.mockRejectedValue(sdkError);
|
||||
createMock.mockResolvedValue({
|
||||
code: 0,
|
||||
data: { message_id: "om_thrown_fallback" },
|
||||
});
|
||||
|
||||
const result = await sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("om_thrown_fallback");
|
||||
});
|
||||
|
||||
it("falls back to create when card reply throws a not-found AxiosError", async () => {
|
||||
const axiosError = Object.assign(new Error("Request failed"), {
|
||||
response: { status: 200, data: { code: 231003, msg: "The message is not found" } },
|
||||
});
|
||||
replyMock.mockRejectedValue(axiosError);
|
||||
createMock.mockResolvedValue({
|
||||
code: 0,
|
||||
data: { message_id: "om_axios_fallback" },
|
||||
});
|
||||
|
||||
const result = await sendCardFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
card: { schema: "2.0" },
|
||||
replyToMessageId: "om_parent",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("om_axios_fallback");
|
||||
});
|
||||
|
||||
it("re-throws non-withdrawn thrown errors for text messages", async () => {
|
||||
const sdkError = Object.assign(new Error("rate limited"), { code: 99991400 });
|
||||
replyMock.mockRejectedValue(sdkError);
|
||||
|
||||
await expect(
|
||||
sendMessageFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
text: "hello",
|
||||
replyToMessageId: "om_parent",
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-throws non-withdrawn thrown errors for card messages", async () => {
|
||||
const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 });
|
||||
replyMock.mockRejectedValue(sdkError);
|
||||
|
||||
await expect(
|
||||
sendCardFeishu({
|
||||
cfg: {} as never,
|
||||
to: "user:ou_target",
|
||||
card: { schema: "2.0" },
|
||||
replyToMessageId: "om_parent",
|
||||
}),
|
||||
).rejects.toThrow("permission denied");
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,61 @@ function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }
|
||||
return msg.includes("withdrawn") || msg.includes("not found");
|
||||
}
|
||||
|
||||
/** Check whether a thrown error indicates a withdrawn/not-found reply target. */
|
||||
function isWithdrawnReplyError(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) {
|
||||
return false;
|
||||
}
|
||||
// SDK error shape: err.code
|
||||
const code = (err as { code?: number }).code;
|
||||
if (typeof code === "number" && WITHDRAWN_REPLY_ERROR_CODES.has(code)) {
|
||||
return true;
|
||||
}
|
||||
// AxiosError shape: err.response.data.code
|
||||
const response = (err as { response?: { data?: { code?: number; msg?: string } } }).response;
|
||||
if (
|
||||
typeof response?.data?.code === "number" &&
|
||||
WITHDRAWN_REPLY_ERROR_CODES.has(response.data.code)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type FeishuCreateMessageClient = {
|
||||
im: {
|
||||
message: {
|
||||
create: (opts: {
|
||||
params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" };
|
||||
data: { receive_id: string; content: string; msg_type: string };
|
||||
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/** Send a direct message as a fallback when a reply target is unavailable. */
|
||||
async function sendFallbackDirect(
|
||||
client: FeishuCreateMessageClient,
|
||||
params: {
|
||||
receiveId: string;
|
||||
receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
|
||||
content: string;
|
||||
msgType: string;
|
||||
},
|
||||
errorPrefix: string,
|
||||
): Promise<FeishuSendResult> {
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: params.receiveIdType },
|
||||
data: {
|
||||
receive_id: params.receiveId,
|
||||
content: params.content,
|
||||
msg_type: params.msgType,
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, errorPrefix);
|
||||
return toFeishuSendResult(response, params.receiveId);
|
||||
}
|
||||
|
||||
export type FeishuMessageInfo = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
@@ -239,41 +294,33 @@ export async function sendMessageFeishu(
|
||||
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
||||
|
||||
const directParams = { receiveId, receiveIdType, content, msgType };
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: msgType,
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
const fallback = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: msgType,
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(fallback, "Feishu send failed");
|
||||
return toFeishuSendResult(fallback, receiveId);
|
||||
} catch (err) {
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
return sendFallbackDirect(client, directParams, "Feishu send failed");
|
||||
}
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
return sendFallbackDirect(client, directParams, "Feishu send failed");
|
||||
}
|
||||
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: msgType,
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, "Feishu send failed");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
return sendFallbackDirect(client, directParams, "Feishu send failed");
|
||||
}
|
||||
|
||||
export type SendFeishuCardParams = {
|
||||
@@ -291,41 +338,33 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
const fallback = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(fallback, "Feishu card send failed");
|
||||
return toFeishuSendResult(fallback, receiveId);
|
||||
} catch (err) {
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
return sendFallbackDirect(client, directParams, "Feishu card send failed");
|
||||
}
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
return sendFallbackDirect(client, directParams, "Feishu card send failed");
|
||||
}
|
||||
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, "Feishu card send failed");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
return sendFallbackDirect(client, directParams, "Feishu card send failed");
|
||||
}
|
||||
|
||||
export async function updateCardFeishu(params: {
|
||||
|
||||
@@ -21,6 +21,21 @@ import {
|
||||
waitForDescendantSubagentSummary,
|
||||
} from "./subagent-followup.js";
|
||||
|
||||
function normalizeDeliveryTarget(channel: string, to: string): string {
|
||||
const channelLower = channel.trim().toLowerCase();
|
||||
const toTrimmed = to.trim();
|
||||
if (channelLower === "feishu" || channelLower === "lark") {
|
||||
const lowered = toTrimmed.toLowerCase();
|
||||
if (lowered.startsWith("user:")) {
|
||||
return toTrimmed.slice("user:".length).trim();
|
||||
}
|
||||
if (lowered.startsWith("chat:")) {
|
||||
return toTrimmed.slice("chat:".length).trim();
|
||||
}
|
||||
}
|
||||
return toTrimmed;
|
||||
}
|
||||
|
||||
export function matchesMessagingToolDeliveryTarget(
|
||||
target: { provider?: string; to?: string; accountId?: string },
|
||||
delivery: { channel?: string; to?: string; accountId?: string },
|
||||
@@ -36,11 +51,11 @@ export function matchesMessagingToolDeliveryTarget(
|
||||
if (target.accountId && delivery.accountId && target.accountId !== delivery.accountId) {
|
||||
return false;
|
||||
}
|
||||
// Strip :topic:NNN suffix from target.to before comparing — the cron delivery.to
|
||||
// is already stripped to chatId only, but the agent's message tool may pass a
|
||||
// topic-qualified target (e.g. "-1003597428309:topic:462").
|
||||
const normalizedTargetTo = target.to.replace(/:topic:\d+$/, "");
|
||||
return normalizedTargetTo === delivery.to;
|
||||
// Strip :topic:NNN from message targets and normalize Feishu/Lark prefixes on
|
||||
// both sides so cron duplicate suppression compares canonical IDs.
|
||||
const normalizedTargetTo = normalizeDeliveryTarget(channel, target.to.replace(/:topic:\d+$/, ""));
|
||||
const normalizedDeliveryTo = normalizeDeliveryTarget(channel, delivery.to);
|
||||
return normalizedTargetTo === normalizedDeliveryTo;
|
||||
}
|
||||
|
||||
export function resolveCronDeliveryBestEffort(job: CronJob): boolean {
|
||||
|
||||
Reference in New Issue
Block a user