import type { JsonObject } from "type-fest" import * as z from "zod" import { parseRetryAfter, retryableActionError, terminalActionError, } from "../../../automation/actions" const DISCORD_API_BASE_URL = "https://discord.com/api/v10/" const DISCORD_API_ORIGIN = new URL(DISCORD_API_BASE_URL).origin const DISCORD_USER_AGENT = "DiscordBot (https://automate.ax, 1.0)" const DISCORD_SECRET_SCHEMA = z.object({ botToken: z.string().min(1) }) const DISCORD_ERROR_SCHEMA = z.looseObject({ code: z.number().int().optional(), global: z.boolean().optional(), message: z.string().optional(), retry_after: z.number().nonnegative().optional(), }) /** Scalar values accepted by Discord query parameters. */ type DiscordQueryValue = boolean | number | string | undefined export interface DiscordRequestOptions { body?: unknown method?: "DELETE" | "GET" | "PATCH" | "POST" | "PUT" query?: Record reason?: string responseSchema: TSchema } interface DiscordApiErrorOptions { code: number | string global?: boolean path: string providerMessage?: string retryAfter?: number status: number } /** Structured Discord REST failure retained by automation runs. */ export class DiscordApiError extends Error { readonly code: number | string readonly details: JsonObject readonly global: boolean readonly path: string readonly providerMessage?: string readonly retryAfter?: number readonly status: number /** * Creates a structured Discord REST failure. * * @param options - Provider response metadata. */ constructor(options: DiscordApiErrorOptions) { super( options.providerMessage ? `Discord API error (${options.status}): ${options.providerMessage}` : `Discord API request failed with status ${options.status}.`, ) this.name = "DiscordApiError" this.code = options.code this.global = options.global ?? false this.path = options.path this.providerMessage = options.providerMessage this.retryAfter = options.retryAfter this.status = options.status this.details = { code: options.code, global: this.global, path: options.path, status: options.status, ...(options.retryAfter === undefined ? {} : { retryAfter: options.retryAfter }), } } } /** * Creates an authenticated Discord REST client for packaged actions. * * @param secret - Connected Discord account secret. */ export function getDiscordApi(secret: Record) { const { botToken } = DISCORD_SECRET_SCHEMA.parse(secret) return { request: async ( path: string, options: DiscordRequestOptions, ): Promise> => { const url = new URL(path.replace(/^\//, ""), DISCORD_API_BASE_URL) if (url.origin !== DISCORD_API_ORIGIN) { throw new Error("Discord API paths must use the Discord API origin.") } for (const [name, value] of Object.entries(options.query ?? {})) { if (value !== undefined) url.searchParams.set(name, String(value)) } const response = await fetch(url, { body: options.body === undefined ? undefined : JSON.stringify(options.body), headers: { Accept: "application/json", Authorization: `Bot ${botToken}`, "User-Agent": DISCORD_USER_AGENT, ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), ...(options.reason === undefined ? {} : { "X-Audit-Log-Reason": encodeURIComponent(options.reason) }), }, method: options.method ?? "GET", }) const text = await response.text() const payload = parseJson(text) if (!response.ok) { const providerError = DISCORD_ERROR_SCHEMA.safeParse(payload) const retryAfter = providerError.success && providerError.data.retry_after !== undefined ? providerError.data.retry_after : secondsUntil(parseRetryAfter(response.headers.get("Retry-After"))) const error = new DiscordApiError({ code: providerError.success && providerError.data.code !== undefined ? providerError.data.code : `http_${response.status}`, global: providerError.success && providerError.data.global, path: url.pathname, providerMessage: providerError.success ? providerError.data.message : text || undefined, retryAfter, status: response.status, }) if (response.status === 429) { throw retryableActionError(error, { retryAt: retryAfter === undefined ? undefined : new Date(Date.now() + retryAfter * 1_000), }) } if (response.status < 500) throw terminalActionError(error) throw error } if (!text) return options.responseSchema.parse(undefined) return options.responseSchema.parse(payload) }, } } /** * Parses a response body when it contains JSON. * * @param value - Raw response text. */ function parseJson(value: string): unknown { if (!value) return undefined try { return JSON.parse(value) as unknown } catch { return value } } /** * Converts an absolute retry time to whole seconds. * * @param retryAt - Provider retry time. */ function secondsUntil(retryAt: Date | undefined) { return retryAt ? Math.max(0, Math.ceil((retryAt.getTime() - Date.now()) / 1_000)) : undefined }