Skip to content

Commit a509154

Browse files
Feishu: send media payloads as attachments (#28959) thanks @icesword0760
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: icesword0760 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 5cb2a3a commit a509154

File tree

3 files changed

+143
-45
lines changed

3 files changed

+143
-45
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515

1616
### Fixes
1717

18+
- 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)
1819
- 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)
1920
- 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.
2021
- 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.

extensions/feishu/src/reply-dispatcher.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
44
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
55
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
66
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
78
const createFeishuClientMock = vi.hoisted(() => vi.fn());
89
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
910
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
@@ -15,6 +16,7 @@ vi.mock("./send.js", () => ({
1516
sendMessageFeishu: sendMessageFeishuMock,
1617
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
1718
}));
19+
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
1820
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
1921
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
2022
vi.mock("./streaming-card.js", () => ({
@@ -41,6 +43,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
4143
beforeEach(() => {
4244
vi.clearAllMocks();
4345
streamingInstances.length = 0;
46+
sendMediaFeishuMock.mockResolvedValue(undefined);
4447

4548
resolveFeishuAccountMock.mockReturnValue({
4649
accountId: "main",
@@ -113,4 +116,74 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
113116
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
114117
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
115118
});
119+
120+
it("sends media-only payloads as attachments", async () => {
121+
createFeishuReplyDispatcher({
122+
cfg: {} as never,
123+
agentId: "agent",
124+
runtime: {} as never,
125+
chatId: "oc_chat",
126+
});
127+
128+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
129+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
130+
131+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
132+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
133+
expect.objectContaining({
134+
to: "oc_chat",
135+
mediaUrl: "https://example.com/a.png",
136+
}),
137+
);
138+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
139+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
140+
});
141+
142+
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
143+
createFeishuReplyDispatcher({
144+
cfg: {} as never,
145+
agentId: "agent",
146+
runtime: {} as never,
147+
chatId: "oc_chat",
148+
});
149+
150+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
151+
await options.deliver(
152+
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
153+
{ kind: "final" },
154+
);
155+
156+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
157+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
158+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
159+
expect.objectContaining({
160+
mediaUrl: "https://example.com/a.png",
161+
}),
162+
);
163+
});
164+
165+
it("sends attachments after streaming final markdown replies", async () => {
166+
createFeishuReplyDispatcher({
167+
cfg: {} as never,
168+
agentId: "agent",
169+
runtime: { log: vi.fn(), error: vi.fn() } as never,
170+
chatId: "oc_chat",
171+
});
172+
173+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
174+
await options.deliver(
175+
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
176+
{ kind: "final" },
177+
);
178+
179+
expect(streamingInstances).toHaveLength(1);
180+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
181+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
182+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
183+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
184+
expect.objectContaining({
185+
mediaUrl: "https://example.com/a.png",
186+
}),
187+
);
188+
});
116189
});

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 69 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "openclaw/plugin-sdk";
99
import { resolveFeishuAccount } from "./accounts.js";
1010
import { createFeishuClient } from "./client.js";
11+
import { sendMediaFeishu } from "./media.js";
1112
import type { MentionTarget } from "./mention.js";
1213
import { buildMentionedCardContent } from "./mention.js";
1314
import { getFeishuRuntime } from "./runtime.js";
@@ -138,60 +139,83 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
138139
},
139140
deliver: async (payload: ReplyPayload, info) => {
140141
const text = payload.text ?? "";
141-
if (!text.trim()) {
142+
const mediaList =
143+
payload.mediaUrls && payload.mediaUrls.length > 0
144+
? payload.mediaUrls
145+
: payload.mediaUrl
146+
? [payload.mediaUrl]
147+
: [];
148+
const hasText = Boolean(text.trim());
149+
const hasMedia = mediaList.length > 0;
150+
151+
if (!hasText && !hasMedia) {
142152
return;
143153
}
144154

145-
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
155+
if (hasText) {
156+
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
146157

147-
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
148-
startStreaming();
149-
if (streamingStartPromise) {
150-
await streamingStartPromise;
158+
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
159+
startStreaming();
160+
if (streamingStartPromise) {
161+
await streamingStartPromise;
162+
}
151163
}
152-
}
153164

154-
if (streaming?.isActive()) {
155-
if (info?.kind === "final") {
156-
streamText = text;
157-
await closeStreaming();
165+
if (streaming?.isActive()) {
166+
if (info?.kind === "final") {
167+
streamText = text;
168+
await closeStreaming();
169+
}
170+
// Send media even when streaming handled the text
171+
if (hasMedia) {
172+
for (const mediaUrl of mediaList) {
173+
await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId });
174+
}
175+
}
176+
return;
158177
}
159-
return;
160-
}
161178

162-
let first = true;
163-
if (useCard) {
164-
for (const chunk of core.channel.text.chunkTextWithMode(
165-
text,
166-
textChunkLimit,
167-
chunkMode,
168-
)) {
169-
await sendMarkdownCardFeishu({
170-
cfg,
171-
to: chatId,
172-
text: chunk,
173-
replyToMessageId,
174-
mentions: first ? mentionTargets : undefined,
175-
accountId,
176-
});
177-
first = false;
179+
let first = true;
180+
if (useCard) {
181+
for (const chunk of core.channel.text.chunkTextWithMode(
182+
text,
183+
textChunkLimit,
184+
chunkMode,
185+
)) {
186+
await sendMarkdownCardFeishu({
187+
cfg,
188+
to: chatId,
189+
text: chunk,
190+
replyToMessageId,
191+
mentions: first ? mentionTargets : undefined,
192+
accountId,
193+
});
194+
first = false;
195+
}
196+
} else {
197+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
198+
for (const chunk of core.channel.text.chunkTextWithMode(
199+
converted,
200+
textChunkLimit,
201+
chunkMode,
202+
)) {
203+
await sendMessageFeishu({
204+
cfg,
205+
to: chatId,
206+
text: chunk,
207+
replyToMessageId,
208+
mentions: first ? mentionTargets : undefined,
209+
accountId,
210+
});
211+
first = false;
212+
}
178213
}
179-
} else {
180-
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
181-
for (const chunk of core.channel.text.chunkTextWithMode(
182-
converted,
183-
textChunkLimit,
184-
chunkMode,
185-
)) {
186-
await sendMessageFeishu({
187-
cfg,
188-
to: chatId,
189-
text: chunk,
190-
replyToMessageId,
191-
mentions: first ? mentionTargets : undefined,
192-
accountId,
193-
});
194-
first = false;
214+
}
215+
216+
if (hasMedia) {
217+
for (const mediaUrl of mediaList) {
218+
await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId });
195219
}
196220
}
197221
},

0 commit comments

Comments
 (0)