feat(feishu): add localRoots configuration for media uploads and enhance media handling tests

This commit is contained in:
saurav470 2026-03-08 11:07:35 +05:30
parent 70da80bcb5
commit 51fddd20b5
3 changed files with 86 additions and 6 deletions

View File

@ -165,6 +165,8 @@ const FeishuSharedConfigShape = {
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
mediaMaxMb: z.number().positive().optional(),
/** Allowed roots for local media paths, or "any" to allow any path. Used by sendMedia/local file uploads. */
localRoots: z.union([z.literal("any"), z.array(z.string())]).optional(),
httpTimeoutMs: z.number().int().positive().max(300_000).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,

View File

@ -288,6 +288,53 @@ describe("sendMediaFeishu msg_type routing", () => {
);
});
it("uses channels.feishu.localRoots 'any' and passes readFile for local paths", async () => {
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("local-file"),
fileName: "pic.png",
kind: "image",
contentType: "image/png",
});
await sendMediaFeishu({
cfg: { channels: { feishu: { localRoots: "any" } } } as any,
to: "user:ou_target",
mediaUrl: "/Users/zhengxing/openclaw/workspace/ask_official.png",
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
"/Users/zhengxing/openclaw/workspace/ask_official.png",
expect.objectContaining({
localRoots: "any",
readFile: expect.any(Function),
}),
);
});
it("uses channels.feishu.localRoots array over context mediaLocalRoots", async () => {
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("local-file"),
fileName: "doc.pdf",
kind: "document",
contentType: "application/pdf",
});
const channelRoots = ["/custom/feishu/root"];
await sendMediaFeishu({
cfg: { channels: { feishu: { localRoots: channelRoots } } } as any,
to: "user:ou_target",
mediaUrl: "/custom/feishu/root/file.pdf",
mediaLocalRoots: ["/other/context/root"],
});
expect(loadWebMediaMock).toHaveBeenCalledWith(
"/custom/feishu/root/file.pdf",
expect.objectContaining({
localRoots: channelRoots,
}),
);
});
it("fails closed when media URL fetch is blocked", async () => {
loadWebMediaMock.mockRejectedValueOnce(
new Error("Blocked: resolves to private/internal IP address"),

View File

@ -8,6 +8,7 @@ import { normalizeFeishuExternalKey } from "./external-keys.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuConfig } from "./types.js";
const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
@ -413,10 +414,29 @@ export function detectFileType(
}
}
/**
* Resolve effective localRoots for loadWebMedia: channel config (localRoots) overrides
* context mediaLocalRoots. Supports channels.feishu.localRoots = "any" or string[].
*/
function resolveFeishuMediaLocalRoots(params: {
cfg: ClawdbotConfig;
mediaLocalRoots?: readonly string[];
}): readonly string[] | "any" | undefined {
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
const channelRoots = feishuCfg?.localRoots;
if (channelRoots === "any") {
return "any";
}
if (Array.isArray(channelRoots) && channelRoots.length > 0) {
return channelRoots;
}
return params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined;
}
/**
* Upload and send media (image or file) from URL, local path, or buffer.
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
* must be passed so loadWebMedia allows the path (post CVE-2026-26321).
* When mediaUrl is a local path, allowed roots come from channels.feishu.localRoots
* (or "any") or from core outbound mediaLocalRoots.
*/
export async function sendMediaFeishu(params: {
cfg: ClawdbotConfig;
@ -427,7 +447,7 @@ export async function sendMediaFeishu(params: {
replyToMessageId?: string;
replyInThread?: boolean;
accountId?: string;
/** Allowed roots for local path reads; required for local filePath to work. */
/** Allowed roots for local path reads (from core); overridden by channels.feishu.localRoots. */
mediaLocalRoots?: readonly string[];
}): Promise<SendMediaResult> {
const {
@ -454,11 +474,22 @@ export async function sendMediaFeishu(params: {
buffer = mediaBuffer;
name = fileName ?? "file";
} else if (mediaUrl) {
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
const localRoots = resolveFeishuMediaLocalRoots({ cfg, mediaLocalRoots });
const loadOptions: {
maxBytes: number;
optimizeImages: boolean;
localRoots: readonly string[] | "any" | undefined;
readFile?: (filePath: string) => Promise<Buffer>;
} = {
maxBytes: mediaMaxBytes,
optimizeImages: false,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
});
localRoots,
};
// Core requires readFile override when localRoots is "any" (unsafe-bypass guard).
if (localRoots === "any") {
loadOptions.readFile = (filePath: string) => fs.promises.readFile(filePath);
}
const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, loadOptions);
buffer = loaded.buffer;
name = fileName ?? loaded.fileName ?? "file";
} else {