From a509154be5fd3adeb8a56ec9843df1a254b9cc67 Mon Sep 17 00:00:00 2001 From: icesword0760 <25030760@qq.com> Date: Sat, 28 Feb 2026 09:06:27 +0800 Subject: [PATCH] Feishu: send media payloads as attachments (openclaw#28959) thanks @icesword0760 Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: icesword0760 <23316247+icesword0760@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + .../feishu/src/reply-dispatcher.test.ts | 73 +++++++++++ extensions/feishu/src/reply-dispatcher.ts | 118 +++++++++++------- 3 files changed, 145 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc3846628f..d718c6feb49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) - Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494) - Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg. - Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg. diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 36dcfc9a04b..43cbdc23333 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -4,6 +4,7 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); const getFeishuRuntimeMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); +const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); @@ -15,6 +16,7 @@ vi.mock("./send.js", () => ({ sendMessageFeishu: sendMessageFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, })); +vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock })); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock })); vi.mock("./streaming-card.js", () => ({ @@ -41,6 +43,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { beforeEach(() => { vi.clearAllMocks(); streamingInstances.length = 0; + sendMediaFeishuMock.mockResolvedValue(undefined); resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -113,4 +116,74 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); + + it("sends media-only payloads as attachments", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" }); + + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "oc_chat", + mediaUrl: "https://example.com/a.png", + }), + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver( + { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] }, + { kind: "final" }, + ); + + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + }), + ); + }); + + it("sends attachments after streaming final markdown replies", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: vi.fn() } as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver( + { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] }, + { kind: "final" }, + ); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); + expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + }), + ); + }); }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 940370cd9f7..9991779e939 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; +import { sendMediaFeishu } from "./media.js"; import type { MentionTarget } from "./mention.js"; import { buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; @@ -138,60 +139,83 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP }, deliver: async (payload: ReplyPayload, info) => { const text = payload.text ?? ""; - if (!text.trim()) { + const mediaList = + payload.mediaUrls && payload.mediaUrls.length > 0 + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + const hasText = Boolean(text.trim()); + const hasMedia = mediaList.length > 0; + + if (!hasText && !hasMedia) { return; } - const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + if (hasText) { + const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) { - startStreaming(); - if (streamingStartPromise) { - await streamingStartPromise; + if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) { + startStreaming(); + if (streamingStartPromise) { + await streamingStartPromise; + } + } + + if (streaming?.isActive()) { + if (info?.kind === "final") { + streamText = text; + await closeStreaming(); + } + // Send media even when streaming handled the text + if (hasMedia) { + for (const mediaUrl of mediaList) { + await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId }); + } + } + return; + } + + let first = true; + if (useCard) { + for (const chunk of core.channel.text.chunkTextWithMode( + text, + textChunkLimit, + chunkMode, + )) { + await sendMarkdownCardFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId, + mentions: first ? mentionTargets : undefined, + accountId, + }); + first = false; + } + } else { + const converted = core.channel.text.convertMarkdownTables(text, tableMode); + for (const chunk of core.channel.text.chunkTextWithMode( + converted, + textChunkLimit, + chunkMode, + )) { + await sendMessageFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId, + mentions: first ? mentionTargets : undefined, + accountId, + }); + first = false; + } } } - if (streaming?.isActive()) { - if (info?.kind === "final") { - streamText = text; - await closeStreaming(); - } - return; - } - - let first = true; - if (useCard) { - for (const chunk of core.channel.text.chunkTextWithMode( - text, - textChunkLimit, - chunkMode, - )) { - await sendMarkdownCardFeishu({ - cfg, - to: chatId, - text: chunk, - replyToMessageId, - mentions: first ? mentionTargets : undefined, - accountId, - }); - first = false; - } - } else { - const converted = core.channel.text.convertMarkdownTables(text, tableMode); - for (const chunk of core.channel.text.chunkTextWithMode( - converted, - textChunkLimit, - chunkMode, - )) { - await sendMessageFeishu({ - cfg, - to: chatId, - text: chunk, - replyToMessageId, - mentions: first ? mentionTargets : undefined, - accountId, - }); - first = false; + if (hasMedia) { + for (const mediaUrl of mediaList) { + await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId }); } } },