mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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: {},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user