commit 0499e9da177af431988eed982264554f2e8d63b6 Author: Clove Twilight Date: Thu Jun 18 21:39:44 2026 +0100 first commit diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..3e7c115 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,12 @@ +# Copy to `.dev.vars` for local `wrangler dev`, and set the same values in +# production with `wrangler secret put `. + +# Bot token — powers presence (gateway) and basic profile (/users/:id). +# Discord Dev Portal > your app > Bot > Reset Token. +DISCORD_BOT_TOKEN= + +# OPTIONAL user token — only used to read the RICH profile endpoint +# (Nitro/boost/quest/orb/gifting badges, connected accounts, bio, pronouns). +# WARNING: using a user token is self-botting and against Discord's ToS; +# it can get the account banned. Leave blank to run bot-token-only. +DISCORD_USER_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..993975a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +dist/ +.dev.vars +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..db3e2d8 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# dough-restful + +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. + +## Setup + +### 1. Create the bot +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. + +### 2. Configure Cloudflare +```bash +pnpm install + +# KV namespace for profile cache — paste the printed id into wrangler.jsonc +pnpx wrangler kv namespace create PROFILE_CACHE + +# Secrets +pnpx wrangler secret put DISCORD_BOT_TOKEN +# Optional, ToS risk — only if you want the rich badges: +pnpx wrangler secret put DISCORD_USER_TOKEN +``` + +Optionally set `TRACKED_GUILD_IDS` in `wrangler.jsonc` (comma-separated) to limit monitoring to specific servers; empty = every guild the bot can see. + +### 3. Run / deploy +```bash +# Local: copy .dev.vars.example -> .dev.vars and fill in tokens +pnpx wrangler dev + +# Production +pnpx wrangler deploy +``` \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..fabf25c --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "dough-restful", + "version": "0.1.0", + "private": true, + "description": "Combined Discord presence (Lanyard-style) + profile/badges (dstn.to-style) API on a single Cloudflare Worker + Durable Object, backed by one Discord bot.", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "tail": "wrangler tail" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240909.0", + "typescript": "^5.5.4", + "wrangler": "^3.78.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a7f80f2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,975 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20240909.0 + version: 4.20260617.1 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + wrangler: + specifier: ^3.78.0 + version: 3.114.17(@cloudflare/workers-types@4.20260617.1) + +packages: + + '@cloudflare/kv-asset-handler@0.3.4': + resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} + engines: {node: '>=16.13'} + + '@cloudflare/unenv-preset@2.0.2': + resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==} + peerDependencies: + unenv: 2.0.0-rc.14 + workerd: ^1.20250124.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20250718.0': + resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20250718.0': + resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20250718.0': + resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260617.1': + resolution: {integrity: sha512-HdbP3CNcdMZBwegitFDjWvzv+6wPkFXvV9gBXMnf6RjV2Cy3W8TJL3IhSEGul0S6F1DHjnucP7lrpIsvkzNEjA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@esbuild-plugins/node-globals-polyfill@0.2.3': + resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} + peerDependencies: + esbuild: '*' + + '@esbuild-plugins/node-modules-polyfill@0.2.2': + resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} + peerDependencies: + esbuild: '*' + + '@esbuild/android-arm64@0.17.19': + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.17.19': + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.17.19': + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.17.19': + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.17.19': + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.17.19': + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.17.19': + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.17.19': + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.17.19': + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.17.19': + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.17.19': + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.17.19': + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.17.19': + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.17.19': + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.17.19': + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.17.19': + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.17.19': + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.17.19': + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.17.19': + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.17.19': + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.17.19': + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.17.19': + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + as-table@1.0.55: + resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + data-uri-to-buffer@2.0.2: + resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-source@2.0.12: + resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + miniflare@3.20250718.3: + resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==} + engines: {node: '>=16.13'} + hasBin: true + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + printable-characters@1.0.42: + resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + + rollup-plugin-inject@3.0.2: + resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. + + rollup-plugin-node-polyfills@0.2.1: + resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + stacktracey@2.2.0: + resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} + + stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + + unenv@2.0.0-rc.14: + resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} + + workerd@1.20250718.0: + resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@3.114.17: + resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20250408.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch@3.3.4: + resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + + zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + +snapshots: + + '@cloudflare/kv-asset-handler@0.3.4': + dependencies: + mime: 3.0.0 + + '@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0)': + dependencies: + unenv: 2.0.0-rc.14 + optionalDependencies: + workerd: 1.20250718.0 + + '@cloudflare/workerd-darwin-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-64@1.20250718.0': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20250718.0': + optional: true + + '@cloudflare/workerd-windows-64@1.20250718.0': + optional: true + + '@cloudflare/workers-types@4.20260617.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + + '@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)': + dependencies: + esbuild: 0.17.19 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + + '@esbuild/android-arm64@0.17.19': + optional: true + + '@esbuild/android-arm@0.17.19': + optional: true + + '@esbuild/android-x64@0.17.19': + optional: true + + '@esbuild/darwin-arm64@0.17.19': + optional: true + + '@esbuild/darwin-x64@0.17.19': + optional: true + + '@esbuild/freebsd-arm64@0.17.19': + optional: true + + '@esbuild/freebsd-x64@0.17.19': + optional: true + + '@esbuild/linux-arm64@0.17.19': + optional: true + + '@esbuild/linux-arm@0.17.19': + optional: true + + '@esbuild/linux-ia32@0.17.19': + optional: true + + '@esbuild/linux-loong64@0.17.19': + optional: true + + '@esbuild/linux-mips64el@0.17.19': + optional: true + + '@esbuild/linux-ppc64@0.17.19': + optional: true + + '@esbuild/linux-riscv64@0.17.19': + optional: true + + '@esbuild/linux-s390x@0.17.19': + optional: true + + '@esbuild/linux-x64@0.17.19': + optional: true + + '@esbuild/netbsd-x64@0.17.19': + optional: true + + '@esbuild/openbsd-x64@0.17.19': + optional: true + + '@esbuild/sunos-x64@0.17.19': + optional: true + + '@esbuild/win32-arm64@0.17.19': + optional: true + + '@esbuild/win32-ia32@0.17.19': + optional: true + + '@esbuild/win32-x64@0.17.19': + optional: true + + '@fastify/busboy@2.1.1': {} + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + acorn-walk@8.3.2: {} + + acorn@8.14.0: {} + + as-table@1.0.55: + dependencies: + printable-characters: 1.0.42 + + blake3-wasm@2.1.5: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + optional: true + + color-name@1.1.4: + optional: true + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + cookie@0.7.2: {} + + data-uri-to-buffer@2.0.2: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: + optional: true + + esbuild@0.17.19: + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + + escape-string-regexp@4.0.0: {} + + estree-walker@0.6.1: {} + + exit-hook@2.2.1: {} + + exsolve@1.0.8: {} + + fsevents@2.3.3: + optional: true + + get-source@2.0.12: + dependencies: + data-uri-to-buffer: 2.0.2 + source-map: 0.6.1 + + glob-to-regexp@0.4.1: {} + + is-arrayish@0.3.4: + optional: true + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + mime@3.0.0: {} + + miniflare@3.20250718.3: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.29.0 + workerd: 1.20250718.0 + ws: 8.18.0 + youch: 3.3.4 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + mustache@4.2.0: {} + + ohash@2.0.11: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + printable-characters@1.0.42: {} + + rollup-plugin-inject@3.0.2: + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + + rollup-plugin-node-polyfills@0.2.1: + dependencies: + rollup-plugin-inject: 3.0.2 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + semver@7.8.4: + optional: true + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.8.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + optional: true + + source-map@0.6.1: {} + + sourcemap-codec@1.4.8: {} + + stacktracey@2.2.0: + dependencies: + as-table: 1.0.55 + get-source: 2.0.12 + + stoppable@1.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + + unenv@2.0.0-rc.14: + dependencies: + defu: 6.1.7 + exsolve: 1.0.8 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.4 + + workerd@1.20250718.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20250718.0 + '@cloudflare/workerd-darwin-arm64': 1.20250718.0 + '@cloudflare/workerd-linux-64': 1.20250718.0 + '@cloudflare/workerd-linux-arm64': 1.20250718.0 + '@cloudflare/workerd-windows-64': 1.20250718.0 + + wrangler@3.114.17(@cloudflare/workers-types@4.20260617.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0) + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + esbuild: 0.17.19 + miniflare: 3.20250718.3 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.14 + workerd: 1.20250718.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20260617.1 + fsevents: 2.3.3 + sharp: 0.33.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + youch@3.3.4: + dependencies: + cookie: 0.7.2 + mustache: 4.2.0 + stacktracey: 2.2.0 + + zod@3.22.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..661850f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true + sharp: true + workerd: true diff --git a/src/discord/constants.ts b/src/discord/constants.ts new file mode 100644 index 0000000..e0a3570 --- /dev/null +++ b/src/discord/constants.ts @@ -0,0 +1,78 @@ +/* ===================================================================== + * discord/constants.ts — gateway opcodes, intents, badge table, CDN. + * ===================================================================== */ + +export const CDN = "https://cdn.discordapp.com"; + +/** Gateway opcodes (Discord v10). */ +export const Op = { + Dispatch: 0, + Heartbeat: 1, + Identify: 2, + PresenceUpdate: 3, + Resume: 6, + Reconnect: 7, + InvalidSession: 9, + Hello: 10, + HeartbeatAck: 11, +} as const; + +/** + * GUILDS (1<<0) | GUILD_MEMBERS (1<<1) | GUILD_PRESENCES (1<<8). + * GUILD_MEMBERS and GUILD_PRESENCES are privileged — enable them in the + * Discord Developer Portal under Bot > Privileged Gateway Intents. + */ +export const INTENTS = (1 << 0) | (1 << 1) | (1 << 8); // 259 + +/** Classic public-flag badges: [bit, id, description, badge-icons hash]. */ +export const FLAG_BADGES: ReadonlyArray<[number, string, string, string]> = [ + [1 << 0, "staff", "Discord Staff", "5e74e9b61934fc1f67c65515d1f7e60d"], + [1 << 1, "partner", "Partnered Server Owner", "3f9748e53446a137a052f3454e2de41e"], + [1 << 2, "hypesquad", "HypeSquad Events", "bf01d1073931f921909045f3a39fd264"], + [1 << 3, "bug_hunter_level_1", "Bug Hunter", "2717692c7dca7289b35297368a940dd0"], + [1 << 6, "hypesquad_house_1", "HypeSquad Bravery", "8a88d63823d8a71cd5e390baa45efa02"], + [1 << 7, "hypesquad_house_2", "HypeSquad Brilliance", "011940fd013da3f7fb926e4a1cd2e618"], + [1 << 8, "hypesquad_house_3", "HypeSquad Balance", "3aa41de486fa12454c3761e8e223442e"], + [1 << 9, "premium_early_supporter", "Early Supporter", "7060786766c9c840eb3019e725d2b358"], + [1 << 14, "bug_hunter_level_2", "Bug Hunter Gold", "848f79194d4be5ff5f81505cbd0ce1e6"], + [1 << 17, "verified_developer", "Early Verified Bot Developer", "6df5892e0f35b051f8b61eace34f4967"], + [1 << 18, "certified_moderator", "Moderator Programs Alumni", "fee1624003e2fee35cb398e125dc479b"], + [1 << 22, "active_developer", "Active Developer", "6bdc42827a38498929a4920da12695d9"], +]; + +export function isAnimated(hash: string | null | undefined): boolean { + return typeof hash === "string" && hash.startsWith("a_"); +} + +export function avatarUrl(id: string, hash: string | null, size = 256): string { + if (!hash) { + // default avatar bucket from the (new) id-based algorithm + const idx = Number((BigInt(id) >> 22n) % 6n); + return `${CDN}/embed/avatars/${idx}.png`; + } + const ext = isAnimated(hash) ? "gif" : "png"; + return `${CDN}/avatars/${id}/${hash}.${ext}?size=${size}`; +} + +export function bannerUrl(id: string, hash: string | null, size = 600): string | null { + if (!hash) return null; + const ext = isAnimated(hash) ? "gif" : "png"; + return `${CDN}/banners/${id}/${hash}.${ext}?size=${size}`; +} + +export function decorationUrl(asset: string): string { + // Decorations are animated APNG served at .png — do NOT add ?size or proxy. + return `${CDN}/avatar-decoration-presets/${asset}.png`; +} + +export function badgeIconUrl(hash: string): string { + return `${CDN}/badge-icons/${hash}.png`; +} + +export function clanBadgeUrl(guildId: string, badge: string): string { + return `${CDN}/guild-tag-badges/${guildId}/${badge}.png?size=24`; +} + +export function emojiUrl(id: string, animated: boolean): string { + return `${CDN}/emojis/${id}.${animated ? "gif" : "png"}?size=32`; +} diff --git a/src/discord/rest.ts b/src/discord/rest.ts new file mode 100644 index 0000000..7bf0b74 --- /dev/null +++ b/src/discord/rest.ts @@ -0,0 +1,77 @@ +/* ===================================================================== + * discord/rest.ts — thin Discord REST client. + * + * Two callers: + * fetchBotUser() — bot token, /users/:id (basic, always safe) + * fetchUserProfile() — user token, /users/:id/profile (rich, ToS risk) + * ===================================================================== */ + +import type { Env } from "../types"; + +function apiBase(env: Env): string { + const v = env.DISCORD_API_VERSION || "10"; + return `https://discord.com/api/v${v}`; +} + +export interface RawDiscordUser { + id: string; + username: string; + global_name?: string | null; + display_name?: string | null; + avatar: string | null; + banner?: string | null; + accent_color?: number | null; + public_flags?: number; + flags?: number; + avatar_decoration_data?: { asset: string; sku_id?: string | null } | null; + primary_guild?: { + identity_guild_id?: string | null; + identity_enabled?: boolean | null; + tag?: string | null; + badge?: string | null; + } | null; + collectibles?: Record | null; + discriminator?: string; +} + +export interface RawProfileBadge { + id: string; + description: string; + icon: string; + link?: string; +} + +export interface RawProfileResponse { + user?: RawDiscordUser & { bio?: string }; + user_profile?: { bio?: string; pronouns?: string; accent_color?: number | null }; + badges?: RawProfileBadge[]; + connected_accounts?: Array<{ type: string; id: string; name: string; verified: boolean }>; + premium_type?: number; + premium_since?: string | null; + premium_guild_since?: string | null; +} + +/** Basic user via bot token. Returns null on 404 / failure. */ +export async function fetchBotUser(env: Env, id: string): Promise { + const res = await fetch(`${apiBase(env)}/users/${id}`, { + headers: { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}` }, + }); + if (!res.ok) return null; + return (await res.json()) as RawDiscordUser; +} + +/** + * Rich profile via USER token (self-bot — ToS risk). Returns null if no user + * token is configured or the request fails, so callers degrade gracefully. + */ +export async function fetchUserProfile(env: Env, id: string): Promise { + if (!env.DISCORD_USER_TOKEN) return null; + const url = + `${apiBase(env)}/users/${id}/profile` + + `?with_mutual_guilds=false&with_mutual_friends=false`; + const res = await fetch(url, { + headers: { Authorization: env.DISCORD_USER_TOKEN }, + }); + if (!res.ok) return null; + return (await res.json()) as RawProfileResponse; +} diff --git a/src/gateway.ts b/src/gateway.ts new file mode 100644 index 0000000..cf2cca1 --- /dev/null +++ b/src/gateway.ts @@ -0,0 +1,359 @@ +/* ===================================================================== + * gateway.ts — GatewayManager Durable Object. + * + * A single DO instance: + * • holds ONE Discord gateway WebSocket (identify / heartbeat / resume), + * • ingests presences from READY/GUILD_CREATE/PRESENCE_UPDATE, + * • keeps an in-memory userId -> UnifiedPresence map, + * • accepts browser WebSockets and speaks the Lanyard socket protocol + * (op1 Hello, op2 Initialize, op3 Heartbeat, op0 INIT_STATE/PRESENCE_UPDATE), + * • broadcasts PRESENCE_UPDATE to subscribed clients. + * + * State is in-memory: if the DO is evicted the gateway reconnects (via cron + * or alarm) and GUILD_CREATE repopulates presences within a second or two. + * ===================================================================== */ + +import type { Env, UnifiedPresence } from "./types"; +import { INTENTS, Op } from "./discord/constants"; +import { buildPresence, offlinePresence, type RawPresence } from "./presence"; + +const CLIENT_HEARTBEAT_INTERVAL = 30_000; + +interface ClientSub { + all: boolean; + ids: Set; +} + +export class GatewayManager implements DurableObject { + private state: DurableObjectState; + private env: Env; + + private discord: WebSocket | null = null; + private connecting = false; + private seq: number | null = null; + private sessionId: string | null = null; + private resumeUrl: string | null = null; + private heartbeatTimer: ReturnType | null = null; + private heartbeatAcked = true; + private reconnectAttempts = 0; + + private presences = new Map(); + private clients = new Map(); + private dispatchSeq = 0; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + // ---- HTTP surface (called by the Worker) ----------------------------- + async fetch(req: Request): Promise { + const url = new URL(req.url); + + if (url.pathname === "/ws") { + return this.handleClientUpgrade(req); + } + + // Ensure the gateway is connected (cron / on-demand). + await this.ensureConnected(); + await this.ensureAlarm(); + + if (url.pathname === "/connect") { + return Response.json({ connected: !!this.discord, tracked: this.presences.size }); + } + if (url.pathname === "/presences") { + return Response.json(Object.fromEntries(this.presences)); + } + if (url.pathname.startsWith("/presence/")) { + const id = url.pathname.slice("/presence/".length); + const p = this.presences.get(id); + return Response.json({ monitored: !!p, presence: p ?? null }); + } + return new Response("not found", { status: 404 }); + } + + // The alarm is a keepalive backstop in case cron is delayed. + async alarm(): Promise { + await this.ensureConnected(); + await this.ensureAlarm(); + } + + private async ensureAlarm(): Promise { + const existing = await this.state.storage.getAlarm(); + if (existing == null) { + await this.state.storage.setAlarm(Date.now() + 45_000); + } + } + + // ---- Discord gateway connection -------------------------------------- + private async ensureConnected(): Promise { + if (this.discord || this.connecting) return; + this.connecting = true; + try { + const base = this.resumeUrl ?? "https://gateway.discord.gg"; + const wsUrl = base.replace(/^wss:\/\//, "https://") + "/?v=10&encoding=json"; + const resp = await fetch(wsUrl, { headers: { Upgrade: "websocket" } }); + const ws = resp.webSocket; + if (!ws) throw new Error(`no webSocket on gateway response (status ${resp.status})`); + ws.accept(); + this.discord = ws; + this.heartbeatAcked = true; + + ws.addEventListener("message", (e) => this.onDiscordMessage(e)); + ws.addEventListener("close", (e) => this.onDiscordClose(e.code, e.reason)); + ws.addEventListener("error", () => this.onDiscordClose(1006, "error")); + } catch (err) { + this.scheduleReconnect(); + } finally { + this.connecting = false; + } + } + + private onDiscordMessage(e: MessageEvent): void { + let msg: any; + try { + msg = JSON.parse(typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data as ArrayBuffer)); + } catch { + return; + } + if (typeof msg.s === "number") this.seq = msg.s; + + switch (msg.op) { + case Op.Hello: + this.startHeartbeat(msg.d.heartbeat_interval); + if (this.sessionId && this.seq != null) this.sendResume(); + else this.sendIdentify(); + break; + case Op.Heartbeat: + this.sendHeartbeat(); + break; + case Op.HeartbeatAck: + this.heartbeatAcked = true; + break; + case Op.Reconnect: + this.reconnect(true); + break; + case Op.InvalidSession: + // d === true means the session is resumable. + this.sessionId = msg.d === true ? this.sessionId : null; + this.seq = msg.d === true ? this.seq : null; + setTimeout(() => this.reconnect(msg.d === true), 1500 + Math.random() * 3500); + break; + case Op.Dispatch: + this.onDispatch(msg.t, msg.d); + break; + } + } + + private onDispatch(t: string, d: any): void { + switch (t) { + case "READY": + this.sessionId = d.session_id ?? null; + this.resumeUrl = d.resume_gateway_url ?? null; + this.reconnectAttempts = 0; + break; + case "RESUMED": + this.reconnectAttempts = 0; + break; + case "GUILD_CREATE": { + const guildOk = this.guildTracked(d.id); + if (guildOk && Array.isArray(d.presences)) { + for (const p of d.presences) { + if (p?.user?.id) this.applyPresence(p as RawPresence, false); + } + } + break; + } + case "PRESENCE_UPDATE": + if (this.guildTracked(d.guild_id) && d?.user?.id) { + this.applyPresence(d as RawPresence, true); + } + break; + } + } + + private guildTracked(guildId: string | undefined): boolean { + const raw = (this.env.TRACKED_GUILD_IDS || "").trim(); + if (!raw) return true; // empty == track every guild the bot can see + if (!guildId) return false; + return raw.split(",").map((s) => s.trim()).includes(guildId); + } + + private applyPresence(raw: RawPresence, broadcast: boolean): void { + const presence = buildPresence(raw); + this.presences.set(presence.user_id, presence); + if (broadcast) this.broadcast(presence); + } + + // ---- heartbeat / identify / resume ----------------------------------- + private startHeartbeat(interval: number): void { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + this.heartbeatTimer = setInterval(() => { + if (!this.heartbeatAcked) { + // Zombied connection — force a reconnect. + this.reconnect(true); + return; + } + this.heartbeatAcked = false; + this.sendHeartbeat(); + }, interval); + } + + private sendHeartbeat(): void { + this.send({ op: Op.Heartbeat, d: this.seq }); + } + + private sendIdentify(): void { + this.send({ + op: Op.Identify, + d: { + token: this.env.DISCORD_BOT_TOKEN, + intents: INTENTS, + properties: { os: "linux", browser: "dough-restful", device: "dough-restful" }, + presence: { status: "invisible", activities: [], since: 0, afk: false }, + }, + }); + } + + private sendResume(): void { + this.send({ + op: Op.Resume, + d: { token: this.env.DISCORD_BOT_TOKEN, session_id: this.sessionId, seq: this.seq }, + }); + } + + private send(payload: unknown): void { + try { + this.discord?.send(JSON.stringify(payload)); + } catch { + /* socket gone; close handler will reconnect */ + } + } + + // ---- reconnection ---------------------------------------------------- + private onDiscordClose(code: number, _reason: string): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + this.discord = null; + // 4004/4010/4011/4013/4014 = fatal (bad token/intents) — don't hammer. + const fatal = [4004, 4010, 4011, 4012, 4013, 4014].includes(code); + if (fatal) { + this.sessionId = null; + this.seq = null; + return; + } + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + this.reconnectAttempts++; + const delay = Math.min(30_000, 1000 * 2 ** Math.min(this.reconnectAttempts, 5)); + setTimeout(() => this.ensureConnected(), delay + Math.random() * 1000); + } + + private reconnect(resumable: boolean): void { + if (!resumable) { + this.sessionId = null; + this.seq = null; + } + try { + this.discord?.close(4000, "reconnecting"); + } catch { + /* ignore */ + } + this.discord = null; + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + setTimeout(() => this.ensureConnected(), 500); + } + + // ---- browser client sockets (Lanyard protocol) ----------------------- + private handleClientUpgrade(req: Request): Response { + if (req.headers.get("Upgrade") !== "websocket") { + return new Response("expected websocket", { status: 426 }); + } + const pair = new WebSocketPair(); + const client = pair[0]; + const server = pair[1]; + server.accept(); + this.clients.set(server, { all: false, ids: new Set() }); + + server.send(JSON.stringify({ op: 1, d: { heartbeat_interval: CLIENT_HEARTBEAT_INTERVAL } })); + + server.addEventListener("message", (e) => this.onClientMessage(server, e)); + server.addEventListener("close", () => this.clients.delete(server)); + server.addEventListener("error", () => this.clients.delete(server)); + + // Make sure the gateway is alive once someone is listening. + this.ensureConnected(); + + return new Response(null, { status: 101, webSocket: client }); + } + + private onClientMessage(socket: WebSocket, e: MessageEvent): void { + let msg: any; + try { + msg = JSON.parse(typeof e.data === "string" ? e.data : ""); + } catch { + socket.close(4006, "invalid_payload"); + return; + } + if (msg.op === 3) return; // client heartbeat — nothing to ack + + if (msg.op === 2) { + const d = msg.d || {}; + const sub: ClientSub = { all: false, ids: new Set() }; + if (d.subscribe_to_all === true) { + sub.all = true; + } else if (typeof d.subscribe_to_id === "string") { + sub.ids.add(d.subscribe_to_id); + } else if (Array.isArray(d.subscribe_to_ids)) { + for (const id of d.subscribe_to_ids) if (typeof id === "string") sub.ids.add(id); + } else { + socket.close(4005, "requires_data_object"); + return; + } + this.clients.set(socket, sub); + this.sendInitState(socket, sub, typeof d.subscribe_to_id === "string" ? d.subscribe_to_id : null); + return; + } + + socket.close(4004, "unknown_opcode"); + } + + private sendInitState(socket: WebSocket, sub: ClientSub, singleId: string | null): void { + let data: unknown; + if (singleId) { + data = this.presences.get(singleId) ?? offlinePresence(singleId); + } else if (sub.all) { + data = Object.fromEntries(this.presences); + } else { + const map: Record = {}; + for (const id of sub.ids) map[id] = this.presences.get(id) ?? offlinePresence(id); + data = map; + } + socket.send(JSON.stringify({ op: 0, seq: ++this.dispatchSeq, t: "INIT_STATE", d: data })); + } + + private broadcast(presence: UnifiedPresence): void { + const payload = JSON.stringify({ + op: 0, + seq: ++this.dispatchSeq, + t: "PRESENCE_UPDATE", + d: presence, + }); + for (const [socket, sub] of this.clients) { + if (sub.all || sub.ids.has(presence.user_id)) { + try { + socket.send(payload); + } catch { + this.clients.delete(socket); + } + } + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..95da6a6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,117 @@ +/* ===================================================================== + * index.ts — Worker entry: HTTP router + cron keepalive. + * + * Routes + * GET / service info + * GET /v1/users/:id unified (presence + profile + badges) + * GET /v1/users/:id/presence presence only + * GET /v1/users/:id/profile profile + badges only + * GET /socket WebSocket (Lanyard protocol) + * + * The Durable Object is a singleton ("gateway") holding the Discord socket. + * ===================================================================== */ + +import type { ApiEnvelope, Env, UnifiedPresence, UnifiedRecord } from "./types"; +import { getProfile } from "./profile"; +import { GatewayManager } from "./gateway"; + +export { GatewayManager }; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +}; + +function json(body: ApiEnvelope, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...CORS }, + }); +} + +function gatewayStub(env: Env): DurableObjectStub { + return env.GATEWAY.get(env.GATEWAY.idFromName("gateway")); +} + +async function fetchPresence(env: Env, id: string): Promise { + const res = await gatewayStub(env).fetch(`https://do/presence/${id}`); + if (!res.ok) return null; + const body = (await res.json()) as { monitored: boolean; presence: UnifiedPresence | null }; + return body.presence; +} + +const ID_RE = /^\d{16,21}$/; + +export default { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + if (req.method === "OPTIONS") return new Response(null, { headers: CORS }); + + const url = new URL(req.url); + const path = url.pathname.replace(/\/+$/, "") || "/"; + + // ---- WebSocket ---- + if (path === "/socket" || path === "/ws") { + return gatewayStub(env).fetch(new Request("https://do/ws", req)); + } + + if (path === "/") { + return json({ + success: true, + data: { + service: "dough-restful", + description: "Combined Discord presence + profile/badges API.", + endpoints: ["/v1/users/:id", "/v1/users/:id/presence", "/v1/users/:id/profile", "/socket"], + } as any, + }); + } + + // ---- /v1/users/:id[/presence|/profile] ---- + const m = path.match(/^\/v1\/users\/(\d{1,32})(?:\/(presence|profile))?$/); + if (m) { + const id = m[1]; + const sub = m[2]; + if (!ID_RE.test(id)) { + return json({ success: false, error: { code: "invalid_id", message: "Not a Discord snowflake." } }, 400); + } + + if (sub === "presence") { + const presence = await fetchPresence(env, id); + if (!presence) { + return json({ success: false, error: { code: "not_monitored", message: "User shares no monitored guild with the bot." } }, 404); + } + return json({ success: true, data: presence }); + } + + if (sub === "profile") { + const profile = await getProfile(env, id); + if (!profile) { + return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404); + } + return json({ success: true, data: profile as any }); + } + + // Unified record: profile (REST) + presence (gateway), in parallel. + const [profile, presence] = await Promise.all([getProfile(env, id), fetchPresence(env, id)]); + if (!profile) { + return json({ success: false, error: { code: "not_found", message: "User not found." } }, 404); + } + const record: UnifiedRecord = { + user: profile.user, + presence, + badges: profile.badges, + connected_accounts: profile.connected_accounts, + updated_at: Date.now(), + source: { presence: presence ? "gateway" : "none", profile: profile.source }, + }; + return json({ success: true, data: record }); + } + + return json({ success: false, error: { code: "not_found", message: "Unknown route." } }, 404); + }, + + // Cron keepalive — make sure the gateway DO is connected. + async scheduled(_event: ScheduledController, env: Env, _ctx: ExecutionContext): Promise { + await gatewayStub(env).fetch("https://do/connect"); + }, +}; diff --git a/src/presence.ts b/src/presence.ts new file mode 100644 index 0000000..4bd47d8 --- /dev/null +++ b/src/presence.ts @@ -0,0 +1,93 @@ +/* ===================================================================== + * presence.ts — turn a raw Discord gateway presence into UnifiedPresence. + * ===================================================================== */ + +import type { DiscordStatus, UnifiedCustomStatus, UnifiedPresence, UnifiedSpotify } from "./types"; +import { emojiUrl } from "./discord/constants"; + +export interface RawPresence { + user: { id: string }; + status?: DiscordStatus; + activities?: any[]; + client_status?: { desktop?: string; mobile?: string; web?: string }; +} + +function spotifyArt(largeImage: string | undefined): string | null { + if (!largeImage) return null; + // Spotify activity asset looks like "spotify:ab67616d00...". + const id = largeImage.startsWith("spotify:") ? largeImage.slice("spotify:".length) : largeImage; + return `https://i.scdn.co/image/${id}`; +} + +function extractSpotify(activities: any[]): UnifiedSpotify | null { + const a = activities.find((x) => x && x.type === 2 && x.name === "Spotify" && x.sync_id); + if (!a) return null; + return { + track_id: a.sync_id ?? null, + song: a.details ?? "", + artist: a.state ?? "", + album: a.assets?.large_text ?? "", + album_art_url: spotifyArt(a.assets?.large_image), + timestamps: a.timestamps + ? { start: a.timestamps.start ?? null, end: a.timestamps.end ?? null } + : null, + }; +} + +function extractCustomStatus(activities: any[]): UnifiedCustomStatus | null { + const c = activities.find((x) => x && x.type === 4); + if (!c) return null; + const text: string | null = c.state ?? null; + const e = c.emoji; + const hasEmoji = e && (e.id || e.name); + if (!text && !hasEmoji) return null; + return { + text, + emoji: hasEmoji + ? { + id: e.id ?? null, + name: e.name ?? null, + animated: !!e.animated, + url: e.id ? emojiUrl(e.id, !!e.animated) : null, + } + : null, + }; +} + +export function buildPresence(raw: RawPresence): UnifiedPresence { + const activities = Array.isArray(raw.activities) ? raw.activities : []; + const status: DiscordStatus = raw.status ?? "offline"; + const cs = raw.client_status || {}; + const spotify = extractSpotify(activities); + + return { + user_id: raw.user.id, + status, + online: status !== "offline", + platform: { + desktop: !!cs.desktop, + mobile: !!cs.mobile, + web: !!cs.web, + }, + activities, + custom_status: extractCustomStatus(activities), + listening_to_spotify: !!spotify, + spotify, + updated_at: Date.now(), + }; +} + +/** Presence for someone we can't see on the gateway (offline placeholder). */ +export function offlinePresence(userId: string): UnifiedPresence { + return { + user_id: userId, + status: "offline", + online: false, + platform: { desktop: false, mobile: false, web: false }, + activities: [], + custom_status: null, + listening_to_spotify: false, + spotify: null, + updated_at: Date.now(), + }; +} diff --git a/src/profile.ts b/src/profile.ts new file mode 100644 index 0000000..cadd901 --- /dev/null +++ b/src/profile.ts @@ -0,0 +1,155 @@ +/* ===================================================================== + * profile.ts — build the UnifiedUser + badges + connections. + * + * Combines the bot-token /users/:id (basic) with the optional user-token + * /users/:id/profile (rich), merges badges, and caches the result in KV + * because none of this comes over the gateway. + * ===================================================================== */ + +import type { + Env, + UnifiedBadge, + UnifiedConnectedAccount, + UnifiedUser, +} from "./types"; +import { + avatarUrl, + badgeIconUrl, + bannerUrl, + clanBadgeUrl, + decorationUrl, + FLAG_BADGES, +} from "./discord/constants"; +import { fetchBotUser, fetchUserProfile, type RawDiscordUser } from "./discord/rest"; + +export interface ProfileResult { + user: UnifiedUser; + badges: UnifiedBadge[]; + connected_accounts: UnifiedConnectedAccount[]; + source: "bot" | "user" | "cache"; +} + +function flagBadges(flags: number): UnifiedBadge[] { + const out: UnifiedBadge[] = []; + for (const [bit, id, description, hash] of FLAG_BADGES) { + if (flags & bit) { + out.push({ + id, + description, + icon: hash, + icon_url: badgeIconUrl(hash), + link: null, + source: "flags", + }); + } + } + return out; +} + +function buildUser(u: RawDiscordUser, bio: string | null, pronouns: string | null): UnifiedUser { + const pg = u.primary_guild; + const clan = + pg && pg.tag && pg.identity_enabled && pg.identity_guild_id + ? { + guild_id: pg.identity_guild_id, + tag: pg.tag, + badge: pg.badge ?? null, + badge_url: pg.badge ? clanBadgeUrl(pg.identity_guild_id, pg.badge) : null, + } + : null; + + const deco = u.avatar_decoration_data; + + return { + id: u.id, + username: u.username, + global_name: u.global_name ?? null, + display_name: u.display_name ?? u.global_name ?? null, + avatar: u.avatar ?? null, + avatar_url: avatarUrl(u.id, u.avatar), + banner: u.banner ?? null, + banner_url: bannerUrl(u.id, u.banner ?? null), + accent_color: u.accent_color ?? null, + avatar_decoration: deco + ? { asset: deco.asset, sku_id: deco.sku_id ?? null, url: decorationUrl(deco.asset) } + : null, + clan, + collectibles: (u.collectibles as Record | null) ?? null, + bio, + pronouns, + }; +} + +function cacheKey(id: string): string { + return `profile:${id}`; +} + +/** Build profile from Discord, with a KV read-through cache. */ +export async function getProfile(env: Env, id: string): Promise { + const cached = await env.PROFILE_CACHE.get(cacheKey(id), "json"); + if (cached) { + const c = cached as Omit; + return { ...c, source: "cache" }; + } + + const result = await buildFreshProfile(env, id); + if (!result) return null; + + const ttl = Math.max(60, Number(env.PROFILE_CACHE_TTL_SECONDS || "300")); + await env.PROFILE_CACHE.put( + cacheKey(id), + JSON.stringify({ + user: result.user, + badges: result.badges, + connected_accounts: result.connected_accounts, + }), + { expirationTtl: ttl } + ); + return result; +} + +async function buildFreshProfile(env: Env, id: string): Promise { + // Rich path first (if user token present); fall back to bot-only. + const profile = await fetchUserProfile(env, id); + + if (profile && profile.user) { + const u = profile.user; + const bio = profile.user_profile?.bio ?? u.bio ?? null; + const pronouns = profile.user_profile?.pronouns ?? null; + + const badges: UnifiedBadge[] = []; + // Flag badges from the user object (so classic badges are always present). + badges.push(...flagBadges(u.public_flags ?? u.flags ?? 0)); + // Rich badges (Nitro/boost/quest/orb/gifting…) from the profile. + for (const b of profile.badges ?? []) { + if (badges.some((x) => x.id === b.id)) continue; + badges.push({ + id: b.id, + description: b.description, + icon: b.icon, + icon_url: badgeIconUrl(b.icon), + link: b.link ?? null, + source: "profile", + }); + } + + const connected: UnifiedConnectedAccount[] = (profile.connected_accounts ?? []).map((c) => ({ + type: c.type, + id: c.id, + name: c.name, + verified: !!c.verified, + })); + + return { user: buildUser(u, bio, pronouns), badges, connected_accounts: connected, source: "user" }; + } + + // Bot-only fallback. + const u = await fetchBotUser(env, id); + if (!u) return null; + return { + user: buildUser(u, null, null), + badges: flagBadges(u.public_flags ?? u.flags ?? 0), + connected_accounts: [], + source: "bot", + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..bed0beb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,122 @@ +/* ===================================================================== + * types.ts — the unified response schema. + * + * This is a NEW combined shape (not Lanyard- or dstn.to-identical): one + * object carries live presence (gateway) + profile/badges (REST). Fields + * that require the optional user token are null when running bot-only. + * ===================================================================== */ + +export interface Env { + GATEWAY: DurableObjectNamespace; + PROFILE_CACHE: KVNamespace; + + DISCORD_BOT_TOKEN: string; + /** Optional self-bot token for rich profile data. Off by default. */ + DISCORD_USER_TOKEN?: string; + + DISCORD_API_VERSION?: string; + TRACKED_GUILD_IDS?: string; + PROFILE_CACHE_TTL_SECONDS?: string; +} + +export type DiscordStatus = "online" | "idle" | "dnd" | "offline"; + +export interface UnifiedAvatarDecoration { + asset: string; + sku_id: string | null; + url: string; +} + +export interface UnifiedClanTag { + guild_id: string; + tag: string; + badge: string | null; + badge_url: string | null; +} + +export interface UnifiedBadge { + /** Discord badge id, e.g. "hypesquad_house_3", "orb_profile_badge". */ + id: string; + description: string; + /** CDN icon hash (badge-icons) when known. */ + icon: string | null; + icon_url: string | null; + link: string | null; + /** Where the badge came from: classic public-flag, or the rich profile. */ + source: "flags" | "profile"; +} + +export interface UnifiedConnectedAccount { + type: string; + id: string; + name: string; + verified: boolean; +} + +export interface UnifiedUser { + id: string; + username: string; + global_name: string | null; + display_name: string | null; + + avatar: string | null; + avatar_url: string; + banner: string | null; + banner_url: string | null; + accent_color: number | null; + + avatar_decoration: UnifiedAvatarDecoration | null; + clan: UnifiedClanTag | null; + /** Raw collectibles blob (nameplate, etc.) passed through as-is. */ + collectibles: Record | null; + + /** Rich profile only (needs user token); null otherwise. */ + bio: string | null; + pronouns: string | null; +} + +export interface UnifiedSpotify { + track_id: string | null; + song: string; + artist: string; + album: string; + album_art_url: string | null; + timestamps: { start: number | null; end: number | null } | null; +} + +export interface UnifiedCustomStatus { + text: string | null; + emoji: { id: string | null; name: string | null; animated: boolean; url: string | null } | null; +} + +export interface UnifiedPresence { + user_id: string; + status: DiscordStatus; + online: boolean; + platform: { desktop: boolean; mobile: boolean; web: boolean }; + /** Plain Discord activities array (custom status / type-4 stripped out). */ + activities: any[]; + custom_status: UnifiedCustomStatus | null; + listening_to_spotify: boolean; + spotify: UnifiedSpotify | null; + updated_at: number; +} + +export interface UnifiedRecord { + user: UnifiedUser; + /** null when the user shares no monitored guild with the bot. */ + presence: UnifiedPresence | null; + badges: UnifiedBadge[]; + connected_accounts: UnifiedConnectedAccount[]; + updated_at: number; + source: { + presence: "gateway" | "none"; + profile: "bot" | "user" | "cache"; + }; +} + +export interface ApiEnvelope { + success: boolean; + data?: T; + error?: { code: string; message: string }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..04f6c7f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..f7a8562 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,42 @@ +{ + // dough-restful — Discord presence + profile API on one Worker. + // Docs: https://developers.cloudflare.com/workers/wrangler/configuration/ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "dough-restful", + "main": "src/index.ts", + "compatibility_date": "2024-09-23", + "account_id": "f87ee4b9600f437b8da1104d077418c3", + // nodejs_compat is not required; we only use Web/Workers APIs. + "observability": { "enabled": true }, + + // ---- Durable Object: single instance holds the Discord gateway socket ---- + "durable_objects": { + "bindings": [ + { "name": "GATEWAY", "class_name": "GatewayManager" } + ] + }, + "migrations": [ + { "tag": "v1", "new_classes": ["GatewayManager"] } + ], + + // ---- KV: cache for profile/badge data (not available over the gateway) ---- + // Create with: wrangler kv namespace create PROFILE_CACHE + // then paste the returned id below. + "kv_namespaces": [ + { "binding": "PROFILE_CACHE", "id": "REPLACE_WITH_KV_ID" } + ], + + // ---- Cron: nudge the Durable Object so the gateway stays connected ---- + "triggers": { + "crons": ["*/2 * * * *"] + }, + + // Non-secret vars. Secrets (tokens) are set via `wrangler secret put`. + "vars": { + "DISCORD_API_VERSION": "10", + // 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_CACHE_TTL_SECONDS": "300" + } +}