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
/** 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_<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 {
return typeof hash === "string" && hash.startsWith("a_");
}

View File

@ -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<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 };
}
// ---- collectible products (resolve wishlist SKUs -> 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<CollectibleFetch> {
// 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<string, string>, label: string): Promise<WishlistFetch> {
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<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: {
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);
}

View File

@ -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<UnifiedWishlistItem, "visibility" | "updated_at">;
/** 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<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). */
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<WishlistCore | null> {
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<string, { visibility?: number; updated_at?: string }>;
},
ctx?: ExecutionContext
ctx?: ExecutionContext,
force = false
): Promise<UnifiedWishlistItem[] | null> {
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<ProfileResult, "source">;
export async function getProfile(
env: Env,
id: string,
ctx?: ExecutionContext
ctx?: ExecutionContext,
force = false
): Promise<ProfileResult | null> {
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 30s5m)
@ -370,7 +432,8 @@ async function buildFreshProfile(
env: Env,
id: string,
tryRich: boolean,
ctx?: ExecutionContext
ctx?: ExecutionContext,
force = false
): Promise<BuildResult> {
// 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" },

View File

@ -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_<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. */
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. */