diff --git a/src/discord/constants.ts b/src/discord/constants.ts index 0b665f4..6d77c1b 100644 --- a/src/discord/constants.ts +++ b/src/discord/constants.ts @@ -24,8 +24,15 @@ export const Op = { */ 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 << 1, "partner", "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"], [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 << 8, "hypesquad_house_3", "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"], [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 << 16, "verified_bot", "Verified Bot", null], [1 << 17, "verified_developer", "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"], [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 << 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_` 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 { return typeof hash === "string" && hash.startsWith("a_"); } diff --git a/src/discord/rest.ts b/src/discord/rest.ts index 4c59c5d..0371b1b 100644 --- a/src/discord/rest.ts +++ b/src/discord/rest.ts @@ -134,9 +134,8 @@ export interface RawProfileResponse { premium_type?: number; premium_since?: string | null; premium_guild_since?: string | null; - /** Profile wishlist: map of collectible SKU id -> per-user settings. The - * product details (name/images) are NOT here — resolve each SKU separately - * via fetchCollectibleProduct. */ + /** Profile wishlist: map of WISHLIST id -> per-wishlist settings. The items + * themselves are NOT here — fetch them with fetchWishlist(wishlistId). */ wishlist_settings?: Record; } @@ -202,42 +201,59 @@ export async function fetchUserProfile(env: Env, id: string): Promise name + images) ------ +// ---- wishlist (the profile's wishlist_settings key is a WISHLIST id) ----- -export interface CollectibleFetch { - /** Raw collectible product JSON; null on failure. */ - raw: unknown | null; +export interface WishlistFetch { + /** Raw wishlist JSON ({ id, user_id, wishlist_items: [...] }); null on fail. */ + raw: any | null; /** HTTP status (0 = not attempted / no token). */ status: number; } -/** - * Resolve one collectible SKU to its product (name, type, item image assets). - * The Shop product endpoint is read with the user token + client fingerprint - * (same path as the rich profile); if no user token is configured we fall back - * to the bot token, which is enough for many Shop reads. Product metadata is - * effectively static, so callers cache the result per SKU. - */ -export async function fetchCollectibleProduct(env: Env, skuId: string): Promise { - // country_code is hardcoded to GB — product name/images don't vary by region, - // it's only here because the Shop endpoint expects the param. - 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; +/** One GET attempt; reads body as text so failures show in `wrangler tail`. */ +async function tryJson(url: string, headers: Record, label: string): Promise { + const res = await fetch(url, { headers }); + const text = await res.text().catch(() => ""); + if (res.ok) { + try { + return { raw: JSON.parse(text), status: 200 }; + } catch { + console.warn(`[dough-restful] ${label} 200 non-JSON: ${text.slice(0, 100)}`); + return { raw: null, status: 200 }; + } } - - // Bot-token fallback (works without a user token in many cases). - 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 }; + console.warn(`[dough-restful] ${label} HTTP ${res.status}: ${text.slice(0, 140)}`); + return { raw: null, status: 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 { + 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 }; } diff --git a/src/index.ts b/src/index.ts index c0cf291..a0c74ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ export default { data: { service: "dough-restful", 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"], } as any, }); @@ -82,6 +83,13 @@ export default { 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") { const presence = await fetchPresence(env, id); if (!presence) { @@ -91,7 +99,7 @@ export default { } if (sub === "profile") { - const profile = await getProfile(env, id, ctx); + const profile = await getProfile(env, id, ctx, force); if (!profile) { 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. - 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) { return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404); } diff --git a/src/profile.ts b/src/profile.ts index 2ed2d60..33af6af 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -18,8 +18,10 @@ import { avatarUrl, badgeIconUrl, bannerUrl, + CDN, clanBadgeUrl, collectibleTypeName, + decodeUserFlags, decorationUrl, FLAG_BADGES, nameplateStaticUrl, @@ -27,8 +29,8 @@ import { } from "./discord/constants"; import { fetchBotUser, - fetchCollectibleProduct, fetchUserProfile, + fetchWishlist, type RawDiscordUser, } from "./discord/rest"; @@ -76,6 +78,7 @@ function buildUser( : null; const deco = u.avatar_decoration_data; + const publicFlags = u.public_flags ?? u.flags ?? 0; return { id: u.id, @@ -87,6 +90,8 @@ function buildUser( banner: u.banner ?? null, banner_url: bannerUrl(u.id, u.banner ?? null), accent_color: u.accent_color ?? null, + public_flags: publicFlags, + flags: decodeUserFlags(publicFlags), avatar_decoration: deco ? { asset: deco.asset, sku_id: deco.sku_id ?? null, url: decorationUrl(deco.asset) } : null, @@ -107,10 +112,10 @@ function buildUser( }; } -// ---- wishlist (Shop collectibles saved to the profile) ------------------ -// The profile carries `wishlist_settings` — a map of collectible SKU id -> -// per-user settings (visibility, updated_at). It has no names or images, so we -// resolve each SKU to its collectible product and pull the image assets out. +// ---- wishlist (profile's wishlist_settings key is a WISHLIST id) --------- +// `wishlist_settings` maps WISHLIST id -> per-wishlist settings (visibility, +// updated_at). The items live at GET /wishlists/{id}, already resolved with +// names + collectible image data, so we just fetch and flatten them. /** Resolve static/animated/video image URLs for one collectible item. */ 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 }; } -/** 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; -/** Turn a resolved collectible product into its user-independent core. */ -function productToCore(product: any, sku: string): WishlistCore { - // A product wraps items[]; use the first item for imagery/labels. - const item = - Array.isArray(product?.items) && product.items.length ? product.items[0] : product; - const typeId = product?.type ?? item?.type ?? null; +/** Price from a SKU's price blob (minor units; amount 599 + exponent 2 => 5.99). */ +function parsePrice(price: any): UnifiedWishlistItem["price"] { + if (!price || typeof price.amount !== "number") return null; return { - sku_id: sku, - type: collectibleTypeName(typeof typeId === "number" ? typeId : null), - type_id: typeof typeId === "number" ? typeId : null, - name: product?.name ?? item?.title ?? item?.name ?? null, - summary: product?.summary ?? product?.description ?? item?.description ?? null, - ...itemImages(item), - label: item?.label ?? item?.accessibilityLabel ?? null, + amount: price.amount, + currency: typeof price.currency === "string" ? price.currency : "usd", + exponent: typeof price.currency_exponent === "number" ? price.currency_exponent : 2, }; } -/** KV key for a resolved collectible product (shared across users). */ -function collectibleKey(sku: string): string { - return `collectible:${sku}`; +/** + * Map one `wishlist_items[]` entry (from GET /wishlists/{id}) to its core + * 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; + 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). */ -async function resolveCollectible( +/** KV key for a fetched + parsed wishlist (shared across viewers). */ +function wishlistKey(wishlistId: string): string { + return `wishlist:${wishlistId}`; +} + +/** Fetch + parse a wishlist by id, cache-first (~1h). */ +async function getWishlistItems( env: Env, - sku: string, - ctx?: ExecutionContext -): Promise { - const cached = (await env.PROFILE_CACHE.get(collectibleKey(sku), "json")) as WishlistCore | null; - if (cached) return cached; - const { raw } = await fetchCollectibleProduct(env, sku); - if (!raw) return null; - const core = productToCore(raw, sku); - const write = env.PROFILE_CACHE.put(collectibleKey(sku), JSON.stringify(core), { - expirationTtl: 604800, // 7d — product metadata barely changes + wishlistId: string, + ctx?: ExecutionContext, + force = false +): Promise<{ ok: boolean; items: WishlistCore[] }> { + if (!force) { + const cached = (await env.PROFILE_CACHE.get(wishlistKey(wishlistId), "json")) as + | WishlistCore[] + | null; + if (cached) return { ok: true, items: cached }; + } + 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); 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; /** - * Build the wishlist from a rich profile payload: read `wishlist_settings`, - * then resolve every SKU (cache-first, in parallel) to name + images. Returns - * null when the profile has no wishlist field at all (i.e. "unavailable"), and - * [] when the wishlist is present but empty. Unresolved SKUs are still included - * (null name/images) so an item is never silently dropped. + * Build the wishlist from a rich profile payload. `wishlist_settings` is keyed + * by wishlist id (usually one); for each we fetch GET /wishlists/{id} and + * flatten its already-resolved items, layering on that wishlist's + * visibility/updated_at. Returns null when the profile has no wishlist field, + * or when every wishlist fetch failed (so the cache-merge keeps a prior good + * list); [] for a reachable-but-empty wishlist. */ async function buildWishlist( env: Env, profile: { wishlist_settings?: Record; }, - ctx?: ExecutionContext + ctx?: ExecutionContext, + force = false ): Promise { const settings = profile.wishlist_settings; if (!settings || typeof settings !== "object") return null; - const entries = Object.entries(settings) - .filter(([sku]) => /^\d{16,21}$/.test(sku)) - .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 []; + const ids = Object.keys(settings).filter((k) => /^\d{16,21}$/.test(k)); + if (!ids.length) return []; - // Newest first (stable, and matches how a wishlist tends to read). - entries.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")); - - return Promise.all( - entries.slice(0, WISHLIST_MAX).map(async ({ sku, visibility, updated_at }) => { - const core = - (await resolveCollectible(env, sku, ctx)) ?? { - sku_id: sku, - type: "unknown" as const, - type_id: null, - name: null, - summary: null, - static_image_url: null, - animated_image_url: null, - video_url: null, - label: null, - }; - return { ...core, visibility, updated_at }; - }) - ); + let anyOk = false; + const out: UnifiedWishlistItem[] = []; + for (const wid of ids) { + const s = settings[wid] || {}; + const visibility = typeof s.visibility === "number" ? s.visibility : null; + const updated_at = typeof s.updated_at === "string" ? s.updated_at : null; + const { ok, items } = await getWishlistItems(env, wid, ctx, force); + if (!ok) continue; + anyOk = true; + for (const core of items) out.push({ ...core, visibility, updated_at }); + } + if (!anyOk) return null; + return out; } function cacheKey(id: string): string { @@ -262,7 +322,8 @@ type CachedProfile = Omit; export async function getProfile( env: Env, id: string, - ctx?: ExecutionContext + ctx?: ExecutionContext, + force = false ): Promise { const got = await env.PROFILE_CACHE.getWithMetadata(cacheKey(id), "json"); 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 // all go stale on the same tick and stampede the rich refresh. 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. 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 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) { // back off all rich attempts for a while (honour Retry-After, clamp 30s–5m) @@ -370,7 +432,8 @@ async function buildFreshProfile( env: Env, id: string, tryRich: boolean, - ctx?: ExecutionContext + ctx?: ExecutionContext, + force = false ): Promise { // Rich path first (unless we're cooling down from a 429); fall back to bot. const rich = tryRich @@ -393,14 +456,16 @@ async function buildFreshProfile( const badges: UnifiedBadge[] = []; // Flag badges from the user object (so classic badges are always present). 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 ?? []) { if (badges.some((x) => x.id === b.id)) continue; badges.push({ id: b.id, description: b.description, - icon: b.icon, - icon_url: badgeIconUrl(b.icon), + icon: b.icon ?? null, + icon_url: b.icon ? badgeIconUrl(b.icon) : null, link: b.link ?? null, source: "profile", }); @@ -415,7 +480,7 @@ async function buildFreshProfile( // Wishlist rides on the rich profile (`wishlist_settings`); resolve its // 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 { result: { user: buildUser(u, bio, pronouns, themeColors), badges, connected_accounts: connected, wishlist, source: "user" }, diff --git a/src/types.ts b/src/types.ts index 282880e..9320db9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,6 +59,14 @@ export interface UnifiedConnectedAccount { 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_" if new. */ + id: string; + /** Human-readable name, e.g. "Active Developer". */ + name: string; +} + /** Human-readable collectible kind, mapped from Discord's numeric product type. */ export type WishlistItemType = | "avatar_decoration" @@ -94,9 +102,13 @@ export interface UnifiedWishlistItem { video_url: string | null; /** Accessibility label / alt text from Discord. */ 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). */ 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; } @@ -112,6 +124,11 @@ export interface UnifiedUser { banner_url: string | 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; clan: UnifiedClanTag | null; /** Raw collectibles blob (nameplate, etc.) passed through as-is. */