import "server-only"; import { discordAvatarDecorationUrl, discordGuildTagBadgeUrl, } from "@/lib/discord/profile-appearance"; import { getDiscordServerConfig } from "@/lib/config/server"; export interface DiscordRole { id: string; name: string; color: number; position: number; } export interface DiscordMemberProfile { id: string; username: string; globalName: string | null; avatarUrl: string | null; avatarDecorationUrl: string | null; bannerUrl: string | null; accentColor: number | null; primaryGuild: { tag: string; badgeUrl: string | null } | null; roles: DiscordRole[]; } export type DiscordSenderProfile = Pick< DiscordMemberProfile, "id" | "username" | "globalName" | "avatarUrl" >; export class DiscordProfileLookupError extends Error { constructor(message = "Discord profile lookup is unavailable") { super(message); this.name = "DiscordProfileLookupError"; } } interface CacheEntry { data: T; expiresAt: number; } const rolesCache: { entry: CacheEntry | null } = { entry: null }; const profileCache = new Map>(); const CACHE_TTL = 60_000; function getCached(entry: CacheEntry | null | undefined): T | null { if (entry && Date.now() < entry.expiresAt) return entry.data; return null; } export async function addDiscordGuildMember(discordId: string, accessToken: string): Promise { if (!discordId || !accessToken) return false; try { const { token, guildId } = getDiscordServerConfig(); const res = await fetch( `https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(discordId)}`, { method: "PUT", headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ access_token: accessToken }), signal: AbortSignal.timeout(5000), }, ); if (res.status === 201 || res.status === 204) return true; const body = await res.text().catch(() => ""); console.error( `Failed to add Discord member ${discordId} to guild ${guildId}: ${res.status}${body ? ` ${body.slice(0, 300)}` : ""}`, ); return false; } catch (error) { console.error(`Failed to add Discord member ${discordId}`, error); return false; } } export async function getGuildRolesInternal(): Promise { const cached = getCached(rolesCache.entry); if (cached) return cached; try { const { token, guildId } = getDiscordServerConfig(); const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, { headers: { Authorization: `Bot ${token}` }, signal: AbortSignal.timeout(5000), }); if (!res.ok) return []; const roles = (await res.json()) as Array<{ id: string; name: string; color: number; position: number; }>; const result = roles .map((role) => ({ id: role.id, name: role.name, color: role.color, position: role.position, })) .sort((a, b) => b.position - a.position); rolesCache.entry = { data: result, expiresAt: Date.now() + CACHE_TTL }; return result; } catch { return []; } } export async function getGuildMemberRoles(discordId: string): Promise { if (!discordId) return []; try { const { token, guildId } = getDiscordServerConfig(); const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, { headers: { Authorization: `Bot ${token}` }, cache: "no-store", signal: AbortSignal.timeout(5000), }); if (!res.ok) return []; const member = (await res.json()) as { roles?: string[] }; return member.roles ?? []; } catch { return []; } } export async function getDiscordMemberProfile(discordId: string): Promise { const cached = getCached(profileCache.get(discordId)); if (cached) return cached; if (!discordId) return null; try { const { token, guildId } = getDiscordServerConfig(); const [memberRes, userRes, allRoles] = await Promise.all([ fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, { headers: { Authorization: `Bot ${token}` }, cache: "no-store", signal: AbortSignal.timeout(5000), }), fetch(`https://discord.com/api/v10/users/${discordId}`, { headers: { Authorization: `Bot ${token}` }, cache: "no-store", signal: AbortSignal.timeout(5000), }), getGuildRolesInternal(), ]); if (!memberRes.ok) return null; type DiscordUserPayload = { id: string; username: string; global_name?: string | null; avatar?: string | null; avatar_decoration_data?: { asset: string; sku_id: string } | null; banner?: string | null; accent_color?: number | null; primary_guild?: { identity_guild_id?: string | null; identity_enabled?: boolean | null; tag?: string | null; badge?: string | null; } | null; }; const member = (await memberRes.json()) as { roles?: string[]; user: DiscordUserPayload; }; const publicUser = userRes.ok ? await userRes.json() as DiscordUserPayload : null; const roles = (member.roles ?? []) .map((id) => allRoles.find((role) => role.id === id)) .filter((role): role is DiscordRole => Boolean(role)) .sort((a, b) => b.position - a.position); const user = { ...member.user, ...publicUser }; const avatarUrl = user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128` : null; const bannerUrl = user.banner ? `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${user.banner.startsWith("a_") ? "gif" : "png"}?size=512` : null; const primaryGuild = user.primary_guild?.identity_enabled && user.primary_guild.identity_guild_id && user.primary_guild.tag ? { tag: user.primary_guild.tag, badgeUrl: discordGuildTagBadgeUrl( user.primary_guild.identity_guild_id, user.primary_guild.badge, ), } : null; const result = { id: user.id, username: user.username, globalName: user.global_name ?? null, avatarUrl, avatarDecorationUrl: discordAvatarDecorationUrl( user.avatar_decoration_data?.asset, ), bannerUrl, accentColor: user.accent_color ?? null, primaryGuild, roles, }; profileCache.set(discordId, { data: result, expiresAt: Date.now() + CACHE_TTL }); return result; } catch (error) { console.error("Failed to fetch member profile", error); return null; } } export async function getDiscordSenderProfile( discordId: string, ): Promise { try { const { token, guildId } = getDiscordServerConfig(); const response = await fetch( `https://discord.com/api/v10/guilds/${encodeURIComponent(guildId)}/members/${encodeURIComponent(discordId)}`, { headers: { Authorization: `Bot ${token}` }, cache: "no-store", signal: AbortSignal.timeout(5000), }, ); if (response.status === 404) return null; if (!response.ok) throw new DiscordProfileLookupError(); const member = (await response.json()) as { user?: { id?: string; username?: string; global_name?: string | null; avatar?: string | null; }; }; const user = member.user; if (!user?.id || !user.username || user.id !== discordId) { throw new DiscordProfileLookupError("Discord returned an invalid sender profile"); } const avatarUrl = user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128` : null; return { id: user.id, username: user.username, globalName: user.global_name ?? null, avatarUrl, }; } catch (error) { if (error instanceof DiscordProfileLookupError) throw error; throw new DiscordProfileLookupError(); } }