Files
openclaw/extensions/discord/src/client.ts
T
Yuval Dinodia df883ab81a fix(discord): stop retrying non-idempotent sends on post-connect-ambiguous errors (#103867)
* fix(discord): stop retrying non-idempotent sends on post-connect-ambiguous errors

Discord's outbound retry runner treated every transient transport error as
retryable, including ECONNRESET, ETIMEDOUT, AbortError, and the undici
headers/body/socket timeouts. Those errors can fire after Discord has already
received and created the message but before the client reads the response, so
replaying the non-idempotent createChannelMessage POST delivers the same
message twice while reporting success.

Add a pre-connect-only classifier (isRetryableDiscordPreConnectError) covering
errors that provably never reached Discord (DNS/connect refused/connect
timeout) plus rate-limit rejections, mirroring the Telegram send-path
carve-out. The retry runner now accepts a per-call nonIdempotent option; the
message-create and thread-create call sites opt in so ambiguous post-connect
errors surface to the caller instead of double-sending. Idempotent REST calls
(reactions, edits, DM-channel lookup, uploads) keep the broader transient set.

* fix(discord): retry nonce-enforced message creates

* fix(discord): scope create retries by endpoint

* fix(discord): stabilize sticker and poll create nonce across retries

* fix(discord): tighten create retry safety

* fix(discord): preserve nonce-safe retries

* fix(discord): scope nonce fields to message creates

* test(discord): cover nonce-safe direct sends

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-11 00:54:23 -07:00

163 lines
5.1 KiB
TypeScript

// Discord plugin module implements client behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
mergeDiscordAccountConfig,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { RequestClient } from "./internal/discord.js";
import { getGateway } from "./monitor/gateway-registry.js";
import { resolveDiscordProxyFetchForAccount } from "./proxy-fetch.js";
import { createDiscordRequestClient } from "./proxy-request-client.js";
import { createDiscordRetryRunner, type DiscordRetryRunner } from "./retry.js";
import type { DiscordRuntimeAccountContext } from "./send.types.js";
import { normalizeDiscordToken } from "./token.js";
export type DiscordClientOpts = {
cfg: OpenClawConfig;
token?: string;
accountId?: string;
rest?: RequestClient;
retry?: RetryConfig;
signal?: AbortSignal;
timeoutMs?: number;
verbose?: boolean;
};
export function createDiscordRuntimeAccountContext(params: {
cfg: OpenClawConfig;
accountId: string;
}): DiscordRuntimeAccountContext {
return {
cfg: params.cfg,
accountId: normalizeAccountId(params.accountId),
};
}
export function resolveDiscordClientAccountContext(
opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
runtime?: Pick<RuntimeEnv, "error">,
) {
const resolvedCfg = requireRuntimeConfig(opts.cfg, "Discord client");
const account = resolveAccountWithoutToken({
cfg: resolvedCfg,
accountId: opts.accountId,
});
return {
cfg: resolvedCfg,
account,
proxyFetch: resolveDiscordProxyFetchForAccount(account, resolvedCfg, runtime),
};
}
function resolveToken(params: {
account: ResolvedDiscordAccount;
accountId: string;
fallbackToken?: string;
}) {
const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token");
if (!fallback) {
if (params.account.tokenStatus === "configured_unavailable") {
throw new Error(
`Discord bot token configured for account "${params.accountId}" is unavailable; resolve SecretRefs against the active runtime snapshot before using this account.`,
);
}
throw new Error(
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
);
}
return fallback;
}
function resolveRest(
token: string,
account: ResolvedDiscordAccount,
cfg: OpenClawConfig,
rest?: RequestClient,
proxyFetch?: typeof fetch,
signal?: AbortSignal,
timeoutMs?: number,
) {
if (rest) {
return rest;
}
const resolvedProxyFetch = proxyFetch ?? resolveDiscordProxyFetchForAccount(account, cfg);
return createDiscordRequestClient(token, {
...(resolvedProxyFetch ? { fetch: resolvedProxyFetch } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
});
}
function resolveAccountWithoutToken(params: {
cfg: OpenClawConfig;
accountId?: string;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const accountEnabled = merged.enabled !== false;
return {
accountId,
enabled: baseEnabled && accountEnabled,
name: normalizeOptionalString(merged.name),
token: "",
tokenSource: "none",
tokenStatus: "missing",
config: merged,
};
}
export function createDiscordRestClient(opts: DiscordClientOpts) {
const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token");
const proxyContext = resolveDiscordClientAccountContext(opts);
const resolvedCfg = proxyContext.cfg;
const account = explicitToken
? proxyContext.account
: resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId });
const token =
explicitToken ??
resolveToken({
account,
accountId: account.accountId,
fallbackToken: account.token,
});
const rest = resolveRest(
token,
account,
resolvedCfg,
opts.rest,
proxyContext.proxyFetch,
opts.signal,
opts.timeoutMs,
);
return { token, rest, account };
}
export function createDiscordClient(opts: DiscordClientOpts): {
token: string;
rest: RequestClient;
request: DiscordRetryRunner;
} {
const { token, rest, account } = createDiscordRestClient(opts);
const request = createDiscordRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
isGatewayDisconnected: () => {
const gateway = getGateway(account.accountId);
return gateway !== undefined && !gateway.isConnected;
},
});
return { token, rest, request };
}
export function resolveDiscordRest(opts: DiscordClientOpts) {
return createDiscordRestClient(opts).rest;
}