From 6a3487e6dad9483e31981daa2d1f56aa9afaa8cf Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Sat, 31 Oct 2026 01:41:54 +0100 Subject: [PATCH] Share member list refreshes, fall back to stale cache and retry on Discord rate limits Co-Authored-By: Claude Opus 5.5 --- src/app/lib/discord.ts | 70 ++++++++++++++++---------------- src/app/lib/members.ts | 92 ++++++++++++++++++------------------------ 2 files changed, 75 insertions(+), 87 deletions(-) diff --git a/src/app/lib/discord.ts b/src/app/lib/discord.ts index 0eb7dd9..138e611 100644 --- a/src/app/lib/discord.ts +++ b/src/app/lib/discord.ts @@ -1,50 +1,50 @@ import type { DiscordGuildMember } from "@/types"; -export async function verifyUserRole(userId: string): Promise { - const guildId = process.env.DISCORD_GUILD_ID; - const requiredRoleId = process.env.DISCORD_REQUIRED_ROLE_ID; +const DISCORD_API_URL = "https://discord.com/api/v10"; +const MAX_ATTEMPTS = 3; +const MAX_RETRY_DELAY_SECONDS = 10; - if (!guildId || !requiredRoleId) { - console.error("Missing DISCORD_GUILD_ID or DISCORD_REQUIRED_ROLE_ID environment variables"); - return false; +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name} environment variable`); } + return value; +} - try { - // Get the bot token from the Discord application - const botToken = process.env.DISCORD_BOT_TOKEN; - if (!botToken) { - console.error("Missing DISCORD_BOT_TOKEN environment variable"); - return false; - } +export async function discordFetch(path: string): Promise { + const botToken = requireEnv("DISCORD_BOT_TOKEN"); - // Fetch the user's guild member information - const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${userId}`, { - headers: { - Authorization: `Bot ${botToken}`, - "Content-Type": "application/json", - }, + for (let attempt = 1; ; attempt++) { + const response = await fetch(`${DISCORD_API_URL}${path}`, { + headers: { Authorization: `Bot ${botToken}` }, }); + if (response.status !== 429 || attempt === MAX_ATTEMPTS) { + return response; + } + + const retryAfter = Math.min(Number(response.headers.get("retry-after")) || 1, MAX_RETRY_DELAY_SECONDS); + await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); + } +} + +export async function verifyUserRole(userId: string): Promise { + try { + const guildId = requireEnv("DISCORD_GUILD_ID"); + const requiredRoleId = requireEnv("DISCORD_REQUIRED_ROLE_ID"); + + const response = await discordFetch(`/guilds/${guildId}/members/${userId}`); + + if (response.status === 404) { + return false; + } if (!response.ok) { - if (response.status === 404) { - // User is not a member of the guild - console.log(`User ${userId} is not a member of guild ${guildId}`); - return false; - } - if (response.status === 403) { - console.error("Bot does not have permission to view guild members"); - return false; - } - throw new Error(`Discord API error: ${response.status} ${response.statusText}`); + throw new Error(`Failed to fetch guild member ${userId}: ${response.status} ${response.statusText}`); } const member: DiscordGuildMember = await response.json(); - - // Check if the user has the required role - const hasRole = member.roles.includes(requiredRoleId); - console.log(`User ${userId} has role ${requiredRoleId}: ${hasRole}`); - - return hasRole; + return member.roles.includes(requiredRoleId); } catch (error) { console.error("Error verifying user role:", error); return false; diff --git a/src/app/lib/members.ts b/src/app/lib/members.ts index da979b8..0062aa7 100644 --- a/src/app/lib/members.ts +++ b/src/app/lib/members.ts @@ -1,68 +1,56 @@ +import { discordFetch, requireEnv } from "@/lib/discord"; import type { DiscordGuildMember, Match } from "@/types"; -interface CacheEntry { - data: DiscordGuildMember[]; - timestamp: number; -} +const CACHE_TTL = 5 * 60 * 1000; +const PAGE_SIZE = 1000; -// In-memory cache for Discord API responses -let discordMembersCache: CacheEntry | null = null; -const CACHE_TTL = 5 * 60 * 1000; // 5 minutes in milliseconds +let membersCache: { members: DiscordGuildMember[]; fetchedAt: number } | null = null; +let pendingFetch: Promise | null = null; -async function getDiscordMembers(): Promise { - const guildId = process.env.DISCORD_GUILD_ID; - const botToken = process.env.DISCORD_BOT_TOKEN; - - if (!guildId || !botToken) { - throw new Error("Missing required Discord environment variables"); - } - - // Check if cache is valid - if (discordMembersCache && Date.now() - discordMembersCache.timestamp < CACHE_TTL) { - return discordMembersCache.data; - } - - // Fetch fresh data from Discord API with pagination - const allMembers: DiscordGuildMember[] = []; - let after: string | undefined = undefined; - const limit = 1000; // Maximum allowed by Discord API - - do { - const url = new URL(`https://discord.com/api/v10/guilds/${guildId}/members`); - url.searchParams.set("limit", limit.toString()); - if (after) { - url.searchParams.set("after", after); - } - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bot ${botToken}`, - "Content-Type": "application/json", - }, - }); +async function fetchAllMembers(): Promise { + const guildId = requireEnv("DISCORD_GUILD_ID"); + const members: DiscordGuildMember[] = []; + let after = "0"; + for (;;) { + const response = await discordFetch(`/guilds/${guildId}/members?limit=${PAGE_SIZE}&after=${after}`); if (!response.ok) { throw new Error(`Failed to fetch guild members: ${response.status} ${response.statusText}`); } - const members: DiscordGuildMember[] = await response.json(); - allMembers.push(...members); + const page: DiscordGuildMember[] = await response.json(); + members.push(...page); - // Update 'after' for next page (use the last member's user ID) - if (members.length === limit) { - after = members[members.length - 1].user.id; - } else { - after = undefined; // No more pages + if (page.length < PAGE_SIZE) { + return members; } - } while (after); + after = page[page.length - 1].user.id; + } +} - // Update cache - discordMembersCache = { - data: allMembers, - timestamp: Date.now(), - }; +async function getDiscordMembers(): Promise { + if (membersCache && Date.now() - membersCache.fetchedAt < CACHE_TTL) { + return membersCache.members; + } - return allMembers; + pendingFetch ??= fetchAllMembers() + .then((members) => { + membersCache = { members, fetchedAt: Date.now() }; + return members; + }) + .finally(() => { + pendingFetch = null; + }); + + try { + return await pendingFetch; + } catch (error) { + if (!membersCache) { + throw error; + } + console.error("Failed to refresh guild members, using stale cache:", error); + return membersCache.members; + } } function getDisplayName(member: DiscordGuildMember): string {