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 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 }, }) }