From bbf8422d85637b83d10e15cccf28fb49d59be313 Mon Sep 17 00:00:00 2001 From: Clove Twilight Date: Fri, 19 Jun 2026 19:54:26 +0100 Subject: [PATCH] feat: add theme_colors/display_name_styles and make profile fetching rate-limit resilient (cache-first + merge, 429 cooldown, token rotation, client fingerprint) --- README.md | 27 ++++++---- package.json | 3 +- scripts/decode-super-properties.mjs | 54 ++++++++++++++++++++ src/discord/rest.ts | 77 ++++++++++++++++++++++++++++- src/types.ts | 3 ++ wrangler.jsonc | 11 ++++- 6 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 scripts/decode-super-properties.mjs diff --git a/README.md b/README.md index db3e2d8..313961e 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,25 @@ A combined Discord **presence** (Lanyard-style) and **profile/badges** (dstn.to-style) API on a **single Cloudflare Worker + Durable Object**, powered by **one Discord bot**. It exposes a REST endpoint and a Lanyard-compatible WebSocket, returning a single unified JSON shape. +## Thanks +This code wasn't just me. It took a good chunk of my own brain plus a lot of +help from Dustin (@dstn.to), who was really generous explaining how he handles +the tricky parts: rate limits, caching, and getting Discord to actually trust +your requests. Thanks Dustin! And credit to Phineas for Lanyard, which inspired +the presence half of this. + ## Setup -### 1. Create the bot +### 1. Settings 1. https://discord.com/developers/applications → **New Application** → **Bot**. 2. **Reset Token**, copy it (this is `DISCORD_BOT_TOKEN`). 3. Under **Privileged Gateway Intents**, enable **PRESENCE INTENT** and **SERVER MEMBERS INTENT**. 4. Invite the bot to a server that contains the people you want to track (OAuth2 URL generator → scope `bot`). Presence is only visible for users sharing a server with the bot — same model as Lanyard. +5. Optionally set `TRACKED_GUILD_IDS` in `wrangler.jsonc` (comma-separated) to limit monitoring to specific servers; empty = every guild the bots can see. -### 2. Configure Cloudflare +### 2. Commands ```bash +# REQUIRED pnpm install # KV namespace for profile cache — paste the printed id into wrangler.jsonc @@ -21,15 +30,15 @@ pnpx wrangler kv namespace create PROFILE_CACHE pnpx wrangler secret put DISCORD_BOT_TOKEN # Optional, ToS risk — only if you want the rich badges: pnpx wrangler secret put DISCORD_USER_TOKEN -``` +# Optional second userbot +pnpx wrangler secret put DISCORD_USER_TOKEN2 -Optionally set `TRACKED_GUILD_IDS` in `wrangler.jsonc` (comma-separated) to limit monitoring to specific servers; empty = every guild the bot can see. +# If you need to update the X-Super-Properties to latest version +pnpm decode "X-Super-Properties: [BASE64 HERE]"pnpm -### 3. Run / deploy -```bash -# Local: copy .dev.vars.example -> .dev.vars and fill in tokens -pnpx wrangler dev +# Local test +pnpm dev # Production -pnpx wrangler deploy +pnpm deploy ``` \ No newline at end of file diff --git a/package.json b/package.json index 0f743e0..c412d06 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", - "tail": "wrangler tail" + "tail": "wrangler tail", + "decode": "node scripts/decode-super-properties.mjs" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260617.1", diff --git a/scripts/decode-super-properties.mjs b/scripts/decode-super-properties.mjs new file mode 100644 index 0000000..8315ee1 --- /dev/null +++ b/scripts/decode-super-properties.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/* + * Decode a Discord `X-Super-Properties` base64 blob and surface the fields that + * matter for the client fingerprint (build number, channel, browser, locale). + */ + +function clean(s) { + return s + .trim() + .replace(/^x-super-properties:\s*/i, "") // tolerate the full header line + .replace(/^["']|["']$/g, "") // tolerate surrounding quotes + .trim(); +} + +async function readStdin() { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + return Buffer.concat(chunks).toString("utf8"); +} + +const arg = process.argv.slice(2).join(" "); +const b64 = clean(arg || (await readStdin())); + +if (!b64) { + console.error('Usage: pnpm decode ""'); + process.exit(1); +} + +let props; +try { + props = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); +} catch (e) { + console.error("Could not decode/parse that value:", e.message); + process.exit(1); +} + +const pick = (k) => (props[k] ?? "(absent)"); + +console.log("\nDecoded X-Super-Properties:\n"); +console.log(JSON.stringify(props, null, 2)); + +console.log("\nFingerprint fields that matter:"); +console.log(` client_build_number : ${pick("client_build_number")}`); +console.log(` release_channel : ${pick("release_channel")}`); +console.log(` browser : ${pick("browser")} ${pick("browser_version")}`); +console.log(` os : ${pick("os")} ${pick("os_version")}`); +console.log(` system_locale : ${pick("system_locale")}`); + +if (props.client_build_number != null) { + console.log( + `\nDrop into wrangler.jsonc: "DISCORD_CLIENT_BUILD_NUMBER": "${props.client_build_number}"` + ); +} +console.log(""); diff --git a/src/discord/rest.ts b/src/discord/rest.ts index 5cf0925..df6a48f 100644 --- a/src/discord/rest.ts +++ b/src/discord/rest.ts @@ -13,6 +13,81 @@ function apiBase(env: Env): string { return `https://discord.com/api/v${v}`; } +// ---- client fingerprint ------------------------------------------------- +// Discord applies MUCH gentler rate limits to requests that look like its real +// client. Bare API calls to /profile get throttled hard; the same calls with a +// proper User-Agent + X-Super-Properties (base64 client-info) get the client's +// treatment. Keep BROWSER_UA + the build number reasonably current — Discord +// trusts up-to-date clients more. Build number is overridable via +// DISCORD_CLIENT_BUILD_NUMBER so you can bump it without a code change. +// Matched to a real Firefox web client (stable channel). Keep these in sync with +// an actual client's X-Super-Properties — re-grab and bump when they drift. +const BROWSER_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0"; + +// A real web client also sends these per-"launch" identifiers. Discord doesn't +// appear to validate them (Dustin runs static ones fine for months), so we mint +// one set per worker start and reuse it — i.e. behave like a single client launch. +let launchIdentity: { + client_launch_id: string; + launch_signature: string; + client_heartbeat_session_id: string; +} | null = null; + +function getLaunchIdentity() { + if (!launchIdentity) { + launchIdentity = { + client_launch_id: crypto.randomUUID(), + launch_signature: crypto.randomUUID(), + client_heartbeat_session_id: crypto.randomUUID(), + }; + } + return launchIdentity; +} + +function superProperties(env: Env): string { + const build = Number(env.DISCORD_CLIENT_BUILD_NUMBER || "565311"); + // Field set matched to a real Firefox WEB client. Do NOT add desktop-only + // fields (native_build_number, os_arch, X-Installation-ID, …) — a "Firefox" + // client that claims those is inconsistent and reads as MORE suspicious. + const props = { + os: "Windows", + browser: "Firefox", + device: "", + system_locale: "en-GB", + has_client_mods: false, + browser_user_agent: BROWSER_UA, + browser_version: "152.0", + os_version: "10", + referrer: "", + referring_domain: "", + referrer_current: "", + referring_domain_current: "", + release_channel: "stable", + client_build_number: build, + client_event_source: null, + ...getLaunchIdentity(), + client_app_state: "focused", + }; + return btoa(JSON.stringify(props)); +} + +/** Headers that make a user-token request look like the official web client. */ +function clientHeaders(env: Env, token: string): Record { + return { + Authorization: token, + "User-Agent": BROWSER_UA, + "X-Super-Properties": superProperties(env), + "X-Discord-Locale": "en-GB", + "X-Discord-Timezone": "Europe/London", + "X-Debug-Options": "bugReporterEnabled", + Accept: "*/*", + "Accept-Language": "en-GB,en;q=0.9", + Origin: "https://discord.com", + Referer: "https://discord.com/channels/@me", + }; +} + export interface RawDiscordUser { id: string; username: string; @@ -106,7 +181,7 @@ export async function fetchUserProfile(env: Env, id: string): Promise