Files
Mace-AIO-Discord-Bot---With…/dashboard/lib/guild-auth.ts

122 lines
3.4 KiB
TypeScript

/**
* Discord guild permission helpers for dashboard access control.
* Includes short-lived cache + 429 retry to avoid Discord rate limits.
*/
export interface DiscordGuild {
id: string;
name?: string;
icon?: string | null;
permissions: string;
owner?: boolean;
}
const MANAGE_GUILD = BigInt(0x20);
const ADMINISTRATOR = BigInt(0x8);
const CACHE_TTL_MS = 60_000;
const MAX_RETRIES = 3;
type CacheEntry = { guilds: DiscordGuild[]; expiresAt: number };
const guildCache = new Map<string, CacheEntry>();
const inflight = new Map<string, Promise<DiscordGuild[]>>();
export function canManageGuild(guild: DiscordGuild): boolean {
if (guild.owner) return true;
try {
const perms = BigInt(guild.permissions);
return (
(perms & ADMINISTRATOR) === ADMINISTRATOR ||
(perms & MANAGE_GUILD) === MANAGE_GUILD
);
} catch {
return false;
}
}
function cacheKey(accessToken: string): string {
// Avoid storing raw token as object key longer than needed — short fingerprint is enough for process cache
return `tok:${accessToken.slice(0, 12)}:${accessToken.length}:${accessToken.slice(-8)}`;
}
export function invalidateUserGuildsCache(accessToken?: string) {
if (!accessToken) {
guildCache.clear();
return;
}
guildCache.delete(cacheKey(accessToken));
}
async function fetchUserGuildsUncached(accessToken: string): Promise<DiscordGuild[]> {
let attempt = 0;
while (attempt < MAX_RETRIES) {
attempt += 1;
const res = await fetch("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
if (res.ok) {
return (await res.json()) as DiscordGuild[];
}
if (res.status === 429) {
let retryAfter = 1;
try {
const body = await res.json();
retryAfter = Number(body.retry_after ?? 1);
} catch {
const header = res.headers.get("retry-after");
retryAfter = header ? Number(header) : 1;
}
const waitMs = Math.min(Math.max(retryAfter * 1000, 300), 5000);
console.warn(`Discord guilds rate-limited — retry in ${waitMs}ms (attempt ${attempt}/${MAX_RETRIES})`);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
const body = await res.text().catch(() => "");
console.error("Discord guilds fetch failed:", res.status, body);
throw new Error(
res.status === 401
? "Discord session expired — please sign in again."
: `Failed to fetch your Discord servers. (${res.status})`
);
}
throw new Error("Failed to fetch your Discord servers. (rate limited)");
}
export async function fetchUserGuilds(accessToken: string): Promise<DiscordGuild[]> {
const key = cacheKey(accessToken);
const cached = guildCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.guilds;
}
const existing = inflight.get(key);
if (existing) return existing;
const promise = fetchUserGuildsUncached(accessToken)
.then((guilds) => {
guildCache.set(key, { guilds, expiresAt: Date.now() + CACHE_TTL_MS });
return guilds;
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
export async function userCanManageGuild(
accessToken: string,
guildId: string
): Promise<boolean> {
const guilds = await fetchUserGuilds(accessToken);
const guild = guilds.find((g) => String(g.id) === String(guildId));
return guild ? canManageGuild(guild) : false;
}