refactor: centralize discord api errors
This commit is contained in:
42
src/discord/api.test.ts
Normal file
42
src/discord/api.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fetchDiscord } from "./api.js";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
}
|
||||
|
||||
describe("fetchDiscord", () => {
|
||||
it("formats rate limit payloads without raw JSON", async () => {
|
||||
const fetcher = async () =>
|
||||
jsonResponse(
|
||||
{
|
||||
message: "You are being rate limited.",
|
||||
retry_after: 0.631,
|
||||
global: false,
|
||||
},
|
||||
429,
|
||||
);
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
const message = String(error);
|
||||
expect(message).toContain("Discord API /users/@me/guilds failed (429)");
|
||||
expect(message).toContain("You are being rate limited.");
|
||||
expect(message).toContain("retry after 0.6s");
|
||||
expect(message).not.toContain("{");
|
||||
expect(message).not.toContain("retry_after");
|
||||
});
|
||||
|
||||
it("preserves non-JSON error text", async () => {
|
||||
const fetcher = async () => new Response("Not Found", { status: 404 });
|
||||
await expect(
|
||||
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch),
|
||||
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
|
||||
});
|
||||
});
|
||||
61
src/discord/api.ts
Normal file
61
src/discord/api.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
|
||||
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 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 async function fetchDiscord<T>(
|
||||
path: string,
|
||||
token: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<T> {
|
||||
const res = await fetcher(`${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}` : "";
|
||||
throw new Error(`Discord API ${path} failed (${res.status})${suffix}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -1,27 +1,15 @@
|
||||
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
|
||||
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { fetchDiscord } from "./api.js";
|
||||
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
|
||||
type DiscordGuild = { id: string; name: string };
|
||||
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
|
||||
type DiscordMember = { user: DiscordUser; nick?: string | null };
|
||||
type DiscordChannel = { id: string; name?: string | null };
|
||||
|
||||
async function fetchDiscord<T>(path: string, token: string): Promise<T> {
|
||||
const res = await fetch(`${DISCORD_API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
@@ -61,54 +62,6 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
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 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 formatDiscordResolveError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const match = raw.match(/^(Discord API [^]+ failed \(\d+\))(?:\s*:\s*(.*))?$/);
|
||||
if (!match) return raw;
|
||||
const prefix = match[1];
|
||||
const detail = match[2]?.trim();
|
||||
if (!detail) return prefix;
|
||||
const payload = parseDiscordApiErrorPayload(detail);
|
||||
if (!payload) {
|
||||
const looksJson = detail.startsWith("{") && detail.endsWith("}");
|
||||
return looksJson ? `${prefix}: unknown error` : `${prefix}: ${detail}`;
|
||||
}
|
||||
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,
|
||||
);
|
||||
const retryHint = retryAfter ? ` (retry after ${retryAfter})` : "";
|
||||
return `${prefix}: ${message}${retryHint}`;
|
||||
}
|
||||
|
||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const cfg = opts.config ?? loadConfig();
|
||||
const account = resolveDiscordAccount({
|
||||
@@ -245,7 +198,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel resolve failed; using config entries. ${formatDiscordResolveError(err)}`,
|
||||
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -273,7 +226,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
summarizeMapping("discord users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord user resolve failed; using config entries. ${formatDiscordResolveError(err)}`,
|
||||
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -355,9 +308,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
summarizeMapping("discord channel users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel user resolve failed; using config entries. ${formatDiscordResolveError(
|
||||
err,
|
||||
)}`,
|
||||
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { RESTGetAPIChannelResult, RESTGetAPIGuildChannelsResult } from "discord-api-types/v10";
|
||||
|
||||
import { fetchDiscord } from "./api.js";
|
||||
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
|
||||
type DiscordGuildSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -60,17 +59,6 @@ function parseDiscordChannelInput(raw: string): {
|
||||
return { guild: trimmed, guildOnly: true };
|
||||
}
|
||||
|
||||
async function fetchDiscord<T>(path: string, token: string, fetcher: typeof fetch): Promise<T> {
|
||||
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function listGuilds(token: string, fetcher: typeof fetch): Promise<DiscordGuildSummary[]> {
|
||||
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
|
||||
"/users/@me/guilds",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { fetchDiscord } from "./api.js";
|
||||
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
|
||||
type DiscordGuildSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -54,17 +53,6 @@ function parseDiscordUserInput(raw: string): {
|
||||
return { userName: trimmed.replace(/^@/, "") };
|
||||
}
|
||||
|
||||
async function fetchDiscord<T>(path: string, token: string, fetcher: typeof fetch): Promise<T> {
|
||||
const res = await fetcher(`${DISCORD_API_BASE}${path}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Discord API ${path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function listGuilds(token: string, fetcher: typeof fetch): Promise<DiscordGuildSummary[]> {
|
||||
const raw = await fetchDiscord<Array<{ id: string; name: string }>>(
|
||||
"/users/@me/guilds",
|
||||
|
||||
Reference in New Issue
Block a user