diff --git a/.env.prod.example b/.env.prod.example index 5d537ef6..69c46ce7 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -6,6 +6,9 @@ # Bot token from the Discord Developer Portal. DISCORD_TOKEN= +# Optional: fatal, error, warn, info (default), debug, trace or silent. +LOG_LEVEL=info + # PostgreSQL credentials — DATABASE_URL is assembled from them in the compose # file, so the database is never reachable from outside the internal network. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 870885ba..a27c78de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: run_install: false - name: Read Node version from .mise.toml @@ -52,7 +52,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: run_install: false - name: Read Node version from .mise.toml @@ -78,7 +78,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # amd64 only here: the arm64 half of the release build runs under QEMU and # is far too slow to sit on every pull request. `publish` covers both. # Each build gets its own cache scope — they would otherwise overwrite one @@ -90,6 +90,7 @@ jobs: target: runtime platforms: linux/amd64 push: false + load: true tags: omnibot:ci cache-from: type=gha,scope=ci-runtime cache-to: type=gha,mode=max,scope=ci-runtime @@ -103,9 +104,26 @@ jobs: target: migrator platforms: linux/amd64 push: false + load: true tags: omnibot-migrator:ci cache-from: type=gha,scope=ci-migrator cache-to: type=gha,mode=max,scope=ci-migrator + # Building is not enough: a CMD pointing at a missing file builds fine and + # only fails at start. Apply the migrations, then start the bot with a + # dummy token — reaching Discord's token check proves the whole import + # graph loads from the production image. + - name: Smoke-test the images + run: | + docker network create smoke + docker run -d --name pg --network smoke -e POSTGRES_PASSWORD=ci postgres:17.11-alpine + until docker exec pg pg_isready -U postgres; do sleep 1; done + url=postgresql://postgres:ci@pg:5432/postgres + docker run --rm --network smoke -e DATABASE_URL=$url omnibot-migrator:ci + set +e + out=$(timeout 60 docker run --rm --network smoke -e DISCORD_TOKEN=ci -e DATABASE_URL=$url omnibot:ci 2>&1) + set -e + echo "$out" + echo "$out" | grep -q TokenInvalid # Publishing lives in this workflow rather than its own so it can depend on the # checks: a separate workflow triggered by the same tag would run *beside* @@ -155,7 +173,7 @@ jobs: platforms: linux/arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d57bf980..cebea8ec 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0 + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - run: pnpm install --frozen-lockfile --filter omnibot-docs diff --git a/.mise.toml b/.mise.toml index 72d0395e..b8ee176b 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,5 @@ [tools] node = "24.21.0" pnpm = "11.24.0" -pitchfork = "2.13.1" -gh = "2.94.0" +pitchfork = "2.29.0" +gh = "2.102.0" diff --git a/AGENTS.md b/AGENTS.md index b036192e..d8284f88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,4 +60,4 @@ Copy `.env.example` to `.env`: - `DISCORD_TOKEN` — bot token from Discord Developer Portal - `DATABASE_URL` — PostgreSQL connection string (default matches `compose.yaml`) -- `LOG_LEVEL` — optional log level (`fatal`/`error`/`warn`/`info`/`debug`/`trace`/`silent`, default `debug`); invalid values fall back to `debug` +- `LOG_LEVEL` — optional log level (`fatal`/`error`/`warn`/`info`/`debug`/`trace`/`silent`, default `debug` in development, `info` otherwise); invalid values fall back to that default. Logs are pretty-printed in development and JSON otherwise diff --git a/Dockerfile b/Dockerfile index 6e064db2..b8d2b701 100644 --- a/Dockerfile +++ b/Dockerfile @@ -138,4 +138,4 @@ USER omnibot # would be worse than none — see the Deployment section of README.md. ENTRYPOINT ["/sbin/tini", "--"] -CMD ["sh", "-c", "node ./bootstrap/validate-env-vars.mjs && exec node ./dist/index.js"] +CMD ["sh", "-c", "node ./bootstrap/validate-env-vars.ts && exec node ./dist/index.js"] diff --git a/bootstrap/validate-env-vars.ts b/bootstrap/validate-env-vars.ts index 9f15437a..af8c4452 100644 --- a/bootstrap/validate-env-vars.ts +++ b/bootstrap/validate-env-vars.ts @@ -3,7 +3,8 @@ // Let's just crash before starting if missing // values. // -// Runs under tsx (see the `dev`/`start` scripts), so the single dev-mode +// Runs under tsx in dev and under Node's native type stripping in production +// (see the `dev`/`start` scripts and the Dockerfile), so the single dev-mode // definition in `#lib/env.js` can be imported instead of duplicating the // "development" literal here. import { isDevMode } from "#lib/env.js"; diff --git a/compose.prod.yaml b/compose.prod.yaml index cc1878f3..554eccc0 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -78,6 +78,7 @@ services: environment: DISCORD_TOKEN: ${DISCORD_TOKEN:?DISCORD_TOKEN is required} DATABASE_URL: *database_url + LOG_LEVEL: ${LOG_LEVEL:-info} depends_on: postgres: condition: service_healthy diff --git a/package.json b/package.json index 09461cd2..af86fcdd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "omni-bot", "version": "0.1.0", "private": true, - "description": "", + "description": "Modular Discord bot whose modules are installed per guild", "keywords": [], "license": "GPL-3.0-or-later", "author": "", @@ -17,7 +17,7 @@ "scripts": { "build": "pnpm prisma:generate && tsc", "dev": "NODE_ENV=development node --conditions=development --env-file=.env --import tsx ./bootstrap/validate-env-vars.ts && NODE_ENV=development node --conditions=development --env-file=.env --import tsx src/index.ts", - "start": "node --import tsx ./bootstrap/validate-env-vars.ts && node ./dist/index.js", + "start": "node ./bootstrap/validate-env-vars.ts && node ./dist/index.js", "test": "vitest run", "test:unit": "vitest run --project unit", "test:integration": "vitest run --project integration", @@ -33,20 +33,20 @@ "@napi-rs/canvas": "^1.0.9", "@prisma/client": "^6.19.3", "discord.js": "^14.27.0", - "i18next": "^26.3.1", - "pino": "^10.3.1", - "pino-pretty": "^13.1.3" + "i18next": "^26.4.2", + "pino": "^10.3.1" }, "devDependencies": { "@commitlint/cli": "^21.0.0", "@commitlint/config-conventional": "^21.0.0", - "@types/node": "^24.0.0", - "lefthook": "^2.1.9", - "oxfmt": "^0.67.0", + "@types/node": "^24.19.1", + "lefthook": "^2.1.16", + "oxfmt": "^0.71.0", "oxlint": "^1.12.0", + "pino-pretty": "^13.1.3", "prisma": "^6.19.3", "tsx": "^4.20.5", - "typescript": "^5.9.2", + "typescript": "^6.0.3", "vitest": "^4.1.8" }, "packageManager": "pnpm@11.24.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ad49e14..20a3010d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,56 +13,56 @@ importers: version: 1.0.9 '@prisma/client': specifier: ^6.19.3 - version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + version: 6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3) discord.js: specifier: ^14.27.0 version: 14.27.0 i18next: - specifier: ^26.3.1 - version: 26.3.1(typescript@5.9.3) + specifier: ^26.4.2 + version: 26.4.2(typescript@6.0.3) pino: specifier: ^10.3.1 version: 10.3.1 - pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 devDependencies: '@commitlint/cli': specifier: ^21.0.0 - version: 21.2.2(@types/node@24.13.4)(conventional-commits-parser@7.1.2)(typescript@5.9.3) + version: 21.2.2(@types/node@24.19.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3) '@commitlint/config-conventional': specifier: ^21.0.0 version: 21.2.2 '@types/node': - specifier: ^24.0.0 - version: 24.13.4 + specifier: ^24.19.1 + version: 24.19.1 lefthook: - specifier: ^2.1.9 - version: 2.1.12 + specifier: ^2.1.16 + version: 2.1.16 oxfmt: - specifier: ^0.67.0 - version: 0.67.0 + specifier: ^0.71.0 + version: 0.71.0 oxlint: specifier: ^1.12.0 version: 1.82.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 prisma: specifier: ^6.19.3 - version: 6.19.3(typescript@5.9.3) + version: 6.19.3(typescript@6.0.3) tsx: specifier: ^4.20.5 version: 4.23.13 typescript: - specifier: ^5.9.2 - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.11(@types/node@24.13.4)(vite@8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@types/node@24.19.1)(vite@8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) docs/site: dependencies: vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.54.0)(@types/node@24.13.4)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.54.0)(@types/node@24.19.1)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3) packages: @@ -696,124 +696,124 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxfmt/binding-android-arm-eabi@0.67.0': - resolution: {integrity: sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q==} + '@oxfmt/binding-android-arm-eabi@0.71.0': + resolution: {integrity: sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.67.0': - resolution: {integrity: sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw==} + '@oxfmt/binding-android-arm64@0.71.0': + resolution: {integrity: sha512-E8q39SUZzSXQ08oZoFoTkxRADkdBNgL6De4qgZYr2O4JCunwqrZwAyCebxIF3yVHBafHM30WUkhTkDi+jNYZOw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.67.0': - resolution: {integrity: sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg==} + '@oxfmt/binding-darwin-arm64@0.71.0': + resolution: {integrity: sha512-pTteTrN88DicrmJ3DocBmpNDa6Umfh0iveEG8rnAlDFQpclmtDS7hnIvZbIEV9mDO0Ot1U0sjtoJQ1itd/ZMhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.67.0': - resolution: {integrity: sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg==} + '@oxfmt/binding-darwin-x64@0.71.0': + resolution: {integrity: sha512-G5melRC3IiNEgXlNKJhSExJYQGAQIppHFp2cl/wFrqQcEwmc4k4TkrHaFTR29SDudjoW5AZq5cXPGJDqtlH/Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.67.0': - resolution: {integrity: sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg==} + '@oxfmt/binding-freebsd-x64@0.71.0': + resolution: {integrity: sha512-Hb1P5yKb9aiH2VSbm3Ml37mKjzqpunYet9krw/YK7V5JsAUJDQlju3Yk/dEqQvJD/usBvpi01Vbk9ZDNo0a8Cw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.67.0': - resolution: {integrity: sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw==} + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': + resolution: {integrity: sha512-r7n4JEO+rkp0kaP3ia+0hv6FzK3PQIHqaF0jxGuhjJ4JHVX9ZVRbgpDVDW+OloyuJsRWpWFDKIHe8gfl1p/4mQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.67.0': - resolution: {integrity: sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg==} + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': + resolution: {integrity: sha512-EEynTF4nakgqDhVCmd3cHQCmTcw0BM8ea6eHigTdtZ3yxdsBNv+dYkt+OpDDZx1n/jzjI7J+Ht7IgkqFWuJUxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.67.0': - resolution: {integrity: sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ==} + '@oxfmt/binding-linux-arm64-gnu@0.71.0': + resolution: {integrity: sha512-7VgJIrywCwR/G6YMv+HqsccUt8Z4q4mbM2xR+Ry/6AeMk4pCmZ9aO4Srd3mmrvVu8PsCZGP7oHEHSXWTmyloaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.67.0': - resolution: {integrity: sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw==} + '@oxfmt/binding-linux-arm64-musl@0.71.0': + resolution: {integrity: sha512-AOCaminv/+fhinUXKvtPZT4POhNYmN9GfvaJdgJa1AbvuzClWGwDIXfJg8eu3sYaVL2B68jNvmS7LZGAYa0z3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.67.0': - resolution: {integrity: sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w==} + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': + resolution: {integrity: sha512-0SHxuaa4QRLM7jfsd8wc6L7KdLg+FR6/ah9k1eAvcrK/Sni7oxchMvsjwUsyakVAta+wBVDkjJTr7/RFU0uUGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.67.0': - resolution: {integrity: sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A==} + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': + resolution: {integrity: sha512-UNKj1+rPorTK6uSlQzpN96vDNbHbL/63zcpznweo0ImQdkDdtQCS7G3puxX++2cESPKT2B56Y6DICUmFTJde5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.67.0': - resolution: {integrity: sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA==} + '@oxfmt/binding-linux-riscv64-musl@0.71.0': + resolution: {integrity: sha512-5cVXKj7a0mWu7GU6/bZgNyv+KtLgM5S9cnT26Os2/abj8pK/bklHsbdYEHGLLniGjNYkyzwem8mZciPCaDtilw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.67.0': - resolution: {integrity: sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg==} + '@oxfmt/binding-linux-s390x-gnu@0.71.0': + resolution: {integrity: sha512-lNpcMuZvyU1V/CY5h19hac03m4E4PLSYRckcxZOynSPojI4Mwt6nEu9wT/gVf0bclH3xGEWjwqApnSTgjkd2oA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.67.0': - resolution: {integrity: sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w==} + '@oxfmt/binding-linux-x64-gnu@0.71.0': + resolution: {integrity: sha512-5/Z6pUewQpknXqC4/ykK6Zc6RiteAnPem1Ci7K1RZLVF6w6MMjwHjR4vsjijW4Czidgv7HKeVglGjElADliT9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.67.0': - resolution: {integrity: sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w==} + '@oxfmt/binding-linux-x64-musl@0.71.0': + resolution: {integrity: sha512-uVdG2N/4GEbOeljpQ+xv+NeEwJWJGj0WaxSiSYnoiqIYy3RWrWd3rGUmxWXP1A8+ferNvvwFoDAtvgsDUvBuSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.67.0': - resolution: {integrity: sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA==} + '@oxfmt/binding-openharmony-arm64@0.71.0': + resolution: {integrity: sha512-1LwEZmSDRFKeBaGd/Hgj2xI3SmgGFpF4YtgkSx0sTYdTiZfHWbcFvRIBjL1YiDNdQd9Khda3Ij8CoqBULjskDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.67.0': - resolution: {integrity: sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w==} + '@oxfmt/binding-win32-arm64-msvc@0.71.0': + resolution: {integrity: sha512-5tplmkeMXmza3PUptJQy25wCPJbIAg5g5r7zs+DI+7eEYOqbY+7roMdSH2zbDvv1Pb4oTDJ6NWYx9IRxBxXwhQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.67.0': - resolution: {integrity: sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA==} + '@oxfmt/binding-win32-ia32-msvc@0.71.0': + resolution: {integrity: sha512-T2LWgh3Vx4PYDjtcqRRGDyCsVxeF/VE5xTpUQiCm65bNLwMpRnJYj6Hlkf6qRM3+jxCIOqHD1kVHNQCCiAVMNQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.67.0': - resolution: {integrity: sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg==} + '@oxfmt/binding-win32-x64-msvc@0.71.0': + resolution: {integrity: sha512-L06SVp7Hu1EQTkh3+PHnKK26dxVbEpOG2InMwCooTE4MA437b5FgfTleCCiGn4ykISHnHU6UHaIg7SiMO7rBHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1283,8 +1283,8 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/node@24.13.4': - resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==} + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1653,8 +1653,8 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} - fast-copy@4.0.3: - resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} + fast-copy@4.1.1: + resolution: {integrity: sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1662,8 +1662,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1713,10 +1713,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - i18next@26.3.1: - resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==} + i18next@26.4.2: + resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==} peerDependencies: - typescript: ^5 || ^6 + typescript: ^5 || ^6 || ^7 peerDependenciesMeta: typescript: optional: true @@ -1755,8 +1755,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true json-parse-even-better-errors@2.3.1: @@ -1765,58 +1765,58 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - lefthook-darwin-arm64@2.1.12: - resolution: {integrity: sha512-GSUjqaCuxAYlPOovbjXEWE3HAIa/xOQkTTmZ937zieQldjPLTaC7+s5FHoxKywn+D9riBlofkDqG7Z4f5zzmPA==} + lefthook-darwin-arm64@2.1.16: + resolution: {integrity: sha512-oOjS1mn3UGNfo72UtR9VJMyulgbjvLkv5KEmJVgfWiO8wZ4Z469o2ZllvH5FrHluzv4Hlkc9OG+vnCP+5/ztMA==} cpu: [arm64] os: [darwin] - lefthook-darwin-x64@2.1.12: - resolution: {integrity: sha512-qAUHSSw/7Afi5wxkoLkxORxSicCeTBXyVLnRgD6YZra6udviozpK3Aok9Z6FIWeKc5FBijRMSNDxGPTREhsjcQ==} + lefthook-darwin-x64@2.1.16: + resolution: {integrity: sha512-/thON8TbzsqXBjF0hkVPsisBJOKGfss0+lVqcJ0jNwaRDIQSn6Frsn7c9eqs7uyMNEQKUVLTeIkpwPrNldT8JA==} cpu: [x64] os: [darwin] - lefthook-freebsd-arm64@2.1.12: - resolution: {integrity: sha512-FHZRfKliFNbyz54zsoGdVe9muq67cNjBTZ6jrPPUjI7xUuyCwSpzA+1OpiwJT00L9pP5ZGONBqnGZKnrpC+7Uw==} + lefthook-freebsd-arm64@2.1.16: + resolution: {integrity: sha512-Eg7s0gcGwZnQqYyWTfOqb+2qupXtmHt/XHftXJvLhTaTMXR6uQbAyl7ceYTl2wH43AOCLrJ5BMMdqlWJaj/EbA==} cpu: [arm64] os: [freebsd] - lefthook-freebsd-x64@2.1.12: - resolution: {integrity: sha512-HYQZPGy2DDEx7dbuotusJpujY5q9igOLgx36qeeQcNQuvvdGHPj2ZGSIsGKhoJ0dw5Qa4knKJoPAHEZQWdV/og==} + lefthook-freebsd-x64@2.1.16: + resolution: {integrity: sha512-vCmyKLO8mtAf+hm8rpfAlt4wQ23S7AGnlT6JlrLPBSt5V8J2kXuI/N1W2XQbWx6OqckqchhBdLQP3Xh4IiYBXA==} cpu: [x64] os: [freebsd] - lefthook-linux-arm64@2.1.12: - resolution: {integrity: sha512-ipjOri2PB/sk4noPrHQuM5fcpNBjmlKo4qMtXyLnxeOxGYHWZzf0Xi0OtrXHQ//tOprcv40ADvhUuSdcGqigLw==} + lefthook-linux-arm64@2.1.16: + resolution: {integrity: sha512-zMEZ8x+PzXqjouEFsf2lWt2o0p5yvvT0YP0CpJC41y4FIRusx0sy3l+cjvKRIysLGOzKMr4V3/fE6w7f2KgwUw==} cpu: [arm64] os: [linux] - lefthook-linux-x64@2.1.12: - resolution: {integrity: sha512-DmU6xfvqVoFdW+STyc70YI4Ry8vkHGKHx+IJ1Js/Gacq5dv+fiwNzwle3bi28EkL6P8xY67B/CfQfRj4SRU4tg==} + lefthook-linux-x64@2.1.16: + resolution: {integrity: sha512-HdfNz3z5hzrEXyP5KC51os+2M6BThL4qMfu8rB777tbXzf7WMoxf/FV6mnzoqM1RN0K+KJAQYbnJatfPQhqusQ==} cpu: [x64] os: [linux] - lefthook-openbsd-arm64@2.1.12: - resolution: {integrity: sha512-vb0J7bElLvoaCWQmna3HntsvoNqMsGAhfjjC99ss1OdCteufZKM3RxCVoMBEbD50Itv2c1Ua2AFVToltVFTy1Q==} + lefthook-openbsd-arm64@2.1.16: + resolution: {integrity: sha512-zKkyN/ocUXOzFaLPn3ciucJ6CYcnUmqFsIwp7ge96sQ8l83omxL9rK0/KvSsMcus+TfHTNN+DFWVJxeMepPVtQ==} cpu: [arm64] os: [openbsd] - lefthook-openbsd-x64@2.1.12: - resolution: {integrity: sha512-QOmhDWqPPK4+99scanfEmbJjUR+JYC93qhr/0bp5Dj3LaR3kVFNWc6zOQUsOTPy/LC6PG759Lej/mr/BtjUL2Q==} + lefthook-openbsd-x64@2.1.16: + resolution: {integrity: sha512-Ptu0D2JK1COlDK1lcr7F6WDGkCzQ80ozeTrqNzPEyBjUdZAFLwR5K5gWgaRtR06RSoYDC72RFxKM1QYnsfF0rQ==} cpu: [x64] os: [openbsd] - lefthook-windows-arm64@2.1.12: - resolution: {integrity: sha512-eErEyr9AHkRFj6TxpCQeKGd0s6IrtL/VEIZ/ssg8dNfG+ycR4jAW/IAlrJSw6/ZQXb3WtWLaQ6QA5c+TLh7vmg==} + lefthook-windows-arm64@2.1.16: + resolution: {integrity: sha512-YTzFJwlF3qbHx+CCuiyDmDPASh6mYBp1I8zfTeGKI3L24Eoct6FUaGUlfAoz8SI3DECPmBhfNgAK4dZlX9Hd5g==} cpu: [arm64] os: [win32] - lefthook-windows-x64@2.1.12: - resolution: {integrity: sha512-0h+WmDVdDriTjloD/UbjPq/ZtqaaJlPAdGcVwMCEdAxfbF0FTgDbKX3igui9g2U/qG2y6QpVhtz1jeiRe7ctaw==} + lefthook-windows-x64@2.1.16: + resolution: {integrity: sha512-mQVpfaagdIZPJCGDeedRnslyd9JTc13vjmp02uu4X7cr/VZK94obwk96dhgsRp9+LL1XMhX5zTcb0zNLo1hC+A==} cpu: [x64] os: [win32] - lefthook@2.1.12: - resolution: {integrity: sha512-2uOexfsrrRhCiTUWdGyDySxVHHhOFJ8MQejs27dM3hugrQB48/z1ZVlaNoxpZ1SnzQ1GaDIbO5rAvicwyiknYQ==} + lefthook@2.1.16: + resolution: {integrity: sha512-U69YGDNsZ7R+umeSzdLzORNCHQFz/w4LKyNTjJQcGrQRQCr8hlCJfJwwm9sqq9Uc5N6dImS58ywURA0FkqoHlg==} hasBin: true lightningcss-android-arm64@1.33.0: @@ -1968,8 +1968,8 @@ packages: oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} - oxfmt@0.67.0: - resolution: {integrity: sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA==} + oxfmt@0.71.0: + resolution: {integrity: sha512-lUPUl0d/+Io5pDrsPXWs6rB4N/bpB78oj9CTDpnbulfDz+0r3XXcHPlQ7kRPJ2GjIT4nX+/mcqunOeP9BvsEtg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2198,8 +2198,8 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@2.1.2: - resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} engines: {node: ^20.0.0 || >=22.0.0} tinyrainbow@3.1.1: @@ -2220,13 +2220,13 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@6.28.1: resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} @@ -2565,12 +2565,12 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@commitlint/cli@21.2.2(@types/node@24.13.4)(conventional-commits-parser@7.1.2)(typescript@5.9.3)': + '@commitlint/cli@21.2.2(@types/node@24.19.1)(conventional-commits-parser@7.1.2)(typescript@6.0.3)': dependencies: '@commitlint/config-conventional': 21.2.2 '@commitlint/format': 21.2.2 '@commitlint/lint': 21.2.2 - '@commitlint/load': 21.2.2(@types/node@24.13.4)(typescript@5.9.3) + '@commitlint/load': 21.2.2(@types/node@24.19.1)(typescript@6.0.3) '@commitlint/read': 21.2.1(conventional-commits-parser@7.1.2) '@commitlint/types': 21.2.0 tinyexec: 1.3.0 @@ -2615,14 +2615,14 @@ snapshots: '@commitlint/rules': 21.2.2 '@commitlint/types': 21.2.0 - '@commitlint/load@21.2.2(@types/node@24.13.4)(typescript@5.9.3)': + '@commitlint/load@21.2.2(@types/node@24.19.1)(typescript@6.0.3)': dependencies: '@commitlint/config-validator': 21.2.0 '@commitlint/execute-rule': 21.0.1 '@commitlint/resolve-extends': 21.2.2 '@commitlint/types': 21.2.0 - cosmiconfig: 9.0.2(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.3.0(@types/node@24.13.4)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig: 9.0.2(typescript@6.0.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.19.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3) es-toolkit: 1.50.0 is-plain-obj: 4.1.0 picocolors: 1.1.1 @@ -2984,61 +2984,61 @@ snapshots: '@oxc-project/types@0.133.0': {} - '@oxfmt/binding-android-arm-eabi@0.67.0': + '@oxfmt/binding-android-arm-eabi@0.71.0': optional: true - '@oxfmt/binding-android-arm64@0.67.0': + '@oxfmt/binding-android-arm64@0.71.0': optional: true - '@oxfmt/binding-darwin-arm64@0.67.0': + '@oxfmt/binding-darwin-arm64@0.71.0': optional: true - '@oxfmt/binding-darwin-x64@0.67.0': + '@oxfmt/binding-darwin-x64@0.71.0': optional: true - '@oxfmt/binding-freebsd-x64@0.67.0': + '@oxfmt/binding-freebsd-x64@0.71.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.67.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.67.0': + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.67.0': + '@oxfmt/binding-linux-arm64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.67.0': + '@oxfmt/binding-linux-arm64-musl@0.71.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.67.0': + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.67.0': + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.67.0': + '@oxfmt/binding-linux-riscv64-musl@0.71.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.67.0': + '@oxfmt/binding-linux-s390x-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.67.0': + '@oxfmt/binding-linux-x64-gnu@0.71.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.67.0': + '@oxfmt/binding-linux-x64-musl@0.71.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.67.0': + '@oxfmt/binding-openharmony-arm64@0.71.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.67.0': + '@oxfmt/binding-win32-arm64-msvc@0.71.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.67.0': + '@oxfmt/binding-win32-ia32-msvc@0.71.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.67.0': + '@oxfmt/binding-win32-x64-msvc@0.71.0': optional: true '@oxlint/binding-android-arm-eabi@1.82.0': @@ -3100,10 +3100,10 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': + '@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3)': optionalDependencies: - prisma: 6.19.3(typescript@5.9.3) - typescript: 5.9.3 + prisma: 6.19.3(typescript@6.0.3) + typescript: 6.0.3 '@prisma/config@6.19.3': dependencies: @@ -3349,9 +3349,9 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/node@24.13.4': + '@types/node@24.19.1': dependencies: - undici-types: 7.18.2 + undici-types: 7.24.6 '@types/unist@3.0.3': {} @@ -3359,14 +3359,14 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 24.13.4 + '@types/node': 24.19.1 '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@24.13.4)(lightningcss@1.33.0))(vue@3.5.38(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@24.19.1)(lightningcss@1.33.0))(vue@3.5.38(typescript@6.0.3))': dependencies: - vite: 5.4.21(@types/node@24.13.4)(lightningcss@1.33.0) - vue: 3.5.38(typescript@5.9.3) + vite: 5.4.21(@types/node@24.19.1)(lightningcss@1.33.0) + vue: 3.5.38(typescript@6.0.3) '@vitest/expect@4.1.11': dependencies: @@ -3377,13 +3377,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -3475,28 +3475,28 @@ snapshots: '@vue/shared': 3.5.38 csstype: 3.2.3 - '@vue/server-renderer@3.5.38(vue@3.5.38(typescript@5.9.3))': + '@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3))': dependencies: '@vue/compiler-ssr': 3.5.38 '@vue/shared': 3.5.38 - vue: 3.5.38(typescript@5.9.3) + vue: 3.5.38(typescript@6.0.3) '@vue/shared@3.5.38': {} - '@vueuse/core@12.8.2(typescript@5.9.3)': + '@vueuse/core@12.8.2(typescript@6.0.3)': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 12.8.2 - '@vueuse/shared': 12.8.2(typescript@5.9.3) - vue: 3.5.38(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.38(typescript@6.0.3) transitivePeerDependencies: - typescript - '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@5.9.3)': + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': dependencies: - '@vueuse/core': 12.8.2(typescript@5.9.3) - '@vueuse/shared': 12.8.2(typescript@5.9.3) - vue: 3.5.38(typescript@5.9.3) + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.38(typescript@6.0.3) optionalDependencies: focus-trap: 7.8.0 transitivePeerDependencies: @@ -3504,16 +3504,16 @@ snapshots: '@vueuse/metadata@12.8.2': {} - '@vueuse/shared@12.8.2(typescript@5.9.3)': + '@vueuse/shared@12.8.2(typescript@6.0.3)': dependencies: - vue: 3.5.38(typescript@5.9.3) + vue: 3.5.38(typescript@6.0.3) transitivePeerDependencies: - typescript ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3616,21 +3616,21 @@ snapshots: dependencies: is-what: 5.5.0 - cosmiconfig-typescript-loader@6.3.0(@types/node@24.13.4)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.3.0(@types/node@24.19.1)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): dependencies: - '@types/node': 24.13.4 - cosmiconfig: 9.0.2(typescript@5.9.3) + '@types/node': 24.19.1 + cosmiconfig: 9.0.2(typescript@6.0.3) jiti: 2.6.1 - typescript: 5.9.3 + typescript: 6.0.3 - cosmiconfig@9.0.2(typescript@5.9.3): + cosmiconfig@9.0.2(typescript@6.0.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 csstype@3.2.3: {} @@ -3773,13 +3773,13 @@ snapshots: dependencies: pure-rand: 6.1.0 - fast-copy@4.0.3: {} + fast-copy@4.1.1: {} fast-deep-equal@3.1.3: {} fast-safe-stringify@2.1.1: {} - fast-uri@3.1.5: {} + fast-uri@3.1.8: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: @@ -3833,9 +3833,9 @@ snapshots: html-void-elements@3.0.0: {} - i18next@26.3.1(typescript@5.9.3): + i18next@26.4.2(typescript@6.0.3): optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 import-fresh@3.3.1: dependencies: @@ -3858,7 +3858,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -3866,48 +3866,48 @@ snapshots: json-schema-traverse@1.0.0: {} - lefthook-darwin-arm64@2.1.12: + lefthook-darwin-arm64@2.1.16: optional: true - lefthook-darwin-x64@2.1.12: + lefthook-darwin-x64@2.1.16: optional: true - lefthook-freebsd-arm64@2.1.12: + lefthook-freebsd-arm64@2.1.16: optional: true - lefthook-freebsd-x64@2.1.12: + lefthook-freebsd-x64@2.1.16: optional: true - lefthook-linux-arm64@2.1.12: + lefthook-linux-arm64@2.1.16: optional: true - lefthook-linux-x64@2.1.12: + lefthook-linux-x64@2.1.16: optional: true - lefthook-openbsd-arm64@2.1.12: + lefthook-openbsd-arm64@2.1.16: optional: true - lefthook-openbsd-x64@2.1.12: + lefthook-openbsd-x64@2.1.16: optional: true - lefthook-windows-arm64@2.1.12: + lefthook-windows-arm64@2.1.16: optional: true - lefthook-windows-x64@2.1.12: + lefthook-windows-x64@2.1.16: optional: true - lefthook@2.1.12: + lefthook@2.1.16: optionalDependencies: - lefthook-darwin-arm64: 2.1.12 - lefthook-darwin-x64: 2.1.12 - lefthook-freebsd-arm64: 2.1.12 - lefthook-freebsd-x64: 2.1.12 - lefthook-linux-arm64: 2.1.12 - lefthook-linux-x64: 2.1.12 - lefthook-openbsd-arm64: 2.1.12 - lefthook-openbsd-x64: 2.1.12 - lefthook-windows-arm64: 2.1.12 - lefthook-windows-x64: 2.1.12 + lefthook-darwin-arm64: 2.1.16 + lefthook-darwin-x64: 2.1.16 + lefthook-freebsd-arm64: 2.1.16 + lefthook-freebsd-x64: 2.1.16 + lefthook-linux-arm64: 2.1.16 + lefthook-linux-x64: 2.1.16 + lefthook-openbsd-arm64: 2.1.16 + lefthook-openbsd-x64: 2.1.16 + lefthook-windows-arm64: 2.1.16 + lefthook-windows-x64: 2.1.16 lightningcss-android-arm64@1.33.0: optional: true @@ -4033,29 +4033,29 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 - oxfmt@0.67.0: + oxfmt@0.71.0: dependencies: - tinypool: 2.1.2 + tinypool: 2.2.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.67.0 - '@oxfmt/binding-android-arm64': 0.67.0 - '@oxfmt/binding-darwin-arm64': 0.67.0 - '@oxfmt/binding-darwin-x64': 0.67.0 - '@oxfmt/binding-freebsd-x64': 0.67.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.67.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.67.0 - '@oxfmt/binding-linux-arm64-gnu': 0.67.0 - '@oxfmt/binding-linux-arm64-musl': 0.67.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.67.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.67.0 - '@oxfmt/binding-linux-riscv64-musl': 0.67.0 - '@oxfmt/binding-linux-s390x-gnu': 0.67.0 - '@oxfmt/binding-linux-x64-gnu': 0.67.0 - '@oxfmt/binding-linux-x64-musl': 0.67.0 - '@oxfmt/binding-openharmony-arm64': 0.67.0 - '@oxfmt/binding-win32-arm64-msvc': 0.67.0 - '@oxfmt/binding-win32-ia32-msvc': 0.67.0 - '@oxfmt/binding-win32-x64-msvc': 0.67.0 + '@oxfmt/binding-android-arm-eabi': 0.71.0 + '@oxfmt/binding-android-arm64': 0.71.0 + '@oxfmt/binding-darwin-arm64': 0.71.0 + '@oxfmt/binding-darwin-x64': 0.71.0 + '@oxfmt/binding-freebsd-x64': 0.71.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.71.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.71.0 + '@oxfmt/binding-linux-arm64-gnu': 0.71.0 + '@oxfmt/binding-linux-arm64-musl': 0.71.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-musl': 0.71.0 + '@oxfmt/binding-linux-s390x-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-musl': 0.71.0 + '@oxfmt/binding-openharmony-arm64': 0.71.0 + '@oxfmt/binding-win32-arm64-msvc': 0.71.0 + '@oxfmt/binding-win32-ia32-msvc': 0.71.0 + '@oxfmt/binding-win32-x64-msvc': 0.71.0 oxlint@1.82.0: optionalDependencies: @@ -4106,7 +4106,7 @@ snapshots: dependencies: colorette: 2.0.20 dateformat: 4.6.3 - fast-copy: 4.0.3 + fast-copy: 4.1.1 fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 @@ -4148,12 +4148,12 @@ snapshots: preact@10.29.2: {} - prisma@6.19.3(typescript@5.9.3): + prisma@6.19.3(typescript@6.0.3): dependencies: '@prisma/config': 6.19.3 '@prisma/engines': 6.19.3 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - magicast @@ -4329,7 +4329,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - tinypool@2.1.2: {} + tinypool@2.2.0: {} tinyrainbow@3.1.1: {} @@ -4345,9 +4345,9 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@5.9.3: {} + typescript@6.0.3: {} - undici-types@7.18.2: {} + undici-types@7.24.6: {} undici@6.28.1: {} @@ -4384,17 +4384,17 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@5.4.21(@types/node@24.13.4)(lightningcss@1.33.0): + vite@5.4.21(@types/node@24.19.1)(lightningcss@1.33.0): dependencies: esbuild: 0.21.5 postcss: 8.5.26 rollup: 4.62.0 optionalDependencies: - '@types/node': 24.13.4 + '@types/node': 24.19.1 fsevents: 2.3.3 lightningcss: 1.33.0 - vite@8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): + vite@8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -4402,14 +4402,14 @@ snapshots: rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 24.13.4 + '@types/node': 24.19.1 esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 yaml: 2.9.0 - vitepress@1.6.4(@algolia/client-search@5.54.0)(@types/node@24.13.4)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.54.0)(@types/node@24.19.1)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.54.0)(search-insights@2.17.3) @@ -4418,17 +4418,17 @@ snapshots: '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@24.13.4)(lightningcss@1.33.0))(vue@3.5.38(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@24.19.1)(lightningcss@1.33.0))(vue@3.5.38(typescript@6.0.3)) '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.38 - '@vueuse/core': 12.8.2(typescript@5.9.3) - '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.9.3) + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) focus-trap: 7.8.0 mark.js: 8.11.1 minisearch: 7.2.0 shiki: 2.5.0 - vite: 5.4.21(@types/node@24.13.4)(lightningcss@1.33.0) - vue: 3.5.38(typescript@5.9.3) + vite: 5.4.21(@types/node@24.19.1)(lightningcss@1.33.0) + vue: 3.5.38(typescript@6.0.3) optionalDependencies: postcss: 8.5.26 transitivePeerDependencies: @@ -4458,10 +4458,10 @@ snapshots: - typescript - universal-cookie - vitest@4.1.11(@types/node@24.13.4)(vite@8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@types/node@24.19.1)(vite@8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4478,22 +4478,22 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.0.16(@types/node@24.13.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.0.16(@types/node@24.19.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.13.4 + '@types/node': 24.19.1 transitivePeerDependencies: - msw - vue@3.5.38(typescript@5.9.3): + vue@3.5.38(typescript@6.0.3): dependencies: '@vue/compiler-dom': 3.5.38 '@vue/compiler-sfc': 3.5.38 '@vue/runtime-dom': 3.5.38 - '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@5.9.3)) + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@6.0.3)) '@vue/shared': 3.5.38 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.3 why-is-node-running@2.3.0: dependencies: diff --git a/src/core/commands/config.command.test.ts b/src/core/commands/config.command.test.ts new file mode 100644 index 00000000..c9c64fcc --- /dev/null +++ b/src/core/commands/config.command.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("#index.js", () => ({ modules: [], client: {} })); +vi.mock("#core/core.module.js", () => ({ + default: { id: "core", name: "Core" }, +})); +vi.mock("#core/utils/core-messages.js", () => ({})); + +const { getAllModulesStateIn, getConfigForModuleIn } = vi.hoisted(() => ({ + getAllModulesStateIn: vi.fn(), + getConfigForModuleIn: vi.fn(), +})); +vi.mock("#core/services/module.service.js", () => ({ + default: { getAllModulesStateIn }, +})); +vi.mock("#core/services/config.service.js", () => ({ + default: { getConfigForModuleIn }, +})); + +const { default: configCommand } = await import("./config.command.js"); + +function fakeAutocomplete(focused: string) { + return { + guildId: "guild-1", + options: { getFocused: () => focused }, + respond: vi.fn(async () => {}), + } as any; +} + +function enabledModules(count: number) { + return Array.from({ length: count }, (_, i) => ({ + module: { id: `mod-${i}`, name: `Module ${i}` }, + enabled: true, + })); +} + +describe("config autocomplete", () => { + beforeEach(() => { + vi.clearAllMocks(); + getConfigForModuleIn.mockResolvedValue({ + t: (_key: string, options: { defaultValue: string }) => + options.defaultValue, + }); + }); + + it("never offers more than 25 choices", async () => { + getAllModulesStateIn.mockResolvedValue(enabledModules(40)); + + const interaction = fakeAutocomplete(""); + await configCommand.complete!(interaction, {} as never); + + expect(interaction.respond.mock.calls[0][0]).toHaveLength(25); + }); + + it("filters choices on what the user typed", async () => { + getAllModulesStateIn.mockResolvedValue(enabledModules(3)); + + const interaction = fakeAutocomplete("core"); + await configCommand.complete!(interaction, {} as never); + + expect(interaction.respond).toHaveBeenCalledWith([ + { name: "Core", value: "core" }, + ]); + }); +}); diff --git a/src/core/commands/config.command.ts b/src/core/commands/config.command.ts index 316eb93c..6c64641e 100644 --- a/src/core/commands/config.command.ts +++ b/src/core/commands/config.command.ts @@ -85,14 +85,23 @@ export default declareCommand({ value: m.module.id, })); - await interaction.respond([ - ...moduleEntries, - { - name: t("modules." + coreModule.id + ".name", { - defaultValue: coreModule.name, - }), - value: coreModule.id, - }, - ]); + const focused = interaction.options.getFocused().toLowerCase(); + await interaction.respond( + [ + ...moduleEntries, + { + name: t("modules." + coreModule.id + ".name", { + defaultValue: coreModule.name, + }), + value: coreModule.id, + }, + ] + .filter( + (entry) => + entry.name.toLowerCase().includes(focused) || + entry.value.toLowerCase().includes(focused) + ) + .slice(0, 25) + ); }, }); diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json index 937a642c..6c890db1 100644 --- a/src/core/i18n/en.json +++ b/src/core/i18n/en.json @@ -33,6 +33,8 @@ "command.notEnabled": "The command `{{commandName}}` is not enabled in this guild.", "command.failed": "Something went wrong while running `{{commandName}}`. Please try again later.", + "interaction.moduleNotEnabled": "This feature's module is not enabled in this server.", + "interaction.failed": "Something went wrong. Please try again later.", "interaction.malformed": "The button is malformed. Please try again later.", "interaction.moduleNotFound": "Module not found. Please try again later.", "interaction.failedEnable": "Failed to enable the module. Please try again later.", diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json index 88e2b202..6ba356a2 100644 --- a/src/core/i18n/fr.json +++ b/src/core/i18n/fr.json @@ -33,6 +33,8 @@ "command.notEnabled": "La commande `{{commandName}}` n'est pas activée sur ce serveur.", "command.failed": "Quelque chose s'est mal passé lors de l'exécution de `{{commandName}}`. Veuillez réessayer plus tard.", + "interaction.moduleNotEnabled": "Le module de cette fonctionnalité n'est pas activé sur ce serveur.", + "interaction.failed": "Quelque chose s'est mal passé. Veuillez réessayer plus tard.", "interaction.malformed": "Le bouton est mal formé. Veuillez réessayer plus tard.", "interaction.moduleNotFound": "Module introuvable. Veuillez réessayer plus tard.", "interaction.failedEnable": "Échec de l'activation du module. Veuillez réessayer plus tard.", diff --git a/src/core/interactions/reset-config.ts b/src/core/interactions/reset-config.ts index 27c551d1..b24cc96f 100644 --- a/src/core/interactions/reset-config.ts +++ b/src/core/interactions/reset-config.ts @@ -62,7 +62,7 @@ export const resetConfigButton = declareInteractionHandler({ }), value: key, })), - ]; + ].slice(0, 25); const menu = new StringSelectMenuBuilder() .setCustomId(customId) diff --git a/src/core/listeners/guild-create.listener.test.ts b/src/core/listeners/guild-create.listener.test.ts index e5a20fd9..903dd484 100644 --- a/src/core/listeners/guild-create.listener.test.ts +++ b/src/core/listeners/guild-create.listener.test.ts @@ -14,18 +14,20 @@ const { clearCacheForGuild, getFullConfigForGuild, getAllModulesStateIn, + updateModuleActivation, installModuleCommandsIn, } = vi.hoisted(() => ({ clearCacheForGuild: vi.fn(), getFullConfigForGuild: vi.fn(), getAllModulesStateIn: vi.fn(), + updateModuleActivation: vi.fn(), installModuleCommandsIn: vi.fn(), })); vi.mock("#core/services/config.service.js", () => ({ default: { clearCacheForGuild, getFullConfigForGuild }, })); vi.mock("#core/services/module.service.js", () => ({ - default: { getAllModulesStateIn }, + default: { getAllModulesStateIn, updateModuleActivation }, })); vi.mock("#core/loaders/command-loader.js", () => ({ installModuleCommandsIn, @@ -110,6 +112,27 @@ describe("guildCreate", () => { expect(installModuleCommandsIn).toHaveBeenCalledTimes(2); }); + it("records the live version only for modules reinstalled successfully", async () => { + const modA = { id: "a", version: "2.0.0", registry: { commands: [{}] } }; + const modB = { id: "b", version: "3.0.0", registry: { commands: [{}] } }; + mockModules.push(modA, modB); + getAllModulesStateIn.mockResolvedValue([ + { module: { id: "a" }, enabled: true }, + { module: { id: "b" }, enabled: true }, + ]); + installModuleCommandsIn.mockRejectedValueOnce(new Error("boom")); + const { guild } = fakeGuild(); + + await listener.execute(guild, undefined); + + expect(updateModuleActivation).toHaveBeenCalledTimes(1); + expect(updateModuleActivation).toHaveBeenCalledWith( + "b", + "guild-1", + "3.0.0" + ); + }); + it("falls back to the owner DM when no system channel exists", async () => { const ownerSend = vi.fn(async () => ({})); const { guild } = fakeGuild({ diff --git a/src/core/listeners/guild-create.listener.ts b/src/core/listeners/guild-create.listener.ts index 0c86942f..c16a752c 100644 --- a/src/core/listeners/guild-create.listener.ts +++ b/src/core/listeners/guild-create.listener.ts @@ -92,6 +92,7 @@ async function reinstallModuleCommands(guild: Guild): Promise { // Sequential on purpose: the installer already POSTs in parallel per // command, no need to fan out across modules too. await installModuleCommandsIn(guild.client, mod, guild); + await moduleService.updateModuleActivation(mod.id, guild.id, mod.version); } catch (err) { logger.error( { err }, diff --git a/src/core/listeners/interaction-create.listener.test.ts b/src/core/listeners/interaction-create.listener.test.ts index d548ed75..4f7e30df 100644 --- a/src/core/listeners/interaction-create.listener.test.ts +++ b/src/core/listeners/interaction-create.listener.test.ts @@ -17,6 +17,10 @@ vi.mock("#core/services/config.service.js", () => ({ default: { getConfigForModuleIn }, })); +vi.mock("#lib/i18n.js", () => ({ + createT: () => (key: string) => key, +})); + const { default: listener } = await import("./interaction-create.listener.js"); function fakeCommand(commandName: string, overrides: Record = {}) { @@ -206,3 +210,108 @@ describe("handleCommand", () => { expect(interaction.reply).not.toHaveBeenCalled(); }); }); + +describe("component interactions", () => { + function pushHandler(handler: any) { + mockModules.push({ + id: "mod-a", + registry: { commands: [], interactionHandlers: [handler] }, + }); + } + + function fakeButton(customId: string, overrides: Record = {}) { + return { + customId, + guild: { id: "guild-1" }, + guildId: "guild-1", + isChatInputCommand: () => false, + isAutocomplete: () => false, + isMessageComponent: () => true, + isModalSubmit: () => false, + replied: false, + deferred: false, + reply: vi.fn(async () => ({})), + followUp: vi.fn(async () => ({})), + ...overrides, + } as any; + } + + it("tells the user when the handler's module is disabled", async () => { + const execute = vi.fn(async () => {}); + pushHandler({ + customId: "btn", + access: "everyone", + check: () => true, + execute, + }); + getModuleStateIn.mockResolvedValue({ activated: false }); + + const interaction = fakeButton("btn:1"); + await listener.execute(interaction, undefined); + + expect(execute).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "interaction.moduleNotEnabled" }) + ); + }); + + it("replies an error when the handler throws", async () => { + pushHandler({ + customId: "btn", + access: "everyone", + check: () => true, + execute: vi.fn(async () => { + throw new Error("boom"); + }), + }); + + const interaction = fakeButton("btn", { deferred: true }); + await listener.execute(interaction, undefined); + + expect(interaction.followUp).toHaveBeenCalledWith( + expect.objectContaining({ content: "interaction.failed" }) + ); + }); + + it("passes the customId arguments to the handler", async () => { + const execute = vi.fn(async () => {}); + pushHandler({ + customId: "btn", + access: "everyone", + check: () => true, + execute, + }); + + const interaction = fakeButton("btn:a:b"); + await listener.execute(interaction, undefined); + + expect(execute).toHaveBeenCalledWith( + interaction, + ["a", "b"], + expect.anything() + ); + }); +}); + +describe("dispatch failures", () => { + it("answers the user when loading the config fails", async () => { + mockModules.push({ + id: "mod-a", + registry: { + commands: [{ data: { name: "hello" }, execute: vi.fn() }], + interactionHandlers: [], + }, + }); + getConfigForModuleIn.mockRejectedValue(new Error("db down")); + + const interaction = fakeCommand("hello", { + locale: "en-US", + isRepliable: () => true, + }); + await listener.execute(interaction, undefined); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "interaction.failed" }) + ); + }); +}); diff --git a/src/core/listeners/interaction-create.listener.ts b/src/core/listeners/interaction-create.listener.ts index 9f8758b1..c503a18d 100644 --- a/src/core/listeners/interaction-create.listener.ts +++ b/src/core/listeners/interaction-create.listener.ts @@ -1,6 +1,7 @@ import { type AutocompleteInteraction, type ChatInputCommandInteraction, + type Interaction, MessageFlags, } from "discord.js"; import coreModule from "#core/core.module.js"; @@ -8,6 +9,7 @@ import configService from "#core/services/config.service.js"; import moduleService from "#core/services/module.service.js"; import { requireAdmin } from "#core/utils/require-admin.js"; import { modules } from "#index.js"; +import { createT } from "#lib/i18n.js"; import type { CompatibleInteraction } from "#lib/interaction.js"; import { declareEventListener } from "#lib/listener.js"; import logger from "#lib/logger.js"; @@ -193,69 +195,126 @@ async function handleInteraction(interaction: CompatibleInteraction) { `Interaction handler found | customId = ${interaction.customId} | module = ${handler.module.id}` ); - if ( + if (!interaction.guild || !interaction.guildId) { + logger.warn( + `Interaction outside guild | customId = ${interaction.customId}` + ); + return; + } + + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId + ); + + const enabled = handler.module.id === coreModule.id || - ( - await moduleService.getModuleStateIn( - handler.module.id, - interaction.guild! - ) - ).activated - ) { - const config = await configService.getConfigForModuleIn( - handler.module, - interaction.guildId! + (await moduleService.getModuleStateIn(handler.module.id, interaction.guild)) + .activated; + + if (!enabled) { + logger.warn( + `Interaction for disabled module | customId = ${interaction.customId} | module = ${handler.module.id}` ); + await interaction.reply({ + content: coreConfig.t("interaction.moduleNotEnabled"), + flags: MessageFlags.Ephemeral, + }); + return; + } - // Read before check(): a rejecting type predicate narrows `interaction` - // to never below. - const checkedCustomId = interaction.customId; - if (!handler.handler.check(interaction, config)) { - logger.debug( - `Interaction check rejected | customId = ${checkedCustomId}` - ); + const config = await configService.getConfigForModuleIn( + handler.module, + interaction.guildId + ); + + // Read before check(): a rejecting type predicate narrows `interaction` + // to never below. + const checkedCustomId = interaction.customId; + if (!handler.handler.check(interaction, config)) { + logger.debug(`Interaction check rejected | customId = ${checkedCustomId}`); + return; + } + + if (handler.handler.access === "admin") { + if (!(await requireAdmin(interaction, coreConfig.t))) { return; } + } - if (handler.handler.access === "admin") { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - if (!(await requireAdmin(interaction, coreConfig.t))) { - return; + try { + logger.debug(`Executing interaction | customId = ${interaction.customId}`); + await handler.handler.execute(interaction, args, config); + } catch (err) { + logger.error( + { err }, + `Interaction handler failed | customId = ${interaction.customId}` + ); + const payload = { + content: coreConfig.t("interaction.failed"), + flags: MessageFlags.Ephemeral, + } as const; + try { + if (interaction.replied || interaction.deferred) { + await interaction.followUp(payload); + } else { + await interaction.reply(payload); } + } catch { + logger.debug(`Error reply failed | customId = ${interaction.customId}`); } + } +} - try { - logger.debug( - `Executing interaction | customId = ${interaction.customId}` - ); - await handler.handler.execute(interaction, args, config); - } catch (err) { - logger.error( - { err }, - `Interaction handler failed | customId = ${interaction.customId}` - ); - } +async function dispatch(interaction: Interaction) { + if (interaction.isChatInputCommand()) { + await handleCommand(interaction); + return; + } + + if (interaction.isAutocomplete()) { + await handleComplete(interaction); + return; + } + + if (interaction.isMessageComponent() || interaction.isModalSubmit()) { + await handleInteraction(interaction); + } +} + +// Last resort when dispatch itself fails (database, config loading…): the +// guild locale may be unreachable, so the user's own locale is used. +async function replyDispatchFailure(interaction: Interaction) { + if (interaction.isAutocomplete()) { + if (!interaction.responded) await interaction.respond([]); + return; + } + + if (!interaction.isRepliable()) return; + + const payload = { + content: createT(interaction.locale, "core")("interaction.failed"), + flags: MessageFlags.Ephemeral, + } as const; + if (interaction.replied || interaction.deferred) { + await interaction.followUp(payload); + } else { + await interaction.reply(payload); } } export default declareEventListener({ eventType: "interactionCreate", execute: async (interaction) => { - if (interaction.isChatInputCommand()) { - await handleCommand(interaction); - return; - } - - if (interaction.isAutocomplete()) { - await handleComplete(interaction); - return; - } - - if (interaction.isMessageComponent() || interaction.isModalSubmit()) { - await handleInteraction(interaction); + try { + await dispatch(interaction); + } catch (err) { + logger.error({ err }, "Interaction dispatch failed"); + try { + await replyDispatchFailure(interaction); + } catch { + logger.debug("Dispatch failure reply failed"); + } } }, }); diff --git a/src/core/loaders/command-loader.integration.test.ts b/src/core/loaders/command-loader.integration.test.ts index e6760eae..38d92838 100644 --- a/src/core/loaders/command-loader.integration.test.ts +++ b/src/core/loaders/command-loader.integration.test.ts @@ -31,6 +31,7 @@ vi.mock("#core/services/module.service.js", () => ({ getModuleStateFromGuildIdIn: vi.fn(), getModuleStateIn: vi.fn(), enableModule: vi.fn(), + disableModule: vi.fn(), getGuildsWhereVersionDoesNotMatch: vi.fn(), updateModuleActivation: vi.fn(), }, @@ -51,7 +52,8 @@ const { installModuleCommandsIn, checkCommandsForVersionChange, } = await import("./command-loader.js"); -const { installModule } = await import("./module-installer.js"); +const { installModule, uninstallModule } = + await import("./module-installer.js"); const { default: moduleService } = await import("#core/services/module.service.js"); @@ -188,6 +190,21 @@ describe("installModule", () => { expect(moduleService.enableModule).not.toHaveBeenCalled(); }); + it("touches neither Discord nor the DB when onInstall throws", async () => { + const module = { + ...fakeModule("mod-a", ["alpha"]), + onInstall: () => { + throw new Error("hook blew up"); + }, + } as unknown as Module; + + await expect(installModule(module, fakeGuild("guild-1"))).rejects.toThrow( + "hook blew up" + ); + expect(restPost).not.toHaveBeenCalled(); + expect(moduleService.enableModule).not.toHaveBeenCalled(); + }); + it("enables the module when registration succeeds", async () => { await installModule(fakeModule("mod-a", ["alpha"]), fakeGuild("guild-1")); @@ -268,4 +285,51 @@ describe("checkCommandsForVersionChange", () => { "2.0.0" ); }); + + it("skips guilds the bot can no longer reach and updates the others", async () => { + const fetchingClient = { + ...client, + guilds: { + fetch: async (id: string) => { + if (id === "guild-1") throw new Error("Unknown Guild"); + return fakeGuild(id); + }, + }, + } as unknown as Client; + + await checkCommandsForVersionChange(fetchingClient, versionedModule()); + + expect(moduleService.updateModuleActivation).toHaveBeenCalledTimes(1); + expect(moduleService.updateModuleActivation).toHaveBeenCalledWith( + "mod-a", + "guild-2", + "2.0.0" + ); + }); +}); + +describe("uninstallModule", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(moduleService.getModuleStateIn).mockResolvedValue({ + activated: true, + } as never); + }); + + it("keeps commands and DB state when onUninstall throws", async () => { + const guild = fakeGuild("guild-1"); + const fetchCommands = vi.spyOn(guild.commands, "fetch"); + const module = { + ...fakeModule("mod-a", ["alpha"]), + onUninstall: () => { + throw new Error("hook blew up"); + }, + } as unknown as Module; + + await expect(uninstallModule(module, guild)).rejects.toThrow( + "hook blew up" + ); + expect(fetchCommands).not.toHaveBeenCalled(); + expect(moduleService.disableModule).not.toHaveBeenCalled(); + }); }); diff --git a/src/core/loaders/command-loader.ts b/src/core/loaders/command-loader.ts index ef2814d4..9381672d 100644 --- a/src/core/loaders/command-loader.ts +++ b/src/core/loaders/command-loader.ts @@ -248,11 +248,18 @@ export async function checkCommandsForVersionChange( `\tFound ${guildsToFix.length} guilds to update commands for module "${module.id}"` ); - const guilds = await Promise.all( - guildsToFix.map(async (info) => await client.guilds.fetch(info.guildId)) - ); + for (const info of guildsToFix) { + let guild: Guild; + try { + guild = await client.guilds.fetch(info.guildId); + } catch (err) { + logger.warn( + { err }, + `Guild unreachable, skipping command update | module = ${module.id} | guild = ${info.guildId}` + ); + continue; + } - for (const guild of guilds) { logger.info( `\tUpdating commands in guild "${guild.id}" for module "${module.id}"` ); diff --git a/src/core/loaders/module-installer.ts b/src/core/loaders/module-installer.ts index 1e6e253e..b5c75016 100644 --- a/src/core/loaders/module-installer.ts +++ b/src/core/loaders/module-installer.ts @@ -28,6 +28,9 @@ export async function installModule(module: Module, guild: Guild) { throw new Error(`Module ${module.id} is already installed in this guild.`); } + // Hook first: if it throws, neither Discord nor the database has changed. + module.onInstall?.(readyClient(), guild, module.registry); + // Install the commands await installModuleCommandsIn(readyClient(), module, guild); @@ -43,6 +46,8 @@ export async function uninstallModule(module: Module, guild: Guild) { throw new Error(`Module ${module.id} is not installed in this guild.`); } + module.onUninstall?.(readyClient(), guild, module.registry); + // Uninstall the commands await uninstallModuleCommandsIn(readyClient(), module, guild); diff --git a/src/core/loaders/module-loader.test.ts b/src/core/loaders/module-loader.test.ts index 05b10e02..0a421a3d 100644 --- a/src/core/loaders/module-loader.test.ts +++ b/src/core/loaders/module-loader.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const { loadModule } = await import("./module-loader.js"); +const { loadModule, findDuplicateDeclarations } = + await import("./module-loader.js"); async function tempModuleDir(): Promise { return mkdtemp(join(tmpdir(), "omnibot-mod-")); @@ -21,3 +22,36 @@ describe("loadModule", () => { await expect(loadModule(dir)).resolves.toBeNull(); }); }); + +describe("findDuplicateDeclarations", () => { + function fakeModule(id: string, commands: string[], customIds: string[]) { + return { + id, + registry: { + commands: commands.map((name) => ({ data: { name } })), + interactionHandlers: customIds.map((customId) => ({ customId })), + }, + } as never; + } + + it("reports a command name or customId declared by two modules", () => { + const duplicates = findDuplicateDeclarations([ + fakeModule("mod-a", ["ping"], ["btn"]), + fakeModule("mod-b", ["ping"], ["btn"]), + ]); + + expect(duplicates).toEqual([ + 'command "ping" | modules = mod-a,mod-b', + 'customId "btn" | modules = mod-a,mod-b', + ]); + }); + + it("reports nothing when every declaration is unique", () => { + expect( + findDuplicateDeclarations([ + fakeModule("mod-a", ["ping"], ["btn-a"]), + fakeModule("mod-b", ["pong"], ["btn-b"]), + ]) + ).toEqual([]); + }); +}); diff --git a/src/core/loaders/module-loader.ts b/src/core/loaders/module-loader.ts index 96d93c94..daa6e6db 100644 --- a/src/core/loaders/module-loader.ts +++ b/src/core/loaders/module-loader.ts @@ -130,3 +130,32 @@ export async function loadModule(modulePath: string): Promise { return module; } + +export function findDuplicateDeclarations(modules: Module[]): string[] { + const commandOwners = new Map(); + const customIdOwners = new Map(); + + for (const module of modules) { + for (const command of module.registry.commands) { + const owners = commandOwners.get(command.data.name) ?? []; + commandOwners.set(command.data.name, [...owners, module.id]); + } + for (const handler of module.registry.interactionHandlers) { + const owners = customIdOwners.get(handler.customId) ?? []; + customIdOwners.set(handler.customId, [...owners, module.id]); + } + } + + const duplicates: string[] = []; + for (const [name, owners] of commandOwners) { + if (owners.length > 1) { + duplicates.push(`command "${name}" | modules = ${owners.join(",")}`); + } + } + for (const [customId, owners] of customIdOwners) { + if (owners.length > 1) { + duplicates.push(`customId "${customId}" | modules = ${owners.join(",")}`); + } + } + return duplicates; +} diff --git a/src/core/services/config.service.test.ts b/src/core/services/config.service.test.ts index 989d4d08..0f3660ae 100644 --- a/src/core/services/config.service.test.ts +++ b/src/core/services/config.service.test.ts @@ -132,3 +132,35 @@ describe("getConfigForModuleIn", () => { expect(config.get("channels")).toEqual([{ id: "chan-1" }]); }); }); + +describe("concurrent config writes", () => { + const moduleA = { id: "mod-a", config: {} } as unknown as Module; + const moduleB = { id: "mod-b", config: {} } as unknown as Module; + + beforeEach(() => { + (modules as unknown as Module[]).push(moduleA, moduleB); + rows.set("guild-w", { "mod-a": {}, "mod-b": {}, core: {} }); + guildsFetch.mockResolvedValue({ channels: { fetch: channelsFetch } }); + }); + + afterEach(() => { + modules.length = 0; + }); + + it("keeps both saves when two modules are updated at the same time", async () => { + await configService.getFullConfigForGuild("guild-w"); + + await Promise.all([ + configService.updateConfigForModuleIn(moduleA, "guild-w", { + x: 1, + } as never), + configService.updateConfigForModuleIn(moduleB, "guild-w", { + y: 2, + } as never), + ]); + + const full = await configService.getFullConfigForGuild("guild-w"); + expect(full["mod-a"]).toEqual({ x: 1 }); + expect(full["mod-b"]).toEqual({ y: 2 }); + }); +}); diff --git a/src/core/services/config.service.ts b/src/core/services/config.service.ts index f1da850b..6b5ad235 100644 --- a/src/core/services/config.service.ts +++ b/src/core/services/config.service.ts @@ -19,6 +19,7 @@ const pendingLoads = new Map< string, Promise>> >(); +const writeChains = new Map>(); class ConfigService { /** @@ -59,78 +60,23 @@ class ConfigService { guildId: string, newConfig: Partial> ): Promise> { - const [locale, currentConfig] = await Promise.all([ - this.getLocaleForGuild(guildId), - this.getOrCreate(guildId), - ]); - const moduleConfig = currentConfig[module.id] as ConfigData; - - if (!moduleConfig) { - throw new Error( - `Unable to find configuration for module ${module.id} in guild ${guildId}` - ); - } - - // Merge the new config with the existing config - const updatedModuleConfig = { ...moduleConfig, ...newConfig }; - const updatedConfig = { - ...currentConfig, - [module.id]: updatedModuleConfig, - }; - - // Update the database - await database.guildConfiguration.upsert({ - where: { guildId }, - create: { guildId, data: updatedConfig }, - update: { data: updatedConfig }, + return this.writeModuleConfig(module, guildId, (moduleConfig) => { + if (!moduleConfig) { + throw new Error( + `Unable to find configuration for module ${module.id} in guild ${guildId}` + ); + } + return { ...moduleConfig, ...newConfig }; }); - - // Update the cache - configCache.set(guildId, updatedConfig); - - // Deserialize the updated config before returning - const deserializedConfig = await this.deserializeConfigData( - module, - updatedModuleConfig, - guildId - ); - - return new ConfigProvider(module, deserializedConfig, locale); } async resetConfigForModuleIn( module: Module, guildId: string ): Promise> { - const [locale, currentConfig] = await Promise.all([ - this.getLocaleForGuild(guildId), - this.getOrCreate(guildId), - ]); - const defaultConfig = this.blankConfigForModule(module); - - const updatedConfig = { - ...currentConfig, - [module.id]: defaultConfig, - }; - - // Update the database - await database.guildConfiguration.upsert({ - where: { guildId }, - create: { guildId, data: updatedConfig }, - update: { data: updatedConfig }, - }); - - // Update the cache - configCache.set(guildId, updatedConfig); - - // Deserialize the default config before returning - const deserializedConfig = await this.deserializeConfigData( - module, - defaultConfig as ConfigData, - guildId + return this.writeModuleConfig(module, guildId, () => + this.blankConfigForModule(module) ); - - return new ConfigProvider(module, deserializedConfig, locale); } /** @@ -144,37 +90,66 @@ class ConfigService { guildId: string, keys: string[] ): Promise> { - const [locale, currentConfig] = await Promise.all([ - this.getLocaleForGuild(guildId), - this.getOrCreate(guildId), - ]); - - const moduleConfig = (currentConfig[module.id] ?? - {}) as ConfigData; - const updatedModuleConfig: Record = { ...moduleConfig }; - for (const key of keys) { - delete updatedModuleConfig[key]; - } + return this.writeModuleConfig(module, guildId, (moduleConfig) => { + const updated: Record = { ...moduleConfig }; + for (const key of keys) { + delete updated[key]; + } + return updated as ConfigData; + }); + } - const updatedConfig = { - ...currentConfig, - [module.id]: updatedModuleConfig as ConfigData, + /** + * Read-modify-write of one module's slice of the guild blob. Writes to the + * same guild are chained: each one reads the result of the previous, so two + * concurrent saves cannot overwrite each other with a stale snapshot. + */ + private writeModuleConfig( + module: Module, + guildId: string, + transform: ( + moduleConfig: ConfigData | undefined + ) => ConfigData + ): Promise> { + const write = async () => { + const [locale, currentConfig] = await Promise.all([ + this.getLocaleForGuild(guildId), + this.getOrCreate(guildId), + ]); + const updatedModuleConfig = transform( + currentConfig[module.id] as ConfigData | undefined + ); + const updatedConfig = { + ...currentConfig, + [module.id]: updatedModuleConfig, + }; + + await database.guildConfiguration.upsert({ + where: { guildId }, + create: { guildId, data: updatedConfig }, + update: { data: updatedConfig }, + }); + configCache.set(guildId, updatedConfig); + + return { locale, updatedModuleConfig }; }; - await database.guildConfiguration.upsert({ - where: { guildId }, - create: { guildId, data: updatedConfig }, - update: { data: updatedConfig }, + const previous = writeChains.get(guildId) ?? Promise.resolve(); + const next = previous.then(write, write); + const settled = next.catch(() => undefined); + writeChains.set(guildId, settled); + void settled.then(() => { + if (writeChains.get(guildId) === settled) writeChains.delete(guildId); }); - configCache.set(guildId, updatedConfig); - - const deserializedConfig = await this.deserializeConfigData( - module, - updatedModuleConfig as ConfigData, - guildId - ); - return new ConfigProvider(module, deserializedConfig, locale); + return next.then(async ({ locale, updatedModuleConfig }) => { + const deserializedConfig = await this.deserializeConfigData( + module, + updatedModuleConfig, + guildId + ); + return new ConfigProvider(module, deserializedConfig, locale); + }); } async getFullConfigForGuild( diff --git a/src/core/services/module.service.test.ts b/src/core/services/module.service.test.ts index 68d2d64b..bd5e63b6 100644 --- a/src/core/services/module.service.test.ts +++ b/src/core/services/module.service.test.ts @@ -5,12 +5,13 @@ import type { Module } from "#lib/module.js"; // client; stub both so importing it never boots the bot or hits a database. vi.mock("#index.js", () => ({ modules: [], client: {} })); -const { findMany, upsert } = vi.hoisted(() => ({ +const { findMany, findFirst, upsert } = vi.hoisted(() => ({ findMany: vi.fn(), + findFirst: vi.fn(), upsert: vi.fn(), })); vi.mock("#lib/database.js", () => ({ - default: { moduleActivation: { findMany, upsert } }, + default: { moduleActivation: { findMany, findFirst, upsert } }, Prisma: {}, })); @@ -107,24 +108,22 @@ describe("getActivatedGuildIds", () => { }); }); -describe("enableModule / disableModule hook ordering", () => { +describe("module state cache", () => { const guild = { id: "guild-1" } as never; - const onInstall = vi.fn(); - const onUninstall = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - // clearAllMocks keeps implementations: drop them so one test's throwing - // hook does not leak into the next. - onInstall.mockReset(); - onUninstall.mockReset(); + moduleService.clearStateCache(); upsert.mockResolvedValue(undefined); + findFirst.mockResolvedValue({ + moduleId: "mod-x", + guildId: "guild-1", + activated: true, + }); (modules as unknown as Module[]).push({ id: "mod-x", version: "1.0.0", registry: {}, - onInstall, - onUninstall, } as unknown as Module); }); @@ -132,35 +131,42 @@ describe("enableModule / disableModule hook ordering", () => { modules.length = 0; }); - it("does not mark the module enabled when onInstall throws", async () => { - onInstall.mockImplementation(() => { - throw new Error("hook blew up"); - }); + it("queries the database once for repeated reads", async () => { + await moduleService.getModuleStateFromGuildIdIn("mod-x", "guild-1"); + await moduleService.getModuleStateFromGuildIdIn("mod-x", "guild-1"); - await expect(moduleService.enableModule("mod-x", guild)).rejects.toThrow( - "hook blew up" - ); - expect(upsert).not.toHaveBeenCalled(); + expect(findFirst).toHaveBeenCalledOnce(); }); - it("runs onInstall before flipping the DB state", async () => { - await moduleService.enableModule("mod-x", guild); + it("re-reads the database after the module is disabled", async () => { + await moduleService.getModuleStateFromGuildIdIn("mod-x", "guild-1"); + findFirst.mockResolvedValue({ + moduleId: "mod-x", + guildId: "guild-1", + activated: false, + }); - expect(onInstall).toHaveBeenCalledOnce(); - expect(upsert).toHaveBeenCalledOnce(); - expect(vi.mocked(onInstall).mock.invocationCallOrder[0]).toBeLessThan( - upsert.mock.invocationCallOrder[0] + await moduleService.disableModule("mod-x", guild); + const state = await moduleService.getModuleStateFromGuildIdIn( + "mod-x", + "guild-1" ); + + expect(state.activated).toBe(false); + expect(findFirst).toHaveBeenCalledTimes(2); }); - it("does not mark the module disabled when onUninstall throws", async () => { - onUninstall.mockImplementation(() => { - throw new Error("hook blew up"); - }); + it("does not keep a failed read in cache", async () => { + findFirst.mockRejectedValueOnce(new Error("db down")); - await expect(moduleService.disableModule("mod-x", guild)).rejects.toThrow( - "hook blew up" + await expect( + moduleService.getModuleStateFromGuildIdIn("mod-x", "guild-1") + ).rejects.toThrow("db down"); + const state = await moduleService.getModuleStateFromGuildIdIn( + "mod-x", + "guild-1" ); - expect(upsert).not.toHaveBeenCalled(); + + expect(state.activated).toBe(true); }); }); diff --git a/src/core/services/module.service.ts b/src/core/services/module.service.ts index 145e447e..f8d3e786 100644 --- a/src/core/services/module.service.ts +++ b/src/core/services/module.service.ts @@ -1,9 +1,17 @@ import type { Guild } from "discord.js"; -import { client, modules } from "#index.js"; +import { modules } from "#index.js"; import prisma from "#lib/database.js"; import type { Module } from "#lib/module.js"; import { declareService, type Service } from "#lib/service.js"; +type ModuleState = { moduleId: string; guildId: string; activated: boolean }; + +const stateCache = new Map>(); + +function stateKey(moduleId: string, guildId: string) { + return `${moduleId}:${guildId}`; +} + class ModuleService implements Service { getAllModules() { return modules; @@ -41,14 +49,23 @@ class ModuleService implements Service { throw new Error(`Module with ID ${moduleId} not found`); } - const state = await prisma.moduleActivation.findFirst({ - where: { - moduleId, - guildId, - }, - }); + const key = stateKey(moduleId, guildId); + let state = stateCache.get(key); + if (!state) { + state = prisma.moduleActivation + .findFirst({ where: { moduleId, guildId } }) + .then((found) => found ?? { moduleId, guildId, activated: false }); + stateCache.set(key, state); + state.catch(() => { + if (stateCache.get(key) === state) stateCache.delete(key); + }); + } - return state ?? { moduleId, guildId, activated: false }; + return state; + } + + clearStateCache() { + stateCache.clear(); } async getActivatedGuildIds(moduleId: string): Promise { @@ -87,11 +104,6 @@ class ModuleService implements Service { throw new Error(`Module with ID ${moduleId} not found`); } - // Hook first: a throwing onInstall must not leave the DB marked enabled, - // otherwise the next install throws "already installed" and the guild is - // stuck. - module.onInstall?.(client, guild, module.registry); - // Create a new activation record const activation = await prisma.moduleActivation.upsert({ where: { @@ -111,6 +123,7 @@ class ModuleService implements Service { activatedVersion: module.version, }, }); + stateCache.delete(stateKey(moduleId, guild.id)); return activation; } @@ -121,9 +134,6 @@ class ModuleService implements Service { throw new Error(`Module with ID ${moduleId} not found`); } - // Same ordering as enableModule: hook first, DB flip only on success. - module.onUninstall?.(client, guild, module.registry); - const activation = await prisma.moduleActivation.upsert({ where: { moduleId_guildId: { @@ -142,6 +152,7 @@ class ModuleService implements Service { activatedVersion: "", }, }); + stateCache.delete(stateKey(moduleId, guild.id)); return activation; } @@ -194,6 +205,7 @@ class ModuleService implements Service { activatedVersion: version, }, }); + stateCache.delete(stateKey(moduleId, guildId)); } } diff --git a/src/index.ts b/src/index.ts index c21a16c7..f3d1e629 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,11 @@ import { loadGlobalEvents, loadModuleEvents, } from "./core/loaders/listener-loader.js"; -import { loadModuleI18n, loadModules } from "./core/loaders/module-loader.js"; +import { + findDuplicateDeclarations, + loadModuleI18n, + loadModules, +} from "./core/loaders/module-loader.js"; import prisma, { Prisma } from "./lib/database.js"; import { initI18n } from "./lib/i18n.js"; import logger from "./lib/logger.js"; @@ -30,9 +34,15 @@ const corePath = path.resolve(fileURLToPath(import.meta.url), "..", "core"); await loadModuleI18n("core", corePath); export const modules = await loadModules("./modules"); -const intents = modules.flatMap((module) => module.intents).filter((a) => !!a); +const intents = [ + ...new Set( + [coreModule, ...modules].flatMap((module) => module.intents ?? []) + ), +]; const partials = [ - ...new Set(modules.flatMap((module) => module.partials ?? [])), + ...new Set( + [coreModule, ...modules].flatMap((module) => module.partials ?? []) + ), ]; export const client = new Client({ @@ -41,6 +51,7 @@ export const client = new Client({ }); client.once(Events.ClientReady, async (readyClient) => { + const failed = new Set(); for (const module of modules) { try { await module.onLoad?.(readyClient, module.registry); @@ -51,24 +62,62 @@ client.once(Events.ClientReady, async (readyClient) => { { err: error }, `Module onLoad failed, skipping | id = ${module.id}` ); + failed.add(module.id); continue; } loadModuleEvents(readyClient, module); } - await coreModule.onLoad?.(readyClient, coreModule.registry); + // Dropped from the shared list so that nothing it registered before + // throwing (commands, interaction handlers) is dispatched or synced. + for (let i = modules.length - 1; i >= 0; i--) { + if (failed.has(modules[i]!.id)) modules.splice(i, 1); + } + + try { + await coreModule.onLoad?.(readyClient, coreModule.registry); + loadGlobalEvents(readyClient); + } catch (err) { + logger.fatal({ err }, "Core failed to load, exiting"); + process.exit(1); + } - await syncCommands(readyClient, modules); + for (const duplicate of findDuplicateDeclarations([...modules, coreModule])) { + logger.error(`Duplicate declaration, first match wins | ${duplicate}`); + } - loadGlobalEvents(readyClient); + try { + await syncCommands(readyClient, modules); + } catch (err) { + logger.error({ err }, "Command sync failed"); + } +}); + +process.on("unhandledRejection", (reason) => { + logger.error({ err: reason }, "Unhandled promise rejection"); +}); + +process.on("uncaughtException", (err) => { + logger.fatal({ err }, "Uncaught exception, exiting"); + process.exit(1); }); await client.login(token); +let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info("Shutting down..."); - await client.destroy(); - await prisma.$disconnect(); + try { + await client.destroy(); + await prisma.$disconnect(); + } catch (err) { + logger.error({ err }, "Error during shutdown"); + process.exit(1); + } process.exit(0); }; diff --git a/src/lib/env.ts b/src/lib/env.ts index f8e89b3a..0aa87dc4 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -11,7 +11,7 @@ export function isDevMode(): boolean { /** * In development, the guild where core commands are registered instantly * (instead of globally, which can take ~1h to propagate). Validated as required - * at startup in dev mode (see bootstrap/validate-env-vars.mjs). + * at startup in dev mode (see bootstrap/validate-env-vars.ts). */ export function devGuildId(): string | undefined { return process.env["DEV_GUILD_ID"]; diff --git a/src/lib/listener.ts b/src/lib/listener.ts index 0e375153..d4c8337e 100644 --- a/src/lib/listener.ts +++ b/src/lib/listener.ts @@ -19,7 +19,6 @@ export interface EventListener< * * @param event The event data that triggered the listener. * @param config The configuration for the module that registered the listener. - * @param config The configuration for the module that registered the listener. * @returns A promise that resolves when the listener has finished executing. */ execute: ( @@ -43,7 +42,6 @@ export function declareEventListener< ): Declared> { return { type: DeclarationType.Listener, - eventType: listener.eventType, - execute: listener.execute, + ...listener, }; } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 75b37dfd..6cb40676 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,4 +1,5 @@ import pino from "pino"; +import { isDevMode } from "./env.js"; function resolveLevel(): pino.LevelWithSilent { switch (process.env["LOG_LEVEL"]) { @@ -11,21 +12,23 @@ function resolveLevel(): pino.LevelWithSilent { case "silent": return process.env["LOG_LEVEL"]; default: - return "debug"; + return isDevMode() ? "debug" : "info"; } } const base = pino({ level: resolveLevel(), - transport: { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "yyyy-mm-dd HH:MM:ss", - ignore: "pid,hostname", - messageFormat: "{if name}[{name}] {end}{msg}", + ...(isDevMode() && { + transport: { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "yyyy-mm-dd HH:MM:ss", + ignore: "pid,hostname", + messageFormat: "{if name}[{name}] {end}{msg}", + }, }, - }, + }), }); export const loggerMaker = (name?: string) => diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 7e9c6699..740cf773 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -16,7 +16,7 @@ export class Registry { * An array of commands declared by the module. * @private */ - private readonly _commands: Declared>[]; + private readonly _commands: Declared>[] = []; /** * An array of event listeners declared by the module. @@ -32,15 +32,6 @@ export class Registry { InteractionHandler >[] = []; - /** - * Creates a new instance of the ModuleRegistry. - */ - constructor() { - this._commands = []; - this._listeners = []; - this._interactionHandlers = []; - } - /** * Retrieves all registered commands. * diff --git a/src/modules/four-hour-game/commands/jd4h-admin.command.ts b/src/modules/four-hour-game/commands/jd4h-admin.command.ts index 87ff273e..2bab2457 100644 --- a/src/modules/four-hour-game/commands/jd4h-admin.command.ts +++ b/src/modules/four-hour-game/commands/jd4h-admin.command.ts @@ -12,7 +12,7 @@ import fourHourGameService, { } from "#modules/four-hour-game/services/four-hour-game.service.js"; const PERMISSION_ADMINISTRATOR = 0x8; -const MAX_SCORE = 2_147_483_647; +const MAX_SCORE = 1_000_000_000; const logger = loggerMaker("four-hour-game");