From 80e6c070bfc2157f1e2d064a5d8742fa3cefd266 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 17:28:15 +0000 Subject: [PATCH] refactor: centralize discord api errors --- src/discord/api.test.ts | 42 +++++++++++++++++++++++ src/discord/api.ts | 61 +++++++++++++++++++++++++++++++++ src/discord/directory-live.ts | 14 +------- src/discord/monitor/provider.ts | 57 +++--------------------------- src/discord/resolve-channels.ts | 14 +------- src/discord/resolve-users.ts | 14 +------- 6 files changed, 110 insertions(+), 92 deletions(-) create mode 100644 src/discord/api.test.ts create mode 100644 src/discord/api.ts diff --git a/src/discord/api.test.ts b/src/discord/api.test.ts new file mode 100644 index 000000000..9ba2d2f68 --- /dev/null +++ b/src/discord/api.test.ts @@ -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"); + }); +}); diff --git a/src/discord/api.ts b/src/discord/api.ts new file mode 100644 index 000000000..6be8b4a0b --- /dev/null +++ b/src/discord/api.ts @@ -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( + path: string, + token: string, + fetcher: typeof fetch = fetch, +): Promise { + 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; +} diff --git a/src/discord/directory-live.ts b/src/discord/directory-live.ts index 45d32f410..4c466627a 100644 --- a/src/discord/directory-live.ts +++ b/src/discord/directory-live.ts @@ -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(path: string, token: string): Promise { - 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() ?? ""; } diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index d8913adbf..a048fdd24 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -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) { 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)}`, ); } } diff --git a/src/discord/resolve-channels.ts b/src/discord/resolve-channels.ts index a37c598e1..cbc1d7dd1 100644 --- a/src/discord/resolve-channels.ts +++ b/src/discord/resolve-channels.ts @@ -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(path: string, token: string, fetcher: typeof fetch): Promise { - 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 { const raw = await fetchDiscord>( "/users/@me/guilds", diff --git a/src/discord/resolve-users.ts b/src/discord/resolve-users.ts index 65fe6db74..1956ee862 100644 --- a/src/discord/resolve-users.ts +++ b/src/discord/resolve-users.ts @@ -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(path: string, token: string, fetcher: typeof fetch): Promise { - 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 { const raw = await fetchDiscord>( "/users/@me/guilds",