diff --git a/package.json b/package.json index c412d06..904a60b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,6 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20260617.1", "typescript": "^6.0.3", - "wrangler": "^4.103.0" + "wrangler": "^4.105.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b49287c..fa6a73d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e9bc9a5..3690e79 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/discord/clientBadges.ts b/src/discord/clientBadges.ts new file mode 100644 index 0000000..666d49a --- /dev/null +++ b/src/discord/clientBadges.ts @@ -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 { + 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 { + 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, + })); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a0c74ac..35cddf8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { await gatewayStub(env).fetch("https://do/connect"); }, -}; +}; \ No newline at end of file diff --git a/src/profile.ts b/src/profile.ts index 33af6af..77e6b54 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -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, }; -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 9320db9..a95dea7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { success: boolean; data?: T; error?: { code: string; message: string }; -} +} \ No newline at end of file