Add 3rd party badges

This commit is contained in:
Clove 2026-06-26 02:11:03 +01:00
parent a6a8b8a437
commit 17a67b5892
7 changed files with 179 additions and 45 deletions

View File

@ -14,6 +14,6 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260617.1", "@cloudflare/workers-types": "^4.20260617.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"wrangler": "^4.103.0" "wrangler": "^4.105.0"
} }
} }

View File

@ -15,8 +15,8 @@ importers:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3 version: 6.0.3
wrangler: wrangler:
specifier: ^4.103.0 specifier: ^4.105.0
version: 4.103.0(@cloudflare/workers-types@4.20260617.1) version: 4.105.0(@cloudflare/workers-types@4.20260617.1)
packages: packages:
@ -33,32 +33,32 @@ packages:
workerd: workerd:
optional: true optional: true
'@cloudflare/workerd-darwin-64@1.20260617.1': '@cloudflare/workerd-darwin-64@1.20260625.1':
resolution: {integrity: sha512-jWwmgEVVWbsHNrLSNXzwjJaH90VzRxq1cWkQFUidxyeUPnMxemeNE8I9qFAfrpzGgE11e9sKDcE3ettJW08swQ==} resolution: {integrity: sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260617.1': '@cloudflare/workerd-darwin-arm64@1.20260625.1':
resolution: {integrity: sha512-LHH7b565g9znfCUOkwbec6FG2rmRbsgCy6aJiU9KN662mNheWl5sw/iKleiFSiljPKQQP3HkjnC/NSkdgi/aSA==} resolution: {integrity: sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-linux-64@1.20260617.1': '@cloudflare/workerd-linux-64@1.20260625.1':
resolution: {integrity: sha512-FMnaAKXe4Cfd8TQurCVd9fs2XQVBFRCsP+Id/SRdUv89MlwYu9zXfoyx6BxM+brPTIUK38SHbo8iaxiwzLi9JQ==} resolution: {integrity: sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260617.1': '@cloudflare/workerd-linux-arm64@1.20260625.1':
resolution: {integrity: sha512-MRoifFYcqbxxIIQy7PqO5tFY/qPFSnjXzakWl0sO93l+HLyG35jRAgOi6jfqa4kBxc7gKKtH861DcewjxUfkjA==} resolution: {integrity: sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@cloudflare/workerd-windows-64@1.20260617.1': '@cloudflare/workerd-windows-64@1.20260625.1':
resolution: {integrity: sha512-rgBV9wQrv0OSKgCTTbhFUFY3sLGNANZ88aqaLvtmEn2gmbFVb1J4PDGochVUdB7NSEp4D/ghHva6/8SZmbONpw==} resolution: {integrity: sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@ -436,8 +436,8 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
miniflare@4.20260617.1: miniflare@4.20260625.0:
resolution: {integrity: sha512-Go3/gzStm99QHptsSgU+q1S+xDfLoRgwjJNY80kaTVi0ENhTyqKq+sc4xZiWBSbM7uUcJwmzm8+QFKtcYLJ9nw==} resolution: {integrity: sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==}
engines: {node: '>=22.0.0'} engines: {node: '>=22.0.0'}
hasBin: true hasBin: true
@ -475,17 +475,17 @@ packages:
unenv@2.0.0-rc.24: unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260617.1: workerd@1.20260625.1:
resolution: {integrity: sha512-Re5pl6pdowt3ZmWUzGlOuB7jbRIIPetgKalmo4cYmucQnVhpo7/3e4MfpekbhLi2EhZZz5EY9NWRu8zFzuEZew==} resolution: {integrity: sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==}
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
wrangler@4.103.0: wrangler@4.105.0:
resolution: {integrity: sha512-3Lv1P5t2xcSEkSTKtG+Lz+3JFryuU7YPLkaCUj7gNe+CJsjZJLtUwqsh1x595QBxkIbCE0GAvDx2DCJUU4+oqw==} resolution: {integrity: sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==}
engines: {node: '>=22.0.0'} engines: {node: '>=22.0.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@cloudflare/workers-types': ^4.20260617.1 '@cloudflare/workers-types': ^4.20260625.1
peerDependenciesMeta: peerDependenciesMeta:
'@cloudflare/workers-types': '@cloudflare/workers-types':
optional: true optional: true
@ -512,25 +512,25 @@ snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1)': '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1)':
dependencies: dependencies:
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
optionalDependencies: optionalDependencies:
workerd: 1.20260617.1 workerd: 1.20260625.1
'@cloudflare/workerd-darwin-64@1.20260617.1': '@cloudflare/workerd-darwin-64@1.20260625.1':
optional: true optional: true
'@cloudflare/workerd-darwin-arm64@1.20260617.1': '@cloudflare/workerd-darwin-arm64@1.20260625.1':
optional: true optional: true
'@cloudflare/workerd-linux-64@1.20260617.1': '@cloudflare/workerd-linux-64@1.20260625.1':
optional: true optional: true
'@cloudflare/workerd-linux-arm64@1.20260617.1': '@cloudflare/workerd-linux-arm64@1.20260625.1':
optional: true optional: true
'@cloudflare/workerd-windows-64@1.20260617.1': '@cloudflare/workerd-windows-64@1.20260625.1':
optional: true optional: true
'@cloudflare/workers-types@4.20260617.1': {} '@cloudflare/workers-types@4.20260617.1': {}
@ -785,12 +785,12 @@ snapshots:
kleur@4.1.5: {} kleur@4.1.5: {}
miniflare@4.20260617.1: miniflare@4.20260625.0:
dependencies: dependencies:
'@cspotcode/source-map-support': 0.8.1 '@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5 sharp: 0.34.5
undici: 7.28.0 undici: 7.28.0
workerd: 1.20260617.1 workerd: 1.20260625.1
ws: 8.21.0 ws: 8.21.0
youch: 4.1.0-beta.10 youch: 4.1.0-beta.10
transitivePeerDependencies: transitivePeerDependencies:
@ -847,24 +847,24 @@ snapshots:
dependencies: dependencies:
pathe: 2.0.3 pathe: 2.0.3
workerd@1.20260617.1: workerd@1.20260625.1:
optionalDependencies: optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260617.1 '@cloudflare/workerd-darwin-64': 1.20260625.1
'@cloudflare/workerd-darwin-arm64': 1.20260617.1 '@cloudflare/workerd-darwin-arm64': 1.20260625.1
'@cloudflare/workerd-linux-64': 1.20260617.1 '@cloudflare/workerd-linux-64': 1.20260625.1
'@cloudflare/workerd-linux-arm64': 1.20260617.1 '@cloudflare/workerd-linux-arm64': 1.20260625.1
'@cloudflare/workerd-windows-64': 1.20260617.1 '@cloudflare/workerd-windows-64': 1.20260625.1
wrangler@4.103.0(@cloudflare/workers-types@4.20260617.1): wrangler@4.105.0(@cloudflare/workers-types@4.20260617.1):
dependencies: dependencies:
'@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260617.1) '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1)
blake3-wasm: 2.1.5 blake3-wasm: 2.1.5
esbuild: 0.28.1 esbuild: 0.28.1
miniflare: 4.20260617.1 miniflare: 4.20260625.0
path-to-regexp: 6.3.0 path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
workerd: 1.20260617.1 workerd: 1.20260625.1
optionalDependencies: optionalDependencies:
'@cloudflare/workers-types': 4.20260617.1 '@cloudflare/workers-types': 4.20260617.1
fsevents: 2.3.3 fsevents: 2.3.3

View File

@ -3,5 +3,11 @@ allowBuilds:
sharp: true sharp: true
workerd: true workerd: true
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- miniflare@4.20260617.1 - miniflare@4.20260617.1 || 4.20260625.0
- wrangler@4.103.0 - wrangler@4.103.0 || 4.105.0
- '@cloudflare/workerd-darwin-64@1.20260625.1'
- '@cloudflare/workerd-darwin-arm64@1.20260625.1'
- '@cloudflare/workerd-linux-64@1.20260625.1'
- '@cloudflare/workerd-linux-arm64@1.20260625.1'
- '@cloudflare/workerd-windows-64@1.20260625.1'
- workerd@1.20260625.1

View File

@ -0,0 +1,93 @@
import type { Env, UnifiedClientBadge } from "../types";
const API_BASE = "https://badges.equicord.org";
interface EquibadgesResponse {
status: number;
/** Flat array (no `separated` query) — one list across all services. */
badges: { tooltip: string; badge: string }[];
}
function cacheKey(id: string): string {
return `clientbadges:${id}`;
}
/** Cache freshness window — these change rarely, so an hour is plenty. */
const TTL_SECONDS = 3600;
/**
* Fetch a user's third-party client-mod badges, cache-first.
* Returns [] when the user has none, null when the aggregator couldn't be
* reached and there's nothing usable cached either.
*/
export async function getClientBadges(
env: Env,
id: string,
ctx?: ExecutionContext,
force = false
): Promise<UnifiedClientBadge[] | null> {
if (!force) {
const cached = (await env.PROFILE_CACHE.get(cacheKey(id), "json")) as
| UnifiedClientBadge[]
| null;
if (cached) return cached;
}
const fetched = await fetchClientBadges(id);
// 404 ("no badges found") is a valid, cacheable empty result — only a
// genuine fetch failure (network error, 5xx, etc.) should fall through.
if (fetched === undefined) {
if (force) {
// Caller explicitly asked for a fresh fetch; fall back to whatever's
// cached (even if stale) rather than returning null outright.
const stale = (await env.PROFILE_CACHE.get(cacheKey(id), "json")) as
| UnifiedClientBadge[]
| null;
return stale ?? null;
}
return null;
}
const write = env.PROFILE_CACHE.put(cacheKey(id), JSON.stringify(fetched), {
expirationTtl: TTL_SECONDS,
});
if (ctx) ctx.waitUntil(write);
else await write;
return fetched;
}
/** Returns the badge list, [] for none, or undefined on a fetch failure. */
async function fetchClientBadges(id: string): Promise<UnifiedClientBadge[] | undefined> {
let res: Response;
try {
res = await fetch(`${API_BASE}/${id}`);
} catch {
return undefined;
}
if (res.status === 404) return [];
if (!res.ok) return undefined;
let data: EquibadgesResponse;
try {
data = (await res.json()) as EquibadgesResponse;
} catch {
return undefined;
}
if (!Array.isArray(data.badges)) return [];
return data.badges
.filter((b) => b && typeof b.badge === "string")
// The aggregator also throws in official Discord badges (HypeSquad,
// Nitro, etc) under /public/badges/discord/. Those already live in
// `data.badges` via Discord's own flags, so drop them here to avoid
// duplicating them under clientBadges.
.filter((b) => !/\/public\/badges\/discord\//i.test(b.badge))
.map((b) => ({
tooltip: typeof b.tooltip === "string" ? b.tooltip : "",
icon_url: b.badge,
}));
}

View File

@ -115,6 +115,7 @@ export default {
user: profile.user, user: profile.user,
presence, presence,
badges: profile.badges, badges: profile.badges,
clientBadges: profile.clientBadges,
connected_accounts: profile.connected_accounts, connected_accounts: profile.connected_accounts,
wishlist: profile.wishlist ?? null, wishlist: profile.wishlist ?? null,
updated_at: Date.now(), updated_at: Date.now(),
@ -130,4 +131,4 @@ export default {
async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> { async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
await gatewayStub(env).fetch("https://do/connect"); await gatewayStub(env).fetch("https://do/connect");
}, },
}; };

View File

@ -9,10 +9,12 @@
import type { import type {
Env, Env,
UnifiedBadge, UnifiedBadge,
UnifiedClientBadge,
UnifiedConnectedAccount, UnifiedConnectedAccount,
UnifiedUser, UnifiedUser,
UnifiedWishlistItem, UnifiedWishlistItem,
} from "./types"; } from "./types";
import { getClientBadges } from "./discord/clientBadges";
import { import {
avatarDecorationImageUrl, avatarDecorationImageUrl,
avatarUrl, avatarUrl,
@ -37,6 +39,8 @@ import {
export interface ProfileResult { export interface ProfileResult {
user: UnifiedUser; user: UnifiedUser;
badges: UnifiedBadge[]; badges: UnifiedBadge[];
/** Third-party client-mod badges (Vencord/Equicord/Aliucord/etc). */
clientBadges: UnifiedClientBadge[] | null;
connected_accounts: UnifiedConnectedAccount[]; connected_accounts: UnifiedConnectedAccount[];
/** Shop collectibles saved to the profile; null when unavailable. */ /** Shop collectibles saved to the profile; null when unavailable. */
wishlist: UnifiedWishlistItem[] | null; wishlist: UnifiedWishlistItem[] | null;
@ -393,6 +397,7 @@ function mergeRichOverBot(cached: CachedProfile, bot: ProfileResult): CachedProf
display_name_styles: cached.user.display_name_styles, display_name_styles: cached.user.display_name_styles,
}, },
badges: cached.badges.length ? cached.badges : bot.badges, badges: cached.badges.length ? cached.badges : bot.badges,
clientBadges: bot.clientBadges != null ? bot.clientBadges : cached.clientBadges,
connected_accounts: cached.connected_accounts.length connected_accounts: cached.connected_accounts.length
? cached.connected_accounts ? cached.connected_accounts
: bot.connected_accounts, : bot.connected_accounts,
@ -411,6 +416,7 @@ async function writeCache(env: Env, id: string, result: ProfileResult): Promise<
JSON.stringify({ JSON.stringify({
user: result.user, user: result.user,
badges: result.badges, badges: result.badges,
clientBadges: result.clientBadges,
connected_accounts: result.connected_accounts, connected_accounts: result.connected_accounts,
wishlist: result.wishlist, wishlist: result.wishlist,
}), }),
@ -481,9 +487,17 @@ async function buildFreshProfile(
// Wishlist rides on the rich profile (`wishlist_settings`); resolve its // Wishlist rides on the rich profile (`wishlist_settings`); resolve its
// SKUs to names + images (cache-first). null if the field is absent. // SKUs to names + images (cache-first). null if the field is absent.
const wishlist = await buildWishlist(env, profile, ctx, force); const wishlist = await buildWishlist(env, profile, ctx, force);
const clientBadges = await getClientBadges(env, id, ctx, force);
return { return {
result: { user: buildUser(u, bio, pronouns, themeColors), badges, connected_accounts: connected, wishlist, source: "user" }, result: {
user: buildUser(u, bio, pronouns, themeColors),
badges,
clientBadges,
connected_accounts: connected,
wishlist,
source: "user",
},
richStatus, richStatus,
retryAfter, retryAfter,
}; };
@ -493,10 +507,12 @@ async function buildFreshProfile(
// have here, so it's null and the cache-merge keeps any previously cached one. // 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 };
const clientBadges = await getClientBadges(env, id, ctx, force);
return { return {
result: { result: {
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),
clientBadges,
connected_accounts: [], connected_accounts: [],
wishlist: null, wishlist: null,
source: "bot", source: "bot",
@ -504,4 +520,4 @@ async function buildFreshProfile(
richStatus, richStatus,
retryAfter, retryAfter,
}; };
} }

View File

@ -52,6 +52,20 @@ export interface UnifiedBadge {
source: "flags" | "profile"; source: "flags" | "profile";
} }
/**
* A badge sourced from a third-party client-mod badge aggregator
* (badges.equicord.org), covering Vencord/Equicord/Aliucord/etc + the
* "global badges" set it aggregates. Deliberately separate from
* `badges` (Discord's own flag/profile badges) since these come from an
* unofficial third-party service.
*/
export interface UnifiedClientBadge {
/** Tooltip text the client mod shows for this badge. */
tooltip: string;
/** Absolute URL to the badge icon (png/gif/webp/svg). */
icon_url: string;
}
export interface UnifiedConnectedAccount { export interface UnifiedConnectedAccount {
type: string; type: string;
id: string; id: string;
@ -182,6 +196,10 @@ export interface UnifiedRecord {
/** null when the user shares no monitored guild with the bot. */ /** null when the user shares no monitored guild with the bot. */
presence: UnifiedPresence | null; presence: UnifiedPresence | null;
badges: UnifiedBadge[]; badges: UnifiedBadge[];
/** Third-party client-mod badges (Vencord/Equicord/Aliucord/etc, via
* badges.equicord.org's "global badges" aggregation). [] if none found,
* null if the aggregator couldn't be reached. */
clientBadges: UnifiedClientBadge[] | null;
connected_accounts: UnifiedConnectedAccount[]; connected_accounts: UnifiedConnectedAccount[];
/** Discord Shop collectibles the user saved to their profile wishlist. /** Discord Shop collectibles the user saved to their profile wishlist.
* null when unavailable (no user token / proxy, or the source was blocked); * null when unavailable (no user token / proxy, or the source was blocked);
@ -198,4 +216,4 @@ export interface ApiEnvelope<T> {
success: boolean; success: boolean;
data?: T; data?: T;
error?: { code: string; message: string }; error?: { code: string; message: string };
} }