fix(discord): avoid startup rest amplification

This commit is contained in:
Peter Steinberger
2026-05-02 04:19:15 +01:00
parent ebe8f615e5
commit 2b9b133285
6 changed files with 60 additions and 42 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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=<missing> source=token`,
);
});
});

View File

@@ -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<Client, "fetchUser">;
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=<missing> source=token`,
);
return { botUserId: parsedBotUserId, botUserName: undefined };
}
let botUser: Awaited<ReturnType<typeof params.client.fetchUser>>;
try {
botUser = await params.client.fetchUser("@me");

View File

@@ -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");
});

View File

@@ -491,6 +491,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
>();
let { botUserId, botUserName } = await fetchDiscordBotIdentity({
client,
token,
runtime,
logStartupPhase: (phase, details) =>
logDiscordStartupPhase({