Wishlist stuff

This commit is contained in:
Clove 2026-06-20 16:20:54 +01:00
parent 4c0da234c9
commit ef7b85b50d
5 changed files with 265 additions and 118 deletions

View File

@ -24,8 +24,15 @@ export const Op = {
*/ */
export const INTENTS = (1 << 0) | (1 << 1) | (1 << 8); // 259 export const INTENTS = (1 << 0) | (1 << 1) | (1 << 8); // 259
/** Classic public-flag badges: [bit, id, description, badge-icons hash]. */ /**
export const FLAG_BADGES: ReadonlyArray<[number, string, string, string]> = [ * Public user flags (the `public_flags` bitfield). Single source of truth:
* [bit, id, description, badge-icons hash | null]. Entries with a hash render
* as a classic profile badge (see FLAG_BADGES); the rest are flags with no
* badge art (team user, verified bot, spammer, ) that we still surface.
* Bits/names from Discord's documented User Flags. 32-bit safe (all public
* flags are <= bit 23).
*/
export const PUBLIC_FLAGS: ReadonlyArray<[number, string, string, string | null]> = [
[1 << 0, "staff", "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"], [1 << 0, "staff", "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"],
[1 << 1, "partner", "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"], [1 << 1, "partner", "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"],
[1 << 2, "hypesquad", "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"], [1 << 2, "hypesquad", "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"],
@ -34,12 +41,46 @@ export const FLAG_BADGES: ReadonlyArray<[number, string, string, string]> = [
[1 << 7, "hypesquad_house_2", "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"], [1 << 7, "hypesquad_house_2", "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"],
[1 << 8, "hypesquad_house_3", "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"], [1 << 8, "hypesquad_house_3", "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"],
[1 << 9, "premium_early_supporter", "Early Supporter", "7060786766c9c840eb3019e725d2b358"], [1 << 9, "premium_early_supporter", "Early Supporter", "7060786766c9c840eb3019e725d2b358"],
[1 << 10, "team_pseudo_user", "Team User", null],
[1 << 12, "system", "System", null],
[1 << 14, "bug_hunter_level_2", "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"], [1 << 14, "bug_hunter_level_2", "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"],
[1 << 16, "verified_bot", "Verified Bot", null],
[1 << 17, "verified_developer", "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"], [1 << 17, "verified_developer", "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"],
[1 << 18, "certified_moderator", "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"], [1 << 18, "certified_moderator", "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"],
[1 << 19, "bot_http_interactions", "HTTP Interactions Bot", null],
[1 << 20, "spammer", "Likely Spammer", null],
[1 << 22, "active_developer", "Active Developer", "6bdc42827a38498929a4920da12695d9"], [1 << 22, "active_developer", "Active Developer", "6bdc42827a38498929a4920da12695d9"],
[1 << 23, "provisional_account", "Provisional Account", null],
]; ];
/** The subset of PUBLIC_FLAGS that have badge art: [bit, id, description, hash]. */
export const FLAG_BADGES = PUBLIC_FLAGS.filter(
(f): f is [number, string, string, string] => f[3] !== null
);
/**
* Decode a `public_flags` bitfield into a named list. Known flags get their
* id + name; any *unknown* set bit (a flag Discord added that we haven't named
* yet) is surfaced as `unknown_<bit>` so new flags/badges show up immediately.
*/
export function decodeUserFlags(flags: number): Array<{ id: string; name: string }> {
flags = Number(flags) || 0;
const out: Array<{ id: string; name: string }> = [];
let known = 0;
for (const [bit, id, name] of PUBLIC_FLAGS) {
known |= bit;
if (flags & bit) out.push({ id, name });
}
// Forward-compat: report set bits we don't recognise (scan the 32-bit range).
for (let b = 0; b < 31; b++) {
const bit = 1 << b;
if ((flags & bit) && !(known & bit)) {
out.push({ id: `unknown_${b}`, name: `Unknown flag (bit ${b})` });
}
}
return out;
}
export function isAnimated(hash: string | null | undefined): boolean { export function isAnimated(hash: string | null | undefined): boolean {
return typeof hash === "string" && hash.startsWith("a_"); return typeof hash === "string" && hash.startsWith("a_");
} }

View File

@ -134,9 +134,8 @@ export interface RawProfileResponse {
premium_type?: number; premium_type?: number;
premium_since?: string | null; premium_since?: string | null;
premium_guild_since?: string | null; premium_guild_since?: string | null;
/** Profile wishlist: map of collectible SKU id -> per-user settings. The /** Profile wishlist: map of WISHLIST id -> per-wishlist settings. The items
* product details (name/images) are NOT here resolve each SKU separately * themselves are NOT here fetch them with fetchWishlist(wishlistId). */
* via fetchCollectibleProduct. */
wishlist_settings?: Record<string, { visibility?: number; updated_at?: string }>; wishlist_settings?: Record<string, { visibility?: number; updated_at?: string }>;
} }
@ -202,42 +201,59 @@ export async function fetchUserProfile(env: Env, id: string): Promise<UserProfil
return { data: null, status: lastStatus, retryAfter: lastRetryAfter }; return { data: null, status: lastStatus, retryAfter: lastRetryAfter };
} }
// ---- collectible products (resolve wishlist SKUs -> name + images) ------ // ---- wishlist (the profile's wishlist_settings key is a WISHLIST id) -----
export interface CollectibleFetch { export interface WishlistFetch {
/** Raw collectible product JSON; null on failure. */ /** Raw wishlist JSON ({ id, user_id, wishlist_items: [...] }); null on fail. */
raw: unknown | null; raw: any | null;
/** HTTP status (0 = not attempted / no token). */ /** HTTP status (0 = not attempted / no token). */
status: number; status: number;
} }
/** /** One GET attempt; reads body as text so failures show in `wrangler tail`. */
* Resolve one collectible SKU to its product (name, type, item image assets). async function tryJson(url: string, headers: Record<string, string>, label: string): Promise<WishlistFetch> {
* The Shop product endpoint is read with the user token + client fingerprint const res = await fetch(url, { headers });
* (same path as the rich profile); if no user token is configured we fall back const text = await res.text().catch(() => "");
* to the bot token, which is enough for many Shop reads. Product metadata is if (res.ok) {
* effectively static, so callers cache the result per SKU. try {
*/ return { raw: JSON.parse(text), status: 200 };
export async function fetchCollectibleProduct(env: Env, skuId: string): Promise<CollectibleFetch> { } catch {
// country_code is hardcoded to GB — product name/images don't vary by region, console.warn(`[dough-restful] ${label} 200 non-JSON: ${text.slice(0, 100)}`);
// it's only here because the Shop endpoint expects the param. return { raw: null, status: 200 };
const params = new URLSearchParams({ include_bundles: "true", country_code: "GB" }); }
const url = `${apiBase(env)}/collectibles-products/${skuId}?${params.toString()}`;
// Prefer user token(s) with the client fingerprint; fail over on a 429.
const tokens = userTokens(env);
const start = tokens.length ? Math.floor(Math.random() * tokens.length) : 0;
let lastStatus = 0;
for (let i = 0; i < tokens.length; i++) {
const idx = (start + i) % tokens.length;
const res = await fetch(url, { headers: clientHeaders(env, tokens[idx]) });
if (res.ok) return { raw: await res.json().catch(() => null), status: 200 };
lastStatus = res.status;
if (res.status !== 429) break;
} }
console.warn(`[dough-restful] ${label} HTTP ${res.status}: ${text.slice(0, 140)}`);
// Bot-token fallback (works without a user token in many cases). return { raw: null, status: res.status };
const res = await fetch(url, { headers: { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}` } }); }
if (res.ok) return { raw: await res.json().catch(() => null), status: 200 };
return { raw: null, status: lastStatus || res.status }; /**
* Fetch a user's wishlist by its id (the key inside the profile's
* `wishlist_settings`). GET /wishlists/{id} returns every item already
* resolved (names + collectible image data), so no per-item lookups are
* needed. User token + client fingerprint first, bot token as a fallback;
* configured API version then v10.
*/
export async function fetchWishlist(env: Env, wishlistId: string): Promise<WishlistFetch> {
const configured = env.DISCORD_API_VERSION || "10";
const versions = configured === "10" ? ["10"] : [configured, "10"];
const tokens = userTokens(env);
let lastStatus = 0;
for (const ver of versions) {
const url = `https://discord.com/api/v${ver}/wishlists/${wishlistId}`;
for (let i = 0; i < tokens.length; i++) {
const r = await tryJson(url, clientHeaders(env, tokens[i]), `wishlist ${wishlistId} v${ver} user#${i + 1}`);
if (r.raw) return r;
lastStatus = r.status;
if (r.status !== 429 && r.status !== 404) break;
}
const rb = await tryJson(
url,
{ Authorization: `Bot ${env.DISCORD_BOT_TOKEN}` },
`wishlist ${wishlistId} v${ver} bot`
);
if (rb.raw) return rb;
lastStatus = rb.status || lastStatus;
}
return { raw: null, status: lastStatus };
} }

View File

@ -68,6 +68,7 @@ export default {
data: { data: {
service: "dough-restful", service: "dough-restful",
description: "Combined Discord presence + profile/badges API.", description: "Combined Discord presence + profile/badges API.",
repository_url: "https://git.gay/doughmination/dough-restful",
endpoints: ["/v1/users/:id", "/v1/users/:id/presence", "/v1/users/:id/profile", "/socket"], endpoints: ["/v1/users/:id", "/v1/users/:id/presence", "/v1/users/:id/profile", "/socket"],
} as any, } as any,
}); });
@ -82,6 +83,13 @@ export default {
return json({ success: false, error: { code: "invalid_id", message: "Not a Discord snowflake." } }, 400); return json({ success: false, error: { code: "invalid_id", message: "Not a Discord snowflake." } }, 400);
} }
// Debug/ops escape hatch: ?fresh=1 (or nocache / refresh) bypasses the
// profile + per-SKU caches and forces a live re-fetch + re-resolve.
const force =
url.searchParams.has("fresh") ||
url.searchParams.has("nocache") ||
url.searchParams.has("refresh");
if (sub === "presence") { if (sub === "presence") {
const presence = await fetchPresence(env, id); const presence = await fetchPresence(env, id);
if (!presence) { if (!presence) {
@ -91,7 +99,7 @@ export default {
} }
if (sub === "profile") { if (sub === "profile") {
const profile = await getProfile(env, id, ctx); const profile = await getProfile(env, id, ctx, force);
if (!profile) { if (!profile) {
return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404); return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404);
} }
@ -99,7 +107,7 @@ export default {
} }
// Unified record: profile (REST) + presence (gateway), in parallel. // Unified record: profile (REST) + presence (gateway), in parallel.
const [profile, presence] = await Promise.all([getProfile(env, id, ctx), fetchPresence(env, id)]); const [profile, presence] = await Promise.all([getProfile(env, id, ctx, force), fetchPresence(env, id)]);
if (!profile) { if (!profile) {
return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404); return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404);
} }

