diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 814adc91..22a02e65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,15 @@ jobs: test: name: CI uses: TytaniumDev/.github/.github/workflows/test.yml@main + + verify-ts: + name: TypeScript Verification + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: ./scripts/verify-ts.sh diff --git a/.gitignore b/.gitignore index 93126972..b1be79c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ venv/ .vscode/ .idea/ *.log -activity/node_modules/ +node_modules/ activity/dist/ # Project-specific diff --git a/Dockerfile b/Dockerfile index dbe5b476..aa7e0ba5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,18 @@ -FROM python:3.11-slim - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - ffmpeg \ - libnacl-dev \ - gcc \ - && rm -rf /var/lib/apt/lists/* +FROM node:22-slim WORKDIR /app ARG GIT_SHA= ENV GIT_SHA=${GIT_SHA} -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Copy workspace configs first for better layer caching +COPY package.json package-lock.json ./ +COPY packages/shared/package.json packages/shared/ +COPY packages/bot/package.json packages/bot/ + +RUN npm ci --workspace=packages/shared --workspace=packages/bot -COPY . . +# Copy source code +COPY packages/ packages/ -CMD ["python", "bot.py"] +CMD ["node", "node_modules/.bin/tsx", "packages/bot/src/bot.ts"] diff --git a/docker-compose.yml b/docker-compose.yml index 1d636c78..c8d3a726 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: volumes: - ./data:/data healthcheck: - test: ["CMD-SHELL", "grep -aq 'bot.py' /proc/1/cmdline"] + test: ["CMD-SHELL", "grep -aq node /proc/1/cmdline"] interval: 10s timeout: 5s retries: 3 diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 00000000..6e5d59f4 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,20 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.strict, + { + ignores: ['**/dist/', '**/node_modules/', 'activity/'], + }, + { + files: ['packages/**/*.ts'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-non-null-assertion': 'warn', + }, + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..53ab13c2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7160 @@ +{ + "name": "MythicPlusDiscordBot", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "workspaces": [ + "packages/*", + "activity" + ], + "devDependencies": { + "@eslint/js": "^9.28.0", + "eslint": "^9.28.0", + "jiti": "^2.6.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.35.0" + } + }, + "activity": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@discord/embedded-app-sdk": "^2.4.0", + "@tailwindcss/vite": "^4.2.1", + "@vitejs/plugin-react": "^5.1.4", + "firebase": "^12.8.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@axe-core/playwright": "^4.10.0", + "@playwright/test": "^1.58.2", + "@types/node": "^25.2.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3" + } + }, + "activity/node_modules/@axe-core/playwright": { + "version": "4.11.1", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "activity/node_modules/@babel/code-frame": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/compat-data": { + "version": "7.29.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/core": { + "version": "7.29.0", + "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" + } + }, + "activity/node_modules/@babel/generator": { + "version": "7.29.1", + "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" + } + }, + "activity/node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "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" + } + }, + "activity/node_modules/@babel/helper-globals": { + "version": "7.28.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "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" + } + }, + "activity/node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/helpers": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/parser": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "activity/node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "activity/node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "activity/node_modules/@babel/template": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@babel/traverse": { + "version": "7.29.0", + "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" + } + }, + "activity/node_modules/@babel/types": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/@discord/embedded-app-sdk": { + "version": "2.4.0", + "license": "MIT", + "dependencies": { + "@types/lodash.transform": "^4.6.6", + "@types/uuid": "^10.0.0", + "big-integer": "^1.6.48", + "decimal.js-light": "^2.5.0", + "eventemitter3": "^5.0.0", + "lodash.transform": "^4.6.0", + "uuid": "^11.0.0", + "zod": "^3.9.8" + } + }, + "activity/node_modules/@firebase/ai": { + "version": "2.7.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "activity/node_modules/@firebase/analytics": { + "version": "0.10.19", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/analytics-compat": { + "version": "0.2.25", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.19", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "license": "Apache-2.0" + }, + "activity/node_modules/@firebase/app": { + "version": "0.14.7", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "activity/node_modules/@firebase/app-check": { + "version": "0.11.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/app-check-compat": { + "version": "0.4.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.0", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "license": "Apache-2.0" + }, + "activity/node_modules/@firebase/app-compat": { + "version": "0.5.7", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.14.7", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "activity/node_modules/@firebase/auth": { + "version": "1.12.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "activity/node_modules/@firebase/auth-compat": { + "version": "0.6.2", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.12.0", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/auth-types": { + "version": "0.13.0", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "activity/node_modules/@firebase/component": { + "version": "0.7.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "activity/node_modules/@firebase/data-connect": { + "version": "0.3.12", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/database": { + "version": "1.1.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "activity/node_modules/@firebase/database-compat": { + "version": "2.1.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "activity/node_modules/@firebase/database-types": { + "version": "1.0.16", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "activity/node_modules/@firebase/firestore": { + "version": "4.10.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "@firebase/webchannel-wrapper": "1.0.5", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/firestore-compat": { + "version": "0.4.4", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/firestore": "4.10.0", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "activity/node_modules/@firebase/functions": { + "version": "0.13.1", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/functions-compat": { + "version": "0.4.1", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/functions": "0.13.1", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/functions-types": { + "version": "0.6.3", + "license": "Apache-2.0" + }, + "activity/node_modules/@firebase/installations": { + "version": "0.6.19", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/installations-compat": { + "version": "0.2.19", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/installations-types": { + "version": "0.5.3", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "activity/node_modules/@firebase/messaging": { + "version": "0.12.23", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.13.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/messaging-compat": { + "version": "0.2.23", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/messaging": "0.12.23", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "license": "Apache-2.0" + }, + "activity/node_modules/@firebase/performance": { + "version": "0.7.9", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/performance-compat": { + "version": "0.2.22", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/performance": "0.7.9", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/performance-types": { + "version": "0.2.3", + "license": "Apache-2.0" + }, + "activity/node_modules/@firebase/remote-config": { + "version": "0.8.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/remote-config-compat": { + "version": "0.2.21", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/remote-config": "0.8.0", + "@firebase/remote-config-types": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/remote-config-types": { + "version": "0.5.0", + "license": "Apache-2.0" + }, + "activity/node_modules/@firebase/storage": { + "version": "0.14.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "activity/node_modules/@firebase/storage-compat": { + "version": "0.4.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/storage": "0.14.0", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "activity/node_modules/@firebase/storage-types": { + "version": "0.8.3", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "activity/node_modules/@firebase/util": { + "version": "1.13.0", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "activity/node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.5", + "license": "Apache-2.0" + }, + "activity/node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "activity/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "activity/node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "activity/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "activity/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "activity/node_modules/@playwright/test": { + "version": "1.58.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "activity/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "license": "MIT" + }, + "activity/node_modules/@tailwindcss/node": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "activity/node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "activity/node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "activity/node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "activity/node_modules/@types/babel__core": { + "version": "7.20.5", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "activity/node_modules/@types/babel__generator": { + "version": "7.27.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "activity/node_modules/@types/babel__template": { + "version": "7.4.4", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "activity/node_modules/@types/babel__traverse": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "activity/node_modules/@types/lodash": { + "version": "4.17.23", + "license": "MIT" + }, + "activity/node_modules/@types/lodash.transform": { + "version": "4.6.9", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "activity/node_modules/@types/node": { + "version": "25.2.0", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "activity/node_modules/@types/react": { + "version": "19.2.14", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "activity/node_modules/@types/react-dom": { + "version": "19.2.3", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "activity/node_modules/@types/uuid": { + "version": "10.0.0", + "license": "MIT" + }, + "activity/node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "activity/node_modules/axe-core": { + "version": "4.11.1", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "activity/node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "activity/node_modules/big-integer": { + "version": "1.6.52", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "activity/node_modules/browserslist": { + "version": "4.28.1", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "activity/node_modules/caniuse-lite": { + "version": "1.0.30001777", + "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" + }, + "activity/node_modules/convert-source-map": { + "version": "2.0.0", + "license": "MIT" + }, + "activity/node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "activity/node_modules/decimal.js-light": { + "version": "2.5.1", + "license": "MIT" + }, + "activity/node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "activity/node_modules/electron-to-chromium": { + "version": "1.5.307", + "license": "ISC" + }, + "activity/node_modules/enhanced-resolve": { + "version": "5.20.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "activity/node_modules/eventemitter3": { + "version": "5.0.4", + "license": "MIT" + }, + "activity/node_modules/firebase": { + "version": "12.8.0", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.7.0", + "@firebase/analytics": "0.10.19", + "@firebase/analytics-compat": "0.2.25", + "@firebase/app": "0.14.7", + "@firebase/app-check": "0.11.0", + "@firebase/app-check-compat": "0.4.0", + "@firebase/app-compat": "0.5.7", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.12.0", + "@firebase/auth-compat": "0.6.2", + "@firebase/data-connect": "0.3.12", + "@firebase/database": "1.1.0", + "@firebase/database-compat": "2.1.0", + "@firebase/firestore": "4.10.0", + "@firebase/firestore-compat": "0.4.4", + "@firebase/functions": "0.13.1", + "@firebase/functions-compat": "0.4.1", + "@firebase/installations": "0.6.19", + "@firebase/installations-compat": "0.2.19", + "@firebase/messaging": "0.12.23", + "@firebase/messaging-compat": "0.2.23", + "@firebase/performance": "0.7.9", + "@firebase/performance-compat": "0.2.22", + "@firebase/remote-config": "0.8.0", + "@firebase/remote-config-compat": "0.2.21", + "@firebase/storage": "0.14.0", + "@firebase/storage-compat": "0.4.0", + "@firebase/util": "1.13.0" + } + }, + "activity/node_modules/gensync": { + "version": "1.0.0-beta.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "activity/node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "activity/node_modules/idb": { + "version": "7.1.1", + "license": "ISC" + }, + "activity/node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "activity/node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "activity/node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "activity/node_modules/lightningcss": { + "version": "1.31.1", + "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.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "activity/node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "activity/node_modules/lodash.transform": { + "version": "4.6.0", + "license": "MIT" + }, + "activity/node_modules/lru-cache": { + "version": "5.1.1", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "activity/node_modules/node-releases": { + "version": "2.0.36", + "license": "MIT" + }, + "activity/node_modules/playwright": { + "version": "1.58.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "activity/node_modules/playwright-core": { + "version": "1.58.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "activity/node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "activity/node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "activity/node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "activity/node_modules/react-refresh": { + "version": "0.18.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "activity/node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "activity/node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "activity/node_modules/tailwindcss": { + "version": "4.2.1", + "license": "MIT" + }, + "activity/node_modules/tapable": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "activity/node_modules/undici-types": { + "version": "7.16.0", + "license": "MIT" + }, + "activity/node_modules/update-browserslist-db": { + "version": "1.2.3", + "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" + } + }, + "activity/node_modules/web-vitals": { + "version": "4.2.4", + "license": "Apache-2.0" + }, + "activity/node_modules/yallist": { + "version": "3.1.1", + "license": "ISC" + }, + "activity/node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "activity/node_modules/zustand": { + "version": "5.0.11", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.16", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", + "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", + "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", + "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/database": "1.1.1", + "@firebase/database-types": "1.0.17", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", + "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.14.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", + "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.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/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@mythicplus/bot": { + "resolved": "packages/bot", + "link": true + }, + "node_modules/@mythicplus/shared": { + "resolved": "packages/shared", + "link": true + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "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/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.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.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.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.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "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.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.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.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/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/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "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/activity": { + "resolved": "activity", + "link": true + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "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==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.41", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.41.tgz", + "integrity": "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.13.0", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.33", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.21.3" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "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", + "optional": true, + "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", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "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": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.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", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-xml-parser": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", + "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "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/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/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", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "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", + "optional": true, + "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-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", + "optional": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "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", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/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", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "optional": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "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/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "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/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "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/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.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", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "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==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "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==", + "devOptional": 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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.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/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.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.0.0" + } + }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "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/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/bot": { + "name": "@mythicplus/bot", + "version": "1.0.0", + "dependencies": { + "@mythicplus/shared": "*", + "discord.js": "^14.18.0", + "dotenv": "^16.5.0", + "firebase-admin": "^13.4.0", + "winston": "^3.17.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "tsx": "^4.19.4", + "vitest": "^3.2.1" + } + }, + "packages/shared": { + "name": "@mythicplus/shared", + "version": "1.0.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..5d0c030c --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "private": true, + "workspaces": [ + "packages/*", + "activity" + ], + "devDependencies": { + "@eslint/js": "^9.28.0", + "eslint": "^9.28.0", + "jiti": "^2.6.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.35.0" + }, + "scripts": { + "lint": "eslint packages/", + "typecheck": "npm -w packages/shared run typecheck && npm -w packages/bot run typecheck", + "test": "npm -w packages/bot run test", + "verify": "./scripts/verify-ts.sh" + } +} diff --git a/packages/bot/package.json b/packages/bot/package.json new file mode 100644 index 00000000..dfd7bda7 --- /dev/null +++ b/packages/bot/package.json @@ -0,0 +1,24 @@ +{ + "name": "@mythicplus/bot", + "version": "1.0.0", + "type": "module", + "main": "src/bot.ts", + "scripts": { + "dev": "tsx src/bot.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@mythicplus/shared": "*", + "discord.js": "^14.18.0", + "dotenv": "^16.5.0", + "firebase-admin": "^13.4.0", + "tsx": "^4.19.4", + "winston": "^3.17.0" + }, + "devDependencies": { + "vitest": "^3.2.1", + "@types/node": "^22.15.0" + } +} diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts new file mode 100644 index 00000000..17899768 --- /dev/null +++ b/packages/bot/src/bot.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs'; +import { createErrorIssue } from './core/issues.js'; +import { DEVELOPER_ID, LOG_FILE } from './core/config.js'; +import logger from './core/logger.js'; +import { GroupService } from './services/groupService.js'; + +export interface DevUser { + send( + content: string, + options?: { files?: { filename: string; content: Buffer }[] }, + ): Promise; +} + +export interface BotClient { + getUser(id: number): DevUser | null; + fetchUser(id: number): Promise; +} + +export class MythicPlusBot { + groupService: GroupService; + private client: BotClient; + + constructor(client: BotClient) { + this.groupService = new GroupService(); + this.client = client; + } + + async sendErrorToDev(error: Error, contextInfo = 'Unknown Context'): Promise { + let dev: DevUser | null = null; + try { + dev = this.client.getUser(DEVELOPER_ID) ?? (await this.client.fetchUser(DEVELOPER_ID)); + if (dev) { + const tbStr = error.stack ?? String(error); + let message = + `⚠️ **Bot Error Detected**\n**Context:** ${contextInfo}\n` + + `**Error:** \`${error.message}\`\n**Type:** \`${error.constructor.name}\``; + const files: { filename: string; content: Buffer }[] = []; + + if (tbStr.length + message.length >= 1900) { + files.push({ filename: 'traceback.txt', content: Buffer.from(tbStr) }); + } else { + message += `\n\`\`\`\n${tbStr}\n\`\`\``; + } + + if (fs.existsSync(LOG_FILE)) { + const logData = fs.readFileSync(LOG_FILE); + files.push({ + filename: 'bot.log', + content: Buffer.isBuffer(logData) ? logData : Buffer.from(logData), + }); + } + + await dev.send(message, files.length > 0 ? { files } : undefined); + } else { + logger.error(`Could not find developer with ID ${DEVELOPER_ID}`); + } + } catch (e) { + logger.error(`Failed to send error DM to developer: ${e}`); + logger.error(`Original error in ${contextInfo}: ${error}`); + } + + // Always attempt to create GitHub issue, regardless of DM success + const issue = await createErrorIssue(error, contextInfo); + if (issue && dev) { + try { + await dev.send(`📋 GitHub issue: ${issue['html_url']}`); + } catch (e) { + logger.warn(`Failed to send GitHub issue follow-up DM: ${e}`); + } + } + } + + async handleAppCommandError( + interaction: { + commandName?: string; + user: { id: string; toString(): string }; + channel: { toString(): string } | null; + responseSent: boolean; + reply(content: string, options?: { ephemeral?: boolean }): Promise; + followUp(content: string, options?: { ephemeral?: boolean }): Promise; + }, + error: Error, + ): Promise { + const context = + `App Command: /${interaction.commandName ?? 'Unknown'}\n` + + `User: ${interaction.user} (${interaction.user.id})\n` + + `Channel: ${interaction.channel}`; + await this.sendErrorToDev(error, context); + logger.error(`App Command Error: ${error}`); + + const msg = '❌ An error occurred while processing your command. Please try again later.'; + if (!interaction.responseSent) { + await interaction.reply(msg, { ephemeral: true }); + } else { + await interaction.followUp(msg, { ephemeral: true }); + } + } + + async handleCommandError( + ctx: { + command: string; + author: { id: string | number; toString(): string }; + channel: { toString(): string }; + send(content: string): Promise; + }, + error: Error, + ): Promise { + const context = + `Command: !${ctx.command}\n` + + `User: ${ctx.author} (${ctx.author.id})\n` + + `Channel: ${ctx.channel}`; + await this.sendErrorToDev(error, context); + logger.error(`Error in ${ctx.command}: ${error}`); + await ctx.send( + '❌ An error occurred while processing your command. Please try again later.', + ); + } + + async handleEventError(eventName: string, error: Error, ...args: unknown[]): Promise { + const context = `Event: ${eventName}\nArgs: ${JSON.stringify(args)}`; + await this.sendErrorToDev(error, context); + logger.error(`Error in event ${eventName}: ${error}`); + } +} diff --git a/packages/bot/src/commands/debug.ts b/packages/bot/src/commands/debug.ts new file mode 100644 index 00000000..8f4bd0ac --- /dev/null +++ b/packages/bot/src/commands/debug.ts @@ -0,0 +1,55 @@ +import type { GroupService } from '../services/groupService.js'; +import logger from '../core/logger.js'; + +export interface DebugContext { + guild: { id: number } | null; + channel: { send(content: string): Promise }; + send(content: string): Promise; +} + +export class DebugHandler { + private groupService: GroupService; + + constructor(groupService: GroupService) { + this.groupService = groupService; + } + + async test(ctx: DebugContext): Promise { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await this.groupService.coreWheel(ctx as any, true); + } catch (e) { + await ctx.send('❌ An unexpected error occurred. Please try again later.'); + logger.error(`Error in test command: ${e}`); + } + } + + async testcase(ctx: DebugContext): Promise { + try { + const guildId = ctx.guild?.id ?? null; + if (!guildId) { + await ctx.send('❌ This command can only be used in a server.'); + return; + } + + const lastResults = this.groupService.lastResults; + if (!lastResults.has(guildId)) { + await ctx.send('❌ No previous results found for this server. Run `!wheel` first.'); + return; + } + + const result = lastResults.get(guildId)!; + const { players, groups } = result; + + await ctx.channel.send( + `players = [${players.map((p) => p.toTestString()).join(', ')}]`, + ); + await ctx.channel.send( + `Groups:\n\n${groups.map((g) => g.toTestString()).join('\n\n')}`, + ); + } catch (e) { + await ctx.send('❌ An unexpected error occurred. Please try again later.'); + logger.error(`Error in testcase command: ${e}`); + } + } +} diff --git a/packages/bot/src/commands/general.ts b/packages/bot/src/commands/general.ts new file mode 100644 index 00000000..60d2f0f2 --- /dev/null +++ b/packages/bot/src/commands/general.ts @@ -0,0 +1,110 @@ +import * as os from 'os'; +import * as config from '../core/config.js'; + +export interface GeneralContext { + guild: { id: number } | null; + send(content: string, options?: Record): Promise; +} + +export interface VoiceClient { + channel: { members: unknown[] }; + disconnect(options?: { force?: boolean }): Promise; +} + +export interface EmbedData { + title: string; + color: number; + fields: { name: string; value: string; inline?: boolean }[]; + footer?: { text: string }; +} + +export class GeneralHandler { + private startTime: number; + latency: number; + applicationId?: string | null; + + constructor(latency = 0, applicationId?: string | null) { + this.startTime = Date.now() / 1000; + this.latency = latency; + this.applicationId = applicationId; + } + + /** Override start time for testing. */ + _setStartTime(t: number): void { + this.startTime = t; + } + + async version(ctx: GeneralContext): Promise { + let value: string; + if (config.GIT_SHA && config.GIT_SHA.length >= 7) { + const shortSha = config.GIT_SHA.slice(0, 7); + const commitUrl = `https://github.com/${config.GITHUB_REPO_OWNER}/${config.GITHUB_REPO_NAME}/commit/${config.GIT_SHA}`; + value = `[\`${shortSha}\`](${commitUrl})`; + } else { + value = 'unknown'; + } + + const embed: EmbedData = { + title: 'Bot Version', + color: 0x3498db, + fields: [{ name: 'Commit', value, inline: false }], + }; + await ctx.send('', { embed }); + } + + async invite(ctx: GeneralContext): Promise { + const appId = this.applicationId ?? config.DISCORD_APPLICATION_ID; + if (!appId) { + await ctx.send( + '❌ Application ID not available. Set config.DISCORD_APPLICATION_ID in your environment.', + ); + return; + } + const permissions = config.BOT_INVITE_PERMISSIONS; + const url = `https://discord.com/api/oauth2/authorize?client_id=${appId}&scope=bot&permissions=${permissions}`; + await ctx.send(`**Add this bot to a server:**\n${url}`); + } + + async status(ctx: GeneralContext): Promise { + const now = Date.now() / 1000; + const uptimeSeconds = Math.floor(now - this.startTime); + + const hours = Math.floor(uptimeSeconds / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + const seconds = uptimeSeconds % 60; + const uptimeStr = `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + const pingMs = Math.round(this.latency * 1000); + + const embed: EmbedData = { + title: 'Bot Status', + color: 0x2ecc71, + fields: [ + { name: 'Uptime', value: uptimeStr, inline: true }, + { name: 'Ping', value: `${pingMs}ms`, inline: true }, + ], + }; + + try { + const loadavg = os.loadavg(); + embed.fields.push({ + name: 'System Load', + value: `${loadavg[0].toFixed(2)}, ${loadavg[1].toFixed(2)}, ${loadavg[2].toFixed(2)}`, + inline: false, + }); + } catch { + // loadavg not available on some platforms + } + + const serverId = ctx.guild?.id ?? 'DM'; + embed.footer = { text: `Server ID: ${serverId}` }; + + await ctx.send('', { embed }); + } + + async onVoiceStateUpdate(voiceClient: VoiceClient | null): Promise { + if (voiceClient && voiceClient.channel && voiceClient.channel.members.length === 1) { + await voiceClient.disconnect({ force: false }); + } + } +} diff --git a/packages/bot/src/commands/groups.ts b/packages/bot/src/commands/groups.ts new file mode 100644 index 00000000..c0e80187 --- /dev/null +++ b/packages/bot/src/commands/groups.ts @@ -0,0 +1,166 @@ +import { SessionService, type Bot, type Guild } from '../services/sessionService.js'; +import type { GroupService } from '../services/groupService.js'; +import { reportBadGroup } from '../core/issues.js'; +import { ACTIVITY_URL, DISCORD_APPLICATION_ID } from '../core/config.js'; +import logger from '../core/logger.js'; + +export interface GroupsContext { + guild: { id: number } | null; + author: { + id: string | number; + name: string; + voice?: { + channel?: { + id: number; + name: string; + members?: { bot: boolean }[]; + createInvite?(): Promise<{ url: string }>; + } | null; + } | null; + }; + send(content: string, options?: { ephemeral?: boolean }): Promise; + defer(options?: { ephemeral?: boolean }): Promise; + interaction?: { response: { sendModal(modal: unknown): Promise } } | null; +} + +export interface VoiceState { + channel: { id: number; members: { bot: boolean }[] } | null; +} + +export class GroupsHandler { + bot: Bot; + sessionService: SessionService; + groupService: GroupService; + + constructor(bot: Bot, groupService: GroupService, sessionService?: SessionService) { + this.bot = bot; + this.groupService = groupService; + this.sessionService = sessionService ?? new SessionService(bot); + } + + async wheel(ctx: GroupsContext): Promise { + await ctx.defer(); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await this.groupService.coreWheel(ctx as any, false); + } catch (e) { + await ctx.send('❌ An unexpected error occurred. Please try again later.'); + logger.error(`Error in wheel command: ${e}`); + } + } + + async activity(ctx: GroupsContext, debug = false): Promise { + await ctx.defer(); + try { + if (!ctx.author.voice?.channel) { + await ctx.send('❌ You must be in a voice channel to start an activity.'); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await this.sessionService.getOrCreateSession(ctx as any, debug); + if (!result) { + await ctx.send('❌ Failed to create/get session. Is Firebase configured?'); + return; + } + + const [guildId, channelId] = result; + + let inviteUrl = 'N/A'; + if (DISCORD_APPLICATION_ID && ctx.author.voice.channel.createInvite) { + try { + const invite = await ctx.author.voice.channel.createInvite(); + inviteUrl = invite.url; + } catch (e) { + logger.warn(`Failed to create embedded invite: ${e}`); + } + } + + const activityUrlBase = ACTIVITY_URL; + let msg = '🎮 **Join the Activity!**\n'; + msg += `**Voice Channel Activity:** ${inviteUrl}\n`; + + if (activityUrlBase) { + const directLink = `${activityUrlBase}?guildId=${guildId}&channelId=${channelId}`; + msg += `**Browser Link:** [Click Here](${directLink})\n`; + } else { + msg += '⚠️ `ACTIVITY_URL` not set in .env.'; + } + + await ctx.send(msg); + } catch (e) { + await ctx.send('❌ An unexpected error occurred. Please try again later.'); + logger.error(`Error in activity command: ${e}`); + } + } + + async badgroup( + ctx: GroupsContext, + title?: string | null, + description?: string | null, + ): Promise { + const guildId = ctx.guild?.id ?? null; + if (!guildId || !this.groupService.lastResults.has(guildId)) { + await ctx.send( + '❌ No group creation data found for this server. Run /wheel first.', + { ephemeral: true }, + ); + return; + } + + const lastResults = this.groupService.lastResults.get(guildId)!; + + // If slash command without arguments, send modal + if (ctx.interaction && title == null) { + // In actual discord.js: ctx.interaction.response.sendModal(...) + await ctx.interaction.response.sendModal(lastResults); + return; + } + + if (!title || !description) { + await ctx.send( + '❌ Please provide both a title and a description when using the prefix command. ' + + 'Usage: `!badgroup "Title" Description` or use `/badgroup` to open a modal.', + { ephemeral: true }, + ); + return; + } + + await ctx.defer({ ephemeral: true }); + try { + const issue = await reportBadGroup({ + reporterName: ctx.author.name, + reporterId: Number(ctx.author.id), + title, + description, + players: lastResults.players, + groups: lastResults.groups, + }); + await ctx.send(`✅ Bad group reported successfully: ${issue.html_url}`, { ephemeral: true }); + } catch (e) { + logger.error(`Error creating GitHub issue via badgroup command: ${e}`); + await ctx.send(`❌ Failed to create issue: ${e}`, { ephemeral: true }); + } + } + + async onVoiceStateUpdate( + member: { bot: boolean; guild: Guild }, + before: VoiceState, + after: VoiceState, + ): Promise { + if (before.channel?.id === after.channel?.id) return; + + if (before.channel && this.sessionService.activeChannels.has(before.channel.id)) { + const humans = before.channel.members.filter((m) => !m.bot); + if (humans.length === 0) { + await this.sessionService.cleanupChannel(before.channel.id); + } else { + await this.sessionService.updateChannelPlayers(before.channel.id, member.guild); + } + } + + if (after.channel && this.sessionService.activeChannels.has(after.channel.id)) { + await this.sessionService.updateChannelPlayers(after.channel.id, member.guild); + } + } +} diff --git a/packages/bot/src/commands/roles.ts b/packages/bot/src/commands/roles.ts new file mode 100644 index 00000000..9065c286 --- /dev/null +++ b/packages/bot/src/commands/roles.ts @@ -0,0 +1,85 @@ +import { getPreferenceService } from '../core/preferenceService.js'; +import { createRoleBoardEmbed, createRoleCheckEmbed, type PlayerRoleInfo } from '../core/roleUi.js'; +import { getPlayerList, getWowName, type DiscordMember } from '../core/utils.js'; + +export interface RolesContext { + guild: { id: number } | null; + author: DiscordMember & { + id: string | number; + voice?: { channel?: { id: number; members: (DiscordMember & { bot: boolean })[] } | null } | null; + }; + channel: { members: (DiscordMember & { bot: boolean })[] }; + send(content: string, options?: Record): Promise; + interaction?: unknown; +} + +export class RolesHandler { + async launchRoleBoard(ctx: RolesContext): Promise { + if (!ctx.guild) { + await ctx.send('❌ This command can only be used in a server.'); + return; + } + + const targetChannel = ctx.author.voice?.channel ?? ctx.channel; + const members = targetChannel.members.filter((m) => !m.bot); + const players = getPlayerList(members); + const embed = createRoleBoardEmbed(players); + + // In real discord.js, also create an ActionRow with buttons + await ctx.send('', { embed, view: 'role_board' }); + } + + async rolecheck(ctx: RolesContext): Promise { + const channel = ctx.author.voice?.channel ?? ctx.channel; + const members = channel.members.filter((m) => !m.bot); + + if (members.length === 0) { + await ctx.send('No members found in the channel.'); + return; + } + + const prefSvc = getPreferenceService(); + const playerInfos: PlayerRoleInfo[] = []; + + for (const member of members) { + const name = getWowName(member); + const discordId = String(member.id); + let savedRoles = prefSvc.getPreferenceSync(discordId); + if (!savedRoles) { + savedRoles = prefSvc.getPreferenceByNameSync(name); + } + if (savedRoles) { + playerInfos.push({ name, roles: savedRoles }); + } else { + playerInfos.push({ name, roles: ['No roles set'] }); + } + } + + const embed = createRoleCheckEmbed(playerInfos); + await ctx.send('', { embed }); + } + + async clearrole(ctx: RolesContext, name?: string | null): Promise { + const prefSvc = getPreferenceService(); + + if (!name) { + const wowName = getWowName(ctx.author); + const discordId = String(ctx.author.id); + const hadPrefs = prefSvc.getPreferenceSync(discordId) != null; + await prefSvc.clearPreference(discordId); + if (hadPrefs) { + await ctx.send(`✅ Cleared your saved roles, **${wowName}**.`); + } else { + await ctx.send(`❌ You had no saved roles, **${wowName}**.`); + } + } else { + const discordId = prefSvc.resolveDiscordId(name); + if (discordId) { + await prefSvc.clearPreference(discordId); + await ctx.send(`✅ Cleared saved roles for **${name}**.`); + } else { + await ctx.send(`❌ No saved roles found for **${name}**.`); + } + } + } +} diff --git a/packages/bot/src/core/config.ts b/packages/bot/src/core/config.ts new file mode 100644 index 00000000..1b65a04d --- /dev/null +++ b/packages/bot/src/core/config.ts @@ -0,0 +1,51 @@ +import 'dotenv/config'; + +// Re-export shared role constants +export { + ROLE_TANK, + ROLE_HEALER, + ROLE_RANGED, + ROLE_MELEE, + ROLE_TANK_OFFSPEC, + ROLE_HEALER_OFFSPEC, + ROLE_RANGED_OFFSPEC, + ROLE_MELEE_OFFSPEC, + ROLE_BREZ, + ROLE_LUST, + ALL_ROLES, +} from '@mythicplus/shared'; + +// Bot-specific environment variables +export const BOT_TOKEN = process.env.BOT_TOKEN ?? undefined; +export const DISCORD_APPLICATION_ID = process.env.DISCORD_APPLICATION_ID ?? undefined; +export const DEVELOPER_ID = parseInt(process.env.DEVELOPER_ID ?? '202184987469021184', 10); +export const BOT_INVITE_PERMISSIONS = parseInt( + process.env.BOT_INVITE_PERMISSIONS ?? '3263489', + 10, +); + +// GitHub Issue Integration +const rawGithubToken = process.env.GITHUB_TOKEN; +export const GITHUB_TOKEN = rawGithubToken?.trim() || undefined; +export const GITHUB_REPO_OWNER = (process.env.GITHUB_REPO_OWNER ?? 'TytaniumDev').trim(); +export const GITHUB_REPO_NAME = ( + process.env.GITHUB_REPO_NAME ?? 'MythicPlusDiscordBot' +).trim(); + +// Container version (commit SHA baked in at build time) +export const GIT_SHA = process.env.GIT_SHA?.trim() || undefined; + +// Activity Link +export const ACTIVITY_URL = ( + process.env.ACTIVITY_URL ?? 'https://tytaniumdev.github.io/MythicPlusDiscordBot/' +).trim(); + +// Assets +export const PLACEHOLDER_CHAR = ':question:'; + +// Logging +export const LOG_FILE = 'mythic_bot.log'; + +// Firebase Credentials (JSON) +export const FIREBASE_CREDENTIALS_JSON = + process.env.FIREBASE_CREDENTIALS_JSON ?? undefined; diff --git a/packages/bot/src/core/firebaseService.ts b/packages/bot/src/core/firebaseService.ts new file mode 100644 index 00000000..3d267617 --- /dev/null +++ b/packages/bot/src/core/firebaseService.ts @@ -0,0 +1,294 @@ +import { createRequire } from 'node:module'; +import logger from './logger.js'; +import * as config from './config.js'; + +// Sentinel for Firestore server timestamps. +// Replaced with FieldValue.serverTimestamp() at initialization time. +export const SERVER_TIMESTAMP = { __sentinel: 'serverTimestamp' } as const; + +// Firebase Admin SDK types — imported dynamically to allow mocking +type FirebaseDb = { + collection: (name: string) => FirebaseCollection; + batch: () => FirebaseBatch; +}; + +type FirebaseCollection = { + doc: (id: string) => FirebaseDocRef; + where: (field: string, op: string, value: unknown) => FirebaseQuery; + get: () => Promise<{ docs: FirebaseDocSnapshot[] }>; + onSnapshot: (callback: (...args: unknown[]) => void) => unknown; +}; + +type FirebaseQuery = { + get: () => Promise<{ docs: FirebaseDocSnapshot[] }>; +}; + +type FirebaseDocRef = { + get: () => Promise; + set: (data: Record) => Promise; + update: (data: Record) => Promise; + delete: () => Promise; + onSnapshot: (callback: (...args: unknown[]) => void) => unknown; +}; + +type FirebaseDocSnapshot = { + exists: boolean; + id: string; + ref: FirebaseDocRef; + data: () => Record | null; +}; + +type FirebaseBatch = { + delete: (ref: FirebaseDocRef) => void; + commit: () => Promise; +}; + +export interface IFirebaseService { + db: FirebaseDb | null; + isAvailable(): boolean; + getOrCreateGuildDoc( + guildId: number, + guildName?: string, + guildIconUrl?: string, + ): Promise; + updateGuildDoc(guildId: string, data: Record): Promise; + deleteGuildDoc(guildId: string): Promise; + getOrCreateChannelDoc( + channelId: number, + guildId: number, + channelName: string, + debug?: boolean, + ): Promise; + updateChannelDoc(channelId: string, data: Record): Promise; + deleteChannelDoc(channelId: string): Promise; + deleteOldDocs(collection: string, seconds: number): Promise; + deleteAllInCollection(collection: string): Promise; +} + +let instance: FirebaseService | null = null; + +export class FirebaseService implements IFirebaseService { + db: FirebaseDb | null = null; + + constructor() { + this._initializeFirebase(); + } + + static getInstance(): FirebaseService { + if (!instance) { + instance = new FirebaseService(); + } + return instance; + } + + private _initializeFirebase(): void { + try { + if (!config.FIREBASE_CREDENTIALS_JSON) { + logger.warn( + 'FIREBASE_CREDENTIALS_JSON not set. Firebase features will be disabled.', + ); + return; + } + + let credDict: Record; + try { + credDict = JSON.parse(config.FIREBASE_CREDENTIALS_JSON) as Record< + string, + unknown + >; + } catch { + logger.error( + 'Failed to parse FIREBASE_CREDENTIALS_JSON. Ensure the JSON is valid.', + ); + this.db = null; + return; + } + + // Use createRequire for CJS interop in ESM context + const esmRequire = createRequire(import.meta.url); + const admin = esmRequire('firebase-admin'); + const cert = admin.credential.cert(credDict); + try { + admin.initializeApp({ credential: cert }); + } catch { + // App already initialized + } + this.db = admin.firestore() as FirebaseDb; + logger.info('Firebase initialized successfully.'); + } catch (e) { + const errType = e instanceof Error ? e.constructor.name : String(e); + logger.error(`Failed to initialize Firebase: ${errType}`); + this.db = null; + } + } + + isAvailable(): boolean { + return this.db !== null; + } + + // Guild Doc Operations + + async getOrCreateGuildDoc( + guildId: number, + guildName?: string, + guildIconUrl?: string, + ): Promise { + if (!this.db) throw new Error('Firebase is not initialized.'); + + const docId = String(guildId); + const docRef = this.db.collection('guilds').doc(docId); + + const guildFields: Record = {}; + if (guildName !== undefined) guildFields.guildName = guildName; + if (guildIconUrl !== undefined) guildFields.guildIconUrl = guildIconUrl; + + const doc = await docRef.get(); + if (!doc.exists) { + await docRef.set({ + guildId: docId, + voiceChannels: [], + createdAt: SERVER_TIMESTAMP, + lastActive: SERVER_TIMESTAMP, + ...guildFields, + }); + } else { + await docRef.update({ + lastActive: SERVER_TIMESTAMP, + ...guildFields, + }); + } + + return docId; + } + + async updateGuildDoc(guildId: string, data: Record): Promise { + if (!this.db) return; + const docRef = this.db.collection('guilds').doc(guildId); + await docRef.update(data); + } + + async deleteGuildDoc(guildId: string): Promise { + if (!this.db) return; + const docRef = this.db.collection('guilds').doc(guildId); + await docRef.delete(); + logger.debug(`Deleted guild doc ${guildId} from Firestore`); + } + + // Channel Doc Operations + + async getOrCreateChannelDoc( + channelId: number, + guildId: number, + channelName: string, + debug = false, + ): Promise { + if (!this.db) throw new Error('Firebase is not initialized.'); + + const docId = String(channelId); + const docRef = this.db.collection('channels').doc(docId); + + const doc = await docRef.get(); + if (!doc.exists) { + await docRef.set({ + channelId: docId, + channelName, + guildId: String(guildId), + status: 'lobby', + players: [], + groups: [], + isDebug: debug, + announceResults: true, + createdAt: SERVER_TIMESTAMP, + lastActive: SERVER_TIMESTAMP, + }); + } else { + await docRef.update({ + lastActive: SERVER_TIMESTAMP, + status: 'lobby', + groups: [], + isDebug: debug, + }); + } + + return docId; + } + + async updateChannelDoc( + channelId: string, + data: Record, + ): Promise { + if (!this.db) return; + const docRef = this.db.collection('channels').doc(channelId); + await docRef.update(data); + } + + async deleteChannelDoc(channelId: string): Promise { + if (!this.db) return; + const docRef = this.db.collection('channels').doc(channelId); + await docRef.delete(); + logger.debug(`Deleted channel doc ${channelId} from Firestore`); + } + + // Collection Operations + + async deleteOldDocs(collection: string, seconds: number): Promise { + if (!this.db) return 0; + + const db = this.db; + const cutoff = new Date(Date.now() - seconds * 1000); + + const snapshot = await db.collection(collection).where('lastActive', '<', cutoff).get(); + let batch = db.batch(); + let count = 0; + + for (const doc of snapshot.docs) { + batch.delete(doc.ref); + count++; + if (count % 500 === 0) { + await batch.commit(); + batch = db.batch(); + } + } + + if (count % 500 !== 0) { + await batch.commit(); + } + + if (count > 0) { + logger.info( + `Deleted ${count} old doc(s) from ${collection} (older than ${seconds} seconds)`, + ); + } + + return count; + } + + async deleteAllInCollection(collection: string): Promise { + if (!this.db) return 0; + + const db = this.db; + const snapshot = await db.collection(collection).get(); + + let batch = db.batch(); + let count = 0; + + for (const docSnap of snapshot.docs) { + batch.delete(docSnap.ref); + count++; + if (count % 500 === 0) { + await batch.commit(); + batch = db.batch(); + } + } + + if (count % 500 !== 0) { + await batch.commit(); + } + + if (count > 0) { + logger.info(`Deleted all ${count} doc(s) from ${collection} collection`); + } + + return count; + } +} diff --git a/packages/bot/src/core/groupUi.ts b/packages/bot/src/core/groupUi.ts new file mode 100644 index 00000000..40853fc6 --- /dev/null +++ b/packages/bot/src/core/groupUi.ts @@ -0,0 +1,136 @@ +import type { WoWGroup } from '@mythicplus/shared'; +import { PLACEHOLDER_CHAR } from './config.js'; +import { getMaskedName, showShortTyping } from './utils.js'; + +export interface EmbedField { + name: string; + value: string; + inline?: boolean; +} + +export interface Embed { + title: string; + color: number; + fields: EmbedField[]; +} + +export interface Message { + edit(options: { embed: Embed }): Promise; +} + +export interface Sendable { + send(content: string | { embed: Embed }): Promise; +} + +// Gold color value matching discord.js +const GOLD = 0xf1c40f; + +function setFieldAt(embed: Embed, index: number, name: string, value: string): Embed { + embed.fields[index] = { ...embed.fields[index], name, value }; + return embed; +} + +export function buildGroupEmbed(group: WoWGroup, groupNumber: number): Embed { + const tankName = group.tank?.name ?? PLACEHOLDER_CHAR; + const healerName = group.healer?.name ?? PLACEHOLDER_CHAR; + const dps1Name = group.dps.length > 0 ? group.dps[0].name : PLACEHOLDER_CHAR; + const dps2Name = group.dps.length > 1 ? group.dps[1].name : PLACEHOLDER_CHAR; + const dps3Name = group.dps.length > 2 ? group.dps[2].name : PLACEHOLDER_CHAR; + + const allPlayers = group.players; + const brezPlayer = allPlayers.find((p) => p.hasBrez)?.name ?? 'None'; + const lustPlayer = allPlayers.find((p) => p.hasLust)?.name ?? 'None'; + + return { + title: `Group ${groupNumber}`, + color: GOLD, + fields: [ + { name: 'Tank', value: tankName }, + { name: 'Healer', value: healerName }, + { name: 'DPS', value: `${dps1Name}, ${dps2Name}, ${dps3Name}` }, + { name: 'Battle Res', value: brezPlayer, inline: true }, + { name: 'Bloodlust', value: lustPlayer, inline: true }, + ], + }; +} + +interface TypingChannel { + sendTyping(): Promise; +} + +async function animateUpdate( + message: Message, + channel: TypingChannel, + embed: Embed, + index: number, + name: string, + value: string, + debug: boolean, +): Promise { + await showShortTyping(channel, debug); + return message.edit({ embed: setFieldAt(embed, index, name, value) }); +} + +export async function announceGroup( + ctx: Sendable, + channel: TypingChannel, + group: WoWGroup, + groupNumber: number, + debug: boolean, +): Promise { + if (debug) { + const embed = buildGroupEmbed(group, groupNumber); + await ctx.send({ embed }); + } else { + const tankName = group.tank?.name ?? PLACEHOLDER_CHAR; + const healerName = group.healer?.name ?? PLACEHOLDER_CHAR; + const dps1Name = group.dps.length > 0 ? group.dps[0].name : PLACEHOLDER_CHAR; + const dps2Name = group.dps.length > 1 ? group.dps[1].name : PLACEHOLDER_CHAR; + const dps3Name = group.dps.length > 2 ? group.dps[2].name : PLACEHOLDER_CHAR; + + const allPlayers = group.players; + const brezPlayer = allPlayers.find((p) => p.hasBrez)?.name ?? 'None'; + const lustPlayer = allPlayers.find((p) => p.hasLust)?.name ?? 'None'; + + const embed: Embed = { + title: `Group ${groupNumber}`, + color: GOLD, + fields: [ + { name: 'Tank', value: getMaskedName(tankName) }, + { name: 'Healer', value: getMaskedName(healerName) }, + { + name: 'DPS', + value: `${getMaskedName(dps1Name)}, ${getMaskedName(dps2Name)}, ${getMaskedName(dps3Name)}`, + }, + { name: 'Battle Res', value: getMaskedName(brezPlayer), inline: true }, + { name: 'Bloodlust', value: getMaskedName(lustPlayer), inline: true }, + ], + }; + + let embedMessage = await ctx.send({ embed }); + embedMessage = await animateUpdate( + embedMessage, channel, embed, 0, 'Tank', tankName, debug, + ); + embedMessage = await animateUpdate( + embedMessage, channel, embed, 1, 'Healer', healerName, debug, + ); + embedMessage = await animateUpdate( + embedMessage, channel, embed, 2, 'DPS', + `${dps1Name}, ${getMaskedName(dps2Name)}, ${getMaskedName(dps3Name)}`, debug, + ); + embedMessage = await animateUpdate( + embedMessage, channel, embed, 2, 'DPS', + `${dps1Name}, ${dps2Name}, ${getMaskedName(dps3Name)}`, debug, + ); + embedMessage = await animateUpdate( + embedMessage, channel, embed, 2, 'DPS', + `${dps1Name}, ${dps2Name}, ${dps3Name}`, debug, + ); + embedMessage = await embedMessage.edit({ + embed: setFieldAt(embed, 3, 'Battle Res', brezPlayer), + }); + await embedMessage.edit({ + embed: setFieldAt(embed, 4, 'Bloodlust', lustPlayer), + }); + } +} diff --git a/packages/bot/src/core/issues.ts b/packages/bot/src/core/issues.ts new file mode 100644 index 00000000..6a49cac3 --- /dev/null +++ b/packages/bot/src/core/issues.ts @@ -0,0 +1,214 @@ +import fs from 'node:fs'; +import * as config from './config.js'; +import logger from './logger.js'; +import { sanitizeForGithub, sanitizeLogs } from './security.js'; + +export class GitHubError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitHubError'; + } +} + +async function getRecentLogs(): Promise { + if (fs.existsSync(config.LOG_FILE)) { + try { + const content = fs.readFileSync(config.LOG_FILE, 'utf-8'); + const lines = content.split('\n'); + return lines.slice(-50).join('\n'); + } catch (e) { + const errType = e instanceof Error ? e.constructor.name : String(e); + logger.error(`Failed to read logs: ${errType}`); + } + } + return null; +} + +export function getVersionString(): string { + if (config.GIT_SHA && config.GIT_SHA.length >= 7) { + const shortSha = config.GIT_SHA.slice(0, 7); + const commitUrl = `https://github.com/${config.GITHUB_REPO_OWNER}/${config.GITHUB_REPO_NAME}/commit/${config.GIT_SHA}`; + return `[\`${shortSha}\`](${commitUrl})`; + } + return 'unknown'; +} + +export async function createGithubIssue( + title: string, + body: string, + labels: string[], +): Promise> { + if (!config.GITHUB_TOKEN || !config.GITHUB_REPO_OWNER || !config.GITHUB_REPO_NAME) { + throw new GitHubError( + 'GitHub configuration is missing. Please check your .env file.', + ); + } + + const url = `https://api.github.com/repos/${config.GITHUB_REPO_OWNER}/${config.GITHUB_REPO_NAME}/issues`; + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `token ${config.GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title, body, labels }), + }); + + if (response.status === 201) { + return (await response.json()) as Record; + } + throw new GitHubError(`Failed to create issue: HTTP ${response.status}`); +} + +export async function searchGithubIssues( + errorType: string, +): Promise | null> { + if (!config.GITHUB_TOKEN || !config.GITHUB_REPO_OWNER || !config.GITHUB_REPO_NAME) { + return null; + } + + const query = `repo:${config.GITHUB_REPO_OWNER}/${config.GITHUB_REPO_NAME} is:issue is:open label:auto-error ${errorType} in:title`; + const url = `https://api.github.com/search/issues?q=${encodeURIComponent(query)}`; + + try { + const response = await fetch(url, { + headers: { + Authorization: `token ${config.GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (response.status === 200) { + const data = (await response.json()) as Record; + const items = (data.items ?? []) as Record[]; + const totalCount = (data.total_count ?? 0) as number; + if (totalCount > 0 && items.length > 0) { + return items[0]; + } + } + } catch (e) { + logger.warn(`Failed to search for existing GitHub issues: ${e}`); + } + return null; +} + +export async function createErrorIssue( + error: Error, + contextInfo: string, +): Promise | null> { + if (!config.GITHUB_TOKEN) return null; + + try { + const errorType = error.constructor.name; + + const existing = await searchGithubIssues(errorType); + if (existing) { + logger.info( + `Skipping auto-error issue: existing open issue found for ${errorType}`, + ); + return existing; + } + + const errorMsg = sanitizeForGithub(String(error)); + const maxTitleMsg = 60; + const shortMsg = + errorMsg.length > maxTitleMsg ? errorMsg.slice(0, maxTitleMsg) + '...' : errorMsg; + const title = `[Auto] ${errorType}: ${shortMsg}`; + + const safeContext = sanitizeForGithub(contextInfo); + const safeTraceback = sanitizeForGithub(error.stack ?? ''); + const versionStr = getVersionString(); + + let body = + `**Version:** ${versionStr}\n\n` + + `**Context:**\n\`\`\`\n${safeContext}\n\`\`\`\n\n` + + `**Error:** \`${errorType}: ${errorMsg}\`\n\n` + + `**Traceback:**\n\`\`\`\n${safeTraceback}\n\`\`\`\n`; + + const lastLines = await getRecentLogs(); + if (lastLines) { + const safeLogs = sanitizeForGithub(lastLines); + body += `\n**Recent Logs:**\n\`\`\`log\n${safeLogs}\n\`\`\`\n`; + } + + return await createGithubIssue(title, body, ['bug', 'auto-error']); + } catch (e) { + const errType = e instanceof Error ? e.constructor.name : String(e); + logger.error(`Failed to create automatic error issue: ${errType}`); + return null; + } +} + +export interface GitHubIssueModalData { + issueType: 'bug' | 'feature'; + title: string; + description: string; + extraInfo: string; + includeLogs: boolean; + reporterName: string; + reporterId: string | number; +} + +export async function submitGithubIssueModal( + data: GitHubIssueModalData, +): Promise> { + const versionStr = getVersionString(); + + let body = + `**Reporter:** ${data.reporterName} (\`${data.reporterId}\`)\n` + + `**Version:** ${versionStr}\n\n` + + `**Description:**\n${data.description}\n`; + + if (data.extraInfo) { + const sectionTitle = + data.issueType === 'bug' ? 'Reproduction Steps' : 'Benefit/Impact'; + body += `\n**${sectionTitle}:**\n${data.extraInfo}\n`; + } + + if (data.issueType === 'bug' && data.includeLogs) { + const lastLines = await getRecentLogs(); + if (lastLines) { + const sanitizedLines = sanitizeLogs(lastLines); + body += `\n**Recent Logs:**\n\`\`\`log\n${sanitizedLines}\n\`\`\`\n`; + } + } + + const labels = + data.issueType === 'bug' ? ['bug', 'jules'] : ['enhancement', 'jules']; + return createGithubIssue(data.title, body, labels); +} + +export interface BadGroupReportData { + reporterName: string; + reporterId: string | number; + title: string; + description: string; + players: { toTestString(): string }[]; + groups: { toTestString(): string }[]; +} + +export async function reportBadGroup( + data: BadGroupReportData, +): Promise> { + const formattedTitle = `[Bad Group] ${data.title}`; + const versionStr = getVersionString(); + + const reproInfo = + `**Input Players:**\n\`\`\`python\n[${data.players.map((p) => p.toTestString()).join(', ')}]\n\`\`\`\n` + + `**Resulting Groups:**\n\`\`\`python\n[${data.groups.map((g) => g.toTestString()).join(', ')}]\n\`\`\`\n`; + + let body = + `**Reporter:** ${data.reporterName} (\`${data.reporterId}\`)\n` + + `**Version:** ${versionStr}\n\n` + + `**Description:**\n${data.description}\n\n` + + reproInfo; + + const lastLines = await getRecentLogs(); + if (lastLines) { + const sanitizedLines = sanitizeLogs(lastLines); + body += `\n**Recent Logs:**\n\`\`\`log\n${sanitizedLines}\n\`\`\`\n`; + } + + return createGithubIssue(formattedTitle, body, ['bug', 'bad-group']); +} diff --git a/packages/bot/src/core/logger.ts b/packages/bot/src/core/logger.ts new file mode 100644 index 00000000..8ffff08a --- /dev/null +++ b/packages/bot/src/core/logger.ts @@ -0,0 +1,22 @@ +import winston from 'winston'; +import { LOG_FILE } from './config.js'; + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL ?? 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.printf(({ timestamp, level, message }) => { + return `${timestamp as string} [${level.toUpperCase()}] ${message as string}`; + }), + ), + transports: [ + new winston.transports.Console(), + new winston.transports.File({ + filename: LOG_FILE, + maxsize: 5 * 1024 * 1024, // 5MB + maxFiles: 3, + }), + ], +}); + +export default logger; diff --git a/packages/bot/src/core/preferenceService.ts b/packages/bot/src/core/preferenceService.ts new file mode 100644 index 00000000..aaabd15c --- /dev/null +++ b/packages/bot/src/core/preferenceService.ts @@ -0,0 +1,233 @@ +import logger from './logger.js'; +import { FirebaseService, SERVER_TIMESTAMP } from './firebaseService.js'; +import { + clearPlayerPreference, + getAllPreferences, + getPlayerPreference, + setPlayerPreference, +} from './storage.js'; + +export interface IPreferenceService { + loadCache(): Promise; + getPreference(discordId: string): Promise; + setPreference(discordId: string, name: string, roles: string[]): Promise; + clearPreference(discordId: string): Promise; + refreshPreference(discordId: string): Promise; + getPreferenceSync(discordId: string): string[] | null; + getPreferenceByNameSync(name: string): string[] | null; + resolveDiscordId(name: string): string | null; +} + +let _instance: PreferenceService | null = null; + +export function getPreferenceService(): PreferenceService { + if (!_instance) { + _instance = new PreferenceService(); + } + return _instance; +} + +/** Reset singleton — for testing only. */ +export function _resetInstance(): void { + _instance = null; +} + +export class PreferenceService implements IPreferenceService { + firebase: FirebaseService; + private _cache: Record = {}; + private _nameToId: Record = {}; + + constructor(firebase?: FirebaseService) { + this.firebase = firebase ?? FirebaseService.getInstance(); + } + + private _firebaseOk(): boolean { + return this.firebase.isAvailable(); + } + + // Async Methods (Firestore + fallback) + + async loadCache(): Promise { + if (!this._firebaseOk()) { + this._loadFromLocal(); + return; + } + + try { + const docs = await this._fetchAllPreferences(); + for (const [discordId, data] of Object.entries(docs)) { + const roles = (data.roles as string[]) ?? []; + const name = (data.wowName as string) ?? ''; + this._cache[discordId] = roles; + if (name) this._nameToId[name] = discordId; + } + logger.info(`Loaded ${Object.keys(docs).length} preferences from Firestore`); + } catch { + logger.error('Failed to load preferences from Firestore, using local'); + this._loadFromLocal(); + } + } + + private _loadFromLocal(): void { + const local = getAllPreferences(); + for (const [name, roles] of Object.entries(local)) { + this._nameToId[name] = name; + this._cache[name] = roles; + } + } + + private async _fetchAllPreferences(): Promise< + Record> + > { + if (!this.firebase.db) return {}; + + const result: Record> = {}; + const db = this.firebase.db; + const snapshot = await db.collection('preferences').get(); + for (const docSnap of snapshot.docs) { + const data = docSnap.data(); + if (data !== null) { + result[docSnap.id] = data; + } + } + return result; + } + + async getPreference(discordId: string): Promise { + if (discordId in this._cache) { + return this._cache[discordId]; + } + + if (this._firebaseOk()) { + try { + const data = await this._readFirestorePref(discordId); + if (data !== null) { + const roles = (data.roles as string[]) ?? []; + const name = (data.wowName as string) ?? ''; + this._cache[discordId] = roles; + if (name) this._nameToId[name] = discordId; + return roles; + } + } catch { + logger.error(`Firestore read failed for ${discordId}`); + } + } + + return null; + } + + async setPreference(discordId: string, name: string, roles: string[]): Promise { + this._cache[discordId] = roles; + this._nameToId[name] = discordId; + + if (this._firebaseOk()) { + try { + await this._writeFirestorePref(discordId, name, roles); + } catch { + logger.error(`Firestore write failed for ${discordId}`); + } + } + + // Local JSON backup + setPlayerPreference(name, roles); + } + + async clearPreference(discordId: string): Promise { + // Find name for local cleanup + const name = + Object.entries(this._nameToId).find(([, did]) => did === discordId)?.[0] ?? null; + + Reflect.deleteProperty(this._cache, discordId); + this._clearNameMapping(discordId); + if (name) clearPlayerPreference(name); + + if (this._firebaseOk()) { + try { + await this._deleteFirestorePref(discordId); + } catch { + logger.error(`Firestore delete failed for ${discordId}`); + } + } + } + + // Sync Methods (cache-only, hot-path reads) + + private _clearNameMapping(discordId: string): void { + for (const [n, did] of Object.entries(this._nameToId)) { + if (did === discordId) { + Reflect.deleteProperty(this._nameToId, n); + break; + } + } + } + + async refreshPreference(discordId: string): Promise { + if (!this._firebaseOk()) return; + try { + const data = await this._readFirestorePref(discordId); + if (data !== null) { + const roles = (data.roles as string[]) ?? []; + const name = (data.wowName as string) ?? ''; + this._cache[discordId] = roles; + this._clearNameMapping(discordId); + if (name) this._nameToId[name] = discordId; + } else { + Reflect.deleteProperty(this._cache, discordId); + this._clearNameMapping(discordId); + } + } catch { + logger.error(`Firestore refresh failed for ${discordId}`); + } + } + + getPreferenceSync(discordId: string): string[] | null { + return this._cache[discordId] ?? null; + } + + getPreferenceByNameSync(name: string): string[] | null { + const discordId = this._nameToId[name]; + if (discordId) { + return this._cache[discordId] ?? null; + } + // Fallback to local JSON + return getPlayerPreference(name); + } + + resolveDiscordId(name: string): string | null { + return this._nameToId[name] ?? null; + } + + // Firestore CRUD helpers — exposed for testing + + async _readFirestorePref( + discordId: string, + ): Promise | null> { + if (!this.firebase.db) return null; + const ref = this.firebase.db.collection('preferences').doc(discordId); + const docSnap = await ref.get(); + if (docSnap.exists) { + return docSnap.data() as Record; + } + return null; + } + + async _writeFirestorePref( + discordId: string, + name: string, + roles: string[], + ): Promise { + if (!this.firebase.db) return; + const ref = this.firebase.db.collection('preferences').doc(discordId); + await ref.set({ + roles, + wowName: name, + updatedAt: SERVER_TIMESTAMP, + }); + } + + async _deleteFirestorePref(discordId: string): Promise { + if (!this.firebase.db) return; + const ref = this.firebase.db.collection('preferences').doc(discordId); + await ref.delete(); + } +} diff --git a/packages/bot/src/core/roleUi.ts b/packages/bot/src/core/roleUi.ts new file mode 100644 index 00000000..734e62c3 --- /dev/null +++ b/packages/bot/src/core/roleUi.ts @@ -0,0 +1,308 @@ +import type { WoWPlayer } from '@mythicplus/shared'; +import { + ROLE_BREZ, + ROLE_HEALER, + ROLE_HEALER_OFFSPEC, + ROLE_LUST, + ROLE_MELEE, + ROLE_MELEE_OFFSPEC, + ROLE_RANGED, + ROLE_RANGED_OFFSPEC, + ROLE_TANK, + ROLE_TANK_OFFSPEC, +} from './config.js'; + +export interface PlayerRoleInfo { + name: string; + roles: string[]; +} + +export type ButtonStyle = 'primary' | 'secondary' | 'success'; + +export interface RoleButtonData { + roleName: string; + label: string; + style: ButtonStyle; + customId: string; + row: number; + isMainSpec: boolean; +} + +export interface NextButtonData { + label: string; + style: ButtonStyle; + customId: string; + row: number; + disabled: boolean; +} + +export interface NoneButtonData { + label: string; + style: ButtonStyle; + customId: string; + row: number; + clearRoles: ReadonlySet; +} + +export interface RoleSelectionState { + playerName: string; + discordId: string; + selectedRoles: Set; + onSaveCallback?: ((interaction: unknown) => Promise) | null; + views: ViewData[]; + stepContents: string[]; +} + +export interface ViewData { + type: 'main' | 'offspec' | 'utilities'; + buttons: (RoleButtonData | NextButtonData | NoneButtonData)[]; + stopped: boolean; +} + +// -- Role button callback logic (pure, testable) -- + +export function handleRoleButtonClick( + state: RoleSelectionState, + viewButtons: (RoleButtonData | NextButtonData | NoneButtonData)[], + roleName: string, + isMainSpec: boolean, +): void { + const btn = viewButtons.find( + (b) => 'roleName' in b && b.roleName === roleName, + ) as RoleButtonData | undefined; + if (!btn) return; + + if (state.selectedRoles.has(roleName)) { + state.selectedRoles.delete(roleName); + btn.style = 'secondary'; + } else { + if (isMainSpec) { + for (const item of viewButtons) { + if ('roleName' in item && item.roleName !== roleName) { + state.selectedRoles.delete(item.roleName); + item.style = 'secondary'; + } + } + } + state.selectedRoles.add(roleName); + btn.style = 'primary'; + + // Deselect NoneButton sibling if present + for (const item of viewButtons) { + if ('clearRoles' in item) { + (item as NoneButtonData).style = 'secondary'; + } + } + } +} + +export function handleNoneButtonClick( + state: RoleSelectionState, + viewButtons: (RoleButtonData | NextButtonData | NoneButtonData)[], + clearRoles: ReadonlySet, +): void { + for (const role of clearRoles) { + state.selectedRoles.delete(role); + } + for (const item of viewButtons) { + if ('roleName' in item) { + (item as RoleButtonData).style = 'secondary'; + } + } + const noneBtn = viewButtons.find((b) => 'clearRoles' in b) as + | NoneButtonData + | undefined; + if (noneBtn) noneBtn.style = 'primary'; +} + +export function handleNextButtonClick( + state: RoleSelectionState, + viewButtons: (RoleButtonData | NextButtonData | NoneButtonData)[], +): { nextView: ViewData; content: string } | null { + const nextBtn = viewButtons.find( + (b) => 'disabled' in b && 'label' in b && (b as NextButtonData).label === 'Next →', + ) as NextButtonData | undefined; + if (!nextBtn) return null; + + // Find current view index + const currentView = state.views.find((v) => v.buttons === viewButtons); + if (!currentView) return null; + const idx = state.views.indexOf(currentView); + const nextIdx = idx + 1; + if (nextIdx >= state.views.length) return null; + + nextBtn.disabled = true; + return { + nextView: state.views[nextIdx], + content: state.stepContents[nextIdx], + }; +} + +// -- View creation functions -- + +const OFFSPEC_ROLES = new Set([ + ROLE_TANK_OFFSPEC, + ROLE_HEALER_OFFSPEC, + ROLE_RANGED_OFFSPEC, + ROLE_MELEE_OFFSPEC, +]); + +export function createMainSpecView( + state: RoleSelectionState, + prefix: string, +): ViewData { + const buttons: (RoleButtonData | NextButtonData)[] = []; + for (const [roleId, label] of [ + [ROLE_TANK, '🛡️ Tank'], + [ROLE_HEALER, '🌿 Healer'], + [ROLE_RANGED, '🏹 Ranged'], + [ROLE_MELEE, '🪓 Melee'], + ] as const) { + buttons.push({ + roleName: roleId, + label, + style: state.selectedRoles.has(roleId) ? 'primary' : 'secondary', + customId: `${prefix}:${roleId}`, + row: 0, + isMainSpec: true, + }); + } + buttons.push({ + label: 'Next →', + style: 'primary', + customId: `${prefix}:next`, + row: 1, + disabled: false, + }); + return { type: 'main', buttons, stopped: false }; +} + +export function createOffspecView( + state: RoleSelectionState, + prefix: string, +): ViewData { + const buttons: (RoleButtonData | NextButtonData | NoneButtonData)[] = []; + for (const [roleId, label] of [ + [ROLE_TANK_OFFSPEC, '🛡️ Tank'], + [ROLE_HEALER_OFFSPEC, '🌿 Healer'], + [ROLE_RANGED_OFFSPEC, '🏹 Ranged'], + [ROLE_MELEE_OFFSPEC, '🪓 Melee'], + ] as const) { + buttons.push({ + roleName: roleId, + label, + style: state.selectedRoles.has(roleId) ? 'primary' : 'secondary', + customId: `${prefix}:${roleId}`, + row: 0, + isMainSpec: false, + }); + } + + const hasOffspec = [...state.selectedRoles].some((r) => OFFSPEC_ROLES.has(r)); + buttons.push({ + label: 'None', + style: hasOffspec ? 'secondary' : 'primary', + customId: `${prefix}:none`, + row: 0, + clearRoles: OFFSPEC_ROLES, + }); + buttons.push({ + label: 'Next →', + style: 'primary', + customId: `${prefix}:next`, + row: 1, + disabled: false, + }); + return { type: 'offspec', buttons, stopped: false }; +} + +export function createUtilitiesView( + state: RoleSelectionState, + prefix: string, +): ViewData { + const buttons: RoleButtonData[] = []; + for (const [roleId, label] of [ + [ROLE_BREZ, '⚰️ Brez'], + [ROLE_LUST, '🎺 Lust'], + ] as const) { + buttons.push({ + roleName: roleId, + label, + style: state.selectedRoles.has(roleId) ? 'primary' : 'secondary', + customId: `${prefix}:${roleId}`, + row: 0, + isMainSpec: false, + }); + } + return { type: 'utilities', buttons, stopped: false }; +} + +// -- Embed building -- + +export interface EmbedData { + title: string; + description?: string; + color: number; + fields: { name: string; value: string; inline?: boolean }[]; + footer?: string; +} + +const GOLD = 0xf1c40f; +const BLUE = 0x3498db; + +export function createRoleBoardEmbed(players: WoWPlayer[]): EmbedData { + function formatPlayer(p: WoWPlayer): string { + let icons = ''; + if (p.hasBrez) icons += '⚰️'; + if (p.hasLust) icons += '🎺'; + return `${p.name} ${icons}`.trim(); + } + + const tanks = players.filter((p) => p.tankMain).map(formatPlayer); + const healers = players.filter((p) => p.healerMain).map(formatPlayer); + const melee = players.filter((p) => p.melee).map(formatPlayer); + const ranged = players.filter((p) => p.ranged).map(formatPlayer); + + const formatList = (names: string[]) => (names.length > 0 ? names.join('\n') : '-'); + + const fields = [ + { name: `🛡️ Tank (${tanks.length})`, value: formatList(tanks), inline: true }, + { name: `🌿 Healer (${healers.length})`, value: formatList(healers), inline: true }, + { name: '\u200b', value: '\u200b', inline: true }, + { name: `🪓 Melee (${melee.length})`, value: formatList(melee), inline: true }, + { name: `🏹 Ranged (${ranged.length})`, value: formatList(ranged), inline: true }, + { name: '\u200b', value: '\u200b', inline: true }, + ]; + + const unassigned = players.filter((p) => !p.hasRoles()).map(formatPlayer); + if (unassigned.length > 0) { + fields.push({ + name: `Unassigned (${unassigned.length})`, + value: formatList(unassigned), + inline: true, + }); + } + + const brezCount = players.filter((p) => p.hasBrez).length; + const lustCount = players.filter((p) => p.hasLust).length; + + return { + title: 'Mythic+ Role Board', + description: 'Current channel roster', + color: GOLD, + fields, + footer: `Utilities: ${brezCount} Brez, ${lustCount} Lust`, + }; +} + +export function createRoleCheckEmbed(playerInfos: PlayerRoleInfo[]): EmbedData { + return { + title: 'Saved Roles Check', + color: BLUE, + fields: playerInfos.map((info) => ({ + name: info.name, + value: info.roles.join(', '), + inline: false, + })), + }; +} diff --git a/packages/bot/src/core/security.ts b/packages/bot/src/core/security.ts new file mode 100644 index 00000000..05b7fedd --- /dev/null +++ b/packages/bot/src/core/security.ts @@ -0,0 +1,52 @@ +import * as config from './config.js'; + +export function obfuscatePii(text: string): string { + if (!text) return text; + + // 1. Discord object repr: + text = text.replace( + /<(\w+)\s+id=\d{15,21}\s+name='.*?'/g, + "<$1 id=[REDACTED_ID] name='[REDACTED]'", + ); + + // 2. "User: Name (ID)" context lines + text = text.replace( + /(User:\s*).+\s\(\d{15,21}\)/g, + '$1[REDACTED_USER] ([REDACTED_ID])', + ); + + // 3. Username#Discriminator (legacy format) + text = text.replace(/.+#\d{4}/g, '[REDACTED_USER]'); + + // 4. "Channel: …" context lines + text = text.replace(/(Channel:\s*).+/g, '$1[REDACTED_CHANNEL]'); + + // 5. Bare Discord snowflake IDs (catch-all, last) + text = text.replace(/\b\d{17,20}\b/g, '[REDACTED_ID]'); + + return text; +} + +export function sanitizeLogs(logs: string | null): string | null { + if (!logs) return logs; + + if (config.BOT_TOKEN) { + logs = logs.replaceAll(config.BOT_TOKEN, '[REDACTED_BOT_TOKEN]'); + } + + if (config.FIREBASE_CREDENTIALS_JSON) { + logs = logs.replaceAll(config.FIREBASE_CREDENTIALS_JSON, '[REDACTED_FIREBASE_CREDENTIALS]'); + } + + if (config.GITHUB_TOKEN) { + logs = logs.replaceAll(config.GITHUB_TOKEN, '[REDACTED_GITHUB_TOKEN]'); + } + + return logs; +} + +export function sanitizeForGithub(text: string): string { + const result = sanitizeLogs(text); + if (result === null) return ''; + return obfuscatePii(result); +} diff --git a/packages/bot/src/core/storage.ts b/packages/bot/src/core/storage.ts new file mode 100644 index 00000000..e96a17f0 --- /dev/null +++ b/packages/bot/src/core/storage.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import logger from './logger.js'; + +const PREFERENCES_PATH = process.env.PREFERENCES_PATH; +const DATA_DIR = process.env.DATA_DIR; + +export const STORAGE_FILE: string = + PREFERENCES_PATH ?? + (DATA_DIR ? path.join(DATA_DIR, 'player_preferences.json') : null) ?? + 'player_preferences.json'; + +let preferencesCache: Record | null = null; + +function ensureLoaded(): void { + if (preferencesCache !== null) return; + + if (fs.existsSync(STORAGE_FILE)) { + try { + const raw = fs.readFileSync(STORAGE_FILE, 'utf-8'); + preferencesCache = JSON.parse(raw) as Record; + } catch (e) { + const errType = e instanceof Error ? e.constructor.name : String(e); + logger.error(`Error loading preferences: ${errType}`); + preferencesCache = {}; + } + } else { + preferencesCache = {}; + } +} + +export function loadPreferences(): Record { + ensureLoaded(); + return { ...preferencesCache! }; +} + +export function savePreferences(preferences: Record): void { + try { + preferencesCache = preferences; + fs.writeFileSync(STORAGE_FILE, JSON.stringify(preferences, null, 4)); + } catch (e) { + const errType = e instanceof Error ? e.constructor.name : String(e); + logger.error(`Error saving preferences: ${errType}`); + } +} + +export function getPlayerPreference(playerName: string): string[] | null { + ensureLoaded(); + return preferencesCache![playerName] ?? null; +} + +export function setPlayerPreference(playerName: string, roles: string[]): void { + const prefs = loadPreferences(); + prefs[playerName] = roles; + savePreferences(prefs); +} + +export function clearPlayerPreference(playerName: string): boolean { + const prefs = loadPreferences(); + if (playerName in prefs) { + Reflect.deleteProperty(prefs, playerName); + savePreferences(prefs); + return true; + } + return false; +} + +export function getAllPreferences(): Record { + return loadPreferences(); +} + +/** Reset cache — for testing only. */ +export function _resetCache(): void { + preferencesCache = null; +} diff --git a/packages/bot/src/core/utils.ts b/packages/bot/src/core/utils.ts new file mode 100644 index 00000000..9314119a --- /dev/null +++ b/packages/bot/src/core/utils.ts @@ -0,0 +1,99 @@ +import { WoWPlayer } from '@mythicplus/shared'; +import { + ROLE_BREZ, + ROLE_HEALER, + ROLE_HEALER_OFFSPEC, + ROLE_LUST, + ROLE_MELEE, + ROLE_MELEE_OFFSPEC, + ROLE_RANGED, + ROLE_TANK, + ROLE_TANK_OFFSPEC, +} from './config.js'; +import { getPreferenceService } from './preferenceService.js'; + +export interface DiscordMember { + nick?: string | null; + global_name?: string | null; + id: string | number; + toString(): string; +} + +export interface TypingChannel { + sendTyping(): Promise; +} + +export function getWowName(member: DiscordMember): string { + const nick = member.nick; + let rawName: string; + + if (nick != null) { + rawName = String(nick); + } else if (member.global_name != null) { + rawName = member.global_name; + } else { + rawName = String(member); + } + + return rawName.replace(/\./g, ''); +} + +export async function showLongTyping( + channel: TypingChannel, + debugMode = false, +): Promise { + if (!debugMode) { + await channel.sendTyping(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +} + +export async function showShortTyping( + channel: TypingChannel, + debugMode = false, +): Promise { + if (!debugMode) { + await channel.sendTyping(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +export function getMaskedName(name: string): string { + return '?'.repeat(name.length); +} + +export function getPlayerFromMember(member: DiscordMember): WoWPlayer { + const name = getWowName(member); + const discordId = String(member.id); + const prefSvc = getPreferenceService(); + let savedRoles = prefSvc.getPreferenceSync(discordId); + if (!savedRoles) { + savedRoles = prefSvc.getPreferenceByNameSync(name); + } + if (savedRoles) { + return WoWPlayer.create(name, savedRoles, discordId); + } + return WoWPlayer.fromFlags({ name, discordId }); +} + +export function getPlayerList(members: DiscordMember[]): WoWPlayer[] { + return members.map(getPlayerFromMember); +} + +export function getDebugPlayers(): WoWPlayer[] { + return [ + WoWPlayer.create('Martz', [ROLE_HEALER, ROLE_TANK_OFFSPEC, ROLE_MELEE_OFFSPEC, ROLE_BREZ]), + WoWPlayer.create('KingofSkillz', [ROLE_RANGED, ROLE_LUST]), + WoWPlayer.create('chaoswaffles', [ROLE_MELEE, ROLE_TANK_OFFSPEC]), + WoWPlayer.create('Upartyhardy', [ROLE_RANGED]), + WoWPlayer.create('Pandemonium', [ROLE_TANK, ROLE_MELEE_OFFSPEC, ROLE_BREZ]), + WoWPlayer.create('Will', [ROLE_MELEE]), + WoWPlayer.create('Tytanium', [ROLE_RANGED, ROLE_HEALER_OFFSPEC, ROLE_LUST]), + WoWPlayer.create('hammer13', [ROLE_MELEE]), + WoWPlayer.create('Ultra9', [ROLE_RANGED, ROLE_LUST]), + WoWPlayer.create('DrZoidberg', [ROLE_RANGED]), + WoWPlayer.create('Player1x', [ROLE_RANGED, ROLE_HEALER_OFFSPEC, ROLE_LUST]), + WoWPlayer.create('lizardtotem', [ROLE_HEALER, ROLE_MELEE_OFFSPEC]), + WoWPlayer.create('rorschach128', [ROLE_MELEE]), + ]; +} diff --git a/packages/bot/src/events/ready.ts b/packages/bot/src/events/ready.ts new file mode 100644 index 00000000..19d2588c --- /dev/null +++ b/packages/bot/src/events/ready.ts @@ -0,0 +1,21 @@ +import { FirebaseService } from '../core/firebaseService.js'; +import { getPreferenceService } from '../core/preferenceService.js'; +import logger from '../core/logger.js'; + +/** Age in seconds beyond which abandoned docs are deleted on bot startup (24 hours). */ +const FIREBASE_DOC_MAX_AGE_SECONDS = 24 * 60 * 60; + +export async function onReady(): Promise { + // Initialize preference service and load cache from Firestore + const prefSvc = getPreferenceService(); + await prefSvc.loadCache(); + + // Clean up abandoned docs (e.g. frontend closed without completing). + const firebase = FirebaseService.getInstance(); + if (firebase.isAvailable()) { + await firebase.deleteOldDocs('guilds', FIREBASE_DOC_MAX_AGE_SECONDS); + await firebase.deleteOldDocs('channels', FIREBASE_DOC_MAX_AGE_SECONDS); + } + + logger.info('Bot ready — preference cache loaded and old docs cleaned up.'); +} diff --git a/packages/bot/src/events/voiceStateUpdate.ts b/packages/bot/src/events/voiceStateUpdate.ts new file mode 100644 index 00000000..b2b82eaf --- /dev/null +++ b/packages/bot/src/events/voiceStateUpdate.ts @@ -0,0 +1,26 @@ +import type { GroupsHandler, VoiceState } from '../commands/groups.js'; +import type { GeneralHandler, VoiceClient } from '../commands/general.js'; +import type { Guild } from '../services/sessionService.js'; + +export interface VoiceStateUpdateMember { + bot: boolean; + guild: Guild & { voiceClient?: VoiceClient | null }; +} + +/** + * Delegates voice state updates to both the groups handler (session tracking) + * and the general handler (auto-disconnect when alone). + */ +export async function onVoiceStateUpdate( + member: VoiceStateUpdateMember, + before: VoiceState, + after: VoiceState, + groupsHandler: GroupsHandler, + generalHandler: GeneralHandler, +): Promise { + // Groups: track active channel changes for Firebase sessions + await groupsHandler.onVoiceStateUpdate(member, before, after); + + // General: auto-disconnect when bot is alone in channel + await generalHandler.onVoiceStateUpdate(member.guild.voiceClient ?? null); +} diff --git a/packages/bot/src/services/groupService.ts b/packages/bot/src/services/groupService.ts new file mode 100644 index 00000000..38983c1f --- /dev/null +++ b/packages/bot/src/services/groupService.ts @@ -0,0 +1,86 @@ +import { WoWGroup, WoWPlayer, createMythicPlusGroups } from '@mythicplus/shared'; +import { announceGroup, type Sendable } from '../core/groupUi.js'; +import { getDebugPlayers, getPlayerList, type DiscordMember, type TypingChannel } from '../core/utils.js'; + +export interface CommandContext extends Sendable { + channel: { members: DiscordMember[] } & TypingChannel; + guild: { id: number } | null; +} + +export interface LastResults { + players: WoWPlayer[]; + groups: WoWGroup[]; +} + +export class GroupService { + lastResults: Map = new Map(); + private serverLocks: Map = new Map(); + + async getGroupsData( + ctx: CommandContext, + debug = false, + ): Promise { + let players: WoWPlayer[]; + + if (debug) { + players = getDebugPlayers(); + } else { + const members = ctx.channel.members.filter( + (m) => !(m as unknown as { bot: boolean }).bot, + ); + if (members.length === 0) { + await ctx.send('❌ No players found in the channel.'); + return null; + } + players = getPlayerList(members).filter((p) => p.hasRoles()); + } + + if (players.length === 0) { + return null; + } + + const guildId = ctx.guild?.id ?? null; + const groups = createMythicPlusGroups(players, debug, guildId); + + return { players: [...players], groups: [...groups] }; + } + + async coreWheel( + ctx: CommandContext, + debugValue: boolean | null = null, + ): Promise { + const debug = debugValue ?? false; + const guildId = ctx.guild?.id ?? null; + + if (!guildId) { + return; + } + + if (this.serverLocks.get(guildId)) { + return; + } + + this.serverLocks.set(guildId, true); + try { + await this._executeCoreWheel(ctx, ctx.channel, guildId, debug); + } finally { + this.serverLocks.set(guildId, false); + } + } + + async _executeCoreWheel( + ctx: CommandContext, + channel: TypingChannel, + guildId: number, + debug: boolean, + ): Promise { + const result = await this.getGroupsData(ctx, debug); + if (!result) return; + + this.lastResults.set(guildId, result); + + for (let i = 0; i < result.groups.length; i++) { + await announceGroup(ctx, channel, result.groups[i], i + 1, debug); + } + } +} diff --git a/packages/bot/src/services/sessionService.ts b/packages/bot/src/services/sessionService.ts new file mode 100644 index 00000000..73c85997 --- /dev/null +++ b/packages/bot/src/services/sessionService.ts @@ -0,0 +1,320 @@ +import { WoWGroup, WoWPlayer, createMythicPlusGroups } from '@mythicplus/shared'; +import { FirebaseService } from '../core/firebaseService.js'; +import { buildGroupEmbed } from '../core/groupUi.js'; +import logger from '../core/logger.js'; +import { getPlayerList, type DiscordMember } from '../core/utils.js'; +import type { GroupService } from './groupService.js'; + +export interface ActiveChannel { + docId: string; + guildId: number; +} + +export interface VoiceChannel { + id: number; + name: string; + members: (DiscordMember & { bot: boolean })[]; + send(content: string | { embed: unknown }): Promise; +} + +export interface Guild { + id: number; + name: string; + icon?: { url: string } | null; + voice_channels: VoiceChannel[]; + get_channel(id: number): VoiceChannel | null; +} + +export interface Bot { + get_guild(id: number): Guild | null; + groupService?: GroupService; + loop?: unknown; +} + +export class SessionService { + bot: Bot; + firebase: FirebaseService; + activeGuilds = new Set(); + activeChannels = new Map(); + channelListeners = new Map(); + guildListeners = new Map(); + + constructor(bot: Bot, firebase?: FirebaseService) { + this.bot = bot; + this.firebase = firebase ?? FirebaseService.getInstance(); + } + + shutdown(): void { + for (const watch of this.channelListeners.values()) { + watch?.unsubscribe(); + } + this.channelListeners.clear(); + + for (const watch of this.guildListeners.values()) { + watch?.unsubscribe(); + } + this.guildListeners.clear(); + + this.activeChannels.clear(); + this.activeGuilds.clear(); + + logger.info('SessionService shutdown complete — all listeners unsubscribed.'); + } + + async getOrCreateSession( + ctx: { + guild: { id: number; name: string; icon?: { url: string } | null; voice_channels: VoiceChannel[] } | null; + author: { voice?: { channel?: { id: number; name: string } | null } | null }; + }, + debug = false, + ): Promise<[string, string] | null> { + if (!this.firebase.isAvailable()) return null; + if (!ctx.guild) return null; + + const guildId = ctx.guild.id; + const guildName = ctx.guild.name; + const guildIconUrl = ctx.guild.icon?.url ?? null; + + const guildDocId = await this.firebase.getOrCreateGuildDoc( + guildId, + guildName, + guildIconUrl ?? undefined, + ); + this.activeGuilds.add(guildId); + + await this.refreshGuildVoiceChannels(ctx.guild as unknown as Guild); + + const voiceChannelId = ctx.author.voice?.channel?.id ?? null; + const voiceChannelName = ctx.author.voice?.channel?.name ?? ''; + + if (voiceChannelId === null) return null; + + const channelDocId = await this.firebase.getOrCreateChannelDoc( + voiceChannelId, + guildId, + voiceChannelName, + debug, + ); + + this.activeChannels.set(voiceChannelId, { + docId: channelDocId, + guildId, + }); + + await this.updateChannelPlayers(voiceChannelId, ctx.guild as unknown as Guild); + + return [guildDocId, channelDocId]; + } + + async updateChannelPlayers(channelId: number, guild: Guild): Promise { + const active = this.activeChannels.get(channelId); + if (!active) return; + + const channel = guild.get_channel(channelId); + let playersData: Record[] = []; + + if (channel) { + const members = channel.members.filter((m) => !m.bot); + const players = getPlayerList(members); + playersData = players.map((p) => p.toDict()); + } + + await this.firebase.updateChannelDoc(active.docId, { players: playersData }); + } + + async refreshGuildVoiceChannels(guild: Guild): Promise { + const guildIdStr = String(guild.id); + + const voiceChannelsData: { id: string; name: string; userCount: number }[] = []; + for (const vc of guild.voice_channels) { + const count = vc.members.filter((m) => !m.bot).length; + voiceChannelsData.push({ + id: String(vc.id), + name: vc.name, + userCount: count, + }); + } + + voiceChannelsData.sort((a, b) => { + if (b.userCount !== a.userCount) return b.userCount - a.userCount; + return a.name.localeCompare(b.name); + }); + + await this.firebase.updateGuildDoc(guildIdStr, { + voiceChannels: voiceChannelsData, + }); + } + + async processSpinRequest( + docId: string, + channelId: number, + guildId: number, + data: Record, + ): Promise { + logger.info(`Processing spin request for channel ${channelId}`); + + try { + const guild = this.bot.get_guild(guildId); + if (!guild) { + logger.error(`Guild ${guildId} not found.`); + return; + } + + const isDebug = (data.isDebug as boolean) ?? false; + let players: WoWPlayer[]; + + if (isDebug) { + const playersData = (data.players ?? []) as Record[]; + players = playersData.map((p) => WoWPlayer.fromDict(p)); + } else { + const channel = guild.get_channel(channelId); + if (!channel) return; + + const members = channel.members.filter((m) => !m.bot); + players = getPlayerList(members).filter((p) => p.hasRoles()); + } + + let groups: WoWGroup[]; + if (players.length === 0 && !isDebug) { + groups = []; + } else { + groups = createMythicPlusGroups(players, isDebug, guildId); + } + + if (this.bot.groupService) { + this.bot.groupService.lastResults.set(guildId, { + players: [...players], + groups: [...groups], + }); + } + + const groupsData = groups.map((g) => g.toDict()); + await this.firebase.updateChannelDoc(docId, { + status: 'spinning', + groups: groupsData, + revealedGroups: 0, + }); + } catch (e) { + logger.error(`Spin request failed for channel ${channelId}: ${e}`); + try { + await this.firebase.updateChannelDoc(docId, { + status: 'lobby', + groups: [], + }); + } catch (e2) { + logger.error(`Failed to reset channel ${channelId} after spin error: ${e2}`); + } + } + } + + async announceCompletion( + channelId: number, + guildId: number, + data: Record, + ): Promise { + const guild = this.bot.get_guild(guildId); + if (!guild) return; + + const channel = guild.get_channel(channelId); + if (!channel) return; + + let groups: WoWGroup[] = []; + const last = this.bot.groupService?.lastResults.get(guildId); + if (last?.groups?.length) { + groups = [...last.groups]; + } else { + const groupsData = (data.groups ?? []) as Record[]; + groups = groupsData.map((g) => WoWGroup.fromDict(g)); + } + + if (groups.length === 0) { + await channel.send('No groups were formed this round.'); + return; + } + + try { + for (let i = 0; i < groups.length; i++) { + const embed = buildGroupEmbed(groups[i], i + 1); + await channel.send({ embed }); + } + } catch (e) { + logger.warn(`Could not send completion embed to channel ${channelId}: ${e}`); + } + } + + async cleanupChannel(channelId: number): Promise { + const active = this.activeChannels.get(channelId); + if (!active) return; + + this.activeChannels.delete(channelId); + + if (this.channelListeners.has(active.docId)) { + const watch = this.channelListeners.get(active.docId); + watch?.unsubscribe(); + this.channelListeners.delete(active.docId); + } + + await this.firebase.deleteChannelDoc(active.docId); + await this._asyncCleanupGuildIfEmpty(active.guildId); + } + + private async _asyncCleanupGuildIfEmpty(guildId: number): Promise { + const guildHasChannels = [...this.activeChannels.values()].some( + (ac) => ac.guildId === guildId, + ); + if (!guildHasChannels) { + this.activeGuilds.delete(guildId); + await this.firebase.deleteGuildDoc(String(guildId)); + if (this.guildListeners.has(guildId)) { + const watch = this.guildListeners.get(guildId); + watch?.unsubscribe(); + this.guildListeners.delete(guildId); + } + } + } + + handleCollectionRemoved(change: { + document: { id: string }; + }): void { + const docId = change.document.id; + let channelId: number; + try { + channelId = parseInt(docId, 10); + } catch { + return; + } + if (isNaN(channelId)) return; + + const active = this.activeChannels.get(channelId); + if (active) { + this.activeChannels.delete(channelId); + if (this.channelListeners.has(docId)) { + const watch = this.channelListeners.get(docId); + watch?.unsubscribe(); + this.channelListeners.delete(docId); + } + logger.info(`Channel ${channelId} removed from tracking`); + this._cleanupGuildIfEmpty(active.guildId); + } + } + + private _cleanupGuildIfEmpty(guildId: number): void { + const guildHasChannels = [...this.activeChannels.values()].some( + (ac) => ac.guildId === guildId, + ); + if (!guildHasChannels) { + this.activeGuilds.delete(guildId); + if (this.guildListeners.has(guildId)) { + const watch = this.guildListeners.get(guildId); + watch?.unsubscribe(); + this.guildListeners.delete(guildId); + } + } + } + + getActiveChannelIdsForGuild(guildId: number): number[] { + return [...this.activeChannels.entries()] + .filter(([, ac]) => ac.guildId === guildId) + .map(([chId]) => chId); + } +} diff --git a/packages/bot/tests/badgroup.test.ts b/packages/bot/tests/badgroup.test.ts new file mode 100644 index 00000000..2dd6176c --- /dev/null +++ b/packages/bot/tests/badgroup.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WoWPlayer, WoWGroup } from '@mythicplus/shared'; + +// Mock config +vi.mock('../src/core/config.js', () => ({ + GITHUB_TOKEN: 'fake_token', + GITHUB_REPO_OWNER: 'owner', + GITHUB_REPO_NAME: 'repo', + GIT_SHA: 'abc123456', + LOG_FILE: 'nonexistent_log.log', + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, +})); + +import { reportBadGroup } from '../src/core/issues.js'; + +describe('reportBadGroup', () => { + const p1 = WoWPlayer.create('Player1', ['Tank']); + const p2 = WoWPlayer.create('Player2', ['Healer']); + const p3 = WoWPlayer.create('Player3', ['Melee']); + const p4 = WoWPlayer.create('Player4', ['Ranged']); + const p5 = WoWPlayer.create('Player5', ['Melee']); + + const players = [p1, p2, p3, p4, p5]; + const group = new WoWGroup(p1, p2, [p3, p4, p5]); + + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + status: 201, + json: vi.fn().mockResolvedValue({ html_url: 'http://url' }), + }); + }); + + it('creates issue with player code and version', async () => { + const result = await reportBadGroup({ + reporterName: 'TestUser', + reporterId: 12345, + title: 'Bad Algo', + description: 'Too many melee', + players, + groups: [group], + }); + + expect(result.html_url).toBe('http://url'); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(fetchCall[1]!.body as string) as { + title: string; + body: string; + labels: string[]; + }; + + expect(body.title).toBe('[Bad Group] Bad Algo'); + expect(body.body).toContain('Too many melee'); + expect(body.body).toContain('WoWPlayer.create("Player1"'); + expect(body.labels).toEqual(['bug', 'bad-group']); + + const expectedVersion = + '[`abc1234`](https://github.com/owner/repo/commit/abc123456)'; + expect(body.body).toContain(`**Version:** ${expectedVersion}`); + }); +}); diff --git a/packages/bot/tests/errorHandling.test.ts b/packages/bot/tests/errorHandling.test.ts new file mode 100644 index 00000000..fed4e955 --- /dev/null +++ b/packages/bot/tests/errorHandling.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../src/core/config.js', () => ({ + DEVELOPER_ID: 999, + LOG_FILE: '/tmp/test.log', + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, + GITHUB_TOKEN: 'fake', + GITHUB_REPO_OWNER: 'owner', + GITHUB_REPO_NAME: 'repo', + GIT_SHA: 'abc123', + DISCORD_APPLICATION_ID: undefined, + ACTIVITY_URL: undefined, + PLACEHOLDER_CHAR: '❓', + BOT_INVITE_PERMISSIONS: 0, +})); + +vi.mock('../src/core/issues.js', () => ({ + createErrorIssue: vi.fn(), + createGithubIssue: vi.fn(), + searchGithubIssues: vi.fn(), + submitGithubIssueModal: vi.fn(), + reportBadGroup: vi.fn(), + getVersionString: vi.fn(), + GitHubError: class extends Error {}, +})); + +vi.mock('../src/core/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../src/core/preferenceService.js', () => ({ + getPreferenceService: vi.fn().mockReturnValue({ + getPreferenceSync: vi.fn().mockReturnValue(null), + getPreferenceByNameSync: vi.fn().mockReturnValue(null), + }), +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +import { MythicPlusBot, type BotClient, type DevUser } from '../src/bot.js'; +import { createErrorIssue } from '../src/core/issues.js'; +import * as fs from 'fs'; + +function makeMockDev(): DevUser { + return { send: vi.fn().mockResolvedValue(undefined) }; +} + +function makeMockClient(dev: DevUser | null = null): BotClient { + return { + getUser: vi.fn().mockReturnValue(dev), + fetchUser: vi.fn().mockResolvedValue(dev), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MythicPlusBot.sendErrorToDev', () => { + it('sends short error DM with log file attached', async () => { + const dev = makeMockDev(); + const client = makeMockClient(dev); + const bot = new MythicPlusBot(client); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('mock log data')); + vi.mocked(createErrorIssue).mockResolvedValue(null); + + const error = new Error('Test Error'); + await bot.sendErrorToDev(error, 'Test Context'); + + expect(dev.send).toHaveBeenCalledOnce(); + const [message, options] = vi.mocked(dev.send).mock.calls[0]; + expect(message).toContain('Bot Error Detected'); + expect(message).toContain('Test Context'); + expect(message).toContain('Test Error'); + expect(message).toContain('Error'); + // Should have log file attached + expect(options?.files).toHaveLength(1); + expect(options?.files?.[0].filename).toBe('bot.log'); + }); + + it('sends long traceback as file attachment', async () => { + const dev = makeMockDev(); + const client = makeMockClient(dev); + const bot = new MythicPlusBot(client); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('mock log data')); + vi.mocked(createErrorIssue).mockResolvedValue(null); + + const error = new Error('Long Error'); + error.stack = 'A'.repeat(2000); + + await bot.sendErrorToDev(error, 'Test Context'); + + expect(dev.send).toHaveBeenCalledOnce(); + const [, options] = vi.mocked(dev.send).mock.calls[0]; + expect(options?.files).toHaveLength(2); // traceback.txt + log + const filenames = options?.files?.map((f) => f.filename); + expect(filenames).toContain('traceback.txt'); + expect(filenames).toContain('bot.log'); + }); + + it('creates GitHub issue and sends follow-up DM with URL', async () => { + const dev = makeMockDev(); + const client = makeMockClient(dev); + const bot = new MythicPlusBot(client); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('log')); + vi.mocked(createErrorIssue).mockResolvedValue({ html_url: 'http://github.com/issue/1' }); + + const error = new Error('Test Error'); + await bot.sendErrorToDev(error, 'Test Context'); + + expect(createErrorIssue).toHaveBeenCalledWith(error, 'Test Context'); + // Two calls: error DM + issue URL follow-up + expect(dev.send).toHaveBeenCalledTimes(2); + const followUpMsg = vi.mocked(dev.send).mock.calls[1][0]; + expect(followUpMsg).toContain('http://github.com/issue/1'); + }); + + it('does not send follow-up when no issue is created', async () => { + const dev = makeMockDev(); + const client = makeMockClient(dev); + const bot = new MythicPlusBot(client); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('log')); + vi.mocked(createErrorIssue).mockResolvedValue(null); + + await bot.sendErrorToDev(new Error('Test'), 'Test Context'); + + expect(dev.send).toHaveBeenCalledOnce(); // Only error DM + }); + + it('creates issue even when developer DM fails', async () => { + const client = makeMockClient(null); // Can't find developer + const bot = new MythicPlusBot(client); + vi.mocked(createErrorIssue).mockResolvedValue(null); + + await bot.sendErrorToDev(new Error('Test'), 'Test Context'); + + expect(createErrorIssue).toHaveBeenCalledOnce(); + }); +}); + +describe('MythicPlusBot.handleCommandError', () => { + it('delegates to sendErrorToDev with command context', async () => { + const client = makeMockClient(); + const bot = new MythicPlusBot(client); + const sendSpy = vi.spyOn(bot, 'sendErrorToDev').mockResolvedValue(undefined); + + const ctx = { + command: 'test_cmd', + author: { id: 12345, toString: () => 'TestUser' }, + channel: { toString: () => 'test_channel' }, + send: vi.fn().mockResolvedValue(undefined), + }; + + const error = new Error('Unexpected Error'); + await bot.handleCommandError(ctx, error); + + expect(sendSpy).toHaveBeenCalledOnce(); + const [sentError, context] = sendSpy.mock.calls[0]; + expect(sentError).toBe(error); + expect(context).toContain('Command: !test_cmd'); + }); +}); + +describe('MythicPlusBot.handleAppCommandError', () => { + it('delegates to sendErrorToDev and sends followup when response done', async () => { + const client = makeMockClient(); + const bot = new MythicPlusBot(client); + const sendSpy = vi.spyOn(bot, 'sendErrorToDev').mockResolvedValue(undefined); + + const interaction = { + commandName: 'test_slash', + user: { id: '12345', toString: () => 'TestUser' }, + channel: { toString: () => 'test_channel' }, + responseSent: true, + reply: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + }; + + const error = new Error('Slash Error'); + await bot.handleAppCommandError(interaction, error); + + expect(sendSpy).toHaveBeenCalledOnce(); + const [, context] = sendSpy.mock.calls[0]; + expect(context).toContain('App Command: /test_slash'); + expect(interaction.followUp).toHaveBeenCalledOnce(); + }); +}); + +describe('MythicPlusBot.handleEventError', () => { + it('delegates to sendErrorToDev with event context', async () => { + const client = makeMockClient(); + const bot = new MythicPlusBot(client); + const sendSpy = vi.spyOn(bot, 'sendErrorToDev').mockResolvedValue(undefined); + + const error = new Error('Event Error'); + await bot.handleEventError('on_message', error, 'arg1'); + + expect(sendSpy).toHaveBeenCalledOnce(); + const [sentError, context] = sendSpy.mock.calls[0]; + expect(sentError).toBe(error); + expect(context).toContain('Event: on_message'); + }); +}); diff --git a/packages/bot/tests/firebaseService.test.ts b/packages/bot/tests/firebaseService.test.ts new file mode 100644 index 00000000..2c6634f7 --- /dev/null +++ b/packages/bot/tests/firebaseService.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { FirebaseService } from '../src/core/firebaseService.js'; + +// Helper to create mock docs +function createMockDoc(id: string) { + return { + id, + ref: { delete: vi.fn() }, + }; +} + +describe('FirebaseService.deleteOldDocs', () => { + let service: FirebaseService; + let mockDb: ReturnType; + + function createMockDb() { + const mockBatch = { + delete: vi.fn(), + commit: vi.fn().mockResolvedValue(undefined), + }; + const mockQuery = { + get: vi.fn().mockResolvedValue({ docs: [] }), + }; + const mockCollection = { + where: vi.fn().mockReturnValue(mockQuery), + get: vi.fn().mockResolvedValue({ docs: [] }), + doc: vi.fn(), + }; + const db = { + collection: vi.fn().mockReturnValue(mockCollection), + batch: vi.fn().mockReturnValue(mockBatch), + }; + return { db, mockCollection, mockQuery, mockBatch }; + } + + beforeEach(() => { + service = Object.create(FirebaseService.prototype); + mockDb = createMockDb(); + // Bypass constructor by directly setting db + service.db = mockDb.db as unknown as FirebaseService['db']; + }); + + it('handles no matching docs', async () => { + mockDb.mockQuery.get.mockResolvedValue({ docs: [] }); + const deleted = await service.deleteOldDocs('channels', 3600); + expect(deleted).toBe(0); + }); + + it('deletes single batch (< 500 docs)', async () => { + const numDocs = 10; + const docs = Array.from({ length: numDocs }, (_, i) => createMockDoc(`doc_${i}`)); + mockDb.mockQuery.get.mockResolvedValue({ docs }); + + const deleted = await service.deleteOldDocs('guilds', 3600); + expect(deleted).toBe(numDocs); + expect(mockDb.mockBatch.delete).toHaveBeenCalledTimes(numDocs); + expect(mockDb.mockBatch.commit).toHaveBeenCalled(); + }); + + it('handles multi-batch (> 500 docs)', async () => { + const numDocs = 550; + const docs = Array.from({ length: numDocs }, (_, i) => createMockDoc(`doc_${i}`)); + mockDb.mockQuery.get.mockResolvedValue({ docs }); + + const batch1 = { delete: vi.fn(), commit: vi.fn().mockResolvedValue(undefined) }; + const batch2 = { delete: vi.fn(), commit: vi.fn().mockResolvedValue(undefined) }; + mockDb.db.batch.mockReturnValueOnce(batch1).mockReturnValueOnce(batch2); + + const deleted = await service.deleteOldDocs('channels', 3600); + expect(deleted).toBe(numDocs); + expect(mockDb.db.batch).toHaveBeenCalledTimes(2); + expect(batch1.delete).toHaveBeenCalledTimes(500); + expect(batch1.commit).toHaveBeenCalledTimes(1); + expect(batch2.delete).toHaveBeenCalledTimes(50); + expect(batch2.commit).toHaveBeenCalledTimes(1); + }); + + it('handles exact batch boundary (500 docs)', async () => { + const numDocs = 500; + const docs = Array.from({ length: numDocs }, (_, i) => createMockDoc(`doc_${i}`)); + mockDb.mockQuery.get.mockResolvedValue({ docs }); + + const batch1 = { delete: vi.fn(), commit: vi.fn().mockResolvedValue(undefined) }; + mockDb.db.batch + .mockReturnValueOnce(batch1) + .mockReturnValueOnce({ delete: vi.fn(), commit: vi.fn().mockResolvedValue(undefined) }); + + const deleted = await service.deleteOldDocs('guilds', 3600); + expect(deleted).toBe(numDocs); + expect(batch1.delete).toHaveBeenCalledTimes(500); + expect(batch1.commit).toHaveBeenCalled(); + }); + + it('works for both collection names', async () => { + mockDb.mockQuery.get.mockResolvedValue({ docs: [] }); + await service.deleteOldDocs('guilds', 3600); + expect(mockDb.db.collection).toHaveBeenCalledWith('guilds'); + + await service.deleteOldDocs('channels', 3600); + expect(mockDb.db.collection).toHaveBeenCalledWith('channels'); + }); +}); + +describe('FirebaseService.deleteAllInCollection', () => { + let service: FirebaseService; + + function createMockDb() { + const mockBatch = { + delete: vi.fn(), + commit: vi.fn().mockResolvedValue(undefined), + }; + const mockCollection = { + get: vi.fn().mockResolvedValue({ docs: [] }), + doc: vi.fn(), + where: vi.fn(), + }; + return { + db: { + collection: vi.fn().mockReturnValue(mockCollection), + batch: vi.fn().mockReturnValue(mockBatch), + }, + mockCollection, + mockBatch, + }; + } + + it('handles empty collection', async () => { + service = Object.create(FirebaseService.prototype); + const mockDb = createMockDb(); + service.db = mockDb.db as unknown as FirebaseService['db']; + mockDb.mockCollection.get.mockResolvedValue({ docs: [] }); + + const deleted = await service.deleteAllInCollection('sessions'); + expect(deleted).toBe(0); + }); + + it('deletes all docs', async () => { + service = Object.create(FirebaseService.prototype); + const mockDb = createMockDb(); + service.db = mockDb.db as unknown as FirebaseService['db']; + + const docs = Array.from({ length: 3 }, () => ({ + ref: { delete: vi.fn() }, + })); + mockDb.mockCollection.get.mockResolvedValue({ docs }); + + const deleted = await service.deleteAllInCollection('sessions'); + expect(deleted).toBe(3); + expect(mockDb.mockBatch.delete).toHaveBeenCalledTimes(3); + expect(mockDb.mockBatch.commit).toHaveBeenCalled(); + }); +}); diff --git a/packages/bot/tests/generalCommand.test.ts b/packages/bot/tests/generalCommand.test.ts new file mode 100644 index 00000000..f5896562 --- /dev/null +++ b/packages/bot/tests/generalCommand.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../src/core/config.js', () => ({ + GIT_SHA: 'abc123456789', + GITHUB_REPO_OWNER: 'Owner', + GITHUB_REPO_NAME: 'Repo', + DISCORD_APPLICATION_ID: '12345', + BOT_INVITE_PERMISSIONS: 274878221376, + DEVELOPER_ID: 999, + LOG_FILE: '/tmp/test.log', + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, + GITHUB_TOKEN: 'fake', + ACTIVITY_URL: undefined, + PLACEHOLDER_CHAR: '❓', +})); + +vi.mock('os', () => ({ + loadavg: vi.fn().mockReturnValue([0.5, 0.4, 0.3]), +})); + +import { GeneralHandler, type GeneralContext, type VoiceClient } from '../src/commands/general.js'; +import * as config from '../src/core/config.js'; + +function makeCtx(overrides: Partial = {}): GeneralContext { + return { + guild: overrides.guild === undefined ? { id: 12345 } : overrides.guild, + send: vi.fn().mockResolvedValue(undefined), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GeneralHandler.version', () => { + it('shows commit link when GIT_SHA is set', async () => { + const handler = new GeneralHandler(); + const ctx = makeCtx(); + + await handler.version(ctx); + + expect(ctx.send).toHaveBeenCalledOnce(); + const [, options] = vi.mocked(ctx.send).mock.calls[0]; + const embed = (options as Record).embed; // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(embed.title).toBe('Bot Version'); + const commitField = embed.fields.find((f: any) => f.name === 'Commit'); // eslint-disable-line @typescript-eslint/no-explicit-any + expect(commitField).toBeTruthy(); + expect(commitField.value).toContain('abc1234'); + expect(commitField.value).toContain( + 'https://github.com/Owner/Repo/commit/abc123456789', + ); + }); + + it('shows unknown when GIT_SHA is empty', async () => { + // Temporarily change the mocked config value + const original = config.GIT_SHA; + Object.defineProperty(config, 'GIT_SHA', { value: null, writable: true, configurable: true }); + + const handler = new GeneralHandler(); + const ctx = makeCtx(); + + await handler.version(ctx); + + const [, options] = vi.mocked(ctx.send).mock.calls[0]; + const embed = (options as Record).embed; // eslint-disable-line @typescript-eslint/no-explicit-any + const commitField = embed.fields.find((f: any) => f.name === 'Commit'); // eslint-disable-line @typescript-eslint/no-explicit-any + expect(commitField.value).toBe('unknown'); + + // Restore + Object.defineProperty(config, 'GIT_SHA', { value: original, writable: true, configurable: true }); + }); +}); + +describe('GeneralHandler.status', () => { + it('shows uptime, ping, and system load', async () => { + const handler = new GeneralHandler(0.05); // 50ms latency + // Set start time to simulate 60 seconds of uptime + const now = Date.now() / 1000; + handler._setStartTime(now - 60); + + const ctx = makeCtx({ guild: { id: 12345 } }); + + await handler.status(ctx); + + expect(ctx.send).toHaveBeenCalledOnce(); + const [, options] = vi.mocked(ctx.send).mock.calls[0]; + const embed = (options as Record).embed; // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(embed.title).toBe('Bot Status'); + + const fields: Record = {}; + for (const f of embed.fields) { + fields[f.name] = f.value; + } + + expect(fields['Uptime']).toBe('0:01:00'); + expect(fields['Ping']).toBe('50ms'); + expect(fields['System Load']).toBe('0.50, 0.40, 0.30'); + expect(embed.footer.text).toContain('Server ID: 12345'); + }); +}); + +describe('GeneralHandler.onVoiceStateUpdate', () => { + it('disconnects when bot is the only one left', async () => { + const handler = new GeneralHandler(); + + const voiceClient: VoiceClient = { + channel: { members: ['bot_only'] }, + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + await handler.onVoiceStateUpdate(voiceClient); + + expect(voiceClient.disconnect).toHaveBeenCalledWith({ force: false }); + }); + + it('does not disconnect when others are present', async () => { + const handler = new GeneralHandler(); + + const voiceClient: VoiceClient = { + channel: { members: ['bot', 'human'] }, + disconnect: vi.fn().mockResolvedValue(undefined), + }; + + await handler.onVoiceStateUpdate(voiceClient); + + expect(voiceClient.disconnect).not.toHaveBeenCalled(); + }); + + it('does nothing when not in a voice channel', async () => { + const handler = new GeneralHandler(); + await handler.onVoiceStateUpdate(null); + }); +}); diff --git a/packages/bot/tests/groupService.test.ts b/packages/bot/tests/groupService.test.ts new file mode 100644 index 00000000..b6f4f0f7 --- /dev/null +++ b/packages/bot/tests/groupService.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WoWPlayer, WoWGroup } from '@mythicplus/shared'; + +vi.mock('@mythicplus/shared', async () => { + const actual = await vi.importActual('@mythicplus/shared'); + return { + ...(actual as Record), + createMythicPlusGroups: vi.fn(), + }; +}); + +vi.mock('../src/core/utils.js', () => ({ + getPlayerList: vi.fn(), + getDebugPlayers: vi.fn(), + getPlayerFromMember: vi.fn(), + getWowName: vi.fn(), + getMaskedName: vi.fn((n: string) => '?'.repeat(n.length)), + showLongTyping: vi.fn().mockResolvedValue(undefined), + showShortTyping: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../src/core/groupUi.js', () => ({ + announceGroup: vi.fn().mockResolvedValue(undefined), + buildGroupEmbed: vi.fn(), +})); + +vi.mock('../src/core/preferenceService.js', () => ({ + getPreferenceService: vi.fn().mockReturnValue({ + getPreferenceSync: vi.fn().mockReturnValue(null), + getPreferenceByNameSync: vi.fn().mockReturnValue(null), + }), +})); + +import { GroupService, type CommandContext } from '../src/services/groupService.js'; +import { getPlayerList, getDebugPlayers } from '../src/core/utils.js'; +import { announceGroup } from '../src/core/groupUi.js'; +import { createMythicPlusGroups } from '@mythicplus/shared'; + +function makeCtx(overrides: { + members?: { bot: boolean; nick?: string; id?: string; toString?: () => string }[]; + guild?: { id: number } | null; +} = {}): CommandContext { + return { + channel: { + members: overrides.members ?? [], + sendTyping: vi.fn().mockResolvedValue(undefined), + }, + guild: overrides.guild === undefined ? { id: 1 } : overrides.guild, + send: vi.fn().mockResolvedValue({ edit: vi.fn() }), + } as unknown as CommandContext; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GroupService.getGroupsData', () => { + it('returns null when no members in channel', async () => { + const service = new GroupService(); + const ctx = makeCtx({ members: [] }); + + const result = await service.getGroupsData(ctx); + + expect(result).toBeNull(); + expect(ctx.send).toHaveBeenCalledOnce(); + }); + + it('uses debug players in debug mode', async () => { + const service = new GroupService(); + const ctx = makeCtx(); + + const debugPlayer = WoWPlayer.create('DebugPlayer', ['Tank']); + vi.mocked(getDebugPlayers).mockReturnValue([debugPlayer]); + vi.mocked(createMythicPlusGroups).mockReturnValue([ + new WoWGroup(debugPlayer, null, []), + ]); + + const result = await service.getGroupsData(ctx, true); + + expect(result).not.toBeNull(); + expect(result!.players).toHaveLength(1); + expect(result!.groups).toHaveLength(1); + expect(getDebugPlayers).toHaveBeenCalledOnce(); + expect(createMythicPlusGroups).toHaveBeenCalledOnce(); + }); + + it('uses player list in normal mode', async () => { + const service = new GroupService(); + const member1 = { bot: false, nick: 'P1', id: '1', toString: () => 'P1' }; + const member2 = { bot: false, nick: 'P2', id: '2', toString: () => 'P2' }; + const ctx = makeCtx({ members: [member1, member2] }); + + const players = [ + WoWPlayer.create('Player1', ['Tank']), + WoWPlayer.create('Player2', ['Melee']), + ]; + vi.mocked(getPlayerList).mockReturnValue(players); + vi.mocked(createMythicPlusGroups).mockReturnValue([ + new WoWGroup(players[0], null, [players[1]]), + ]); + + const result = await service.getGroupsData(ctx, false); + + expect(result).not.toBeNull(); + expect(result!.players).toHaveLength(2); + expect(result!.groups).toHaveLength(1); + expect(getPlayerList).toHaveBeenCalledWith([member1, member2]); + expect(createMythicPlusGroups).toHaveBeenCalledOnce(); + }); + + it('returns null when no players have valid roles', async () => { + const service = new GroupService(); + const member1 = { bot: false, nick: 'P1', id: '1', toString: () => 'P1' }; + const ctx = makeCtx({ members: [member1] }); + + vi.mocked(getPlayerList).mockReturnValue([WoWPlayer.create('Roleless', [])]); + + const result = await service.getGroupsData(ctx, false); + + expect(result).toBeNull(); + }); +}); + +describe('GroupService.coreWheel', () => { + it('returns immediately when guild is null', async () => { + const service = new GroupService(); + const ctx = makeCtx({ guild: null }); + + const executeSpy = vi.spyOn(service, '_executeCoreWheel'); + + await service.coreWheel(ctx); + + expect(executeSpy).not.toHaveBeenCalled(); + }); + + it('executes and releases lock on success', async () => { + const service = new GroupService(); + const ctx = makeCtx({ guild: { id: 123 } }); + + const executeSpy = vi.spyOn(service, '_executeCoreWheel').mockResolvedValue(undefined); + + await service.coreWheel(ctx); + + expect(executeSpy).toHaveBeenCalledOnce(); + expect(executeSpy).toHaveBeenCalledWith(ctx, ctx.channel, 123, false); + + // Lock should be released — a second call should succeed + await service.coreWheel(ctx); + expect(executeSpy).toHaveBeenCalledTimes(2); + }); + + it('prevents concurrent execution for same guild', async () => { + const service = new GroupService(); + const ctx = makeCtx({ guild: { id: 123 } }); + + let resolveFirst!: () => void; + const firstPromise = new Promise((r) => { + resolveFirst = r; + }); + + const executeSpy = vi + .spyOn(service, '_executeCoreWheel') + .mockReturnValue(firstPromise); + + // First call acquires the lock synchronously before await + const call1 = service.coreWheel(ctx); + // Second call sees lock is true and returns immediately + const call2 = service.coreWheel(ctx); + + resolveFirst(); + await call1; + await call2; + + expect(executeSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('GroupService._executeCoreWheel', () => { + it('stores results and announces groups', async () => { + const service = new GroupService(); + const ctx = makeCtx(); + const guildId = 123; + + const players = [WoWPlayer.create('P1', ['Tank'])]; + const groups = [ + new WoWGroup(players[0], null, []), + new WoWGroup(null, null, []), + ]; + + vi.spyOn(service, 'getGroupsData').mockResolvedValue({ players, groups }); + + await service._executeCoreWheel(ctx, ctx.channel, guildId, false); + + expect(service.getGroupsData).toHaveBeenCalledWith(ctx, false); + expect(service.lastResults.get(guildId)).toEqual({ players, groups }); + expect(announceGroup).toHaveBeenCalledTimes(2); + }); + + it('does nothing when getGroupsData returns null', async () => { + const service = new GroupService(); + const ctx = makeCtx(); + const guildId = 123; + + vi.spyOn(service, 'getGroupsData').mockResolvedValue(null); + + await service._executeCoreWheel(ctx, ctx.channel, guildId, false); + + expect(service.lastResults.has(guildId)).toBe(false); + expect(announceGroup).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bot/tests/groupUi.test.ts b/packages/bot/tests/groupUi.test.ts new file mode 100644 index 00000000..03a682b3 --- /dev/null +++ b/packages/bot/tests/groupUi.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WoWPlayer, WoWGroup, ROLE_TANK, ROLE_HEALER, ROLE_MELEE, ROLE_RANGED, ROLE_BREZ, ROLE_LUST } from '@mythicplus/shared'; +import { buildGroupEmbed, announceGroup } from '../src/core/groupUi.js'; +import type { Embed, Message, Sendable } from '../src/core/groupUi.js'; + +// Mock the utils module +vi.mock('../src/core/utils.js', async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + getMaskedName: (n: string) => '?'.repeat(n.length), + showShortTyping: vi.fn().mockResolvedValue(undefined), + }; +}); + +describe('buildGroupEmbed', () => { + it('has all fields', () => { + const tank = WoWPlayer.create('TankPlayer', [ROLE_TANK, ROLE_BREZ]); + const healer = WoWPlayer.create('HealerPlayer', [ROLE_HEALER, ROLE_LUST]); + const dps1 = WoWPlayer.create('DPS1', [ROLE_MELEE]); + const dps2 = WoWPlayer.create('DPS2', [ROLE_RANGED]); + const dps3 = WoWPlayer.create('DPS3', [ROLE_MELEE]); + + const group = new WoWGroup(tank, healer, [dps1, dps2, dps3]); + const embed = buildGroupEmbed(group, 1); + + expect(embed.title).toBe('Group 1'); + const fields = Object.fromEntries(embed.fields.map((f) => [f.name, f.value])); + expect(fields['Tank']).toBe('TankPlayer'); + expect(fields['Healer']).toBe('HealerPlayer'); + expect(fields['DPS']).toBe('DPS1, DPS2, DPS3'); + expect(fields['Battle Res']).toBe('TankPlayer'); + expect(fields['Bloodlust']).toBe('HealerPlayer'); + }); + + it('shows None for missing utilities', () => { + const tank = WoWPlayer.create('Tank', [ROLE_TANK]); + const healer = WoWPlayer.create('Healer', [ROLE_HEALER]); + const dps1 = WoWPlayer.create('DPS1', [ROLE_MELEE]); + + const group = new WoWGroup(tank, healer, [dps1]); + const embed = buildGroupEmbed(group, 2); + + const fields = Object.fromEntries(embed.fields.map((f) => [f.name, f.value])); + expect(fields['Battle Res']).toBe('None'); + expect(fields['Bloodlust']).toBe('None'); + }); +}); + +describe('announceGroup', () => { + let ctx: Sendable; + let channel: { sendTyping: ReturnType }; + + beforeEach(() => { + ctx = { + send: vi.fn().mockImplementation(() => { + const msg: Message = { + edit: vi.fn().mockReturnThis() as Message['edit'], + }; + return Promise.resolve(msg); + }), + }; + channel = { sendTyping: vi.fn().mockResolvedValue(undefined) }; + }); + + it('sends embed directly in debug mode', async () => { + const tank = WoWPlayer.create('TankPlayer', [ROLE_TANK]); + const healer = WoWPlayer.create('HealerPlayer', [ROLE_HEALER]); + const dps1 = WoWPlayer.create('DPS1', [ROLE_MELEE]); + const dps2 = WoWPlayer.create('DPS2', [ROLE_RANGED]); + const dps3 = WoWPlayer.create('DPS3', [ROLE_MELEE]); + + const group = new WoWGroup(tank, healer, [dps1, dps2, dps3]); + await announceGroup(ctx, channel, group, 1, true); + + expect(ctx.send).toHaveBeenCalledTimes(1); + const sendArgs = vi.mocked(ctx.send).mock.calls[0][0] as { embed: Embed }; + const embed = sendArgs.embed; + expect(embed.title).toBe('Group 1'); + const fields = Object.fromEntries(embed.fields.map((f) => [f.name, f.value])); + expect(fields['Tank']).toBe('TankPlayer'); + expect(fields['Healer']).toBe('HealerPlayer'); + }); + + it('uses masked names in normal mode with animation', async () => { + const { showShortTyping } = await import('../src/core/utils.js'); + + const tank = WoWPlayer.create('TankPlayer', [ROLE_TANK]); + const healer = WoWPlayer.create('HealerPlayer', [ROLE_HEALER]); + const dps1 = WoWPlayer.create('DPS1', [ROLE_MELEE]); + const dps2 = WoWPlayer.create('DPS2', [ROLE_RANGED]); + const dps3 = WoWPlayer.create('DPS3', [ROLE_MELEE]); + + const group = new WoWGroup(tank, healer, [dps1, dps2, dps3]); + + const mockMessage = { + edit: vi.fn().mockReturnThis() as Message['edit'], + }; + vi.mocked(ctx.send).mockResolvedValue(mockMessage as unknown as Message); + + await announceGroup(ctx, channel, group, 1, false); + + // Verify initial send was called + expect(ctx.send).toHaveBeenCalledTimes(1); + + // Verify typing was called 5 times (for each role reveal) + expect(showShortTyping).toHaveBeenCalledTimes(5); + + // Verify edits happened (5 animated + 2 utility = 7) + expect(mockMessage.edit).toHaveBeenCalledTimes(7); + }); +}); diff --git a/packages/bot/tests/groupsCommand.test.ts b/packages/bot/tests/groupsCommand.test.ts new file mode 100644 index 00000000..cc9f014c --- /dev/null +++ b/packages/bot/tests/groupsCommand.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// WoWPlayer, WoWGroup imported by mocked modules + +vi.mock('../src/core/config.js', () => ({ + DEVELOPER_ID: 999, + LOG_FILE: '/tmp/test.log', + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, + GITHUB_TOKEN: 'fake', + GITHUB_REPO_OWNER: 'owner', + GITHUB_REPO_NAME: 'repo', + GIT_SHA: 'abc123', + DISCORD_APPLICATION_ID: '12345', + ACTIVITY_URL: 'https://tytaniumdev.github.io/MythicPlusDiscordBot/', + PLACEHOLDER_CHAR: '❓', + BOT_INVITE_PERMISSIONS: 0, +})); + +vi.mock('../src/core/issues.js', () => ({ + reportBadGroup: vi.fn(), + createErrorIssue: vi.fn(), + createGithubIssue: vi.fn(), + searchGithubIssues: vi.fn(), + submitGithubIssueModal: vi.fn(), + getVersionString: vi.fn(), + GitHubError: class extends Error {}, +})); + +vi.mock('../src/core/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../src/core/firebaseService.js', () => ({ + FirebaseService: { getInstance: vi.fn() }, +})); + +vi.mock('../src/core/utils.js', () => ({ + getPlayerList: vi.fn(), + getDebugPlayers: vi.fn(), + getPlayerFromMember: vi.fn(), + getWowName: vi.fn(), + getMaskedName: vi.fn((n: string) => '?'.repeat(n.length)), + showLongTyping: vi.fn().mockResolvedValue(undefined), + showShortTyping: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../src/core/groupUi.js', () => ({ + announceGroup: vi.fn().mockResolvedValue(undefined), + buildGroupEmbed: vi.fn(), +})); + +vi.mock('../src/core/preferenceService.js', () => ({ + getPreferenceService: vi.fn().mockReturnValue({ + getPreferenceSync: vi.fn().mockReturnValue(null), + getPreferenceByNameSync: vi.fn().mockReturnValue(null), + }), +})); + +import { GroupsHandler, type GroupsContext } from '../src/commands/groups.js'; +import { GroupService } from '../src/services/groupService.js'; +import type { SessionService } from '../src/services/sessionService.js'; +import { reportBadGroup } from '../src/core/issues.js'; + +function makeMockSessionService() { + return { + getOrCreateSession: vi.fn().mockResolvedValue(['123', '456']), + activeChannels: new Map(), + updateChannelPlayers: vi.fn().mockResolvedValue(undefined), + cleanupChannel: vi.fn().mockResolvedValue(undefined), + } as unknown as SessionService; +} + +function makeMockBot() { + return { + get_guild: vi.fn().mockReturnValue(null), + }; +} + +function makeCtx(overrides: Partial = {}): GroupsContext { + return { + guild: overrides.guild === undefined ? { id: 123 } : overrides.guild, + author: overrides.author ?? { + id: '1', + name: 'TestUser', + voice: { + channel: { + id: 99, + name: 'Raid', + members: [], + createInvite: vi.fn().mockResolvedValue({ url: 'http://discord.invite' }), + }, + }, + }, + send: vi.fn().mockResolvedValue(undefined), + defer: vi.fn().mockResolvedValue(undefined), + interaction: overrides.interaction === undefined ? null : overrides.interaction, + } as unknown as GroupsContext; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GroupsHandler.badgroup', () => { + it('opens modal when called via slash command without arguments', async () => { + const groupService = new GroupService(); + groupService.lastResults.set(123, { players: [], groups: [] }); + + const handler = new GroupsHandler(makeMockBot() as any, groupService, makeMockSessionService()); // eslint-disable-line @typescript-eslint/no-explicit-any + + const mockSendModal = vi.fn().mockResolvedValue(undefined); + const ctx = makeCtx({ + interaction: { response: { sendModal: mockSendModal } }, + }); + + await handler.badgroup(ctx, null, null); + + expect(mockSendModal).toHaveBeenCalledOnce(); + expect(mockSendModal).toHaveBeenCalledWith({ players: [], groups: [] }); + }); + + it('reports directly with title and description', async () => { + const groupService = new GroupService(); + groupService.lastResults.set(123, { players: [], groups: [] }); + + const handler = new GroupsHandler(makeMockBot() as any, groupService, makeMockSessionService()); // eslint-disable-line @typescript-eslint/no-explicit-any + + vi.mocked(reportBadGroup).mockResolvedValue({ html_url: 'http://url' }); + + const ctx = makeCtx(); + + await handler.badgroup(ctx, 'Title', 'Desc'); + + expect(reportBadGroup).toHaveBeenCalledOnce(); + expect(ctx.send).toHaveBeenCalledWith( + '✅ Bad group reported successfully: http://url', + { ephemeral: true }, + ); + }); + + it('reports no data when no previous results', async () => { + const groupService = new GroupService(); + // No results set + + const handler = new GroupsHandler(makeMockBot() as any, groupService, makeMockSessionService()); // eslint-disable-line @typescript-eslint/no-explicit-any + const ctx = makeCtx(); + + await handler.badgroup(ctx, null, null); + + expect(ctx.send).toHaveBeenCalledWith( + '❌ No group creation data found for this server. Run /wheel first.', + { ephemeral: true }, + ); + }); +}); + +describe('GroupsHandler.activity', () => { + it('sends activity URL with default base URL', async () => { + const handler = new GroupsHandler( + makeMockBot() as any, // eslint-disable-line @typescript-eslint/no-explicit-any + new GroupService(), + makeMockSessionService(), + ); + + const ctx = makeCtx(); + await handler.activity(ctx); + + const calls = vi.mocked(ctx.send).mock.calls; + const msgCall = calls.find( + ([msg]) => typeof msg === 'string' && msg.includes('guildId=123&channelId=456'), + ); + expect(msgCall).toBeTruthy(); + expect(msgCall![0]).toContain( + 'https://tytaniumdev.github.io/MythicPlusDiscordBot/?guildId=123&channelId=456', + ); + }); + + it('sends activity URL in debug mode', async () => { + const handler = new GroupsHandler( + makeMockBot() as any, // eslint-disable-line @typescript-eslint/no-explicit-any + new GroupService(), + makeMockSessionService(), + ); + + const ctx = makeCtx(); + await handler.activity(ctx, true); + + const calls = vi.mocked(ctx.send).mock.calls; + const msgCall = calls.find( + ([msg]) => typeof msg === 'string' && msg.includes('guildId=123&channelId=456'), + ); + expect(msgCall).toBeTruthy(); + }); +}); + +describe('GroupsHandler.onVoiceStateUpdate', () => { + it('updates tracked channels on join', async () => { + const sessionService = makeMockSessionService(); + sessionService.activeChannels.set(123, { docId: '123', guildId: 456 }); + + const handler = new GroupsHandler( + makeMockBot() as any, // eslint-disable-line @typescript-eslint/no-explicit-any + new GroupService(), + sessionService, + ); + + const guild = { id: 456, name: 'G', voice_channels: [], get_channel: vi.fn() }; + const member = { bot: false, guild }; + + // Join a tracked channel + const before = { channel: null }; + const after = { channel: { id: 123, members: [member] } }; + + await handler.onVoiceStateUpdate(member as any, before, after); // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(sessionService.updateChannelPlayers).toHaveBeenCalledWith(123, guild); + }); + + it('updates tracked channels on leave with humans remaining', async () => { + const sessionService = makeMockSessionService(); + sessionService.activeChannels.set(123, { docId: '123', guildId: 456 }); + + const handler = new GroupsHandler( + makeMockBot() as any, // eslint-disable-line @typescript-eslint/no-explicit-any + new GroupService(), + sessionService, + ); + + const guild = { id: 456, name: 'G', voice_channels: [], get_channel: vi.fn() }; + const member = { bot: false, guild }; + const human = { bot: false }; + + const before = { channel: { id: 123, members: [human] } }; + const after = { channel: null }; + + await handler.onVoiceStateUpdate(member as any, before, after); // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(sessionService.updateChannelPlayers).toHaveBeenCalledWith(123, guild); + expect(sessionService.cleanupChannel).not.toHaveBeenCalled(); + }); + + it('cleans up when last human leaves', async () => { + const sessionService = makeMockSessionService(); + sessionService.activeChannels.set(123, { docId: '123', guildId: 456 }); + + const handler = new GroupsHandler( + makeMockBot() as any, // eslint-disable-line @typescript-eslint/no-explicit-any + new GroupService(), + sessionService, + ); + + const guild = { id: 456, name: 'G', voice_channels: [], get_channel: vi.fn() }; + const member = { bot: false, guild }; + const botMember = { bot: true }; + + const before = { channel: { id: 123, members: [botMember] } }; + const after = { channel: null }; + + await handler.onVoiceStateUpdate(member as any, before, after); // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(sessionService.cleanupChannel).toHaveBeenCalledWith(123); + }); + + it('ignores same-channel events (mute/unmute)', async () => { + const sessionService = makeMockSessionService(); + sessionService.activeChannels.set(123, { docId: '123', guildId: 456 }); + + const handler = new GroupsHandler( + makeMockBot() as any, // eslint-disable-line @typescript-eslint/no-explicit-any + new GroupService(), + sessionService, + ); + + const guild = { id: 456, name: 'G', voice_channels: [], get_channel: vi.fn() }; + const member = { bot: false, guild }; + const channel = { id: 123, members: [member] }; + + const before = { channel }; + const after = { channel }; + + await handler.onVoiceStateUpdate(member as any, before, after); // eslint-disable-line @typescript-eslint/no-explicit-any + + expect(sessionService.updateChannelPlayers).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bot/tests/issues.test.ts b/packages/bot/tests/issues.test.ts new file mode 100644 index 00000000..6b9ee641 --- /dev/null +++ b/packages/bot/tests/issues.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock config before importing issues +vi.mock('../src/core/config.js', () => ({ + GITHUB_TOKEN: undefined as string | undefined, + GITHUB_REPO_OWNER: 'owner', + GITHUB_REPO_NAME: 'repo', + GIT_SHA: undefined as string | undefined, + LOG_FILE: 'mythic_bot.log', + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, +})); + +import * as config from '../src/core/config.js'; +import { + GitHubError, + createGithubIssue, + submitGithubIssueModal, + getVersionString, +} from '../src/core/issues.js'; + +function setConfig(overrides: Partial) { + Object.assign(config, overrides); +} + +describe('createGithubIssue', () => { + beforeEach(() => { + setConfig({ GITHUB_TOKEN: undefined }); + }); + + it('succeeds on 201', async () => { + setConfig({ GITHUB_TOKEN: 'fake_token' }); + + global.fetch = vi.fn().mockResolvedValue({ + status: 201, + json: vi.fn().mockResolvedValue({ html_url: 'http://github.com/issue/1' }), + }); + + const result = await createGithubIssue('Title', 'Body', ['bug']); + expect(result.html_url).toBe('http://github.com/issue/1'); + }); + + it('throws on failure', async () => { + setConfig({ GITHUB_TOKEN: 'fake_token' }); + + global.fetch = vi.fn().mockResolvedValue({ + status: 401, + text: vi.fn().mockResolvedValue('Unauthorized'), + }); + + await expect(createGithubIssue('Title', 'Body', ['bug'])).rejects.toThrow( + GitHubError, + ); + }); +}); + +describe('submitGithubIssueModal', () => { + beforeEach(() => { + setConfig({ GITHUB_TOKEN: 'fake_token', GIT_SHA: 'abc123456' }); + }); + + it('submits bug report with version string', async () => { + global.fetch = vi.fn().mockResolvedValue({ + status: 201, + json: vi.fn().mockResolvedValue({ html_url: 'http://url' }), + }); + + const result = await submitGithubIssueModal({ + issueType: 'bug', + title: 'Bug Title', + description: 'Bug Description', + extraInfo: 'Steps', + includeLogs: false, + reporterName: 'TestUser', + reporterId: 12345, + }); + + expect(result.html_url).toBe('http://url'); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(fetchCall[1]!.body as string) as { + title: string; + body: string; + labels: string[]; + }; + expect(body.title).toBe('Bug Title'); + expect(body.body).toContain('Bug Description'); + expect(body.body).toContain('Steps'); + expect(body.labels).toEqual(['bug', 'jules']); + + const expectedVersion = + '[`abc1234`](https://github.com/owner/repo/commit/abc123456)'; + expect(body.body).toContain(`**Version:** ${expectedVersion}`); + }); + + it('handles failure', async () => { + global.fetch = vi.fn().mockResolvedValue({ + status: 500, + text: vi.fn().mockResolvedValue('Error'), + }); + + await expect( + submitGithubIssueModal({ + issueType: 'bug', + title: 'Bug Title', + description: 'Bug Description', + extraInfo: '', + includeLogs: false, + reporterName: 'TestUser', + reporterId: 12345, + }), + ).rejects.toThrow(GitHubError); + }); +}); + +describe('getVersionString', () => { + it('formats version with SHA', () => { + setConfig({ GIT_SHA: 'abc123456' }); + expect(getVersionString()).toBe( + '[`abc1234`](https://github.com/owner/repo/commit/abc123456)', + ); + }); + + it('returns unknown without SHA', () => { + setConfig({ GIT_SHA: undefined }); + expect(getVersionString()).toBe('unknown'); + }); +}); diff --git a/packages/bot/tests/models.test.ts b/packages/bot/tests/models.test.ts new file mode 100644 index 00000000..d45152b4 --- /dev/null +++ b/packages/bot/tests/models.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + WoWPlayer, + WoWGroup, + ROLE_TANK, + ROLE_HEALER, + ROLE_RANGED, + ROLE_MELEE, + ROLE_TANK_OFFSPEC, + ROLE_HEALER_OFFSPEC, + ROLE_RANGED_OFFSPEC, + ROLE_MELEE_OFFSPEC, + ROLE_BREZ, + ROLE_LUST, +} from '@mythicplus/shared'; + +describe('WoWPlayer', () => { + it('creates a tank', () => { + const player = WoWPlayer.create('TankPlayer', [ROLE_TANK]); + expect(player.tankMain).toBe(true); + expect(player.healerMain).toBe(false); + expect(player.dpsMain).toBe(false); + }); + + it('creates a healer', () => { + const player = WoWPlayer.create('HealerPlayer', [ROLE_HEALER]); + expect(player.tankMain).toBe(false); + expect(player.healerMain).toBe(true); + expect(player.dpsMain).toBe(false); + }); + + it('creates DPS variations', () => { + const ranged = WoWPlayer.create('RangedPlayer', [ROLE_RANGED]); + expect(ranged.dpsMain).toBe(true); + expect(ranged.ranged).toBe(true); + expect(ranged.melee).toBe(false); + + const melee = WoWPlayer.create('MeleePlayer', [ROLE_MELEE]); + expect(melee.dpsMain).toBe(true); + expect(melee.melee).toBe(true); + expect(melee.ranged).toBe(false); + }); + + it('creates offspecs', () => { + const player = WoWPlayer.create('OffspecPlayer', [ + ROLE_TANK_OFFSPEC, + ROLE_HEALER_OFFSPEC, + ROLE_RANGED_OFFSPEC, + ]); + expect(player.offtank).toBe(true); + expect(player.offhealer).toBe(true); + expect(player.offdps).toBe(true); + expect(player.offranged).toBe(true); + }); + + it('creates offspec melee', () => { + const player = WoWPlayer.create('OffMelee', [ROLE_MELEE_OFFSPEC]); + expect(player.offdps).toBe(true); + expect(player.offmelee).toBe(true); + expect(player.offranged).toBe(false); + }); + + it('creates utilities', () => { + const player = WoWPlayer.create('UtilPlayer', [ROLE_BREZ, ROLE_LUST]); + expect(player.hasBrez).toBe(true); + expect(player.hasLust).toBe(true); + }); + + it('checks hasRoles', () => { + const noRole = WoWPlayer.create('NoRole', []); + expect(noRole.hasRoles()).toBe(false); + + const hasRole = WoWPlayer.create('HasRole', [ROLE_TANK]); + expect(hasRole.hasRoles()).toBe(true); + + const offspecOnly = WoWPlayer.create('OffspecOnly', [ROLE_MELEE_OFFSPEC]); + expect(offspecOnly.hasRoles()).toBe(true); + }); + + it('creates with discord ID', () => { + const player = WoWPlayer.create('TestPlayer', [ROLE_TANK], '12345'); + expect(player.discordId).toBe('12345'); + expect(player.tankMain).toBe(true); + }); + + it('defaults discord ID to empty', () => { + const player = WoWPlayer.create('TestPlayer', [ROLE_TANK]); + expect(player.discordId).toBe(''); + }); + + it('serializes and deserializes', () => { + const original = WoWPlayer.create('TestPlayer', [ROLE_TANK, ROLE_BREZ], '99999'); + const data = original.toDict(); + const restored = WoWPlayer.fromDict(data); + + expect(original.name).toBe(restored.name); + expect(original.discordId).toBe(restored.discordId); + expect(original.tankMain).toBe(restored.tankMain); + expect(original.hasBrez).toBe(restored.hasBrez); + expect(original.equals(restored)).toBe(true); + expect(data.discordId).toBe('99999'); + }); + + it('deserializes with missing discord ID', () => { + const data = { + name: 'Legacy', + roles: { tankMain: true }, + }; + const player = WoWPlayer.fromDict(data); + expect(player.discordId).toBe(''); + expect(player.tankMain).toBe(true); + }); +}); + +describe('WoWGroup', () => { + let tank: WoWPlayer; + let healer: WoWPlayer; + let dps1: WoWPlayer; + let dps2: WoWPlayer; + let dps3: WoWPlayer; + let lustPlayer: WoWPlayer; + + beforeEach(() => { + tank = WoWPlayer.create('Tank', [ROLE_TANK]); + healer = WoWPlayer.create('Healer', [ROLE_HEALER]); + dps1 = WoWPlayer.create('DPS1', [ROLE_MELEE]); + dps2 = WoWPlayer.create('DPS2', [ROLE_RANGED]); + dps3 = WoWPlayer.create('DPS3', [ROLE_MELEE, ROLE_BREZ]); + lustPlayer = WoWPlayer.create('LustPlayer', [ROLE_RANGED, ROLE_LUST]); + }); + + it('computes group properties', () => { + const group = new WoWGroup(tank, healer, [dps1, dps2, dps3]); + + expect(group.isComplete).toBe(true); + expect(group.size).toBe(5); + expect(group.hasBrez).toBe(true); + expect(group.hasRanged).toBe(true); + expect(group.hasLust).toBe(false); + }); + + it('detects incomplete group', () => { + const group = new WoWGroup(tank, healer, [dps1]); + expect(group.isComplete).toBe(false); + expect(group.size).toBe(3); + }); + + it('detects lust', () => { + const group = new WoWGroup(tank, healer, [dps1, dps2, lustPlayer]); + expect(group.hasLust).toBe(true); + }); + + it('serializes', () => { + const group = new WoWGroup(tank, healer, [dps1]); + const data = group.toDict(); + + expect(data.tank!.name).toBe('Tank'); + expect(data.healer!.name).toBe('Healer'); + expect(data.dps.length).toBe(1); + expect(data.dps[0].name).toBe('DPS1'); + }); + + it('round-trip serializes', () => { + const group = new WoWGroup(tank, healer, [dps1, dps2, dps3]); + const data = group.toDict(); + const restored = WoWGroup.fromDict(data); + + expect(restored.tank!.equals(group.tank!)).toBe(true); + expect(restored.healer!.equals(group.healer!)).toBe(true); + expect(restored.dps.length).toBe(3); + for (let i = 0; i < group.dps.length; i++) { + expect(restored.dps[i].equals(group.dps[i])).toBe(true); + } + expect(restored.hasBrez).toBe(group.hasBrez); + expect(restored.hasLust).toBe(group.hasLust); + }); + + it('deserializes empty group', () => { + const data = { tank: null, healer: null, dps: [] }; + const restored = WoWGroup.fromDict(data); + + expect(restored.tank).toBeNull(); + expect(restored.healer).toBeNull(); + expect(restored.dps).toEqual([]); + }); +}); diff --git a/packages/bot/tests/parallelGroupCreator.test.ts b/packages/bot/tests/parallelGroupCreator.test.ts new file mode 100644 index 00000000..5f208b95 --- /dev/null +++ b/packages/bot/tests/parallelGroupCreator.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + WoWPlayer, + WoWGroup, + clear, + setLastGroups, + createMythicPlusGroups, +} from '@mythicplus/shared'; +import { + TankWarrior, + TankDeathKnight, + HealerDruid, + HealerPriest, + Mage, + Paladin, + BalanceDruid, + FeralDruid, + Warrior, +} from './prebuiltClasses.js'; + +describe('GroupCreator', () => { + // Real example players + let cynoc: WoWPlayer; + let gazzi: WoWPlayer; + let temma: WoWPlayer; + let moriim: WoWPlayer; + let sorovar: WoWPlayer; + let selinora: WoWPlayer; + let tytanium: WoWPlayer; + let widdershins: WoWPlayer; + let bevan: WoWPlayer; + let poppybros: WoWPlayer; + let mickey: WoWPlayer; + let johng: WoWPlayer; + let justine: WoWPlayer; + let raxef: WoWPlayer; + let kat: WoWPlayer; + + beforeEach(() => { + clear(); + cynoc = WoWPlayer.create('Cynoc', ['Tank', 'Melee Offspec']); + gazzi = WoWPlayer.create('Gazzi', ['Tank', 'Brez']); + temma = WoWPlayer.create('Temma', ['Tank', 'Melee', 'Brez']); + moriim = WoWPlayer.create('Moriim', [ + 'Tank Offspec', + 'Healer Offspec', + 'Melee', + 'Ranged', + ]); + sorovar = WoWPlayer.create('Sorovar', ['Healer']); + selinora = WoWPlayer.create('Selinora', ['Healer']); + tytanium = WoWPlayer.create('Tytanium', ['Healer Offspec', 'Melee', 'Brez']); + widdershins = WoWPlayer.create('Widdershins', [ + 'Healer Offspec', + 'Ranged', + 'Lust', + ]); + bevan = WoWPlayer.create('Bevan', ['Ranged']); + poppybros = WoWPlayer.create('Poppybros', ['Ranged', 'Lust']); + mickey = WoWPlayer.create('Mickey', ['Melee']); + johng = WoWPlayer.create('John G.', ['Melee', 'Brez']); + justine = WoWPlayer.create('Justine', ['Melee', 'Brez']); + raxef = WoWPlayer.create('Raxef', ['Melee']); + kat = WoWPlayer.create('Kat', ['Melee']); + }); + + afterEach(() => { + clear(); + }); + + it('handles real world scenario', () => { + const players = [ + cynoc, gazzi, temma, moriim, sorovar, selinora, + tytanium, widdershins, bevan, poppybros, mickey, + johng, justine, raxef, kat, + ]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(3); + for (const group of groups) { + expect(group.isComplete).toBe(true); + } + + const utilityGroups = groups.filter((g) => g.hasBrez && g.hasLust); + expect(utilityGroups.length).toBeGreaterThanOrEqual(2); + + for (const group of groups) { + expect(group.hasBrez).toBe(true); + } + }); + + it('handles small incomplete group', () => { + const players = [gazzi, sorovar, tytanium, poppybros, raxef, temma, johng]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(2); + expect(groups[0].isComplete).toBe(true); + }); + + it('handles smallest incomplete group with just dps', () => { + const players = [gazzi, sorovar, tytanium, poppybros, raxef, johng]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(2); + expect(groups[0].isComplete).toBe(true); + }); + + it('handles smallest incomplete group with just a tank', () => { + const players = [gazzi, sorovar, tytanium, poppybros, raxef, temma]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(2); + expect(groups[0].isComplete).toBe(true); + }); + + it('handles smallest incomplete group with just a healer', () => { + const players = [gazzi, sorovar, tytanium, poppybros, raxef, selinora]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(2); + expect(groups[0].isComplete).toBe(true); + }); + + it('distributes utilities', () => { + const players = [ + TankWarrior('Tank1'), + TankDeathKnight('Brez1'), + HealerDruid('Brez2'), + HealerPriest('Healer2'), + Mage('Lust1'), + Mage('Lust2'), + Warrior('Warrior1'), + Warrior('Warrior2'), + FeralDruid('Feral1'), + FeralDruid('Feral2'), + ]; + const groups = createMythicPlusGroups(players); + + for (const group of groups) { + expect(group.hasBrez && group.hasLust).toBe(true); + } + }); + + it('uses offspecs when main specs exhausted', () => { + const players = [ + TankWarrior('Tank1'), + Paladin('Offtank', { offtank: true }), + HealerDruid('Healer1'), + BalanceDruid('Offhealer', { offhealer: true }), + Mage('Mage1'), + Mage('Mage2'), + Warrior('Warrior1'), + Warrior('Warrior2'), + FeralDruid('Feral1'), + FeralDruid('Feral2'), + ]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(2); + for (const group of groups) { + expect(group.isComplete).toBe(true); + } + }); + + it('balances ranged and melee', () => { + const players = [ + TankWarrior('Tank1'), + TankWarrior('Tank2'), + HealerDruid('Healer1'), + HealerDruid('Healer2'), + Mage('Mage1'), + Mage('Mage2'), + Warrior('Warrior1'), + Warrior('Warrior2'), + FeralDruid('Feral1'), + FeralDruid('Feral2'), + ]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(2); + for (const group of groups) { + expect(group.hasRanged).toBe(true); + } + }); + + it('handles weird remainder groups', () => { + const players = [ + TankWarrior('Tank1'), + TankWarrior('Tank2'), + TankWarrior('Tank3'), + TankWarrior('Tank4'), + HealerDruid('Healer1'), + Mage('Mage1'), + Mage('Mage2'), + Warrior('Warrior1'), + Warrior('Warrior3'), + Warrior('Warrior5'), + Warrior('Warrior2'), + FeralDruid('Feral1', { offhealer: true }), + FeralDruid('Feral2'), + ]; + const groups = createMythicPlusGroups(players); + + expect(groups.length).toBe(4); + expect(groups[2].size).toBe(1); + expect(groups[3].size).toBe(2); + }); + + it('avoids old teammates when possible', () => { + const tank = TankWarrior('Tank'); + const healer = HealerPriest('Healer'); + const dps1 = Warrior('DPS1'); + const dps2 = Warrior('DPS2'); + const dps3 = Warrior('DPS3'); + const dps4 = Warrior('DPS4'); + const dps5 = Warrior('DPS5'); + const dps6 = Warrior('DPS6'); + + // Setup history: Tank played with DPS1, DPS2, DPS3 + const g1 = new WoWGroup(); + g1.tank = tank; + g1.dps = [dps1, dps2, dps3]; + setLastGroups([g1]); + + const allPlayers = [tank, healer, dps1, dps2, dps3, dps4, dps5, dps6]; + const groups = createMythicPlusGroups(allPlayers); + + expect(groups.length).toBeGreaterThanOrEqual(1); + const group = groups[0]; + + expect(group.tank!.equals(tank)).toBe(true); + expect(group.healer!.equals(healer)).toBe(true); + + const dpsNames = new Set(group.dps.map((p) => p.name)); + const expectedFreshDps = new Set(['DPS4', 'DPS5', 'DPS6']); + const intersection = new Set([...dpsNames].filter((n) => expectedFreshDps.has(n))); + expect(intersection.size).toBe(3); + }); +}); diff --git a/packages/bot/tests/prebuiltClasses.ts b/packages/bot/tests/prebuiltClasses.ts new file mode 100644 index 00000000..f60b21ef --- /dev/null +++ b/packages/bot/tests/prebuiltClasses.ts @@ -0,0 +1,211 @@ +import { WoWPlayer } from '@mythicplus/shared'; + +// Tanks +export function TankPaladin( + name: string, + { offhealer = false, offdps = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + tankMain: true, + hasBrez: true, + offhealer, + offdps, + }); +} + +export function TankWarrior(name: string, { offdps = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, tankMain: true, offdps }); +} + +export function TankDruid( + name: string, + { offhealer = false, offdps = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + tankMain: true, + hasBrez: true, + offhealer, + offdps, + }); +} + +export function TankDeathKnight(name: string, { offdps = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, tankMain: true, hasBrez: true, offdps }); +} + +export function TankMonk( + name: string, + { offhealer = false, offdps = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ name, tankMain: true, offhealer, offdps }); +} + +export function TankDemonHunter(name: string, { offdps = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, tankMain: true, offdps }); +} + +// Healers +export function HealerPaladin( + name: string, + { offtank = false, offdps = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + healerMain: true, + hasBrez: true, + offtank, + offdps, + }); +} + +export function HealerPriest(name: string, { offdps = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, healerMain: true, offdps }); +} + +export function HealerShaman(name: string, { offdps = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, healerMain: true, hasLust: true, offdps }); +} + +export function HealerDruid( + name: string, + { offtank = false, offdps = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + healerMain: true, + hasBrez: true, + offtank, + offdps, + }); +} + +export function HealerEvoker(name: string, { offdps = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, healerMain: true, hasLust: true, offdps }); +} + +export function HealerMonk( + name: string, + { offtank = false, offdps = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ name, healerMain: true, offtank, offdps }); +} + +// DPS +export function DeathKnight(name: string, { offtank = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + melee: true, + hasBrez: true, + offtank, + }); +} + +export function DemonHunter(name: string, { offtank = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, dpsMain: true, melee: true, offtank }); +} + +export function BalanceDruid( + name: string, + { offtank = false, offhealer = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + ranged: true, + hasBrez: true, + offhealer, + offtank, + }); +} + +export function FeralDruid( + name: string, + { offtank = false, offhealer = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + melee: true, + hasBrez: true, + offhealer, + offtank, + }); +} + +export function Evoker(name: string, { offhealer = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + ranged: true, + hasLust: true, + offhealer, + }); +} + +export function Hunter(name: string): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + ranged: true, + hasLust: true, + }); +} + +export function Mage(name: string): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + ranged: true, + hasLust: true, + }); +} + +export function Monk( + name: string, + { offtank = false, offhealer = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ name, dpsMain: true, melee: true, offhealer, offtank }); +} + +export function Paladin( + name: string, + { offtank = false, offhealer = false } = {}, +): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + melee: true, + hasBrez: true, + offhealer, + offtank, + }); +} + +export function Priest(name: string, { offhealer = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, dpsMain: true, ranged: true, offhealer }); +} + +export function Rogue(name: string): WoWPlayer { + return WoWPlayer.fromFlags({ name, dpsMain: true, melee: true }); +} + +export function Shaman(name: string, { offhealer = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ + name, + dpsMain: true, + ranged: true, + hasLust: true, + offhealer, + }); +} + +export function Warlock(name: string): WoWPlayer { + return WoWPlayer.fromFlags({ name, dpsMain: true, ranged: true }); +} + +export function Warrior(name: string, { offtank = false } = {}): WoWPlayer { + return WoWPlayer.fromFlags({ name, dpsMain: true, melee: true, offtank }); +} diff --git a/packages/bot/tests/preferenceService.test.ts b/packages/bot/tests/preferenceService.test.ts new file mode 100644 index 00000000..82ac9f70 --- /dev/null +++ b/packages/bot/tests/preferenceService.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { PreferenceService } from '../src/core/preferenceService.js'; +import { FirebaseService } from '../src/core/firebaseService.js'; + +function createMockFirebase(available: boolean): FirebaseService { + const fb = Object.create(FirebaseService.prototype) as FirebaseService; + fb.db = null; + fb.isAvailable = vi.fn().mockReturnValue(available); + return fb; +} + +describe('PreferenceService', () => { + let svc: PreferenceService; + + beforeEach(() => { + const mockFb = createMockFirebase(false); + svc = new PreferenceService(mockFb); + }); + + it('sets and gets preference sync', async () => { + vi.mock('../src/core/storage.js', async (importOriginal) => { + const original = (await importOriginal()) as Record; + return { + ...original, + setPlayerPreference: vi.fn(), + clearPlayerPreference: vi.fn(), + getPlayerPreference: vi.fn().mockReturnValue(null), + getAllPreferences: vi.fn().mockReturnValue({}), + }; + }); + + await svc.setPreference('123', 'Martz', ['Tank', 'Brez']); + const result = svc.getPreferenceSync('123'); + expect(result).toEqual(['Tank', 'Brez']); + }); + + it('gets by name sync', async () => { + await svc.setPreference('123', 'Martz', ['Healer']); + const result = svc.getPreferenceByNameSync('Martz'); + expect(result).toEqual(['Healer']); + }); + + it('clears preference', async () => { + await svc.setPreference('123', 'Martz', ['Tank']); + await svc.clearPreference('123'); + + expect(svc.getPreferenceSync('123')).toBeNull(); + expect(svc.getPreferenceByNameSync('Martz')).toBeNull(); + }); + + it('loads cache with local fallback', async () => { + const mockFb = createMockFirebase(false); + const localSvc = new PreferenceService(mockFb); + + // Mock the internal _loadFromLocal by providing mock storage + const { getAllPreferences } = await import('../src/core/storage.js'); + vi.mocked(getAllPreferences).mockReturnValue({ + Martz: ['Tank'], + Tytanium: ['Ranged'], + }); + + await localSvc.loadCache(); + + expect(localSvc.getPreferenceByNameSync('Martz')).toEqual(['Tank']); + expect(localSvc.getPreferenceByNameSync('Tytanium')).toEqual(['Ranged']); + }); + + it('refreshes preference and updates cache', async () => { + const mockFb = createMockFirebase(true); + const refreshSvc = new PreferenceService(mockFb); + + // Set initial state + (refreshSvc as unknown as { _cache: Record })._cache = { + '123': ['Tank'], + }; + (refreshSvc as unknown as { _nameToId: Record })._nameToId = { + OldName: '123', + }; + + // Mock the Firestore read + refreshSvc._readFirestorePref = vi + .fn() + .mockResolvedValue({ roles: ['Ranged'], wowName: 'NewName' }); + + await refreshSvc.refreshPreference('123'); + + expect(refreshSvc.getPreferenceSync('123')).toEqual(['Ranged']); + expect(refreshSvc.getPreferenceByNameSync('NewName')).toEqual(['Ranged']); + expect(refreshSvc.getPreferenceByNameSync('OldName')).toBeNull(); + }); + + it('refreshes preference removes on delete', async () => { + const mockFb = createMockFirebase(true); + const refreshSvc = new PreferenceService(mockFb); + + (refreshSvc as unknown as { _cache: Record })._cache = { + '123': ['Tank'], + }; + (refreshSvc as unknown as { _nameToId: Record })._nameToId = { + Martz: '123', + }; + + refreshSvc._readFirestorePref = vi.fn().mockResolvedValue(null); + + await refreshSvc.refreshPreference('123'); + + expect(refreshSvc.getPreferenceSync('123')).toBeNull(); + expect(refreshSvc.getPreferenceByNameSync('Martz')).toBeNull(); + }); +}); diff --git a/packages/bot/tests/roleUi.test.ts b/packages/bot/tests/roleUi.test.ts new file mode 100644 index 00000000..186a2268 --- /dev/null +++ b/packages/bot/tests/roleUi.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect } from 'vitest'; +import { ROLE_TANK, ROLE_HEALER, ROLE_TANK_OFFSPEC, ROLE_HEALER_OFFSPEC } from '@mythicplus/shared'; +import { + createMainSpecView, + createOffspecView, + createUtilitiesView, + createRoleBoardEmbed, + createRoleCheckEmbed, + handleRoleButtonClick, + handleNoneButtonClick, + handleNextButtonClick, + type RoleSelectionState, + type RoleButtonData, + type NoneButtonData, + type NextButtonData, + type PlayerRoleInfo, +} from '../src/core/roleUi.js'; + +function makeState(selectedRoles: string[] = []): RoleSelectionState { + return { + playerName: 'TestPlayer', + discordId: '12345', + selectedRoles: new Set(selectedRoles), + views: [], + stepContents: [], + }; +} + +describe('createRoleBoardEmbed', () => { + it('has correct title and description', () => { + const embed = createRoleBoardEmbed([]); + expect(embed.title).toBe('Mythic+ Role Board'); + expect(embed.description).toBe('Current channel roster'); + expect(embed.color).toBe(0xf1c40f); + }); +}); + +describe('createRoleCheckEmbed', () => { + it('lists players and roles', () => { + const infos: PlayerRoleInfo[] = [ + { name: 'Player1', roles: ['Tank', 'Healer'] }, + { name: 'Player2', roles: ['No roles set'] }, + ]; + const embed = createRoleCheckEmbed(infos); + + expect(embed.title).toBe('Saved Roles Check'); + expect(embed.color).toBe(0x3498db); + expect(embed.fields.length).toBe(2); + expect(embed.fields[0].name).toBe('Player1'); + expect(embed.fields[0].value).toBe('Tank, Healer'); + expect(embed.fields[1].name).toBe('Player2'); + expect(embed.fields[1].value).toBe('No roles set'); + }); +}); + +describe('MainSpecView', () => { + it('initializes with pre-selected roles', () => { + const state = makeState([ROLE_TANK, 'Brez']); + const view = createMainSpecView(state, 'test'); + + const tankBtn = view.buttons.find( + (b) => 'roleName' in b && b.roleName === ROLE_TANK, + ) as RoleButtonData; + expect(tankBtn.style).toBe('primary'); + + const healerBtn = view.buttons.find( + (b) => 'roleName' in b && b.roleName === ROLE_HEALER, + ) as RoleButtonData; + expect(healerBtn.style).toBe('secondary'); + }); +}); + +describe('handleRoleButtonClick', () => { + it('toggles role on and off', () => { + const state = makeState(); + const view = createMainSpecView(state, 'test'); + + // Click tank on + handleRoleButtonClick(state, view.buttons, ROLE_TANK, true); + expect(state.selectedRoles.has(ROLE_TANK)).toBe(true); + const tankBtn = view.buttons.find( + (b) => 'roleName' in b && b.roleName === ROLE_TANK, + ) as RoleButtonData; + expect(tankBtn.style).toBe('primary'); + + // Click tank off + handleRoleButtonClick(state, view.buttons, ROLE_TANK, true); + expect(state.selectedRoles.has(ROLE_TANK)).toBe(false); + expect(tankBtn.style).toBe('secondary'); + }); + + it('enforces main spec mutual exclusivity', () => { + const state = makeState(); + const view = createMainSpecView(state, 'test'); + + handleRoleButtonClick(state, view.buttons, ROLE_TANK, true); + expect(state.selectedRoles.has(ROLE_TANK)).toBe(true); + + // Selecting healer should deselect tank + handleRoleButtonClick(state, view.buttons, ROLE_HEALER, true); + expect(state.selectedRoles.has(ROLE_HEALER)).toBe(true); + expect(state.selectedRoles.has(ROLE_TANK)).toBe(false); + + const tankBtn = view.buttons.find( + (b) => 'roleName' in b && b.roleName === ROLE_TANK, + ) as RoleButtonData; + expect(tankBtn.style).toBe('secondary'); + }); +}); + +describe('shared state across views', () => { + it('modifying one view affects the other via shared state', () => { + const state = makeState(); + const mainView = createMainSpecView(state, 'test_main'); + const offspecView = createOffspecView(state, 'test_off'); + + handleRoleButtonClick(state, mainView.buttons, ROLE_TANK, true); + handleRoleButtonClick(state, offspecView.buttons, ROLE_HEALER_OFFSPEC, false); + + expect(state.selectedRoles.has(ROLE_TANK)).toBe(true); + expect(state.selectedRoles.has(ROLE_HEALER_OFFSPEC)).toBe(true); + }); +}); + +describe('NextButton', () => { + it('advances to next view', () => { + const state = makeState(); + const mainView = createMainSpecView(state, 'test_main'); + const offspecView = createOffspecView(state, 'test_off'); + const utilitiesView = createUtilitiesView(state, 'test_util'); + state.views = [mainView, offspecView, utilitiesView]; + state.stepContents = [ + 'Select your roles for **TestPlayer**:\n**Main Spec** (pick one)', + '**Offspec** (pick any)', + '**Utilities**', + ]; + + const result = handleNextButtonClick(state, mainView.buttons); + + expect(result).not.toBeNull(); + expect(result!.nextView).toBe(offspecView); + expect(result!.content).toBe('**Offspec** (pick any)'); + + // Next button should be disabled + const nextBtn = mainView.buttons.find( + (b) => 'disabled' in b && 'label' in b && (b as NextButtonData).label === 'Next →', + ) as NextButtonData; + expect(nextBtn.disabled).toBe(true); + }); +}); + +describe('NoneButton', () => { + it('starts highlighted when no offspec roles', () => { + const state = makeState(); + const view = createOffspecView(state, 'test_off'); + const noneBtn = view.buttons.find((b) => 'clearRoles' in b) as NoneButtonData; + expect(noneBtn.style).toBe('primary'); + }); + + it('starts secondary when offspec roles exist', () => { + const state = makeState([ROLE_TANK_OFFSPEC]); + const view = createOffspecView(state, 'test_off'); + const noneBtn = view.buttons.find((b) => 'clearRoles' in b) as NoneButtonData; + expect(noneBtn.style).toBe('secondary'); + }); + + it('clears all offspec roles', () => { + const state = makeState([ROLE_TANK_OFFSPEC, ROLE_HEALER_OFFSPEC]); + const view = createOffspecView(state, 'test_off'); + + const noneBtn = view.buttons.find((b) => 'clearRoles' in b) as NoneButtonData; + handleNoneButtonClick(state, view.buttons, noneBtn.clearRoles); + + expect(state.selectedRoles.has(ROLE_TANK_OFFSPEC)).toBe(false); + expect(state.selectedRoles.has(ROLE_HEALER_OFFSPEC)).toBe(false); + expect(noneBtn.style).toBe('primary'); + + // All role buttons reset + for (const btn of view.buttons) { + if ('roleName' in btn) { + expect((btn as RoleButtonData).style).toBe('secondary'); + } + } + }); + + it('is deselected when a role is clicked', () => { + const state = makeState(); + const view = createOffspecView(state, 'test_off'); + const noneBtn = view.buttons.find((b) => 'clearRoles' in b) as NoneButtonData; + expect(noneBtn.style).toBe('primary'); + + // Click an offspec role + handleRoleButtonClick(state, view.buttons, ROLE_TANK_OFFSPEC, false); + expect(noneBtn.style).toBe('secondary'); + expect(state.selectedRoles.has(ROLE_TANK_OFFSPEC)).toBe(true); + }); +}); + +describe('UtilitiesView', () => { + it('creates with correct buttons', () => { + const state = makeState(); + const view = createUtilitiesView(state, 'test_util'); + expect(view.buttons.length).toBe(2); + expect(view.type).toBe('utilities'); + }); +}); diff --git a/packages/bot/tests/rolesCommand.test.ts b/packages/bot/tests/rolesCommand.test.ts new file mode 100644 index 00000000..73f08e6e --- /dev/null +++ b/packages/bot/tests/rolesCommand.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../src/core/utils.js', () => ({ + getPlayerList: vi.fn(), + getWowName: vi.fn(), + getDebugPlayers: vi.fn(), + getPlayerFromMember: vi.fn(), + getMaskedName: vi.fn(), + showLongTyping: vi.fn().mockResolvedValue(undefined), + showShortTyping: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../src/core/roleUi.js', () => ({ + createRoleBoardEmbed: vi.fn().mockReturnValue({ title: 'Test Board', fields: [] }), + createRoleCheckEmbed: vi.fn(), + createMainSpecView: vi.fn(), + createOffspecView: vi.fn(), + createUtilitiesView: vi.fn(), + handleRoleButtonClick: vi.fn(), + handleNoneButtonClick: vi.fn(), + handleNextButtonClick: vi.fn(), +})); + +vi.mock('../src/core/preferenceService.js', () => { + const mockSvc = { + getPreferenceSync: vi.fn().mockReturnValue(null), + getPreferenceByNameSync: vi.fn().mockReturnValue(null), + clearPreference: vi.fn().mockResolvedValue(undefined), + resolveDiscordId: vi.fn().mockReturnValue(null), + }; + return { + getPreferenceService: vi.fn().mockReturnValue(mockSvc), + PreferenceService: vi.fn(), + _resetInstance: vi.fn(), + }; +}); + +vi.mock('../src/core/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../src/core/firebaseService.js', () => ({ + FirebaseService: { getInstance: vi.fn() }, +})); + +import { RolesHandler, type RolesContext } from '../src/commands/roles.js'; +import { getPlayerList, getWowName } from '../src/core/utils.js'; +import { createRoleBoardEmbed, createRoleCheckEmbed } from '../src/core/roleUi.js'; +import { getPreferenceService } from '../src/core/preferenceService.js'; + +function makeCtx(overrides: Partial = {}): RolesContext { + return { + guild: overrides.guild === undefined ? { id: 1 } : overrides.guild, + author: overrides.author ?? { + id: '111', + nick: 'TestUser', + toString: () => 'TestUser', + voice: { + channel: { + id: 42, + members: [], + }, + }, + }, + channel: overrides.channel ?? { members: [] }, + send: vi.fn().mockResolvedValue(undefined), + interaction: overrides.interaction, + } as unknown as RolesContext; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('RolesHandler.launchRoleBoard', () => { + it('sends role board embed for voice channel', async () => { + const handler = new RolesHandler(); + + const member = { bot: false, nick: 'P1', id: '1', toString: () => 'P1' }; + const ctx = makeCtx({ + author: { + id: '111', + nick: 'TestUser', + toString: () => 'TestUser', + voice: { channel: { id: 42, members: [member] } }, + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + vi.mocked(getPlayerList).mockReturnValue([]); + + await handler.launchRoleBoard(ctx); + + expect(getPlayerList).toHaveBeenCalledOnce(); + expect(createRoleBoardEmbed).toHaveBeenCalledOnce(); + expect(ctx.send).toHaveBeenCalledOnce(); + const callArgs = vi.mocked(ctx.send).mock.calls[0]; + expect(callArgs[1]).toHaveProperty('embed'); + }); + + it('rejects when used outside a guild', async () => { + const handler = new RolesHandler(); + const ctx = makeCtx({ guild: null }); + + await handler.launchRoleBoard(ctx); + + expect(ctx.send).toHaveBeenCalledWith('❌ This command can only be used in a server.'); + }); +}); + +describe('RolesHandler.rolecheck', () => { + it('shows saved roles for members', async () => { + const handler = new RolesHandler(); + + const member = { bot: false, nick: 'TestPlayer', id: 111, toString: () => 'TestPlayer' }; + const ctx = makeCtx({ + author: { + id: '111', + nick: 'TestUser', + toString: () => 'TestUser', + voice: { channel: { id: 42, members: [member] } }, + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + vi.mocked(getWowName).mockReturnValue('TestPlayer'); + + const mockSvc = getPreferenceService(); + vi.mocked(mockSvc.getPreferenceSync).mockReturnValue(['Tank', 'Healer']); + + const mockEmbed = { + title: 'Saved Roles Check', + fields: [{ name: 'TestPlayer', value: 'Tank, Healer' }], + }; + vi.mocked(createRoleCheckEmbed).mockReturnValue(mockEmbed as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + await handler.rolecheck(ctx); + + expect(ctx.send).toHaveBeenCalledOnce(); + const callArgs = vi.mocked(ctx.send).mock.calls[0]; + expect(callArgs[1]).toHaveProperty('embed'); + expect((callArgs[1] as any).embed.fields[0].name).toBe('TestPlayer'); // eslint-disable-line @typescript-eslint/no-explicit-any + expect((callArgs[1] as any).embed.fields[0].value).toBe('Tank, Healer'); // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + it('shows "No roles set" for unsaved members', async () => { + const handler = new RolesHandler(); + + const member = { bot: false, nick: 'NewUser', id: 222, toString: () => 'NewUser' }; + const ctx = makeCtx({ + author: { + id: '222', + nick: 'NewUser', + toString: () => 'NewUser', + voice: { channel: { id: 42, members: [member] } }, + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + vi.mocked(getWowName).mockReturnValue('NewUser'); + + const mockSvc = getPreferenceService(); + vi.mocked(mockSvc.getPreferenceSync).mockReturnValue(null); + vi.mocked(mockSvc.getPreferenceByNameSync).mockReturnValue(null); + + const mockEmbed = { + title: 'Saved Roles Check', + fields: [{ name: 'NewUser', value: 'No roles set' }], + }; + vi.mocked(createRoleCheckEmbed).mockReturnValue(mockEmbed as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + await handler.rolecheck(ctx); + + expect(ctx.send).toHaveBeenCalledOnce(); + // Verify createRoleCheckEmbed was called with "No roles set" + const callArg = vi.mocked(createRoleCheckEmbed).mock.calls[0][0]; + expect(callArg[0].roles).toEqual(['No roles set']); + }); + + it('handles empty channel', async () => { + const handler = new RolesHandler(); + + const ctx = makeCtx({ + author: { + id: '111', + nick: 'TestUser', + toString: () => 'TestUser', + voice: { channel: { id: 42, members: [] } }, + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + await handler.rolecheck(ctx); + + expect(ctx.send).toHaveBeenCalledWith('No members found in the channel.'); + }); +}); + +describe('RolesHandler.clearrole', () => { + it('clears own roles', async () => { + const handler = new RolesHandler(); + const ctx = makeCtx({ + author: { + id: 123, + nick: 'MyName', + toString: () => 'MyName', + } as any, // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + vi.mocked(getWowName).mockReturnValue('MyName'); + + const mockSvc = getPreferenceService(); + vi.mocked(mockSvc.getPreferenceSync).mockReturnValue(['Tank']); + + await handler.clearrole(ctx, null); + + expect(mockSvc.clearPreference).toHaveBeenCalledWith('123'); + expect(ctx.send).toHaveBeenCalledWith('✅ Cleared your saved roles, **MyName**.'); + }); + + it('clears another player roles', async () => { + const handler = new RolesHandler(); + const ctx = makeCtx(); + + const mockSvc = getPreferenceService(); + vi.mocked(mockSvc.resolveDiscordId).mockReturnValue('456'); + + await handler.clearrole(ctx, 'OtherPlayer'); + + expect(mockSvc.clearPreference).toHaveBeenCalledWith('456'); + expect(ctx.send).toHaveBeenCalledWith('✅ Cleared saved roles for **OtherPlayer**.'); + }); + + it('reports failure when player not found', async () => { + const handler = new RolesHandler(); + const ctx = makeCtx(); + + const mockSvc = getPreferenceService(); + vi.mocked(mockSvc.resolveDiscordId).mockReturnValue(null); + + await handler.clearrole(ctx, 'Unknown'); + + expect(ctx.send).toHaveBeenCalledWith('❌ No saved roles found for **Unknown**.'); + }); +}); diff --git a/packages/bot/tests/security.test.ts b/packages/bot/tests/security.test.ts new file mode 100644 index 00000000..c07202ca --- /dev/null +++ b/packages/bot/tests/security.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// We need to mock the config before importing security +vi.mock('../src/core/config.js', () => ({ + BOT_TOKEN: undefined as string | undefined, + FIREBASE_CREDENTIALS_JSON: undefined as string | undefined, + GITHUB_TOKEN: undefined as string | undefined, +})); + +import * as config from '../src/core/config.js'; +import { obfuscatePii, sanitizeLogs, sanitizeForGithub } from '../src/core/security.js'; + +// Helper to set config values for tests +function setConfig(overrides: Partial) { + Object.assign(config, overrides); +} + +describe('sanitizeLogs', () => { + afterEach(() => { + setConfig({ + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, + GITHUB_TOKEN: undefined, + }); + }); + + it('redacts all secret types', () => { + setConfig({ + BOT_TOKEN: 'SECRET_TOKEN', + FIREBASE_CREDENTIALS_JSON: '{"private_key": "abc"}', + GITHUB_TOKEN: 'ghp_secret', + }); + + const logs = + 'Error: Invalid token SECRET_TOKEN provided. also gh: ghp_secret and fb: {"private_key": "abc"}'; + const sanitized = sanitizeLogs(logs); + + expect(sanitized).not.toBeNull(); + expect(sanitized!).not.toContain('SECRET_TOKEN'); + expect(sanitized!).toContain('[REDACTED_BOT_TOKEN]'); + expect(sanitized!).not.toContain('ghp_secret'); + expect(sanitized!).toContain('[REDACTED_GITHUB_TOKEN]'); + expect(sanitized!).not.toContain('{"private_key": "abc"}'); + expect(sanitized!).toContain('[REDACTED_FIREBASE_CREDENTIALS]'); + }); + + it('returns unchanged when no secrets', () => { + const logs = 'Just some normal logs.'; + expect(sanitizeLogs(logs)).toBe(logs); + }); + + it('returns null for null input', () => { + expect(sanitizeLogs(null)).toBeNull(); + }); + + it('returns empty string for empty input', () => { + expect(sanitizeLogs('')).toBe(''); + }); +}); + +describe('obfuscatePii', () => { + it('redacts Discord object repr', () => { + const text = ""; + const result = obfuscatePii(text); + expect(result).not.toContain('123456789012345678'); + expect(result).not.toContain('JohnDoe'); + expect(result).toContain('[REDACTED_ID]'); + expect(result).toContain('[REDACTED]'); + }); + + it('redacts voice channel repr', () => { + const text = ""; + const result = obfuscatePii(text); + expect(result).not.toContain('987654321098765432'); + expect(result).not.toContain('Gaming Lounge'); + }); + + it('redacts user context line', () => { + const text = 'User: JohnDoe (123456789012345678)'; + const result = obfuscatePii(text); + expect(result).not.toContain('JohnDoe'); + expect(result).not.toContain('123456789012345678'); + expect(result).toContain('[REDACTED_USER]'); + expect(result).toContain('[REDACTED_ID]'); + }); + + it('redacts channel context line', () => { + const text = 'Channel: #my-secret-channel'; + const result = obfuscatePii(text); + expect(result).not.toContain('my-secret-channel'); + expect(result).toContain('[REDACTED_CHANNEL]'); + }); + + it('redacts bare snowflake IDs', () => { + const text = 'Error for guild 123456789012345678 in channel 987654321098765432'; + const result = obfuscatePii(text); + expect(result).not.toContain('123456789012345678'); + expect(result).not.toContain('987654321098765432'); + expect(result.match(/\[REDACTED_ID\]/g)?.length).toBe(2); + }); + + it('preserves short numbers', () => { + const text = 'Error code 12345 at line 67'; + expect(obfuscatePii(text)).toBe(text); + }); + + it('redacts username#discriminator', () => { + const text = 'Something about CoolUser#9876 happened'; + const result = obfuscatePii(text); + expect(result).not.toContain('CoolUser#9876'); + expect(result).toContain('[REDACTED_USER]'); + }); + + it('redacts username with spaces#discriminator', () => { + const text = 'Something about Cool User#9876 happened'; + const result = obfuscatePii(text); + expect(result).not.toContain('Cool User#9876'); + expect(result).toContain('[REDACTED_USER]'); + }); + + it("redacts Discord object repr with single quote in name", () => { + const text = + ""; + const result = obfuscatePii(text); + expect(result).not.toContain("O'Reilly"); + expect(result).not.toContain('123456789012345678'); + }); + + it('redacts user context with parens in name', () => { + const text = 'User: Name(Tag) (123456789012345678)'; + const result = obfuscatePii(text); + expect(result).not.toContain('Name(Tag)'); + expect(result).not.toContain('123456789012345678'); + }); + + it('handles empty string', () => { + expect(obfuscatePii('')).toBe(''); + }); + + it('handles no PII', () => { + const text = 'Just a normal error message with no PII'; + expect(obfuscatePii(text)).toBe(text); + }); + + it('preserves code paths', () => { + const text = 'File "/app/cogs/groups.py", line 45, in wheel'; + expect(obfuscatePii(text)).toBe(text); + }); + + it('handles full context string', () => { + const text = [ + 'App Command: /wheel', + 'User: Tytanium (202184987469021184)', + "Channel: ", + ].join('\n'); + const result = obfuscatePii(text); + expect(result).not.toContain('Tytanium'); + expect(result).not.toContain('202184987469021184'); + expect(result).not.toContain('Gaming'); + expect(result).toContain('/wheel'); + }); +}); + +describe('sanitizeForGithub', () => { + beforeEach(() => { + setConfig({ + BOT_TOKEN: undefined, + FIREBASE_CREDENTIALS_JSON: undefined, + GITHUB_TOKEN: undefined, + }); + }); + + it('chains both sanitizers', () => { + setConfig({ BOT_TOKEN: 'SECRET_TOKEN' }); + const text = 'Token SECRET_TOKEN\nUser: JohnDoe (123456789012345678)'; + const result = sanitizeForGithub(text); + expect(result).not.toContain('SECRET_TOKEN'); + expect(result).not.toContain('JohnDoe'); + expect(result).not.toContain('123456789012345678'); + }); + + it('handles empty string', () => { + expect(sanitizeForGithub('')).toBe(''); + }); +}); diff --git a/packages/bot/tests/sessionService.test.ts b/packages/bot/tests/sessionService.test.ts new file mode 100644 index 00000000..73c0f569 --- /dev/null +++ b/packages/bot/tests/sessionService.test.ts @@ -0,0 +1,733 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WoWPlayer, WoWGroup } from '@mythicplus/shared'; + +vi.mock('@mythicplus/shared', async () => { + const actual = await vi.importActual('@mythicplus/shared'); + return { + ...(actual as Record), + createMythicPlusGroups: vi.fn(), + }; +}); + +vi.mock('../src/core/firebaseService.js', () => ({ + FirebaseService: { getInstance: vi.fn() }, +})); + +vi.mock('../src/core/utils.js', () => ({ + getPlayerList: vi.fn(), + getDebugPlayers: vi.fn(), + getPlayerFromMember: vi.fn(), + getWowName: vi.fn(), + getMaskedName: vi.fn((n: string) => '?'.repeat(n.length)), + showLongTyping: vi.fn().mockResolvedValue(undefined), + showShortTyping: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../src/core/groupUi.js', () => ({ + buildGroupEmbed: vi.fn(), + announceGroup: vi.fn(), +})); + +vi.mock('../src/core/logger.js', () => ({ + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../src/core/preferenceService.js', () => ({ + getPreferenceService: vi.fn().mockReturnValue({ + getPreferenceSync: vi.fn().mockReturnValue(null), + getPreferenceByNameSync: vi.fn().mockReturnValue(null), + }), +})); + +import { + SessionService, + type Bot, + type Guild, + type VoiceChannel, +} from '../src/services/sessionService.js'; +import { getPlayerList } from '../src/core/utils.js'; +import { buildGroupEmbed } from '../src/core/groupUi.js'; +import { createMythicPlusGroups } from '@mythicplus/shared'; +import { + TankPaladin, + HealerPriest, + Warrior, + Mage, + Rogue, +} from './prebuiltClasses.js'; + +// ---------- helpers ---------- + +interface MockFirebase { + isAvailable: ReturnType; + getOrCreateGuildDoc: ReturnType; + getOrCreateChannelDoc: ReturnType; + updateGuildDoc: ReturnType; + updateChannelDoc: ReturnType; + deleteChannelDoc: ReturnType; + deleteGuildDoc: ReturnType; +} + +function createMockFirebase(): MockFirebase { + return { + isAvailable: vi.fn().mockReturnValue(true), + getOrCreateGuildDoc: vi.fn().mockResolvedValue('guild-1'), + getOrCreateChannelDoc: vi.fn().mockResolvedValue('channel-1'), + updateGuildDoc: vi.fn().mockResolvedValue(undefined), + updateChannelDoc: vi.fn().mockResolvedValue(undefined), + deleteChannelDoc: vi.fn().mockResolvedValue(undefined), + deleteGuildDoc: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeMember(name: string, bot = false) { + return { nick: name, global_name: null, id: name, bot, toString: () => name }; +} + +function makeVoiceChannel( + id: number, + name: string, + members: ReturnType[] = [], +): VoiceChannel { + return { + id, + name, + members, + send: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeGuild( + id: number, + channels: VoiceChannel[] = [], + overrides: Partial = {}, +): Guild { + return { + id, + name: 'Test Guild', + icon: { url: 'http://icon' }, + voice_channels: channels, + get_channel: vi.fn((chId: number) => channels.find((c) => c.id === chId) ?? null), + ...overrides, + }; +} + +function makeBot(overrides: Partial = {}): Bot { + return { + get_guild: vi.fn().mockReturnValue(null), + ...overrides, + }; +} + +function makeService(bot?: Bot, firebase?: MockFirebase) { + const fb = firebase ?? createMockFirebase(); + const b = bot ?? makeBot(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(b, fb as any); + return { service, bot: b, firebase: fb }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ---------- getOrCreateSession ---------- + +describe('SessionService.getOrCreateSession', () => { + it('creates guild and channel docs and returns IDs', async () => { + const vc = makeVoiceChannel(99, 'Raid', [makeMember('P1')]); + const guild = makeGuild(1, [vc]); + const firebase = createMockFirebase(); + firebase.getOrCreateGuildDoc.mockResolvedValue('1'); + firebase.getOrCreateChannelDoc.mockResolvedValue('99'); + + const bot = makeBot(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + vi.mocked(getPlayerList).mockReturnValue([]); + + const ctx = { + guild, + author: { voice: { channel: { id: 99, name: 'Raid' } } }, + }; + + const result = await service.getOrCreateSession(ctx); + + expect(result).not.toBeNull(); + const [guildDocId, channelDocId] = result!; + expect(guildDocId).toBe('1'); + expect(channelDocId).toBe('99'); + expect(service.activeGuilds.has(1)).toBe(true); + expect(service.activeChannels.has(99)).toBe(true); + + expect(firebase.getOrCreateGuildDoc).toHaveBeenCalledWith(1, 'Test Guild', 'http://icon'); + expect(firebase.getOrCreateChannelDoc).toHaveBeenCalledWith(99, 1, 'Raid', false); + }); + + it('returns null when firebase is unavailable', async () => { + const firebase = createMockFirebase(); + firebase.isAvailable.mockReturnValue(false); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const ctx = { + guild: makeGuild(1), + author: { voice: { channel: { id: 99, name: 'Raid' } } }, + }; + + const result = await service.getOrCreateSession(ctx); + expect(result).toBeNull(); + }); + + it('returns null when guild is null', async () => { + const { service } = makeService(); + const ctx = { guild: null, author: { voice: { channel: { id: 99, name: 'Raid' } } } }; + + const result = await service.getOrCreateSession(ctx); + expect(result).toBeNull(); + }); + + it('returns null when author is not in a voice channel', async () => { + const firebase = createMockFirebase(); + const guild = makeGuild(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const ctx = { guild, author: { voice: null } }; + const result = await service.getOrCreateSession(ctx); + expect(result).toBeNull(); + }); +}); + +// ---------- updateChannelPlayers ---------- + +describe('SessionService.updateChannelPlayers', () => { + it('writes player data to channel doc', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + + const vc = makeVoiceChannel(42, 'Raid', [makeMember('Tank1')]); + const guild = makeGuild(1, [vc]); + + const player = TankPaladin('Tank1'); + vi.mocked(getPlayerList).mockReturnValue([player]); + + await service.updateChannelPlayers(42, guild); + + expect(firebase.updateChannelDoc).toHaveBeenCalledOnce(); + const [docId, data] = firebase.updateChannelDoc.mock.calls[0]; + expect(docId).toBe('42'); + expect(data.players).toHaveLength(1); + }); + + it('skips untracked channels', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + // No active channels + + const guild = makeGuild(1); + await service.updateChannelPlayers(42, guild); + + expect(firebase.updateChannelDoc).not.toHaveBeenCalled(); + }); + + it('writes empty players when channel is gone', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + + // Guild returns null for channel + const guild = makeGuild(1); + + await service.updateChannelPlayers(42, guild); + + const data = firebase.updateChannelDoc.mock.calls[0][1]; + expect(data.players).toEqual([]); + }); +}); + +// ---------- refreshGuildVoiceChannels ---------- + +describe('SessionService.refreshGuildVoiceChannels', () => { + it('writes voice channels to guild doc', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const vc1 = makeVoiceChannel(42, 'Raid', [makeMember('P1'), makeMember('P2')]); + const vc2 = makeVoiceChannel(43, 'AFK', []); + const guild = makeGuild(1, [vc1, vc2]); + + await service.refreshGuildVoiceChannels(guild); + + expect(firebase.updateGuildDoc).toHaveBeenCalledOnce(); + const [guildIdStr, data] = firebase.updateGuildDoc.mock.calls[0]; + expect(guildIdStr).toBe('1'); + + const channels = data.voiceChannels; + expect(channels).toHaveLength(2); + expect(channels[0].id).toBe('42'); + expect(channels[0].userCount).toBe(2); + expect(channels[1].id).toBe('43'); + expect(channels[1].userCount).toBe(0); + }); + + it('sorts by user count descending', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const vc1 = makeVoiceChannel(42, 'Small', [makeMember('P1')]); + const vc2 = makeVoiceChannel( + 43, + 'Big', + Array.from({ length: 5 }, (_, i) => makeMember(`P${i}`)), + ); + const vc3 = makeVoiceChannel( + 44, + 'Medium', + Array.from({ length: 3 }, (_, i) => makeMember(`Q${i}`)), + ); + const guild = makeGuild(1, [vc1, vc2, vc3]); + + await service.refreshGuildVoiceChannels(guild); + + const channels = firebase.updateGuildDoc.mock.calls[0][1].voiceChannels; + expect(channels).toHaveLength(3); + expect(channels[0].name).toBe('Big'); + expect(channels[0].userCount).toBe(5); + expect(channels[1].name).toBe('Medium'); + expect(channels[2].name).toBe('Small'); + }); + + it('sorts empty channels alphabetically', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const vc1 = makeVoiceChannel(42, 'Zeta', []); + const vc2 = makeVoiceChannel(43, 'Alpha', []); + const vc3 = makeVoiceChannel(44, 'Mango', []); + const guild = makeGuild(1, [vc1, vc2, vc3]); + + await service.refreshGuildVoiceChannels(guild); + + const channels = firebase.updateGuildDoc.mock.calls[0][1].voiceChannels; + expect(channels[0].name).toBe('Alpha'); + expect(channels[1].name).toBe('Mango'); + expect(channels[2].name).toBe('Zeta'); + }); + + it('filters bots from user count', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const vc = makeVoiceChannel(42, 'Raid', [ + makeMember('Human'), + makeMember('Bot', true), + ]); + const guild = makeGuild(1, [vc]); + + await service.refreshGuildVoiceChannels(guild); + + const channels = firebase.updateGuildDoc.mock.calls[0][1].voiceChannels; + expect(channels[0].userCount).toBe(1); + }); +}); + +// ---------- processSpinRequest ---------- + +describe('SessionService.processSpinRequest', () => { + it('calculates groups and updates channel doc', async () => { + const firebase = createMockFirebase(); + + const vc = makeVoiceChannel(42, 'Raid', [makeMember('M1')]); + const guild = makeGuild(1, [vc]); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { lastResults: new Map() } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + const players = [ + TankPaladin('Tank'), + HealerPriest('Healer'), + Warrior('Dps1'), + Mage('Dps2'), + Rogue('Dps3'), + ]; + vi.mocked(getPlayerList).mockReturnValue(players); + + const groups = [new WoWGroup(players[0], players[1], [players[2], players[3], players[4]])]; + vi.mocked(createMythicPlusGroups).mockReturnValue(groups); + + await service.processSpinRequest('42', 42, 1, { status: 'request_spin' }); + + expect(guild.get_channel).toHaveBeenCalledWith(42); + expect(getPlayerList).toHaveBeenCalledOnce(); + expect(createMythicPlusGroups).toHaveBeenCalledWith(players, false, 1); + + expect(firebase.updateChannelDoc).toHaveBeenCalledOnce(); + const [docId, data] = firebase.updateChannelDoc.mock.calls[0]; + expect(docId).toBe('42'); + expect(data.status).toBe('spinning'); + expect(data.groups).toHaveLength(1); + }); + + it('filters roleless players before group creation', async () => { + const firebase = createMockFirebase(); + + const vc = makeVoiceChannel(42, 'Raid', [makeMember('M1')]); + const guild = makeGuild(1, [vc]); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { lastResults: new Map() } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + vi.mocked(getPlayerList).mockReturnValue([ + TankPaladin('Tank'), + WoWPlayer.create('Roleless', []), + ]); + vi.mocked(createMythicPlusGroups).mockReturnValue([]); + + await service.processSpinRequest('42', 42, 1, { status: 'request_spin' }); + + expect(createMythicPlusGroups).toHaveBeenCalledOnce(); + const actualPlayers = vi.mocked(createMythicPlusGroups).mock.calls[0][0]; + expect(actualPlayers).toHaveLength(1); + expect(actualPlayers[0].name).toBe('Tank'); + }); + + it('returns early when guild not found', async () => { + const firebase = createMockFirebase(); + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(null) }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + await service.processSpinRequest('42', 42, 999, { status: 'request_spin' }); + + expect(firebase.updateChannelDoc).not.toHaveBeenCalled(); + }); + + it('uses debug players from data when isDebug is true', async () => { + const firebase = createMockFirebase(); + + const guild = makeGuild(1); + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { lastResults: new Map() } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + const playerDict = TankPaladin('DebugTank').toDict(); + vi.mocked(createMythicPlusGroups).mockReturnValue([]); + + await service.processSpinRequest('42', 42, 1, { + status: 'request_spin', + isDebug: true, + players: [playerDict], + }); + + expect(createMythicPlusGroups).toHaveBeenCalledOnce(); + const actualPlayers = vi.mocked(createMythicPlusGroups).mock.calls[0][0]; + expect(actualPlayers).toHaveLength(1); + expect(actualPlayers[0].name).toBe('DebugTank'); + // getPlayerList should NOT have been called in debug mode + expect(getPlayerList).not.toHaveBeenCalled(); + }); +}); + +// ---------- announceCompletion ---------- + +describe('SessionService.announceCompletion', () => { + it('uses last results when available', async () => { + const firebase = createMockFirebase(); + + const tank = TankPaladin('Tank1'); + const healer = HealerPriest('Healer1'); + const dps = [Warrior('D1'), Mage('D2'), Rogue('D3')]; + const group = new WoWGroup(tank, healer, dps); + + const vc = makeVoiceChannel(42, 'Raid'); + const guild = makeGuild(1, [vc]); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { + lastResults: new Map([[1, { players: [], groups: [group] }]]), + } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + const mockEmbed = { title: 'Group 1', color: 0, fields: [] }; + vi.mocked(buildGroupEmbed).mockReturnValue(mockEmbed); + + await service.announceCompletion(42, 1, { groups: [] }); + + expect(buildGroupEmbed).toHaveBeenCalledWith(group, 1); + expect(vc.send).toHaveBeenCalledWith({ embed: mockEmbed }); + }); + + it('falls back to firestore data when no last results', async () => { + const firebase = createMockFirebase(); + + const tank = TankPaladin('Tank1'); + const healer = HealerPriest('Healer1'); + const group = new WoWGroup(tank, healer, [Warrior('D1')]); + const groupDict = group.toDict(); + + const vc = makeVoiceChannel(42, 'Raid'); + const guild = makeGuild(1, [vc]); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { lastResults: new Map() } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + const mockEmbed = { title: 'Group 1', color: 0, fields: [] }; + vi.mocked(buildGroupEmbed).mockReturnValue(mockEmbed); + + await service.announceCompletion(42, 1, { groups: [groupDict] }); + + expect(buildGroupEmbed).toHaveBeenCalledOnce(); + const reconstructed = vi.mocked(buildGroupEmbed).mock.calls[0][0]; + expect(reconstructed.tank).not.toBeNull(); + expect(reconstructed.tank!.name).toBe('Tank1'); + expect(vc.send).toHaveBeenCalledOnce(); + }); + + it('sends fallback message when no groups', async () => { + const firebase = createMockFirebase(); + + const vc = makeVoiceChannel(42, 'Raid'); + const guild = makeGuild(1, [vc]); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { lastResults: new Map() } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + await service.announceCompletion(42, 1, { groups: [] }); + + expect(vc.send).toHaveBeenCalledWith('No groups were formed this round.'); + }); + + it('skips when guild not found', async () => { + const firebase = createMockFirebase(); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(null) }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + await service.announceCompletion(42, 1, { groups: [] }); + + expect(bot.get_guild).toHaveBeenCalledWith(1); + }); + + it('sends one embed per group', async () => { + const firebase = createMockFirebase(); + + const group1 = new WoWGroup(TankPaladin('T1'), HealerPriest('H1'), [Warrior('D1')]); + const group2 = new WoWGroup(TankPaladin('T2'), HealerPriest('H2'), [Mage('D2')]); + + const vc = makeVoiceChannel(42, 'Raid'); + const guild = makeGuild(1, [vc]); + + const bot = makeBot({ get_guild: vi.fn().mockReturnValue(guild) }); + bot.groupService = { + lastResults: new Map([[1, { players: [], groups: [group1, group2] }]]), + } as any; // eslint-disable-line @typescript-eslint/no-explicit-any + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(bot, firebase as any); + + const mockEmbed = { title: '', color: 0, fields: [] }; + vi.mocked(buildGroupEmbed).mockReturnValue(mockEmbed); + + await service.announceCompletion(42, 1, { groups: [] }); + + expect(buildGroupEmbed).toHaveBeenCalledTimes(2); + expect(buildGroupEmbed).toHaveBeenCalledWith(group1, 1); + expect(buildGroupEmbed).toHaveBeenCalledWith(group2, 2); + expect(vc.send).toHaveBeenCalledTimes(2); + }); +}); + +// ---------- cleanupChannel ---------- + +describe('SessionService.cleanupChannel', () => { + it('removes tracking, deletes docs, and cleans up guild', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + const mockWatch = { unsubscribe: vi.fn() }; + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeGuilds.add(1); + service.channelListeners.set('42', mockWatch); + service.guildListeners.set(1, { unsubscribe: vi.fn() }); + + await service.cleanupChannel(42); + + expect(service.activeChannels.has(42)).toBe(false); + expect(service.channelListeners.has('42')).toBe(false); + expect(mockWatch.unsubscribe).toHaveBeenCalledOnce(); + expect(firebase.deleteChannelDoc).toHaveBeenCalledWith('42'); + + // Last channel for guild → guild also cleaned up + expect(service.activeGuilds.has(1)).toBe(false); + expect(firebase.deleteGuildDoc).toHaveBeenCalledWith('1'); + }); + + it('keeps guild when other channels exist', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeChannels.set(43, { docId: '43', guildId: 1 }); + service.activeGuilds.add(1); + service.channelListeners.set('42', { unsubscribe: vi.fn() }); + + await service.cleanupChannel(42); + + expect(service.activeChannels.has(42)).toBe(false); + expect(service.activeChannels.has(43)).toBe(true); + expect(service.activeGuilds.has(1)).toBe(true); + expect(firebase.deleteGuildDoc).not.toHaveBeenCalled(); + }); + + it('is a no-op for nonexistent channels', async () => { + const firebase = createMockFirebase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const service = new SessionService(makeBot(), firebase as any); + + await service.cleanupChannel(999); + + expect(firebase.deleteChannelDoc).not.toHaveBeenCalled(); + }); +}); + +// ---------- getActiveChannelIdsForGuild ---------- + +describe('SessionService.getActiveChannelIdsForGuild', () => { + it('returns matching channel IDs', () => { + const { service } = makeService(); + + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeChannels.set(43, { docId: '43', guildId: 1 }); + service.activeChannels.set(99, { docId: '99', guildId: 2 }); + + const result = service.getActiveChannelIdsForGuild(1); + expect(result.sort()).toEqual([42, 43]); + }); + + it('returns empty for unknown guild', () => { + const { service } = makeService(); + + const result = service.getActiveChannelIdsForGuild(999); + expect(result).toEqual([]); + }); +}); + +// ---------- handleCollectionRemoved ---------- + +describe('SessionService.handleCollectionRemoved', () => { + it('cleans up tracking on removed event', () => { + const { service } = makeService(); + + const mockWatch = { unsubscribe: vi.fn() }; + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeGuilds.add(1); + service.channelListeners.set('42', mockWatch); + + service.handleCollectionRemoved({ document: { id: '42' } }); + + expect(service.activeChannels.has(42)).toBe(false); + expect(service.channelListeners.has('42')).toBe(false); + expect(mockWatch.unsubscribe).toHaveBeenCalledOnce(); + // Guild also cleaned up (last channel) + expect(service.activeGuilds.has(1)).toBe(false); + }); + + it('handles removed event even without channel listener', () => { + const { service } = makeService(); + + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeGuilds.add(1); + // No channel listener set + + service.handleCollectionRemoved({ document: { id: '42' } }); + + expect(service.activeChannels.has(42)).toBe(false); + expect(service.activeGuilds.has(1)).toBe(false); + }); + + it('keeps guild when other channels exist', () => { + const { service } = makeService(); + + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeChannels.set(43, { docId: '43', guildId: 1 }); + service.activeGuilds.add(1); + service.channelListeners.set('42', { unsubscribe: vi.fn() }); + + service.handleCollectionRemoved({ document: { id: '42' } }); + + expect(service.activeChannels.has(42)).toBe(false); + expect(service.activeChannels.has(43)).toBe(true); + expect(service.activeGuilds.has(1)).toBe(true); + }); + + it('ignores non-numeric doc IDs', () => { + const { service } = makeService(); + + service.handleCollectionRemoved({ document: { id: 'not-a-number' } }); + + // Should not throw or modify state + expect(service.activeChannels.size).toBe(0); + }); +}); + +// ---------- shutdown ---------- + +describe('SessionService.shutdown', () => { + it('unsubscribes all listeners and clears state', () => { + const { service } = makeService(); + + const channelWatch = { unsubscribe: vi.fn() }; + const guildWatch = { unsubscribe: vi.fn() }; + + service.activeChannels.set(42, { docId: '42', guildId: 1 }); + service.activeGuilds.add(1); + service.channelListeners.set('42', channelWatch); + service.guildListeners.set(1, guildWatch); + + service.shutdown(); + + expect(channelWatch.unsubscribe).toHaveBeenCalledOnce(); + expect(guildWatch.unsubscribe).toHaveBeenCalledOnce(); + expect(service.channelListeners.size).toBe(0); + expect(service.guildListeners.size).toBe(0); + expect(service.activeChannels.size).toBe(0); + expect(service.activeGuilds.size).toBe(0); + }); +}); diff --git a/packages/bot/tests/storage.test.ts b/packages/bot/tests/storage.test.ts new file mode 100644 index 00000000..fcd9686c --- /dev/null +++ b/packages/bot/tests/storage.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import { + STORAGE_FILE, + loadPreferences, + savePreferences, + getPlayerPreference, + setPlayerPreference, + clearPlayerPreference, + getAllPreferences, + _resetCache, +} from '../src/core/storage.js'; + +describe('Storage', () => { + let backupExists = false; + + beforeEach(() => { + backupExists = fs.existsSync(STORAGE_FILE); + if (backupExists) { + fs.renameSync(STORAGE_FILE, STORAGE_FILE + '.bak'); + } + _resetCache(); + }); + + afterEach(() => { + if (fs.existsSync(STORAGE_FILE)) { + fs.unlinkSync(STORAGE_FILE); + } + if (backupExists) { + fs.renameSync(STORAGE_FILE + '.bak', STORAGE_FILE); + } + _resetCache(); + }); + + it('caches after first load', () => { + const initialData = { Player1: ['Tank'] }; + fs.writeFileSync(STORAGE_FILE, JSON.stringify(initialData)); + + const data1 = loadPreferences(); + expect(data1).toEqual(initialData); + + // Modify file on disk — cache should return old value + fs.writeFileSync(STORAGE_FILE, JSON.stringify({ Player1: ['Healer'] })); + const data2 = loadPreferences(); + expect(data2).toEqual(initialData); + }); + + it('saves and loads', () => { + const prefs = { Player1: ['Tank', 'Melee'] }; + savePreferences(prefs); + _resetCache(); // Force re-read from disk + const loaded = loadPreferences(); + expect(loaded).toEqual(prefs); + }); + + it('gets and sets preference', () => { + setPlayerPreference('Player2', ['Healer']); + expect(getPlayerPreference('Player2')).toEqual(['Healer']); + expect(getPlayerPreference('NonExistent')).toBeNull(); + }); + + it('clears preference', () => { + setPlayerPreference('Player3', ['Melee']); + expect(clearPlayerPreference('Player3')).toBe(true); + expect(getPlayerPreference('Player3')).toBeNull(); + expect(clearPlayerPreference('Player3')).toBe(false); + }); + + it('gets all preferences', () => { + setPlayerPreference('P1', ['Role1']); + setPlayerPreference('P2', ['Role2']); + const allPrefs = getAllPreferences(); + expect(Object.keys(allPrefs).length).toBe(2); + expect(allPrefs).toHaveProperty('P1'); + expect(allPrefs).toHaveProperty('P2'); + }); + + it('handles corrupt file', () => { + fs.writeFileSync(STORAGE_FILE, '{invalid json'); + const prefs = loadPreferences(); + expect(prefs).toEqual({}); + }); + + it('returns empty when file does not exist', () => { + const prefs = loadPreferences(); + expect(prefs).toEqual({}); + }); +}); diff --git a/packages/bot/tests/utils.test.ts b/packages/bot/tests/utils.test.ts new file mode 100644 index 00000000..56bff5cc --- /dev/null +++ b/packages/bot/tests/utils.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getWowName, + getMaskedName, + getDebugPlayers, + getPlayerList, + showLongTyping, + showShortTyping, +} from '../src/core/utils.js'; +import type { DiscordMember, TypingChannel } from '../src/core/utils.js'; + +// Mock the preference service +vi.mock('../src/core/preferenceService.js', () => { + const mockSvc = { + getPreferenceSync: vi.fn().mockReturnValue(null), + getPreferenceByNameSync: vi.fn().mockReturnValue(null), + }; + return { + getPreferenceService: vi.fn().mockReturnValue(mockSvc), + PreferenceService: vi.fn(), + _resetInstance: vi.fn(), + }; +}); + +describe('getWowName', () => { + it('prioritizes nick > global > str and removes dots', () => { + // Case 1: Nickname exists + const member1: DiscordMember = { + nick: 'Nick.Name', + global_name: 'Global.Name', + id: '1', + toString: () => 'User.Name', + }; + expect(getWowName(member1)).toBe('NickName'); + + // Case 2: No nick, global name exists + const member2: DiscordMember = { + nick: null, + global_name: 'Global.Name', + id: '2', + toString: () => 'User.Name', + }; + expect(getWowName(member2)).toBe('GlobalName'); + + // Case 3: No nick, no global name + const member3: DiscordMember = { + nick: null, + global_name: null, + id: '3', + toString: () => 'User.Name', + }; + expect(getWowName(member3)).toBe('UserName'); + }); +}); + +describe('getDebugPlayers', () => { + it('returns valid list of WoWPlayers', () => { + const players = getDebugPlayers(); + expect(players.length).toBeGreaterThan(0); + for (const player of players) { + expect(player.name).toBeTruthy(); + expect(player.hasRoles()).toBe(true); + } + }); +}); + +describe('getPlayerList', () => { + it('returns all members with or without roles', async () => { + const { getPreferenceService } = await import('../src/core/preferenceService.js'); + const mockSvc = getPreferenceService(); + vi.mocked(mockSvc.getPreferenceSync).mockImplementation((discordId: string) => { + if (discordId === '111') return ['Tank']; + return null; + }); + vi.mocked(mockSvc.getPreferenceByNameSync).mockReturnValue(null); + + const members: DiscordMember[] = [ + { nick: 'SavedPlayer', id: 111, toString: () => 'SavedPlayer' }, + { nick: 'NoRolesPlayer', id: 222, toString: () => 'NoRolesPlayer' }, + { nick: 'AnotherPlayer', id: 333, toString: () => 'AnotherPlayer' }, + ]; + + const players = getPlayerList(members); + expect(players.length).toBe(3); + + const p1 = players.find((p) => p.name === 'SavedPlayer'); + expect(p1).toBeTruthy(); + expect(p1!.tankMain).toBe(true); + expect(p1!.hasRoles()).toBe(true); + expect(p1!.discordId).toBe('111'); + + const p2 = players.find((p) => p.name === 'NoRolesPlayer'); + expect(p2).toBeTruthy(); + expect(p2!.hasRoles()).toBe(false); + expect(p2!.discordId).toBe('222'); + + const p3 = players.find((p) => p.name === 'AnotherPlayer'); + expect(p3).toBeTruthy(); + expect(p3!.hasRoles()).toBe(false); + }); +}); + +describe('getMaskedName', () => { + it('returns question marks equal to string length', () => { + expect(getMaskedName('abc')).toBe('???'); + expect(getMaskedName('')).toBe(''); + expect(getMaskedName('hello world')).toBe('???????????'); + }); +}); + +describe('Typing functions', () => { + let channel: TypingChannel; + + beforeEach(() => { + channel = { sendTyping: vi.fn().mockResolvedValue(undefined) }; + }); + + it('showLongTyping calls sendTyping unless debug', async () => { + // In debug mode, nothing happens + await showLongTyping(channel, true); + expect(channel.sendTyping).not.toHaveBeenCalled(); + }); + + it('showShortTyping calls sendTyping unless debug', async () => { + // In debug mode, nothing happens + await showShortTyping(channel, true); + expect(channel.sendTyping).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/bot/tsconfig.json b/packages/bot/tsconfig.json new file mode 100644 index 00000000..3432f0ff --- /dev/null +++ b/packages/bot/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src", "tests"] +} diff --git a/packages/bot/vitest.config.ts b/packages/bot/vitest.config.ts new file mode 100644 index 00000000..78d38d2f --- /dev/null +++ b/packages/bot/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + }, + resolve: { + conditions: ['import', 'module', 'node', 'default'], + }, +}); diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..30701472 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,10 @@ +{ + "name": "@mythicplus/shared", + "version": "1.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + } +} diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts new file mode 100644 index 00000000..c5a4e1df --- /dev/null +++ b/packages/shared/src/config.ts @@ -0,0 +1,28 @@ +// Discord role name constants (platform-agnostic defaults) +// Bot-specific env var overrides live in packages/bot/src/core/config.ts + +export const ROLE_TANK = 'Tank'; +export const ROLE_HEALER = 'Healer'; +export const ROLE_RANGED = 'Ranged'; +export const ROLE_MELEE = 'Melee'; +export const ROLE_TANK_OFFSPEC = 'Tank Offspec'; +export const ROLE_HEALER_OFFSPEC = 'Healer Offspec'; +export const ROLE_RANGED_OFFSPEC = 'Ranged Offspec'; +export const ROLE_MELEE_OFFSPEC = 'Melee Offspec'; +export const ROLE_BREZ = 'Brez'; +export const ROLE_LUST = 'Lust'; + +export const ALL_ROLES = [ + ROLE_TANK, + ROLE_HEALER, + ROLE_RANGED, + ROLE_MELEE, + ROLE_TANK_OFFSPEC, + ROLE_HEALER_OFFSPEC, + ROLE_RANGED_OFFSPEC, + ROLE_MELEE_OFFSPEC, + ROLE_BREZ, + ROLE_LUST, +] as const; + +export type RoleName = (typeof ALL_ROLES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 00000000..5447243c --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,29 @@ +export { + ROLE_TANK, + ROLE_HEALER, + ROLE_RANGED, + ROLE_MELEE, + ROLE_TANK_OFFSPEC, + ROLE_HEALER_OFFSPEC, + ROLE_RANGED_OFFSPEC, + ROLE_MELEE_OFFSPEC, + ROLE_BREZ, + ROLE_LUST, + ALL_ROLES, + type RoleName, +} from './config.js'; + +export { WoWPlayer, WoWGroup } from './models.js'; + +export { + clear, + setLastGroups, + createMythicPlusGroups, +} from './parallelGroupCreator.js'; + +export type { + SessionStatus, + WoWPlayerRolesDict, + WoWPlayerDict, + WoWGroupDict, +} from './types.js'; diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts new file mode 100644 index 00000000..f81e2d3c --- /dev/null +++ b/packages/shared/src/models.ts @@ -0,0 +1,272 @@ +import { + ROLE_BREZ, + ROLE_HEALER, + ROLE_HEALER_OFFSPEC, + ROLE_LUST, + ROLE_MELEE, + ROLE_MELEE_OFFSPEC, + ROLE_RANGED, + ROLE_RANGED_OFFSPEC, + ROLE_TANK, + ROLE_TANK_OFFSPEC, +} from './config.js'; +import type { WoWGroupDict, WoWPlayerDict } from './types.js'; + +export class WoWPlayer { + readonly name: string; + readonly discordId: string; + readonly tankMain: boolean; + readonly healerMain: boolean; + readonly dpsMain: boolean; + readonly offtank: boolean; + readonly offhealer: boolean; + readonly offdps: boolean; + readonly offranged: boolean; + readonly offmelee: boolean; + readonly ranged: boolean; + readonly melee: boolean; + readonly hasBrez: boolean; + readonly hasLust: boolean; + + private constructor(params: { + name: string; + discordId?: string; + tankMain?: boolean; + healerMain?: boolean; + dpsMain?: boolean; + offtank?: boolean; + offhealer?: boolean; + offdps?: boolean; + offranged?: boolean; + offmelee?: boolean; + ranged?: boolean; + melee?: boolean; + hasBrez?: boolean; + hasLust?: boolean; + }) { + this.name = params.name; + this.discordId = params.discordId ?? ''; + this.tankMain = params.tankMain ?? false; + this.healerMain = params.healerMain ?? false; + this.dpsMain = params.dpsMain ?? false; + this.offtank = params.offtank ?? false; + this.offhealer = params.offhealer ?? false; + this.offdps = params.offdps ?? false; + this.offranged = params.offranged ?? false; + this.offmelee = params.offmelee ?? false; + this.ranged = params.ranged ?? false; + this.melee = params.melee ?? false; + this.hasBrez = params.hasBrez ?? false; + this.hasLust = params.hasLust ?? false; + } + + static create(name: string, roles: string[], discordId = ''): WoWPlayer { + const tankMain = roles.includes(ROLE_TANK); + const healerMain = roles.includes(ROLE_HEALER); + const ranged = roles.includes(ROLE_RANGED); + const melee = roles.includes(ROLE_MELEE); + const dpsMain = ranged || melee; + const offtank = roles.includes(ROLE_TANK_OFFSPEC); + const offhealer = roles.includes(ROLE_HEALER_OFFSPEC); + const offranged = roles.includes(ROLE_RANGED_OFFSPEC); + const offmelee = roles.includes(ROLE_MELEE_OFFSPEC); + const offdps = offranged || offmelee; + const hasBrez = roles.includes(ROLE_BREZ); + const hasLust = roles.includes(ROLE_LUST); + + return new WoWPlayer({ + name, + discordId, + tankMain, + healerMain, + dpsMain, + offtank, + offhealer, + offdps, + offranged, + offmelee, + ranged, + melee, + hasBrez, + hasLust, + }); + } + + /** + * Direct construction with explicit boolean flags. + * Used by prebuilt test helpers and from_dict deserialization. + */ + static fromFlags(params: { + name: string; + discordId?: string; + tankMain?: boolean; + healerMain?: boolean; + dpsMain?: boolean; + offtank?: boolean; + offhealer?: boolean; + offdps?: boolean; + offranged?: boolean; + offmelee?: boolean; + ranged?: boolean; + melee?: boolean; + hasBrez?: boolean; + hasLust?: boolean; + }): WoWPlayer { + return new WoWPlayer(params); + } + + equals(other: WoWPlayer): boolean { + return this.name === other.name; + } + + hasRoles(): boolean { + return ( + this.tankMain || + this.healerMain || + this.dpsMain || + this.offtank || + this.offhealer || + this.offdps + ); + } + + toTestString(): string { + const roles: string[] = []; + if (this.tankMain) roles.push(ROLE_TANK); + if (this.healerMain) roles.push(ROLE_HEALER); + if (this.ranged) roles.push(ROLE_RANGED); + if (this.melee) roles.push(ROLE_MELEE); + if (this.offtank) roles.push(ROLE_TANK_OFFSPEC); + if (this.offhealer) roles.push(ROLE_HEALER_OFFSPEC); + if (this.offranged) roles.push(ROLE_RANGED_OFFSPEC); + if (this.offmelee) roles.push(ROLE_MELEE_OFFSPEC); + if (this.hasBrez) roles.push(ROLE_BREZ); + if (this.hasLust) roles.push(ROLE_LUST); + return `WoWPlayer.create("${this.name}", [${roles.map((r) => `"${r}"`).join(', ')}])`; + } + + toUtilitiesString(): string { + const utilities: string[] = []; + if (this.hasBrez) utilities.push(ROLE_BREZ); + if (this.hasLust) utilities.push(ROLE_LUST); + if (utilities.length > 0) { + return `${this.name}(${utilities.join(', ')})`; + } + return this.name; + } + + toString(): string { + return this.name; + } + + toDict(): WoWPlayerDict { + return { + name: this.name, + discordId: this.discordId, + roles: { + tankMain: this.tankMain, + healerMain: this.healerMain, + dpsMain: this.dpsMain, + offtank: this.offtank, + offhealer: this.offhealer, + offdps: this.offdps, + offranged: this.offranged, + offmelee: this.offmelee, + ranged: this.ranged, + melee: this.melee, + hasBrez: this.hasBrez, + hasLust: this.hasLust, + }, + }; + } + + static fromDict(data: Record): WoWPlayer { + const roles = (data.roles ?? {}) as Record; + return new WoWPlayer({ + name: data.name as string, + discordId: (data.discordId as string) ?? '', + tankMain: roles.tankMain ?? false, + healerMain: roles.healerMain ?? false, + dpsMain: roles.dpsMain ?? false, + offtank: roles.offtank ?? false, + offhealer: roles.offhealer ?? false, + offdps: roles.offdps ?? false, + offranged: roles.offranged ?? false, + offmelee: roles.offmelee ?? false, + ranged: roles.ranged ?? false, + melee: roles.melee ?? false, + hasBrez: roles.hasBrez ?? false, + hasLust: roles.hasLust ?? false, + }); + } +} + +export class WoWGroup { + tank: WoWPlayer | null; + healer: WoWPlayer | null; + dps: WoWPlayer[]; + + constructor( + tank: WoWPlayer | null = null, + healer: WoWPlayer | null = null, + dps: WoWPlayer[] = [], + ) { + this.tank = tank; + this.healer = healer; + this.dps = dps; + } + + get hasBrez(): boolean { + return this.players.some((p) => p.hasBrez); + } + + get hasLust(): boolean { + return this.players.some((p) => p.hasLust); + } + + get hasRanged(): boolean { + return this.players.some((p) => p.ranged); + } + + get isComplete(): boolean { + return this.tank !== null && this.healer !== null && this.dps.length === 3; + } + + get size(): number { + return this.players.length; + } + + get players(): WoWPlayer[] { + const all: WoWPlayer[] = []; + if (this.tank) all.push(this.tank); + if (this.healer) all.push(this.healer); + all.push(...this.dps); + return all; + } + + toTestString(): string { + const tankStr = this.tank ? `"${this.tank.toUtilitiesString()}"` : 'None'; + const healerStr = this.healer ? `"${this.healer.toUtilitiesString()}"` : 'None'; + const dpsStr = this.dps.map((p) => `"${p.toUtilitiesString()}"`).join(', '); + return `WoWGroup(Tank=${tankStr}, Healer=${healerStr}, DPS=${dpsStr})`; + } + + toDict(): WoWGroupDict { + return { + tank: this.tank?.toDict() ?? null, + healer: this.healer?.toDict() ?? null, + dps: this.dps.map((p) => p.toDict()), + }; + } + + static fromDict(data: Record): WoWGroup { + const tankData = data.tank as Record | null; + const healerData = data.healer as Record | null; + const dpsData = (data.dps ?? []) as Record[]; + return new WoWGroup( + tankData ? WoWPlayer.fromDict(tankData) : null, + healerData ? WoWPlayer.fromDict(healerData) : null, + dpsData.map((p) => WoWPlayer.fromDict(p)), + ); + } +} diff --git a/packages/shared/src/parallelGroupCreator.ts b/packages/shared/src/parallelGroupCreator.ts new file mode 100644 index 00000000..05e21c48 --- /dev/null +++ b/packages/shared/src/parallelGroupCreator.ts @@ -0,0 +1,294 @@ +import { WoWGroup, WoWPlayer } from './models.js'; + +/** Per-guild history of last groups — avoids cross-guild contamination. */ +const lastGroups = new Map(); + +export function clear(): void { + lastGroups.clear(); +} + +export function setLastGroups(groups: WoWGroup[], guildId: number | null = null): void { + lastGroups.set(guildId, groups); +} + +/** Fisher-Yates shuffle (in-place). */ +function shuffle(arr: T[]): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +/** Remove a player by name-equality from a list (in-place). */ +function removeFromList(list: WoWPlayer[], player: WoWPlayer): void { + const idx = list.findIndex((p) => p.equals(player)); + if (idx !== -1) list.splice(idx, 1); +} + +/** Check if a player (by name) is in a list. */ +function isInList(list: WoWPlayer[], player: WoWPlayer): boolean { + return list.some((p) => p.equals(player)); +} + +export function createMythicPlusGroups( + players: WoWPlayer[], + _debug = true, + guildId: number | null = null, +): WoWGroup[] { + const previousGroups = lastGroups.get(guildId) ?? []; + + // Pre-compute teammate lookups for O(1) check + const lastGroupsDict = new Map>(); + for (const group of previousGroups) { + const members = group.players; + for (const member of members) { + if (!lastGroupsDict.has(member.name)) { + lastGroupsDict.set(member.name, new Set()); + } + const teammates = lastGroupsDict.get(member.name)!; + for (const m of members) { + if (!m.equals(member)) teammates.add(m.name); + } + } + } + + const groups: WoWGroup[] = []; + players = [...players]; + const usedPlayers = new Set(); // Track by name + + const maximumPossibleGroups = Math.floor(players.length / 5); + + // Tanks + const mainTanks = shuffle(players.filter((p) => p.tankMain)); + const offTanks = shuffle(players.filter((p) => p.offtank && !p.tankMain)); + const availableTanks = [...mainTanks, ...offTanks]; + + // Healers + const mainHealers = shuffle(players.filter((p) => p.healerMain)); + const offHealers = shuffle(players.filter((p) => p.offhealer && !p.healerMain)); + const availableHealers = [...mainHealers, ...offHealers]; + let offhealersToGrab = Math.max(0, maximumPossibleGroups - mainHealers.length); + + // DPS + const mainDps = shuffle(players.filter((p) => p.dpsMain)); + const offDps = shuffle(players.filter((p) => p.offdps && !p.dpsMain)); + const availableDps = [...mainDps, ...offDps]; + + // Utilities + const brezPlayers = shuffle(players.filter((p) => p.hasBrez)); + const lustPlayers = shuffle(players.filter((p) => p.hasLust)); + + function removePlayer(player: WoWPlayer | null): void { + if (player === null) return; + usedPlayers.add(player.name); + + // Tank lists + if (player.tankMain) { + removeFromList(mainTanks, player); + removeFromList(availableTanks, player); + } else if (player.offtank) { + removeFromList(offTanks, player); + removeFromList(availableTanks, player); + } + + // Healer lists + if (player.healerMain) { + removeFromList(mainHealers, player); + removeFromList(availableHealers, player); + } else if (player.offhealer) { + removeFromList(offHealers, player); + removeFromList(availableHealers, player); + } + + // DPS lists + if (player.dpsMain) { + removeFromList(mainDps, player); + removeFromList(availableDps, player); + } else if (player.offdps) { + removeFromList(offDps, player); + removeFromList(availableDps, player); + } + + // Utility lists + if (player.hasBrez) removeFromList(brezPlayers, player); + if (player.hasLust) removeFromList(lustPlayers, player); + } + + function grabNextAvailablePlayer( + availablePlayers: WoWPlayer[], + group: WoWGroup, + ): WoWPlayer | null { + const teammates = group.players; + const filteredList: WoWPlayer[] = []; + + // Pre-check: Find all players that are ineligible due to previous grouping + const ineligiblePlayers = new Set(); + for (const teammate of teammates) { + const prev = lastGroupsDict.get(teammate.name); + if (prev) { + for (const name of prev) ineligiblePlayers.add(name); + } + } + + for (const p of availablePlayers) { + if (ineligiblePlayers.has(p.name)) continue; + filteredList.push(p); + } + + // Try to grab a player from the filtered list first + for (const player of filteredList) { + if (!usedPlayers.has(player.name)) { + removePlayer(player); + return player; + } + } + + // Fallback if we can't find a player who hasn't played with this group before + for (const player of availablePlayers) { + if (!usedPlayers.has(player.name)) { + removePlayer(player); + return player; + } + } + + return null; + } + + // Start forming full groups + for (let i = 0; i < maximumPossibleGroups; i++) { + groups.push(new WoWGroup()); + } + + // Fill out each full group in stages + // Grab a tank + for (const currentGroup of groups) { + currentGroup.tank = grabNextAvailablePlayer(availableTanks, currentGroup); + } + + // Fill bloodlust spot next because no tanks have bloodlust + for (const currentGroup of groups) { + if (!currentGroup.hasLust) { + const lustPlayer = grabNextAvailablePlayer( + lustPlayers.filter((p) => !isInList(availableTanks, p)), + currentGroup, + ); + + if (lustPlayer !== null) { + if (lustPlayer.healerMain || (offhealersToGrab > 0 && lustPlayer.offhealer)) { + currentGroup.healer = lustPlayer; + if (lustPlayer.offhealer) offhealersToGrab--; + } else if (lustPlayer.dpsMain) { + currentGroup.dps.push(lustPlayer); + } + } + } + } + + // Now grab a brez if we don't have one + for (const currentGroup of groups) { + if (!currentGroup.hasBrez) { + let brezPlayer: WoWPlayer | null; + if (currentGroup.healer !== null) { + // We have a healer already, so grab a dps brez + brezPlayer = grabNextAvailablePlayer( + brezPlayers.filter( + (p) => !isInList(availableTanks, p) && !isInList(availableHealers, p), + ), + currentGroup, + ); + } else { + // We don't have a healer, so grab any brez + brezPlayer = grabNextAvailablePlayer( + brezPlayers.filter((p) => !isInList(availableTanks, p)), + currentGroup, + ); + } + + if (brezPlayer !== null) { + if (brezPlayer.healerMain || (offhealersToGrab > 0 && brezPlayer.offhealer)) { + currentGroup.healer = brezPlayer; + if (brezPlayer.offhealer) offhealersToGrab--; + } else if (brezPlayer.dpsMain) { + currentGroup.dps.push(brezPlayer); + } + } + } + } + + // If we still don't have a healer, grab one now + for (const currentGroup of groups) { + if (currentGroup.healer === null) { + const mainHealer = grabNextAvailablePlayer([...mainHealers], currentGroup); + if (mainHealer !== null) { + currentGroup.healer = mainHealer; + } else { + const offHealer = grabNextAvailablePlayer([...availableHealers], currentGroup); + if (offHealer !== null) { + currentGroup.healer = offHealer; + } + } + } + } + + // Try to grab a ranged dps if we don't have one + for (const currentGroup of groups) { + if (!currentGroup.hasRanged) { + const rangedDps = grabNextAvailablePlayer( + availableDps.filter((p) => p.ranged), + currentGroup, + ); + if (rangedDps !== null) { + currentGroup.dps.push(rangedDps); + } + } + } + + // Fill the rest of the dps slots with anyone left + for (const currentGroup of groups) { + while (currentGroup.dps.length < 3) { + const dpsPlayer = grabNextAvailablePlayer(availableDps, currentGroup); + if (dpsPlayer === null) break; + currentGroup.dps.push(dpsPlayer); + } + } + + // Handle remainder players + while (usedPlayers.size < players.length) { + const remainderGroup = new WoWGroup(); + while (usedPlayers.size < players.length) { + const player = grabNextAvailablePlayer( + players.filter((p) => !usedPlayers.has(p.name)), + remainderGroup, + ); + if (player !== null) { + if (remainderGroup.tank === null && (player.tankMain || player.offtank)) { + remainderGroup.tank = player; + continue; + } else if ( + remainderGroup.healer === null && + (player.healerMain || player.offhealer) + ) { + remainderGroup.healer = player; + continue; + } else if ( + remainderGroup.dps.length < 3 && + (player.dpsMain || player.offdps) + ) { + remainderGroup.dps.push(player); + continue; + } else { + // Everything is full, make another group + usedPlayers.delete(player.name); + break; + } + } else { + break; + } + } + groups.push(remainderGroup); + } + + lastGroups.set(guildId, groups); + return groups; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 00000000..916d50ff --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,31 @@ +export type SessionStatus = 'lobby' | 'request_spin' | 'spinning' | 'completed'; + +export interface WoWPlayerRolesDict { + [key: string]: boolean; + tankMain: boolean; + healerMain: boolean; + dpsMain: boolean; + offtank: boolean; + offhealer: boolean; + offdps: boolean; + offranged: boolean; + offmelee: boolean; + ranged: boolean; + melee: boolean; + hasBrez: boolean; + hasLust: boolean; +} + +export interface WoWPlayerDict { + [key: string]: unknown; + name: string; + discordId: string; + roles: WoWPlayerRolesDict; +} + +export interface WoWGroupDict { + [key: string]: unknown; + tank: WoWPlayerDict | null; + healer: WoWPlayerDict | null; + dps: WoWPlayerDict[]; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..635ce155 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/scripts/verify-ts.sh b/scripts/verify-ts.sh new file mode 100755 index 00000000..3c71071d --- /dev/null +++ b/scripts/verify-ts.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "=== TypeScript Verification ===" + +echo "" +echo "--- Lint (shared + bot) ---" +npx eslint packages/ + +echo "" +echo "--- Typecheck: packages/shared ---" +npx -w packages/shared tsc --noEmit + +echo "" +echo "--- Typecheck: packages/bot ---" +npx -w packages/bot tsc --noEmit + +echo "" +echo "--- Tests: packages/bot ---" +npm -w packages/bot run test + +echo "" +echo "=== All checks passed ==="