diff --git a/CHANGELOG.md b/CHANGELOG.md index 626b64a4354..25d0a996963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai - Discord/DMs: keep no-guild inbound messages on direct-message routing when Discord channel lookup is temporarily unavailable, preventing degraded DMs from forking into channel sessions. Fixes #59817. Thanks @DooPeePey. - Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo. - Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive. +- Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos. - Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom. - Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index e1b7d10e5ea..be1b0245a74 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -8bbb620e445cba64aa8a451cfc1a7142ac24e8c80088d74a2fc813ee9e221680 config-baseline.json -d145a87759d16d5f58873db337a25cb134ab25e776cd454812dca99bb9cb12a7 config-baseline.core.json -c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json -7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json +1d9157a39ad18841d666af90c58e0539d6427cbd2ad0c1ce29047a5a2131ba7e config-baseline.json +80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json +1cec599c3d27c258b9df3446baa547cb164e502afa9b30c052bba8737183f551 config-baseline.channel.json +8346667910d2b3a3884efce8f96591adebc4f7ea99ce18337b80e4d70bf8e4d2 config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 9e2c58ba098..a182112aba9 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1255,6 +1255,22 @@ openclaw logs --follow + + OpenClaw waits for Discord's gateway `READY` event during startup and after runtime reconnects. Multi-account setups with startup staggering can need a longer startup READY window than the default. + + READY timeout knobs: + + - startup single-account: `channels.discord.gatewayReadyTimeoutMs` + - startup multi-account: `channels.discord.accounts..gatewayReadyTimeoutMs` + - startup env fallback when config is unset: `OPENCLAW_DISCORD_READY_TIMEOUT_MS` + - startup default: `15000` (15 seconds), max: `120000` + - runtime single-account: `channels.discord.gatewayRuntimeReadyTimeoutMs` + - runtime multi-account: `channels.discord.accounts..gatewayRuntimeReadyTimeoutMs` + - runtime env fallback when config is unset: `OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS` + - runtime default: `30000` (30 seconds), max: `120000` + + + `channels status --probe` permission checks only work for numeric channel IDs. @@ -1301,7 +1317,7 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels# - policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*` - command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*` - event queue: `eventQueue.listenerTimeout` (listener budget), `eventQueue.maxQueueSize`, `eventQueue.maxConcurrency` -- gateway metadata: `gatewayInfoTimeoutMs` +- gateway: `gatewayInfoTimeoutMs`, `gatewayReadyTimeoutMs`, `gatewayRuntimeReadyTimeoutMs` - reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage` - streaming: `streaming` (legacy alias: `streamMode`), `streaming.preview.toolProgress`, `draftChunk`, `blockStreaming`, `blockStreamingCoalesce` diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 882a92b90d9..60563c66e26 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -168,6 +168,33 @@ describe("discordPlugin outbound", () => { expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all"); }); + it("inherits Discord gateway READY timeout settings per account", () => { + const cfg = { + channels: { + discord: { + token: "discord-token", + gatewayReadyTimeoutMs: 90_000, + gatewayRuntimeReadyTimeoutMs: 120_000, + accounts: { + work: { + token: "discord-token-work", + gatewayReadyTimeoutMs: 60_000, + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveAccount(cfg).config).toMatchObject({ + gatewayReadyTimeoutMs: 90_000, + gatewayRuntimeReadyTimeoutMs: 120_000, + }); + expect(resolveAccount(cfg, "work").config).toMatchObject({ + gatewayReadyTimeoutMs: 60_000, + gatewayRuntimeReadyTimeoutMs: 120_000, + }); + }); + it("forwards full media send context to sendMessageDiscord", async () => { const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" })); const mediaReadFile = vi.fn(async () => Buffer.from("media")); diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 8c5fc6d129e..4255a710ada 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -141,6 +141,14 @@ export const discordChannelConfigUiHints = { label: "Discord Gateway Metadata Timeout (ms)", help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.", }, + gatewayReadyTimeoutMs: { + label: "Discord Gateway READY Timeout (ms)", + help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.", + }, + gatewayRuntimeReadyTimeoutMs: { + label: "Discord Gateway Runtime READY Timeout (ms)", + help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.", + }, "voice.enabled": { label: "Discord Voice Enabled", help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.", diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 93d438c9f07..095334b02e6 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -59,9 +59,15 @@ vi.mock("./gateway-registry.js", () => ({ describe("runDiscordGatewayLifecycle", () => { let runDiscordGatewayLifecycle: typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle; + let resolveDiscordGatewayReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayReadyTimeoutMs; + let resolveDiscordGatewayRuntimeReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayRuntimeReadyTimeoutMs; beforeAll(async () => { - ({ runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js")); + ({ + runDiscordGatewayLifecycle, + resolveDiscordGatewayReadyTimeoutMs, + resolveDiscordGatewayRuntimeReadyTimeoutMs, + } = await import("./provider.lifecycle.js")); }); beforeEach(() => { @@ -143,24 +149,25 @@ describe("runDiscordGatewayLifecycle", () => { error: runtimeError, exit: vi.fn(), }; + const lifecycleParams: LifecycleParams = { + accountId: "default", + gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined, + runtime, + isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false), + voiceManager: null, + voiceManagerRef: { current: null }, + threadBindings: { stop: threadStop }, + gatewaySupervisor, + statusSink, + abortSignal: undefined, + }; return { threadStop, runtimeLog, runtimeError, gatewaySupervisor, statusSink, - lifecycleParams: { - accountId: "default", - gateway: gateway ? (gateway as unknown as MutableDiscordGateway) : undefined, - runtime, - isDisallowedIntentsError: params?.isDisallowedIntentsError ?? (() => false), - voiceManager: null, - voiceManagerRef: { current: null }, - threadBindings: { stop: threadStop }, - gatewaySupervisor, - statusSink, - abortSignal: undefined as AbortSignal | undefined, - } satisfies LifecycleParams, + lifecycleParams, }; } @@ -176,6 +183,26 @@ describe("runDiscordGatewayLifecycle", () => { expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1); } + it("resolves gateway READY timeouts from config, env, then defaults", () => { + expect(resolveDiscordGatewayReadyTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000); + expect( + resolveDiscordGatewayReadyTimeoutMs({ + env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "90000" }, + }), + ).toBe(90_000); + expect(resolveDiscordGatewayReadyTimeoutMs({ env: {} })).toBe(15_000); + + expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ configuredTimeoutMs: 60_000 })).toBe( + 60_000, + ); + expect( + resolveDiscordGatewayRuntimeReadyTimeoutMs({ + env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "120000" }, + }), + ).toBe(120_000); + expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ env: {} })).toBe(30_000); + }); + it("cleans up thread bindings when gateway wait fails before READY", async () => { waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed")); const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness(); @@ -228,14 +255,15 @@ describe("runDiscordGatewayLifecycle", () => { gateway: null, }, ); + lifecycleParams.gatewayReadyTimeoutMs = 5_000; const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); lifecyclePromise.catch(() => {}); await vi.advanceTimersByTimeAsync(0); - await vi.advanceTimersByTimeAsync(15_500); + await vi.advanceTimersByTimeAsync(5_500); await expect(lifecyclePromise).rejects.toThrow( - "discord gateway did not reach READY within 15000ms", + "discord gateway did not reach READY within 5000ms", ); expect(statusSink).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -606,15 +634,16 @@ describe("runDiscordGatewayLifecycle", () => { ); const { lifecycleParams, runtimeError, statusSink } = createLifecycleHarness({ gateway }); + lifecycleParams.gatewayRuntimeReadyTimeoutMs = 5_000; const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams); lifecyclePromise.catch(() => {}); - await vi.advanceTimersByTimeAsync(30_500); + await vi.advanceTimersByTimeAsync(5_500); await expect(lifecyclePromise).rejects.toThrow( - "discord gateway opened but did not reach READY within 30000ms", + "discord gateway opened but did not reach READY within 5000ms", ); expect(runtimeError).toHaveBeenCalledWith( - expect.stringContaining("did not reach READY within 30000ms"), + expect.stringContaining("did not reach READY within 5000ms"), ); expect(statusSink).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index e782a5deec4..d0720a5eddf 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -19,8 +19,11 @@ import { } from "./gateway-supervisor.js"; import type { DiscordMonitorStatusSink } from "./status.js"; -const DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000; -const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS = 30_000; +const DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS = 15_000; +const DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS = 30_000; +const MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS = 120_000; +const DISCORD_GATEWAY_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_READY_TIMEOUT_MS"; +const DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV = "OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS"; const DISCORD_GATEWAY_READY_POLL_MS = 250; const DISCORD_GATEWAY_STARTUP_DISCONNECT_DRAIN_TIMEOUT_MS = 5_000; const DISCORD_GATEWAY_STARTUP_TERMINATE_CLOSE_TIMEOUT_MS = 1_000; @@ -28,6 +31,37 @@ const DISCORD_GATEWAY_TRANSPORT_ACTIVITY_STATUS_MIN_INTERVAL_MS = 30_000; type GatewayReadyWaitResult = "ready" | "stopped" | "timeout"; +function normalizeGatewayReadyTimeoutMs(value: unknown): number | undefined { + const numeric = + typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; + if (!Number.isFinite(numeric) || numeric <= 0) { + return undefined; + } + return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS); +} + +export function resolveDiscordGatewayReadyTimeoutMs(params?: { + configuredTimeoutMs?: number; + env?: NodeJS.ProcessEnv; +}): number { + return ( + normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ?? + normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_READY_TIMEOUT_ENV]) ?? + DEFAULT_DISCORD_GATEWAY_READY_TIMEOUT_MS + ); +} + +export function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: { + configuredTimeoutMs?: number; + env?: NodeJS.ProcessEnv; +}): number { + return ( + normalizeGatewayReadyTimeoutMs(params?.configuredTimeoutMs) ?? + normalizeGatewayReadyTimeoutMs(params?.env?.[DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_ENV]) ?? + DEFAULT_DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS + ); +} + async function restartGatewayAfterReadyTimeout(params: { gateway?: Pick; abortSignal?: AbortSignal; @@ -158,6 +192,7 @@ function createGatewayStatusObserver(params: { runtime: RuntimeEnv; pushStatus: (patch: Parameters[0]) => void; isLifecycleStopping: () => boolean; + runtimeReadyTimeoutMs: number; }) { let forceStopHandler: ((err: unknown) => void) | undefined; let queuedForceStopError: unknown; @@ -214,7 +249,7 @@ function createGatewayStatusObserver(params: { } const at = Date.now(); const error = new Error( - `discord gateway opened but did not reach READY within ${DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS}ms`, + `discord gateway opened but did not reach READY within ${params.runtimeReadyTimeoutMs}ms`, ); params.pushStatus({ connected: false, @@ -227,7 +262,7 @@ function createGatewayStatusObserver(params: { }); params.runtime.error?.(danger(error.message)); triggerForceStop(error); - }, DISCORD_GATEWAY_RUNTIME_READY_TIMEOUT_MS); + }, params.runtimeReadyTimeoutMs); readyTimeoutId.unref?.(); } }; @@ -292,9 +327,10 @@ async function waitForGatewayReady(params: { pushStatus?: (patch: Parameters[0]) => void; runtime: RuntimeEnv; beforeRestart?: () => Promise | void; + readyTimeoutMs: number; }): Promise { const waitUntilReady = async (): Promise => { - const deadlineAt = Date.now() + DISCORD_GATEWAY_READY_TIMEOUT_MS; + const deadlineAt = Date.now() + params.readyTimeoutMs; while (!params.abortSignal?.aborted) { if ((await params.beforePoll?.()) === "stop") { return "stopped"; @@ -324,16 +360,12 @@ async function waitForGatewayReady(params: { return; } if (!params.gateway) { - throw new Error( - `discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms`, - ); + throw new Error(`discord gateway did not reach READY within ${params.readyTimeoutMs}ms`); } const restartAt = Date.now(); params.runtime.error?.( - danger( - `discord: gateway was not ready after ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms; restarting gateway`, - ), + danger(`discord: gateway was not ready after ${params.readyTimeoutMs}ms; restarting gateway`), ); params.pushStatus?.({ connected: false, @@ -356,7 +388,7 @@ async function waitForGatewayReady(params: { if ((await waitUntilReady()) === "timeout") { throw new Error( - `discord gateway did not reach READY within ${DISCORD_GATEWAY_READY_TIMEOUT_MS}ms after restart`, + `discord gateway did not reach READY within ${params.readyTimeoutMs}ms after restart`, ); } } @@ -372,6 +404,8 @@ export async function runDiscordGatewayLifecycle(params: { threadBindings: { stop: () => void }; gatewaySupervisor: DiscordGatewaySupervisor; statusSink?: DiscordMonitorStatusSink; + gatewayReadyTimeoutMs?: number; + gatewayRuntimeReadyTimeoutMs?: number; }) { const gateway = params.gateway; if (gateway) { @@ -387,12 +421,21 @@ export async function runDiscordGatewayLifecycle(params: { const pushStatus = (patch: Parameters[0]) => { params.statusSink?.(patch); }; + const gatewayReadyTimeoutMs = resolveDiscordGatewayReadyTimeoutMs({ + configuredTimeoutMs: params.gatewayReadyTimeoutMs, + env: process.env, + }); + const gatewayRuntimeReadyTimeoutMs = resolveDiscordGatewayRuntimeReadyTimeoutMs({ + configuredTimeoutMs: params.gatewayRuntimeReadyTimeoutMs, + env: process.env, + }); const statusObserver = createGatewayStatusObserver({ gateway, abortSignal: params.abortSignal, runtime: params.runtime, pushStatus, isLifecycleStopping: () => lifecycleStopping, + runtimeReadyTimeoutMs: gatewayRuntimeReadyTimeoutMs, }); gatewayEmitter?.on("debug", statusObserver.onGatewayDebug); let lastTransportActivityStatusAt: number | undefined; @@ -460,6 +503,7 @@ export async function runDiscordGatewayLifecycle(params: { pushStatus, runtime: params.runtime, beforeRestart: statusObserver.clearReadyWatch, + readyTimeoutMs: gatewayReadyTimeoutMs, }); if (drainPendingGatewayErrors() === "stop") { diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 9627c617490..7733746faaa 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -382,6 +382,33 @@ describe("monitorDiscordProvider", () => { expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1); }); + it("passes configured gateway READY timeouts to the lifecycle monitor", async () => { + resolveDiscordAccountMock.mockReturnValueOnce({ + accountId: "default", + token: "cfg-token", + config: { + commands: { native: true, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + gatewayReadyTimeoutMs: 90_000, + gatewayRuntimeReadyTimeoutMs: 120_000, + }, + }); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime: baseRuntime(), + }); + + expect(monitorLifecycleMock).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayReadyTimeoutMs: 90_000, + gatewayRuntimeReadyTimeoutMs: 120_000, + }), + ); + }); + it("does not load the Discord voice runtime when voice is disabled", async () => { await monitorDiscordProvider({ config: baseConfig(), diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 2b4bf255521..9f679ecb7e7 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -626,6 +626,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { voiceManagerRef, threadBindings, gatewaySupervisor, + gatewayReadyTimeoutMs: account.config.gatewayReadyTimeoutMs, + gatewayRuntimeReadyTimeoutMs: account.config.gatewayRuntimeReadyTimeoutMs, }); } finally { cleanupDiscordProviderStartup({ diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 0200fffc75f..b93c850864f 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -806,6 +806,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 120000, }, + gatewayReadyTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, + gatewayRuntimeReadyTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, allowBots: { anyOf: [ { @@ -2182,6 +2192,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ exclusiveMinimum: 0, maximum: 120000, }, + gatewayReadyTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, + gatewayRuntimeReadyTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, allowBots: { anyOf: [ { @@ -3573,6 +3593,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Discord Gateway Metadata Timeout (ms)", help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.", }, + gatewayReadyTimeoutMs: { + label: "Discord Gateway READY Timeout (ms)", + help: "Startup wait for the Discord gateway READY event before restarting the socket. Default is 15000; OPENCLAW_DISCORD_READY_TIMEOUT_MS can override when config is unset.", + }, + gatewayRuntimeReadyTimeoutMs: { + label: "Discord Gateway Runtime READY Timeout (ms)", + help: "Runtime reconnect wait for the Discord gateway READY event before force-stopping the lifecycle. Default is 30000; OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS can override when config is unset.", + }, "voice.enabled": { label: "Discord Voice Enabled", help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 9142ba43af0..d70cf8fb5b0 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -251,6 +251,10 @@ export type DiscordAccountConfig = { proxy?: string; /** Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default: 30000. */ gatewayInfoTimeoutMs?: number; + /** Startup wait for the gateway READY event before restarting the socket. Default: 15000. */ + gatewayReadyTimeoutMs?: number; + /** Runtime reconnect wait for the gateway READY event before force-stopping the lifecycle. Default: 30000. */ + gatewayRuntimeReadyTimeoutMs?: number; /** Allow bot-authored messages to trigger replies (default: false). Set "mentions" to gate on mentions. */ allowBots?: boolean | "mentions"; /** diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 8f99b19b235..09fb4041fde 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -532,6 +532,8 @@ export const DiscordAccountSchema = z applicationId: DiscordIdSchema.optional(), proxy: z.string().optional(), gatewayInfoTimeoutMs: z.number().int().positive().max(120_000).optional(), + gatewayReadyTimeoutMs: z.number().int().positive().max(120_000).optional(), + gatewayRuntimeReadyTimeoutMs: z.number().int().positive().max(120_000).optional(), allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(), dangerouslyAllowNameMatching: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"),