fix(feishu): preserve api error diagnostics

This commit is contained in:
Peter Steinberger
2026-05-02 05:52:46 +01:00
parent c3b8e5c812
commit 1ecb2fc2c7
8 changed files with 243 additions and 57 deletions

View File

@@ -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.

View File

@@ -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"',
);
});
});

View File

@@ -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 }) });
}
},
},

View File

@@ -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<T>(
request: () => Promise<T>,
errorPrefix: string,
options: {
includeConfigParams?: boolean;
includeNestedErrorLogId?: boolean;
} = {},
): Promise<T> {
try {
return await request();
} catch (error) {
throw createFeishuApiError(error, errorPrefix, options);
}
}
type ParsedCommentDocumentRef = {
fileType?: CommentFileType;
fileToken?: string;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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<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,
},
});
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;