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:
parent
062b976569
commit
bbf8422d85
27
README.md
27
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.
|
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
|
## Setup
|
||||||
|
|
||||||
### 1. Create the bot
|
### 1. Settings
|
||||||
1. https://discord.com/developers/applications → **New Application** → **Bot**.
|
1. https://discord.com/developers/applications → **New Application** → **Bot**.
|
||||||
2. **Reset Token**, copy it (this is `DISCORD_BOT_TOKEN`).
|
2. **Reset Token**, copy it (this is `DISCORD_BOT_TOKEN`).
|
||||||
3. Under **Privileged Gateway Intents**, enable **PRESENCE INTENT** and **SERVER MEMBERS INTENT**.
|
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.
|
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
|
```bash
|
||||||
|
# REQUIRED
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# KV namespace for profile cache — paste the printed id into wrangler.jsonc
|
# 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
|
pnpx wrangler secret put DISCORD_BOT_TOKEN
|
||||||
# Optional, ToS risk — only if you want the rich badges:
|
# Optional, ToS risk — only if you want the rich badges:
|
||||||
pnpx wrangler secret put DISCORD_USER_TOKEN
|
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
|
# Local test
|
||||||
```bash
|
pnpm dev
|
||||||
# Local: copy .dev.vars.example -> .dev.vars and fill in tokens
|
|
||||||
pnpx wrangler dev
|
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
pnpx wrangler deploy
|
pnpm deploy
|
||||||
```
|
```
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"dev": "wrangler dev",
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"tail": "wrangler tail"
|
"tail": "wrangler tail",
|
||||||
|
"decode": "node scripts/decode-super-properties.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260617.1",
|
"@cloudflare/workers-types": "^4.20260617.1",
|
||||||
|
|
|
||||||
|
|
@ -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("");
|
||||||
|
|
@ -13,6 +13,81 @@ function apiBase(env: Env): string {
|
||||||
return `https://discord.com/api/v${v}`;
|
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 {
|
export interface RawDiscordUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: 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++) {
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
const idx = (start + i) % tokens.length;
|
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) {
|
if (res.ok) {
|
||||||
return { data: (await res.json()) as RawProfileResponse, status: 200, retryAfter: 0 };
|
return { data: (await res.json()) as RawProfileResponse, status: 200, retryAfter: 0 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ export interface Env {
|
||||||
DISCORD_API_VERSION?: string;
|
DISCORD_API_VERSION?: string;
|
||||||
TRACKED_GUILD_IDS?: string;
|
TRACKED_GUILD_IDS?: string;
|
||||||
PROFILE_CACHE_TTL_SECONDS?: 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";
|
export type DiscordStatus = "online" | "idle" | "dnd" | "offline";
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,20 @@
|
||||||
},
|
},
|
||||||
// Non-secret vars. Secrets (tokens) are set via `wrangler secret put`.
|
// Non-secret vars. Secrets (tokens) are set via `wrangler secret put`.
|
||||||
"vars": {
|
"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.
|
// Comma-separated guild IDs the bot is in that you want monitored.
|
||||||
// Leave empty to monitor every guild the bot can see.
|
// 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
|
// Profile freshness window (seconds). Profiles change rarely and the
|
||||||
// user-token /profile route is heavily rate-limited, so keep this long.
|
// user-token /profile route is heavily rate-limited, so keep this long.
|
||||||
// Presence stays live via the gateway regardless of this value.
|
// 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue