Skip to content

Commit bb3a0c9

Browse files
committed
fix: quiet Discord slash command deploy rate limits
1 parent 027ea5f commit bb3a0c9

5 files changed

Lines changed: 346 additions & 152 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord.
1415
- Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent.
1516
- Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when `contextTokens` is larger than native `contextWindow`. Fixes #74917. Thanks @kAIborg24.
1617
- Gateway/systemd: exit with sysexits 78 for supervised lock and `EADDRINUSE` conflicts so `RestartPreventExitStatus=78` stops `Restart=always` restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { inspect } from "node:util";
2+
import { formatDurationSeconds } from "openclaw/plugin-sdk/runtime-env";
3+
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
4+
import { RateLimitError } from "../internal/discord.js";
5+
6+
const DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT = 3;
7+
8+
export type DiscordDeployErrorLike = {
9+
status?: unknown;
10+
statusCode?: unknown;
11+
discordCode?: unknown;
12+
retryAfter?: unknown;
13+
scope?: unknown;
14+
rawBody?: unknown;
15+
deployRequestBody?: unknown;
16+
};
17+
18+
export type DiscordDeployRateLimitDetails = {
19+
status?: number;
20+
retryAfterMs?: number;
21+
scope?: string;
22+
discordCode?: number | string;
23+
};
24+
25+
export function attachDiscordDeployRequestBody(err: unknown, body: unknown) {
26+
if (!err || typeof err !== "object" || body === undefined) {
27+
return;
28+
}
29+
const deployErr = err as DiscordDeployErrorLike;
30+
if (deployErr.deployRequestBody === undefined) {
31+
deployErr.deployRequestBody = body;
32+
}
33+
}
34+
35+
function stringifyDiscordDeployField(value: unknown): string {
36+
if (typeof value === "string") {
37+
return JSON.stringify(value);
38+
}
39+
try {
40+
return JSON.stringify(value);
41+
} catch {
42+
return inspect(value, { depth: 2, breakLength: 120 });
43+
}
44+
}
45+
46+
function readDiscordDeployRejectedFields(value: unknown): string[] {
47+
if (Array.isArray(value)) {
48+
return value.filter((entry): entry is string => typeof entry === "string").slice(0, 6);
49+
}
50+
if (!value || typeof value !== "object") {
51+
return [];
52+
}
53+
return Object.keys(value).slice(0, 6);
54+
}
55+
56+
function resolveDiscordRejectedDeployEntriesSource(
57+
rawBody: unknown,
58+
): Record<string, unknown> | null {
59+
if (!rawBody || typeof rawBody !== "object") {
60+
return null;
61+
}
62+
const payload = rawBody as { errors?: unknown };
63+
const errors = payload.errors && typeof payload.errors === "object" ? payload.errors : undefined;
64+
const source = errors ?? rawBody;
65+
return source && typeof source === "object" ? (source as Record<string, unknown>) : null;
66+
}
67+
68+
function readDiscordDeployObjectField(value: unknown, field: string): unknown {
69+
return value && typeof value === "object" && field in value
70+
? (value as Record<string, unknown>)[field]
71+
: undefined;
72+
}
73+
74+
function readFiniteNumber(value: unknown): number | undefined {
75+
if (typeof value === "number" && Number.isFinite(value)) {
76+
return value;
77+
}
78+
if (typeof value === "string" && value.trim().length > 0) {
79+
const parsed = Number(value);
80+
return Number.isFinite(parsed) ? parsed : undefined;
81+
}
82+
return undefined;
83+
}
84+
85+
export function resolveDiscordDeployRateLimitDetails(
86+
err: unknown,
87+
): DiscordDeployRateLimitDetails | undefined {
88+
if (!err || typeof err !== "object") {
89+
return undefined;
90+
}
91+
const deployErr = err as DiscordDeployErrorLike;
92+
const status = readFiniteNumber(deployErr.status) ?? readFiniteNumber(deployErr.statusCode);
93+
const retryAfterSeconds =
94+
readFiniteNumber(deployErr.retryAfter) ??
95+
readFiniteNumber(readDiscordDeployObjectField(deployErr.rawBody, "retry_after"));
96+
const isRateLimit =
97+
err instanceof RateLimitError || status === 429 || retryAfterSeconds !== undefined;
98+
if (!isRateLimit) {
99+
return undefined;
100+
}
101+
const rawGlobal = readDiscordDeployObjectField(deployErr.rawBody, "global");
102+
const scope =
103+
typeof deployErr.scope === "string" && deployErr.scope.trim().length > 0
104+
? deployErr.scope
105+
: rawGlobal === true
106+
? "global"
107+
: rawGlobal === false
108+
? "route"
109+
: undefined;
110+
const discordCode =
111+
typeof deployErr.discordCode === "number" || typeof deployErr.discordCode === "string"
112+
? deployErr.discordCode
113+
: undefined;
114+
return {
115+
status,
116+
retryAfterMs:
117+
retryAfterSeconds === undefined ? undefined : Math.max(0, retryAfterSeconds * 1000),
118+
scope,
119+
discordCode,
120+
};
121+
}
122+
123+
export function formatDiscordDeployRateLimitDetails(err: unknown): string {
124+
const rateLimit = resolveDiscordDeployRateLimitDetails(err);
125+
if (!rateLimit) {
126+
return "";
127+
}
128+
const details: string[] = [];
129+
if (typeof rateLimit.status === "number") {
130+
details.push(`status=${rateLimit.status}`);
131+
}
132+
if (typeof rateLimit.retryAfterMs === "number") {
133+
details.push(
134+
`retryAfter=${formatDurationSeconds(rateLimit.retryAfterMs, {
135+
decimals: 1,
136+
})}`,
137+
);
138+
}
139+
if (rateLimit.scope) {
140+
details.push(`scope=${rateLimit.scope}`);
141+
}
142+
if (typeof rateLimit.discordCode === "number" || typeof rateLimit.discordCode === "string") {
143+
details.push(`code=${rateLimit.discordCode}`);
144+
}
145+
return details.length > 0 ? ` (${details.join(", ")})` : "";
146+
}
147+
148+
export function formatDiscordDeployRateLimitWarning(
149+
err: unknown,
150+
accountId: string,
151+
): string | undefined {
152+
const rateLimit = resolveDiscordDeployRateLimitDetails(err);
153+
if (!rateLimit) {
154+
return undefined;
155+
}
156+
const parts = [`discord: native slash command deploy rate limited for ${accountId}`];
157+
if (typeof rateLimit.retryAfterMs === "number") {
158+
parts.push(
159+
`retry after ${formatDurationSeconds(rateLimit.retryAfterMs, {
160+
decimals: 1,
161+
})}`,
162+
);
163+
}
164+
if (rateLimit.scope) {
165+
parts.push(`scope=${rateLimit.scope}`);
166+
}
167+
if (typeof rateLimit.discordCode === "number" || typeof rateLimit.discordCode === "string") {
168+
parts.push(`code=${rateLimit.discordCode}`);
169+
}
170+
return `${parts.join("; ")}. Existing slash commands stay active. Message send/receive is unaffected.`;
171+
}
172+
173+
function formatDiscordRejectedDeployEntries(params: {
174+
rawBody: unknown;
175+
requestBody: unknown;
176+
}): string[] {
177+
const requestBody = Array.isArray(params.requestBody) ? params.requestBody : null;
178+
const rejectedEntriesSource = resolveDiscordRejectedDeployEntriesSource(params.rawBody);
179+
if (!rejectedEntriesSource || !requestBody || requestBody.length === 0) {
180+
return [];
181+
}
182+
const rawEntries = Object.entries(rejectedEntriesSource).filter(([key]) => /^\d+$/.test(key));
183+
return rawEntries.slice(0, DISCORD_DEPLOY_REJECTED_ENTRY_LIMIT).flatMap(([key, value]) => {
184+
const index = Number.parseInt(key, 10);
185+
if (!Number.isFinite(index) || index < 0 || index >= requestBody.length) {
186+
return [];
187+
}
188+
const command = requestBody[index];
189+
if (!command || typeof command !== "object") {
190+
return [`#${index} fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`];
191+
}
192+
const payload = command as {
193+
name?: unknown;
194+
description?: unknown;
195+
options?: unknown;
196+
};
197+
const parts = [
198+
`#${index}`,
199+
`fields=${readDiscordDeployRejectedFields(value).join("|") || "unknown"}`,
200+
];
201+
if (typeof payload.name === "string" && payload.name.trim().length > 0) {
202+
parts.push(`name=${payload.name}`);
203+
}
204+
if (payload.description !== undefined) {
205+
parts.push(`description=${stringifyDiscordDeployField(payload.description)}`);
206+
}
207+
if (Array.isArray(payload.options) && payload.options.length > 0) {
208+
parts.push(`options=${payload.options.length}`);
209+
}
210+
return [parts.join(" ")];
211+
});
212+
}
213+
214+
export function formatDiscordDeployErrorDetails(err: unknown): string {
215+
if (!err || typeof err !== "object") {
216+
return "";
217+
}
218+
const rateLimitDetails = formatDiscordDeployRateLimitDetails(err);
219+
if (rateLimitDetails) {
220+
return rateLimitDetails;
221+
}
222+
const status = (err as DiscordDeployErrorLike).status;
223+
const discordCode = (err as DiscordDeployErrorLike).discordCode;
224+
const rawBody = (err as DiscordDeployErrorLike).rawBody;
225+
const requestBody = (err as DiscordDeployErrorLike).deployRequestBody;
226+
const details: string[] = [];
227+
if (typeof status === "number") {
228+
details.push(`status=${status}`);
229+
}
230+
if (typeof discordCode === "number" || typeof discordCode === "string") {
231+
details.push(`code=${discordCode}`);
232+
}
233+
if (rawBody !== undefined) {
234+
let bodyText = "";
235+
try {
236+
bodyText = JSON.stringify(rawBody);
237+
} catch {
238+
bodyText =
239+
typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 });
240+
}
241+
if (bodyText) {
242+
const maxLen = 800;
243+
const trimmed = bodyText.length > maxLen ? `${bodyText.slice(0, maxLen)}...` : bodyText;
244+
details.push(`body=${trimmed}`);
245+
}
246+
}
247+
const rejectedEntries = formatDiscordRejectedDeployEntries({ rawBody, requestBody });
248+
if (rejectedEntries.length > 0) {
249+
details.push(`rejected=${rejectedEntries.join("; ")}`);
250+
}
251+
return details.length > 0 ? ` (${details.join(", ")})` : "";
252+
}
253+
254+
export function isDiscordDeployDailyCreateLimit(err: unknown): boolean {
255+
if (!err || typeof err !== "object") {
256+
return false;
257+
}
258+
const deployErr = err as DiscordDeployErrorLike;
259+
const discordCode = readFiniteNumber(deployErr.discordCode);
260+
const rawCode = readFiniteNumber(readDiscordDeployObjectField(deployErr.rawBody, "code"));
261+
return (
262+
(discordCode === 30034 || rawCode === 30034) &&
263+
/daily application command creates/i.test(formatErrorMessage(err))
264+
);
265+
}

0 commit comments

Comments
 (0)