/** * @file Discord OAuth helpers for token exchange, user fetching, and URL building. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ import { logger } from "./logger.js"; const discordClientId = "1479551654264049908"; const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback"; interface DiscordTokenResponse { access_token: string; token_type: string; expires_in: number; refresh_token: string; scope: string; } interface DiscordUser { id: string; username: string; discriminator: string; avatar: string | null; } /** * Exchanges a Discord OAuth authorisation code for an access token. * @param code - The authorisation code received from Discord's OAuth callback. * @returns The Discord token response containing the access token. * @throws {Error} If OAuth environment variables are missing or the exchange fails. */ const exchangeCode = async( code: string, ): Promise => { const clientSecret = process.env.DISCORD_CLIENT_SECRET; if (clientSecret === undefined || clientSecret === "") { throw new Error("Discord OAuth environment variables are required"); } const parameters = new URLSearchParams({ client_id: discordClientId, client_secret: clientSecret, code: code, grant_type: "authorization_code", redirect_uri: discordRedirectUri, }); try { const response = await fetch("https://discord.com/api/v10/oauth2/token", { body: parameters.toString(), headers: { "Content-Type": "application/x-www-form-urlencoded" }, method: "POST", }); if (!response.ok) { throw new Error(`Discord token exchange failed: ${response.statusText}`); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordTokenResponse shape */ return await (response.json() as Promise); } catch (error) { void logger.error( "discord_exchange_code", error instanceof Error ? error : new Error(String(error)), ); throw error; } }; /** * Fetches the Discord user profile for the given access token. * @param accessToken - A valid Discord OAuth access token. * @returns The Discord user object. * @throws {Error} If the user fetch fails. */ const fetchDiscordUser = async( accessToken: string, ): Promise => { try { const response = await fetch("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { throw new Error(`Discord user fetch failed: ${response.statusText}`); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */ return await (response.json() as Promise); } catch (error) { void logger.error( "discord_fetch_user", error instanceof Error ? error : new Error(String(error)), ); throw error; } }; /** * Fetches a Discord user's profile by their Discord ID using the bot token. * Returns null on any failure so callers are never blocked by Discord API issues. * @param discordId - The Discord user ID to look up. * @returns The Discord user object, or null if the fetch fails. */ const fetchDiscordUserById = async( discordId: string, ): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; if (botToken === undefined || botToken === "") { return null; } try { const response = await fetch( `https://discord.com/api/v10/users/${discordId}`, { headers: { Authorization: `Bot ${botToken}` } }, ); if (!response.ok) { return null; } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Response JSON matches DiscordUser shape */ return await (response.json() as Promise); } catch (error) { void logger.error( "discord_fetch_user_by_id", error instanceof Error ? error : new Error(String(error)), ); return null; } }; /** * Builds the Discord OAuth authorisation URL. * @returns The full OAuth URL to redirect the user to. * @throws {Error} If OAuth environment variables are missing. */ const buildOAuthUrl = (): string => { const parameters = new URLSearchParams({ client_id: discordClientId, redirect_uri: discordRedirectUri, response_type: "code", scope: "identify", }); return `https://discord.com/api/oauth2/authorize?${parameters.toString()}`; }; export type { DiscordTokenResponse, DiscordUser }; export { buildOAuthUrl, exchangeCode, fetchDiscordUser, fetchDiscordUserById };