diff --git a/src/discord/constants.ts b/src/discord/constants.ts index e0a3570..0b665f4 100644 --- a/src/discord/constants.ts +++ b/src/discord/constants.ts @@ -76,3 +76,43 @@ export function clanBadgeUrl(guildId: string, badge: string): string { export function emojiUrl(id: string, animated: boolean): string { return `${CDN}/emojis/${id}.${animated ? "gif" : "png"}?size=32`; } + +// ---- collectibles (Shop wishlist) --------------------------------------- +// Discord product type ids -> our human-readable kind. See Userdoccers +// "Collectible Product Type". 1000/2000/3000 are bundle/variants/external. +import type { WishlistItemType } from "../types"; + +export function collectibleTypeName(type: number | null | undefined): WishlistItemType { + switch (type) { + case 0: + return "avatar_decoration"; + case 1: + return "profile_effect"; + case 2: + return "nameplate"; + case 1000: + return "bundle"; + case 2000: + return "variants_group"; + case 3000: + return "external_sku"; + default: + return "unknown"; + } +} + +/** Static preset image for an avatar decoration (APNG served at .png). */ +export function avatarDecorationImageUrl(asset: string): string { + return `${CDN}/avatar-decoration-presets/${asset}.png`; +} + +/** + * Nameplate images. `asset` is a path prefix (e.g. "nameplates/nameplate_x/"); + * Discord serves a still PNG and a WEBM video under /assets/collectibles/. + */ +export function nameplateStaticUrl(asset: string): string { + return `${CDN}/assets/collectibles/${asset}static.png`; +} +export function nameplateVideoUrl(asset: string): string { + return `${CDN}/assets/collectibles/${asset}asset.webm`; +} diff --git a/src/discord/rest.ts b/src/discord/rest.ts index df6a48f..4c59c5d 100644 --- a/src/discord/rest.ts +++ b/src/discord/rest.ts @@ -134,6 +134,10 @@ 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. */ + wishlist_settings?: Record; } /** Basic user via bot token. Returns null on 404 / failure. */ @@ -197,3 +201,43 @@ export async function fetchUserProfile(env: Env, id: string): Promise name + images) ------ + +export interface CollectibleFetch { + /** Raw collectible product JSON; null on failure. */ + raw: unknown | 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; + } + + // 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 }; +} diff --git a/src/index.ts b/src/index.ts index 016d333..c0cf291 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,6 +108,7 @@ export default { presence, badges: profile.badges, connected_accounts: profile.connected_accounts, + wishlist: profile.wishlist ?? null, updated_at: Date.now(), source: { presence: presence ? "gateway" : "none", profile: profile.source }, }; diff --git a/src/profile.ts b/src/profile.ts index 12b4caa..2ed2d60 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -11,21 +11,33 @@ import type { UnifiedBadge, UnifiedConnectedAccount, UnifiedUser, + UnifiedWishlistItem, } from "./types"; import { + avatarDecorationImageUrl, avatarUrl, badgeIconUrl, bannerUrl, clanBadgeUrl, + collectibleTypeName, decorationUrl, FLAG_BADGES, + nameplateStaticUrl, + nameplateVideoUrl, } from "./discord/constants"; -import { fetchBotUser, fetchUserProfile, type RawDiscordUser } from "./discord/rest"; +import { + fetchBotUser, + fetchCollectibleProduct, + fetchUserProfile, + type RawDiscordUser, +} from "./discord/rest"; export interface ProfileResult { user: UnifiedUser; badges: UnifiedBadge[]; connected_accounts: UnifiedConnectedAccount[]; + /** Shop collectibles saved to the profile; null when unavailable. */ + wishlist: UnifiedWishlistItem[] | null; source: "bot" | "user" | "cache"; } @@ -95,6 +107,133 @@ 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. + +/** Resolve static/animated/video image URLs for one collectible item. */ +function itemImages(it: any): Pick< + UnifiedWishlistItem, + "static_image_url" | "animated_image_url" | "video_url" +> { + const a = (it && it.assets) || {}; + let stat: string | null = a.static_image_url ?? null; + let anim: string | null = a.animated_image_url ?? null; + let vid: string | null = a.video_url ?? null; + const type = it?.type; + const asset = it?.asset; + if (type === 0 && asset) { + // avatar decoration — APNG served at .png + stat = stat ?? avatarDecorationImageUrl(asset); + anim = anim ?? avatarDecorationImageUrl(asset); + } else if (type === 2 && asset) { + // nameplate — still PNG + WEBM video under /assets/collectibles/ + stat = stat ?? nameplateStaticUrl(asset); + vid = vid ?? nameplateVideoUrl(asset); + } else if (type === 1) { + // profile effect — image fields are full URLs on the item itself + stat = stat ?? it?.staticFrameSrc ?? it?.thumbnailPreviewSrc ?? null; + anim = anim ?? it?.thumbnailPreviewSrc ?? it?.reducedMotionSrc ?? null; + } + return { static_image_url: stat, animated_image_url: anim, video_url: vid }; +} + +/** Core (SKU-keyed, user-independent) fields of a resolved collectible. */ +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; + 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, + }; +} + +/** KV key for a resolved collectible product (shared across users). */ +function collectibleKey(sku: string): string { + return `collectible:${sku}`; +} + +/** Resolve a SKU to its core fields, cache-first (product metadata is static). */ +async function resolveCollectible( + 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 + }); + if (ctx) ctx.waitUntil(write); + else await write; + return core; +} + +/** Hard cap so a huge wishlist can't fan out into unbounded SKU resolves. */ +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. + */ +async function buildWishlist( + env: Env, + profile: { + wishlist_settings?: Record; + }, + ctx?: ExecutionContext +): 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 []; + + // 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 }; + }) + ); +} + function cacheKey(id: string): string { return `profile:${id}`; } @@ -143,7 +282,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); + const { result: built, richStatus, retryAfter } = await buildFreshProfile(env, id, tryRich, ctx); if (richStatus === 429) { // back off all rich attempts for a while (honour Retry-After, clamp 30s–5m) @@ -156,6 +295,11 @@ export async function getProfile( } if (built && built.source === "user") { + // Don't clobber a cached wishlist with null if this refresh got the profile + // but not the wishlist (e.g. it 429'd). An empty [] still overwrites. + if (built.wishlist == null && cached?.wishlist != null) { + built.wishlist = cached.wishlist; + } const write = writeCache(env, id, built); if (ctx) ctx.waitUntil(write); else await write; @@ -190,6 +334,9 @@ function mergeRichOverBot(cached: CachedProfile, bot: ProfileResult): CachedProf connected_accounts: cached.connected_accounts.length ? cached.connected_accounts : bot.connected_accounts, + // Bot-only refreshes can't read the wishlist (it rides on the rich + // profile), so keep the cached one rather than dropping it to null. + wishlist: bot.wishlist != null ? bot.wishlist : cached.wishlist, }; } @@ -203,6 +350,7 @@ async function writeCache(env: Env, id: string, result: ProfileResult): Promise< user: result.user, badges: result.badges, connected_accounts: result.connected_accounts, + wishlist: result.wishlist, }), // Keep the rich blob ~24h so it's available to merge over bot data even // when it's well past its freshness window. @@ -218,7 +366,12 @@ interface BuildResult { retryAfter: number; } -async function buildFreshProfile(env: Env, id: string, tryRich: boolean): Promise { +async function buildFreshProfile( + env: Env, + id: string, + tryRich: boolean, + ctx?: ExecutionContext +): Promise { // Rich path first (unless we're cooling down from a 429); fall back to bot. const rich = tryRich ? await fetchUserProfile(env, id) @@ -260,14 +413,19 @@ async function buildFreshProfile(env: Env, id: string, tryRich: boolean): Promis verified: !!c.verified, })); + // 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); + return { - result: { user: buildUser(u, bio, pronouns, themeColors), badges, connected_accounts: connected, source: "user" }, + result: { user: buildUser(u, bio, pronouns, themeColors), badges, connected_accounts: connected, wishlist, source: "user" }, richStatus, retryAfter, }; } - // Bot-only fallback. + // Bot-only fallback — the wishlist rides on the rich profile, which we don't + // have here, so it's null and the cache-merge keeps any previously cached one. const u = await fetchBotUser(env, id); if (!u) return { result: null, richStatus, retryAfter }; return { @@ -275,6 +433,7 @@ async function buildFreshProfile(env: Env, id: string, tryRich: boolean): Promis user: buildUser(u, null, null, null), badges: flagBadges(u.public_flags ?? u.flags ?? 0), connected_accounts: [], + wishlist: null, source: "bot", }, richStatus, diff --git a/src/types.ts b/src/types.ts index e08e921..282880e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,6 +59,47 @@ export interface UnifiedConnectedAccount { verified: boolean; } +/** Human-readable collectible kind, mapped from Discord's numeric product type. */ +export type WishlistItemType = + | "avatar_decoration" + | "profile_effect" + | "nameplate" + | "bundle" + | "variants_group" + | "external_sku" + | "unknown"; + +/** + * One Discord Shop collectible a user has saved to their profile wishlist. + * The SKU + per-user settings come from the profile's `wishlist_settings`; the + * name/type/images are resolved from the collectible product so the frontend + * can render the wishlist directly. `static_image_url` is set when known, + * `animated_image_url`/`video_url` filled for collectibles that animate. + */ +export interface UnifiedWishlistItem { + /** SKU id of the collectible (stable identifier for the shop item). */ + sku_id: string; + /** Human-readable collectible kind. */ + type: WishlistItemType; + /** Raw Discord numeric product type (0/1/2/1000/…); null if unresolved. */ + type_id: number | null; + name: string | null; + /** Short description/summary when the product provides one. */ + summary: string | null; + /** Still image (PNG/APNG first frame). */ + static_image_url: string | null; + /** Animated image (APNG) when the collectible animates. */ + animated_image_url: string | null; + /** Video preview (WEBM/MP4) when present — mainly profile effects/nameplates. */ + video_url: string | null; + /** Accessibility label / alt text from Discord. */ + label: string | 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. */ + updated_at: string | null; +} + export interface UnifiedUser { id: string; username: string; @@ -125,6 +166,10 @@ export interface UnifiedRecord { presence: UnifiedPresence | null; badges: UnifiedBadge[]; connected_accounts: UnifiedConnectedAccount[]; + /** Discord Shop collectibles the user saved to their profile wishlist. + * null when unavailable (no user token / proxy, or the source was blocked); + * [] means we reached the source and the wishlist is empty. */ + wishlist: UnifiedWishlistItem[] | null; updated_at: number; source: { presence: "gateway" | "none"; diff --git a/wrangler.jsonc b/wrangler.jsonc index c217ae8..b353d1d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,16 +1,12 @@ { - // dough-restful — Discord presence + profile API on one Worker. - // Docs: https://developers.cloudflare.com/workers/wrangler/configuration/ "$schema": "node_modules/wrangler/config-schema.json", "name": "dough-restful", "main": "src/index.ts", "compatibility_date": "2024-09-23", "account_id": "f87ee4b9600f437b8da1104d077418c3", - // nodejs_compat is not required; we only use Web/Workers APIs. "observability": { "enabled": true }, - // ---- Durable Object: single instance holds the Discord gateway socket ---- "durable_objects": { "bindings": [ { @@ -27,37 +23,21 @@ ] } ], - // ---- KV: cache for profile/badge data (not available over the gateway) ---- - // Create with: wrangler kv namespace create PROFILE_CACHE - // then paste the returned id below. "kv_namespaces": [ { "binding": "PROFILE_CACHE", "id": "0ad7fefa9239482a9028c820e4a0cec1" } ], - // ---- Cron: nudge the Durable Object so the gateway stays connected ---- "triggers": { "crons": [ "*/2 * * * *" ] }, - // Non-secret vars. Secrets (tokens) are set via `wrangler secret put`. "vars": { - // v9 matches what the web client actually uses; v9/v10 are interchangeable - // for the user/profile endpoints. Matching the client = more "trustworthy". "DISCORD_API_VERSION": "9", - // Comma-separated guild IDs the bot is in that you want monitored. - // Leave empty to monitor every guild the bot can see. "TRACKED_GUILD_IDS": "", - // Profile freshness window (seconds). Profiles change rarely and the - // user-token /profile route is heavily rate-limited, so keep this long. - // Presence stays live via the gateway regardless of this value. "PROFILE_CACHE_TTL_SECONDS": "1800", - // Current Discord client build number, sent in X-Super-Properties so the - // user-token /profile calls get the client's gentler rate limits. Keep it - // reasonably current — grab the latest from your client's devtools - // (look for "client_build_number") and bump this when it drifts. "DISCORD_CLIENT_BUILD_NUMBER": "565311" } } \ No newline at end of file