diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 8c7f5437676..f9c9278ea2f 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -18,6 +18,7 @@ import { resolveOutboundSendDep, } from "openclaw/plugin-sdk/outbound-runtime"; import { normalizeMessageChannel } from "openclaw/plugin-sdk/routing"; +import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, @@ -86,6 +87,19 @@ async function loadDiscordProbeRuntime() { const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; +const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000; + +function resolveDiscordStartupDelayMs(cfg: OpenClawConfig, accountId: string): number { + const startupAccountIds = listDiscordAccountIds(cfg).filter((candidateId) => { + const candidate = resolveDiscordAccount({ cfg, accountId: candidateId }); + return ( + candidate.enabled && + (resolveConfiguredFromCredentialStatuses(candidate) ?? Boolean(candidate.token.trim())) + ); + }); + const startupIndex = startupAccountIds.findIndex((candidateId) => candidateId === accountId); + return startupIndex <= 0 ? 0 : startupIndex * DISCORD_ACCOUNT_STARTUP_STAGGER_MS; +} const resolveDiscordDmPolicy = createScopedDmSecurityResolver({ channelKey: "discord", @@ -561,6 +575,17 @@ export const discordPlugin: ChannelPlugin gateway: { startAccount: async (ctx) => { const account = ctx.account; + const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId); + if (startupDelayMs > 0) { + ctx.log?.info( + `[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`, + ); + try { + await sleepWithAbort(startupDelayMs, ctx.abortSignal); + } catch { + return; + } + } const token = account.token.trim(); let discordBotLabel = ""; try { diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts index 1f6c34d10c8..958fd4e4f23 100644 --- a/extensions/discord/src/client.ts +++ b/extensions/discord/src/client.ts @@ -1,5 +1,6 @@ import { RequestClient } from "@buape/carbon"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { makeProxyFetch } from "openclaw/plugin-sdk/infra-runtime"; import type { RetryConfig, RetryRunner } from "openclaw/plugin-sdk/retry-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { @@ -29,8 +30,53 @@ function resolveToken(params: { accountId: string; fallbackToken?: string }) { return fallback; } -function resolveRest(token: string, rest?: RequestClient) { - return rest ?? new RequestClient(token); +function resolveDiscordProxyUrl( + account: Pick, + cfg?: ReturnType, +): string | undefined { + const accountProxy = account.config.proxy?.trim(); + if (accountProxy) { + return accountProxy; + } + const channelProxy = cfg?.channels?.discord?.proxy; + if (typeof channelProxy !== "string") { + return undefined; + } + const trimmed = channelProxy.trim(); + return trimmed || undefined; +} + +export function resolveDiscordProxyFetchForAccount( + account: Pick, + cfg?: ReturnType, +): typeof fetch | undefined { + const proxy = resolveDiscordProxyUrl(account, cfg); + return proxy ? makeProxyFetch(proxy) : undefined; +} + +export function resolveDiscordProxyFetch( + opts: Pick, + cfg?: ReturnType, +): typeof fetch | undefined { + const resolvedCfg = opts.cfg ?? cfg ?? loadConfig(); + const account = resolveAccountWithoutToken({ + cfg: resolvedCfg, + accountId: opts.accountId, + }); + return resolveDiscordProxyFetchForAccount(account, resolvedCfg); +} + +function resolveRest( + token: string, + account: ResolvedDiscordAccount, + cfg: ReturnType, + rest?: RequestClient, +) { + if (rest) { + return rest; + } + const proxyFetch = resolveDiscordProxyFetchForAccount(account, cfg); + return new RequestClient(token, proxyFetch ? { fetch: proxyFetch } : undefined); } function resolveAccountWithoutToken(params: { @@ -66,7 +112,7 @@ export function createDiscordRestClient( accountId: account.accountId, fallbackToken: account.token, }); - const rest = resolveRest(token, opts.rest); + const rest = resolveRest(token, account, resolvedCfg, opts.rest); return { token, rest, account }; } diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index e50a72f6772..078f8975700 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -70,6 +70,7 @@ export function createDiscordMonitorClient(params: { accountId: string; applicationId: string; token: string; + proxyFetch?: typeof fetch; commands: BaseCommand[]; components: BaseMessageInteractiveComponent[]; modals: Modal[]; @@ -115,6 +116,7 @@ export function createDiscordMonitorClient(params: { token: params.token, autoDeploy: false, eventQueue: eventQueueOpts, + ...(params.proxyFetch ? { requestOptions: { fetch: params.proxyFetch } } : {}), }, { commands: params.commands, diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index b61c60901e5..0ece4854b82 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -42,6 +42,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { summarizeStringEntries } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccount } from "../accounts.js"; import { isDiscordExecApprovalClientEnabled } from "../exec-approvals.js"; +import { resolveDiscordProxyFetchForAccount } from "../client.js"; import { fetchDiscordApplicationId } from "../probe.js"; import { normalizeDiscordToken } from "../token.js"; import { createDiscordVoiceCommand } from "../voice/command.js"; @@ -592,6 +593,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const discordAccountThreadBindings = cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); + const discordProxyFetch = resolveDiscordProxyFetchForAccount(account, cfg); const dmConfig = rawDiscordCfg.dm; let guildEntries = rawDiscordCfg.guilds; const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); @@ -903,6 +905,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { accountId: account.accountId, applicationId, token, + proxyFetch: discordProxyFetch, commands, components, modals, diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index 7726a6bbe1d..626ee6b11fb 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -16,6 +16,7 @@ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; +import { resolveDiscordProxyFetch } from "./client.js"; import { rewriteDiscordKnownMentions } from "./mentions.js"; import { buildDiscordMessagePayload, @@ -369,8 +370,9 @@ export async function sendWebhookMessageDiscord( }); const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : ""; const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined; + const fetchImpl = resolveDiscordProxyFetch({ cfg: opts.cfg, accountId: opts.accountId }); - const response = await fetch( + const response = await (fetchImpl ?? fetch)( resolveWebhookExecutionUrl({ webhookId, webhookToken,