Skip to content

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

Package listing

@kody/discord

src/errors.ts

203 lines · 7.0 KB · TypeScript
import type { DiscordLane } from './validation.ts'

export const DISCORD_API_BASE_URL = 'https://discord.com/api/v10'
export const DISCORD_USER_AGENT = 'Kody (@kody/discord)'
export const DISCORD_HOST = 'discord.com'

export const OAUTH_CONNECT_URL =
	'https://kody.codes/connect/oauth?provider=discord&authorizeUrl=https%3A%2F%2Fdiscord.com%2Foauth2%2Fauthorize&tokenUrl=https%3A%2F%2Fdiscord.com%2Fapi%2Foauth2%2Ftoken&flow=confidential&scopes=identify%20guilds'

export const BOT_SECRET_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=discordBotToken&description=Discord%20bot%20token&allowedHosts=discord.com&scope=user'

export const BOT_INSTALL_URL =
	'https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&scope=bot&permissions=68672'

export type DiscordJson = Record<string, unknown>

export class DiscordApiError extends Error {
	readonly status: number
	readonly code: number | null
	readonly path: string
	readonly lane: DiscordLane
	readonly missingScope: string | null
	readonly nextStep: string
	readonly details: unknown

	constructor(input: {
		message: string
		status: number
		code: number | null
		path: string
		lane: DiscordLane
		missingScope: string | null
		nextStep: string
		details: unknown
	}) {
		super(input.message)
		this.name = 'DiscordApiError'
		this.status = input.status
		this.code = input.code
		this.path = input.path
		this.lane = input.lane
		this.missingScope = input.missingScope
		this.nextStep = input.nextStep
		this.details = input.details
	}
}

function asRecord(value: unknown): DiscordJson | null {
	if (value && typeof value === 'object' && !Array.isArray(value)) return value as DiscordJson
	return null
}

function discordCode(details: unknown): number | null {
	const record = asRecord(details)
	return typeof record?.code === 'number' ? record.code : null
}

function discordMessage(details: unknown, status: number): string {
	const record = asRecord(details)
	if (typeof record?.message === 'string' && record.message.trim()) return record.message
	if (typeof details === 'string' && details.trim()) return details
	return 'HTTP ' + status
}

export function inferMissingOauthScope(path: string, status: number, message: string): string | null {
	if (path.includes('/users/@me/guilds/') && path.includes('/member')) return 'guilds.members.read'
	if (path.includes('/users/@me/guilds')) return 'guilds'
	if (path.includes('/users/@me/connections')) return 'connections'
	if (/email/i.test(message)) return 'email'
	if (status === 401 || status === 403 || /scope|identify/i.test(message)) return 'identify'
	return null
}

function oauthConnectUrl(integration: string, extraScopes: string[] = []): string {
	const scopes = ['identify', 'guilds', ...extraScopes].filter((scope, index, all) => all.indexOf(scope) === index)
	const provider = encodeURIComponent(integration)
	return (
		'https://kody.codes/connect/oauth?provider=' +
		provider +
		'&authorizeUrl=https%3A%2F%2Fdiscord.com%2Foauth2%2Fauthorize&tokenUrl=https%3A%2F%2Fdiscord.com%2Fapi%2Foauth2%2Ftoken&flow=confidential&scopes=' +
		encodeURIComponent(scopes.join(' '))
	)
}

export function botSecretSetupUrl(secretName: string): string {
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=Discord%20bot%20token&allowedHosts=discord.com&scope=user'
	)
}

export function throwDiscordApiError(input: {
	status: number
	path: string
	lane: DiscordLane
	details: unknown
	secretName?: string
	integration?: string
}): never {
	const code = discordCode(input.details)
	const apiMessage = discordMessage(input.details, input.status)
	let missingScope: string | null = null
	let nextStep: string
	let message: string

	if (input.lane === 'oauth') {
		missingScope = inferMissingOauthScope(input.path, input.status, apiMessage)
		const connectUrl = oauthConnectUrl(input.integration ?? 'discord', missingScope && missingScope !== 'identify' ? [missingScope] : [])
		if (input.status === 401) {
			message =
				'Discord OAuth request failed (401). Access tokens expire after 7 days. Reconnect the `' +
				(input.integration ?? 'discord') +
				'` integration so Kody can refresh: ' +
				connectUrl
			nextStep = connectUrl
		} else if (missingScope) {
			message =
				'Discord OAuth request failed (' +
				input.status +
				'): missing `' +
				missingScope +
				'` scope. Reconnect with that scope at ' +
				connectUrl +
				'. Original: ' +
				apiMessage
			nextStep = connectUrl
		} else {
			message = 'Discord OAuth request failed (' + input.status + '): ' + apiMessage + '. Reconnect at ' + connectUrl
			nextStep = connectUrl
		}
	} else {
		const secretUrl = botSecretSetupUrl(input.secretName ?? 'discordBotToken')
		if (input.status === 401) {
			message =
				'Discord bot request failed (401). Use the `Bot ` prefix (this package does) and confirm the `' +
				(input.secretName ?? 'discordBotToken') +
				'` secret was not reset. Save or rotate it at ' +
				secretUrl
			nextStep = secretUrl
		} else if (code === 50001 || /missing access/i.test(apiMessage)) {
			message =
				'Discord bot request failed (403 Missing Access, code 50001): the bot is not in that server or cannot see the channel. Reinstall with the bot scope and permissions (view channel, send messages, read history, add reactions): ' +
				BOT_INSTALL_URL +
				'. Then retry. Original: ' +
				apiMessage
			nextStep = BOT_INSTALL_URL
		} else if (code === 50013 || /missing permissions/i.test(apiMessage)) {
			message =
				'Discord bot request failed (403 Missing Permissions, code 50013): the bot role lacks the channel permission for ' +
				input.path +
				'. Grant Send Messages, Read Message History, Add Reactions, and/or Create Public Threads as needed, and enable the privileged Message Content intent if you need message text. Reinstall helper: ' +
				BOT_INSTALL_URL +
				'. Original: ' +
				apiMessage
			nextStep = BOT_INSTALL_URL
		} else if (input.status === 403) {
			message =
				'Discord bot request failed (403): ' +
				apiMessage +
				'. If this is a server/channel the bot should reach, reinstall it (' +
				BOT_INSTALL_URL +
				') and confirm Message Content intent is enabled for reading message text.'
			nextStep = BOT_INSTALL_URL
		} else {
			message = 'Discord bot request failed (' + input.status + '): ' + apiMessage
			nextStep = secretUrl
		}
	}

	throw new DiscordApiError({
		message,
		status: input.status,
		code,
		path: input.path,
		lane: input.lane,
		missingScope,
		nextStep,
		details: input.details,
	})
}

export function missingOauthIntegrationError(integration: string, cause: unknown): DiscordApiError {
	const connectUrl = oauthConnectUrl(integration)
	const causeMessage = cause instanceof Error ? cause.message : String(cause)
	return new DiscordApiError({
		message:
			'Could not load Discord OAuth integration `' +
			integration +
			'`. Connect it at ' +
			connectUrl +
			' (redirect URI https://kody.codes/connect/oauth, scopes `identify guilds`). ' +
			causeMessage,
		status: 0,
		code: null,
		path: '/users/@me',
		lane: 'oauth',
		missingScope: 'identify',
		nextStep: connectUrl,
		details: { cause: causeMessage },
	})
}