From f4908e60fdcdbe92b06c6e89d94795ea787f6691 Mon Sep 17 00:00:00 2001 From: Dan Hayman <25741871+danhayman@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:19:53 +0200 Subject: [PATCH] fix(discord): fall back to text when voice delivery fails (#89962) * fix(discord): fall back to text when voice delivery fails * fix(discord): preserve reply target on voice fallback * fix(discord): suppress duplicate delivered TTS fallback * refactor(discord): bound voice fallback handling --------- Co-authored-by: Peter Steinberger --- .../discord/src/outbound-adapter.test.ts | 104 ++++++++++++++++++ extensions/discord/src/outbound-payload.ts | 50 +++++++-- 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index f094e3b92588..5da68ee14adc 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -402,6 +402,110 @@ describe("discordOutbound", () => { ).toEqual(["reply-1", "reply-1"]); }); + it.each([ + { + name: "visible text", + payload: { + text: "voice note", + mediaUrls: ["https://example.com/voice.ogg"], + audioAsVoice: true, + }, + expectedText: "voice note", + }, + { + name: "TTS supplement text", + payload: { + mediaUrls: ["https://example.com/voice.ogg"], + audioAsVoice: true, + ttsSupplement: { + spokenText: "spoken answer", + }, + }, + expectedText: "spoken answer", + }, + ])("falls back to $name when audioAsVoice delivery fails", async ({ payload, expectedText }) => { + hoisted.sendVoiceMessageDiscordMock.mockRejectedValueOnce(new Error("ffmpeg unavailable")); + + const result = await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload, + accountId: "default", + replyToId: "reply-1", + replyToMode: "first", + }); + + expect(hoisted.sendVoiceMessageDiscordMock).toHaveBeenCalledOnce(); + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledOnce(); + const messageCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 0); + expect(messageCall[0]).toBe("channel:123456"); + expect(messageCall[1]).toBe(expectedText); + expect(mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 0, 2).replyTo).toBe( + "reply-1", + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-1", + channelId: "ch-1", + }); + }); + + it("does not duplicate already-delivered TTS supplement text when audioAsVoice delivery fails", async () => { + hoisted.sendVoiceMessageDiscordMock.mockRejectedValueOnce(new Error("ffmpeg unavailable")); + + const result = await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + mediaUrls: ["https://example.com/voice.ogg"], + audioAsVoice: true, + ttsSupplement: { + spokenText: "spoken answer", + visibleTextAlreadyDelivered: true, + }, + }, + accountId: "default", + replyToId: "reply-1", + replyToMode: "first", + }); + + expect(hoisted.sendVoiceMessageDiscordMock).toHaveBeenCalledOnce(); + expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + channel: "discord", + messageId: "", + channelId: "channel:123456", + receipt: { + platformMessageIds: [], + parts: [], + }, + }); + }); + + it("does not treat delivery progress failures as voice delivery failures", async () => { + await expect( + discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + text: "voice note", + mediaUrls: ["https://example.com/voice.ogg"], + audioAsVoice: true, + }, + accountId: "default", + onDeliveryResult: async () => { + throw new Error("progress unavailable"); + }, + }), + ).rejects.toThrow("progress unavailable"); + + expect(hoisted.sendVoiceMessageDiscordMock).toHaveBeenCalledOnce(); + expect(hoisted.sendMessageDiscordMock).not.toHaveBeenCalled(); + }); + it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => { await discordOutbound.sendPayload?.({ cfg: {}, diff --git a/extensions/discord/src/outbound-payload.ts b/extensions/discord/src/outbound-payload.ts index 37a5824dc967..94257808ab51 100644 --- a/extensions/discord/src/outbound-payload.ts +++ b/extensions/discord/src/outbound-payload.ts @@ -4,6 +4,7 @@ import { type ChannelOutboundAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import { + getReplyPayloadTtsSupplement, resolvePayloadMediaUrls, sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, @@ -95,15 +96,46 @@ export async function sendDiscordOutboundPayload(params: { // audioAsVoice emits one logical Discord reply across voice/text/media sends. // Capture before helper calls consume implicit single-use reply targets. const voiceReplyTo = sendContext.resolveReplyTo(); - let lastResult = await sendContext.withRetry( - async () => - await sendContext.sendVoice(sendContext.target, mediaUrls[0], { - ...resolveDiscordDeliveryOptions(ctx, sendContext), - replyTo: voiceReplyTo, - }), - ); - await ctx.onDeliveryResult?.(attachChannelToResult("discord", lastResult)); - if (payload.text?.trim()) { + let deliveredVoice = false; + let lastResult: Awaited>; + try { + lastResult = await sendContext.withRetry( + async () => + await sendContext.sendVoice(sendContext.target, mediaUrls[0], { + ...resolveDiscordDeliveryOptions(ctx, sendContext), + replyTo: voiceReplyTo, + }), + ); + deliveredVoice = true; + } catch (err) { + const supplement = getReplyPayloadTtsSupplement(payload); + const visibleFallbackText = payload.text?.trim() ? payload.text : undefined; + const hiddenFallbackText = supplement?.visibleTextAlreadyDelivered + ? undefined + : supplement?.spokenText; + const fallbackText = visibleFallbackText ?? hiddenFallbackText; + if (!fallbackText) { + if (supplement?.visibleTextAlreadyDelivered) { + lastResult = createDiscordUnknownPayloadResult(sendContext.target); + } else { + throw err; + } + } else { + lastResult = await sendContext.withRetry( + async () => + await sendContext.send(sendContext.target, fallbackText, { + verbose: false, + ...resolveDiscordFormattedDeliveryOptions(ctx, sendContext), + replyTo: voiceReplyTo, + onDeliveryResult: resolveDiscordDeliveryProgress(ctx), + }), + ); + } + } + if (deliveredVoice) { + await ctx.onDeliveryResult?.(attachChannelToResult("discord", lastResult)); + } + if (deliveredVoice && payload.text?.trim()) { lastResult = await sendContext.withRetry( async () => await sendContext.send(sendContext.target, payload.text, {