Skip to content

Commit 0e4c24e

Browse files
paceywTakhoffman
andauthored
fix(feishu): auto-convert local image path text to image message in outbound (#29264) thanks @paceyw
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: paceyw <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 3f06693 commit 0e4c24e

File tree

2 files changed

+165
-0
lines changed

2 files changed

+165
-0
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
7+
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
8+
9+
vi.mock("./media.js", () => ({
10+
sendMediaFeishu: sendMediaFeishuMock,
11+
}));
12+
13+
vi.mock("./send.js", () => ({
14+
sendMessageFeishu: sendMessageFeishuMock,
15+
}));
16+
17+
vi.mock("./runtime.js", () => ({
18+
getFeishuRuntime: () => ({
19+
channel: {
20+
text: {
21+
chunkMarkdownText: (text: string) => [text],
22+
},
23+
},
24+
}),
25+
}));
26+
27+
import { feishuOutbound } from "./outbound.js";
28+
const sendText = feishuOutbound.sendText!;
29+
30+
describe("feishuOutbound.sendText local-image auto-convert", () => {
31+
beforeEach(() => {
32+
vi.clearAllMocks();
33+
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
34+
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
35+
});
36+
37+
async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
38+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-"));
39+
const file = path.join(dir, `sample${ext}`);
40+
await fs.writeFile(file, "image-data");
41+
return { dir, file };
42+
}
43+
44+
it("sends an absolute existing local image path as media", async () => {
45+
const { dir, file } = await createTmpImage();
46+
try {
47+
const result = await sendText({
48+
cfg: {} as any,
49+
to: "chat_1",
50+
text: file,
51+
accountId: "main",
52+
});
53+
54+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
55+
expect.objectContaining({
56+
to: "chat_1",
57+
mediaUrl: file,
58+
accountId: "main",
59+
}),
60+
);
61+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
62+
expect(result).toEqual(
63+
expect.objectContaining({ channel: "feishu", messageId: "media_msg" }),
64+
);
65+
} finally {
66+
await fs.rm(dir, { recursive: true, force: true });
67+
}
68+
});
69+
70+
it("keeps non-path text on the text-send path", async () => {
71+
await sendText({
72+
cfg: {} as any,
73+
to: "chat_1",
74+
text: "please upload /tmp/example.png",
75+
accountId: "main",
76+
});
77+
78+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
79+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
to: "chat_1",
82+
text: "please upload /tmp/example.png",
83+
accountId: "main",
84+
}),
85+
);
86+
});
87+
88+
it("falls back to plain text if local-image media send fails", async () => {
89+
const { dir, file } = await createTmpImage();
90+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
91+
try {
92+
await sendText({
93+
cfg: {} as any,
94+
to: "chat_1",
95+
text: file,
96+
accountId: "main",
97+
});
98+
99+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
100+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
101+
expect.objectContaining({
102+
to: "chat_1",
103+
text: file,
104+
accountId: "main",
105+
}),
106+
);
107+
} finally {
108+
await fs.rm(dir, { recursive: true, force: true });
109+
}
110+
});
111+
});

extensions/feishu/src/outbound.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,68 @@
1+
import fs from "fs";
2+
import path from "path";
13
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
24
import { sendMediaFeishu } from "./media.js";
35
import { getFeishuRuntime } from "./runtime.js";
46
import { sendMessageFeishu } from "./send.js";
57

8+
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
9+
const raw = text?.trim();
10+
if (!raw) return null;
11+
12+
// Only auto-convert when the message is a pure path-like payload.
13+
// Avoid converting regular sentences that merely contain a path.
14+
const hasWhitespace = /\s/.test(raw);
15+
if (hasWhitespace) return null;
16+
17+
// Ignore links/data URLs; those should stay in normal mediaUrl/text paths.
18+
if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null;
19+
20+
const ext = path.extname(raw).toLowerCase();
21+
const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
22+
ext,
23+
);
24+
if (!isImageExt) return null;
25+
26+
if (!path.isAbsolute(raw)) return null;
27+
if (!fs.existsSync(raw)) return null;
28+
29+
// Fix race condition: wrap statSync in try-catch to handle file deletion
30+
// between existsSync and statSync
31+
try {
32+
if (!fs.statSync(raw).isFile()) return null;
33+
} catch {
34+
// File may have been deleted or became inaccessible between checks
35+
return null;
36+
}
37+
38+
return raw;
39+
}
40+
641
export const feishuOutbound: ChannelOutboundAdapter = {
742
deliveryMode: "direct",
843
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
944
chunkerMode: "markdown",
1045
textChunkLimit: 4000,
1146
sendText: async ({ cfg, to, text, accountId }) => {
47+
// Scheme A compatibility shim:
48+
// when upstream accidentally returns a local image path as plain text,
49+
// auto-upload and send as Feishu image message instead of leaking path text.
50+
const localImagePath = normalizePossibleLocalImagePath(text);
51+
if (localImagePath) {
52+
try {
53+
const result = await sendMediaFeishu({
54+
cfg,
55+
to,
56+
mediaUrl: localImagePath,
57+
accountId: accountId ?? undefined,
58+
});
59+
return { channel: "feishu", ...result };
60+
} catch (err) {
61+
console.error(`[feishu] local image path auto-send failed:`, err);
62+
// fall through to plain text as last resort
63+
}
64+
}
65+
1266
const result = await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined });
1367
return { channel: "feishu", ...result };
1468
},

0 commit comments

Comments
 (0)