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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
// ---- 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue