From 3b61c22921025a4dcc96606ed12c209981d32355 Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Thu, 23 Jul 2026 12:53:32 +0530 Subject: [PATCH 1/6] feat(bot): Discord bot workspace with /meet link and /meet create Add a 4th monorepo workspace, bot/, a thin discord.js adapter over the /api/integrations/discord API (ticket #185). Slash commands only (no Message Content intent): - /meet link -> mints an account-link token, replies ephemerally with the confirmation URL. - /meet create -> creates an instant room hosted by the linked user and posts a public embed with the join link; not-linked users get an ephemeral prompt to run /meet link; other failures reply with a friendly ephemeral error. Includes a typed HTTP client for the two bot-facing endpoints (X-Bot-Api-Key auth, not_linked detection), a slash-command registration script, strict TS + Vitest + ESLint mirroring the other workspaces, per-workspace lockfile, CI lint/test jobs, aggregate typecheck coverage, a production Dockerfile, and a bot service wired into docker-compose.prod.yml (restart: always). Closes #186 --- .github/workflows/ci.yml | 35 + bot/.dockerignore | 5 + bot/.env.example | 25 + bot/Dockerfile | 42 + bot/README.md | 52 + bot/eslint.config.js | 35 + bot/package-lock.json | 3484 +++++++++++++++++++++++++++++++++ bot/package.json | 37 + bot/src/bot.ts | 49 + bot/src/commands/handlers.ts | 85 + bot/src/commands/meet.ts | 20 + bot/src/config/env.ts | 60 + bot/src/http/client.ts | 109 ++ bot/src/index.ts | 17 + bot/src/interactions.ts | 32 + bot/src/register-commands.ts | 31 + bot/test/client.test.ts | 104 + bot/test/env.test.ts | 45 + bot/test/handlers.test.ts | 127 ++ bot/test/interactions.test.ts | 72 + bot/test/meet.test.ts | 10 + bot/tsconfig.json | 11 + bot/vitest.config.ts | 22 + docker-compose.prod.yml | 37 + package-lock.json | 279 ++- package.json | 10 +- 26 files changed, 4831 insertions(+), 4 deletions(-) create mode 100644 bot/.dockerignore create mode 100644 bot/.env.example create mode 100644 bot/Dockerfile create mode 100644 bot/README.md create mode 100644 bot/eslint.config.js create mode 100644 bot/package-lock.json create mode 100644 bot/package.json create mode 100644 bot/src/bot.ts create mode 100644 bot/src/commands/handlers.ts create mode 100644 bot/src/commands/meet.ts create mode 100644 bot/src/config/env.ts create mode 100644 bot/src/http/client.ts create mode 100644 bot/src/index.ts create mode 100644 bot/src/interactions.ts create mode 100644 bot/src/register-commands.ts create mode 100644 bot/test/client.test.ts create mode 100644 bot/test/env.test.ts create mode 100644 bot/test/handlers.test.ts create mode 100644 bot/test/interactions.test.ts create mode 100644 bot/test/meet.test.ts create mode 100644 bot/tsconfig.json create mode 100644 bot/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d606463..6ff18ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,41 @@ jobs: - run: npm ci --ignore-scripts - run: npm run lint + bot-lint: + name: Bot lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: bot + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: bot/package-lock.json + # ESLint needs no native build, so --ignore-scripts keeps install fast. + - run: npm ci --ignore-scripts + - run: npm run lint + + bot: + name: Bot tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: bot + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: bot/package-lock.json + - run: npm ci --ignore-scripts + # Run under coverage so the non-decreasing ratchet (thresholds in + # vitest.config.ts) gates every PR — a coverage drop fails this check. + - run: npm run test:coverage + server: name: Server tests runs-on: ubuntu-latest diff --git a/bot/.dockerignore b/bot/.dockerignore new file mode 100644 index 0000000..74f8369 --- /dev/null +++ b/bot/.dockerignore @@ -0,0 +1,5 @@ +node_modules +coverage +dist +.env +*.log diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..a8dd56a --- /dev/null +++ b/bot/.env.example @@ -0,0 +1,25 @@ +# A-Meet Discord bot — local configuration sample. +# Copy to bot/.env and fill in. NEVER commit real values; in production these are +# injected by the container environment (SSM SecureStrings for the secrets). + +# ── Discord application credentials ───────────────────────────────────────── +# From the Discord Developer Portal (https://discord.com/developers/applications). +# DISCORD_TOKEN is the bot token (Bot → Reset Token). SECRET — never commit/log. +DISCORD_TOKEN= +# The application (client) id — Discord Developer Portal → General Information. +DISCORD_CLIENT_ID= +# Optional: register slash commands to a single guild for instant updates while +# developing. Leave empty to register globally (can take up to ~1h to propagate). +DISCORD_GUILD_ID= + +# ── A-Meet endpoints ──────────────────────────────────────────────────────── +# Base URL of the A-Meet API server the bot calls. Local: http://localhost:5000. +SERVER_URL=http://localhost:5000 +# Base URL of the A-Meet web client, used to build public meeting join links. +CLIENT_URL=http://localhost:5173 + +# ── Integration auth ──────────────────────────────────────────────────────── +# Shared secret sent to /api/integrations/discord/* in the X-Bot-Api-Key header. +# MUST match the server's DISCORD_BOT_API_KEY. HOST-GRADE secret — never commit; +# in prod this is an SSM SecureString. +DISCORD_BOT_API_KEY= diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..0a1ddc8 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# +# Production image for the A-Meet Discord bot — a small discord.js process that +# adapts slash commands to the /api/integrations/discord API. No native builds +# (unlike the server's mediasoup), so the image is a plain Node runtime. + +# ---- build stage: install the bot workspace from the root lockfile ---- +FROM node:22-bookworm-slim AS build +WORKDIR /app + +# Install the bot workspace and its shared-contract dependency from the root +# lockfile. The image build context is the repository root so workspace links +# resolve exactly as they do in CI and local development. +COPY package.json package-lock.json ./ +COPY bot/package.json ./bot/package.json +COPY shared/package.json ./shared/package.json +RUN npm ci --omit=dev --workspace bot --ignore-scripts + +COPY bot/src ./bot/src +COPY bot/tsconfig.json ./bot/tsconfig.json +COPY shared/src ./shared/src +COPY tsconfig.base.json ./tsconfig.base.json + +# ---- runtime stage: slim image carrying only the built artifacts ---- +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app/bot + +COPY --from=build /app/node_modules /app/node_modules +COPY --from=build /app/bot ./ +COPY --from=build /app/shared /app/shared +COPY --from=build /app/package.json /app/package.json +COPY --from=build /app/package-lock.json /app/package-lock.json +COPY --from=build /app/tsconfig.base.json /app/tsconfig.base.json + +RUN chown -R node:node /app +USER node + +# Run through the tsx loader: sources are TypeScript and tsx strips types at load +# time, so the image needs no separate compile step. tsx ships as a runtime +# dependency (kept by --omit=dev). +CMD ["node", "--import", "tsx", "src/index.ts"] diff --git a/bot/README.md b/bot/README.md new file mode 100644 index 0000000..4028653 --- /dev/null +++ b/bot/README.md @@ -0,0 +1,52 @@ +# A-Meet Discord bot + +A small [discord.js](https://discord.js.org) process that lets Discord users create +A-Meet meetings from a channel. It is a **thin adapter** — it holds no meeting logic +and talks to the A-Meet server only through the `/api/integrations/discord` API +(authenticated with a shared bot API key). The bot and the API server are separate +processes: a Discord outage never affects the API and vice-versa. + +## Commands (slash-only, no Message Content intent) + +- **`/meet link`** — one-time Discord ↔ A-Meet account linking. Replies **ephemerally** + (only the invoker sees it) with a short-lived confirmation URL to open in the browser + while signed in to A-Meet. +- **`/meet create`** — creates an instant meeting hosted by the linked user and posts a + **public embed** with the join link (`/lobby/`) and "Started by @user". + If the account isn't linked yet, replies ephemerally prompting `/meet link`. + +## Configuration + +Copy `.env.example` to `.env` and fill it in. Variables: + +| Var | Required | Purpose | +|---|---|---| +| `DISCORD_TOKEN` | yes | Bot token (secret) used to log the gateway client in. | +| `DISCORD_CLIENT_ID` | yes | Application id, needed to register slash commands. | +| `DISCORD_GUILD_ID` | no | If set, registers commands to that guild (instant, dev-friendly); else global. | +| `SERVER_URL` | yes | Base URL of the A-Meet API server. | +| `CLIENT_URL` | yes | Base URL of the web client, used to build meeting links. | +| `DISCORD_BOT_API_KEY` | yes | Shared secret (`X-Bot-Api-Key`); must match the server's value. Secret. | + +Secrets are never committed. In production they come from SSM SecureStrings via the +container environment. + +## Scripts + +```bash +npm --prefix bot run register # register/update the /meet slash command with Discord +npm --prefix bot run dev # run with reload (tsx watch) +npm --prefix bot start # run once +npm --prefix bot test # unit tests (no real Discord) +npm --prefix bot run typecheck +npm --prefix bot run lint +``` + +## First-time setup + +1. Create an application + bot in the [Discord Developer Portal](https://discord.com/developers/applications); + copy the **bot token** and **application id** into `.env`. +2. Invite the bot to a server with the `applications.commands` (and `bot`) scopes. +3. `npm --prefix bot run register` to publish the `/meet` command. +4. Set `DISCORD_BOT_API_KEY` to the same value configured on the server. +5. `npm --prefix bot start`. diff --git a/bot/eslint.config.js b/bot/eslint.config.js new file mode 100644 index 0000000..95113ec --- /dev/null +++ b/bot/eslint.config.js @@ -0,0 +1,35 @@ +import globals from 'globals'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import { baseEslintConfigs } from '../eslint.config.base.mjs'; + +// Flat config for the Discord bot (ESLint 10), mirroring the server setup. +// Lints application source, tests, and config files with ESM parsing and Node +// globals; test files additionally get Vitest's globals. +export default defineConfig([ + globalIgnores(['node_modules', 'coverage', 'dist']), + { + files: ['**/*.js'], + extends: [baseEslintConfigs.javascript], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.node, + }, + }, + { + files: ['**/*.ts'], + extends: [baseEslintConfigs.javascript, baseEslintConfigs.typescript], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.node, + }, + }, + { + // Vitest exposes describe/it/expect/vi etc.; scope those to test files only. + files: ['test/**/*.{js,ts}', '**/*.test.{js,ts}'], + languageOptions: { + globals: { ...globals.node, ...globals.vitest }, + }, + }, +]); diff --git a/bot/package-lock.json b/bot/package-lock.json new file mode 100644 index 0000000..3c393ca --- /dev/null +++ b/bot/package-lock.json @@ -0,0 +1,3484 @@ +{ + "name": "bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bot", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@a-meet/contracts": "file:../shared", + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "tsx": "^4.22.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.0.1", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.3.0", + "globals": "^17.6.0", + "typescript-eslint": "^8.62.1", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "../shared": { + "name": "@a-meet/contracts", + "version": "0.0.0", + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.6.0", + "globals": "^17.7.0", + "mediasoup-client": "^3.20.0", + "typescript-eslint": "^8.62.1" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@a-meet/contracts": { + "resolved": "../shared", + "link": true + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", + "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.50", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.50", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.50.tgz", + "integrity": "sha512-J2n/bpIETX3DQ6AJ7/0xbsTLmYiJQtO/LKcXKC1YDbB56OUwtDbdXOFE8Q4g8jVGHBR2VAy1+D4ngaIgkMNV9w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.27.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", + "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.2", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.5", + "discord-api-types": "^0.38.49", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "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 + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/bot/package.json b/bot/package.json new file mode 100644 index 0000000..c0baa74 --- /dev/null +++ b/bot/package.json @@ -0,0 +1,37 @@ +{ + "name": "bot", + "version": "1.0.0", + "description": "A-Meet Discord bot — thin discord.js adapter over the /api/integrations/discord API", + "type": "module", + "engines": { + "node": ">=22.13.0" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "register": "tsx src/register-commands.ts", + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@a-meet/contracts": "file:../shared", + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "tsx": "^4.22.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.0.1", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.3.0", + "globals": "^17.6.0", + "typescript-eslint": "^8.62.1", + "vitest": "^4.1.10" + } +} diff --git a/bot/src/bot.ts b/bot/src/bot.ts new file mode 100644 index 0000000..5b71c0d --- /dev/null +++ b/bot/src/bot.ts @@ -0,0 +1,49 @@ +import { Client, GatewayIntentBits, Events, MessageFlags } from 'discord.js'; +import type { BotConfig } from './config/env.js'; +import { DiscordIntegrationClient } from './http/client.js'; +import { handleInteraction } from './interactions.js'; + +// Gateway bootstrap: builds the discord.js client, wires the interaction router, +// and logs in. Only the Guilds intent is needed — slash commands don't require +// the privileged Message Content intent. Not unit-tested (it talks to the real +// Discord gateway); the routing logic it delegates to lives in interactions.ts. +export function createBot(config: BotConfig): Client { + const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + + const api = new DiscordIntegrationClient({ + serverUrl: config.serverUrl, + apiKey: config.botApiKey, + }); + + client.once(Events.ClientReady, (ready) => { + console.log(`Discord bot logged in as ${ready.user.tag}`); + }); + + client.on(Events.InteractionCreate, async (interaction) => { + try { + await handleInteraction(interaction, api, config.clientUrl); + } catch (err) { + console.error('Unhandled interaction error:', err); + // Best-effort friendly answer so a crash never leaves the user staring at + // a spinner. Both reply and followUp can throw if the token expired; ignore. + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + try { + await interaction.reply({ + content: 'Something went wrong. Please try again.', + flags: MessageFlags.Ephemeral, + }); + } catch { + /* interaction already gone — nothing more we can do */ + } + } + } + }); + + return client; +} + +export async function startBot(config: BotConfig): Promise { + const client = createBot(config); + await client.login(config.discordToken); + return client; +} diff --git a/bot/src/commands/handlers.ts b/bot/src/commands/handlers.ts new file mode 100644 index 0000000..b4deb56 --- /dev/null +++ b/bot/src/commands/handlers.ts @@ -0,0 +1,85 @@ +import { EmbedBuilder, MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { + DiscordIntegrationClient, + NotLinkedError, +} from '../http/client.js'; + +// Command handlers: thin glue between a Discord interaction and the integration +// HTTP client. Every path answers the interaction — success or failure, never +// silence. Private answers use MessageFlags.Ephemeral (only the invoker sees +// them); the meeting announcement is a public channel embed. + +const GENERIC_ERROR = 'Something went wrong talking to A-Meet. Please try again in a moment.'; + +/** + * `/meet link` — mint an account-link token and DM-style ephemeral reply the + * confirmation URL so only the requester sees it. + */ +export async function handleLink( + interaction: ChatInputCommandInteraction, + client: DiscordIntegrationClient, +): Promise { + try { + const { linkUrl } = await client.createLinkToken(interaction.user.id); + await interaction.reply({ + content: + `Link your A-Meet account: ${linkUrl}\n` + + 'Open it in your browser while signed in to A-Meet. The link expires in ~10 minutes.', + flags: MessageFlags.Ephemeral, + }); + } catch { + await interaction.reply({ + content: GENERIC_ERROR, + flags: MessageFlags.Ephemeral, + }); + } +} + +/** + * `/meet create` — create an instant meeting hosted by the linked user and post + * a public embed with the join link. Not-linked users get an ephemeral nudge to + * run `/meet link`; any other failure gets an ephemeral friendly error. + */ +export async function handleCreate( + interaction: ChatInputCommandInteraction, + client: DiscordIntegrationClient, + clientUrl: string, +): Promise { + let roomId: string; + try { + ({ roomId } = await client.createRoom(interaction.user.id)); + } catch (err) { + if (err instanceof NotLinkedError) { + await interaction.reply({ + content: 'Link your account first with `/meet link`.', + flags: MessageFlags.Ephemeral, + }); + return; + } + await interaction.reply({ + content: GENERIC_ERROR, + flags: MessageFlags.Ephemeral, + }); + return; + } + + const meetingUrl = buildMeetingUrl(clientUrl, roomId); + const embed = new EmbedBuilder() + .setTitle('A-Meet meeting started') + .setDescription(`[Join the meeting](${meetingUrl})`) + .addFields( + { name: 'Link', value: meetingUrl }, + { name: 'Started by', value: interaction.user.toString() }, + ); + + await interaction.reply({ embeds: [embed] }); +} + +/** + * Public join URL — matches the client's own `buildJoinUrl` + * (`/lobby/`, the RoomGuard → LobbyPage entry point). + */ +export function buildMeetingUrl(clientUrl: string, roomId: string): string { + return `${clientUrl.replace(/\/+$/, '')}/lobby/${encodeURIComponent(roomId)}`; +} diff --git a/bot/src/commands/meet.ts b/bot/src/commands/meet.ts new file mode 100644 index 0000000..c52bfe7 --- /dev/null +++ b/bot/src/commands/meet.ts @@ -0,0 +1,20 @@ +import { SlashCommandBuilder } from 'discord.js'; + +// The single `/meet` slash command with two subcommands. Slash commands only — +// the bot needs no privileged Message Content intent. +export const meetCommand = new SlashCommandBuilder() + .setName('meet') + .setDescription('A-Meet meetings') + .addSubcommand((sub) => + sub + .setName('link') + .setDescription('Link your Discord account to your A-Meet account'), + ) + .addSubcommand((sub) => + sub + .setName('create') + .setDescription('Start an instant A-Meet meeting and post the join link'), + ); + +/** JSON payload for slash-command registration. */ +export const meetCommandJSON = meetCommand.toJSON(); diff --git a/bot/src/config/env.ts b/bot/src/config/env.ts new file mode 100644 index 0000000..c958400 --- /dev/null +++ b/bot/src/config/env.ts @@ -0,0 +1,60 @@ +// Typed, validated bot configuration. `loadBotEnv` is pure over its input source +// (defaults to process.env) so it is unit-testable off the real environment. +// Missing required values throw a single aggregated error — the process +// entrypoint turns that into a clean exit rather than a half-configured bot. + +export interface BotConfig { + /** Discord bot token used to log the gateway client in. */ + discordToken: string; + /** Discord application (client) id — needed to register slash commands. */ + discordClientId: string; + /** + * Optional guild id. When set, `register-commands` installs the commands to + * that single guild (instant, ideal for dev); when unset it registers them + * globally (can take up to an hour to propagate). + */ + discordGuildId?: string; + /** Base URL of the A-Meet API server, e.g. http://localhost:5000. */ + serverUrl: string; + /** Base URL of the A-Meet web client, used to build public meeting links. */ + clientUrl: string; + /** Shared secret presented to the /api/integrations/discord/* endpoints. */ + botApiKey: string; +} + +type EnvSource = Record; + +function required(source: EnvSource, key: string, missing: string[]): string { + const value = source[key]?.trim(); + if (!value) { + missing.push(key); + return ''; + } + return value; +} + +/** + * Read and validate the bot configuration. Throws if any required variable is + * absent so the process fails fast with an actionable message instead of + * booting into an unauthenticated/broken state. + */ +export function loadBotEnv(source: EnvSource = process.env): BotConfig { + const missing: string[] = []; + const config: BotConfig = { + discordToken: required(source, 'DISCORD_TOKEN', missing), + discordClientId: required(source, 'DISCORD_CLIENT_ID', missing), + serverUrl: required(source, 'SERVER_URL', missing), + clientUrl: required(source, 'CLIENT_URL', missing), + botApiKey: required(source, 'DISCORD_BOT_API_KEY', missing), + }; + const guildId = source.DISCORD_GUILD_ID?.trim(); + if (guildId) config.discordGuildId = guildId; + + if (missing.length > 0) { + throw new Error( + `Missing required bot environment variable(s): ${missing.join(', ')}. ` + + 'See bot/.env.example.', + ); + } + return config; +} diff --git a/bot/src/http/client.ts b/bot/src/http/client.ts new file mode 100644 index 0000000..6335f54 --- /dev/null +++ b/bot/src/http/client.ts @@ -0,0 +1,109 @@ +import type { + DiscordLinkTokenResponse, + DiscordRoomResponse, + DiscordNotLinkedError, +} from '@a-meet/contracts'; + +// Typed HTTP client wrapping the two bot-facing A-Meet integration endpoints. +// The bot holds no meeting logic — this is the whole surface it talks to the +// server through. Errors are modelled as typed throws so command handlers can +// route replies (link prompt vs generic error) without string-matching. + +/** The A-Meet account for this Discord id has not been linked yet. */ +export class NotLinkedError extends Error { + constructor(message = 'Discord account is not linked') { + super(message); + this.name = 'NotLinkedError'; + } +} + +/** The server answered with a non-success status we can't specifically handle. */ +export class IntegrationHttpError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = 'IntegrationHttpError'; + this.status = status; + } +} + +/** Minimal fetch surface, injectable so tests don't touch the global. */ +export type FetchLike = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise<{ + status: number; + ok: boolean; + json: () => Promise; +}>; + +export interface IntegrationClientOptions { + /** Base URL of the API server, e.g. http://localhost:5000 (no trailing slash needed). */ + serverUrl: string; + /** Shared bot API key sent in the X-Bot-Api-Key header. */ + apiKey: string; + /** Override the fetch implementation (defaults to global fetch). */ + fetchImpl?: FetchLike; +} + +const BOT_API_KEY_HEADER = 'X-Bot-Api-Key'; + +export class DiscordIntegrationClient { + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: FetchLike; + + constructor(options: IntegrationClientOptions) { + // Normalise so `${base}/api/...` never produces a double slash. + this.baseUrl = options.serverUrl.replace(/\/+$/, '') + '/api/integrations/discord'; + this.apiKey = options.apiKey; + this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + } + + private async post(path: string, body: unknown): Promise<{ status: number; ok: boolean; data: unknown }> { + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [BOT_API_KEY_HEADER]: this.apiKey, + }, + body: JSON.stringify(body), + }); + let data: unknown = undefined; + try { + data = await res.json(); + } catch { + // Non-JSON bodies (e.g. a proxy 502) leave data undefined; handled below. + } + return { status: res.status, ok: res.ok, data }; + } + + /** Mint a short-lived account-link token + ready-made confirmation URL. */ + async createLinkToken(discordId: string): Promise { + const { status, ok, data } = await this.post('/link-token', { discordId }); + if (!ok) { + throw new IntegrationHttpError(status, `link-token failed (${status})`); + } + return data as DiscordLinkTokenResponse; + } + + /** + * Create an instant room hosted by the linked A-Meet user. + * @throws NotLinkedError when the Discord id has no linked account (404 + code). + * @throws IntegrationHttpError for any other non-success response. + */ + async createRoom(discordId: string): Promise { + const { status, ok, data } = await this.post('/rooms', { discordId }); + if (ok) { + return data as DiscordRoomResponse; + } + if (status === 404 && (data as DiscordNotLinkedError | undefined)?.code === 'not_linked') { + throw new NotLinkedError(); + } + throw new IntegrationHttpError(status, `rooms failed (${status})`); + } +} diff --git a/bot/src/index.ts b/bot/src/index.ts new file mode 100644 index 0000000..c7d78dc --- /dev/null +++ b/bot/src/index.ts @@ -0,0 +1,17 @@ +import 'dotenv/config'; +import { loadBotEnv } from './config/env.js'; +import { startBot } from './bot.js'; + +// Process entrypoint. Loads .env (no-op in prod where SSM/compose inject env), +// validates config, then logs the gateway client in. A missing-config or login +// failure exits non-zero so the container's `restart: always` doesn't spin on a +// silently-broken process. +async function main(): Promise { + const config = loadBotEnv(); + await startBot(config); +} + +main().catch((err) => { + console.error('Bot failed to start:', err); + process.exit(1); +}); diff --git a/bot/src/interactions.ts b/bot/src/interactions.ts new file mode 100644 index 0000000..54edb86 --- /dev/null +++ b/bot/src/interactions.ts @@ -0,0 +1,32 @@ +import { MessageFlags } from 'discord.js'; +import type { Interaction } from 'discord.js'; +import { DiscordIntegrationClient } from './http/client.js'; +import { handleLink, handleCreate } from './commands/handlers.js'; + +// Single interaction router. Kept separate from the gateway bootstrap so it can +// be unit-tested with a mocked interaction and HTTP client. +export async function handleInteraction( + interaction: Interaction, + client: DiscordIntegrationClient, + clientUrl: string, +): Promise { + if (!interaction.isChatInputCommand()) return; + if (interaction.commandName !== 'meet') return; + + const sub = interaction.options.getSubcommand(); + switch (sub) { + case 'link': + await handleLink(interaction, client); + return; + case 'create': + await handleCreate(interaction, client, clientUrl); + return; + default: + // Unknown subcommand — should be impossible given registration, but never + // leave the interaction unanswered. + await interaction.reply({ + content: 'Unknown command.', + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/bot/src/register-commands.ts b/bot/src/register-commands.ts new file mode 100644 index 0000000..402d865 --- /dev/null +++ b/bot/src/register-commands.ts @@ -0,0 +1,31 @@ +import 'dotenv/config'; +import { REST, Routes } from 'discord.js'; +import { loadBotEnv } from './config/env.js'; +import { meetCommandJSON } from './commands/meet.js'; + +// One-shot ops script: (re)register the /meet slash command with Discord. +// npm --prefix bot run register +// With DISCORD_GUILD_ID set it registers to that guild (updates are instant — +// use this in development). Without it, commands register globally (propagation +// can take up to an hour). Run again whenever the command definition changes. +async function main(): Promise { + const config = loadBotEnv(); + const rest = new REST({ version: '10' }).setToken(config.discordToken); + const body = [meetCommandJSON]; + + if (config.discordGuildId) { + await rest.put( + Routes.applicationGuildCommands(config.discordClientId, config.discordGuildId), + { body }, + ); + console.log(`Registered ${body.length} command(s) to guild ${config.discordGuildId}.`); + } else { + await rest.put(Routes.applicationCommands(config.discordClientId), { body }); + console.log(`Registered ${body.length} command(s) globally (may take up to 1h to appear).`); + } +} + +main().catch((err) => { + console.error('Command registration failed:', err); + process.exit(1); +}); diff --git a/bot/test/client.test.ts b/bot/test/client.test.ts new file mode 100644 index 0000000..f4cfba7 --- /dev/null +++ b/bot/test/client.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + DiscordIntegrationClient, + NotLinkedError, + IntegrationHttpError, + type FetchLike, +} from '../src/http/client.js'; + +// Build a fake fetch that returns a single canned response and records its call. +function fakeFetch(status: number, body: unknown): { impl: FetchLike; calls: unknown[][] } { + const calls: unknown[][] = []; + const impl: FetchLike = vi.fn(async (input, init) => { + calls.push([input, init]); + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + }; + }); + return { impl, calls }; +} + +function makeClient(impl: FetchLike) { + return new DiscordIntegrationClient({ + serverUrl: 'http://server:5000', + apiKey: 'test-key', + fetchImpl: impl, + }); +} + +describe('DiscordIntegrationClient.createLinkToken', () => { + it('POSTs to the link-token endpoint with the bot key header and discordId body', async () => { + const { impl, calls } = fakeFetch(201, { + token: 'jwt', + linkUrl: 'http://client/link/discord?token=jwt', + }); + const result = await makeClient(impl).createLinkToken('123'); + + expect(result).toEqual({ token: 'jwt', linkUrl: 'http://client/link/discord?token=jwt' }); + const [url, init] = calls[0] as [string, { method: string; headers: Record; body: string }]; + expect(url).toBe('http://server:5000/api/integrations/discord/link-token'); + expect(init.method).toBe('POST'); + expect(init.headers['X-Bot-Api-Key']).toBe('test-key'); + expect(init.headers['Content-Type']).toBe('application/json'); + expect(JSON.parse(init.body)).toEqual({ discordId: '123' }); + }); + + it('throws IntegrationHttpError on a non-success status (e.g. bad key)', async () => { + const { impl } = fakeFetch(401, { error: 'Invalid bot credentials' }); + await expect(makeClient(impl).createLinkToken('123')).rejects.toBeInstanceOf( + IntegrationHttpError, + ); + }); +}); + +describe('DiscordIntegrationClient.createRoom', () => { + it('returns the roomId on 201', async () => { + const { impl, calls } = fakeFetch(201, { roomId: 'abc-defg-hij' }); + const result = await makeClient(impl).createRoom('123'); + + expect(result).toEqual({ roomId: 'abc-defg-hij' }); + const [url] = calls[0] as [string]; + expect(url).toBe('http://server:5000/api/integrations/discord/rooms'); + }); + + it('throws NotLinkedError on a 404 with code not_linked', async () => { + const { impl } = fakeFetch(404, { error: 'Discord account is not linked', code: 'not_linked' }); + await expect(makeClient(impl).createRoom('123')).rejects.toBeInstanceOf(NotLinkedError); + }); + + it('throws IntegrationHttpError on a 404 without the not_linked code', async () => { + const { impl } = fakeFetch(404, { error: 'nope' }); + const err = await makeClient(impl).createRoom('123').catch((e) => e); + expect(err).toBeInstanceOf(IntegrationHttpError); + expect(err).not.toBeInstanceOf(NotLinkedError); + }); + + it('throws IntegrationHttpError on a 500', async () => { + const { impl } = fakeFetch(500, { error: 'boom' }); + const err = await makeClient(impl).createRoom('123').catch((e) => e); + expect(err).toBeInstanceOf(IntegrationHttpError); + expect((err as IntegrationHttpError).status).toBe(500); + }); + + it('propagates a network-level fetch rejection', async () => { + const impl: FetchLike = vi.fn(async () => { + throw new Error('ECONNREFUSED'); + }); + await expect(makeClient(impl).createRoom('123')).rejects.toThrow('ECONNREFUSED'); + }); +}); + +describe('DiscordIntegrationClient base URL handling', () => { + it('does not produce a double slash when serverUrl has a trailing slash', async () => { + const { impl, calls } = fakeFetch(201, { roomId: 'x' }); + await new DiscordIntegrationClient({ + serverUrl: 'http://server:5000/', + apiKey: 'k', + fetchImpl: impl, + }).createRoom('1'); + const [url] = calls[0] as [string]; + expect(url).toBe('http://server:5000/api/integrations/discord/rooms'); + }); +}); diff --git a/bot/test/env.test.ts b/bot/test/env.test.ts new file mode 100644 index 0000000..461ff10 --- /dev/null +++ b/bot/test/env.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { loadBotEnv } from '../src/config/env.js'; + +const full = { + DISCORD_TOKEN: 'tok', + DISCORD_CLIENT_ID: 'cid', + SERVER_URL: 'http://localhost:5000', + CLIENT_URL: 'http://localhost:5173', + DISCORD_BOT_API_KEY: 'secret', +}; + +describe('loadBotEnv', () => { + it('returns a typed config when all required vars are present', () => { + const config = loadBotEnv(full); + expect(config).toEqual({ + discordToken: 'tok', + discordClientId: 'cid', + serverUrl: 'http://localhost:5000', + clientUrl: 'http://localhost:5173', + botApiKey: 'secret', + }); + }); + + it('includes the guild id only when provided', () => { + expect(loadBotEnv(full).discordGuildId).toBeUndefined(); + expect(loadBotEnv({ ...full, DISCORD_GUILD_ID: 'gid' }).discordGuildId).toBe('gid'); + }); + + it('trims surrounding whitespace from values', () => { + const config = loadBotEnv({ ...full, DISCORD_TOKEN: ' spaced ' }); + expect(config.discordToken).toBe('spaced'); + }); + + it('throws listing every missing required variable', () => { + expect(() => loadBotEnv({ DISCORD_TOKEN: 'tok' })).toThrow( + /DISCORD_CLIENT_ID.*SERVER_URL.*CLIENT_URL.*DISCORD_BOT_API_KEY/, + ); + }); + + it('treats blank/whitespace-only values as missing', () => { + expect(() => loadBotEnv({ ...full, DISCORD_BOT_API_KEY: ' ' })).toThrow( + /DISCORD_BOT_API_KEY/, + ); + }); +}); diff --git a/bot/test/handlers.test.ts b/bot/test/handlers.test.ts new file mode 100644 index 0000000..ed4eff3 --- /dev/null +++ b/bot/test/handlers.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MessageFlags } from 'discord.js'; +import type { ChatInputCommandInteraction } from 'discord.js'; +import { handleLink, handleCreate, buildMeetingUrl } from '../src/commands/handlers.js'; +import { + DiscordIntegrationClient, + NotLinkedError, + IntegrationHttpError, +} from '../src/http/client.js'; + +const CLIENT_URL = 'http://client:5173'; + +// A mocked interaction exposing only what the handlers touch. Cast at the call +// site so the handlers still see the real discord.js type. +function mockInteraction() { + const reply = vi.fn().mockResolvedValue(undefined); + const interaction = { + user: { id: '42', toString: () => '<@42>' }, + reply, + } as unknown as ChatInputCommandInteraction; + return { interaction, reply }; +} + +// A mocked HTTP client — only the two methods the handlers call. +function mockClient(overrides: Partial>> = {}) { + return { + createLinkToken: overrides.createLinkToken ?? vi.fn(), + createRoom: overrides.createRoom ?? vi.fn(), + } as unknown as DiscordIntegrationClient; +} + +function isEphemeral(flags: unknown): boolean { + return typeof flags === 'number' && (flags & MessageFlags.Ephemeral) !== 0; +} + +describe('handleLink', () => { + it('replies ephemerally with the link URL on success', async () => { + const { interaction, reply } = mockInteraction(); + const createLinkToken = vi.fn().mockResolvedValue({ + token: 'jwt', + linkUrl: 'http://client/link/discord?token=jwt', + }); + await handleLink(interaction, mockClient({ createLinkToken })); + + expect(createLinkToken).toHaveBeenCalledWith('42'); + const payload = reply.mock.calls[0][0]; + expect(isEphemeral(payload.flags)).toBe(true); + expect(payload.content).toContain('http://client/link/discord?token=jwt'); + }); + + it('replies ephemerally with a friendly error when the token call fails', async () => { + const { interaction, reply } = mockInteraction(); + const createLinkToken = vi.fn().mockRejectedValue(new IntegrationHttpError(500, 'boom')); + await handleLink(interaction, mockClient({ createLinkToken })); + + const payload = reply.mock.calls[0][0]; + expect(isEphemeral(payload.flags)).toBe(true); + expect(payload.content).toMatch(/something went wrong/i); + expect(payload.embeds).toBeUndefined(); + }); +}); + +describe('handleCreate', () => { + it('posts a PUBLIC embed with the meeting URL and starter on success', async () => { + const { interaction, reply } = mockInteraction(); + const createRoom = vi.fn().mockResolvedValue({ roomId: 'abc-defg-hij' }); + await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); + + expect(createRoom).toHaveBeenCalledWith('42'); + const payload = reply.mock.calls[0][0]; + // Public: no ephemeral flag. + expect(isEphemeral(payload.flags)).toBe(false); + expect(payload.embeds).toHaveLength(1); + const data = payload.embeds[0].data; + const rendered = JSON.stringify(data); + expect(rendered).toContain('http://client:5173/lobby/abc-defg-hij'); + expect(rendered).toContain('<@42>'); + }); + + it('replies ephemerally prompting /meet link when the account is not linked', async () => { + const { interaction, reply } = mockInteraction(); + const createRoom = vi.fn().mockRejectedValue(new NotLinkedError()); + await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); + + const payload = reply.mock.calls[0][0]; + expect(isEphemeral(payload.flags)).toBe(true); + expect(payload.content).toContain('/meet link'); + expect(payload.embeds).toBeUndefined(); + }); + + it('replies ephemerally with a friendly error on a server error', async () => { + const { interaction, reply } = mockInteraction(); + const createRoom = vi.fn().mockRejectedValue(new IntegrationHttpError(500, 'boom')); + await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); + + const payload = reply.mock.calls[0][0]; + expect(isEphemeral(payload.flags)).toBe(true); + expect(payload.content).toMatch(/something went wrong/i); + expect(payload.embeds).toBeUndefined(); + }); + + it('replies ephemerally with a friendly error on a network failure', async () => { + const { interaction, reply } = mockInteraction(); + const createRoom = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); + + const payload = reply.mock.calls[0][0]; + expect(isEphemeral(payload.flags)).toBe(true); + expect(payload.embeds).toBeUndefined(); + }); +}); + +describe('buildMeetingUrl', () => { + it('builds the canonical /lobby/ join URL', () => { + expect(buildMeetingUrl('http://client:5173', 'abc-defg-hij')).toBe( + 'http://client:5173/lobby/abc-defg-hij', + ); + }); + + it('tolerates a trailing slash on the client URL', () => { + expect(buildMeetingUrl('http://client:5173/', 'x')).toBe('http://client:5173/lobby/x'); + }); + + it('URL-encodes the room id', () => { + expect(buildMeetingUrl('http://c', 'a b')).toBe('http://c/lobby/a%20b'); + }); +}); diff --git a/bot/test/interactions.test.ts b/bot/test/interactions.test.ts new file mode 100644 index 0000000..c99ace1 --- /dev/null +++ b/bot/test/interactions.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MessageFlags } from 'discord.js'; +import type { Interaction } from 'discord.js'; +import { handleInteraction } from '../src/interactions.js'; +import { DiscordIntegrationClient } from '../src/http/client.js'; + +function mockClient() { + return { + createLinkToken: vi.fn().mockResolvedValue({ token: 't', linkUrl: 'http://l' }), + createRoom: vi.fn().mockResolvedValue({ roomId: 'r' }), + } as unknown as DiscordIntegrationClient; +} + +// Build a chat-input `/meet` interaction for the given subcommand. +function meetInteraction(sub: string) { + const reply = vi.fn().mockResolvedValue(undefined); + const interaction = { + isChatInputCommand: () => true, + commandName: 'meet', + options: { getSubcommand: () => sub }, + user: { id: '7', toString: () => '<@7>' }, + reply, + } as unknown as Interaction; + return { interaction, reply }; +} + +describe('handleInteraction', () => { + it('routes /meet link to the link handler (mints a token)', async () => { + const client = mockClient(); + const { interaction } = meetInteraction('link'); + await handleInteraction(interaction, client, 'http://client'); + expect(client.createLinkToken).toHaveBeenCalledWith('7'); + expect(client.createRoom).not.toHaveBeenCalled(); + }); + + it('routes /meet create to the create handler (creates a room)', async () => { + const client = mockClient(); + const { interaction } = meetInteraction('create'); + await handleInteraction(interaction, client, 'http://client'); + expect(client.createRoom).toHaveBeenCalledWith('7'); + expect(client.createLinkToken).not.toHaveBeenCalled(); + }); + + it('ignores non chat-input interactions', async () => { + const client = mockClient(); + const reply = vi.fn(); + const interaction = { isChatInputCommand: () => false, reply } as unknown as Interaction; + await handleInteraction(interaction, client, 'http://client'); + expect(reply).not.toHaveBeenCalled(); + expect(client.createLinkToken).not.toHaveBeenCalled(); + }); + + it('ignores commands other than /meet', async () => { + const client = mockClient(); + const reply = vi.fn(); + const interaction = { + isChatInputCommand: () => true, + commandName: 'other', + reply, + } as unknown as Interaction; + await handleInteraction(interaction, client, 'http://client'); + expect(reply).not.toHaveBeenCalled(); + }); + + it('answers an unknown subcommand ephemerally rather than going silent', async () => { + const client = mockClient(); + const { interaction, reply } = meetInteraction('bogus'); + await handleInteraction(interaction, client, 'http://client'); + const payload = reply.mock.calls[0][0]; + expect(payload.flags & MessageFlags.Ephemeral).toBe(MessageFlags.Ephemeral); + }); +}); diff --git a/bot/test/meet.test.ts b/bot/test/meet.test.ts new file mode 100644 index 0000000..5ae6988 --- /dev/null +++ b/bot/test/meet.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { meetCommandJSON } from '../src/commands/meet.js'; + +describe('meet command definition', () => { + it('is named /meet with link and create subcommands', () => { + expect(meetCommandJSON.name).toBe('meet'); + const subNames = (meetCommandJSON.options ?? []).map((o) => o.name).sort(); + expect(subNames).toEqual(['create', 'link']); + }); +}); diff --git a/bot/tsconfig.json b/bot/tsconfig.json new file mode 100644 index 0000000..83f1f97 --- /dev/null +++ b/bot/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"] + }, + // Greenfield TypeScript workspace — the whole tree is strict-checked (there is + // no JS→TS migration here). Tests are type-checked too so mocked interactions + // stay honest against discord.js types. + "include": ["src", "test"] +} diff --git a/bot/vitest.config.ts b/bot/vitest.config.ts new file mode 100644 index 0000000..e79b594 --- /dev/null +++ b/bot/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + // Measure the unit-testable adapter logic only. The process entrypoints + // (index.ts boots the gateway client; bot.ts wires the login/dispatch; + // register-commands.ts is a one-shot ops script) talk to real Discord and + // aren't unit-testable, so they're excluded rather than dragging the floor. + include: ['src/**/*.ts'], + exclude: ['src/index.ts', 'src/bot.ts', 'src/register-commands.ts'], + // Non-decreasing ratchet — `vitest run --coverage` fails when any metric + // drops below these. Raising them is an intentional, reviewed commit; + // never lower them to make a drop pass. + thresholds: { lines: 90, functions: 90, branches: 85, statements: 90 }, + }, + }, +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 292a435..60bd65e 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -78,3 +78,40 @@ services: awslogs-group: ${CLOUDWATCH_LOG_GROUP:-/a-meet/prod/server} awslogs-stream: ${DEPLOY_ENVIRONMENT:-prod}/server awslogs-create-group: "false" + + # The Discord bot — a separate, self-healing process. It is a thin adapter over + # the server's /api/integrations/discord API and holds no meeting logic, so if + # it dies the API is unaffected (and vice-versa). Docker's restart policy keeps + # it up. Unlike the server it needs no inbound ports, but it shares the host + # network so it can reach the (host-networked) server at localhost:5000. + bot: + image: ${BOT_IMAGE:-a-meet-bot:latest} + build: + context: . + dockerfile: bot/Dockerfile + container_name: a-meet-bot + network_mode: host + restart: always + # Small, steady process (one gateway websocket + occasional HTTP calls). + mem_limit: 256m + mem_reservation: 64m + cpus: 0.5 + # Secrets are injected from the environment at deploy time (populated from the + # same SSM SecureStrings as the server — /a-meet/prod/bot/*). `:?` fails the + # `up` loudly if a required secret is missing rather than booting broken. + environment: + NODE_ENV: production + DISCORD_TOKEN: ${DISCORD_TOKEN:?set DISCORD_TOKEN (SSM: /a-meet/prod/bot/discord-token)} + DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID} + DISCORD_BOT_API_KEY: ${DISCORD_BOT_API_KEY:?set DISCORD_BOT_API_KEY (must match the server's)} + SERVER_URL: ${BOT_SERVER_URL:-http://localhost:5000} + CLIENT_URL: ${BOT_CLIENT_URL:?set BOT_CLIENT_URL (public web client base URL)} + # Optional: register commands to a single guild instead of globally. + DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} + logging: + driver: awslogs + options: + awslogs-region: ${AWS_REGION:?set AWS_REGION} + awslogs-group: ${CLOUDWATCH_LOG_GROUP:-/a-meet/prod/server} + awslogs-stream: ${DEPLOY_ENVIRONMENT:-prod}/bot + awslogs-create-group: "false" diff --git a/package-lock.json b/package-lock.json index 15f9d25..6bd3ed0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "client", "server", "shared", + "bot", "e2e" ], "devDependencies": { @@ -21,6 +22,28 @@ "node": ">=22.13.0" } }, + "bot": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@a-meet/contracts": "file:../shared", + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "tsx": "^4.22.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^26.0.1", + "@vitest/coverage-v8": "^4.1.10", + "eslint": "^10.3.0", + "globals": "^17.6.0", + "typescript-eslint": "^8.62.1", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22.13.0" + } + }, "client": { "version": "0.0.0", "dependencies": { @@ -851,6 +874,145 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz", + "integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.50", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -2376,6 +2538,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@smithy/core": { "version": "3.29.4", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.4.tgz", @@ -3321,6 +3516,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3751,6 +3956,10 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bot": { + "resolved": "bot", + "link": true + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -4315,6 +4524,51 @@ "wrappy": "1" } }, + "node_modules/discord-api-types": { + "version": "0.38.50", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.50.tgz", + "integrity": "sha512-J2n/bpIETX3DQ6AJ7/0xbsTLmYiJQtO/LKcXKC1YDbB56OUwtDbdXOFE8Q4g8jVGHBR2VAy1+D4ngaIgkMNV9w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.27.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz", + "integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.2", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.5", + "discord-api-types": "^0.38.49", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "^6.27.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4949,7 +5203,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -6304,6 +6557,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -6346,6 +6605,12 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6378,6 +6643,12 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8859,6 +9130,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index fbffdca..2e498a8 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "client", "server", "shared", + "bot", "e2e" ], "scripts": { @@ -19,22 +20,25 @@ "docker:up": "docker compose up -d", "docker:down": "docker compose down", "docker:logs": "docker compose logs -f", - "test": "npm run test:server && npm run test:client", + "test": "npm run test:server && npm run test:client && npm run test:bot", "test:server": "npm --prefix server test", "test:client": "npm --prefix client test", + "test:bot": "npm --prefix bot test", "coverage": "npm run coverage:server && npm run coverage:client", "coverage:server": "npm --prefix server run test:coverage", "coverage:client": "npm --prefix client run test:coverage", "test:e2e": "npm --prefix e2e test", "test:e2e:install": "npm --prefix e2e run install:browsers", - "typecheck": "npm run typecheck:shared && npm run typecheck:server && npm run typecheck:client", + "typecheck": "npm run typecheck:shared && npm run typecheck:server && npm run typecheck:client && npm run typecheck:bot", "typecheck:shared": "npm --prefix shared run typecheck", "typecheck:server": "npm --prefix server run typecheck", "typecheck:client": "npm --prefix client run typecheck", + "typecheck:bot": "npm --prefix bot run typecheck", "lint:shared": "npm --prefix shared run lint", "lint:server": "npm --prefix server run lint", "lint:client": "npm --prefix client run lint", - "lint": "npm run lint:shared && npm run lint:server && npm run lint:client", + "lint:bot": "npm --prefix bot run lint", + "lint": "npm run lint:shared && npm run lint:server && npm run lint:client && npm run lint:bot", "lint:fix": "npm --workspaces --if-present run lint -- --fix", "audit:server": "npm audit --audit-level=high --prefix server", "audit:client": "npm audit --audit-level=high --prefix client", From 05f1f65f7205f9117469a2577cfe4406813a4a54 Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Thu, 23 Jul 2026 13:07:03 +0530 Subject: [PATCH 2/6] fix(bot): defer interactions, fix prod compose/dockerignore wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings: - Handlers defer the reply immediately (deferReply ephemeral) before the HTTP call, then editReply/followUp — Discord invalidates the interaction token after ~3s, so a slow API call no longer leaves the user with a dead interaction. Success posts the public embed as a non-ephemeral followUp while errors/not-linked stay ephemeral, preserving the required reply routing. - docker-compose.prod.yml: the bot service is now profile-gated (profiles: [bot]) so the automated server deploy's bare `up -d` never touches it, and its env uses non-failing `:-` defaults instead of `:?` guards (which also fixes invalid YAML from a colon-space in the guard message). The bot validates its own required config at startup and exits non-zero if misconfigured. - Root .dockerignore now allowlists bot/package.json + bot/src so the bot image (built from the repo-root context) resolves its COPYs; removed the dead bot/.dockerignore (a per-Dockerfile ignore is not applied for a root context). - bot/Dockerfile drops the unused tsconfig copies (tsx strips types without them). --- .dockerignore | 10 +++- bot/.dockerignore | 5 -- bot/Dockerfile | 3 -- bot/src/commands/handlers.ts | 39 +++++++------- bot/test/handlers.test.ts | 97 ++++++++++++++++++----------------- bot/test/interactions.test.ts | 7 ++- docker-compose.prod.yml | 28 +++++++--- 7 files changed, 107 insertions(+), 82 deletions(-) delete mode 100644 bot/.dockerignore diff --git a/.dockerignore b/.dockerignore index f272e5f..8141004 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ -# The server image only needs workspace manifests plus server/shared sources. +# The server and bot images share this repository root as their build context, +# so Docker applies THIS ignore file (a per-Dockerfile bot/.dockerignore would be +# ignored). Allowlist exactly the workspace manifests + sources each image needs. ** !package.json !package-lock.json @@ -8,6 +10,12 @@ server/** !server/Dockerfile !server/src/ !server/src/** +!bot/ +bot/** +!bot/package.json +!bot/Dockerfile +!bot/src/ +!bot/src/** !shared/ shared/** !shared/package.json diff --git a/bot/.dockerignore b/bot/.dockerignore deleted file mode 100644 index 74f8369..0000000 --- a/bot/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -coverage -dist -.env -*.log diff --git a/bot/Dockerfile b/bot/Dockerfile index 0a1ddc8..d623ef3 100644 --- a/bot/Dockerfile +++ b/bot/Dockerfile @@ -17,9 +17,7 @@ COPY shared/package.json ./shared/package.json RUN npm ci --omit=dev --workspace bot --ignore-scripts COPY bot/src ./bot/src -COPY bot/tsconfig.json ./bot/tsconfig.json COPY shared/src ./shared/src -COPY tsconfig.base.json ./tsconfig.base.json # ---- runtime stage: slim image carrying only the built artifacts ---- FROM node:22-bookworm-slim AS runtime @@ -31,7 +29,6 @@ COPY --from=build /app/bot ./ COPY --from=build /app/shared /app/shared COPY --from=build /app/package.json /app/package.json COPY --from=build /app/package-lock.json /app/package-lock.json -COPY --from=build /app/tsconfig.base.json /app/tsconfig.base.json RUN chown -R node:node /app USER node diff --git a/bot/src/commands/handlers.ts b/bot/src/commands/handlers.ts index b4deb56..85ecd87 100644 --- a/bot/src/commands/handlers.ts +++ b/bot/src/commands/handlers.ts @@ -7,32 +7,36 @@ import { // Command handlers: thin glue between a Discord interaction and the integration // HTTP client. Every path answers the interaction — success or failure, never -// silence. Private answers use MessageFlags.Ephemeral (only the invoker sees -// them); the meeting announcement is a public channel embed. +// silence. +// +// Discord invalidates an interaction token if no initial response is sent within +// ~3 seconds, and the API call can take longer (cold start, slow network). So +// each handler defers IMMEDIATELY, then edits/follows-up once the call returns. +// The deferred reply is ephemeral, so every private outcome (link URL, not-linked +// nudge, errors) is edited in as ephemeral. The one PUBLIC surface — the meeting +// announcement — is sent as a separate non-ephemeral followUp, so success stays +// visible to the whole channel while failures stay private to the invoker. const GENERIC_ERROR = 'Something went wrong talking to A-Meet. Please try again in a moment.'; /** - * `/meet link` — mint an account-link token and DM-style ephemeral reply the + * `/meet link` — mint an account-link token and ephemerally reply the * confirmation URL so only the requester sees it. */ export async function handleLink( interaction: ChatInputCommandInteraction, client: DiscordIntegrationClient, ): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const { linkUrl } = await client.createLinkToken(interaction.user.id); - await interaction.reply({ + await interaction.editReply({ content: `Link your A-Meet account: ${linkUrl}\n` + 'Open it in your browser while signed in to A-Meet. The link expires in ~10 minutes.', - flags: MessageFlags.Ephemeral, }); } catch { - await interaction.reply({ - content: GENERIC_ERROR, - flags: MessageFlags.Ephemeral, - }); + await interaction.editReply({ content: GENERIC_ERROR }); } } @@ -46,21 +50,17 @@ export async function handleCreate( client: DiscordIntegrationClient, clientUrl: string, ): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + let roomId: string; try { ({ roomId } = await client.createRoom(interaction.user.id)); } catch (err) { if (err instanceof NotLinkedError) { - await interaction.reply({ - content: 'Link your account first with `/meet link`.', - flags: MessageFlags.Ephemeral, - }); + await interaction.editReply({ content: 'Link your account first with `/meet link`.' }); return; } - await interaction.reply({ - content: GENERIC_ERROR, - flags: MessageFlags.Ephemeral, - }); + await interaction.editReply({ content: GENERIC_ERROR }); return; } @@ -73,7 +73,10 @@ export async function handleCreate( { name: 'Started by', value: interaction.user.toString() }, ); - await interaction.reply({ embeds: [embed] }); + // Public channel announcement (non-ephemeral): everyone can see and join. + await interaction.followUp({ embeds: [embed] }); + // Resolve the ephemeral deferred reply with a private confirmation. + await interaction.editReply({ content: 'Meeting created — join link posted in the channel.' }); } /** diff --git a/bot/test/handlers.test.ts b/bot/test/handlers.test.ts index ed4eff3..ba3e4cc 100644 --- a/bot/test/handlers.test.ts +++ b/bot/test/handlers.test.ts @@ -10,18 +10,27 @@ import { const CLIENT_URL = 'http://client:5173'; -// A mocked interaction exposing only what the handlers touch. Cast at the call -// site so the handlers still see the real discord.js type. +// A mocked interaction exposing only what the handlers touch: the defer/edit/ +// followUp trio and the invoking user. Cast at the call site so the handlers +// still see the real discord.js type. function mockInteraction() { - const reply = vi.fn().mockResolvedValue(undefined); + const deferReply = vi.fn().mockResolvedValue(undefined); + const editReply = vi.fn().mockResolvedValue(undefined); + const followUp = vi.fn().mockResolvedValue(undefined); const interaction = { user: { id: '42', toString: () => '<@42>' }, - reply, + deferReply, + editReply, + followUp, } as unknown as ChatInputCommandInteraction; - return { interaction, reply }; + return { interaction, deferReply, editReply, followUp }; +} + +// True when a flags value carries the Ephemeral bit. +function hasEphemeral(flags: unknown): boolean { + return typeof flags === 'number' && (flags & MessageFlags.Ephemeral) !== 0; } -// A mocked HTTP client — only the two methods the handlers call. function mockClient(overrides: Partial>> = {}) { return { createLinkToken: overrides.createLinkToken ?? vi.fn(), @@ -29,13 +38,9 @@ function mockClient(overrides: Partial { - it('replies ephemerally with the link URL on success', async () => { - const { interaction, reply } = mockInteraction(); + it('defers ephemerally and edits in the link URL on success', async () => { + const { interaction, deferReply, editReply, followUp } = mockInteraction(); const createLinkToken = vi.fn().mockResolvedValue({ token: 'jwt', linkUrl: 'http://client/link/discord?token=jwt', @@ -43,70 +48,70 @@ describe('handleLink', () => { await handleLink(interaction, mockClient({ createLinkToken })); expect(createLinkToken).toHaveBeenCalledWith('42'); - const payload = reply.mock.calls[0][0]; - expect(isEphemeral(payload.flags)).toBe(true); - expect(payload.content).toContain('http://client/link/discord?token=jwt'); + // Ephemeral routing: the deferred (and therefore edited) reply is ephemeral. + expect(hasEphemeral(deferReply.mock.calls[0][0].flags)).toBe(true); + expect(editReply.mock.calls[0][0].content).toContain('http://client/link/discord?token=jwt'); + // Never a public message for /meet link. + expect(followUp).not.toHaveBeenCalled(); }); - it('replies ephemerally with a friendly error when the token call fails', async () => { - const { interaction, reply } = mockInteraction(); + it('edits in a friendly ephemeral error when the token call fails', async () => { + const { interaction, deferReply, editReply, followUp } = mockInteraction(); const createLinkToken = vi.fn().mockRejectedValue(new IntegrationHttpError(500, 'boom')); await handleLink(interaction, mockClient({ createLinkToken })); - const payload = reply.mock.calls[0][0]; - expect(isEphemeral(payload.flags)).toBe(true); - expect(payload.content).toMatch(/something went wrong/i); - expect(payload.embeds).toBeUndefined(); + expect(hasEphemeral(deferReply.mock.calls[0][0].flags)).toBe(true); + expect(editReply.mock.calls[0][0].content).toMatch(/something went wrong/i); + expect(followUp).not.toHaveBeenCalled(); }); }); describe('handleCreate', () => { - it('posts a PUBLIC embed with the meeting URL and starter on success', async () => { - const { interaction, reply } = mockInteraction(); + it('posts a PUBLIC embed via followUp and keeps the ack ephemeral on success', async () => { + const { interaction, deferReply, editReply, followUp } = mockInteraction(); const createRoom = vi.fn().mockResolvedValue({ roomId: 'abc-defg-hij' }); await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); expect(createRoom).toHaveBeenCalledWith('42'); - const payload = reply.mock.calls[0][0]; - // Public: no ephemeral flag. - expect(isEphemeral(payload.flags)).toBe(false); - expect(payload.embeds).toHaveLength(1); - const data = payload.embeds[0].data; - const rendered = JSON.stringify(data); + // The deferred ack is ephemeral (only the invoker sees it)... + expect(hasEphemeral(deferReply.mock.calls[0][0].flags)).toBe(true); + // ...but the meeting announcement is a PUBLIC followUp — no ephemeral flag. + const followUpPayload = followUp.mock.calls[0][0]; + expect(hasEphemeral(followUpPayload.flags)).toBe(false); + expect(followUpPayload.embeds).toHaveLength(1); + const rendered = JSON.stringify(followUpPayload.embeds[0].data); expect(rendered).toContain('http://client:5173/lobby/abc-defg-hij'); expect(rendered).toContain('<@42>'); + expect(editReply).toHaveBeenCalled(); }); - it('replies ephemerally prompting /meet link when the account is not linked', async () => { - const { interaction, reply } = mockInteraction(); + it('edits in an ephemeral /meet link prompt when the account is not linked', async () => { + const { interaction, deferReply, editReply, followUp } = mockInteraction(); const createRoom = vi.fn().mockRejectedValue(new NotLinkedError()); await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); - const payload = reply.mock.calls[0][0]; - expect(isEphemeral(payload.flags)).toBe(true); - expect(payload.content).toContain('/meet link'); - expect(payload.embeds).toBeUndefined(); + expect(hasEphemeral(deferReply.mock.calls[0][0].flags)).toBe(true); + expect(editReply.mock.calls[0][0].content).toContain('/meet link'); + // No public message when not linked. + expect(followUp).not.toHaveBeenCalled(); }); - it('replies ephemerally with a friendly error on a server error', async () => { - const { interaction, reply } = mockInteraction(); + it('edits in a friendly ephemeral error on a server error (no public post)', async () => { + const { interaction, editReply, followUp } = mockInteraction(); const createRoom = vi.fn().mockRejectedValue(new IntegrationHttpError(500, 'boom')); await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); - const payload = reply.mock.calls[0][0]; - expect(isEphemeral(payload.flags)).toBe(true); - expect(payload.content).toMatch(/something went wrong/i); - expect(payload.embeds).toBeUndefined(); + expect(editReply.mock.calls[0][0].content).toMatch(/something went wrong/i); + expect(followUp).not.toHaveBeenCalled(); }); - it('replies ephemerally with a friendly error on a network failure', async () => { - const { interaction, reply } = mockInteraction(); + it('edits in a friendly ephemeral error on a network failure (no public post)', async () => { + const { interaction, editReply, followUp } = mockInteraction(); const createRoom = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); - const payload = reply.mock.calls[0][0]; - expect(isEphemeral(payload.flags)).toBe(true); - expect(payload.embeds).toBeUndefined(); + expect(editReply.mock.calls[0][0].content).toMatch(/something went wrong/i); + expect(followUp).not.toHaveBeenCalled(); }); }); diff --git a/bot/test/interactions.test.ts b/bot/test/interactions.test.ts index c99ace1..6077ab7 100644 --- a/bot/test/interactions.test.ts +++ b/bot/test/interactions.test.ts @@ -11,7 +11,9 @@ function mockClient() { } as unknown as DiscordIntegrationClient; } -// Build a chat-input `/meet` interaction for the given subcommand. +// Build a chat-input `/meet` interaction for the given subcommand. Includes the +// defer/edit/followUp trio the handlers use plus `reply` for the unknown-command +// fast path. function meetInteraction(sub: string) { const reply = vi.fn().mockResolvedValue(undefined); const interaction = { @@ -20,6 +22,9 @@ function meetInteraction(sub: string) { options: { getSubcommand: () => sub }, user: { id: '7', toString: () => '<@7>' }, reply, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), } as unknown as Interaction; return { interaction, reply }; } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 60bd65e..ad73cf7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -84,7 +84,22 @@ services: # it dies the API is unaffected (and vice-versa). Docker's restart policy keeps # it up. Unlike the server it needs no inbound ports, but it shares the host # network so it can reach the (host-networked) server at localhost:5000. + # + # Profile-gated ON PURPOSE. The automated server deploy runs a bare + # `docker compose up -d` over this file; the `bot` profile keeps that from + # touching the bot, so the existing server release path is completely + # unaffected. Bring the bot up explicitly once its config + image exist: + # docker compose -f docker-compose.prod.yml --profile bot up -d bot + # Full deploy-pipeline automation (build/push a bot image, export the bot's + # SSM SecureStrings) is a deliberate follow-up — see bot/README.md. + # + # Config comes from the environment (locally an .env; in prod exported from the + # same SSM SecureStrings as the server). Values default to empty here rather + # than using `:?` guards so this shared file can never abort the server's + # `up -d`; the bot validates its own required config at startup (loadBotEnv) + # and exits non-zero if anything is missing. bot: + profiles: ["bot"] image: ${BOT_IMAGE:-a-meet-bot:latest} build: context: . @@ -96,22 +111,19 @@ services: mem_limit: 256m mem_reservation: 64m cpus: 0.5 - # Secrets are injected from the environment at deploy time (populated from the - # same SSM SecureStrings as the server — /a-meet/prod/bot/*). `:?` fails the - # `up` loudly if a required secret is missing rather than booting broken. environment: NODE_ENV: production - DISCORD_TOKEN: ${DISCORD_TOKEN:?set DISCORD_TOKEN (SSM: /a-meet/prod/bot/discord-token)} - DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:?set DISCORD_CLIENT_ID} - DISCORD_BOT_API_KEY: ${DISCORD_BOT_API_KEY:?set DISCORD_BOT_API_KEY (must match the server's)} + DISCORD_TOKEN: ${DISCORD_TOKEN:-} + DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-} + DISCORD_BOT_API_KEY: ${DISCORD_BOT_API_KEY:-} SERVER_URL: ${BOT_SERVER_URL:-http://localhost:5000} - CLIENT_URL: ${BOT_CLIENT_URL:?set BOT_CLIENT_URL (public web client base URL)} + CLIENT_URL: ${BOT_CLIENT_URL:-} # Optional: register commands to a single guild instead of globally. DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} logging: driver: awslogs options: - awslogs-region: ${AWS_REGION:?set AWS_REGION} + awslogs-region: ${AWS_REGION:-eu-north-1} awslogs-group: ${CLOUDWATCH_LOG_GROUP:-/a-meet/prod/server} awslogs-stream: ${DEPLOY_ENVIRONMENT:-prod}/bot awslogs-create-group: "false" From a582a715981cf869235bf3d4fd48722da77ad21e Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Thu, 23 Jul 2026 13:11:05 +0530 Subject: [PATCH 3/6] fix(bot): resolve deferred reply when the public post fails; harden public-boundary test - handleCreate now wraps the public followUp in try/catch: if posting the announcement fails (e.g. the bot can't send in that channel), it resolves the ephemeral deferred reply with the meeting link instead of letting the error bubble to the InteractionCreate handler, which skips already-deferred interactions and would leave the invoker hanging. - handlers test asserts the public followUp sets neither the ephemeral flag bit nor the deprecated `ephemeral: true` shorthand, and adds a rejecting-followUp case. --- bot/src/commands/handlers.ts | 14 +++++++++++++- bot/test/handlers.test.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/bot/src/commands/handlers.ts b/bot/src/commands/handlers.ts index 85ecd87..434a950 100644 --- a/bot/src/commands/handlers.ts +++ b/bot/src/commands/handlers.ts @@ -74,7 +74,19 @@ export async function handleCreate( ); // Public channel announcement (non-ephemeral): everyone can see and join. - await interaction.followUp({ embeds: [embed] }); + try { + await interaction.followUp({ embeds: [embed] }); + } catch { + // Posting to the channel can fail (e.g. the bot lacks Send Messages/Embed + // Links there). The room WAS created, so resolve the deferred ephemeral + // reply by handing the invoker the link directly — never leave them hanging. + await interaction.editReply({ + content: + `Your meeting is ready: ${meetingUrl}\n` + + "I couldn't post it to this channel — check that I can send messages here.", + }); + return; + } // Resolve the ephemeral deferred reply with a private confirmation. await interaction.editReply({ content: 'Meeting created — join link posted in the channel.' }); } diff --git a/bot/test/handlers.test.ts b/bot/test/handlers.test.ts index ba3e4cc..46549ea 100644 --- a/bot/test/handlers.test.ts +++ b/bot/test/handlers.test.ts @@ -31,6 +31,12 @@ function hasEphemeral(flags: unknown): boolean { return typeof flags === 'number' && (flags & MessageFlags.Ephemeral) !== 0; } +// A reply payload is PUBLIC only if it neither sets the ephemeral flag bit nor +// the (deprecated but still honored) `ephemeral: true` shorthand. +function isPublic(payload: { flags?: unknown; ephemeral?: unknown }): boolean { + return !hasEphemeral(payload.flags) && payload.ephemeral !== true; +} + function mockClient(overrides: Partial>> = {}) { return { createLinkToken: overrides.createLinkToken ?? vi.fn(), @@ -75,9 +81,10 @@ describe('handleCreate', () => { expect(createRoom).toHaveBeenCalledWith('42'); // The deferred ack is ephemeral (only the invoker sees it)... expect(hasEphemeral(deferReply.mock.calls[0][0].flags)).toBe(true); - // ...but the meeting announcement is a PUBLIC followUp — no ephemeral flag. + // ...but the meeting announcement is a PUBLIC followUp — neither the + // ephemeral flag bit nor the deprecated `ephemeral: true` shorthand. const followUpPayload = followUp.mock.calls[0][0]; - expect(hasEphemeral(followUpPayload.flags)).toBe(false); + expect(isPublic(followUpPayload)).toBe(true); expect(followUpPayload.embeds).toHaveLength(1); const rendered = JSON.stringify(followUpPayload.embeds[0].data); expect(rendered).toContain('http://client:5173/lobby/abc-defg-hij'); @@ -85,6 +92,22 @@ describe('handleCreate', () => { expect(editReply).toHaveBeenCalled(); }); + it('resolves the deferred reply with the link when the public post fails', async () => { + const { interaction, deferReply, editReply, followUp } = mockInteraction(); + const createRoom = vi.fn().mockResolvedValue({ roomId: 'abc-defg-hij' }); + // Channel post rejects (e.g. the bot lacks Send Messages there). + (followUp as unknown as ReturnType).mockRejectedValue( + new Error('Missing Permissions'), + ); + await handleCreate(interaction, mockClient({ createRoom }), CLIENT_URL); + + // The invoker is NOT left hanging: the ephemeral deferred reply is resolved + // with the meeting link so the created room isn't wasted. + expect(hasEphemeral(deferReply.mock.calls[0][0].flags)).toBe(true); + const editPayload = editReply.mock.calls.at(-1)![0]; + expect(editPayload.content).toContain('http://client:5173/lobby/abc-defg-hij'); + }); + it('edits in an ephemeral /meet link prompt when the account is not linked', async () => { const { interaction, deferReply, editReply, followUp } = mockInteraction(); const createRoom = vi.fn().mockRejectedValue(new NotLinkedError()); From db45d71839708e2903418dcbdddc56db3b33536f Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Thu, 23 Jul 2026 13:17:04 +0530 Subject: [PATCH 4/6] fix(bot): deferred-interaction recovery net; bot in coverage+audit gates - Extract dispatchInteraction: a top-level safety net around the router that recovers ALREADY-deferred/replied interactions via editReply (the previous guard only handled the not-yet-deferred case, so a mid-handler editReply/ followUp rejection left the invoker with an unresolved spinner). bot.ts now delegates to it. Added tests for the deferred, not-deferred, failing-recovery, and non-repliable paths. - Harden the public-boundary test helper to reject the Ephemeral marker in every form discord.js accepts (numeric bit, 'Ephemeral' string, array) plus the deprecated ephemeral:true shorthand. - Root coverage + audit aggregates and the CI audit job now include the bot workspace, so its tests and dependency tree are inside the pre-merge gates. --- .github/workflows/ci.yml | 4 ++ bot/src/bot.ts | 26 +++-------- bot/src/interactions.ts | 28 ++++++++++++ bot/test/handlers.test.ts | 20 ++++++-- bot/test/interactions.test.ts | 86 ++++++++++++++++++++++++++++++++++- package.json | 6 ++- 6 files changed, 142 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ff18ed..960d1c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,10 @@ jobs: working-directory: client - run: npm audit --audit-level=high working-directory: client + - run: npm ci --ignore-scripts + working-directory: bot + - run: npm audit --audit-level=high + working-directory: bot client: name: Client tests + build diff --git a/bot/src/bot.ts b/bot/src/bot.ts index 5b71c0d..0a32aed 100644 --- a/bot/src/bot.ts +++ b/bot/src/bot.ts @@ -1,7 +1,7 @@ -import { Client, GatewayIntentBits, Events, MessageFlags } from 'discord.js'; +import { Client, GatewayIntentBits, Events } from 'discord.js'; import type { BotConfig } from './config/env.js'; import { DiscordIntegrationClient } from './http/client.js'; -import { handleInteraction } from './interactions.js'; +import { dispatchInteraction } from './interactions.js'; // Gateway bootstrap: builds the discord.js client, wires the interaction router, // and logs in. Only the Guilds intent is needed — slash commands don't require @@ -19,24 +19,10 @@ export function createBot(config: BotConfig): Client { console.log(`Discord bot logged in as ${ready.user.tag}`); }); - client.on(Events.InteractionCreate, async (interaction) => { - try { - await handleInteraction(interaction, api, config.clientUrl); - } catch (err) { - console.error('Unhandled interaction error:', err); - // Best-effort friendly answer so a crash never leaves the user staring at - // a spinner. Both reply and followUp can throw if the token expired; ignore. - if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { - try { - await interaction.reply({ - content: 'Something went wrong. Please try again.', - flags: MessageFlags.Ephemeral, - }); - } catch { - /* interaction already gone — nothing more we can do */ - } - } - } + // dispatchInteraction owns the try/catch safety net (including recovery for + // already-deferred interactions), so a thrown handler never leaves a spinner. + client.on(Events.InteractionCreate, (interaction) => { + void dispatchInteraction(interaction, api, config.clientUrl); }); return client; diff --git a/bot/src/interactions.ts b/bot/src/interactions.ts index 54edb86..1b6d5e3 100644 --- a/bot/src/interactions.ts +++ b/bot/src/interactions.ts @@ -30,3 +30,31 @@ export async function handleInteraction( }); } } + +// Top-level safety net around the router. If routing throws AFTER the handler +// already deferred/replied (e.g. an editReply/followUp rejects), the interaction +// must still be resolved — a bare `!deferred` guard would skip exactly those +// common cases and leave the user staring at a spinner. Best-effort: if the +// recovery call itself rejects, there is nothing more we can do. +export async function dispatchInteraction( + interaction: Interaction, + client: DiscordIntegrationClient, + clientUrl: string, +): Promise { + try { + await handleInteraction(interaction, client, clientUrl); + } catch (err) { + console.error('Unhandled interaction error:', err); + if (!interaction.isRepliable()) return; + const content = 'Something went wrong. Please try again.'; + try { + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content }); + } else { + await interaction.reply({ content, flags: MessageFlags.Ephemeral }); + } + } catch { + // Interaction already gone / API failing — nothing more we can do. + } + } +} diff --git a/bot/test/handlers.test.ts b/bot/test/handlers.test.ts index 46549ea..2bea93b 100644 --- a/bot/test/handlers.test.ts +++ b/bot/test/handlers.test.ts @@ -26,15 +26,25 @@ function mockInteraction() { return { interaction, deferReply, editReply, followUp }; } -// True when a flags value carries the Ephemeral bit. +// True when a flags value carries the Ephemeral marker in any of the forms +// discord.js accepts: the numeric bit, the 'Ephemeral' string, or an array of +// either. +function carriesEphemeral(flags: unknown): boolean { + if (typeof flags === 'number') return (flags & MessageFlags.Ephemeral) !== 0; + if (typeof flags === 'string') return flags === 'Ephemeral'; + if (Array.isArray(flags)) return flags.some(carriesEphemeral); + return false; +} + +// True when the deferred reply was created ephemeral. function hasEphemeral(flags: unknown): boolean { - return typeof flags === 'number' && (flags & MessageFlags.Ephemeral) !== 0; + return carriesEphemeral(flags); } -// A reply payload is PUBLIC only if it neither sets the ephemeral flag bit nor -// the (deprecated but still honored) `ephemeral: true` shorthand. +// A reply payload is PUBLIC only if it carries the Ephemeral marker in NO form +// and does not use the (deprecated but still honored) `ephemeral: true` shorthand. function isPublic(payload: { flags?: unknown; ephemeral?: unknown }): boolean { - return !hasEphemeral(payload.flags) && payload.ephemeral !== true; + return !carriesEphemeral(payload.flags) && payload.ephemeral !== true; } function mockClient(overrides: Partial>> = {}) { diff --git a/bot/test/interactions.test.ts b/bot/test/interactions.test.ts index 6077ab7..f0661cd 100644 --- a/bot/test/interactions.test.ts +++ b/bot/test/interactions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { MessageFlags } from 'discord.js'; import type { Interaction } from 'discord.js'; -import { handleInteraction } from '../src/interactions.js'; +import { handleInteraction, dispatchInteraction } from '../src/interactions.js'; import { DiscordIntegrationClient } from '../src/http/client.js'; function mockClient() { @@ -75,3 +75,87 @@ describe('handleInteraction', () => { expect(payload.flags & MessageFlags.Ephemeral).toBe(MessageFlags.Ephemeral); }); }); + +describe('dispatchInteraction (top-level safety net)', () => { + it('recovers a NOT-yet-deferred interaction with an ephemeral reply when routing throws', async () => { + const client = mockClient(); + // deferReply rejects -> handleLink throws before the interaction is deferred. + const reply = vi.fn().mockResolvedValue(undefined); + const interaction = { + isChatInputCommand: () => true, + isRepliable: () => true, + commandName: 'meet', + options: { getSubcommand: () => 'link' }, + user: { id: '7', toString: () => '<@7>' }, + deferred: false, + replied: false, + deferReply: vi.fn().mockRejectedValue(new Error('defer failed')), + editReply: vi.fn().mockResolvedValue(undefined), + reply, + } as unknown as Interaction; + + await dispatchInteraction(interaction, client, 'http://client'); + const payload = reply.mock.calls[0][0]; + expect(payload.flags & MessageFlags.Ephemeral).toBe(MessageFlags.Ephemeral); + }); + + it('recovers an ALREADY-deferred interaction via editReply (not reply) when routing throws', async () => { + const client = mockClient(); + const reply = vi.fn(); + const editReply = vi + .fn() + .mockRejectedValueOnce(new Error('editReply failed')) // handler's own edit throws... + .mockResolvedValue(undefined); // ...recovery edit succeeds. + const interaction = { + isChatInputCommand: () => true, + isRepliable: () => true, + commandName: 'meet', + options: { getSubcommand: () => 'link' }, + user: { id: '7', toString: () => '<@7>' }, + deferred: true, + replied: false, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply, + reply, + } as unknown as Interaction; + + // createLinkToken resolves, so the handler tries editReply (rejects) -> throws. + await dispatchInteraction(interaction, client, 'http://client'); + // Recovery used editReply (deferred), never reply. + expect(reply).not.toHaveBeenCalled(); + expect(editReply.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('swallows a failing recovery call without throwing', async () => { + const client = mockClient(); + const interaction = { + isChatInputCommand: () => true, + isRepliable: () => true, + commandName: 'meet', + options: { getSubcommand: () => 'link' }, + user: { id: '7', toString: () => '<@7>' }, + deferred: true, + replied: false, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockRejectedValue(new Error('always fails')), + reply: vi.fn(), + } as unknown as Interaction; + + // Must resolve, not reject, even though every editReply rejects. + await expect(dispatchInteraction(interaction, client, 'http://client')).resolves.toBeUndefined(); + }); + + it('ignores a non-repliable interaction after a routing error', async () => { + const reply = vi.fn(); + const interaction = { + isChatInputCommand: () => { + throw new Error('boom'); + }, + isRepliable: () => false, + reply, + } as unknown as Interaction; + + await expect(dispatchInteraction(interaction, mockClient(), 'http://client')).resolves.toBeUndefined(); + expect(reply).not.toHaveBeenCalled(); + }); +}); diff --git a/package.json b/package.json index 2e498a8..e28ea79 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,10 @@ "test:server": "npm --prefix server test", "test:client": "npm --prefix client test", "test:bot": "npm --prefix bot test", - "coverage": "npm run coverage:server && npm run coverage:client", + "coverage": "npm run coverage:server && npm run coverage:client && npm run coverage:bot", "coverage:server": "npm --prefix server run test:coverage", "coverage:client": "npm --prefix client run test:coverage", + "coverage:bot": "npm --prefix bot run test:coverage", "test:e2e": "npm --prefix e2e test", "test:e2e:install": "npm --prefix e2e run install:browsers", "typecheck": "npm run typecheck:shared && npm run typecheck:server && npm run typecheck:client && npm run typecheck:bot", @@ -42,7 +43,8 @@ "lint:fix": "npm --workspaces --if-present run lint -- --fix", "audit:server": "npm audit --audit-level=high --prefix server", "audit:client": "npm audit --audit-level=high --prefix client", - "audit": "npm run audit:server && npm run audit:client", + "audit:bot": "npm audit --audit-level=high --prefix bot", + "audit": "npm run audit:server && npm run audit:client && npm run audit:bot", "build:client": "npm --prefix client run build", "verify": "npm run lint && npm run audit && npm run typecheck && npm run coverage && npm run build:client && npm run test:e2e" }, From 0ddbeb4110a8b4442473e22319395d4dd03ec54c Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Thu, 23 Jul 2026 13:22:34 +0530 Subject: [PATCH 5/6] fix(bot): bound the integration HTTP client with a request timeout A server that accepts the connection but never completes the response would otherwise leave a deferred /meet interaction hanging with no exception for dispatchInteraction to recover from. Each request now runs under an AbortController deadline (default 10s, configurable); on timeout the fetch rejects and the handler answers with a friendly ephemeral error. Tested with a never-resolving fetch under fake timers plus a signal-passing assertion. --- bot/src/http/client.ts | 45 +++++++++++++++++++++++++++++------------ bot/test/client.test.ts | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/bot/src/http/client.ts b/bot/src/http/client.ts index 6335f54..0812ba7 100644 --- a/bot/src/http/client.ts +++ b/bot/src/http/client.ts @@ -34,6 +34,7 @@ export type FetchLike = ( method?: string; headers?: Record; body?: string; + signal?: AbortSignal; }, ) => Promise<{ status: number; @@ -41,6 +42,9 @@ export type FetchLike = ( json: () => Promise; }>; +/** Default per-request deadline. A hung server must not wedge an interaction. */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + export interface IntegrationClientOptions { /** Base URL of the API server, e.g. http://localhost:5000 (no trailing slash needed). */ serverUrl: string; @@ -48,6 +52,8 @@ export interface IntegrationClientOptions { apiKey: string; /** Override the fetch implementation (defaults to global fetch). */ fetchImpl?: FetchLike; + /** Per-request deadline in ms (default {@link DEFAULT_REQUEST_TIMEOUT_MS}). */ + requestTimeoutMs?: number; } const BOT_API_KEY_HEADER = 'X-Bot-Api-Key'; @@ -56,30 +62,43 @@ export class DiscordIntegrationClient { private readonly baseUrl: string; private readonly apiKey: string; private readonly fetchImpl: FetchLike; + private readonly requestTimeoutMs: number; constructor(options: IntegrationClientOptions) { // Normalise so `${base}/api/...` never produces a double slash. this.baseUrl = options.serverUrl.replace(/\/+$/, '') + '/api/integrations/discord'; this.apiKey = options.apiKey; this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); + this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; } private async post(path: string, body: unknown): Promise<{ status: number; ok: boolean; data: unknown }> { - const res = await this.fetchImpl(`${this.baseUrl}${path}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - [BOT_API_KEY_HEADER]: this.apiKey, - }, - body: JSON.stringify(body), - }); - let data: unknown = undefined; + // Bound every request: a server that accepts the connection but never + // finishes the response would otherwise leave the deferred interaction + // hanging forever with no error for the caller to recover from. On timeout + // the fetch rejects (AbortError) and the handler answers with a friendly error. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); try { - data = await res.json(); - } catch { - // Non-JSON bodies (e.g. a proxy 502) leave data undefined; handled below. + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [BOT_API_KEY_HEADER]: this.apiKey, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + let data: unknown = undefined; + try { + data = await res.json(); + } catch { + // Non-JSON bodies (e.g. a proxy 502) leave data undefined; handled below. + } + return { status: res.status, ok: res.ok, data }; + } finally { + clearTimeout(timer); } - return { status: res.status, ok: res.ok, data }; } /** Mint a short-lived account-link token + ready-made confirmation URL. */ diff --git a/bot/test/client.test.ts b/bot/test/client.test.ts index f4cfba7..382104f 100644 --- a/bot/test/client.test.ts +++ b/bot/test/client.test.ts @@ -90,6 +90,41 @@ describe('DiscordIntegrationClient.createRoom', () => { }); }); +describe('DiscordIntegrationClient request timeout', () => { + it('aborts and rejects when the server never completes the response', async () => { + vi.useFakeTimers(); + // A server that accepts the connection but never responds; it only settles + // when the abort signal fires. + const impl: FetchLike = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('The operation was aborted', 'AbortError')), + ); + }); + const client = new DiscordIntegrationClient({ + serverUrl: 'http://server:5000', + apiKey: 'k', + fetchImpl: impl, + requestTimeoutMs: 5000, + }); + + const pending = client.createRoom('1'); + const assertion = expect(pending).rejects.toThrow(/abort/i); + await vi.advanceTimersByTimeAsync(5000); + await assertion; + vi.useRealTimers(); + }); + + it('passes an abort signal to fetch and clears the timer on success', async () => { + const { impl, calls } = fakeFetch(201, { roomId: 'x' }); + await makeClient(impl).createRoom('1'); + const [, init] = calls[0] as [string, { signal?: AbortSignal }]; + expect(init.signal).toBeInstanceOf(AbortSignal); + // Timer was cleared on completion, so the signal is not aborted afterwards. + expect(init.signal?.aborted).toBe(false); + }); +}); + describe('DiscordIntegrationClient base URL handling', () => { it('does not produce a double slash when serverUrl has a trailing slash', async () => { const { impl, calls } = fakeFetch(201, { roomId: 'x' }); From 23e35c1e649b4174ad4e9056e168387ada20a016 Mon Sep 17 00:00:00 2001 From: Anuraj Jit Saikia Date: Thu, 23 Jul 2026 13:24:43 +0530 Subject: [PATCH 6/6] test(bot): make the timeout-cleanup assertion non-vacuous Advance fake timers past the deadline after a successful request and assert the signal stays un-aborted, so the test would fail if clearTimeout were removed. --- bot/test/client.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/test/client.test.ts b/bot/test/client.test.ts index 382104f..745f266 100644 --- a/bot/test/client.test.ts +++ b/bot/test/client.test.ts @@ -116,12 +116,17 @@ describe('DiscordIntegrationClient request timeout', () => { }); it('passes an abort signal to fetch and clears the timer on success', async () => { + vi.useFakeTimers(); const { impl, calls } = fakeFetch(201, { roomId: 'x' }); + // Default 10s deadline; the response resolves immediately. await makeClient(impl).createRoom('1'); const [, init] = calls[0] as [string, { signal?: AbortSignal }]; expect(init.signal).toBeInstanceOf(AbortSignal); - // Timer was cleared on completion, so the signal is not aborted afterwards. + // Advance WELL past the deadline: if the timer had not been cleared it would + // fire now and abort the signal. It stays un-aborted, proving cleanup ran. + await vi.advanceTimersByTimeAsync(20_000); expect(init.signal?.aborted).toBe(false); + vi.useRealTimers(); }); });