diff --git a/.env.example b/.env.example index bd191ec..dea5974 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,14 @@ -DISCORD_TOKEN= -DISCORD_CLIENT_ID= -# Optional. If set, command registration targets this guild for instant updates. +# Bootstrap environment variables. +# Runtime configuration is loaded from bot_settings in Config DB. +# Keep only database bootstrap values in .env. +DATABASE_URL=postgres://postgres:postgres@localhost:5432/omo_bot +CONFIG_DB_ENABLED=true + +# Optional scope used by db:seed for guild-specific keys. +# If omitted, seed writes to __global__ scope. DISCORD_GUILD_ID= + +# One-time migration path (legacy env -> Config DB): +# 1) Temporarily set legacy env keys. +# 2) Run npm run db:seed. +# 3) Remove legacy env keys from .env. diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..ca548fd --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,88 @@ +name: CI-CD + +on: + push: + branches: + - "**" + pull_request: + +jobs: + bot-checks: + name: Bot Lint Test Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm run test + + dashboard-checks: + name: Dashboard Lint Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: admin-dashboard + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: admin-dashboard/package-lock.json + + - name: Install dashboard dependencies + run: npm ci + + - name: Lint dashboard + run: npm run lint + + - name: Build dashboard + run: npm run build + + deploy-coolify: + name: Deploy to Coolify + runs-on: ubuntu-latest + needs: + - bot-checks + - dashboard-checks + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + steps: + - name: Trigger Coolify deploy hook + env: + COOLIFY_DEPLOY_HOOK_URL: ${{ secrets.COOLIFY_DEPLOY_HOOK_URL }} + COOLIFY_DEPLOY_TOKEN: ${{ secrets.COOLIFY_DEPLOY_TOKEN }} + run: | + if [ -z "$COOLIFY_DEPLOY_HOOK_URL" ]; then + echo "Missing COOLIFY_DEPLOY_HOOK_URL secret" + exit 1 + fi + + if [ -n "$COOLIFY_DEPLOY_TOKEN" ]; then + curl --fail --show-error --silent \ + -X POST \ + -H "Authorization: Bearer $COOLIFY_DEPLOY_TOKEN" \ + "$COOLIFY_DEPLOY_HOOK_URL" + else + curl --fail --show-error --silent -X POST "$COOLIFY_DEPLOY_HOOK_URL" + fi + + echo "Coolify deploy triggered" diff --git a/README.md b/README.md index 6e31352..2e8a996 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,77 @@ # Discord Bot for Open Mic Odyssey -A custom Discord bot and web integration layer designed to bridge the [openmicodyssey.com](https://openmicodyssey.com) experience with our community server. This application gamifies community engagement, automates content syndication, and provides a suite of event management tools centered around the themes of stand-up comedy, indie filmmaking, and a cross-country road trip. +A modular Discord bot + admin platform that syncs community engagement between Discord and [openmicodyssey.com](https://openmicodyssey.com). +The system includes onboarding roles, stage queue management, mileage progression, content syndication, an admin API, and a React admin dashboard. ## Core Features -### The Call Sheet (User & Role Management) +### The Call Sheet (Onboarding + Roles) -- **The Experience:** New members are greeted with a customized onboarding menu to declare their interests (e.g., `@ComedyFan`, `@Filmmaker`). Active users progress through community ranks, leveling up from **"Extra"** to **"Roadie"** and eventually **"Executive Producer"**. -- **Technical Implementation:** Utilizes Discord's Reaction Role payloads and interaction webhooks for automated Role-Based Access Control (RBAC). Implements dynamic permission bitfield assignment and role hierarchy state management based on user activity telemetry. +- Reaction-role onboarding message with emoji-to-role bindings +- Role hierarchy safety checks before assignment/removal +- Optional onboarding completion role and welcome-channel message -### The Tour Schedule (Event Management) +### The Tour Schedule (Stage Queue) -- **The Experience:** Organizes virtual **"Tour Stops"** (screenings) and digital open mics. Users can utilize the `/sign-up` command to enter the stage queue, while attendees receive temporary **"VIP Backstage"** passes for live Q&As. -- **Technical Implementation:** Wraps the Discord Scheduled Events API. Implements a FIFO (First-In-First-Out) queue data structure for managing Voice/Stage channel speaker states. Handles automated role assignment/revocation for temporary event permissions. +- `/sign-up` subcommands: `join`, `leave`, `list`, `next`, `clear` +- FIFO queue per guild +- Optional stage-speaker promotion and queue announcement dispatch -### The Dailies (Content Webhooks & Syndication) +### The Dailies (Content Syndication) -- **The Experience:** Automatically delivers fresh behind-the-scenes content directly from the crew's socials to dedicated server channels: -- `#polaroids-from-the-van`: Instagram drops. -- `#outtakes`: TikTok crowd work and detours. -- `#screenings`: YouTube trailers and vlogs. +- Adapter-based polling pipeline +- YouTube RSS adapter implemented +- Discord webhook dispatch with duplicate suppression -- **Technical Implementation:** Event-driven architecture utilizing incoming Discord Webhooks. Integrates external API polling (YouTube Data API, TikTok/IG endpoints) to fetch, parse, and format multimedia payloads into rich Discord Embeds. +### Mileage Engine -### Mileage & The Hidden Map (Gamified Progression) +- PostgreSQL-backed mileage event/user persistence +- Configurable event scoring +- Role-tier upgrades on threshold milestones +- Retry behavior for transient write failures -- **The Experience:** Every interaction earns users **"Mileage"**. Accumulating miles unlocks secure passwords and GPS coordinates for the hidden `/map` route on the main website, granting access to deleted scenes and exclusive scripts. -- **Technical Implementation:** Requires Discord OAuth2 integration with the main web application. Message and event telemetry are captured, scored, and stored in a database (e.g., PostgreSQL/MongoDB), continuously syncing the user's Discord state with their authenticated web session. +### Admin API + Dashboard -### The Control Room (Admin Web Interface) +- Express Admin API for config, queue, stats, OAuth bridge, configuration DB +- React/Vite dashboard for OAuth login + analytics views -- **The Experience:** A dedicated dashboard for the **"Producers"** and **"Directors"** to manage the server, schedule content drops, and view engagement without touching Discord commands. -- **Technical Implementation:** A standalone web portal (intended for `admin.openmicodyssey.com`). Exposes secure RESTful/GraphQL endpoints for bot configuration, CRON job scheduling for content drops, and data visualization for external link click-through rates. +### OAuth2 Bridge + +- Discord OAuth2 code exchange to user profile +- Ephemeral session issuance +- Optional sync callback to openmicodyssey.com backend + +### Configuration Database + +- `bot_settings`, `content_schedules`, `engagement_stats` tables +- Admin CRUD endpoints for settings/schedules +- Engagement snapshot persistence from runtime events + +## Implemented Modules + +- `src/bot.ts`: gateway client lifecycle, command dispatch, service wiring +- `src/call-sheet.ts`: reaction role onboarding and role hierarchy guards +- `src/tour-schedule.ts`: FIFO queue, stage speaker promotion, announcements +- `src/mileage.ts`: mileage persistence, scoring, role-upgrade triggers +- `src/dailies/*`: adapter/poller/webhook syndication pipeline +- `src/admin-api.ts`: operational and configuration endpoints +- `src/oauth-bridge.ts`: Discord code exchange + openmic session sync +- `src/configuration-database.ts`: bot settings, schedules, engagement snapshots +- `admin-dashboard/src/*`: admin SPA, OAuth callback flow, analytics views ## Architecture Documentation (arc42) -This project uses the [arc42](https://arc42.org) architecture documentation template. -All chapters are in `docs/`: - -| # | Chapter | File | -| --- | ------------------------ | ---------------------------------------------------------------------------- | -| 1 | Introduction & Goals | [`docs/01_introduction_and_goals.md`](docs/01_introduction_and_goals.md) | -| 2 | Architecture Constraints | [`docs/02_architecture_constraints.md`](docs/02_architecture_constraints.md) | -| 3 | Context & Scope | [`docs/03_context_and_scope.md`](docs/03_context_and_scope.md) | -| 4 | Solution Strategy | [`docs/04_solution_strategy.md`](docs/04_solution_strategy.md) | -| 5 | Building Block View | [`docs/05_building_block_view.md`](docs/05_building_block_view.md) | -| 6 | Runtime View | [`docs/06_runtime_view.md`](docs/06_runtime_view.md) | -| 7 | Deployment View | [`docs/07_deployment_view.md`](docs/07_deployment_view.md) | -| 8 | Cross-cutting Concepts | [`docs/08_concepts.md`](docs/08_concepts.md) | -| 9 | Architecture Decisions | [`docs/09_architecture_decisions.md`](docs/09_architecture_decisions.md) | -| 10 | Quality Requirements | [`docs/10_quality_requirements.md`](docs/10_quality_requirements.md) | -| 11 | Risks & Technical Debt | [`docs/11_technical_risks.md`](docs/11_technical_risks.md) | -| 12 | Glossary | [`docs/12_glossary.md`](docs/12_glossary.md) | +This project uses the arc42 template. Chapters are in `docs/`. ## Architecture & Tech Stack | Layer | Choice | | --------------- | -------------------- | -| Runtime | Node.js (Discord.js) | +| Runtime | Node.js + TypeScript | +| Discord SDK | discord.js | | Database | PostgreSQL | -| Admin Dashboard | React | +| Admin API | Express | +| Admin Dashboard | React + Vite | | Auth | Discord OAuth2 | | Hosting | Coolify + Nixpacks | @@ -67,52 +79,139 @@ All chapters are in `docs/`: ### Prerequisites -- Node.js v16+ (or Python 3.9+) -- A Discord Developer Application with Bot Token -- Access to `openmicodyssey.com` backend for OAuth syncing +- Node.js 22+ +- npm 10+ +- Discord Developer Application (bot token + OAuth2 app) +- PostgreSQL instance ### Installation -1. Clone the repository: +1. Clone repository: ```bash -git clone https://github.com/your-org/open-mic-odyssey-bot.git -cd open-mic-odyssey-bot - +git clone https://git.allucanget.biz/allucanget/omo-bot.git +cd omo-bot ``` -2. Install dependencies: +2. Install root dependencies: ```bash npm install - ``` -3. Configure environment variables. Duplicate `.env.example` to `.env` and add your specific keys: +3. Configure bootstrap environment variables (`.env` from `.env.example`): ```env -DISCORD_TOKEN=your_bot_token -CLIENT_ID=your_client_id -GUILD_ID=your_server_id -DB_CONNECTION_STRING=your_db_uri -YOUTUBE_API_KEY=your_yt_key - +DATABASE_URL=postgres://... +CONFIG_DB_ENABLED=true +DISCORD_GUILD_ID=... # optional seed scope ``` -4. Deploy Slash Commands: +4. Register slash commands: ```bash -npm run deploy-commands - +npm run register:commands ``` -5. Start the bot: +5. Run bot in development: ```bash +npm run dev +``` + +6. Build and run production bundle: + +```bash +npm run build npm start - ``` +### Admin Dashboard + +```bash +cd admin-dashboard +npm install +npm run dev +``` + +Dashboard environment template: `admin-dashboard/.env.example`. + +### Validation Commands + +```bash +npm run lint +npm run build +npm run test +``` + +### Database Setup Commands + +`DATABASE_URL` is required for each command. + +```bash +npm run db:migrate +npm run db:seed +npm run db:setup +``` + +- `db:migrate`: applies schema migrations tracked in `schema_migrations` +- `db:seed`: inserts initial config values from environment into `bot_settings` when missing +- `db:setup`: runs migrate then seed + +### Runtime Config Source + +Runtime reads configuration keys from `bot_settings` table first, then falls back to environment values. + +- Global scope: `guild_id = __global__` +- Guild override scope: `guild_id = DISCORD_GUILD_ID` +- Key names match previous env variable names (examples: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `ADMIN_API_TOKEN`) + +Recommended flow: + +1. Set bootstrap env (`DATABASE_URL`, optional `DISCORD_GUILD_ID`). +2. Run `npm run db:migrate`. +3. Run `npm run db:seed` once while legacy env vars are still present. +4. Remove legacy runtime vars from `.env` after confirming `bot_settings` contains required keys. + +Dashboard quality check: + +```bash +cd admin-dashboard +npm run lint +npm run build +``` + +## Admin API Endpoints + +- `GET /health` +- `GET /admin/config` +- `GET /admin/stats?guildId=...` +- `GET /admin/schedule?guildId=...` +- `POST /admin/schedule/clear` +- `POST /admin/oauth/discord/exchange` +- `GET /admin/oauth/session/:sessionId` +- `GET /admin/db/settings?guildId=...` +- `PUT /admin/db/settings` +- `GET /admin/db/schedules?guildId=...` +- `PUT /admin/db/schedules` +- `DELETE /admin/db/schedules/:guildId/:scheduleKey` +- `GET /admin/db/engagement/latest?guildId=...` + +## CI/CD (Gitea Actions) + +Pipeline file: `.gitea/workflows/ci-cd.yml` + +Flow: + +- bot checks: `npm ci -> npm run lint -> npm run build -> npm run test` +- dashboard checks: `admin-dashboard/npm ci -> npm run lint -> npm run build` +- deploy: on push to `main`, trigger Coolify deploy webhook + +Required Gitea secrets: + +- `COOLIFY_DEPLOY_HOOK_URL` (required) +- `COOLIFY_DEPLOY_TOKEN` (optional bearer token) + ## Contributing -For internal development only. Please refer to [CONTRIBUTING.md](CONTRIBUTING.md) for styling guidelines and PR review processes for "The Control Room" dashboard updates. +Internal development only. See `CONTRIBUTING.md`. diff --git a/admin-dashboard/.env.example b/admin-dashboard/.env.example new file mode 100644 index 0000000..af22a96 --- /dev/null +++ b/admin-dashboard/.env.example @@ -0,0 +1,4 @@ +VITE_ADMIN_API_BASE_URL=http://localhost:8787 +VITE_DISCORD_CLIENT_ID= +# Must match Discord OAuth2 redirect URI whitelist. +VITE_DISCORD_REDIRECT_URI=http://localhost:5173 diff --git a/admin-dashboard/.gitignore b/admin-dashboard/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/admin-dashboard/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/admin-dashboard/README.md b/admin-dashboard/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/admin-dashboard/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/admin-dashboard/eslint.config.js b/admin-dashboard/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/admin-dashboard/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/admin-dashboard/index.html b/admin-dashboard/index.html new file mode 100644 index 0000000..f79623d --- /dev/null +++ b/admin-dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + admin-dashboard + + +
+ + + diff --git a/admin-dashboard/package-lock.json b/admin-dashboard/package-lock.json new file mode 100644 index 0000000..4d60fac --- /dev/null +++ b/admin-dashboard/package-lock.json @@ -0,0 +1,2765 @@ +{ + "name": "admin-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-dashboard", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "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.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "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.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "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/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "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.59.3", + "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.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "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.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "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.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "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.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "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.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "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.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "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.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "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/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.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/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "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/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "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/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/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "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/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "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.1", + "@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-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "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/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/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==", + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "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.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "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.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "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/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "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/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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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.11", + "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/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.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.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/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/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "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", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.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/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "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.1.18", + "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/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/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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "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" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/admin-dashboard/package.json b/admin-dashboard/package.json new file mode 100644 index 0000000..087fb60 --- /dev/null +++ b/admin-dashboard/package.json @@ -0,0 +1,30 @@ +{ + "name": "admin-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/admin-dashboard/public/favicon.svg b/admin-dashboard/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/admin-dashboard/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-dashboard/public/icons.svg b/admin-dashboard/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/admin-dashboard/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin-dashboard/src/App.css b/admin-dashboard/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/admin-dashboard/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/admin-dashboard/src/App.tsx b/admin-dashboard/src/App.tsx new file mode 100644 index 0000000..8327f58 --- /dev/null +++ b/admin-dashboard/src/App.tsx @@ -0,0 +1,304 @@ +import { useEffect, useMemo, useState } from "react"; +import type { FormEvent, MouseEvent } from "react"; +import { exchangeDiscordOAuthCode, fetchDashboardData } from "./api"; +import type { + AdminConfig, + AdminSchedule, + AdminStats, + OAuthBridgeSession, +} from "./api"; +import { + buildDiscordAuthorizeUrl, + clearOAuthQueryParams, + isExpectedOAuthState, + readOAuthUiState, +} from "./oauth"; + +const API_BASE_URL = + import.meta.env.VITE_ADMIN_API_BASE_URL ?? "http://localhost:8787"; +const DISCORD_CLIENT_ID = import.meta.env.VITE_DISCORD_CLIENT_ID ?? ""; +const DISCORD_REDIRECT_URI = + import.meta.env.VITE_DISCORD_REDIRECT_URI ?? window.location.origin; + +interface DashboardData { + config: AdminConfig; + schedule: AdminSchedule; + stats: AdminStats; +} + +function formatDuration(ms: number): string { + const minutes = Math.floor(ms / 60000); + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +function App() { + const oauth = useMemo(() => readOAuthUiState(), []); + const [guildId, setGuildId] = useState(""); + const [token, setToken] = useState( + window.localStorage.getItem("omo.dashboard.api.token") ?? "", + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [oauthSession, setOauthSession] = useState( + null, + ); + const [oauthExchangeError, setOauthExchangeError] = useState( + null, + ); + + useEffect(() => { + window.localStorage.setItem("omo.dashboard.api.token", token); + }, [token]); + + useEffect(() => { + const oauthCode = oauth.code; + if (!oauthCode) { + return; + } + + void (async () => { + if (!isExpectedOAuthState(oauth.state)) { + setTimeout(() => { + setOauthExchangeError("OAuth state mismatch. Restart login."); + }, 0); + clearOAuthQueryParams(); + return; + } + + try { + const session = await exchangeDiscordOAuthCode( + { + baseUrl: API_BASE_URL, + token, + }, + oauthCode, + ); + setOauthSession(session); + setOauthExchangeError(null); + } catch (exchangeError: unknown) { + setOauthExchangeError(String(exchangeError)); + } finally { + clearOAuthQueryParams(); + } + })(); + }, [oauth.code, oauth.state, token]); + + async function refresh(): Promise { + if (!guildId.trim()) { + setError("Guild ID required."); + return; + } + + setLoading(true); + setError(null); + + try { + const dashboardData = await fetchDashboardData( + { + baseUrl: API_BASE_URL, + token, + }, + guildId.trim(), + ); + setData(dashboardData); + } catch (requestError: unknown) { + setError(String(requestError)); + } finally { + setLoading(false); + } + } + + function onLoadSubmit(event: FormEvent): void { + event.preventDefault(); + void refresh(); + } + + const oauthStatus = oauthExchangeError + ? `OAuth exchange failed: ${oauthExchangeError}` + : oauth.error + ? `OAuth error: ${oauth.error}` + : oauthSession + ? oauthSession.syncedToOpenmic + ? "OAuth bridge active. Session synced to openmicodyssey.com." + : "OAuth bridge active. Session created locally." + : "Not signed in via Discord OAuth."; + + function onOAuthClick(event: MouseEvent): void { + if (!DISCORD_CLIENT_ID) { + event.preventDefault(); + return; + } + + event.preventDefault(); + const oauthUrl = buildDiscordAuthorizeUrl( + DISCORD_CLIENT_ID, + DISCORD_REDIRECT_URI, + ); + window.location.assign(oauthUrl); + } + + return ( +
+
+

OpenMic Odyssey

+

Admin Dashboard

+

+ Real-time control room for config, queue flow, and mileage analytics. +

+
+ +
+
+

OAuth2 Login

+

Connect Discord identity for step 9 bridge handoff.

+
+
+ + Connect Discord + +

{oauthStatus}

+ {oauthSession ? ( +

+ Session {oauthSession.sessionId} | expires{" "} + {new Date(oauthSession.expiresAt).toLocaleString()} +

+ ) : null} +
+
+ +
+

Analytics Query

+
+ + + + + + + +
+ {error ?

{error}

: null} +
+ + {data ? ( + <> +
+
+

Commands Run

+

{data.stats.runtime.commandCount}

+ + Since {new Date(data.stats.runtime.startedAt).toLocaleString()} + +
+ +
+

Bot Uptime

+

{formatDuration(data.stats.runtime.uptimeMs)}

+ Live runtime sample +
+ +
+

Queue Depth

+

{data.schedule.size}

+ Current sign-up backlog +
+ +
+

Mileage Total

+

{data.stats.mileage.totalMilesAwarded}

+ {data.stats.mileage.totalEvents} events logged +
+
+ +
+
+

Top Mileage

+
+ + + + + + + + + {data.stats.mileage.topUsers.length ? ( + data.stats.mileage.topUsers.map((row) => ( + + + + + )) + ) : ( + + + + )} + +
UserMiles
@{row.userId}{row.totalMiles}
No mileage data.
+
+
+ +
+

Tour Queue

+ {data.schedule.queue.length ? ( +
    + {data.schedule.queue.map((userId) => ( +
  1. @{userId}
  2. + ))} +
+ ) : ( +

Queue empty.

+ )} + +

Config Snapshot

+
    +
  • + Call Sheet bindings: {data.config.callSheet.bindings.length} +
  • +
  • + Dailies enabled: {data.config.dailies.enabled ? "yes" : "no"} +
  • +
  • + Webhook targets: {data.config.dailies.webhookTargets.length} +
  • +
  • + Admin auth:{" "} + {data.config.adminApi.authConfigured ? "enabled" : "off"} +
  • +
+
+
+ + ) : null} +
+ ); +} + +export default App; diff --git a/admin-dashboard/src/api.ts b/admin-dashboard/src/api.ts new file mode 100644 index 0000000..2b86c60 --- /dev/null +++ b/admin-dashboard/src/api.ts @@ -0,0 +1,161 @@ +export interface AdminConfig { + discordGuildId: string | null; + callSheet: { + messageId: string | null; + onboardedRoleId: string | null; + welcomeChannelId: string | null; + bindings: Array<{ + emoji: string; + roleId: string; + label?: string; + group?: string; + }>; + }; + mileage: { + enabled: boolean; + roleTiers: Array<{ roleId: string; minMiles: number }>; + eventScores: Record; + }; + tourSchedule: { + stageChannelId?: string; + announceChannelId?: string; + }; + dailies: { + enabled: boolean; + pollIntervalMs: number; + youtubeChannelIds: string[]; + webhookTargets: Array<{ label: string | null }>; + }; + adminApi: { + enabled: boolean; + host: string; + port: number; + authConfigured: boolean; + }; + oauthBridge: { + enabled: boolean; + redirectConfigured: boolean; + openmicSyncConfigured: boolean; + }; +} + +export interface AdminSchedule { + guildId: string; + queue: string[]; + size: number; +} + +export interface AdminStats { + guildId: string; + runtime: { + startedAt: string; + uptimeMs: number; + commandCount: number; + }; + queue: { + size: number; + }; + mileage: { + enabled: boolean; + guildId: string; + totalMembersTracked: number; + totalMilesAwarded: number; + totalEvents: number; + topUsers: Array<{ userId: string; totalMiles: number }>; + }; +} + +export interface OAuthBridgeSession { + sessionId: string; + createdAt: string; + expiresAt: string; + syncedToOpenmic: boolean; + discordUser: { + id: string; + username: string; + displayName: string; + avatarUrl: string | null; + }; +} + +interface FetchOptions { + baseUrl: string; + token: string; +} + +async function requestJson( + path: string, + options: FetchOptions, + query?: Record, +): Promise { + const url = new URL(path, options.baseUrl); + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + } + + const headers: Record = { + Accept: "application/json", + }; + if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`API ${response.status}: ${body || response.statusText}`); + } + + return (await response.json()) as T; +} + +export async function fetchDashboardData( + options: FetchOptions, + guildId: string, +): Promise<{ + config: AdminConfig; + schedule: AdminSchedule; + stats: AdminStats; +}> { + const [config, schedule, stats] = await Promise.all([ + requestJson("/admin/config", options), + requestJson("/admin/schedule", options, { guildId }), + requestJson("/admin/stats", options, { guildId }), + ]); + + return { config, schedule, stats }; +} + +export async function exchangeDiscordOAuthCode( + options: FetchOptions, + code: string, +): Promise { + const url = new URL("/admin/oauth/discord/exchange", options.baseUrl); + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ code }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error( + `OAuth exchange ${response.status}: ${body || response.statusText}`, + ); + } + + return (await response.json()) as OAuthBridgeSession; +} diff --git a/admin-dashboard/src/assets/hero.png b/admin-dashboard/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/admin-dashboard/src/assets/hero.png differ diff --git a/admin-dashboard/src/assets/react.svg b/admin-dashboard/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/admin-dashboard/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-dashboard/src/assets/vite.svg b/admin-dashboard/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/admin-dashboard/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/admin-dashboard/src/index.css b/admin-dashboard/src/index.css new file mode 100644 index 0000000..9a94631 --- /dev/null +++ b/admin-dashboard/src/index.css @@ -0,0 +1,286 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap"); + +:root { + --bg: #f2efe7; + --ink: #14213d; + --ink-soft: #4a5568; + --panel: #fffaf0; + --line: #d6d3c8; + --accent: #ff6b35; + --accent-2: #2ec4b6; + --danger: #b42318; + --shadow: 0 14px 36px rgba(20, 33, 61, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient( + circle at 10% 20%, + rgba(46, 196, 182, 0.28), + transparent 36% + ), + radial-gradient( + circle at 90% 10%, + rgba(255, 107, 53, 0.22), + transparent 42% + ), + var(--bg); +} + +#root { + min-height: 100vh; +} + +.app-shell { + max-width: 1150px; + margin: 0 auto; + padding: 2.25rem 1rem 3rem; + display: grid; + gap: 1rem; +} + +.hero-panel { + border: 1px solid var(--line); + border-radius: 24px; + padding: 1.5rem; + background: + linear-gradient(130deg, rgba(255, 107, 53, 0.16), rgba(46, 196, 182, 0.18)), + var(--panel); + box-shadow: var(--shadow); +} + +.eyebrow { + margin: 0; + font-size: 0.8rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); +} + +h1 { + margin: 0.4rem 0; + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 1; +} + +h2, +h3 { + margin: 0; + line-height: 1.15; +} + +.hero-copy { + margin: 0; + max-width: 62ch; + color: var(--ink-soft); +} + +.panel { + border: 1px solid var(--line); + border-radius: 20px; + background: var(--panel); + box-shadow: var(--shadow); + padding: 1.25rem; +} + +.auth-panel { + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 1rem; + align-items: start; +} + +.auth-panel p { + margin: 0.4rem 0 0; + color: var(--ink-soft); +} + +.auth-actions { + display: grid; + gap: 0.65rem; +} + +.oauth-btn { + display: inline-flex; + justify-content: center; + align-items: center; + padding: 0.72rem 1rem; + border-radius: 12px; + text-decoration: none; + font-weight: 700; + color: #fff; + background: linear-gradient(130deg, var(--accent), #f08a5d); + transition: transform 160ms ease; +} + +.oauth-btn:hover { + transform: translateY(-2px); +} + +.oauth-btn.disabled { + opacity: 0.5; + pointer-events: none; +} + +.oauth-status { + margin: 0; + font-family: "IBM Plex Mono", Consolas, monospace; + font-size: 0.83rem; +} + +.controls-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.9rem; + margin-top: 0.8rem; +} + +.controls-grid label { + display: grid; + gap: 0.3rem; + font-size: 0.88rem; +} + +.controls-grid span { + color: var(--ink-soft); +} + +.controls-grid input { + border: 1px solid var(--line); + border-radius: 10px; + padding: 0.66rem 0.75rem; + font: inherit; + background: #fff; +} + +.controls-grid button { + border: none; + border-radius: 10px; + padding: 0.74rem 1rem; + font: inherit; + font-weight: 700; + color: #fff; + background: linear-gradient(130deg, #2541b2, #2ec4b6); + cursor: pointer; +} + +.controls-grid button:disabled { + cursor: wait; + opacity: 0.75; +} + +.error { + margin: 0.75rem 0 0; + color: var(--danger); + font-family: "IBM Plex Mono", Consolas, monospace; + font-size: 0.83rem; +} + +.cards-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.8rem; +} + +.metric-card { + background: #fff; + border: 1px solid var(--line); + border-radius: 16px; + padding: 1rem; +} + +.metric-card h3 { + font-size: 0.88rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.metric-card p { + margin: 0.35rem 0; + font-size: 2rem; + font-weight: 700; +} + +.metric-card small { + color: var(--ink-soft); +} + +.split-panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.table-wrap { + margin-top: 0.65rem; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.93rem; +} + +th, +td { + padding: 0.6rem; + border-bottom: 1px solid var(--line); + text-align: left; +} + +.queue-list { + margin: 0.65rem 0 1rem; + padding-left: 1.15rem; +} + +.queue-list li { + margin-bottom: 0.35rem; +} + +.queue-empty { + margin: 0.65rem 0 1rem; + color: var(--ink-soft); +} + +.config-list { + list-style: square; + margin: 0.6rem 0 0; + padding-left: 1.2rem; + color: var(--ink-soft); +} + +@media (max-width: 920px) { + .auth-panel, + .split-panel, + .controls-grid { + grid-template-columns: 1fr; + } + + .cards-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 620px) { + .app-shell { + padding: 1rem 0.65rem 2rem; + } + + .cards-grid { + grid-template-columns: 1fr; + } + + .metric-card p { + font-size: 1.7rem; + } +} diff --git a/admin-dashboard/src/main.tsx b/admin-dashboard/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/admin-dashboard/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/admin-dashboard/src/oauth.ts b/admin-dashboard/src/oauth.ts new file mode 100644 index 0000000..7b67101 --- /dev/null +++ b/admin-dashboard/src/oauth.ts @@ -0,0 +1,51 @@ +export interface OAuthUiState { + code: string | null; + state: string | null; + error: string | null; +} + +const STATE_STORAGE_KEY = "omo.dashboard.oauth.state"; + +export function readOAuthUiState(): OAuthUiState { + const params = new URLSearchParams(window.location.search); + return { + code: params.get("code"), + state: params.get("state"), + error: params.get("error"), + }; +} + +export function buildDiscordAuthorizeUrl( + clientId: string, + redirectUri: string, +): string { + const state = crypto.randomUUID(); + window.localStorage.setItem(STATE_STORAGE_KEY, state); + + const url = new URL("https://discord.com/oauth2/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "identify guilds"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + url.searchParams.set("prompt", "consent"); + + return url.toString(); +} + +export function isExpectedOAuthState(state: string | null): boolean { + if (!state) { + return false; + } + + const expected = window.localStorage.getItem(STATE_STORAGE_KEY); + return expected === state; +} + +export function clearOAuthQueryParams(): void { + const url = new URL(window.location.href); + url.searchParams.delete("code"); + url.searchParams.delete("state"); + url.searchParams.delete("error"); + window.history.replaceState({}, document.title, url.toString()); +} diff --git a/admin-dashboard/tsconfig.app.json b/admin-dashboard/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/admin-dashboard/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/admin-dashboard/tsconfig.json b/admin-dashboard/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/admin-dashboard/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/admin-dashboard/tsconfig.node.json b/admin-dashboard/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/admin-dashboard/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/admin-dashboard/vite.config.ts b/admin-dashboard/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/admin-dashboard/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/eslint.config.cjs b/eslint.config.cjs index 496928d..7953b5c 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -4,14 +4,14 @@ const tsParser = require("@typescript-eslint/parser"); /** @type {import("eslint").Linter.FlatConfig[]} */ module.exports = [ { - ignores: ["dist/**", "node_modules/**"], + ignores: ["dist/**", "node_modules/**", "**/*.d.ts"], }, { files: ["**/*.ts"], languageOptions: { parser: tsParser, parserOptions: { - project: "./tsconfig.json", + project: "./tsconfig.eslint.json", sourceType: "module", }, }, diff --git a/package-lock.json b/package-lock.json index 30b04e8..a7c79ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,15 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "discord.js": "^14.26.4" + "discord.js": "^14.26.4", + "express": "^5.2.1", + "pg": "^8.20.0" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", "eslint": "^10.4.0", @@ -1570,6 +1574,27 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1584,6 +1609,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1638,6 +1695,53 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2214,6 +2318,19 @@ "npm": ">=7.0.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2478,6 +2595,30 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -2555,6 +2696,44 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2769,6 +2948,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2776,6 +2977,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2802,7 +3021,6 @@ "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" @@ -2848,6 +3066,15 @@ "node": ">=0.10.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2904,6 +3131,20 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2911,6 +3152,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.357", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", @@ -2938,6 +3185,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2948,6 +3204,36 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2958,6 +3244,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3173,6 +3465,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3232,6 +3533,49 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3293,6 +3637,27 @@ "node": ">=16.0.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3348,6 +3713,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3370,6 +3753,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3390,6 +3782,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3400,6 +3816,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3481,6 +3910,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3520,6 +3961,30 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3527,6 +3992,26 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3537,6 +4022,22 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -3593,9 +4094,17 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3646,6 +4155,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4533,6 +5048,36 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4540,6 +5085,31 @@ "dev": true, "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4590,7 +5160,6 @@ "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/napi-postinstall": { @@ -4616,6 +5185,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -4660,11 +5238,34 @@ "node": ">=8" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4772,6 +5373,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4826,6 +5436,105 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4925,6 +5634,45 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4964,6 +5712,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4991,6 +5752,45 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", @@ -5040,6 +5840,28 @@ "node": ">=8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", @@ -5053,6 +5875,57 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5076,6 +5949,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5120,6 +6065,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5150,6 +6104,15 @@ "node": ">=8" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -5445,6 +6408,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5616,6 +6588,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -5659,6 +6662,15 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -5757,6 +6769,15 @@ "node": ">=10.12.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -5899,7 +6920,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -5937,6 +6957,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index fc8c123..8ffcdee 100644 --- a/package.json +++ b/package.json @@ -12,18 +12,25 @@ "start": "node dist/index.js", "lint": "eslint . --ext .ts", "register:commands": "ts-node src/deploy-commands.ts", - "test": "jest --passWithNoTests" + "test": "jest --passWithNoTests", + "db:migrate": "ts-node src/db/cli.ts migrate", + "db:seed": "ts-node src/db/cli.ts seed", + "db:setup": "ts-node src/db/cli.ts setup" }, "keywords": [], "author": "", "license": "ISC", "type": "commonjs", "dependencies": { - "discord.js": "^14.26.4" + "discord.js": "^14.26.4", + "express": "^5.2.1", + "pg": "^8.20.0" }, "devDependencies": { + "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.8.0", + "@types/pg": "^8.20.0", "@typescript-eslint/eslint-plugin": "^8.59.3", "@typescript-eslint/parser": "^8.59.3", "eslint": "^10.4.0", diff --git a/src/admin-api.ts b/src/admin-api.ts new file mode 100644 index 0000000..73aa00e --- /dev/null +++ b/src/admin-api.ts @@ -0,0 +1,368 @@ +import express, { NextFunction, Request, Response } from "express"; +import { AddressInfo } from "net"; +import { ConfigurationDatabase } from "./configuration-database"; +import { AdminApiConfig, BotConfig } from "./config"; +import { MileageEngine } from "./mileage"; +import { OAuthBridgeService } from "./oauth-bridge"; +import { TourScheduleEngine } from "./tour-schedule"; + +export interface RuntimeStats { + startedAt: string; + commandCount: number; +} + +export interface AdminApiContext { + config: BotConfig; + mileage: MileageEngine; + configurationDatabase: ConfigurationDatabase; + tourSchedule: TourScheduleEngine; + oauthBridge: OAuthBridgeService; + runtimeStats: RuntimeStats; +} + +export class AdminApiServer { + private readonly config: AdminApiConfig; + private readonly context: AdminApiContext; + private readonly app = express(); + private server: ReturnType | null = null; + + constructor(config: AdminApiConfig, context: AdminApiContext) { + this.config = config; + this.context = context; + this.app.use(express.json()); + this.app.use((request, response, next) => + this.authenticate(request, response, next), + ); + this.registerRoutes(); + } + + async start(): Promise { + if (!this.config.enabled) { + console.log( + "Admin API disabled: set ADMIN_API_ENABLED=true to activate.", + ); + return; + } + + await new Promise((resolve) => { + this.server = this.app.listen(this.config.port, this.config.host, () => { + const address = this.server?.address(); + const port = + typeof address === "object" && address + ? (address as AddressInfo).port + : this.config.port; + console.log(`Admin API listening on ${this.config.host}:${port}`); + resolve(); + }); + }); + } + + async stop(): Promise { + if (!this.server) { + return; + } + + await new Promise((resolve, reject) => { + this.server?.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + + this.server = null; + } + + private authenticate( + request: Request, + response: Response, + next: NextFunction, + ): void { + // OAuth endpoints must stay callable before a dashboard session exists. + if (request.path.startsWith("/admin/oauth/")) { + next(); + return; + } + + const token = this.config.token; + if (!token) { + next(); + return; + } + + const authorization = request.header("authorization"); + const expected = `Bearer ${token}`; + if (authorization !== expected) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + next(); + } + + private registerRoutes(): void { + this.app.get("/health", (_request, response) => { + response.json({ ok: true }); + }); + + this.app.get("/admin/config", (_request, response) => { + const safeConfig = { + discordGuildId: this.context.config.discordGuildId ?? null, + callSheet: { + messageId: this.context.config.callSheet.messageId ?? null, + onboardedRoleId: + this.context.config.callSheet.onboardedRoleId ?? null, + welcomeChannelId: + this.context.config.callSheet.welcomeChannelId ?? null, + bindings: this.context.config.callSheet.bindings, + }, + mileage: { + enabled: this.context.mileage.enabled, + roleTiers: this.context.config.mileage.roleTiers, + eventScores: this.context.config.mileage.eventScores, + }, + tourSchedule: this.context.config.tourSchedule, + dailies: { + enabled: this.context.config.dailies.enabled, + pollIntervalMs: this.context.config.dailies.pollIntervalMs, + youtubeChannelIds: this.context.config.dailies.youtubeChannelIds, + webhookTargets: this.context.config.dailies.webhookTargets.map( + (target) => ({ + label: target.label ?? null, + }), + ), + }, + adminApi: { + enabled: this.context.config.adminApi.enabled, + host: this.context.config.adminApi.host, + port: this.context.config.adminApi.port, + authConfigured: Boolean(this.context.config.adminApi.token), + }, + oauthBridge: { + enabled: this.context.config.oauthBridge.enabled, + redirectConfigured: Boolean( + this.context.config.oauthBridge.redirectUri, + ), + openmicSyncConfigured: Boolean( + this.context.config.oauthBridge.openmicSyncUrl, + ), + }, + configurationDatabase: { + enabled: this.context.configurationDatabase.enabled, + }, + }; + + response.json(safeConfig); + }); + + this.app.get("/admin/schedule", (request, response) => { + const guildId = String(request.query.guildId ?? "").trim(); + if (!guildId) { + response.status(400).json({ error: "guildId query param required" }); + return; + } + + const queue = this.context.tourSchedule.list(guildId); + response.json({ guildId, queue, size: queue.length }); + }); + + this.app.post("/admin/schedule/clear", (request, response) => { + const guildId = String(request.body?.guildId ?? "").trim(); + if (!guildId) { + response.status(400).json({ error: "guildId body field required" }); + return; + } + + const removed = this.context.tourSchedule.clear(guildId); + response.json({ guildId, removed }); + }); + + this.app.get("/admin/stats", async (request, response) => { + const guildId = String(request.query.guildId ?? "").trim(); + if (!guildId) { + response.status(400).json({ error: "guildId query param required" }); + return; + } + + const mileage = await this.context.mileage.getGuildStats(guildId); + const queueSize = this.context.tourSchedule.getQueueLength(guildId); + + response.json({ + guildId, + runtime: { + startedAt: this.context.runtimeStats.startedAt, + uptimeMs: + Date.now() - Date.parse(this.context.runtimeStats.startedAt), + commandCount: this.context.runtimeStats.commandCount, + }, + queue: { + size: queueSize, + }, + mileage, + }); + }); + + this.app.get("/admin/db/settings", async (request, response) => { + const guildId = String(request.query.guildId ?? "").trim(); + if (!guildId) { + response.status(400).json({ error: "guildId query param required" }); + return; + } + + try { + const settings = + await this.context.configurationDatabase.getSettings(guildId); + response.json({ guildId, settings }); + } catch (error: unknown) { + response.status(500).json({ error: String(error) }); + } + }); + + this.app.put("/admin/db/settings", async (request, response) => { + const guildId = String(request.body?.guildId ?? "").trim(); + const settingKey = String(request.body?.settingKey ?? "").trim(); + const settingValue = request.body?.settingValue; + + if (!guildId || !settingKey || settingValue === undefined) { + response.status(400).json({ + error: "guildId, settingKey, settingValue body fields required", + }); + return; + } + + try { + const setting = await this.context.configurationDatabase.upsertSetting( + guildId, + settingKey, + settingValue, + ); + response.json(setting); + } catch (error: unknown) { + response.status(500).json({ error: String(error) }); + } + }); + + this.app.get("/admin/db/schedules", async (request, response) => { + const guildId = String(request.query.guildId ?? "").trim(); + if (!guildId) { + response.status(400).json({ error: "guildId query param required" }); + return; + } + + try { + const schedules = + await this.context.configurationDatabase.listSchedules(guildId); + response.json({ guildId, schedules }); + } catch (error: unknown) { + response.status(500).json({ error: String(error) }); + } + }); + + this.app.put("/admin/db/schedules", async (request, response) => { + const guildId = String(request.body?.guildId ?? "").trim(); + const scheduleKey = String(request.body?.scheduleKey ?? "").trim(); + const publishAt = String(request.body?.publishAt ?? "").trim(); + const payload = request.body?.payload; + + if (!guildId || !scheduleKey || !publishAt || payload === undefined) { + response.status(400).json({ + error: + "guildId, scheduleKey, publishAt, payload body fields required", + }); + return; + } + + try { + const schedule = + await this.context.configurationDatabase.upsertSchedule( + guildId, + scheduleKey, + publishAt, + payload, + ); + response.json(schedule); + } catch (error: unknown) { + response.status(500).json({ error: String(error) }); + } + }); + + this.app.delete( + "/admin/db/schedules/:guildId/:scheduleKey", + async (request, response) => { + const guildId = String(request.params.guildId ?? "").trim(); + const scheduleKey = String(request.params.scheduleKey ?? "").trim(); + + if (!guildId || !scheduleKey) { + response + .status(400) + .json({ error: "guildId and scheduleKey required" }); + return; + } + + try { + const removed = + await this.context.configurationDatabase.deleteSchedule( + guildId, + scheduleKey, + ); + response.json({ guildId, scheduleKey, removed }); + } catch (error: unknown) { + response.status(500).json({ error: String(error) }); + } + }, + ); + + this.app.get("/admin/db/engagement/latest", async (request, response) => { + const guildId = String(request.query.guildId ?? "").trim(); + if (!guildId) { + response.status(400).json({ error: "guildId query param required" }); + return; + } + + try { + const engagement = + await this.context.configurationDatabase.getLatestEngagement(guildId); + response.json({ guildId, engagement }); + } catch (error: unknown) { + response.status(500).json({ error: String(error) }); + } + }); + + this.app.post( + "/admin/oauth/discord/exchange", + async (request, response) => { + const code = String(request.body?.code ?? "").trim(); + if (!code) { + response.status(400).json({ error: "code body field required" }); + return; + } + + try { + const exchanged = await this.context.oauthBridge.exchangeCode(code); + response.json(exchanged); + } catch (error: unknown) { + response.status(400).json({ error: String(error) }); + } + }, + ); + + this.app.get("/admin/oauth/session/:sessionId", (request, response) => { + const sessionId = String(request.params.sessionId ?? "").trim(); + if (!sessionId) { + response.status(400).json({ error: "sessionId required" }); + return; + } + + const session = this.context.oauthBridge.getSession(sessionId); + if (!session) { + response.status(404).json({ error: "session not found" }); + return; + } + + response.json(session); + }); + } +} diff --git a/src/bot.ts b/src/bot.ts index 5f0bcf8..756e382 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -2,16 +2,95 @@ import { Client, Events, GatewayIntentBits, + GuildMember, Interaction, Partials, } from "discord.js"; +import { AdminApiServer } from "./admin-api"; +import { registerCallSheetHandlers } from "./call-sheet"; import { commandMap } from "./commands"; +import { ConfigurationDatabase } from "./configuration-database"; import { BotConfig } from "./config"; +import { initDailies } from "./dailies"; +import { MileageEngine } from "./mileage"; +import { OAuthBridgeService } from "./oauth-bridge"; +import { initTourSchedule, TourScheduleEngine } from "./tour-schedule"; + +async function resolveInteractionMember( + interaction: Interaction, +): Promise { + if (!interaction.isChatInputCommand() || !interaction.inGuild()) { + return undefined; + } + + if (!interaction.guild) { + return undefined; + } + + try { + return await interaction.guild.members.fetch(interaction.user.id); + } catch { + return undefined; + } +} export async function startBot(config: BotConfig): Promise { + const runtimeStats = { + startedAt: new Date().toISOString(), + commandCount: 0, + }; + + const mileageEngine = new MileageEngine(config.mileage); + await mileageEngine.init(config.discordGuildId); + const configurationDatabase = new ConfigurationDatabase( + config.configurationDatabase, + ); + await configurationDatabase.init(); + const tourSchedule: TourScheduleEngine = initTourSchedule( + config.tourSchedule, + ); + const dailies = initDailies(config.dailies); + const oauthBridge = new OAuthBridgeService( + config.oauthBridge, + config.discordClientId, + ); + const adminApi = new AdminApiServer(config.adminApi, { + config, + mileage: mileageEngine, + configurationDatabase, + tourSchedule, + oauthBridge, + runtimeStats, + }); + const client = new Client({ - intents: [GatewayIntentBits.Guilds], - partials: [Partials.Channel], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessageReactions, + ], + partials: [ + Partials.Channel, + Partials.Message, + Partials.Reaction, + Partials.User, + ], + }); + + registerCallSheetHandlers(client, config, { + onRoleAssigned: async (member, binding) => { + await mileageEngine.awardMiles({ + guildId: member.guild.id, + userId: member.id, + eventType: "reaction_role_select", + metadata: { + roleId: binding.roleId, + emoji: binding.emoji, + source: "call_sheet", + }, + member, + }); + }, }); client.once(Events.ClientReady, (readyClient) => { @@ -34,6 +113,34 @@ export async function startBot(config: BotConfig): Promise { try { await command.execute(interaction); + runtimeStats.commandCount += 1; + + if (interaction.inGuild()) { + const member = await resolveInteractionMember(interaction); + const mileageResult = await mileageEngine.awardMiles({ + guildId: interaction.guildId, + userId: interaction.user.id, + eventType: "command_execute", + metadata: { + commandName: interaction.commandName, + }, + ...(member ? { member } : {}), + }); + + if (configurationDatabase.enabled) { + await configurationDatabase.insertEngagementSnapshot({ + guildId: interaction.guildId, + commandCount: runtimeStats.commandCount, + queueSize: tourSchedule.getQueueLength(interaction.guildId), + mileageTotal: mileageResult.totalMiles, + activeUsers: 1, + payload: { + commandName: interaction.commandName, + source: "interaction", + }, + }); + } + } } catch (error: unknown) { console.error(`Command failed: ${interaction.commandName}`, error); @@ -52,5 +159,24 @@ export async function startBot(config: BotConfig): Promise { }); await client.login(config.discordToken); + + client.once(Events.ClientReady, () => { + dailies.start(); + void adminApi.start(); + + process.once("SIGINT", () => { + dailies.stop(); + void adminApi.stop(); + void configurationDatabase.close(); + void mileageEngine.close(); + }); + process.once("SIGTERM", () => { + dailies.stop(); + void adminApi.stop(); + void configurationDatabase.close(); + void mileageEngine.close(); + }); + }); + return client; } diff --git a/src/call-sheet.ts b/src/call-sheet.ts new file mode 100644 index 0000000..e7a66e1 --- /dev/null +++ b/src/call-sheet.ts @@ -0,0 +1,235 @@ +import { + ChannelType, + Client, + Events, + GuildMember, + MessageReaction, + PartialMessageReaction, + PartialUser, + Role, + User, +} from "discord.js"; +import { BotConfig, ReactionRoleBinding } from "./config"; + +export interface CallSheetHooks { + onRoleAssigned?: ( + member: GuildMember, + binding: ReactionRoleBinding, + ) => Promise; +} + +function normalizeEmoji( + reaction: MessageReaction | PartialMessageReaction, +): string | null { + return reaction.emoji.id ?? reaction.emoji.name; +} + +async function hydrateReaction( + reaction: MessageReaction | PartialMessageReaction, +): Promise { + if (reaction.partial) { + return reaction.fetch(); + } + + return reaction; +} + +async function hydrateUser(user: User | PartialUser): Promise { + if (user.partial) { + return user.fetch(); + } + + return user; +} + +function getBinding( + reaction: MessageReaction | PartialMessageReaction, + config: BotConfig, +): ReactionRoleBinding | null { + const emoji = normalizeEmoji(reaction); + if (!emoji) { + return null; + } + + return ( + config.callSheet.bindings.find((binding) => binding.emoji === emoji) ?? null + ); +} + +function isTargetMessage( + reaction: MessageReaction | PartialMessageReaction, + config: BotConfig, +): boolean { + if (!config.callSheet.messageId) { + return false; + } + + return reaction.message.id === config.callSheet.messageId; +} + +function ensureRoleManageable(member: GuildMember, role: Role): boolean { + const botMember = member.guild.members.me; + if (!botMember) { + return false; + } + + return botMember.roles.highest.position > role.position; +} + +async function maybeSendWelcome( + member: GuildMember, + config: BotConfig, +): Promise { + if (!config.callSheet.welcomeChannelId) { + return; + } + + const channel = await member.guild.channels.fetch( + config.callSheet.welcomeChannelId, + ); + if (!channel || channel.type !== ChannelType.GuildText) { + return; + } + + await channel.send(`Welcome ${member.user}, onboarding complete.`); +} + +async function applyBinding( + member: GuildMember, + binding: ReactionRoleBinding, + config: BotConfig, +): Promise { + const role = await member.guild.roles.fetch(binding.roleId); + if (!role) { + throw new Error(`Role not found for binding ${binding.emoji}`); + } + + if (!ensureRoleManageable(member, role)) { + throw new Error(`Cannot assign role ${role.id}. Check role hierarchy.`); + } + + if (binding.group) { + const siblingBindings = config.callSheet.bindings.filter( + (candidate) => + candidate.group === binding.group && + candidate.roleId !== binding.roleId, + ); + + for (const sibling of siblingBindings) { + if (member.roles.cache.has(sibling.roleId)) { + await member.roles.remove( + sibling.roleId, + "Call Sheet single-role group update", + ); + } + } + } + + await member.roles.add(role, "Call Sheet reaction role selection"); + + if ( + config.callSheet.onboardedRoleId && + !member.roles.cache.has(config.callSheet.onboardedRoleId) + ) { + const onboardedRole = await member.guild.roles.fetch( + config.callSheet.onboardedRoleId, + ); + if (onboardedRole && ensureRoleManageable(member, onboardedRole)) { + await member.roles.add(onboardedRole, "Call Sheet onboarding complete"); + await maybeSendWelcome(member, config); + } + } +} + +async function removeBinding( + member: GuildMember, + binding: ReactionRoleBinding, +): Promise { + if (member.roles.cache.has(binding.roleId)) { + await member.roles.remove( + binding.roleId, + "Call Sheet reaction role removal", + ); + } +} + +async function getMemberForUser( + reaction: MessageReaction | PartialMessageReaction, + user: User, +): Promise { + const guild = reaction.message.guild; + if (!guild) { + return null; + } + + return guild.members.fetch(user.id); +} + +export function registerCallSheetHandlers( + client: Client, + config: BotConfig, + hooks: CallSheetHooks = {}, +): void { + if (!config.callSheet.bindings.length) { + console.log("Call Sheet disabled: no bindings configured."); + return; + } + + if (!config.callSheet.messageId) { + console.log( + "Call Sheet handlers idle: set CALL_SHEET_MESSAGE_ID to activate.", + ); + return; + } + + client.on(Events.MessageReactionAdd, async (rawReaction, rawUser) => { + try { + const reaction = await hydrateReaction(rawReaction); + const user = await hydrateUser(rawUser); + if (user.bot || !isTargetMessage(reaction, config)) { + return; + } + + const binding = getBinding(reaction, config); + if (!binding) { + return; + } + + const member = await getMemberForUser(reaction, user); + if (!member) { + return; + } + + await applyBinding(member, binding, config); + if (hooks.onRoleAssigned) { + await hooks.onRoleAssigned(member, binding); + } + } catch (error: unknown) { + console.error("Call Sheet add reaction failed:", error); + } + }); + + client.on(Events.MessageReactionRemove, async (rawReaction, rawUser) => { + try { + const reaction = await hydrateReaction(rawReaction); + const user = await hydrateUser(rawUser); + if (user.bot || !isTargetMessage(reaction, config)) { + return; + } + + const binding = getBinding(reaction, config); + if (!binding) { + return; + } + + const member = await getMemberForUser(reaction, user); + if (!member) { + return; + } + + await removeBinding(member, binding); + } catch (error: unknown) { + console.error("Call Sheet remove reaction failed:", error); + } + }); +} diff --git a/src/commands/call-sheet.ts b/src/commands/call-sheet.ts new file mode 100644 index 0000000..263b5de --- /dev/null +++ b/src/commands/call-sheet.ts @@ -0,0 +1,86 @@ +import { + ChatInputCommandInteraction, + PermissionsBitField, + SlashCommandBuilder, +} from "discord.js"; +import { loadRuntimeConfig } from "../config"; + +function buildCallSheetMessage( + bindings: ReadonlyArray<{ emoji: string; roleId: string; label?: string }>, +): string { + if (!bindings.length) { + throw new Error( + "CALL_SHEET_REACTION_ROLES empty. Add bindings before posting call sheet.", + ); + } + + const lines = [ + "# The Call Sheet", + "React below to pick your role.", + "", + ...bindings.map((binding) => { + const title = binding.label ?? `<@&${binding.roleId}>`; + return `${binding.emoji} - ${title}`; + }), + ]; + + return lines.join("\n"); +} + +export const callSheetCommand = { + data: new SlashCommandBuilder() + .setName("call-sheet") + .setDescription("Manage onboarding call sheet.") + .setDefaultMemberPermissions(PermissionsBitField.Flags.ManageRoles) + .addSubcommand((subcommand) => + subcommand + .setName("post") + .setDescription("Post onboarding call sheet in current channel."), + ), + + async execute(interaction: ChatInputCommandInteraction): Promise { + if (interaction.options.getSubcommand() !== "post") { + await interaction.reply({ + content: "Unsupported subcommand.", + ephemeral: true, + }); + return; + } + + if ( + !interaction.inGuild() || + !interaction.channel || + !interaction.channel.isSendable() + ) { + await interaction.reply({ + content: "Run this in text channel.", + ephemeral: true, + }); + return; + } + + try { + const config = await loadRuntimeConfig(); + const content = buildCallSheetMessage(config.callSheet.bindings); + const posted = await interaction.channel.send({ content }); + + for (const binding of config.callSheet.bindings) { + await posted.react(binding.emoji); + } + + await interaction.reply({ + content: [ + `Call sheet posted: ${posted.url}`, + "Set CALL_SHEET_MESSAGE_ID to posted message id for reaction handling.", + `CALL_SHEET_MESSAGE_ID=${posted.id}`, + ].join("\n"), + ephemeral: true, + }); + } catch (error: unknown) { + await interaction.reply({ + content: `Failed to post call sheet: ${String(error)}`, + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/dailies.ts b/src/commands/dailies.ts new file mode 100644 index 0000000..72b7282 --- /dev/null +++ b/src/commands/dailies.ts @@ -0,0 +1,41 @@ +import { + ChatInputCommandInteraction, + PermissionFlagsBits, + SlashCommandBuilder, +} from "discord.js"; +import { getDailies } from "../dailies"; + +export const dailiesCommand = { + data: new SlashCommandBuilder() + .setName("dailies") + .setDescription("Dailies controls.") + .addSubcommand((subcommand) => + subcommand + .setName("poll") + .setDescription("Run dailies poll now and dispatch new items."), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + + async execute(interaction: ChatInputCommandInteraction): Promise { + if (interaction.options.getSubcommand() !== "poll") { + await interaction.reply({ + content: "Unsupported subcommand.", + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: true }); + + try { + const published = await getDailies().pollAndDispatch(); + await interaction.editReply( + published.length + ? `Dailies dispatch complete. Published ${published.length} item(s).` + : "Dailies dispatch complete. No new items.", + ); + } catch (error: unknown) { + await interaction.editReply(`Dailies poll failed: ${String(error)}`); + } + }, +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 8b934c1..e02ad32 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,12 +1,24 @@ -import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + SlashCommandSubcommandsOnlyBuilder, +} from "discord.js"; +import { callSheetCommand } from "./call-sheet"; +import { dailiesCommand } from "./dailies"; import { pingCommand } from "./ping"; +import { signUpCommand } from "./sign-up"; export interface ChatCommand { - data: SlashCommandBuilder; + data: SlashCommandBuilder | SlashCommandSubcommandsOnlyBuilder; execute(interaction: ChatInputCommandInteraction): Promise; } -export const commands: ChatCommand[] = [pingCommand]; +export const commands: ChatCommand[] = [ + pingCommand, + callSheetCommand, + dailiesCommand, + signUpCommand, +]; export const commandMap = new Map( commands.map((command) => [command.data.name, command]), diff --git a/src/commands/sign-up.ts b/src/commands/sign-up.ts new file mode 100644 index 0000000..397544a --- /dev/null +++ b/src/commands/sign-up.ts @@ -0,0 +1,155 @@ +import { + ChatInputCommandInteraction, + PermissionFlagsBits, + SlashCommandBuilder, +} from "discord.js"; +import { getTourSchedule } from "../tour-schedule"; + +function requireGuild( + interaction: ChatInputCommandInteraction, +): interaction is ChatInputCommandInteraction & { guildId: string } { + return interaction.inGuild() && Boolean(interaction.guildId); +} + +function formatQueue(queue: string[]): string { + if (!queue.length) { + return "Queue empty."; + } + + return queue.map((userId, index) => `${index + 1}. <@${userId}>`).join("\n"); +} + +function isQueueManager(interaction: ChatInputCommandInteraction): boolean { + if (!interaction.memberPermissions) { + return false; + } + + return interaction.memberPermissions.has(PermissionFlagsBits.ManageChannels); +} + +export const signUpCommand = { + data: new SlashCommandBuilder() + .setName("sign-up") + .setDescription("Tour schedule queue controls.") + .addSubcommand((subcommand) => + subcommand.setName("join").setDescription("Join speaking queue."), + ) + .addSubcommand((subcommand) => + subcommand.setName("leave").setDescription("Leave speaking queue."), + ) + .addSubcommand((subcommand) => + subcommand + .setName("list") + .setDescription("Show current FIFO speaking queue."), + ) + .addSubcommand((subcommand) => + subcommand + .setName("next") + .setDescription("Advance queue and promote next stage speaker."), + ) + .addSubcommand((subcommand) => + subcommand.setName("clear").setDescription("Clear queue."), + ), + + async execute(interaction: ChatInputCommandInteraction): Promise { + if (!requireGuild(interaction)) { + await interaction.reply({ + content: "Run this command in server.", + ephemeral: true, + }); + return; + } + + const queue = getTourSchedule(); + const action = interaction.options.getSubcommand(); + + if (action === "join") { + const result = queue.join(interaction.guildId, interaction.user.id); + await interaction.reply({ + content: result.joined + ? `Added to queue at position ${result.position}.` + : `Already queued at position ${result.position}.`, + ephemeral: true, + }); + return; + } + + if (action === "leave") { + const removed = queue.leave(interaction.guildId, interaction.user.id); + await interaction.reply({ + content: removed ? "Removed from queue." : "You are not in queue.", + ephemeral: true, + }); + return; + } + + if (action === "list") { + const list = queue.list(interaction.guildId); + await interaction.reply({ + content: formatQueue(list), + ephemeral: true, + }); + return; + } + + if (!isQueueManager(interaction)) { + await interaction.reply({ + content: "Need Manage Channels permission for this action.", + ephemeral: true, + }); + return; + } + + if (action === "clear") { + const cleared = queue.clear(interaction.guildId); + await interaction.reply({ + content: `Queue cleared. Removed ${cleared} member(s).`, + ephemeral: true, + }); + return; + } + + if (action === "next") { + if (!interaction.guild) { + await interaction.reply({ + content: "Guild context missing.", + ephemeral: true, + }); + return; + } + + const result = await queue.next(interaction.guild); + if (!result.nextUserId) { + await interaction.reply({ + content: "Queue empty.", + ephemeral: true, + }); + return; + } + + const stageNote = + result.stageResult === "promoted" + ? "Stage speaker promoted." + : result.stageResult === "not_configured" + ? "Stage channel not configured." + : result.stageResult === "stage_not_found" + ? "Configured stage channel not found." + : "User not in configured stage channel."; + + await interaction.reply({ + content: [ + `Now up: <@${result.nextUserId}>`, + `Queue remaining: ${result.remaining}`, + stageNote, + ].join("\n"), + ephemeral: false, + }); + return; + } + + await interaction.reply({ + content: "Unsupported subcommand.", + ephemeral: true, + }); + }, +}; diff --git a/src/config.ts b/src/config.ts index 8ac2e8a..3c876e7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,24 +1,491 @@ +import { Pool } from "pg"; + export interface BotConfig { discordToken: string; discordClientId: string; discordGuildId?: string; + callSheet: CallSheetConfig; + mileage: MileageConfig; + tourSchedule: TourScheduleConfig; + dailies: DailiesConfig; + adminApi: AdminApiConfig; + oauthBridge: OAuthBridgeConfig; + configurationDatabase: ConfigurationDatabaseConfig; } -function readRequiredEnv(name: string): string { - const value = process.env[name]; +export interface ReactionRoleBinding { + emoji: string; + roleId: string; + label?: string; + group?: string; +} + +export interface CallSheetConfig { + messageId?: string; + onboardedRoleId?: string; + welcomeChannelId?: string; + bindings: ReactionRoleBinding[]; +} + +export interface MileageRoleTier { + roleId: string; + minMiles: number; +} + +export interface MileageConfig { + databaseUrl?: string; + roleTiers: MileageRoleTier[]; + eventScores: Record; +} + +export interface TourScheduleConfig { + stageChannelId?: string; + announceChannelId?: string; +} + +export interface DailiesWebhookTarget { + url: string; + label?: string; +} + +export interface DailiesConfig { + enabled: boolean; + pollIntervalMs: number; + youtubeChannelIds: string[]; + webhookTargets: DailiesWebhookTarget[]; +} + +export interface AdminApiConfig { + enabled: boolean; + host: string; + port: number; + token?: string; +} + +export interface OAuthBridgeConfig { + enabled: boolean; + clientSecret?: string; + redirectUri?: string; + sessionTtlMs: number; + openmicSyncUrl?: string; + openmicSyncToken?: string; +} + +export interface ConfigurationDatabaseConfig { + enabled: boolean; + databaseUrl?: string; +} + +const GLOBAL_CONFIG_SCOPE = "__global__"; + +type ConfigReader = (name: string) => string | undefined; + +function readRequiredValue(readConfig: ConfigReader, name: string): string { + const value = readConfig(name); if (!value) { - throw new Error(`Missing required environment variable: ${name}`); + throw new Error(`Missing required configuration value: ${name}`); } return value; } -export function loadConfig(): BotConfig { - const discordGuildId = process.env.DISCORD_GUILD_ID; +function parseReactionBindings(raw?: string): ReactionRoleBinding[] { + if (!raw) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error( + `Invalid JSON in CALL_SHEET_REACTION_ROLES: ${String(error)}`, + ); + } + + if (!Array.isArray(parsed)) { + throw new Error("CALL_SHEET_REACTION_ROLES must be JSON array."); + } + + return parsed.map((item, index) => { + if ( + !item || + typeof item !== "object" || + !("emoji" in item) || + !("roleId" in item) + ) { + throw new Error( + `CALL_SHEET_REACTION_ROLES[${index}] must include emoji and roleId.`, + ); + } + + const emoji = String(item.emoji).trim(); + const roleId = String(item.roleId).trim(); + const label = "label" in item ? String(item.label).trim() : undefined; + const group = "group" in item ? String(item.group).trim() : undefined; + + if (!emoji || !roleId) { + throw new Error( + `CALL_SHEET_REACTION_ROLES[${index}] emoji/roleId cannot be empty.`, + ); + } + + return { + emoji, + roleId, + ...(label ? { label } : {}), + ...(group ? { group } : {}), + }; + }); +} + +function normalizeConfigValue(value: unknown): string | undefined { + if (value === null || value === undefined) { + return undefined; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +async function loadConfigOverridesFromDatabase( + databaseUrl: string, + guildScope?: string, +): Promise> { + const pool = new Pool({ + connectionString: databaseUrl, + }); + + const scopes = [GLOBAL_CONFIG_SCOPE]; + if (guildScope && guildScope !== GLOBAL_CONFIG_SCOPE) { + scopes.push(guildScope); + } + + try { + const result = await pool.query<{ + guild_id: string; + setting_key: string; + setting_value: unknown; + }>( + ` + SELECT guild_id, setting_key, setting_value + FROM bot_settings + WHERE guild_id = ANY($1::text[]) + ORDER BY + CASE + WHEN guild_id = $2 THEN 1 + ELSE 0 + END ASC, + updated_at ASC + `, + [scopes, guildScope ?? GLOBAL_CONFIG_SCOPE], + ); + + const overrides = new Map(); + for (const row of result.rows) { + const normalized = normalizeConfigValue(row.setting_value); + if (normalized !== undefined) { + overrides.set(row.setting_key, normalized); + } + } + + return overrides; + } finally { + await pool.end(); + } +} + +function buildConfig(readConfig: ConfigReader): BotConfig { + const discordGuildId = readConfig("DISCORD_GUILD_ID"); + const callSheetMessageId = readConfig("CALL_SHEET_MESSAGE_ID"); + const callSheetOnboardedRoleId = readConfig("CALL_SHEET_ONBOARDED_ROLE_ID"); + const callSheetWelcomeChannelId = readConfig("CALL_SHEET_WELCOME_CHANNEL_ID"); + const bindings = parseReactionBindings( + readConfig("CALL_SHEET_REACTION_ROLES"), + ); + const mileageRoleTiers = parseMileageRoleTiers( + readConfig("MILEAGE_ROLE_TIERS"), + ); + const mileageEventScores = parseMileageEventScores( + readConfig("MILEAGE_EVENT_SCORES"), + ); + const databaseUrl = readConfig("DATABASE_URL"); + const tourStageChannelId = readConfig("TOUR_STAGE_CHANNEL_ID"); + const tourAnnounceChannelId = readConfig("TOUR_ANNOUNCE_CHANNEL_ID"); + const dailiesEnabled = readConfig("DAILIES_ENABLED") === "true"; + const dailiesPollIntervalMs = Number( + readConfig("DAILIES_POLL_INTERVAL_MS") ?? "300000", + ); + if (Number.isNaN(dailiesPollIntervalMs) || dailiesPollIntervalMs < 10000) { + throw new Error("DAILIES_POLL_INTERVAL_MS must be number >= 10000."); + } + + const dailiesYouTubeChannelIds = parseStringArray( + readConfig("DAILIES_YOUTUBE_CHANNEL_IDS"), + "DAILIES_YOUTUBE_CHANNEL_IDS", + ); + const dailiesWebhookTargets = parseWebhookTargets( + readConfig("DAILIES_WEBHOOK_TARGETS"), + ); + const adminApiEnabled = readConfig("ADMIN_API_ENABLED") === "true"; + const adminApiHost = readConfig("ADMIN_API_HOST") ?? "0.0.0.0"; + const adminApiPort = Number(readConfig("ADMIN_API_PORT") ?? "8787"); + if (Number.isNaN(adminApiPort) || adminApiPort <= 0 || adminApiPort > 65535) { + throw new Error("ADMIN_API_PORT must be valid TCP port."); + } + + const adminApiToken = readConfig("ADMIN_API_TOKEN")?.trim(); + const oauthBridgeClientSecret = readConfig( + "OAUTH_BRIDGE_CLIENT_SECRET", + )?.trim(); + const oauthBridgeRedirectUri = readConfig( + "OAUTH_BRIDGE_REDIRECT_URI", + )?.trim(); + const oauthBridgeSessionTtlMs = Number( + readConfig("OAUTH_BRIDGE_SESSION_TTL_MS") ?? "3600000", + ); + if ( + Number.isNaN(oauthBridgeSessionTtlMs) || + oauthBridgeSessionTtlMs < 60000 + ) { + throw new Error("OAUTH_BRIDGE_SESSION_TTL_MS must be number >= 60000."); + } + + const openmicSyncUrl = readConfig("OPENMIC_SYNC_URL")?.trim(); + const openmicSyncToken = readConfig("OPENMIC_SYNC_TOKEN")?.trim(); + const oauthBridgeEnabled = + Boolean(oauthBridgeClientSecret) && Boolean(oauthBridgeRedirectUri); + const configurationDatabaseEnabled = + readConfig("CONFIG_DB_ENABLED") === "false" ? false : Boolean(databaseUrl); return { - discordToken: readRequiredEnv("DISCORD_TOKEN"), - discordClientId: readRequiredEnv("DISCORD_CLIENT_ID"), + discordToken: readRequiredValue(readConfig, "DISCORD_TOKEN"), + discordClientId: readRequiredValue(readConfig, "DISCORD_CLIENT_ID"), ...(discordGuildId ? { discordGuildId } : {}), + callSheet: { + ...(callSheetMessageId ? { messageId: callSheetMessageId } : {}), + ...(callSheetOnboardedRoleId + ? { onboardedRoleId: callSheetOnboardedRoleId } + : {}), + ...(callSheetWelcomeChannelId + ? { welcomeChannelId: callSheetWelcomeChannelId } + : {}), + bindings, + }, + mileage: { + ...(databaseUrl ? { databaseUrl } : {}), + roleTiers: mileageRoleTiers, + eventScores: mileageEventScores, + }, + tourSchedule: { + ...(tourStageChannelId ? { stageChannelId: tourStageChannelId } : {}), + ...(tourAnnounceChannelId + ? { announceChannelId: tourAnnounceChannelId } + : {}), + }, + dailies: { + enabled: dailiesEnabled, + pollIntervalMs: dailiesPollIntervalMs, + youtubeChannelIds: dailiesYouTubeChannelIds, + webhookTargets: dailiesWebhookTargets, + }, + adminApi: { + enabled: adminApiEnabled, + host: adminApiHost, + port: adminApiPort, + ...(adminApiToken ? { token: adminApiToken } : {}), + }, + oauthBridge: { + enabled: oauthBridgeEnabled, + ...(oauthBridgeClientSecret + ? { clientSecret: oauthBridgeClientSecret } + : {}), + ...(oauthBridgeRedirectUri + ? { redirectUri: oauthBridgeRedirectUri } + : {}), + sessionTtlMs: oauthBridgeSessionTtlMs, + ...(openmicSyncUrl ? { openmicSyncUrl } : {}), + ...(openmicSyncToken ? { openmicSyncToken } : {}), + }, + configurationDatabase: { + enabled: configurationDatabaseEnabled, + ...(databaseUrl ? { databaseUrl } : {}), + }, }; } + +function parseMileageRoleTiers(raw?: string): MileageRoleTier[] { + if (!raw) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error(`Invalid JSON in MILEAGE_ROLE_TIERS: ${String(error)}`); + } + + if (!Array.isArray(parsed)) { + throw new Error("MILEAGE_ROLE_TIERS must be JSON array."); + } + + return parsed.map((item, index) => { + if ( + !item || + typeof item !== "object" || + !("roleId" in item) || + !("minMiles" in item) + ) { + throw new Error( + `MILEAGE_ROLE_TIERS[${index}] must include roleId and minMiles.`, + ); + } + + const roleId = String(item.roleId).trim(); + const minMiles = Number(item.minMiles); + + if (!roleId || Number.isNaN(minMiles) || minMiles < 0) { + throw new Error( + `MILEAGE_ROLE_TIERS[${index}] invalid roleId or minMiles value.`, + ); + } + + return { roleId, minMiles }; + }); +} + +function parseMileageEventScores(raw?: string): Record { + const defaults: Record = { + command_execute: 10, + reaction_role_select: 25, + }; + + if (!raw) { + return defaults; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error(`Invalid JSON in MILEAGE_EVENT_SCORES: ${String(error)}`); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("MILEAGE_EVENT_SCORES must be JSON object."); + } + + const mapped: Record = { ...defaults }; + + for (const [eventName, value] of Object.entries(parsed)) { + const score = Number(value); + if (Number.isNaN(score) || score < 0) { + throw new Error( + `MILEAGE_EVENT_SCORES.${eventName} must be non-negative.`, + ); + } + + mapped[eventName] = score; + } + + return mapped; +} + +function parseStringArray(raw?: string, envName?: string): string[] { + if (!raw) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error(`Invalid JSON in ${envName ?? "array"}: ${String(error)}`); + } + + if (!Array.isArray(parsed)) { + throw new Error(`${envName ?? "value"} must be JSON array.`); + } + + return parsed + .map((item) => String(item).trim()) + .filter((item) => item.length > 0); +} + +function parseWebhookTargets(raw?: string): DailiesWebhookTarget[] { + if (!raw) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error( + `Invalid JSON in DAILIES_WEBHOOK_TARGETS: ${String(error)}`, + ); + } + + if (!Array.isArray(parsed)) { + throw new Error("DAILIES_WEBHOOK_TARGETS must be JSON array."); + } + + return parsed.map((item, index) => { + if (!item || typeof item !== "object" || !("url" in item)) { + throw new Error(`DAILIES_WEBHOOK_TARGETS[${index}] must include url.`); + } + + const url = String(item.url).trim(); + const label = "label" in item ? String(item.label).trim() : undefined; + + if (!url.startsWith("http://") && !url.startsWith("https://")) { + throw new Error(`DAILIES_WEBHOOK_TARGETS[${index}].url must be http(s).`); + } + + return { + url, + ...(label ? { label } : {}), + }; + }); +} + +export function loadConfig(): BotConfig { + return buildConfig((name) => process.env[name]); +} + +export async function loadRuntimeConfig(): Promise { + const databaseUrl = process.env.DATABASE_URL?.trim(); + const configurationDatabaseEnabled = + process.env.CONFIG_DB_ENABLED === "false" ? false : Boolean(databaseUrl); + const guildScope = process.env.DISCORD_GUILD_ID?.trim(); + + if (!configurationDatabaseEnabled || !databaseUrl) { + return loadConfig(); + } + + try { + const overrides = await loadConfigOverridesFromDatabase( + databaseUrl, + guildScope, + ); + + return buildConfig((name) => overrides.get(name) ?? process.env[name]); + } catch (error: unknown) { + console.warn( + `Falling back to environment config. Config DB overrides unavailable: ${String(error)}`, + ); + return loadConfig(); + } +} diff --git a/src/configuration-database.ts b/src/configuration-database.ts new file mode 100644 index 0000000..0c7e34f --- /dev/null +++ b/src/configuration-database.ts @@ -0,0 +1,385 @@ +import { Pool } from "pg"; +import { ConfigurationDatabaseConfig } from "./config"; + +export interface BotSettingRecord { + guildId: string; + settingKey: string; + settingValue: unknown; + updatedAt: string; +} + +export interface ContentScheduleRecord { + guildId: string; + scheduleKey: string; + publishAt: string; + payload: unknown; + updatedAt: string; +} + +export interface EngagementStatRecord { + guildId: string; + snapshotAt: string; + commandCount: number; + queueSize: number; + mileageTotal: number; + activeUsers: number; + payload: unknown; +} + +export class ConfigurationDatabase { + private readonly config: ConfigurationDatabaseConfig; + private readonly pool?: Pool; + + constructor(config: ConfigurationDatabaseConfig) { + this.config = config; + if (config.enabled && config.databaseUrl) { + this.pool = new Pool({ + connectionString: config.databaseUrl, + }); + } + } + + get enabled(): boolean { + return Boolean(this.pool); + } + + async init(): Promise { + if (!this.pool) { + console.log( + "Configuration DB disabled: set DATABASE_URL and CONFIG_DB_ENABLED=true.", + ); + return; + } + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS bot_settings ( + guild_id TEXT NOT NULL, + setting_key TEXT NOT NULL, + setting_value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, setting_key) + ); + `); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS content_schedules ( + guild_id TEXT NOT NULL, + schedule_key TEXT NOT NULL, + publish_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, schedule_key) + ); + `); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS engagement_stats ( + id BIGSERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + command_count INTEGER NOT NULL, + queue_size INTEGER NOT NULL, + mileage_total INTEGER NOT NULL, + active_users INTEGER NOT NULL, + payload JSONB NOT NULL + ); + `); + } + + async close(): Promise { + if (!this.pool) { + return; + } + + await this.pool.end(); + } + + async getSettings(guildId: string): Promise { + if (!this.pool) { + return []; + } + + const result = await this.pool.query<{ + guild_id: string; + setting_key: string; + setting_value: unknown; + updated_at: string; + }>( + ` + SELECT guild_id, setting_key, setting_value, updated_at::text AS updated_at + FROM bot_settings + WHERE guild_id = $1 + ORDER BY setting_key ASC + `, + [guildId], + ); + + return result.rows.map((row) => ({ + guildId: row.guild_id, + settingKey: row.setting_key, + settingValue: row.setting_value, + updatedAt: row.updated_at, + })); + } + + async upsertSetting( + guildId: string, + settingKey: string, + settingValue: unknown, + ): Promise { + if (!this.pool) { + throw new Error("Configuration DB disabled"); + } + + const result = await this.pool.query<{ + guild_id: string; + setting_key: string; + setting_value: unknown; + updated_at: string; + }>( + ` + INSERT INTO bot_settings (guild_id, setting_key, setting_value) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (guild_id, setting_key) + DO UPDATE SET + setting_value = EXCLUDED.setting_value, + updated_at = NOW() + RETURNING guild_id, setting_key, setting_value, updated_at::text AS updated_at + `, + [guildId, settingKey, JSON.stringify(settingValue)], + ); + + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to upsert bot setting"); + } + + return { + guildId: row.guild_id, + settingKey: row.setting_key, + settingValue: row.setting_value, + updatedAt: row.updated_at, + }; + } + + async listSchedules(guildId: string): Promise { + if (!this.pool) { + return []; + } + + const result = await this.pool.query<{ + guild_id: string; + schedule_key: string; + publish_at: string; + payload: unknown; + updated_at: string; + }>( + ` + SELECT + guild_id, + schedule_key, + publish_at::text AS publish_at, + payload, + updated_at::text AS updated_at + FROM content_schedules + WHERE guild_id = $1 + ORDER BY publish_at ASC + `, + [guildId], + ); + + return result.rows.map((row) => ({ + guildId: row.guild_id, + scheduleKey: row.schedule_key, + publishAt: row.publish_at, + payload: row.payload, + updatedAt: row.updated_at, + })); + } + + async upsertSchedule( + guildId: string, + scheduleKey: string, + publishAt: string, + payload: unknown, + ): Promise { + if (!this.pool) { + throw new Error("Configuration DB disabled"); + } + + const result = await this.pool.query<{ + guild_id: string; + schedule_key: string; + publish_at: string; + payload: unknown; + updated_at: string; + }>( + ` + INSERT INTO content_schedules (guild_id, schedule_key, publish_at, payload) + VALUES ($1, $2, $3::timestamptz, $4::jsonb) + ON CONFLICT (guild_id, schedule_key) + DO UPDATE SET + publish_at = EXCLUDED.publish_at, + payload = EXCLUDED.payload, + updated_at = NOW() + RETURNING + guild_id, + schedule_key, + publish_at::text AS publish_at, + payload, + updated_at::text AS updated_at + `, + [guildId, scheduleKey, publishAt, JSON.stringify(payload)], + ); + + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to upsert content schedule"); + } + + return { + guildId: row.guild_id, + scheduleKey: row.schedule_key, + publishAt: row.publish_at, + payload: row.payload, + updatedAt: row.updated_at, + }; + } + + async deleteSchedule(guildId: string, scheduleKey: string): Promise { + if (!this.pool) { + throw new Error("Configuration DB disabled"); + } + + const result = await this.pool.query( + ` + DELETE FROM content_schedules + WHERE guild_id = $1 AND schedule_key = $2 + `, + [guildId, scheduleKey], + ); + + return (result.rowCount ?? 0) > 0; + } + + async insertEngagementSnapshot( + record: Omit & { snapshotAt?: string }, + ): Promise { + if (!this.pool) { + throw new Error("Configuration DB disabled"); + } + + const result = await this.pool.query<{ + guild_id: string; + snapshot_at: string; + command_count: number; + queue_size: number; + mileage_total: number; + active_users: number; + payload: unknown; + }>( + ` + INSERT INTO engagement_stats ( + guild_id, + snapshot_at, + command_count, + queue_size, + mileage_total, + active_users, + payload + ) + VALUES ( + $1, + COALESCE($2::timestamptz, NOW()), + $3, + $4, + $5, + $6, + $7::jsonb + ) + RETURNING + guild_id, + snapshot_at::text AS snapshot_at, + command_count, + queue_size, + mileage_total, + active_users, + payload + `, + [ + record.guildId, + // Allow explicit backfill timestamps while defaulting to ingestion time. + record.snapshotAt ?? null, + record.commandCount, + record.queueSize, + record.mileageTotal, + record.activeUsers, + JSON.stringify(record.payload), + ], + ); + + const row = result.rows[0]; + if (!row) { + throw new Error("Failed to insert engagement snapshot"); + } + + return { + guildId: row.guild_id, + snapshotAt: row.snapshot_at, + commandCount: row.command_count, + queueSize: row.queue_size, + mileageTotal: row.mileage_total, + activeUsers: row.active_users, + payload: row.payload, + }; + } + + async getLatestEngagement( + guildId: string, + ): Promise { + if (!this.pool) { + return null; + } + + const result = await this.pool.query<{ + guild_id: string; + snapshot_at: string; + command_count: number; + queue_size: number; + mileage_total: number; + active_users: number; + payload: unknown; + }>( + ` + SELECT + guild_id, + snapshot_at::text AS snapshot_at, + command_count, + queue_size, + mileage_total, + active_users, + payload + FROM engagement_stats + WHERE guild_id = $1 + ORDER BY snapshot_at DESC + LIMIT 1 + `, + [guildId], + ); + + const row = result.rows[0]; + if (!row) { + return null; + } + + return { + guildId: row.guild_id, + snapshotAt: row.snapshot_at, + commandCount: row.command_count, + queueSize: row.queue_size, + mileageTotal: row.mileage_total, + activeUsers: row.active_users, + payload: row.payload, + }; + } +} diff --git a/src/dailies/content-adapter.ts b/src/dailies/content-adapter.ts new file mode 100644 index 0000000..be7d1d0 --- /dev/null +++ b/src/dailies/content-adapter.ts @@ -0,0 +1,13 @@ +export interface ContentItem { + id: string; + source: string; + title: string; + url: string; + publishedAt?: string; + summary?: string; +} + +export interface ContentAdapter { + readonly name: string; + poll(): Promise; +} diff --git a/src/dailies/index.ts b/src/dailies/index.ts new file mode 100644 index 0000000..da86595 --- /dev/null +++ b/src/dailies/index.ts @@ -0,0 +1,17 @@ +import { DailiesConfig } from "../config"; +import { DailiesService } from "./service"; + +let dailiesService: DailiesService | undefined; + +export function initDailies(config: DailiesConfig): DailiesService { + dailiesService = new DailiesService(config); + return dailiesService; +} + +export function getDailies(): DailiesService { + if (!dailiesService) { + throw new Error("Dailies service not initialized."); + } + + return dailiesService; +} diff --git a/src/dailies/service.ts b/src/dailies/service.ts new file mode 100644 index 0000000..c343efb --- /dev/null +++ b/src/dailies/service.ts @@ -0,0 +1,79 @@ +import { DailiesConfig } from "../config"; +import { ContentAdapter, ContentItem } from "./content-adapter"; +import { YouTubeAdapter } from "./youtube-adapter"; +import { dispatchToWebhooks } from "./webhook-dispatcher"; + +export class DailiesService { + private readonly config: DailiesConfig; + private readonly adapters: ContentAdapter[]; + private readonly seenItemIds = new Set(); + private timer: NodeJS.Timeout | null = null; + + constructor(config: DailiesConfig) { + this.config = config; + this.adapters = this.buildAdapters(config); + } + + start(): void { + if (!this.config.enabled) { + console.log("Dailies disabled: set DAILIES_ENABLED=true to activate."); + return; + } + + if (!this.config.webhookTargets.length) { + console.log("Dailies disabled: no DAILIES_WEBHOOK_TARGETS configured."); + return; + } + + if (!this.adapters.length) { + console.log("Dailies disabled: no content adapters configured."); + return; + } + + this.timer = setInterval(() => { + void this.pollAndDispatch().catch((error: unknown) => { + console.error("Dailies poll failed:", error); + }); + }, this.config.pollIntervalMs); + + void this.pollAndDispatch().catch((error: unknown) => { + console.error("Dailies initial poll failed:", error); + }); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async pollAndDispatch(): Promise { + const publishedItems: ContentItem[] = []; + + for (const adapter of this.adapters) { + const items = await adapter.poll(); + for (const item of items) { + if (this.seenItemIds.has(item.id)) { + continue; + } + + this.seenItemIds.add(item.id); + await dispatchToWebhooks(this.config.webhookTargets, item); + publishedItems.push(item); + } + } + + return publishedItems; + } + + private buildAdapters(config: DailiesConfig): ContentAdapter[] { + const adapters: ContentAdapter[] = []; + + if (config.youtubeChannelIds.length) { + adapters.push(new YouTubeAdapter(config.youtubeChannelIds)); + } + + return adapters; + } +} diff --git a/src/dailies/webhook-dispatcher.ts b/src/dailies/webhook-dispatcher.ts new file mode 100644 index 0000000..89fbf96 --- /dev/null +++ b/src/dailies/webhook-dispatcher.ts @@ -0,0 +1,36 @@ +import { DailiesWebhookTarget } from "../config"; +import { ContentItem } from "./content-adapter"; + +function buildMessage(item: ContentItem): string { + return [ + `New ${item.source} drop:`, + item.title, + item.url, + item.publishedAt ? `Published: ${item.publishedAt}` : undefined, + ] + .filter((part): part is string => Boolean(part)) + .join("\n"); +} + +export async function dispatchToWebhooks( + targets: DailiesWebhookTarget[], + item: ContentItem, +): Promise { + for (const target of targets) { + const response = await fetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: buildMessage(item), + }), + }); + + if (!response.ok) { + throw new Error( + `Webhook dispatch failed (${target.label ?? target.url}): ${response.status}`, + ); + } + } +} diff --git a/src/dailies/youtube-adapter.ts b/src/dailies/youtube-adapter.ts new file mode 100644 index 0000000..69ce75a --- /dev/null +++ b/src/dailies/youtube-adapter.ts @@ -0,0 +1,89 @@ +import { ContentAdapter, ContentItem } from "./content-adapter"; + +function decodeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"); +} + +function extractTag(source: string, tagName: string): string | undefined { + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)`, "i"); + const match = source.match(regex); + return match?.[1]?.trim(); +} + +function extractLink(source: string): string | undefined { + const selfClosingLinkRegex = new RegExp( + ']*href="([^"]+)"[^>]*/?>', + "i", + ); + const wrappedLinkRegex = new RegExp( + ']*href="([^"]+)"[^>]*>', + "i", + ); + const attributeMatch = + source.match(selfClosingLinkRegex) ?? source.match(wrappedLinkRegex); + if (attributeMatch?.[1]) { + return attributeMatch[1].trim(); + } + + return extractTag(source, "link"); +} + +function parseFeed(xml: string, channelId: string): ContentItem[] { + const blocks = xml.match(/[\s\S]*?<\/entry>/gi) ?? []; + + return blocks + .map((block): ContentItem | null => { + const videoId = + extractTag(block, "yt:videoId") ?? extractTag(block, "videoId"); + const title = extractTag(block, "title"); + const link = extractLink(block); + const publishedAt = extractTag(block, "published"); + + if (!videoId || !title || !link) { + return null; + } + + return { + id: `${channelId}:${videoId}`, + source: "youtube", + title: decodeXml(title), + url: link, + ...(publishedAt ? { publishedAt } : {}), + }; + }) + .filter((item): item is ContentItem => Boolean(item)); +} + +export class YouTubeAdapter implements ContentAdapter { + readonly name = "youtube"; + private readonly channelIds: string[]; + + constructor(channelIds: string[]) { + this.channelIds = channelIds; + } + + async poll(): Promise { + const allItems: ContentItem[] = []; + + for (const channelId of this.channelIds) { + const response = await fetch( + `https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channelId)}`, + ); + if (!response.ok) { + throw new Error( + `YouTube feed request failed for ${channelId}: ${response.status}`, + ); + } + + const xml = await response.text(); + allItems.push(...parseFeed(xml, channelId)); + } + + return allItems; + } +} diff --git a/src/db/cli.ts b/src/db/cli.ts new file mode 100644 index 0000000..adf2233 --- /dev/null +++ b/src/db/cli.ts @@ -0,0 +1,30 @@ +import { migrateAndSeed, runMigrations, seedInitialConfig } from "./migrations"; + +async function main(): Promise { + const command = (process.argv[2] ?? "migrate").trim(); + + if (command === "migrate") { + await runMigrations(); + console.log("Database migrations applied."); + return; + } + + if (command === "seed") { + const inserted = await seedInitialConfig(); + console.log(`Database seed complete. Inserted ${inserted} config entries.`); + return; + } + + if (command === "setup") { + const inserted = await migrateAndSeed(); + console.log(`Database setup complete. Seeded ${inserted} config entries.`); + return; + } + + throw new Error(`Unknown db command: ${command}. Use migrate|seed|setup.`); +} + +void main().catch((error: unknown) => { + console.error("Database command failed:", error); + process.exit(1); +}); diff --git a/src/db/migrations.ts b/src/db/migrations.ts new file mode 100644 index 0000000..3364ce2 --- /dev/null +++ b/src/db/migrations.ts @@ -0,0 +1,213 @@ +import { Pool } from "pg"; + +const MIGRATIONS_TABLE = "schema_migrations"; +const GLOBAL_SCOPE = "__global__"; + +interface MigrationStep { + id: string; + sql: string; +} + +const MIGRATIONS: MigrationStep[] = [ + { + id: "2026051701_base_schema", + sql: ` + CREATE TABLE IF NOT EXISTS mileage_users ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + total_miles INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS mileage_events ( + id BIGSERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + miles_delta INTEGER NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS mileage_role_tiers ( + guild_id TEXT NOT NULL, + role_id TEXT NOT NULL, + min_miles INTEGER NOT NULL, + PRIMARY KEY (guild_id, role_id) + ); + + CREATE TABLE IF NOT EXISTS bot_settings ( + guild_id TEXT NOT NULL, + setting_key TEXT NOT NULL, + setting_value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, setting_key) + ); + + CREATE TABLE IF NOT EXISTS content_schedules ( + guild_id TEXT NOT NULL, + schedule_key TEXT NOT NULL, + publish_at TIMESTAMPTZ NOT NULL, + payload JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, schedule_key) + ); + + CREATE TABLE IF NOT EXISTS engagement_stats ( + id BIGSERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + command_count INTEGER NOT NULL, + queue_size INTEGER NOT NULL, + mileage_total INTEGER NOT NULL, + active_users INTEGER NOT NULL, + payload JSONB NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_mileage_events_guild_created + ON mileage_events (guild_id, created_at DESC); + + CREATE INDEX IF NOT EXISTS idx_engagement_stats_guild_snapshot + ON engagement_stats (guild_id, snapshot_at DESC); + `, + }, +]; + +const SEEDED_CONFIG_KEYS = [ + "DISCORD_TOKEN", + "DISCORD_CLIENT_ID", + "DISCORD_GUILD_ID", + "CALL_SHEET_REACTION_ROLES", + "CALL_SHEET_MESSAGE_ID", + "CALL_SHEET_ONBOARDED_ROLE_ID", + "CALL_SHEET_WELCOME_CHANNEL_ID", + "MILEAGE_EVENT_SCORES", + "MILEAGE_ROLE_TIERS", + "TOUR_STAGE_CHANNEL_ID", + "TOUR_ANNOUNCE_CHANNEL_ID", + "DAILIES_ENABLED", + "DAILIES_POLL_INTERVAL_MS", + "DAILIES_YOUTUBE_CHANNEL_IDS", + "DAILIES_WEBHOOK_TARGETS", + "ADMIN_API_ENABLED", + "ADMIN_API_HOST", + "ADMIN_API_PORT", + "ADMIN_API_TOKEN", + "OAUTH_BRIDGE_CLIENT_SECRET", + "OAUTH_BRIDGE_REDIRECT_URI", + "OAUTH_BRIDGE_SESSION_TTL_MS", + "OPENMIC_SYNC_URL", + "OPENMIC_SYNC_TOKEN", + "CONFIG_DB_ENABLED", +] as const; + +function getDatabaseUrl(): string { + const databaseUrl = process.env.DATABASE_URL?.trim(); + if (!databaseUrl) { + throw new Error("DATABASE_URL required for migrations and seeding."); + } + + return databaseUrl; +} + +async function ensureMigrationsTable(pool: Pool): Promise { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} ( + id TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +} + +async function hasMigration(pool: Pool, id: string): Promise { + const result = await pool.query<{ id: string }>( + `SELECT id FROM ${MIGRATIONS_TABLE} WHERE id = $1 LIMIT 1`, + [id], + ); + + return Boolean(result.rows[0]); +} + +export async function runMigrations( + databaseUrl = getDatabaseUrl(), +): Promise { + const pool = new Pool({ + connectionString: databaseUrl, + }); + + try { + await ensureMigrationsTable(pool); + + for (const migration of MIGRATIONS) { + const applied = await hasMigration(pool, migration.id); + if (applied) { + continue; + } + + await pool.query("BEGIN"); + try { + await pool.query(migration.sql); + await pool.query(`INSERT INTO ${MIGRATIONS_TABLE} (id) VALUES ($1)`, [ + migration.id, + ]); + await pool.query("COMMIT"); + } catch (error: unknown) { + await pool.query("ROLLBACK"); + throw error; + } + } + } finally { + await pool.end(); + } +} + +function parseSeedValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +export async function seedInitialConfig( + databaseUrl = getDatabaseUrl(), + guildScope = process.env.DISCORD_GUILD_ID?.trim() || GLOBAL_SCOPE, +): Promise { + const pool = new Pool({ + connectionString: databaseUrl, + }); + + let inserted = 0; + try { + for (const key of SEEDED_CONFIG_KEYS) { + const raw = process.env[key]?.trim(); + if (!raw) { + continue; + } + + const result = await pool.query( + ` + INSERT INTO bot_settings (guild_id, setting_key, setting_value) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (guild_id, setting_key) + DO NOTHING + `, + [guildScope, key, JSON.stringify(parseSeedValue(raw))], + ); + + inserted += result.rowCount ?? 0; + } + } finally { + await pool.end(); + } + + return inserted; +} + +export async function migrateAndSeed( + databaseUrl = getDatabaseUrl(), +): Promise { + await runMigrations(databaseUrl); + return seedInitialConfig(databaseUrl); +} diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts index 1d6aa1a..d45c911 100644 --- a/src/deploy-commands.ts +++ b/src/deploy-commands.ts @@ -1,9 +1,9 @@ import { REST, Routes } from "discord.js"; import { commands } from "./commands"; -import { loadConfig } from "./config"; +import { loadRuntimeConfig } from "./config"; async function registerCommands(): Promise { - const config = loadConfig(); + const config = await loadRuntimeConfig(); const rest = new REST({ version: "10" }).setToken(config.discordToken); const body = commands.map((command) => command.data.toJSON()); diff --git a/src/index.ts b/src/index.ts index ce874b3..7cf57d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,13 @@ import { startBot } from "./bot"; -import { loadConfig } from "./config"; +import { loadRuntimeConfig } from "./config"; +import { runMigrations } from "./db/migrations"; export async function bootstrap(): Promise { - const config = loadConfig(); + if (process.env.DATABASE_URL?.trim()) { + await runMigrations(); + } + + const config = await loadRuntimeConfig(); await startBot(config); } diff --git a/src/mileage.ts b/src/mileage.ts new file mode 100644 index 0000000..25e9b44 --- /dev/null +++ b/src/mileage.ts @@ -0,0 +1,322 @@ +import { GuildMember, Role } from "discord.js"; +import { Pool, PoolClient } from "pg"; +import { MileageConfig } from "./config"; + +export interface MileageAwardInput { + guildId: string; + userId: string; + eventType: string; + metadata?: Record; + pointsOverride?: number; + member?: GuildMember; +} + +export interface MileageAwardResult { + awardedMiles: number; + totalMiles: number; + upgradedRoleIds: string[]; +} + +export interface MileageGuildStats { + enabled: boolean; + guildId: string; + totalMembersTracked: number; + totalMilesAwarded: number; + totalEvents: number; + topUsers: Array<{ + userId: string; + totalMiles: number; + }>; +} + +const DEFAULT_RESULT: MileageAwardResult = { + awardedMiles: 0, + totalMiles: 0, + upgradedRoleIds: [], +}; + +async function runWithRetries( + work: () => Promise, + retries = 3, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= retries; attempt += 1) { + try { + return await work(); + } catch (error: unknown) { + lastError = error; + if (attempt < retries) { + await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); + } + } + } + + throw lastError; +} + +function ensureRoleManageable(member: GuildMember, role: Role): boolean { + const botMember = member.guild.members.me; + if (!botMember) { + return false; + } + + return botMember.roles.highest.position > role.position; +} + +export class MileageEngine { + private readonly config: MileageConfig; + private readonly pool?: Pool; + + constructor(config: MileageConfig) { + this.config = config; + if (config.databaseUrl) { + this.pool = new Pool({ + connectionString: config.databaseUrl, + }); + } + } + + get enabled(): boolean { + return Boolean(this.pool); + } + + async init(guildId?: string): Promise { + if (!this.pool) { + console.log("Mileage disabled: set DATABASE_URL to enable persistence."); + return; + } + + await this.ensureSchema(); + if (guildId) { + await this.syncRoleTiers(guildId); + } + } + + async close(): Promise { + if (!this.pool) { + return; + } + + await this.pool.end(); + } + + getScoreForEvent(eventType: string): number { + return this.config.eventScores[eventType] ?? 0; + } + + async awardMiles(input: MileageAwardInput): Promise { + if (!this.pool) { + return DEFAULT_RESULT; + } + + const points = + input.pointsOverride ?? this.getScoreForEvent(input.eventType); + if (points <= 0) { + return DEFAULT_RESULT; + } + + const totalMiles = await runWithRetries(async () => { + const client = await this.pool!.connect(); + try { + return await this.persistAward(client, input, points); + } finally { + client.release(); + } + }); + + const upgradedRoleIds = await this.applyRoleUpgrades( + input.member, + totalMiles, + ); + + return { + awardedMiles: points, + totalMiles, + upgradedRoleIds, + }; + } + + async getGuildStats(guildId: string): Promise { + if (!this.pool) { + return { + enabled: false, + guildId, + totalMembersTracked: 0, + totalMilesAwarded: 0, + totalEvents: 0, + topUsers: [], + }; + } + + const [membersResult, mileageResult, eventsResult, topUsersResult] = + await Promise.all([ + this.pool.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM mileage_users WHERE guild_id = $1", + [guildId], + ), + this.pool.query<{ total_miles: string }>( + "SELECT COALESCE(SUM(total_miles), 0)::text AS total_miles FROM mileage_users WHERE guild_id = $1", + [guildId], + ), + this.pool.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM mileage_events WHERE guild_id = $1", + [guildId], + ), + this.pool.query<{ user_id: string; total_miles: number }>( + ` + SELECT user_id, total_miles + FROM mileage_users + WHERE guild_id = $1 + ORDER BY total_miles DESC + LIMIT 10 + `, + [guildId], + ), + ]); + + return { + enabled: true, + guildId, + totalMembersTracked: Number(membersResult.rows[0]?.count ?? "0"), + totalMilesAwarded: Number(mileageResult.rows[0]?.total_miles ?? "0"), + totalEvents: Number(eventsResult.rows[0]?.count ?? "0"), + topUsers: topUsersResult.rows.map((row) => ({ + userId: row.user_id, + totalMiles: Number(row.total_miles), + })), + }; + } + + private async ensureSchema(): Promise { + await this.pool!.query(` + CREATE TABLE IF NOT EXISTS mileage_users ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + total_miles INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, user_id) + ); + `); + + await this.pool!.query(` + CREATE TABLE IF NOT EXISTS mileage_events ( + id BIGSERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + event_type TEXT NOT NULL, + miles_delta INTEGER NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await this.pool!.query(` + CREATE TABLE IF NOT EXISTS mileage_role_tiers ( + guild_id TEXT NOT NULL, + role_id TEXT NOT NULL, + min_miles INTEGER NOT NULL, + PRIMARY KEY (guild_id, role_id) + ); + `); + } + + private async syncRoleTiers(guildId: string): Promise { + if (!this.config.roleTiers.length || !this.pool) { + return; + } + + await this.pool.query( + "DELETE FROM mileage_role_tiers WHERE guild_id = $1", + [guildId], + ); + + for (const tier of this.config.roleTiers) { + await this.pool.query( + ` + INSERT INTO mileage_role_tiers (guild_id, role_id, min_miles) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, role_id) + DO UPDATE SET min_miles = EXCLUDED.min_miles + `, + [guildId, tier.roleId, tier.minMiles], + ); + } + } + + private async persistAward( + client: PoolClient, + input: MileageAwardInput, + points: number, + ): Promise { + await client.query("BEGIN"); + + try { + await client.query( + ` + INSERT INTO mileage_events (guild_id, user_id, event_type, miles_delta, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb) + `, + [ + input.guildId, + input.userId, + input.eventType, + points, + JSON.stringify(input.metadata ?? {}), + ], + ); + + const result = await client.query<{ total_miles: number }>( + ` + INSERT INTO mileage_users (guild_id, user_id, total_miles) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE SET + total_miles = mileage_users.total_miles + EXCLUDED.total_miles, + updated_at = NOW() + RETURNING total_miles + `, + [input.guildId, input.userId, points], + ); + + await client.query("COMMIT"); + return result.rows[0]?.total_miles ?? 0; + } catch (error: unknown) { + await client.query("ROLLBACK"); + throw error; + } + } + + private async applyRoleUpgrades( + member: GuildMember | undefined, + totalMiles: number, + ): Promise { + if (!member || !this.config.roleTiers.length) { + return []; + } + + const gainedRoleIds: string[] = []; + const sortedTiers = [...this.config.roleTiers].sort( + (left, right) => left.minMiles - right.minMiles, + ); + + for (const tier of sortedTiers) { + if (totalMiles < tier.minMiles || member.roles.cache.has(tier.roleId)) { + continue; + } + + const role = await member.guild.roles.fetch(tier.roleId); + if (!role || !ensureRoleManageable(member, role)) { + continue; + } + + await member.roles.add( + role, + `Mileage upgrade: reached ${tier.minMiles} miles`, + ); + gainedRoleIds.push(tier.roleId); + } + + return gainedRoleIds; + } +} diff --git a/src/oauth-bridge.ts b/src/oauth-bridge.ts new file mode 100644 index 0000000..0fa3b75 --- /dev/null +++ b/src/oauth-bridge.ts @@ -0,0 +1,198 @@ +import { OAuthBridgeConfig } from "./config"; + +interface DiscordTokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +} + +interface DiscordUser { + id: string; + username: string; + global_name: string | null; + avatar: string | null; +} + +interface BridgeSession { + id: string; + discordUser: { + id: string; + username: string; + displayName: string; + avatarUrl: string | null; + }; + createdAt: string; + expiresAt: string; + syncedToOpenmic: boolean; +} + +export interface OAuthExchangeResult { + sessionId: string; + createdAt: string; + expiresAt: string; + syncedToOpenmic: boolean; + discordUser: BridgeSession["discordUser"]; +} + +export class OAuthBridgeService { + private readonly config: OAuthBridgeConfig; + private readonly discordClientId: string; + private readonly sessions = new Map(); + + constructor(config: OAuthBridgeConfig, discordClientId: string) { + this.config = config; + this.discordClientId = discordClientId; + } + + get enabled(): boolean { + return this.config.enabled; + } + + async exchangeCode(code: string): Promise { + if ( + !this.config.enabled || + !this.config.clientSecret || + !this.config.redirectUri + ) { + throw new Error( + "OAuth bridge disabled. Configure client secret and redirect URI.", + ); + } + + const token = await this.fetchDiscordToken(code); + const discordUser = await this.fetchDiscordUser(token.access_token); + const now = new Date(); + const expiresAt = new Date(now.getTime() + this.config.sessionTtlMs); + + const sessionId = crypto.randomUUID(); + const session: BridgeSession = { + id: sessionId, + discordUser: { + id: discordUser.id, + username: discordUser.username, + displayName: discordUser.global_name ?? discordUser.username, + avatarUrl: discordUser.avatar + ? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png` + : null, + }, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + syncedToOpenmic: false, + }; + + session.syncedToOpenmic = await this.syncOpenmicSession(session); + + this.cleanupExpiredSessions(); + this.sessions.set(session.id, session); + + return { + sessionId: session.id, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + syncedToOpenmic: session.syncedToOpenmic, + discordUser: session.discordUser, + }; + } + + getSession(sessionId: string): OAuthExchangeResult | null { + this.cleanupExpiredSessions(); + + const session = this.sessions.get(sessionId); + if (!session) { + return null; + } + + return { + sessionId: session.id, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + syncedToOpenmic: session.syncedToOpenmic, + discordUser: session.discordUser, + }; + } + + private cleanupExpiredSessions(): void { + const now = Date.now(); + for (const [sessionId, session] of this.sessions.entries()) { + if (Date.parse(session.expiresAt) <= now) { + this.sessions.delete(sessionId); + } + } + } + + private async fetchDiscordToken(code: string): Promise { + const body = new URLSearchParams({ + client_id: this.discordClientId, + client_secret: this.config.clientSecret!, + grant_type: "authorization_code", + code, + redirect_uri: this.config.redirectUri!, + }); + + const response = await fetch("https://discord.com/api/v10/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + const raw = await response.text(); + throw new Error( + `Discord token exchange failed (${response.status}): ${raw}`, + ); + } + + return (await response.json()) as DiscordTokenResponse; + } + + private async fetchDiscordUser(accessToken: string): Promise { + const response = await fetch("https://discord.com/api/v10/users/@me", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const raw = await response.text(); + throw new Error(`Discord user fetch failed (${response.status}): ${raw}`); + } + + return (await response.json()) as DiscordUser; + } + + private async syncOpenmicSession(session: BridgeSession): Promise { + if (!this.config.openmicSyncUrl) { + return false; + } + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (this.config.openmicSyncToken) { + headers.Authorization = `Bearer ${this.config.openmicSyncToken}`; + } + + const response = await fetch(this.config.openmicSyncUrl, { + method: "POST", + headers, + body: JSON.stringify({ + sessionId: session.id, + discordUser: session.discordUser, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + }), + }); + + if (!response.ok) { + const raw = await response.text(); + throw new Error(`Openmic sync failed (${response.status}): ${raw}`); + } + + return true; + } +} diff --git a/src/tour-schedule.ts b/src/tour-schedule.ts new file mode 100644 index 0000000..624b548 --- /dev/null +++ b/src/tour-schedule.ts @@ -0,0 +1,182 @@ +import { ChannelType, Guild, GuildMember } from "discord.js"; +import { TourScheduleConfig } from "./config"; + +export interface QueueJoinResult { + joined: boolean; + position: number; +} + +export interface QueueNextResult { + nextUserId?: string; + remaining: number; + stageResult: + | "promoted" + | "not_configured" + | "not_in_stage" + | "stage_not_found"; +} + +export class TourScheduleEngine { + private readonly queues = new Map(); + private config: TourScheduleConfig; + + constructor(config: TourScheduleConfig) { + this.config = config; + } + + updateConfig(config: TourScheduleConfig): void { + this.config = config; + } + + join(guildId: string, userId: string): QueueJoinResult { + const queue = this.getQueue(guildId); + const index = queue.indexOf(userId); + if (index >= 0) { + return { + joined: false, + position: index + 1, + }; + } + + queue.push(userId); + return { + joined: true, + position: queue.length, + }; + } + + leave(guildId: string, userId: string): boolean { + const queue = this.getQueue(guildId); + const index = queue.indexOf(userId); + if (index < 0) { + return false; + } + + queue.splice(index, 1); + return true; + } + + list(guildId: string): string[] { + return [...this.getQueue(guildId)]; + } + + clear(guildId: string): number { + const queue = this.getQueue(guildId); + const size = queue.length; + queue.splice(0, queue.length); + return size; + } + + getQueueLength(guildId: string): number { + return this.getQueue(guildId).length; + } + + getAllQueues(): Record { + const snapshot: Record = {}; + for (const [guildId, queue] of this.queues.entries()) { + snapshot[guildId] = [...queue]; + } + + return snapshot; + } + + async next(guild: Guild): Promise { + const queue = this.getQueue(guild.id); + const nextUserId = queue.shift(); + if (!nextUserId) { + return { + remaining: 0, + stageResult: "not_configured", + }; + } + + const member = await guild.members.fetch(nextUserId).catch(() => null); + const stageResult = await this.promoteToSpeaker(guild, member); + + await this.sendAnnouncement(guild, nextUserId, queue.length); + + return { + nextUserId, + remaining: queue.length, + stageResult, + }; + } + + private getQueue(guildId: string): string[] { + let queue = this.queues.get(guildId); + if (!queue) { + queue = []; + this.queues.set(guildId, queue); + } + + return queue; + } + + private async promoteToSpeaker( + guild: Guild, + member: GuildMember | null, + ): Promise { + const stageChannelId = this.config.stageChannelId; + if (!stageChannelId) { + return "not_configured"; + } + + const channel = await guild.channels + .fetch(stageChannelId) + .catch(() => null); + if (!channel || channel.type !== ChannelType.GuildStageVoice) { + return "stage_not_found"; + } + + if (!member || member.voice.channelId !== stageChannelId) { + return "not_in_stage"; + } + + await member.voice.setSuppressed(false); + return "promoted"; + } + + private async sendAnnouncement( + guild: Guild, + nextUserId: string, + remaining: number, + ): Promise { + const announceChannelId = this.config.announceChannelId; + if (!announceChannelId) { + return; + } + + const channel = await guild.channels + .fetch(announceChannelId) + .catch(() => null); + if (!channel || !channel.isSendable()) { + return; + } + + await channel.send( + `Next up: <@${nextUserId}>. Queue remaining: ${remaining}.`, + ); + } +} + +let tourScheduleEngine: TourScheduleEngine | undefined; + +export function initTourSchedule( + config: TourScheduleConfig, +): TourScheduleEngine { + if (!tourScheduleEngine) { + tourScheduleEngine = new TourScheduleEngine(config); + return tourScheduleEngine; + } + + tourScheduleEngine.updateConfig(config); + return tourScheduleEngine; +} + +export function getTourSchedule(): TourScheduleEngine { + if (!tourScheduleEngine) { + throw new Error("Tour schedule engine not initialized."); + } + + return tourScheduleEngine; +} diff --git a/tests/commands/ping.test.d.ts b/tests/commands/ping.test.d.ts new file mode 100644 index 0000000..69fe23b --- /dev/null +++ b/tests/commands/ping.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=ping.test.d.ts.map \ No newline at end of file diff --git a/tests/commands/ping.test.d.ts.map b/tests/commands/ping.test.d.ts.map new file mode 100644 index 0000000..9918d46 --- /dev/null +++ b/tests/commands/ping.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"ping.test.d.ts","sourceRoot":"","sources":["ping.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/tests/commands/ping.test.js b/tests/commands/ping.test.js new file mode 100644 index 0000000..a4060da --- /dev/null +++ b/tests/commands/ping.test.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const ping_1 = require("../../src/commands/ping"); +describe("pingCommand", () => { + it("replies with Pong", async () => { + const reply = jest.fn().mockResolvedValue(undefined); + await ping_1.pingCommand.execute({ reply }); + expect(reply).toHaveBeenCalledWith("Pong!"); + }); +}); +//# sourceMappingURL=ping.test.js.map \ No newline at end of file diff --git a/tests/commands/ping.test.js.map b/tests/commands/ping.test.js.map new file mode 100644 index 0000000..5901b9c --- /dev/null +++ b/tests/commands/ping.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"ping.test.js","sourceRoot":"","sources":["ping.test.ts"],"names":[],"mappings":";;AAAA,kDAAsD;AAEtD,QAAQ,CAAC,aAAa,EAAE,GAAG,EAAE;IAC3B,EAAE,CAAC,mBAAmB,EAAE,KAAK,IAAI,EAAE;QACjC,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QAErD,MAAM,kBAAW,CAAC,OAAO,CAAC,EAAE,KAAK,EAAS,CAAC,CAAC;QAE5C,MAAM,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/tests/commands/ping.test.ts b/tests/commands/ping.test.ts new file mode 100644 index 0000000..c57a3de --- /dev/null +++ b/tests/commands/ping.test.ts @@ -0,0 +1,11 @@ +import { pingCommand } from "../../src/commands/ping"; + +describe("pingCommand", () => { + it("replies with Pong", async () => { + const reply = jest.fn().mockResolvedValue(undefined); + + await pingCommand.execute({ reply } as any); + + expect(reply).toHaveBeenCalledWith("Pong!"); + }); +}); diff --git a/tests/commands/sign-up.test.d.ts b/tests/commands/sign-up.test.d.ts new file mode 100644 index 0000000..bdedbd4 --- /dev/null +++ b/tests/commands/sign-up.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=sign-up.test.d.ts.map \ No newline at end of file diff --git a/tests/commands/sign-up.test.d.ts.map b/tests/commands/sign-up.test.d.ts.map new file mode 100644 index 0000000..f7086d0 --- /dev/null +++ b/tests/commands/sign-up.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sign-up.test.d.ts","sourceRoot":"","sources":["sign-up.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/tests/commands/sign-up.test.js b/tests/commands/sign-up.test.js new file mode 100644 index 0000000..e41f45a --- /dev/null +++ b/tests/commands/sign-up.test.js @@ -0,0 +1,78 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const discord_js_1 = require("discord.js"); +const sign_up_1 = require("../../src/commands/sign-up"); +const tour_schedule_1 = require("../../src/tour-schedule"); +jest.mock("../../src/tour-schedule", () => ({ + getTourSchedule: jest.fn(), +})); +describe("signUpCommand", () => { + const queue = { + join: jest.fn(), + leave: jest.fn(), + list: jest.fn(), + clear: jest.fn(), + next: jest.fn(), + }; + beforeEach(() => { + jest.clearAllMocks(); + tour_schedule_1.getTourSchedule.mockReturnValue(queue); + }); + function createInteraction(action) { + return { + inGuild: () => true, + guildId: "guild-1", + user: { id: "user-1" }, + guild: { id: "guild-1" }, + memberPermissions: { + has: jest.fn((permission) => permission === discord_js_1.PermissionFlagsBits.ManageChannels), + }, + options: { + getSubcommand: () => action, + }, + reply: jest.fn().mockResolvedValue(undefined), + }; + } + it("joins queue and replies with position", async () => { + queue.join.mockReturnValue({ joined: true, position: 2 }); + const interaction = createInteraction("join"); + await sign_up_1.signUpCommand.execute(interaction); + expect(queue.join).toHaveBeenCalledWith("guild-1", "user-1"); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "Added to queue at position 2.", + ephemeral: true, + }); + }); + it("lists queue in FIFO order", async () => { + queue.list.mockReturnValue(["user-1", "user-2"]); + const interaction = createInteraction("list"); + await sign_up_1.signUpCommand.execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "1. <@user-1>\n2. <@user-2>", + ephemeral: true, + }); + }); + it("blocks next action when missing Manage Channels", async () => { + const interaction = createInteraction("next"); + interaction.memberPermissions.has = jest.fn().mockReturnValue(false); + await sign_up_1.signUpCommand.execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "Need Manage Channels permission for this action.", + ephemeral: true, + }); + }); + it("advances queue and announces stage status", async () => { + queue.next.mockResolvedValue({ + nextUserId: "user-7", + remaining: 3, + stageResult: "promoted", + }); + const interaction = createInteraction("next"); + await sign_up_1.signUpCommand.execute(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "Now up: <@user-7>\nQueue remaining: 3\nStage speaker promoted.", + ephemeral: false, + }); + }); +}); +//# sourceMappingURL=sign-up.test.js.map \ No newline at end of file diff --git a/tests/commands/sign-up.test.js.map b/tests/commands/sign-up.test.js.map new file mode 100644 index 0000000..9d08655 --- /dev/null +++ b/tests/commands/sign-up.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sign-up.test.js","sourceRoot":"","sources":["sign-up.test.ts"],"names":[],"mappings":";;AAAA,2CAAiD;AACjD,wDAA2D;AAC3D,2DAA0D;AAE1D,IAAI,CAAC,IAAI,CAAC,yBAAyB,EAAE,GAAG,EAAE,CAAC,CAAC;IAC1C,eAAe,EAAE,IAAI,CAAC,EAAE,EAAE;CAC3B,CAAC,CAAC,CAAC;AAEJ,QAAQ,CAAC,eAAe,EAAE,GAAG,EAAE;IAC7B,MAAM,KAAK,GAAG;QACZ,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE;QACf,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;QAChB,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE;QACf,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;QAChB,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE;KAChB,CAAC;IAEF,UAAU,CAAC,GAAG,EAAE;QACd,IAAI,CAAC,aAAa,EAAE,CAAC;QACpB,+BAA6B,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,SAAS,iBAAiB,CAAC,MAAc;QACvC,OAAO;YACL,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI;YACnB,OAAO,EAAE,SAAS;YAClB,IAAI,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;YACtB,KAAK,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE;YACxB,iBAAiB,EAAE;gBACjB,GAAG,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC,UAAkB,EAAE,EAAE,CAClC,UAAU,KAAK,gCAAmB,CAAC,cAAc,CAClD;aACF;YACD,OAAO,EAAE;gBACP,aAAa,EAAE,GAAG,EAAE,CAAC,MAAM;aAC5B;YACD,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC;SAC9C,CAAC;IACJ,CAAC;IAED,EAAE,CAAC,uCAAuC,EAAE,KAAK,IAAI,EAAE;QACrD,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC,CAAC;QAC1D,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAE9C,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,oBAAoB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAC7D,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,+BAA+B;YACxC,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2BAA2B,EAAE,KAAK,IAAI,EAAE;QACzC,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;QACjD,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAE9C,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,4BAA4B;YACrC,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,iDAAiD,EAAE,KAAK,IAAI,EAAE;QAC/D,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAC9C,WAAW,CAAC,iBAAiB,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QAErE,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,kDAAkD;YAC3D,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QACzD,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC;YAC3B,UAAU,EAAE,QAAQ;YACpB,SAAS,EAAE,CAAC;YACZ,WAAW,EAAE,UAAU;SACxB,CAAC,CAAC;QACH,MAAM,WAAW,GAAG,iBAAiB,CAAC,MAAM,CAAC,CAAC;QAE9C,MAAM,uBAAa,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QAEzC,MAAM,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC;YAC7C,OAAO,EAAE,gEAAgE;YACzE,SAAS,EAAE,KAAK;SACjB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/tests/commands/sign-up.test.ts b/tests/commands/sign-up.test.ts new file mode 100644 index 0000000..ef516c2 --- /dev/null +++ b/tests/commands/sign-up.test.ts @@ -0,0 +1,93 @@ +import { PermissionFlagsBits } from "discord.js"; +import { signUpCommand } from "../../src/commands/sign-up"; +import { getTourSchedule } from "../../src/tour-schedule"; + +jest.mock("../../src/tour-schedule", () => ({ + getTourSchedule: jest.fn(), +})); + +describe("signUpCommand", () => { + const queue = { + join: jest.fn(), + leave: jest.fn(), + list: jest.fn(), + clear: jest.fn(), + next: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getTourSchedule as jest.Mock).mockReturnValue(queue); + }); + + function createInteraction(action: string): any { + return { + inGuild: () => true, + guildId: "guild-1", + user: { id: "user-1" }, + guild: { id: "guild-1" }, + memberPermissions: { + has: jest.fn((permission: bigint) => + permission === PermissionFlagsBits.ManageChannels, + ), + }, + options: { + getSubcommand: () => action, + }, + reply: jest.fn().mockResolvedValue(undefined), + }; + } + + it("joins queue and replies with position", async () => { + queue.join.mockReturnValue({ joined: true, position: 2 }); + const interaction = createInteraction("join"); + + await signUpCommand.execute(interaction); + + expect(queue.join).toHaveBeenCalledWith("guild-1", "user-1"); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "Added to queue at position 2.", + ephemeral: true, + }); + }); + + it("lists queue in FIFO order", async () => { + queue.list.mockReturnValue(["user-1", "user-2"]); + const interaction = createInteraction("list"); + + await signUpCommand.execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith({ + content: "1. <@user-1>\n2. <@user-2>", + ephemeral: true, + }); + }); + + it("blocks next action when missing Manage Channels", async () => { + const interaction = createInteraction("next"); + interaction.memberPermissions.has = jest.fn().mockReturnValue(false); + + await signUpCommand.execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith({ + content: "Need Manage Channels permission for this action.", + ephemeral: true, + }); + }); + + it("advances queue and announces stage status", async () => { + queue.next.mockResolvedValue({ + nextUserId: "user-7", + remaining: 3, + stageResult: "promoted", + }); + const interaction = createInteraction("next"); + + await signUpCommand.execute(interaction); + + expect(interaction.reply).toHaveBeenCalledWith({ + content: "Now up: <@user-7>\nQueue remaining: 3\nStage speaker promoted.", + ephemeral: false, + }); + }); +}); diff --git a/tests/integration/mileage-engine-flow.test.d.ts b/tests/integration/mileage-engine-flow.test.d.ts new file mode 100644 index 0000000..9ac67e1 --- /dev/null +++ b/tests/integration/mileage-engine-flow.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=mileage-engine-flow.test.d.ts.map \ No newline at end of file diff --git a/tests/integration/mileage-engine-flow.test.d.ts.map b/tests/integration/mileage-engine-flow.test.d.ts.map new file mode 100644 index 0000000..cc574ea --- /dev/null +++ b/tests/integration/mileage-engine-flow.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"mileage-engine-flow.test.d.ts","sourceRoot":"","sources":["mileage-engine-flow.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/tests/integration/mileage-engine-flow.test.js b/tests/integration/mileage-engine-flow.test.js new file mode 100644 index 0000000..ed29767 --- /dev/null +++ b/tests/integration/mileage-engine-flow.test.js @@ -0,0 +1,74 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const mockClient = { + query: jest.fn(), + release: jest.fn(), +}; +const mockPool = { + connect: jest.fn(async () => mockClient), + query: jest.fn(), + end: jest.fn(), +}; +jest.mock("pg", () => ({ + Pool: jest.fn(() => mockPool), +})); +const mileage_1 = require("../../src/mileage"); +describe("MileageEngine flow", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockClient.query.mockImplementation(async (query) => { + if (query.includes("RETURNING total_miles")) { + return { rows: [{ total_miles: 120 }] }; + } + return { rows: [] }; + }); + }); + it("awards miles, persists event, and upgrades role when threshold reached", async () => { + const roleAdd = jest.fn().mockResolvedValue(undefined); + const member = { + roles: { + cache: { + has: jest.fn().mockReturnValue(false), + }, + add: roleAdd, + }, + guild: { + members: { + me: { + roles: { + highest: { + position: 100, + }, + }, + }, + }, + roles: { + fetch: jest.fn().mockResolvedValue({ + id: "role-1", + position: 10, + }), + }, + }, + }; + const engine = new mileage_1.MileageEngine({ + databaseUrl: "postgres://test", + roleTiers: [{ roleId: "role-1", minMiles: 100 }], + eventScores: { command_execute: 10 }, + }); + const result = await engine.awardMiles({ + guildId: "guild-1", + userId: "user-1", + eventType: "command_execute", + member, + }); + expect(result).toEqual({ + awardedMiles: 10, + totalMiles: 120, + upgradedRoleIds: ["role-1"], + }); + expect(mockClient.query).toHaveBeenCalledWith("BEGIN"); + expect(mockClient.query).toHaveBeenCalledWith("COMMIT"); + expect(roleAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "role-1" }), "Mileage upgrade: reached 100 miles"); + }); +}); +//# sourceMappingURL=mileage-engine-flow.test.js.map \ No newline at end of file diff --git a/tests/integration/mileage-engine-flow.test.js.map b/tests/integration/mileage-engine-flow.test.js.map new file mode 100644 index 0000000..90b28e8 --- /dev/null +++ b/tests/integration/mileage-engine-flow.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"mileage-engine-flow.test.js","sourceRoot":"","sources":["mileage-engine-flow.test.ts"],"names":[],"mappings":";;AAAA,MAAM,UAAU,GAAG;IACjB,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;IAChB,OAAO,EAAE,IAAI,CAAC,EAAE,EAAE;CACnB,CAAC;AAEF,MAAM,QAAQ,GAAG;IACf,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC,UAAU,CAAC;IACxC,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE;IAChB,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE;CACf,CAAC;AAEF,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;IACrB,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC;CAC9B,CAAC,CAAC,CAAC;AAEJ,+CAAkD;AAElD,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,UAAU,CAAC,GAAG,EAAE;QACd,IAAI,CAAC,aAAa,EAAE,CAAC;QACrB,UAAU,CAAC,KAAK,CAAC,kBAAkB,CAAC,KAAK,EAAE,KAAa,EAAE,EAAE;YAC1D,IAAI,KAAK,CAAC,QAAQ,CAAC,uBAAuB,CAAC,EAAE,CAAC;gBAC5C,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;YAC1C,CAAC;YAED,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;QACtB,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wEAAwE,EAAE,KAAK,IAAI,EAAE;QACtF,MAAM,OAAO,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG;YACb,KAAK,EAAE;gBACL,KAAK,EAAE;oBACL,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC;iBACtC;gBACD,GAAG,EAAE,OAAO;aACb;YACD,KAAK,EAAE;gBACL,OAAO,EAAE;oBACP,EAAE,EAAE;wBACF,KAAK,EAAE;4BACL,OAAO,EAAE;gCACP,QAAQ,EAAE,GAAG;6BACd;yBACF;qBACF;iBACF;gBACD,KAAK,EAAE;oBACL,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC;wBACjC,EAAE,EAAE,QAAQ;wBACZ,QAAQ,EAAE,EAAE;qBACb,CAAC;iBACH;aACF;SACK,CAAC;QAET,MAAM,MAAM,GAAG,IAAI,uBAAa,CAAC;YAC/B,WAAW,EAAE,iBAAiB;YAC9B,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC;YAChD,WAAW,EAAE,EAAE,eAAe,EAAE,EAAE,EAAE;SACrC,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC;YACrC,OAAO,EAAE,SAAS;YAClB,MAAM,EAAE,QAAQ;YAChB,SAAS,EAAE,iBAAiB;YAC5B,MAAM;SACP,CAAC,CAAC;QAEH,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,YAAY,EAAE,EAAE;YAChB,UAAU,EAAE,GAAG;YACf,eAAe,EAAE,CAAC,QAAQ,CAAC;SAC5B,CAAC,CAAC;QAEH,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;QACvD,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,QAAQ,CAAC,CAAC;QACxD,MAAM,CAAC,OAAO,CAAC,CAAC,oBAAoB,CAClC,MAAM,CAAC,gBAAgB,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,EACzC,oCAAoC,CACrC,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/tests/integration/mileage-engine-flow.test.ts b/tests/integration/mileage-engine-flow.test.ts new file mode 100644 index 0000000..239d552 --- /dev/null +++ b/tests/integration/mileage-engine-flow.test.ts @@ -0,0 +1,84 @@ +const mockClient = { + query: jest.fn(), + release: jest.fn(), +}; + +const mockPool = { + connect: jest.fn(async () => mockClient), + query: jest.fn(), + end: jest.fn(), +}; + +jest.mock("pg", () => ({ + Pool: jest.fn(() => mockPool), +})); + +import { MileageEngine } from "../../src/mileage"; + +describe("MileageEngine flow", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockClient.query.mockImplementation(async (query: string) => { + if (query.includes("RETURNING total_miles")) { + return { rows: [{ total_miles: 120 }] }; + } + + return { rows: [] }; + }); + }); + + it("awards miles, persists event, and upgrades role when threshold reached", async () => { + const roleAdd = jest.fn().mockResolvedValue(undefined); + const member = { + roles: { + cache: { + has: jest.fn().mockReturnValue(false), + }, + add: roleAdd, + }, + guild: { + members: { + me: { + roles: { + highest: { + position: 100, + }, + }, + }, + }, + roles: { + fetch: jest.fn().mockResolvedValue({ + id: "role-1", + position: 10, + }), + }, + }, + } as any; + + const engine = new MileageEngine({ + databaseUrl: "postgres://test", + roleTiers: [{ roleId: "role-1", minMiles: 100 }], + eventScores: { command_execute: 10 }, + }); + + const result = await engine.awardMiles({ + guildId: "guild-1", + userId: "user-1", + eventType: "command_execute", + member, + }); + + expect(result).toEqual({ + awardedMiles: 10, + totalMiles: 120, + upgradedRoleIds: ["role-1"], + }); + + expect(mockClient.query).toHaveBeenCalledWith("BEGIN"); + expect(mockClient.query).toHaveBeenCalledWith("COMMIT"); + expect(roleAdd).toHaveBeenCalledWith( + expect.objectContaining({ id: "role-1" }), + "Mileage upgrade: reached 100 miles", + ); + }); +}); diff --git a/tests/integration/tour-schedule-flow.test.d.ts b/tests/integration/tour-schedule-flow.test.d.ts new file mode 100644 index 0000000..5b0cb88 --- /dev/null +++ b/tests/integration/tour-schedule-flow.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=tour-schedule-flow.test.d.ts.map \ No newline at end of file diff --git a/tests/integration/tour-schedule-flow.test.d.ts.map b/tests/integration/tour-schedule-flow.test.d.ts.map new file mode 100644 index 0000000..c9f5458 --- /dev/null +++ b/tests/integration/tour-schedule-flow.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"tour-schedule-flow.test.d.ts","sourceRoot":"","sources":["tour-schedule-flow.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/tests/integration/tour-schedule-flow.test.js b/tests/integration/tour-schedule-flow.test.js new file mode 100644 index 0000000..83a7306 --- /dev/null +++ b/tests/integration/tour-schedule-flow.test.js @@ -0,0 +1,59 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const discord_js_1 = require("discord.js"); +const tour_schedule_1 = require("../../src/tour-schedule"); +describe("TourScheduleEngine flow", () => { + it("handles FIFO next flow and announcement dispatch", async () => { + const send = jest.fn().mockResolvedValue(undefined); + const guild = { + id: "guild-1", + members: { + fetch: jest.fn().mockResolvedValue({ + voice: { + channelId: "stage-1", + setSuppressed: jest.fn().mockResolvedValue(undefined), + }, + }), + }, + channels: { + fetch: jest + .fn() + .mockImplementation(async (channelId) => { + if (channelId === "stage-1") { + return { + type: discord_js_1.ChannelType.GuildStageVoice, + }; + } + if (channelId === "announce-1") { + return { + isSendable: () => true, + send, + }; + } + return null; + }), + }, + }; + const engine = new tour_schedule_1.TourScheduleEngine({ + stageChannelId: "stage-1", + announceChannelId: "announce-1", + }); + engine.join("guild-1", "user-a"); + engine.join("guild-1", "user-b"); + const first = await engine.next(guild); + const second = await engine.next(guild); + expect(first).toEqual({ + nextUserId: "user-a", + remaining: 1, + stageResult: "promoted", + }); + expect(second).toEqual({ + nextUserId: "user-b", + remaining: 0, + stageResult: "promoted", + }); + expect(send).toHaveBeenNthCalledWith(1, "Next up: <@user-a>. Queue remaining: 1."); + expect(send).toHaveBeenNthCalledWith(2, "Next up: <@user-b>. Queue remaining: 0."); + }); +}); +//# sourceMappingURL=tour-schedule-flow.test.js.map \ No newline at end of file diff --git a/tests/integration/tour-schedule-flow.test.js.map b/tests/integration/tour-schedule-flow.test.js.map new file mode 100644 index 0000000..e22dae7 --- /dev/null +++ b/tests/integration/tour-schedule-flow.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"tour-schedule-flow.test.js","sourceRoot":"","sources":["tour-schedule-flow.test.ts"],"names":[],"mappings":";;AAAA,2CAAyC;AACzC,2DAA6D;AAE7D,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,EAAE,CAAC,kDAAkD,EAAE,KAAK,IAAI,EAAE;QAChE,MAAM,IAAI,GAAG,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC,CAAC;QACpD,MAAM,KAAK,GAAG;YACZ,EAAE,EAAE,SAAS;YACb,OAAO,EAAE;gBACP,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC;oBACjC,KAAK,EAAE;wBACL,SAAS,EAAE,SAAS;wBACpB,aAAa,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,SAAS,CAAC;qBACtD;iBACF,CAAC;aACH;YACD,QAAQ,EAAE;gBACR,KAAK,EAAE,IAAI;qBACR,EAAE,EAAE;qBACJ,kBAAkB,CAAC,KAAK,EAAE,SAAiB,EAAE,EAAE;oBAC9C,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;wBAC5B,OAAO;4BACL,IAAI,EAAE,wBAAW,CAAC,eAAe;yBAClC,CAAC;oBACJ,CAAC;oBAED,IAAI,SAAS,KAAK,YAAY,EAAE,CAAC;wBAC/B,OAAO;4BACL,UAAU,EAAE,GAAG,EAAE,CAAC,IAAI;4BACtB,IAAI;yBACL,CAAC;oBACJ,CAAC;oBAED,OAAO,IAAI,CAAC;gBACd,CAAC,CAAC;aACL;SACK,CAAC;QAET,MAAM,MAAM,GAAG,IAAI,kCAAkB,CAAC;YACpC,cAAc,EAAE,SAAS;YACzB,iBAAiB,EAAE,YAAY;SAChC,CAAC,CAAC;QAEH,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QACjC,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QAEjC,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACvC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAExC,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC;YACpB,UAAU,EAAE,QAAQ;YACpB,SAAS,EAAE,CAAC;YACZ,WAAW,EAAE,UAAU;SACxB,CAAC,CAAC;QACH,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,UAAU,EAAE,QAAQ;YACpB,SAAS,EAAE,CAAC;YACZ,WAAW,EAAE,UAAU;SACxB,CAAC,CAAC;QAEH,MAAM,CAAC,IAAI,CAAC,CAAC,uBAAuB,CAClC,CAAC,EACD,yCAAyC,CAC1C,CAAC;QACF,MAAM,CAAC,IAAI,CAAC,CAAC,uBAAuB,CAClC,CAAC,EACD,yCAAyC,CAC1C,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/tests/integration/tour-schedule-flow.test.ts b/tests/integration/tour-schedule-flow.test.ts new file mode 100644 index 0000000..a34e36d --- /dev/null +++ b/tests/integration/tour-schedule-flow.test.ts @@ -0,0 +1,70 @@ +import { ChannelType } from "discord.js"; +import { TourScheduleEngine } from "../../src/tour-schedule"; + +describe("TourScheduleEngine flow", () => { + it("handles FIFO next flow and announcement dispatch", async () => { + const send = jest.fn().mockResolvedValue(undefined); + const guild = { + id: "guild-1", + members: { + fetch: jest.fn().mockResolvedValue({ + voice: { + channelId: "stage-1", + setSuppressed: jest.fn().mockResolvedValue(undefined), + }, + }), + }, + channels: { + fetch: jest + .fn() + .mockImplementation(async (channelId: string) => { + if (channelId === "stage-1") { + return { + type: ChannelType.GuildStageVoice, + }; + } + + if (channelId === "announce-1") { + return { + isSendable: () => true, + send, + }; + } + + return null; + }), + }, + } as any; + + const engine = new TourScheduleEngine({ + stageChannelId: "stage-1", + announceChannelId: "announce-1", + }); + + engine.join("guild-1", "user-a"); + engine.join("guild-1", "user-b"); + + const first = await engine.next(guild); + const second = await engine.next(guild); + + expect(first).toEqual({ + nextUserId: "user-a", + remaining: 1, + stageResult: "promoted", + }); + expect(second).toEqual({ + nextUserId: "user-b", + remaining: 0, + stageResult: "promoted", + }); + + expect(send).toHaveBeenNthCalledWith( + 1, + "Next up: <@user-a>. Queue remaining: 1.", + ); + expect(send).toHaveBeenNthCalledWith( + 2, + "Next up: <@user-b>. Queue remaining: 0.", + ); + }); +}); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..0dd13d8 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json index 0614492..93c835c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -43,6 +43,6 @@ "esModuleInterop": true }, - "include": ["src/**/*.ts", "tests/**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["dist", "node_modules"] }