Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/discord

src/bot.ts

96 lines · 2.4 KB · TypeScript
import {
	BOT_SECRET_SETUP_URL,
	DISCORD_API_BASE_URL,
	DISCORD_USER_AGENT,
	throwDiscordApiError,
} from './errors.ts'
import { secretName, type InputRecord } from './validation.ts'

export type DiscordRequestInit = {
	method?: string
	headers?: HeadersInit
	body?: BodyInit | null
	query?: Record<string, string | number | undefined>
}

function sleep(ms: number) {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

export function botAuthorizationHeader(name: string): string {
	return 'Bot {{secret:' + name + '|scope=user}}'
}

export async function discordBotRequest(
	path: string,
	input: InputRecord,
	init: DiscordRequestInit = {},
): Promise<unknown> {
	const name = secretName(input)
	const url = new URL(DISCORD_API_BASE_URL + path)
	for (const [key, value] of Object.entries(init.query ?? {})) {
		if (value === undefined) continue
		url.searchParams.set(key, String(value))
	}

	const requestPath = url.pathname + url.search
	for (let attempt = 0; ; attempt += 1) {
		const headers = new Headers(init.headers || {})
		headers.set('Authorization', botAuthorizationHeader(name))
		headers.set('User-Agent', DISCORD_USER_AGENT)
		if (init.body && !headers.has('Content-Type') && typeof init.body === 'string') {
			headers.set('Content-Type', 'application/json')
		}

		let response: Response
		try {
			response = await fetch(url.toString(), {
				method: init.method ?? 'GET',
				headers,
				body: init.body ?? undefined,
			})
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error)
			throw new Error(
				'Discord bot fetch failed before a response: ' +
					message +
					'. Approve host discord.com and save `' +
					name +
					'` at ' +
					BOT_SECRET_SETUP_URL,
			)
		}

		const text = await response.text()
		let json: unknown = null
		if (text) {
			try {
				json = JSON.parse(text)
			} catch {
				json = text
			}
		}

		if (response.status === 429 && attempt === 0) {
			const retryAfter =
				typeof json === 'object' && json && 'retry_after' in json
					? Number((json as { retry_after?: unknown }).retry_after)
					: Number(response.headers.get('retry-after'))
			const waitSeconds = Math.min(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 1, 5)
			await sleep(waitSeconds * 1000)
			continue
		}

		if (!response.ok) {
			throwDiscordApiError({
				status: response.status,
				path: requestPath,
				lane: 'bot',
				details: json ?? text,
				secretName: name,
			})
		}

		return json
	}
}