diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c69d6d78aa..a5a05e25b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk. - Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana. - Web search/MiniMax: allow `MINIMAX_OAUTH_TOKEN` to satisfy MiniMax Search credentials, so OAuth-authorized MiniMax Token Plan setups do not need a separate web-search key. Fixes #65768. Thanks @kikibrian and @zhouhe-xydt. - Providers/MiniMax: derive Coding Plan usage polling from the configured MiniMax base URL, so global setups no longer query the CN usage host. Fixes #65054. Thanks @sixone74 and @Yanhu007. diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index 19395801225..9e58724f770 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -142,4 +142,55 @@ describe("registerFeishuChatTools", () => { ); expect(registerTool).not.toHaveBeenCalled(); }); + + it("preserves Feishu diagnostics from rejected member lookups", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + }, + }, + }, + registerTool, + }), + ); + + const tool = registerTool.mock.calls[0]?.[0]; + contactUserGetMock.mockRejectedValueOnce( + Object.assign(new Error("Request failed with status code 400"), { + response: { + status: 400, + data: { + code: 99992360, + msg: "The request you send is not a valid {user_id} or not exists", + error: { + log_id: "20260429124800CHAT", + troubleshooter: "https://open.feishu.cn/search?log_id=20260429124800CHAT", + }, + }, + }, + }), + ); + + const result = await tool.execute("tc_4", { + action: "member_info", + member_id: "ou_1", + }); + + expect(result.details.error).toContain('"http_status":400'); + expect(result.details.error).toContain('"feishu_code":99992360'); + expect(result.details.error).toContain( + '"feishu_msg":"The request you send is not a valid {user_id} or not exists"', + ); + expect(result.details.error).toContain('"feishu_log_id":"20260429124800CHAT"'); + expect(result.details.error).toContain( + '"feishu_troubleshooter":"https://open.feishu.cn/search?log_id=20260429124800CHAT"', + ); + }); }); diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index e1b8b29f91b..85572b9b0a5 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -1,9 +1,9 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; -import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js"; import { createFeishuClient } from "./client.js"; +import { formatFeishuApiError } from "./comment-shared.js"; import { resolveToolsConfig } from "./tools-config.js"; function json(data: unknown) { @@ -179,7 +179,7 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { return json({ error: `Unknown action: ${String(p.action)}` }); } } catch (err) { - return json({ error: formatErrorMessage(err) }); + return json({ error: formatFeishuApiError(err, { includeNestedErrorLogId: true }) }); } }, }, diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index 4b9ee90a577..43eb78aeaad 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -41,6 +41,7 @@ export function formatFeishuApiError( (options.includeNestedErrorLogId ? readString(isRecord(responseData?.error) ? responseData.error.log_id : undefined) : undefined); + const nestedError = isRecord(responseData?.error) ? responseData.error : undefined; return JSON.stringify({ message: @@ -58,9 +59,49 @@ export function formatFeishuApiError( typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code), feishu_msg: readString(responseData?.msg), feishu_log_id: feishuLogId, + feishu_troubleshooter: + readString(responseData?.troubleshooter) || readString(nestedError?.troubleshooter), }); } +export function formatFeishuApiFailure( + error: unknown, + errorPrefix: string, + options: { + includeConfigParams?: boolean; + includeNestedErrorLogId?: boolean; + } = {}, +): string { + const details = formatFeishuApiError(error, options); + return `${errorPrefix}: ${details || "unknown error"}`; +} + +export function createFeishuApiError( + error: unknown, + errorPrefix: string, + options: { + includeConfigParams?: boolean; + includeNestedErrorLogId?: boolean; + } = {}, +): Error { + return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error }); +} + +export async function requestFeishuApi( + request: () => Promise, + errorPrefix: string, + options: { + includeConfigParams?: boolean; + includeNestedErrorLogId?: boolean; + } = {}, +): Promise { + try { + return await request(); + } catch (error) { + throw createFeishuApiError(error, errorPrefix, options); + } +} + type ParsedCommentDocumentRef = { fileType?: CommentFileType; fileToken?: string; diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index bfba1e1fde1..efc6f67b991 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -384,6 +384,34 @@ describe("sendMediaFeishu msg_type routing", () => { ); }); + it("preserves Feishu diagnostics when media sends reject before response checks", async () => { + messageCreateMock.mockRejectedValueOnce( + Object.assign(new Error("Request failed with status code 400"), { + response: { + status: 400, + data: { + code: 9499, + msg: "Bad Request", + error: { + log_id: "20260429124731MEDIA", + troubleshooter: "https://open.feishu.cn/search?log_id=20260429124731MEDIA", + }, + }, + }, + }), + ); + + const send = sendMediaFeishu({ + cfg: emptyConfig, + to: "user:ou_target", + mediaBuffer: Buffer.from("image"), + fileName: "photo.png", + }); + + await expect(send).rejects.toThrow(/Feishu image send failed: .*"feishu_code":9499/); + await expect(send).rejects.toThrow(/"feishu_log_id":"20260429124731MEDIA"/); + }); + it("uses msg_type=media when replying with mp4", async () => { await sendMediaFeishu({ cfg: emptyConfig, diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 5fccab567b2..b9241827eff 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -12,6 +12,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { requestFeishuApi } from "./comment-shared.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { getFeishuRuntime } from "./runtime.js"; import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js"; @@ -418,12 +419,17 @@ export async function uploadImageFeishu(params: { // See: https://github.com/larksuite/node-sdk/issues/121 const imageData = typeof image === "string" ? fs.createReadStream(image) : image; - const response = await client.im.image.create({ - data: { - image_type: imageType, - image: imageData, - }, - }); + const response = await requestFeishuApi( + () => + client.im.image.create({ + data: { + image_type: imageType, + image: imageData, + }, + }), + "Feishu image upload failed", + { includeNestedErrorLogId: true }, + ); return { imageKey: extractFeishuUploadKey(response, { @@ -469,14 +475,19 @@ export async function uploadFileFeishu(params: { const safeFileName = sanitizeFileNameForUpload(fileName); - const response = await client.im.file.create({ - data: { - file_type: fileType, - file_name: safeFileName, - file: fileData, - ...(duration !== undefined && { duration }), - }, - }); + const response = await requestFeishuApi( + () => + client.im.file.create({ + data: { + file_type: fileType, + file_name: safeFileName, + file: fileData, + ...(duration !== undefined && { duration }), + }, + }), + "Feishu file upload failed", + { includeNestedErrorLogId: true }, + ); return { fileKey: extractFeishuUploadKey(response, { @@ -506,26 +517,36 @@ export async function sendImageFeishu(params: { const content = JSON.stringify({ image_key: imageKey }); if (replyToMessageId) { - const response = await client.im.message.reply({ - path: { message_id: replyToMessageId }, - data: { - content, - msg_type: "image", - ...(replyInThread ? { reply_in_thread: true } : {}), - }, - }); + const response = await requestFeishuApi( + () => + client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: "image", + ...(replyInThread ? { reply_in_thread: true } : {}), + }, + }), + "Feishu image reply failed", + { includeNestedErrorLogId: true }, + ); assertFeishuMessageApiSuccess(response, "Feishu image 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: "image", - }, - }); + const response = await requestFeishuApi( + () => + client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: "image", + }, + }), + "Feishu image send failed", + { includeNestedErrorLogId: true }, + ); assertFeishuMessageApiSuccess(response, "Feishu image send failed"); return toFeishuSendResult(response, receiveId); } @@ -553,26 +574,36 @@ export async function sendFileFeishu(params: { const content = JSON.stringify({ file_key: fileKey }); if (replyToMessageId) { - const response = await client.im.message.reply({ - path: { message_id: replyToMessageId }, - data: { - content, - msg_type: msgType, - ...(replyInThread ? { reply_in_thread: true } : {}), - }, - }); + const response = await requestFeishuApi( + () => + client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { + content, + msg_type: msgType, + ...(replyInThread ? { reply_in_thread: true } : {}), + }, + }), + "Feishu file reply failed", + { includeNestedErrorLogId: true }, + ); assertFeishuMessageApiSuccess(response, "Feishu file 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, - }, - }); + const response = await requestFeishuApi( + () => + client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + content, + msg_type: msgType, + }, + }), + "Feishu file send failed", + { includeNestedErrorLogId: true }, + ); assertFeishuMessageApiSuccess(response, "Feishu file send failed"); return toFeishuSendResult(response, receiveId); } diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 2fb1bdc2798..cd11d05cd61 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -57,6 +57,34 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { }); }); + it("preserves Feishu diagnostics when direct sends reject before response checks", async () => { + const apiError = Object.assign(new Error("Request failed with status code 400"), { + response: { + status: 400, + data: { + code: 9499, + msg: "Bad Request", + error: { + log_id: "202604291247104BEF4C42D2420A9AD569", + troubleshooter: + "https://open.feishu.cn/search?log_id=202604291247104BEF4C42D2420A9AD569", + }, + }, + }, + }); + createMock.mockRejectedValue(apiError); + + await expect( + sendMessageFeishu({ + cfg: {} as never, + to: "user:ou_target", + text: "hello", + }), + ).rejects.toThrow( + /Feishu send failed: .*"http_status":400.*"feishu_code":9499.*"feishu_msg":"Bad Request".*"feishu_log_id":"202604291247104BEF4C42D2420A9AD569".*"feishu_troubleshooter":"https:\/\/open\.feishu\.cn\/search\?log_id=202604291247104BEF4C42D2420A9AD569"/, + ); + }); + it("falls back to create for withdrawn post replies", async () => { replyMock.mockResolvedValue({ code: 230011, diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 3724ed9e5d8..293e34414dc 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -7,6 +7,7 @@ import { import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js"; import type { MentionTarget } from "./mention-target.types.js"; import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js"; import { parsePostContent } from "./post.js"; @@ -117,14 +118,19 @@ async function sendFallbackDirect( }, 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, - }, - }); + const response = await requestFeishuApi( + () => + client.im.message.create({ + params: { receive_id_type: params.receiveIdType }, + data: { + receive_id: params.receiveId, + content: params.content, + msg_type: params.msgType, + }, + }), + errorPrefix, + { includeNestedErrorLogId: true }, + ); assertFeishuMessageApiSuccess(response, errorPrefix); return toFeishuSendResult(response, params.receiveId); } @@ -168,7 +174,7 @@ async function sendReplyOrFallbackDirect( }); } catch (err) { if (!isWithdrawnReplyError(err)) { - throw err; + throw createFeishuApiError(err, params.replyErrorPrefix, { includeNestedErrorLogId: true }); } if (threadReplyFallbackError) { throw threadReplyFallbackError;