diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fee5228bed..8cb026400c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - 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. - Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93. - Discord: add configured outbound mention aliases so known `@Name` references can be rewritten to real Discord user mentions instead of relying only on the transient directory cache. Fixes #67587. Thanks @McoreD. +- Discord: avoid startup REST amplification by skipping native command deploy retries after Discord rate limits and deriving the bot id from parseable bot tokens instead of requiring a `/users/@me` lookup. Fixes #75341. Thanks @PrinceOfEgypt. - Plugins/hooks: derive hook `ctx.channelId` from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels. - 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/extensions/discord/src/monitor/provider.deploy.ts b/extensions/discord/src/monitor/provider.deploy.ts index 3eff0914015..e738b956a4d 100644 --- a/extensions/discord/src/monitor/provider.deploy.ts +++ b/extensions/discord/src/monitor/provider.deploy.ts @@ -1,4 +1,4 @@ -import { formatDurationSeconds, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { Client, overwriteApplicationCommands, type RequestClient } from "../internal/discord.js"; import { @@ -9,7 +9,6 @@ import { formatDiscordDeployRateLimitDetails, formatDiscordDeployRateLimitWarning, isDiscordDeployDailyCreateLimit, - resolveDiscordDeployRateLimitDetails, } from "./provider.deploy-errors.js"; import { logDiscordStartupPhase } from "./provider.startup-log.js"; @@ -130,9 +129,6 @@ async function deployDiscordCommands(params: { } const startupStartedAt = params.startupStartedAt ?? Date.now(); const accountId = params.accountId ?? "default"; - const maxAttempts = 3; - const maxRetryDelayMs = 15_000; - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); const restoreDeployRestLogging = installDeployRestLogging({ rest: params.client.rest, runtime: params.runtime, @@ -141,46 +137,29 @@ async function deployDiscordCommands(params: { shouldLogVerbose: params.shouldLogVerbose, }); try { - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - await params.client.deployCommands({ mode: "reconcile" }); + try { + await params.client.deployCommands({ mode: "reconcile" }); + return; + } catch (err) { + if (isDiscordDeployDailyCreateLimit(err)) { + params.runtime.log?.( + warn( + `discord: native slash command deploy skipped for ${accountId}; daily application command create limit reached. Existing slash commands stay active until Discord resets the quota. Message send/receive is unaffected.`, + ), + ); return; - } catch (err) { - if (isDiscordDeployDailyCreateLimit(err)) { - params.runtime.log?.( - warn( - `discord: native slash command deploy skipped for ${accountId}; daily application command create limit reached. Existing slash commands stay active until Discord resets the quota. Message send/receive is unaffected.`, - ), - ); - return; - } - const rateLimitDetails = resolveDiscordDeployRateLimitDetails(err); - if (!rateLimitDetails || attempt >= maxAttempts) { - throw err; - } - const retryAfterMs = Math.max(0, Math.ceil(rateLimitDetails.retryAfterMs ?? 0)); - if (retryAfterMs > maxRetryDelayMs) { - params.runtime.log?.( - warn( - `discord: native slash command deploy skipped for ${accountId}; retry after ${formatDurationSeconds(retryAfterMs, { decimals: 1 })} exceeds startup budget. Existing slash commands stay active. Message send/receive is unaffected.`, - ), - ); - return; - } - if (params.shouldLogVerbose()) { - params.runtime.log?.( - `discord startup [${accountId}] deploy-retry ${Math.max(0, Date.now() - startupStartedAt)}ms attempt=${attempt}/${maxAttempts - 1} retryAfterMs=${retryAfterMs} scope=${rateLimitDetails.scope ?? "unknown"} code=${rateLimitDetails.discordCode ?? "unknown"}`, - ); - } - await sleep(retryAfterMs); } + const rateLimitWarning = formatDiscordDeployRateLimitWarning(err, accountId); + if (rateLimitWarning) { + params.runtime.log?.(warn(rateLimitWarning)); + return; + } + throw err; } } catch (err) { - const rateLimitWarning = formatDiscordDeployRateLimitWarning(err, accountId); params.runtime.log?.( warn( - rateLimitWarning ?? - `discord: native slash command deploy warning (not message send): ${formatDiscordDeployErrorMessage(err)}${formatDiscordDeployErrorDetails(err)}`, + `discord: native slash command deploy warning (not message send): ${formatDiscordDeployErrorMessage(err)}${formatDiscordDeployErrorDetails(err)}`, ), ); } finally { diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index c9b5f23d44e..e557196a0e4 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -81,7 +81,7 @@ vi.mock("./presence.js", () => ({ })); import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; -import { createDiscordMonitorClient } from "./provider.startup.js"; +import { createDiscordMonitorClient, fetchDiscordBotIdentity } from "./provider.startup.js"; describe("createDiscordMonitorClient", () => { beforeEach(() => { @@ -295,3 +295,28 @@ describe("createDiscordMonitorClient", () => { expect(createAutoPresenceControllerForTest).not.toHaveBeenCalled(); }); }); + +describe("fetchDiscordBotIdentity", () => { + it("derives the bot id from a Discord bot token without calling /users/@me", async () => { + const fetchUser = vi.fn(async () => { + throw new Error("network should not be used"); + }); + const logStartupPhase = vi.fn(); + const botId = "1477179610322964541"; + + await expect( + fetchDiscordBotIdentity({ + client: { fetchUser } as never, + token: `${Buffer.from(botId).toString("base64")}.GhIiP9.vU1xEpJ6NjFm`, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + logStartupPhase, + }), + ).resolves.toEqual({ botUserId: botId, botUserName: undefined }); + + expect(fetchUser).not.toHaveBeenCalled(); + expect(logStartupPhase).toHaveBeenCalledWith( + "fetch-bot-identity:done", + `botUserId=${botId} botUserName= source=token`, + ); + }); +}); diff --git a/extensions/discord/src/monitor/provider.startup.ts b/extensions/discord/src/monitor/provider.startup.ts index 6995edfd3fb..5f4d220b6d3 100644 --- a/extensions/discord/src/monitor/provider.startup.ts +++ b/extensions/discord/src/monitor/provider.startup.ts @@ -13,6 +13,7 @@ import { } from "../internal/discord.js"; import type { GatewayPlugin } from "../internal/gateway.js"; import { VoicePlugin } from "../internal/voice.js"; +import { parseApplicationIdFromToken } from "../probe.js"; import { createDiscordRequestClient, DISCORD_REST_TIMEOUT_MS } from "../proxy-request-client.js"; import type { DiscordGuildEntryResolved } from "./allow-list.js"; import { createDiscordAutoPresenceController } from "./auto-presence.js"; @@ -190,10 +191,20 @@ export async function createDiscordMonitorClient(params: { export async function fetchDiscordBotIdentity(params: { client: Pick; + token?: string; runtime: RuntimeEnv; logStartupPhase: (phase: string, details?: string) => void; }) { params.logStartupPhase("fetch-bot-identity:start"); + const parsedBotUserId = parseApplicationIdFromToken(params.token ?? ""); + if (parsedBotUserId) { + params.logStartupPhase( + "fetch-bot-identity:done", + `botUserId=${parsedBotUserId} botUserName= source=token`, + ); + return { botUserId: parsedBotUserId, botUserName: undefined }; + } + let botUser: Awaited>; try { botUser = await params.client.fetchUser("@me"); diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 7733746faaa..d8200ec6e5e 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -879,7 +879,7 @@ describe("monitorDiscordProvider", () => { ); }); - it("logs repeated native command deploy rate limits as one concise warning", async () => { + it("skips native command deploy retries after one rate limit warning", async () => { const runtime = baseRuntime(); const rateLimitError = createRateLimitError( new Response(null, { @@ -898,7 +898,7 @@ describe("monitorDiscordProvider", () => { runtime, }); - await vi.waitFor(() => expect(clientDeployCommandsMock).toHaveBeenCalledTimes(3)); + await vi.waitFor(() => expect(clientDeployCommandsMock).toHaveBeenCalledTimes(1)); const warningMessages = vi .mocked(runtime.log) .mock.calls.map((call) => String(call[0])) @@ -1012,6 +1012,7 @@ describe("monitorDiscordProvider", () => { }); expect(fetchApplicationId).not.toHaveBeenCalled(); + expect(clientFetchUserMock).not.toHaveBeenCalled(); expect(getConstructedClientOptions().clientId).toBe("123"); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 9f679ecb7e7..28f0ec2c474 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -491,6 +491,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { >(); let { botUserId, botUserName } = await fetchDiscordBotIdentity({ client, + token, runtime, logStartupPhase: (phase, details) => logDiscordStartupPhase({