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 <steipete@gmail.com>
This commit is contained in:
Dan Hayman
2026-07-05 09:19:53 +02:00
committed by GitHub
parent cfdc2b3ff9
commit f4908e60fd
2 changed files with 145 additions and 9 deletions
@@ -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: {},
+41 -9
View File
@@ -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<ReturnType<DiscordPayloadSendContext["send"]>>;
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, {