Add wishlist options

This commit is contained in:
Clove 2026-06-20 05:17:44 +01:00
parent e96dba6b63
commit 4c0da234c9
6 changed files with 294 additions and 25 deletions

View File

@ -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`;
}

View File

@ -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<string, { visibility?: number; updated_at?: string }>;
}
/** Basic user via bot token. Returns null on 404 / failure. */
@ -197,3 +201,43 @@ export async function fetchUserProfile(env: Env, id: string): Promise<UserProfil
}
return { data: null, status: lastStatus, retryAfter: lastRetryAfter };
}
// ---- collectible products (resolve wishlist SKUs -> 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<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;
}
// 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 };
}

View File

@ -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 },
};

View File

@ -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<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;
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<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
});
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<string, { visibility?: number; updated_at?: string }>;
},
ctx?: ExecutionContext
): 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 [];
// 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 30s5m)
@ -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<BuildResult> {
async function buildFreshProfile(
env: Env,
id: string,
tryRich: boolean,
ctx?: ExecutionContext
): Promise<BuildResult> {
// 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,

View File

@ -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";

View File

@ -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"
}
}