mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:31:33 +00:00
fix(discord): avoid replaying ambiguous voice deliveries (#117127)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
3c81eb4925
commit
a8dfd37332
@@ -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<typeof discordOutbound.sendPayload>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<object>();
|
||||
type DiscordRetrySafety = "idempotent" | "nonce-protected-create" | "non-idempotent-create";
|
||||
type DiscordDeliveryFailure = "rejected" | "pre-connect" | "ambiguous" | "unknown";
|
||||
|
||||
export type DiscordRetryRunner = <T>(
|
||||
fn: () => Promise<T>,
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<typeof import("./voice-message.js").getVoiceMessageMetadata>
|
||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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 <T>(fn: () => Promise<T>, label?: string): Promise<T> => {
|
||||
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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user