Add wishlist options
This commit is contained in:
parent
e96dba6b63
commit
4c0da234c9
|
|
@ -76,3 +76,43 @@ export function clanBadgeUrl(guildId: string, badge: string): string {
|
||||||
export function emojiUrl(id: string, animated: boolean): string {
|
export function emojiUrl(id: string, animated: boolean): string {
|
||||||
return `${CDN}/emojis/${id}.${animated ? "gif" : "png"}?size=32`;
|
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`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,10 @@ export interface RawProfileResponse {
|
||||||
premium_type?: number;
|
premium_type?: number;
|
||||||
premium_since?: string | null;
|
premium_since?: string | null;
|
||||||
premium_guild_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. */
|
/** 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 };
|
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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export default {
|
||||||
presence,
|
presence,
|
||||||
badges: profile.badges,
|
badges: profile.badges,
|
||||||
connected_accounts: profile.connected_accounts,
|
connected_accounts: profile.connected_accounts,
|
||||||
|
wishlist: profile.wishlist ?? null,
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
source: { presence: presence ? "gateway" : "none", profile: profile.source },
|
source: { presence: presence ? "gateway" : "none", profile: profile.source },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
169
src/profile.ts
169
src/profile.ts
|
|
@ -11,21 +11,33 @@ import type {
|
||||||
UnifiedBadge,
|
UnifiedBadge,
|
||||||
UnifiedConnectedAccount,
|
UnifiedConnectedAccount,
|
||||||
UnifiedUser,
|
UnifiedUser,
|
||||||
|
UnifiedWishlistItem,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
|
avatarDecorationImageUrl,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
badgeIconUrl,
|
badgeIconUrl,
|
||||||
bannerUrl,
|
bannerUrl,
|
||||||
clanBadgeUrl,
|
clanBadgeUrl,
|
||||||
|
collectibleTypeName,
|
||||||
decorationUrl,
|
decorationUrl,
|
||||||
FLAG_BADGES,
|
FLAG_BADGES,
|
||||||
|
nameplateStaticUrl,
|
||||||
|
nameplateVideoUrl,
|
||||||
} from "./discord/constants";
|
} from "./discord/constants";
|
||||||
import { fetchBotUser, fetchUserProfile, type RawDiscordUser } from "./discord/rest";
|
import {
|
||||||
|
fetchBotUser,
|
||||||
|
fetchCollectibleProduct,
|
||||||
|
fetchUserProfile,
|
||||||
|
type RawDiscordUser,
|
||||||
|
} from "./discord/rest";
|
||||||
|
|
||||||
export interface ProfileResult {
|
export interface ProfileResult {
|
||||||
user: UnifiedUser;
|
user: UnifiedUser;
|
||||||
badges: UnifiedBadge[];
|
badges: UnifiedBadge[];
|
||||||
connected_accounts: UnifiedConnectedAccount[];
|
connected_accounts: UnifiedConnectedAccount[];
|
||||||
|
/** Shop collectibles saved to the profile; null when unavailable. */
|
||||||
|
wishlist: UnifiedWishlistItem[] | null;
|
||||||
source: "bot" | "user" | "cache";
|
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 {
|
function cacheKey(id: string): string {
|
||||||
return `profile:${id}`;
|
return `profile:${id}`;
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +282,7 @@ export async function getProfile(
|
||||||
const cdRaw = await env.PROFILE_CACHE.get(COOLDOWN_KEY);
|
const cdRaw = await env.PROFILE_CACHE.get(COOLDOWN_KEY);
|
||||||
const tryRich = !(cdRaw && Date.now() < Number(cdRaw));
|
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) {
|
if (richStatus === 429) {
|
||||||
// back off all rich attempts for a while (honour Retry-After, clamp 30s–5m)
|
// 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") {
|
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);
|
const write = writeCache(env, id, built);
|
||||||
if (ctx) ctx.waitUntil(write);
|
if (ctx) ctx.waitUntil(write);
|
||||||
else await write;
|
else await write;
|
||||||
|
|
@ -190,6 +334,9 @@ function mergeRichOverBot(cached: CachedProfile, bot: ProfileResult): CachedProf
|
||||||
connected_accounts: cached.connected_accounts.length
|
connected_accounts: cached.connected_accounts.length
|
||||||
? cached.connected_accounts
|
? cached.connected_accounts
|
||||||
: bot.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,
|
user: result.user,
|
||||||
badges: result.badges,
|
badges: result.badges,
|
||||||
connected_accounts: result.connected_accounts,
|
connected_accounts: result.connected_accounts,
|
||||||
|
wishlist: result.wishlist,
|
||||||
}),
|
}),
|
||||||
// Keep the rich blob ~24h so it's available to merge over bot data even
|
// Keep the rich blob ~24h so it's available to merge over bot data even
|
||||||
// when it's well past its freshness window.
|
// when it's well past its freshness window.
|
||||||
|
|
@ -218,7 +366,12 @@ interface BuildResult {
|
||||||
retryAfter: number;
|
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.
|
// Rich path first (unless we're cooling down from a 429); fall back to bot.
|
||||||
const rich = tryRich
|
const rich = tryRich
|
||||||
? await fetchUserProfile(env, id)
|
? await fetchUserProfile(env, id)
|
||||||
|
|
@ -260,14 +413,19 @@ async function buildFreshProfile(env: Env, id: string, tryRich: boolean): Promis
|
||||||
verified: !!c.verified,
|
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 {
|
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,
|
richStatus,
|
||||||
retryAfter,
|
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);
|
const u = await fetchBotUser(env, id);
|
||||||
if (!u) return { result: null, richStatus, retryAfter };
|
if (!u) return { result: null, richStatus, retryAfter };
|
||||||
return {
|
return {
|
||||||
|
|
@ -275,6 +433,7 @@ async function buildFreshProfile(env: Env, id: string, tryRich: boolean): Promis
|
||||||
user: buildUser(u, null, null, null),
|
user: buildUser(u, null, null, null),
|
||||||
badges: flagBadges(u.public_flags ?? u.flags ?? 0),
|
badges: flagBadges(u.public_flags ?? u.flags ?? 0),
|
||||||
connected_accounts: [],
|
connected_accounts: [],
|
||||||
|
wishlist: null,
|
||||||
source: "bot",
|
source: "bot",
|
||||||
},
|
},
|
||||||
richStatus,
|
richStatus,
|
||||||
|
|
|
||||||
45
src/types.ts
45
src/types.ts
|
|
@ -59,6 +59,47 @@ export interface UnifiedConnectedAccount {
|
||||||
verified: boolean;
|
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 {
|
export interface UnifiedUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -125,6 +166,10 @@ export interface UnifiedRecord {
|
||||||
presence: UnifiedPresence | null;
|
presence: UnifiedPresence | null;
|
||||||
badges: UnifiedBadge[];
|
badges: UnifiedBadge[];
|
||||||
connected_accounts: UnifiedConnectedAccount[];
|
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;
|
updated_at: number;
|
||||||
source: {
|
source: {
|
||||||
presence: "gateway" | "none";
|
presence: "gateway" | "none";
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "dough-restful",
|
"name": "dough-restful",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"compatibility_date": "2024-09-23",
|
"compatibility_date": "2024-09-23",
|
||||||
"account_id": "f87ee4b9600f437b8da1104d077418c3",
|
"account_id": "f87ee4b9600f437b8da1104d077418c3",
|
||||||
// nodejs_compat is not required; we only use Web/Workers APIs.
|
|
||||||
"observability": {
|
"observability": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
// ---- Durable Object: single instance holds the Discord gateway socket ----
|
|
||||||
"durable_objects": {
|
"durable_objects": {
|
||||||
"bindings": [
|
"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": [
|
"kv_namespaces": [
|
||||||
{
|
{
|
||||||
"binding": "PROFILE_CACHE",
|
"binding": "PROFILE_CACHE",
|
||||||
"id": "0ad7fefa9239482a9028c820e4a0cec1"
|
"id": "0ad7fefa9239482a9028c820e4a0cec1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// ---- Cron: nudge the Durable Object so the gateway stays connected ----
|
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"crons": [
|
"crons": [
|
||||||
"*/2 * * * *"
|
"*/2 * * * *"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Non-secret vars. Secrets (tokens) are set via `wrangler secret put`.
|
|
||||||
"vars": {
|
"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",
|
"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": "",
|
"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",
|
"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"
|
"DISCORD_CLIENT_BUILD_NUMBER": "565311"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue