diff --git a/extensions/discord/src/outbound-payload.contract.test.ts b/extensions/discord/src/outbound-payload.contract.test.ts index b8f691bf9d9e..3b4637bbbed8 100644 --- a/extensions/discord/src/outbound-payload.contract.test.ts +++ b/extensions/discord/src/outbound-payload.contract.test.ts @@ -4,8 +4,10 @@ import { primeChannelOutboundSendMock, type OutboundPayloadHarnessParams, } from "openclaw/plugin-sdk/channel-contract-testing"; -import { describe, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { DiscordError, RateLimitError } from "./internal/discord.js"; import { discordOutbound } from "./outbound-adapter.js"; +import { recordDiscordMessageCreateAmbiguity } from "./retry.js"; type DiscordSendPayload = NonNullable; @@ -48,3 +50,225 @@ describe("Discord outbound payload contract", () => { createHarness: createDiscordHarness, }); }); + +describe("Discord voice fallback delivery safety", () => { + function runVoicePayload( + error: unknown, + options: { deliveredText?: boolean; messageCreateAmbiguous?: boolean } = {}, + ) { + const voiceDelivery = vi.fn(async () => { + if (options.messageCreateAmbiguous) { + recordDiscordMessageCreateAmbiguity(error); + } + throw error; + }); + const textDelivery = vi.fn(async () => ({ + messageId: "fallback-text", + channelId: "123456", + })); + const sendPayload = requireDiscordSendPayload(); + const promise = sendPayload({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + ...(options.deliveredText + ? { ttsSupplement: { spokenText: "answer", visibleTextAlreadyDelivered: true } } + : { text: "answer" }), + mediaUrls: ["https://example.test/voice.ogg"], + audioAsVoice: true, + }, + deps: { + discord: textDelivery, + discordVoice: voiceDelivery, + }, + }); + return { promise, textDelivery, voiceDelivery }; + } + + it.each([ + { + label: "a request timeout", + error: Object.assign(new Error("voice create timed out"), { status: 408 }), + }, + { + label: "an actual Discord HTTP error", + error: new DiscordError(new Response(null, { status: 502 }), { + message: "voice create failed after acceptance", + }), + }, + { + label: "a wrapped server error with a pre-connect cause", + error: Object.assign(new Error("voice create failed after acceptance"), { + status: 502, + cause: Object.assign(new Error("proxy refused"), { code: "ECONNREFUSED" }), + }), + }, + { + label: "a connection reset", + error: Object.assign(new Error("voice create connection reset"), { code: "ECONNRESET" }), + }, + { + label: "an undici response body timeout", + error: Object.assign(new Error("voice create response timed out"), { + code: "UND_ERR_BODY_TIMEOUT", + }), + }, + { + label: "a wrapped socket timeout", + error: new Error("Discord voice request failed", { + cause: Object.assign(new Error("socket timeout"), { code: "ETIMEDOUT" }), + }), + }, + { + label: "an aborted request", + error: Object.assign(new Error("voice request aborted"), { name: "AbortError" }), + }, + { + label: "an actual message-only fetch failure", + error: new TypeError("fetch failed"), + }, + { + label: "a wrapped message-only fetch failure", + error: new Error("Discord voice request failed", { cause: new TypeError("fetch failed") }), + }, + ...[ + "network error", + "NetworkError", + "socket hang up", + "bad gateway", + "service unavailable", + "temporarily unavailable", + "timed out", + "timeout", + "connection closed", + "connection reset", + "connection refused", + ].map((message) => ({ + label: `the existing Discord retry owner's message-only ${message} transport error`, + error: new Error(message), + })), + ])("does not replay a potentially accepted voice message after $label", async ({ error }) => { + const { promise, textDelivery, voiceDelivery } = runVoicePayload(error, { + messageCreateAmbiguous: true, + }); + + await expect(promise).rejects.toBe(error); + expect(voiceDelivery).toHaveBeenCalledOnce(); + expect(textDelivery).not.toHaveBeenCalled(); + }); + + it("does not conceal an ambiguous voice failure behind an already-delivered transcript", async () => { + const error = Object.assign(new Error("voice create failed after acceptance"), { status: 503 }); + const { promise, textDelivery, voiceDelivery } = runVoicePayload(error, { + deliveredText: true, + messageCreateAmbiguous: true, + }); + + await expect(promise).rejects.toBe(error); + expect(voiceDelivery).toHaveBeenCalledOnce(); + expect(textDelivery).not.toHaveBeenCalled(); + }); + + it("does not replay after an ambiguous create retry ends with a pre-connect failure", async () => { + const error = Object.assign(new Error("final retry could not connect"), { + code: "ECONNREFUSED", + }); + const { promise, textDelivery, voiceDelivery } = runVoicePayload(error, { + messageCreateAmbiguous: true, + }); + + await expect(promise).rejects.toBe(error); + expect(voiceDelivery).toHaveBeenCalledOnce(); + expect(textDelivery).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "remote audio download failed with a server error", + error: new DiscordError(new Response(null, { status: 503 }), { + message: "audio source unavailable", + }), + }, + { + label: "attachment negotiation failed before message creation", + error: Object.assign(new Error("voice upload unavailable"), { status: 502 }), + }, + { + label: "the source fetch failed before message creation", + error: new TypeError("fetch failed"), + }, + { + label: "voice preparation was aborted before message creation", + error: Object.assign(new Error("audio source aborted"), { name: "AbortError" }), + }, + ])("preserves text fallback when $label", async ({ error }) => { + const { promise, textDelivery, voiceDelivery } = runVoicePayload(error); + + await expect(promise).resolves.toMatchObject({ messageId: "fallback-text" }); + expect(voiceDelivery).toHaveBeenCalledOnce(); + expect(textDelivery).toHaveBeenCalledOnce(); + }); + + it.each([ + { + label: "an unavailable audio encoder", + error: new Error("ffmpeg unavailable"), + }, + { + label: "a definitive voice rejection", + error: Object.assign(new Error("voice payload rejected"), { status: 400 }), + }, + { + label: "an expired attachment", + error: Object.assign(new Error("voice attachment not found"), { statusCode: 404 }), + }, + { + label: "a rate-limit rejection", + error: new RateLimitError(new Response(null, { status: 429 }), { + message: "voice create rate limited", + retry_after: 1, + global: false, + }), + }, + { + label: "a pre-connect failure", + error: Object.assign(new Error("voice connect refused"), { code: "ECONNREFUSED" }), + }, + { + label: "a wrapped DNS failure", + error: new Error("voice connection failed", { + cause: Object.assign(new Error("DNS lookup failed"), { code: "ENOTFOUND" }), + }), + }, + { + label: "a fetch failure whose nested DNS error proves pre-connect rejection", + error: new TypeError("fetch failed", { + cause: Object.assign(new Error("DNS lookup failed"), { code: "ENOTFOUND" }), + }), + }, + ])("retains the text fallback after $label", async ({ error }) => { + const { promise, textDelivery, voiceDelivery } = runVoicePayload(error); + + await expect(promise).resolves.toMatchObject({ messageId: "fallback-text" }); + expect(voiceDelivery).toHaveBeenCalledOnce(); + expect(textDelivery).toHaveBeenCalledWith( + "channel:123456", + "answer", + expect.objectContaining({ cfg: {} }), + ); + }); + + it("keeps an existing transcript when an audio encoder fails before voice delivery", async () => { + const { promise, textDelivery, voiceDelivery } = runVoicePayload( + new Error("ffmpeg unavailable"), + { + deliveredText: true, + }, + ); + + await expect(promise).resolves.toMatchObject({ messageId: "" }); + expect(voiceDelivery).toHaveBeenCalledOnce(); + expect(textDelivery).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/discord/src/outbound-payload.ts b/extensions/discord/src/outbound-payload.ts index cffc3b897588..7adb5e64ef4e 100644 --- a/extensions/discord/src/outbound-payload.ts +++ b/extensions/discord/src/outbound-payload.ts @@ -17,6 +17,7 @@ import { sendDiscordComponentMessageLazy, } from "./outbound-components.js"; import { createDiscordPayloadSendContext } from "./outbound-send-context.js"; +import { hasDiscordMessageCreateAmbiguity } from "./retry.js"; import { createDiscordSendReceipt } from "./send.receipt.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "./send.shared.js"; @@ -108,6 +109,10 @@ export async function sendDiscordOutboundPayload(params: { }); deliveredVoice = true; } catch (err) { + // A lost create response can hide a committed voice; a text retry has a different nonce. + if (hasDiscordMessageCreateAmbiguity(err)) { + throw err; + } const supplement = getReplyPayloadTtsSupplement(payload); const visibleFallbackText = payload.text?.trim() ? payload.text : undefined; const hiddenFallbackText = supplement?.visibleTextAlreadyDelivered diff --git a/extensions/discord/src/retry.test.ts b/extensions/discord/src/retry.test.ts index 13a7afad76d4..aef6d67ce370 100644 --- a/extensions/discord/src/retry.test.ts +++ b/extensions/discord/src/retry.test.ts @@ -1,7 +1,12 @@ // Discord tests cover retry plugin behavior. import { afterEach, describe, expect, it, vi } from "vitest"; -import { RateLimitError } from "./internal/discord.js"; -import { createDiscordRetryRunner } from "./retry.js"; +import { DiscordError, RateLimitError } from "./internal/discord.js"; +import { + classifyDiscordDeliveryFailure, + createDiscordRetryRunner, + hasDiscordMessageCreateAmbiguity, + recordDiscordMessageCreateAmbiguity, +} from "./retry.js"; const ZERO_DELAY_RETRY = { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }; @@ -28,6 +33,85 @@ function createRateLimitError(retryAfter = 0): RateLimitError { }); } +describe("classifyDiscordDeliveryFailure", () => { + it.each([ + ["actual rate-limit rejection", createRateLimitError(), "rejected"], + [ + "actual rejected HTTP request", + new DiscordError(new Response(null, { status: 404 }), {}), + "rejected", + ], + [ + "actual ambiguous HTTP request", + new DiscordError(new Response(null, { status: 502 }), {}), + "ambiguous", + ], + [ + "HTTP request timeout", + Object.assign(new Error("request timeout"), { status: 408 }), + "ambiguous", + ], + [ + "authoritative server status before a nested connection refusal", + Object.assign(new Error("bad gateway"), { + status: 503, + cause: Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" }), + }), + "ambiguous", + ], + ["actual fetch failure", new TypeError("fetch failed"), "ambiguous"], + [ + "wrapped fetch failure", + new Error("voice request failed", { cause: new TypeError("fetch failed") }), + "ambiguous", + ], + [ + "response body timeout", + Object.assign(new Error("response stalled"), { code: "UND_ERR_BODY_TIMEOUT" }), + "ambiguous", + ], + ["aborted request", Object.assign(new Error("aborted"), { name: "AbortError" }), "ambiguous"], + [ + "connection refusal", + Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" }), + "pre-connect", + ], + [ + "fetch failure with an authoritative nested DNS cause", + new TypeError("fetch failed", { + cause: Object.assign(new Error("DNS lookup failed"), { code: "ENOTFOUND" }), + }), + "pre-connect", + ], + ["unavailable audio encoder", new Error("ffmpeg unavailable"), "unknown"], + ["unstructured text", "fetch failed", "unknown"], + ])("classifies %s as %s", (_label, error, expected) => { + expect(classifyDiscordDeliveryFailure(error)).toBe(expected); + }); +}); + +describe("Discord message-create ambiguity", () => { + it("records the actual create failure without changing or retaining the error", () => { + const error = Object.freeze(new Error("voice creation timed out")); + const wrapped = new Error("voice delivery failed", { cause: error }); + + expect(hasDiscordMessageCreateAmbiguity(error)).toBe(false); + recordDiscordMessageCreateAmbiguity(error); + + expect(hasDiscordMessageCreateAmbiguity(error)).toBe(true); + expect(hasDiscordMessageCreateAmbiguity(wrapped)).toBe(true); + expect(Object.isFrozen(error)).toBe(true); + }); + + it("never treats an unrelated preparation failure as a message create", () => { + const error = Object.assign(new Error("voice attachment unavailable"), { status: 503 }); + + expect(hasDiscordMessageCreateAmbiguity(error)).toBe(false); + recordDiscordMessageCreateAmbiguity("fetch failed"); + expect(hasDiscordMessageCreateAmbiguity("fetch failed")).toBe(false); + }); +}); + describe("createDiscordRetryRunner error classification", () => { it.each([ ["rate limit", createRateLimitError()], @@ -80,6 +164,18 @@ describe("createDiscordRetryRunner error classification", () => { await expect(runner(fn, "create", { safety: "non-idempotent-create" })).rejects.toBe(error); expect(fn).toHaveBeenCalledTimes(1); }); + + it("does not mistake a nested pre-connect failure for a rejected server request", async () => { + const error = Object.assign(new Error("bad gateway"), { + status: 503, + cause: Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" }), + }); + const fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValue("ok"); + const runner = createDiscordRetryRunner({ retry: ZERO_DELAY_RETRY }); + + await expect(runner(fn, "create", { safety: "non-idempotent-create" })).rejects.toBe(error); + expect(fn).toHaveBeenCalledOnce(); + }); }); describe("createDiscordRetryRunner create safety", () => { diff --git a/extensions/discord/src/retry.ts b/extensions/discord/src/retry.ts index 74a3352af652..d28f7fbd24e8 100644 --- a/extensions/discord/src/retry.ts +++ b/extensions/discord/src/retry.ts @@ -24,10 +24,11 @@ const DISCORD_RETRY_DEFAULTS = { } satisfies RetryConfig; const DISCORD_GATEWAY_RECONNECT_EXTRA_ATTEMPTS = 2; -const DISCORD_RETRYABLE_STATUS_CODES = new Set([408, 429]); const DISCORD_TRANSIENT_MESSAGE_RE = /\b(?:bad gateway|fetch failed|network error|networkerror|service unavailable|socket hang up|temporarily unavailable|timed out|timeout)\b|connection (?:closed|reset|refused)/i; +const ambiguousDiscordMessageCreates = new WeakSet(); type DiscordRetrySafety = "idempotent" | "nonce-protected-create" | "non-idempotent-create"; +type DiscordDeliveryFailure = "rejected" | "pre-connect" | "ambiguous" | "unknown"; export type DiscordRetryRunner = ( fn: () => Promise, @@ -48,50 +49,88 @@ function readDiscordErrorStatus(err: unknown): number | undefined { return parseStrictNonNegativeInteger(raw); } -function isRetryableDiscordTransientError(err: unknown): boolean { - if (err instanceof RateLimitError) { - return true; - } - for (const candidate of collectErrorGraphCandidates(err, (current) => [ +export function classifyDiscordDeliveryFailure(error: unknown): DiscordDeliveryFailure { + const candidates = collectErrorGraphCandidates(error, (current) => [ current.cause, current.error, - ])) { + ]); + + // An HTTP response proves the request reached Discord, even with a nested transport error. + for (const candidate of candidates) { const status = readDiscordErrorStatus(candidate); - if (status !== undefined && (DISCORD_RETRYABLE_STATUS_CODES.has(status) || status >= 500)) { - return true; - } - if (classifyTransientNetworkErrorCode(extractErrorCode(candidate))) { - return true; - } - if (readErrorName(candidate) === "AbortError") { - return true; - } - if ( - (candidate instanceof Error || (candidate !== null && typeof candidate === "object")) && - DISCORD_TRANSIENT_MESSAGE_RE.test(formatErrorMessage(candidate)) - ) { - return true; + if (status !== undefined) { + if (status === 408 || status >= 500) { + return "ambiguous"; + } + if (status >= 400) { + return "rejected"; + } } } - return false; + + if ( + candidates.some( + (candidate) => + readErrorName(candidate) === "AbortError" || + classifyTransientNetworkErrorCode(extractErrorCode(candidate)) === "ambiguous", + ) + ) { + return "ambiguous"; + } + // A confirmed connect/DNS failure is safer than generic outer "fetch failed" wording. + if ( + candidates.some( + (candidate) => + classifyTransientNetworkErrorCode(extractErrorCode(candidate)) === "pre-connect", + ) + ) { + return "pre-connect"; + } + return candidates.some( + (candidate) => + (candidate instanceof Error || (candidate !== null && typeof candidate === "object")) && + DISCORD_TRANSIENT_MESSAGE_RE.test(formatErrorMessage(candidate)), + ) + ? "ambiguous" + : "unknown"; } -function isRetryableDiscordPreConnectError(err: unknown): boolean { - if (err instanceof RateLimitError) { - return true; +export function recordDiscordMessageCreateAmbiguity(error: unknown): void { + if (error !== null && typeof error === "object") { + ambiguousDiscordMessageCreates.add(error); } - for (const candidate of collectErrorGraphCandidates(err, (current) => [ - current.cause, - current.error, - ])) { - if (readDiscordErrorStatus(candidate) === 429) { - return true; - } - if (classifyTransientNetworkErrorCode(extractErrorCode(candidate)) === "pre-connect") { - return true; - } - } - return false; +} + +export function hasDiscordMessageCreateAmbiguity(error: unknown): boolean { + return collectErrorGraphCandidates(error, (current) => [current.cause, current.error]).some( + (candidate) => + candidate !== null && + typeof candidate === "object" && + ambiguousDiscordMessageCreates.has(candidate), + ); +} + +function hasDiscordRateLimitRejection(error: unknown): boolean { + return ( + error instanceof RateLimitError || + collectErrorGraphCandidates(error, (current) => [current.cause, current.error]).some( + (candidate) => readDiscordErrorStatus(candidate) === 429, + ) + ); +} + +function isRetryableDiscordTransientError(error: unknown): boolean { + const failure = classifyDiscordDeliveryFailure(error); + return ( + failure === "ambiguous" || failure === "pre-connect" || hasDiscordRateLimitRejection(error) + ); +} + +function isRetryableDiscordPreConnectError(error: unknown): boolean { + const failure = classifyDiscordDeliveryFailure(error); + return ( + failure === "pre-connect" || (failure === "rejected" && hasDiscordRateLimitRejection(error)) + ); } function resolveDiscordRetryPredicate(safety: DiscordRetrySafety) { diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index 4f095126ff6b..3815de1a03f4 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -3,9 +3,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withServer } from "openclaw/plugin-sdk/test-env"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RequestClient } from "./internal/discord.js"; +import { DiscordError, type RequestClient } from "./internal/discord.js"; import { DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS } from "./monitor/timeouts.js"; -import type { DiscordRetryRunner } from "./retry.js"; +import { hasDiscordMessageCreateAmbiguity, type DiscordRetryRunner } from "./retry.js"; type VoiceMessageMetadata = Awaited< ReturnType @@ -256,6 +256,31 @@ describe("sendDiscordVoiceMessage", () => { } as unknown as RequestClient; } + function mockSuccessfulVoiceUpload() { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const method = input instanceof Request ? input.method : (init?.method ?? "GET"); + if (method === "POST" && url.endsWith("/channels/channel-1/attachments")) { + return new Response( + JSON.stringify({ + attachments: [ + { + id: 0, + upload_url: "https://cdn.test/upload", + upload_filename: "uploaded.ogg", + }, + ], + }), + { status: 200 }, + ); + } + if (method === "PUT" && url === "https://cdn.test/upload") { + return new Response(null, { status: 200 }); + } + throw new Error(`unexpected fetch ${method} ${url}`); + }); + } + async function retryRateLimits(fn: () => Promise): Promise { let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { @@ -425,6 +450,136 @@ describe("sendDiscordVoiceMessage", () => { expect(secondBody?.nonce).toBe(firstBody?.nonce); }); + it.each([ + { + label: "actual Discord HTTP failure", + error: new DiscordError(new Response(null, { status: 503 }), { + message: "voice message may have been accepted", + }), + }, + { label: "actual fetch failure", error: new TypeError("fetch failed") }, + { + label: "aborted message create", + error: Object.assign(new Error("voice message create aborted"), { name: "AbortError" }), + }, + ])("records delivery ambiguity only after the final $label", async ({ error }) => { + const post = vi.fn(async () => { + throw error; + }); + const rest = createRest(post); + mockSuccessfulVoiceUpload(); + + await expect( + sendDiscordVoiceMessage( + rest, + "channel-1", + Buffer.from("ogg"), + metadata, + undefined, + async (fn) => await fn(), + false, + "bot-token", + ), + ).rejects.toBe(error); + + expect(post).toHaveBeenCalledOnce(); + expect(hasDiscordMessageCreateAmbiguity(error)).toBe(true); + }); + + it("retains an earlier ambiguous create when its final retry cannot connect", async () => { + const ambiguous = Object.assign(new Error("voice create response lost"), { status: 502 }); + const finalFailure = Object.assign(new Error("final create could not connect"), { + code: "ECONNREFUSED", + }); + const post = vi.fn().mockRejectedValueOnce(ambiguous).mockRejectedValueOnce(finalFailure); + const rest = createRest(post); + mockSuccessfulVoiceUpload(); + const request = vi.fn(async (fn: () => Promise, label?: string): Promise => { + if (label === "voice-message") { + await fn().catch(() => undefined); + } + return await fn(); + }) as unknown as DiscordRetryRunner; + + await expect( + sendDiscordVoiceMessage( + rest, + "channel-1", + Buffer.from("ogg"), + metadata, + undefined, + request, + false, + "bot-token", + ), + ).rejects.toBe(finalFailure); + + expect(post).toHaveBeenCalledTimes(2); + expect(hasDiscordMessageCreateAmbiguity(finalFailure)).toBe(true); + }); + + it.each([ + { stage: "upload-url", failure: "HTTP" }, + { stage: "upload-url", failure: "fetch" }, + { stage: "attachment", failure: "HTTP" }, + { stage: "attachment", failure: "fetch" }, + ])("does not mark a $failure failure during $stage negotiation", async ({ stage, failure }) => { + const post = vi.fn(async () => ({ id: "msg-1", channel_id: "channel-1" })); + const rest = createRest(post); + const fetchFailure = new TypeError("fetch failed"); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + const method = input instanceof Request ? input.method : (init?.method ?? "GET"); + const currentStage = method === "POST" ? "upload-url" : "attachment"; + if (currentStage === stage) { + if (failure === "fetch") { + throw fetchFailure; + } + return new Response("voice attachment unavailable", { status: 503 }); + } + if (method === "POST" && url.endsWith("/channels/channel-1/attachments")) { + return new Response( + JSON.stringify({ + attachments: [ + { + id: 0, + upload_url: "https://cdn.test/upload", + upload_filename: "uploaded.ogg", + }, + ], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${method} ${url}`); + }); + + let caught: unknown; + try { + await sendDiscordVoiceMessage( + rest, + "channel-1", + Buffer.from("ogg"), + metadata, + undefined, + async (fn) => await fn(), + false, + "bot-token", + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect(hasDiscordMessageCreateAmbiguity(caught)).toBe(false); + expect(post).not.toHaveBeenCalled(); + if (failure === "fetch") { + expect(caught).toBe(fetchFailure); + } else { + expect((caught as { status?: unknown }).status).toBe(503); + } + }); + it("throws typed CDN upload failures", async () => { const rest = createRest(); vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { @@ -472,6 +627,7 @@ describe("sendDiscordVoiceMessage", () => { expect((error as { rawBody?: unknown }).rawBody).toEqual({ message: "cdn unavailable", }); + expect(hasDiscordMessageCreateAmbiguity(error)).toBe(false); }); it("bounds voice upload error bodies without using response.text()", async () => { diff --git a/extensions/discord/src/voice-message.ts b/extensions/discord/src/voice-message.ts index c1207a8718d4..0c8c1b905933 100644 --- a/extensions/discord/src/voice-message.ts +++ b/extensions/discord/src/voice-message.ts @@ -35,7 +35,11 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { DiscordError, RateLimitError, type RequestClient } from "./internal/discord.js"; import { readDiscordMessage, readRetryAfter } from "./internal/rest-errors.js"; import { DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS } from "./monitor/timeouts.js"; -import type { DiscordRetryRunner } from "./retry.js"; +import { + classifyDiscordDeliveryFailure, + recordDiscordMessageCreateAmbiguity, + type DiscordRetryRunner, +} from "./retry.js"; import { createDiscordMessageNonce } from "./send.message-request.js"; const DISCORD_VOICE_MESSAGE_FLAG = 1 << 13; @@ -483,14 +487,27 @@ export async function sendDiscordVoiceMessage( }; } - const res = (await request( - () => - rest.post(`/channels/${channelId}/messages`, { - body: messagePayload, - }) as Promise<{ id: string; channel_id: string }>, - "voice-message", - { safety: "nonce-protected-create" }, - )) as { id: string; channel_id: string }; - - return res; + let messageCreateMayHaveCommitted = false; + try { + return (await request( + async () => { + try { + return (await rest.post(`/channels/${channelId}/messages`, { + body: messagePayload, + })) as { id: string; channel_id: string }; + } catch (error) { + messageCreateMayHaveCommitted ||= classifyDiscordDeliveryFailure(error) === "ambiguous"; + throw error; + } + }, + "voice-message", + { safety: "nonce-protected-create" }, + )) as { id: string; channel_id: string }; + } catch (error) { + // Only this final request can commit a message; upload/preflight failures cannot. + if (messageCreateMayHaveCommitted) { + recordDiscordMessageCreateAmbiguity(error); + } + throw error; + } }