Wishlist stuff
This commit is contained in:
parent
4c0da234c9
commit
ef7b85b50d
|
|
@ -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_");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
}
|
||||
console.warn(`[dough-restful] ${label} HTTP ${res.status}: ${text.slice(0, 140)}`);
|
||||
return { raw: null, status: res.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 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.
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
12
src/index.ts
12
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);
|
||||
}
|
||||
|
|
|
|||
221
src/profile.ts
221
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<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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a SKU to its core fields, cache-first (product metadata is static). */
|
||||
async function resolveCollectible(
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 30s–5m)
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
19
src/types.ts
19
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_<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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue