From 8a0ddd33113bb61aab9648478a79cba25c5d82b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 05:05:43 -0700 Subject: [PATCH] fix(feishu): preserve all accepted voice fallback content (#128130) --- .../feishu/src/reply-delivery-result.ts | 7 +- .../feishu/src/reply-dispatcher.test.ts | 65 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 3 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/extensions/feishu/src/reply-delivery-result.ts b/extensions/feishu/src/reply-delivery-result.ts index 47c4eb839478..08493cd85d96 100644 --- a/extensions/feishu/src/reply-delivery-result.ts +++ b/extensions/feishu/src/reply-delivery-result.ts @@ -74,12 +74,17 @@ export function mergeFeishuReplyDeliveryResults( content?: string, ): FeishuReplyDeliveryResult { const visible = results.filter((result) => result.visibleReplySent === true); + const acceptedContent = visible.flatMap((result) => + result.content === undefined ? [] : [result.content], + ); return createFeishuReplyDeliveryResult({ results: visible, visibleReplySent: visible.length > 0, content: content === undefined - ? results.find((result) => result.content !== undefined)?.content + ? acceptedContent.length > 0 + ? acceptedContent.join("\n\n") + : undefined : content, }); } diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 385870eba357..274358fc1ac8 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -2381,6 +2381,71 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + it("reports every accepted voice upload fallback in the successful delivery result", async () => { + sendMediaFeishuMock + .mockRejectedValueOnce(new Error("first upload failed")) + .mockRejectedValueOnce(new Error("second upload failed")); + sendMessageFeishuMock + .mockResolvedValueOnce({ messageId: "om-first-fallback" }) + .mockResolvedValueOnce({ messageId: "om-second-fallback" }); + const { options } = createDispatcherHarness(); + + const delivery = await options.deliver( + { + text: "spoken reply", + mediaUrls: ["https://example.com/first.mp3", "https://example.com/second.mp3"], + audioAsVoice: true, + }, + { kind: "final" }, + ); + + expect(delivery).toMatchObject({ + messageIds: ["om-first-fallback", "om-second-fallback"], + visibleReplySent: true, + content: + "spoken reply\n\nšŸ“Ž https://example.com/first.mp3\n\nšŸ“Ž https://example.com/second.mp3", + }); + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2); + }); + + it("retains every accepted voice upload fallback when a later fallback fails", async () => { + sendMediaFeishuMock + .mockRejectedValueOnce(new Error("first upload failed")) + .mockRejectedValueOnce(new Error("second upload failed")) + .mockRejectedValueOnce(new Error("third upload failed")); + sendMessageFeishuMock + .mockResolvedValueOnce({ messageId: "om-first-fallback" }) + .mockResolvedValueOnce({ messageId: "om-second-fallback" }) + .mockRejectedValueOnce(new Error("third fallback failed")); + const { options } = createDispatcherHarness(); + + const error = await options + .deliver( + { + text: "spoken reply", + mediaUrls: [ + "https://example.com/first.mp3", + "https://example.com/second.mp3", + "https://example.com/third.mp3", + ], + audioAsVoice: true, + }, + { kind: "final" }, + ) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: "CHANNEL_PARTIAL_DELIVERY", + deliveryResult: { + messageIds: ["om-first-fallback", "om-second-fallback"], + visibleReplySent: true, + content: + "spoken reply\n\nšŸ“Ž https://example.com/first.mp3\n\nšŸ“Ž https://example.com/second.mp3", + }, + }); + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(3); + }); + it("does not leak local media paths in the upload failure fallback", async () => { const mediaPath = path.join(os.tmpdir(), "openclaw-feishu-reply-local-voice.mp3"); sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed")); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 04c3c6e985bc..994adcfb3d76 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1495,7 +1495,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP hasVoiceMedia && hasText ? { fallbackText: text } : undefined, ); } - const result = mergeFeishuReplyDeliveryResults(deliveredResults, text); + const deliveredContent = hasVoiceMedia ? (deliveredResults.at(-1)?.content ?? text) : text; + const result = mergeFeishuReplyDeliveryResults(deliveredResults, deliveredContent); if (priorClosedStreamingSettlement?.error !== undefined) { throw createFeishuPartialReplyDeliveryError( isChannelPartialDeliveryError(priorClosedStreamingSettlement.error) &&