View File

@ -18,8 +18,10 @@ import {
avatarUrl, avatarUrl,
badgeIconUrl, badgeIconUrl,
bannerUrl, bannerUrl,
CDN,
clanBadgeUrl, clanBadgeUrl,
collectibleTypeName, collectibleTypeName,
decodeUserFlags,
decorationUrl, decorationUrl,
FLAG_BADGES, FLAG_BADGES,
nameplateStaticUrl, nameplateStaticUrl,
@ -27,8 +29,8 @@ import {
} from "./discord/constants"; } from "./discord/constants";
import { import {
fetchBotUser, fetchBotUser,
fetchCollectibleProduct,
fetchUserProfile, fetchUserProfile,
fetchWishlist,
type RawDiscordUser, type RawDiscordUser,
} from "./discord/rest"; } from "./discord/rest";
@ -76,6 +78,7 @@ function buildUser(
: null; : null;
const deco = u.avatar_decoration_data; const deco = u.avatar_decoration_data;
const publicFlags = u.public_flags ?? u.flags ?? 0;
return { return {
id: u.id, id: u.id,
@ -87,6 +90,8 @@ function buildUser(
banner: u.banner ?? null, banner: u.banner ?? null,
banner_url: bannerUrl(u.id, u.banner ?? null), banner_url: bannerUrl(u.id, u.banner ?? null),
accent_color: u.accent_color ?? null, accent_color: u.accent_color ?? null,
public_flags: publicFlags,
flags: decodeUserFlags(publicFlags),
avatar_decoration: deco avatar_decoration: deco
? { asset: deco.asset, sku_id: deco.sku_id ?? null, url: decorationUrl(deco.asset) } ? { asset: deco.asset, sku_id: deco.sku_id ?? null, url: decorationUrl(deco.asset) }
: null, : null,
@ -107,10 +112,10 @@ function buildUser(
}; };
} }
// ---- wishlist (Shop collectibles saved to the profile) ------------------ // ---- wishlist (profile's wishlist_settings key is a WISHLIST id) ---------
// The profile carries `wishlist_settings` — a map of collectible SKU id -> // `wishlist_settings` maps WISHLIST id -> per-wishlist settings (visibility,
// per-user settings (visibility, updated_at). It has no names or images, so we // updated_at). The items live at GET /wishlists/{id}, already resolved with
// resolve each SKU to its collectible product and pull the image assets out. // names + collectible image data, so we just fetch and flatten them.
/** Resolve static/animated/video image URLs for one collectible item. */ /** Resolve static/animated/video image URLs for one collectible item. */
function itemImages(it: any): Pick< function itemImages(it: any): Pick<
@ -139,99 +144,154 @@ function itemImages(it: any): Pick<
return { static_image_url: stat, animated_image_url: anim, video_url: vid }; return { static_image_url: stat, animated_image_url: anim, video_url: vid };
} }
/** Core (SKU-keyed, user-independent) fields of a resolved collectible. */ /** Core (item) fields, before per-wishlist visibility/updated_at are layered on. */
type WishlistCore = Omit<UnifiedWishlistItem, "visibility" | "updated_at">; type WishlistCore = Omit<UnifiedWishlistItem, "visibility" | "updated_at">;
/** Turn a resolved collectible product into its user-independent core. */ /** Price from a SKU's price blob (minor units; amount 599 + exponent 2 => 5.99). */
function productToCore(product: any, sku: string): WishlistCore { function parsePrice(price: any): UnifiedWishlistItem["price"] {
// A product wraps items[]; use the first item for imagery/labels. if (!price || typeof price.amount !== "number") return null;
const item =
Array.isArray(product?.items) && product.items.length ? product.items[0] : product;
const typeId = product?.type ?? item?.type ?? null;
return { return {
sku_id: sku, amount: price.amount,
type: collectibleTypeName(typeof typeId === "number" ? typeId : null), currency: typeof price.currency === "string" ? price.currency : "usd",
type_id: typeof typeId === "number" ? typeId : null, exponent: typeof price.currency_exponent === "number" ? price.currency_exponent : 2,
name: product?.name ?? item?.title ?? item?.name ?? null,
summary: product?.summary ?? product?.description ?? item?.description ?? null,
...itemImages(item),
label: item?.label ?? item?.accessibilityLabel ?? null,
}; };
} }
/** KV key for a resolved collectible product (shared across users). */ /**
function collectibleKey(sku: string): string { * Map one `wishlist_items[]` entry (from GET /wishlists/{id}) to its core
return `collectible:${sku}`; * fields. Collectibles carry rich image data under `collectibles_item`
* (handled by itemImages); other SKUs (games, etc.) fall back to the SKU's
* store thumbnail.
*/
function wishlistItemToCore(it: any): WishlistCore {
const sku = (it && it.sku) || {};
const ci = it?.collectibles_item ?? sku?.tenant_metadata?.collectibles?.item ?? null;
const typeId = typeof ci?.type === "number" ? ci.type : null;
// bundle_items[] entries are collectible items directly ({ type, asset, … }).
const bundleItems: any[] = Array.isArray(it?.bundle_items) ? it.bundle_items : [];
const isBundle = !ci && bundleItems.length > 0;
let images: Pick<UnifiedWishlistItem, "static_image_url" | "animated_image_url" | "video_url">;
if (ci) {
images = itemImages(ci);
} else if (isBundle) {
// A bundle has its own shop preview (fg over bg); fall back to the first
// bundled item's art if the preview is missing.
const preview = sku.preview_asset_paths || {};
const previewImg: string | null = preview.fg_static || preview.bg_static || null;
images = previewImg
? { static_image_url: previewImg, animated_image_url: null, video_url: null }
: itemImages(bundleItems[0]);
} else {
const appId = sku.application_id;
const thumb = sku.thumbnail_asset_id;
images = {
static_image_url: appId && thumb ? `${CDN}/app-assets/${appId}/store/${thumb}.png` : null,
animated_image_url: null,
video_url: null,
};
}
// Discord ships bundle summaries as a "{joinedItems}" template — rebuild it
// from the bundled SKU names (bundle_items omit names for some item types).
let summary: string | null = sku.description ?? ci?.description ?? null;
if (summary && /\{[^}]*\}/.test(summary)) {
const bundled = Array.isArray(sku.bundled_skus) ? sku.bundled_skus : [];
let names: string[] = bundled
.map((s: any) => s?.name)
.filter((n: any): n is string => typeof n === "string" && n.length > 0);
if (!names.length) {
names = bundleItems
.map((b) => b?.title ?? b?.sku_name ?? b?.name)
.filter((n: any): n is string => typeof n === "string" && n.length > 0);
}
summary = names.length ? names.join(", ") : null;
}
return {
sku_id: String(it?.sku_id ?? sku.id ?? ""),
type: ci ? collectibleTypeName(typeId) : isBundle ? "bundle" : "external_sku",
type_id: ci ? typeId : isBundle ? 1000 : null,
name: it?.sku_name ?? ci?.title ?? sku.name ?? null,
summary,
...images,
label: ci?.label ?? ci?.accessibilityLabel ?? null,
is_owned: typeof it?.is_owned === "boolean" ? it.is_owned : null,
price: parsePrice(sku.price),
};
} }
/** Resolve a SKU to its core fields, cache-first (product metadata is static). */ /** KV key for a fetched + parsed wishlist (shared across viewers). */
async function resolveCollectible( function wishlistKey(wishlistId: string): string {
return `wishlist:${wishlistId}`;
}
/** Fetch + parse a wishlist by id, cache-first (~1h). */
async function getWishlistItems(
env: Env, env: Env,
sku: string, wishlistId: string,
ctx?: ExecutionContext ctx?: ExecutionContext,
): Promise<WishlistCore | null> { force = false
const cached = (await env.PROFILE_CACHE.get(collectibleKey(sku), "json")) as WishlistCore | null; ): Promise<{ ok: boolean; items: WishlistCore[] }> {
if (cached) return cached; if (!force) {
const { raw } = await fetchCollectibleProduct(env, sku); const cached = (await env.PROFILE_CACHE.get(wishlistKey(wishlistId), "json")) as
if (!raw) return null; | WishlistCore[]
const core = productToCore(raw, sku); | null;
const write = env.PROFILE_CACHE.put(collectibleKey(sku), JSON.stringify(core), { if (cached) return { ok: true, items: cached };
expirationTtl: 604800, // 7d — product metadata barely changes }
const { raw } = await fetchWishlist(env, wishlistId);
if (!raw) return { ok: false, items: [] };
const arr = Array.isArray(raw.wishlist_items) ? raw.wishlist_items : [];
const items = arr
.slice(0, WISHLIST_MAX)
.map(wishlistItemToCore)
.filter((x: WishlistCore) => x.sku_id);
const write = env.PROFILE_CACHE.put(wishlistKey(wishlistId), JSON.stringify(items), {
expirationTtl: 3600, // 1h — wishlists change occasionally
}); });
if (ctx) ctx.waitUntil(write); if (ctx) ctx.waitUntil(write);
else await write; else await write;
return core; return { ok: true, items };
} }
/** Hard cap so a huge wishlist can't fan out into unbounded SKU resolves. */ /** Hard cap so an enormous wishlist can't blow up the response. */
const WISHLIST_MAX = 100; const WISHLIST_MAX = 100;
/** /**
* Build the wishlist from a rich profile payload: read `wishlist_settings`, * Build the wishlist from a rich profile payload. `wishlist_settings` is keyed
* then resolve every SKU (cache-first, in parallel) to name + images. Returns * by wishlist id (usually one); for each we fetch GET /wishlists/{id} and
* null when the profile has no wishlist field at all (i.e. "unavailable"), and * flatten its already-resolved items, layering on that wishlist's
* [] when the wishlist is present but empty. Unresolved SKUs are still included * visibility/updated_at. Returns null when the profile has no wishlist field,
* (null name/images) so an item is never silently dropped. * or when every wishlist fetch failed (so the cache-merge keeps a prior good
* list); [] for a reachable-but-empty wishlist.
*/ */
async function buildWishlist( async function buildWishlist(
env: Env, env: Env,
profile: { profile: {
wishlist_settings?: Record<string, { visibility?: number; updated_at?: string }>; wishlist_settings?: Record<string, { visibility?: number; updated_at?: string }>;
}, },
ctx?: ExecutionContext ctx?: ExecutionContext,
force = false
): Promise<UnifiedWishlistItem[] | null> { ): Promise<UnifiedWishlistItem[] | null> {
const settings = profile.wishlist_settings; const settings = profile.wishlist_settings;
if (!settings || typeof settings !== "object") return null; if (!settings || typeof settings !== "object") return null;
const entries = Object.entries(settings) const ids = Object.keys(settings).filter((k) => /^\d{16,21}$/.test(k));
.filter(([sku]) => /^\d{16,21}$/.test(sku)) if (!ids.length) return [];
.map(([sku, s]) => ({
sku,
visibility: typeof s?.visibility === "number" ? s.visibility : null,
updated_at: typeof s?.updated_at === "string" ? s.updated_at : null,
}));
if (!entries.length) return [];
// Newest first (stable, and matches how a wishlist tends to read). let anyOk = false;
entries.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")); const out: UnifiedWishlistItem[] = [];
for (const wid of ids) {
return Promise.all( const s = settings[wid] || {};
entries.slice(0, WISHLIST_MAX).map(async ({ sku, visibility, updated_at }) => { const visibility = typeof s.visibility === "number" ? s.visibility : null;
const core = const updated_at = typeof s.updated_at === "string" ? s.updated_at : null;
(await resolveCollectible(env, sku, ctx)) ?? { const { ok, items } = await getWishlistItems(env, wid, ctx, force);
sku_id: sku, if (!ok) continue;
type: "unknown" as const, anyOk = true;
type_id: null, for (const core of items) out.push({ ...core, visibility, updated_at });
name: null, }
summary: null, if (!anyOk) return null;
static_image_url: null, return out;
animated_image_url: null,
video_url: null,
label: null,
};
return { ...core, visibility, updated_at };
})
);
} }
function cacheKey(id: string): string { function cacheKey(id: string): string {
@ -262,7 +322,8 @@ type CachedProfile = Omit<ProfileResult, "source">;
export async function getProfile( export async function getProfile(
env: Env, env: Env,
id: string, id: string,
ctx?: ExecutionContext ctx?: ExecutionContext,
force = false
): Promise<ProfileResult | null> { ): Promise<ProfileResult | null> {
const got = await env.PROFILE_CACHE.getWithMetadata(cacheKey(id), "json"); const got = await env.PROFILE_CACHE.getWithMetadata(cacheKey(id), "json");
const cached = (got.value as CachedProfile | null) ?? null; const cached = (got.value as CachedProfile | null) ?? null;
@ -271,7 +332,8 @@ export async function getProfile(
// Per-entry TTL is jittered at write time so a big batch of profiles doesn't // Per-entry TTL is jittered at write time so a big batch of profiles doesn't
// all go stale on the same tick and stampede the rich refresh. // all go stale on the same tick and stampede the rich refresh.
const entryTtlMs = (meta?.ttl ?? baseTtl(env)) * 1000; const entryTtlMs = (meta?.ttl ?? baseTtl(env)) * 1000;
const cacheFresh = !!cached && Date.now() - lastWrite < entryTtlMs; // `force` (?fresh=1) treats the cache as stale so we re-fetch + re-resolve.
const cacheFresh = !force && !!cached && Date.now() - lastWrite < entryTtlMs;
// 1) Fresh rich cache -> serve it without touching Discord at all. // 1) Fresh rich cache -> serve it without touching Discord at all.
if (cached && cacheFresh) return { ...cached, source: "cache" }; if (cached && cacheFresh) return { ...cached, source: "cache" };
@ -282,7 +344,7 @@ export async function getProfile(
const cdRaw = await env.PROFILE_CACHE.get(COOLDOWN_KEY); const cdRaw = await env.PROFILE_CACHE.get(COOLDOWN_KEY);
const tryRich = !(cdRaw && Date.now() < Number(cdRaw)); const tryRich = !(cdRaw && Date.now() < Number(cdRaw));
const { result: built, richStatus, retryAfter } = await buildFreshProfile(env, id, tryRich, ctx); const { result: built, richStatus, retryAfter } = await buildFreshProfile(env, id, tryRich, ctx, force);
if (richStatus === 429) { if (richStatus === 429) {
// back off all rich attempts for a while (honour Retry-After, clamp 30s5m) // back off all rich attempts for a while (honour Retry-After, clamp 30s5m)
@ -370,7 +432,8 @@ async function buildFreshProfile(
env: Env, env: Env,
id: string, id: string,
tryRich: boolean, tryRich: boolean,
ctx?: ExecutionContext ctx?: ExecutionContext,
force = false
): Promise<BuildResult> { ): Promise<BuildResult> {
// Rich path first (unless we're cooling down from a 429); fall back to bot. // Rich path first (unless we're cooling down from a 429); fall back to bot.
const rich = tryRich const rich = tryRich
@ -393,14 +456,16 @@ async function buildFreshProfile(
const badges: UnifiedBadge[] = []; const badges: UnifiedBadge[] = [];
// Flag badges from the user object (so classic badges are always present). // Flag badges from the user object (so classic badges are always present).
badges.push(...flagBadges(u.public_flags ?? u.flags ?? 0)); badges.push(...flagBadges(u.public_flags ?? u.flags ?? 0));
// Rich badges (Nitro/boost/quest/orb/gifting…) from the profile. // Rich badges (Nitro/boost/quest/orb/gifting…) from the profile. Passed
// through generically so brand-new badges appear automatically — we don't
// gate on a known list. Guard the icon URL in case a new badge has none.
for (const b of profile.badges ?? []) { for (const b of profile.badges ?? []) {
if (badges.some((x) => x.id === b.id)) continue; if (badges.some((x) => x.id === b.id)) continue;
badges.push({ badges.push({
id: b.id, id: b.id,
description: b.description, description: b.description,
icon: b.icon, icon: b.icon ?? null,
icon_url: badgeIconUrl(b.icon), icon_url: b.icon ? badgeIconUrl(b.icon) : null,
link: b.link ?? null, link: b.link ?? null,
source: "profile", source: "profile",
}); });
@ -415,7 +480,7 @@ async function buildFreshProfile(
// Wishlist rides on the rich profile (`wishlist_settings`); resolve its // Wishlist rides on the rich profile (`wishlist_settings`); resolve its
// SKUs to names + images (cache-first). null if the field is absent. // SKUs to names + images (cache-first). null if the field is absent.
const wishlist = await buildWishlist(env, profile, ctx); const wishlist = await buildWishlist(env, profile, ctx, force);
return { return {
result: { user: buildUser(u, bio, pronouns, themeColors), badges, connected_accounts: connected, wishlist, source: "user" }, result: { user: buildUser(u, bio, pronouns, themeColors), badges, connected_accounts: connected, wishlist, source: "user" },

View File

@ -59,6 +59,14 @@ export interface UnifiedConnectedAccount {
verified: boolean; verified: boolean;
} }
/** A decoded public user flag (whether or not it has badge art). */
export interface UnifiedFlag {
/** e.g. "active_developer", "verified_bot", or "unknown_<bit>" if new. */
id: string;
/** Human-readable name, e.g. "Active Developer". */
name: string;
}
/** Human-readable collectible kind, mapped from Discord's numeric product type. */ /** Human-readable collectible kind, mapped from Discord's numeric product type. */
export type WishlistItemType = export type WishlistItemType =
| "avatar_decoration" | "avatar_decoration"
@ -94,9 +102,13 @@ export interface UnifiedWishlistItem {
video_url: string | null; video_url: string | null;
/** Accessibility label / alt text from Discord. */ /** Accessibility label / alt text from Discord. */
label: string | null; label: string | null;
/** Whether the wishlist owner already owns this item. */
is_owned: boolean | null;
/** Price in minor units (amount=599, exponent=2, currency="gbp" => £5.99). */
price: { amount: number; currency: string; exponent: number } | null;
/** Wishlist visibility from the profile (1 = everyone; null if unknown). */ /** Wishlist visibility from the profile (1 = everyone; null if unknown). */
visibility: number | null; visibility: number | null;
/** ISO timestamp the item was added/updated on the wishlist; null if unknown. */ /** ISO timestamp the wishlist was last updated; null if unknown. */
updated_at: string | null; updated_at: string | null;
} }
@ -112,6 +124,11 @@ export interface UnifiedUser {
banner_url: string | null; banner_url: string | null;
accent_color: number | null; accent_color: number | null;
/** Raw `public_flags` bitfield. */
public_flags: number;
/** Decoded public flags (badge and non-badge), incl. any new/unknown ones. */
flags: UnifiedFlag[];
avatar_decoration: UnifiedAvatarDecoration | null; avatar_decoration: UnifiedAvatarDecoration | null;
clan: UnifiedClanTag | null; clan: UnifiedClanTag | null;
/** Raw collectibles blob (nameplate, etc.) passed through as-is. */ /** Raw collectibles blob (nameplate, etc.) passed through as-is. */