Files
Moltbot/src/discord/api.ts
2026-01-24 08:43:28 +00:00

125 lines
3.9 KiB
TypeScript

import { resolveFetch } from "../infra/fetch.js";
import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30_000,
jitter: 0.1,
};
type DiscordApiErrorPayload = {
message?: string;
retry_after?: number;
code?: number;
global?: boolean;
};
function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
const payload = JSON.parse(trimmed);
if (payload && typeof payload === "object") return payload as DiscordApiErrorPayload;
} catch {
return null;
}
return null;
}
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
const payload = parseDiscordApiErrorPayload(text);
const retryAfter =
payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after)
? payload.retry_after
: undefined;
if (retryAfter !== undefined) return retryAfter;
const header = response.headers.get("Retry-After");
if (!header) return undefined;
const parsed = Number(header);
return Number.isFinite(parsed) ? parsed : undefined;
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${rounded}s`;
}
function formatDiscordApiErrorText(text: string): string | undefined {
const trimmed = text.trim();
if (!trimmed) return undefined;
const payload = parseDiscordApiErrorPayload(trimmed);
if (!payload) {
const looksJson = trimmed.startsWith("{") && trimmed.endsWith("}");
return looksJson ? "unknown error" : trimmed;
}
const message =
typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "unknown error";
const retryAfter = formatRetryAfterSeconds(
typeof payload.retry_after === "number" ? payload.retry_after : undefined,
);
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
}
export class DiscordApiError extends Error {
status: number;
retryAfter?: number;
constructor(message: string, status: number, retryAfter?: number) {
super(message);
this.status = status;
this.retryAfter = retryAfter;
}
}
export type DiscordFetchOptions = {
retry?: RetryConfig;
label?: string;
};
export async function fetchDiscord<T>(
path: string,
token: string,
fetcher: typeof fetch = fetch,
options?: DiscordFetchOptions,
): Promise<T> {
const fetchImpl = resolveFetch(fetcher);
if (!fetchImpl) {
throw new Error("fetch is not available");
}
const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry);
return retryAsync(
async () => {
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const detail = formatDiscordApiErrorText(text);
const suffix = detail ? `: ${detail}` : "";
const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined;
throw new DiscordApiError(
`Discord API ${path} failed (${res.status})${suffix}`,
res.status,
retryAfter,
);
}
return (await res.json()) as T;
},
{
...retryConfig,
label: options?.label ?? path,
shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429,
retryAfterMs: (err) =>
err instanceof DiscordApiError && typeof err.retryAfter === "number"
? err.retryAfter * 1000
: undefined,
},
);
}