restful/src/discord/rest.ts

104 lines
3.3 KiB
TypeScript

/* =====================================================================
* discord/rest.ts — thin Discord REST client.
*
* Two callers:
* fetchBotUser() — bot token, /users/:id (basic, always safe)
* fetchUserProfile() — user token, /users/:id/profile (rich, ToS risk)
* ===================================================================== */
import type { Env } from "../types";
function apiBase(env: Env): string {
const v = env.DISCORD_API_VERSION || "10";
return `https://discord.com/api/v${v}`;
}
export interface RawDiscordUser {
id: string;
username: string;
global_name?: string | null;
display_name?: string | null;
avatar: string | null;
banner?: string | null;
accent_color?: number | null;
public_flags?: number;
flags?: number;
avatar_decoration_data?: { asset: string; sku_id?: string | null } | null;
primary_guild?: {
identity_guild_id?: string | null;
identity_enabled?: boolean | null;
tag?: string | null;
badge?: string | null;
} | null;
collectibles?: Record<string, unknown> | null;
discriminator?: string;
display_name_styles?: {
colors?: number[] | null;
font_id?: number | null;
effect_id?: number | null;
} | null;
}
export interface RawProfileBadge {
id: string;
description: string;
icon: string;
link?: string;
}
export interface RawProfileResponse {
user?: RawDiscordUser & { bio?: string };
user_profile?: {
bio?: string;
pronouns?: string;
accent_color?: number | null;
theme_colors?: number[] | null;
};
badges?: RawProfileBadge[];
connected_accounts?: Array<{ type: string; id: string; name: string; verified: boolean }>;
premium_type?: number;
premium_since?: string | null;
premium_guild_since?: string | null;
}
/** Basic user via bot token. Returns null on 404 / failure. */
export async function fetchBotUser(env: Env, id: string): Promise<RawDiscordUser | null> {
const res = await fetch(`${apiBase(env)}/users/${id}`, {
headers: { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}` },
});
if (!res.ok) return null;
return (await res.json()) as RawDiscordUser;
}
export interface UserProfileFetch {
data: RawProfileResponse | null;
/** HTTP status (0 = not attempted / no token). */
status: number;
/** Seconds from a 429 Retry-After header, when present. */
retryAfter: number;
}
/**
* Rich profile via USER token (self-bot — ToS risk). Reports the HTTP status so
* callers can tell a 429 rate-limit (back off) apart from a 401/403 token issue,
* rather than silently degrading to the bot token.
*/
export async function fetchUserProfile(env: Env, id: string): Promise<UserProfileFetch> {
if (!env.DISCORD_USER_TOKEN) return { data: null, status: 0, retryAfter: 0 };
const url =
`${apiBase(env)}/users/${id}/profile` +
`?with_mutual_guilds=false&with_mutual_friends=false`;
const res = await fetch(url, {
headers: { Authorization: env.DISCORD_USER_TOKEN },
});
if (!res.ok) {
const retryAfter = Number(res.headers.get("retry-after")) || 0;
console.warn(
`[dough-restful] user-token /users/${id}/profile -> HTTP ${res.status}` +
(retryAfter ? ` (retry ${retryAfter}s)` : "")
);
return { data: null, status: res.status, retryAfter };
}
return { data: (await res.json()) as RawProfileResponse, status: 200, retryAfter: 0 };
}