first commit
This commit is contained in:
commit
0499e9da17
|
|
@ -0,0 +1,12 @@
|
|||
# Copy to `.dev.vars` for local `wrangler dev`, and set the same values in
|
||||
# production with `wrangler secret put <NAME>`.
|
||||
|
||||
# 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=
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
.dev.vars
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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<string, unknown> | 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<RawDiscordUser | null> {
|
||||
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<RawProfileResponse | null> {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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<string>;
|
||||
}
|
||||
|
||||
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<typeof setInterval> | null = null;
|
||||
private heartbeatAcked = true;
|
||||
private reconnectAttempts = 0;
|
||||
|
||||
private presences = new Map<string, UnifiedPresence>();
|
||||
private clients = new Map<WebSocket, ClientSub>();
|
||||
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<Response> {
|
||||
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<void> {
|
||||
await this.ensureConnected();
|
||||
await this.ensureAlarm();
|
||||
}
|
||||
|
||||
private async ensureAlarm(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, UnifiedPresence> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T>(body: ApiEnvelope<T>, 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<UnifiedPresence | null> {
|
||||
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<Response> {
|
||||
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<UnifiedPresence>({ 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<UnifiedRecord>({ 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<void> {
|
||||
await gatewayStub(env).fetch("https://do/connect");
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown> | 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<ProfileResult | null> {
|
||||
const cached = await env.PROFILE_CACHE.get(cacheKey(id), "json");
|
||||
if (cached) {
|
||||
const c = cached as Omit<ProfileResult, "source">;
|
||||
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<ProfileResult | null> {
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, unknown> | 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<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue