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": {
"@cloudflare/workers-types": "^4.20260617.1",
"typescript": "^6.0.3",
"wrangler": "^4.103.0"
"wrangler": "^4.105.0"
}
}

View File

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

View File

@ -3,5 +3,11 @@ allowBuilds:
sharp: true
workerd: true
minimumReleaseAgeExclude:
- miniflare@4.20260617.1
- wrangler@4.103.0
- miniflare@4.20260617.1 || 4.20260625.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,
presence,
badges: profile.badges,
clientBadges: profile.clientBadges,
connected_accounts: profile.connected_accounts,
wishlist: profile.wishlist ?? null,
updated_at: Date.now(),
@ -130,4 +131,4 @@ export default {
async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
await gatewayStub(env).fetch("https://do/connect");
},
};
};

View File

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

View File

@ -52,6 +52,20 @@ export interface UnifiedBadge {
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 {
type: string;
id: string;
@ -182,6 +196,10 @@ export interface UnifiedRecord {
/** null when the user shares no monitored guild with the bot. */
presence: UnifiedPresence | null;
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[];
/** Discord Shop collectibles the user saved to their profile wishlist.
* null when unavailable (no user token / proxy, or the source was blocked);
@ -198,4 +216,4 @@ export interface ApiEnvelope<T> {
success: boolean;
data?: T;
error?: { code: string; message: string };
}
}