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