154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
/* =====================================================================
|
|
* discord/clientBadges.ts — third-party client-mod badges.
|
|
*
|
|
* badges.equicord.org aggregates badges from Vencord, Equicord, Aliucord,
|
|
* and a bunch of other client mods (Nekocord, ReviewDB, BadgeVault, Aero,
|
|
* Raincord, Velocity, Enmity, Replugged, Paicord) into one "global badges"
|
|
* style response. We just hit the plain GET /:userId (no query params) —
|
|
* that's already the combined view across every service — and cache it,
|
|
* since none of this is ours to rate-limit.
|
|
*
|
|
* This is intentionally kept separate from `badges` (Discord's own
|
|
* flags/profile badges): different source, different trust level, and the
|
|
* caller asked for it to live at `data.clientBadges` instead.
|
|
* ===================================================================== */
|
|
|
|
import type { Env, UnifiedClientBadge } from "../types";
|
|
|
|
const API_BASE = "https://badges.equicord.org";
|
|
|
|
interface EquibadgesResponse {
|
|
status: number;
|
|
/** Flat array (no `separated` query) — one list across all services. */
|
|
badges: { tooltip: string; badge: string }[];
|
|
}
|
|
|
|
function cacheKey(id: string): string {
|
|
return `clientbadges:${id}`;
|
|
}
|
|
|
|
/** Cache freshness window — these change rarely, so an hour is plenty. */
|
|
const TTL_SECONDS = 3600;
|
|
|
|
/**
|
|
* Fetch a user's third-party client-mod badges, cache-first.
|
|
* Returns [] when the user has none, null when the aggregator couldn't be
|
|
* reached and there's nothing usable cached either.
|
|
*/
|
|
export async function getClientBadges(
|
|
env: Env,
|
|
id: string,
|
|
ctx?: ExecutionContext,
|
|
force = false
|
|
): Promise<UnifiedClientBadge[] | null> {
|
|
if (!force) {
|
|
const cached = (await env.PROFILE_CACHE.get(cacheKey(id), "json")) as
|
|
| UnifiedClientBadge[]
|
|
| null;
|
|
if (cached) return cached;
|
|
}
|
|
|
|
const fetched = await fetchClientBadges(id);
|
|
|
|
// 404 ("no badges found") is a valid, cacheable empty result — only a
|
|
// genuine fetch failure (network error, 5xx, etc.) should fall through.
|
|
if (fetched === undefined) {
|
|
if (force) {
|
|
// Caller explicitly asked for a fresh fetch; fall back to whatever's
|
|
// cached (even if stale) rather than returning null outright.
|
|
const stale = (await env.PROFILE_CACHE.get(cacheKey(id), "json")) as
|
|
| UnifiedClientBadge[]
|
|
| null;
|
|
return stale ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const write = env.PROFILE_CACHE.put(cacheKey(id), JSON.stringify(fetched), {
|
|
expirationTtl: TTL_SECONDS,
|
|
});
|
|
if (ctx) ctx.waitUntil(write);
|
|
else await write;
|
|
|
|
return fetched;
|
|
}
|
|
|
|
/** Returns the badge list, [] for none, or undefined on a fetch failure. */
|
|
async function fetchClientBadges(id: string): Promise<UnifiedClientBadge[] | undefined> {
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(`${API_BASE}/${id}`);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
|
|
if (res.status === 404) return [];
|
|
if (!res.ok) return undefined;
|
|
|
|
let data: EquibadgesResponse;
|
|
try {
|
|
data = (await res.json()) as EquibadgesResponse;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
|
|
if (!Array.isArray(data.badges)) return [];
|
|
|
|
return data.badges
|
|
.filter((b) => b && typeof b.badge === "string")
|
|
// The aggregator also throws in official Discord badges (HypeSquad,
|
|
// Nitro, etc) under /public/badges/discord/. Those already live in
|
|
// `data.badges` via Discord's own flags, so drop them here to avoid
|
|
// duplicating them under clientBadges.
|
|
.filter((b) => !/\/public\/badges\/discord\//i.test(b.badge))
|
|
.map((b) => {
|
|
const tooltip = typeof b.tooltip === "string" ? b.tooltip : "";
|
|
return {
|
|
id: badgeId(tooltip, b.badge),
|
|
tooltip,
|
|
icon_url: b.badge,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Host -> display-name table. The equicord endpoint aggregates several
|
|
* underlying services, and the only thing in the response that tells us
|
|
* which one a given badge actually came from is the hostname of its icon
|
|
* URL. Add new services here as we spot them in the wild — anything not
|
|
* listed falls back to "Equicord" (the aggregator's own native badges,
|
|
* e.g. /public/badges/equicord.png, live on that host too).
|
|
*
|
|
* Still TBD / not yet seen in a sample response: ReviewDB, Aero, Aliucord,
|
|
* Raincord, Velocity, Enmity, Replugged, Paicord.
|
|
*/
|
|
const SOURCE_BY_HOST: Record<string, string> = {
|
|
"gb.obamabot.me": "BadgeVault",
|
|
"nekocord.dev": "Neokcord",
|
|
"badges.vencord.dev": "Vencord",
|
|
"badges.equicord.org": "Equicord",
|
|
};
|
|
|
|
/** Falls back to "Equicord" for unknown/malformed hosts. */
|
|
function sourceForIconUrl(iconUrl: string): string {
|
|
try {
|
|
const host = new URL(iconUrl).hostname;
|
|
return SOURCE_BY_HOST[host] ?? "Equicord";
|
|
} catch {
|
|
return "Equicord";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Human-readable id for a badge — the upstream API has no id field of its
|
|
* own (these are arbitrary per-user badges, not a fixed catalog), so we
|
|
* build one from the originating service + tooltip, e.g.
|
|
* "GlobalBadges - Moffman" or "Equicord - Equicord Contributor".
|
|
*
|
|
* Note: this is NOT guaranteed unique — two badges from the same service
|
|
* with the same tooltip text collide. Fine for display/grouping; if exact
|
|
* per-badge identity is ever needed, icon_url is the only unique field.
|
|
*/
|
|
function badgeId(tooltip: string, iconUrl: string): string {
|
|
return `${sourceForIconUrl(iconUrl)} - ${tooltip}`;
|
|
} |