feat: add theme_colors/display_name_styles and make profile fetching rate-limit resilient (cache-first + merge, 429 cooldown, token rotation, client fingerprint)

This commit is contained in:
Clove 2026-06-19 19:54:26 +01:00
parent 062b976569
commit bbf8422d85
6 changed files with 162 additions and 13 deletions

View File

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

View File

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

View File

@ -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 "<X-Super-Properties base64>"');
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("");

View File

@ -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<string, string> {
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<UserProfil
for (let i = 0; i < tokens.length; i++) {
const idx = (start + i) % tokens.length;
const res = await fetch(url, { headers: { Authorization: tokens[idx] } });
const res = await fetch(url, { headers: clientHeaders(env, tokens[idx]) });
if (res.ok) {
return { data: (await res.json()) as RawProfileResponse, status: 200, retryAfter: 0 };
}

View File

@ -20,6 +20,9 @@ export interface Env {
DISCORD_API_VERSION?: string;
TRACKED_GUILD_IDS?: string;
PROFILE_CACHE_TTL_SECONDS?: string;
/** Current Discord client build number, sent in X-Super-Properties so the
* user-token /profile requests get the client's gentler rate limits. */
DISCORD_CLIENT_BUILD_NUMBER?: string;
}
export type DiscordStatus = "online" | "idle" | "dnd" | "offline";

View File

@ -44,13 +44,20 @@
},
// Non-secret vars. Secrets (tokens) are set via `wrangler secret put`.
"vars": {
"DISCORD_API_VERSION": "10",
// 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"
"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"
}
}