diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts new file mode 100644 index 000000000..3a56d1136 --- /dev/null +++ b/extensions/feishu/src/outbound.test.ts @@ -0,0 +1,111 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); + +vi.mock("./media.js", () => ({ + sendMediaFeishu: sendMediaFeishuMock, +})); + +vi.mock("./send.js", () => ({ + sendMessageFeishu: sendMessageFeishuMock, +})); + +vi.mock("./runtime.js", () => ({ + getFeishuRuntime: () => ({ + channel: { + text: { + chunkMarkdownText: (text: string) => [text], + }, + }, + }), +})); + +import { feishuOutbound } from "./outbound.js"; +const sendText = feishuOutbound.sendText!; + +describe("feishuOutbound.sendText local-image auto-convert", () => { + beforeEach(() => { + vi.clearAllMocks(); + sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); + sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + }); + + async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-")); + const file = path.join(dir, `sample${ext}`); + await fs.writeFile(file, "image-data"); + return { dir, file }; + } + + it("sends an absolute existing local image path as media", async () => { + const { dir, file } = await createTmpImage(); + try { + const result = await sendText({ + cfg: {} as any, + to: "chat_1", + text: file, + accountId: "main", + }); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + mediaUrl: file, + accountId: "main", + }), + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ channel: "feishu", messageId: "media_msg" }), + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("keeps non-path text on the text-send path", async () => { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: "please upload /tmp/example.png", + accountId: "main", + }); + + expect(sendMediaFeishuMock).not.toHaveBeenCalled(); + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: "please upload /tmp/example.png", + accountId: "main", + }), + ); + }); + + it("falls back to plain text if local-image media send fails", async () => { + const { dir, file } = await createTmpImage(); + sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed")); + try { + await sendText({ + cfg: {} as any, + to: "chat_1", + text: file, + accountId: "main", + }); + + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + text: file, + accountId: "main", + }), + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index d1f43022a..6a190242c 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,14 +1,68 @@ +import fs from "fs"; +import path from "path"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; import { sendMediaFeishu } from "./media.js"; import { getFeishuRuntime } from "./runtime.js"; import { sendMessageFeishu } from "./send.js"; +function normalizePossibleLocalImagePath(text: string | undefined): string | null { + const raw = text?.trim(); + if (!raw) return null; + + // Only auto-convert when the message is a pure path-like payload. + // Avoid converting regular sentences that merely contain a path. + const hasWhitespace = /\s/.test(raw); + if (hasWhitespace) return null; + + // Ignore links/data URLs; those should stay in normal mediaUrl/text paths. + if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null; + + const ext = path.extname(raw).toLowerCase(); + const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes( + ext, + ); + if (!isImageExt) return null; + + if (!path.isAbsolute(raw)) return null; + if (!fs.existsSync(raw)) return null; + + // Fix race condition: wrap statSync in try-catch to handle file deletion + // between existsSync and statSync + try { + if (!fs.statSync(raw).isFile()) return null; + } catch { + // File may have been deleted or became inaccessible between checks + return null; + } + + return raw; +} + export const feishuOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, sendText: async ({ cfg, to, text, accountId }) => { + // Scheme A compatibility shim: + // when upstream accidentally returns a local image path as plain text, + // auto-upload and send as Feishu image message instead of leaking path text. + const localImagePath = normalizePossibleLocalImagePath(text); + if (localImagePath) { + try { + const result = await sendMediaFeishu({ + cfg, + to, + mediaUrl: localImagePath, + accountId: accountId ?? undefined, + }); + return { channel: "feishu", ...result }; + } catch (err) { + console.error(`[feishu] local image path auto-send failed:`, err); + // fall through to plain text as last resort + } + } + const result = await sendMessageFeishu({ cfg, to, text, accountId: accountId ?? undefined }); return { channel: "feishu", ...result }; },