diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 182cb3c4be9..75dda353bbe 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -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(); + }); }); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 7cb53e79f4c..74358eb4049 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -19,6 +19,56 @@ 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: string }; + 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: string; content: string; msgType: string }, + errorPrefix: string, +): Promise { + 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 +289,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 +333,33 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise